From 0adc5613487a26211f01028b66425d7c8ebf00ed Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Thu, 27 Nov 2025 20:13:04 +0500 Subject: [PATCH 001/248] Add a list of remaining Deferred APIs. (#7146) --- docs/topics/coroutines.rst | 60 ++++++++++++++++++-------------------- 1 file changed, 29 insertions(+), 31 deletions(-) diff --git a/docs/topics/coroutines.rst b/docs/topics/coroutines.rst index fdd3dcd87..bf2daf47c 100644 --- a/docs/topics/coroutines.rst +++ b/docs/topics/coroutines.rst @@ -77,37 +77,36 @@ In the future we plan to add support for the ``async def`` syntax to these APIs or replace them with other APIs where changing the existing ones is possible. -The following Scrapy methods return :class:`~twisted.internet.defer.Deferred` -objects (this list is not complete as it only includes methods that we think -may be useful for user code): - -- :class:`scrapy.crawler.Crawler`: - - - :meth:`~scrapy.crawler.Crawler.crawl` - - - :meth:`~scrapy.crawler.Crawler.stop` - -- :class:`scrapy.crawler.CrawlerRunner` (also inherited by - :class:`scrapy.crawler.CrawlerProcess`): - - - :meth:`~scrapy.crawler.CrawlerRunner.crawl` - - - :meth:`~scrapy.crawler.CrawlerRunner.stop` - - - :meth:`~scrapy.crawler.CrawlerRunner.join` - -- :class:`scrapy.core.engine.ExecutionEngine`: - - - :meth:`~scrapy.core.engine.ExecutionEngine.download` - -- :class:`scrapy.signalmanager.SignalManager`: - - - :meth:`~scrapy.signalmanager.SignalManager.send_catch_log_deferred` +These APIs don't have a coroutine-based counterpart: - :class:`~scrapy.mail.MailSender` - :meth:`~scrapy.mail.MailSender.send` +These APIs have a coroutine-based implementation and a Deferred-based one: + +- :class:`scrapy.crawler.Crawler`: + + - :meth:`~scrapy.crawler.Crawler.crawl_async` (coroutine-based) and + :meth:`~scrapy.crawler.Crawler.crawl` (Deferred-based): the former + doesn't support non-default reactors and so the latter should be used + with those. + +- :class:`scrapy.crawler.AsyncCrawlerRunner` and its subclass + :class:`scrapy.crawler.AsyncCrawlerProcess` (coroutine-based) and + :class:`scrapy.crawler.CrawlerRunner` and its subclass + :class:`scrapy.crawler.CrawlerProcess` (Deferred-based): the former + doesn't support non-default reactors and so the latter should be used + with those. + +- :class:`scrapy.signalmanager.SignalManager`: + + - :meth:`~scrapy.signalmanager.SignalManager.send_catch_log_async` + (coroutine-based) and + :meth:`~scrapy.signalmanager.SignalManager.send_catch_log_deferred` + (Deferred-based): the latter will be deprecated in a later Scrapy + version. + The following user-supplied methods can return :class:`~twisted.internet.defer.Deferred` objects (the methods that can also return coroutines are listed in :ref:`coroutine-support`): @@ -158,11 +157,10 @@ more information about this. For example: -- The :meth:`ExecutionEngine.download() - ` method returns a - :class:`~twisted.internet.defer.Deferred` object that fires with the - downloaded response. You can use this object directly in Deferred-based - code or convert it into a :class:`~asyncio.Future` object with +- The :meth:`MailSender.send() ` method returns + a :class:`~twisted.internet.defer.Deferred` object that fires when the + email is sent. You can use this object directly in Deferred-based code or + convert it into a :class:`~asyncio.Future` object with :func:`~scrapy.utils.defer.maybe_deferred_to_future`. - A custom download handler needs to define a ``download_request()`` method that returns a :class:`~twisted.internet.defer.Deferred` object. You can From 1e8de2438060d85629473c5fe44c703c70109632 Mon Sep 17 00:00:00 2001 From: Leonardo Tozzo <86009937+Icorebleidd@users.noreply.github.com> Date: Thu, 27 Nov 2025 21:16:02 +0100 Subject: [PATCH 002/248] Merge pull request #7145 from Icorebleidd/master Fix duplicate assertion in test_utils_deprecate --- tests/test_utils_deprecate.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/tests/test_utils_deprecate.py b/tests/test_utils_deprecate.py index a88b5e008..c5425d99d 100644 --- a/tests/test_utils_deprecate.py +++ b/tests/test_utils_deprecate.py @@ -169,17 +169,12 @@ class TestWarnWhenSubclassed: class UnrelatedClass: pass - class OldStyleClass: - pass - assert issubclass(UpdatedUserClass1, NewName) assert issubclass(UpdatedUserClass1a, NewName) assert issubclass(UpdatedUserClass1, DeprecatedName) assert issubclass(UpdatedUserClass1a, DeprecatedName) assert issubclass(OutdatedUserClass1, DeprecatedName) assert not issubclass(UnrelatedClass, DeprecatedName) - assert not issubclass(OldStyleClass, DeprecatedName) - assert not issubclass(OldStyleClass, DeprecatedName) assert not issubclass(OutdatedUserClass1, OutdatedUserClass1a) assert not issubclass(OutdatedUserClass1a, OutdatedUserClass1) @@ -206,9 +201,6 @@ class TestWarnWhenSubclassed: class UnrelatedClass: pass - class OldStyleClass: - pass - assert isinstance(UpdatedUserClass2(), NewName) assert isinstance(UpdatedUserClass2a(), NewName) assert isinstance(UpdatedUserClass2(), DeprecatedName) @@ -218,7 +210,6 @@ class TestWarnWhenSubclassed: assert not isinstance(OutdatedUserClass2a(), OutdatedUserClass2) assert not isinstance(OutdatedUserClass2(), OutdatedUserClass2a) assert not isinstance(UnrelatedClass(), DeprecatedName) - assert not isinstance(OldStyleClass(), DeprecatedName) def test_clsdict(self): with warnings.catch_warnings(): From 11073c86802d82a3e9d16400ae8f55289fdb9ef4 Mon Sep 17 00:00:00 2001 From: Laerte Pereira <5853172+Laerte@users.noreply.github.com> Date: Sun, 30 Nov 2025 17:26:17 -0300 Subject: [PATCH 003/248] Deprecate spider attributes that can be replaced by settings, round 2 (#7039) * Move duplicate code to utils * move new function to end * draft * update docs * Update tests * Update test name * rename test * rollback signature * leftover * sort * Rollback some changes * Rollback download_delay warning * Rollback * fix checks * Remove unused imports * Add pragma: no cover. --------- Co-authored-by: Andrey Rakhmatullin --- scrapy/core/downloader/__init__.py | 13 ++---- scrapy/core/downloader/handlers/http11.py | 8 ++++ scrapy/core/http2/protocol.py | 8 ++++ .../downloadermiddlewares/downloadtimeout.py | 3 ++ .../downloadermiddlewares/httpcompression.py | 19 +++----- scrapy/downloadermiddlewares/useragent.py | 4 ++ scrapy/utils/deprecate.py | 10 +++++ .../test_downloader_handler_twisted_http2.py | 12 ++--- tests/test_downloader_handlers_http_base.py | 45 +++++++++---------- ...st_downloadermiddleware_downloadtimeout.py | 8 ++-- tests/test_downloadermiddleware_useragent.py | 19 -------- 11 files changed, 74 insertions(+), 75 deletions(-) diff --git a/scrapy/core/downloader/__init__.py b/scrapy/core/downloader/__init__.py index 77b17287d..0d23aa85c 100644 --- a/scrapy/core/downloader/__init__.py +++ b/scrapy/core/downloader/__init__.py @@ -1,7 +1,6 @@ from __future__ import annotations import random -import warnings from collections import deque from datetime import datetime from time import time @@ -13,7 +12,6 @@ from twisted.python.failure import Failure from scrapy import Request, Spider, signals from scrapy.core.downloader.handlers import DownloadHandlers from scrapy.core.downloader.middleware import DownloaderMiddlewareManager -from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.resolver import dnscache from scrapy.utils.asyncio import ( AsyncioLoopingCall, @@ -27,6 +25,7 @@ from scrapy.utils.defer import ( _schedule_coro, maybe_deferred_to_future, ) +from scrapy.utils.deprecate import warn_on_deprecated_spider_attribute from scrapy.utils.httpobj import urlparse_cached if TYPE_CHECKING: @@ -97,13 +96,9 @@ def _get_concurrency_delay( if hasattr(spider, "download_delay"): delay = spider.download_delay - if hasattr(spider, "max_concurrent_requests"): - warnings.warn( - "The 'max_concurrent_requests' spider attribute is deprecated. " - "Use Spider.custom_settings or Spider.update_settings() instead. " - "The corresponding setting name is 'CONCURRENT_REQUESTS'.", - category=ScrapyDeprecationWarning, - stacklevel=2, + if hasattr(spider, "max_concurrent_requests"): # pragma: no cover + warn_on_deprecated_spider_attribute( + "max_concurrent_requests", "CONCURRENT_REQUESTS" ) concurrency = spider.max_concurrent_requests diff --git a/scrapy/core/downloader/handlers/http11.py b/scrapy/core/downloader/handlers/http11.py index d8965c130..02aaf7c5c 100644 --- a/scrapy/core/downloader/handlers/http11.py +++ b/scrapy/core/downloader/handlers/http11.py @@ -35,6 +35,7 @@ from scrapy.core.downloader.contextfactory import load_context_factory_from_sett from scrapy.exceptions import StopDownload from scrapy.http import Headers, Response from scrapy.responsetypes import responsetypes +from scrapy.utils.deprecate import warn_on_deprecated_spider_attribute from scrapy.utils.httpobj import urlparse_cached from scrapy.utils.python import to_bytes, to_unicode from scrapy.utils.url import add_http_if_no_scheme @@ -92,6 +93,13 @@ class HTTP11DownloadHandler: def download_request(self, request: Request, spider: Spider) -> Deferred[Response]: """Return a deferred for the HTTP download""" + if hasattr(spider, "download_maxsize"): # pragma: no cover + warn_on_deprecated_spider_attribute("download_maxsize", "DOWNLOAD_MAXSIZE") + if hasattr(spider, "download_warnsize"): # pragma: no cover + warn_on_deprecated_spider_attribute( + "download_warnsize", "DOWNLOAD_WARNSIZE" + ) + agent = ScrapyAgent( contextFactory=self._contextFactory, pool=self._pool, diff --git a/scrapy/core/http2/protocol.py b/scrapy/core/http2/protocol.py index cf2742de6..ee9211efc 100644 --- a/scrapy/core/http2/protocol.py +++ b/scrapy/core/http2/protocol.py @@ -34,6 +34,7 @@ from zope.interface import implementer from scrapy.core.http2.stream import Stream, StreamCloseReason from scrapy.http import Request, Response +from scrapy.utils.deprecate import warn_on_deprecated_spider_attribute if TYPE_CHECKING: from ipaddress import IPv4Address, IPv6Address @@ -191,6 +192,13 @@ class H2ClientProtocol(Protocol, TimeoutMixin): def _new_stream(self, request: Request, spider: Spider) -> Stream: """Instantiates a new Stream object""" + if hasattr(spider, "download_maxsize"): # pragma: no cover + warn_on_deprecated_spider_attribute("download_maxsize", "DOWNLOAD_MAXSIZE") + if hasattr(spider, "download_warnsize"): # pragma: no cover + warn_on_deprecated_spider_attribute( + "download_warnsize", "DOWNLOAD_WARNSIZE" + ) + stream = Stream( stream_id=next(self._stream_id_generator), request=request, diff --git a/scrapy/downloadermiddlewares/downloadtimeout.py b/scrapy/downloadermiddlewares/downloadtimeout.py index b57d5c2a9..bccfb230c 100644 --- a/scrapy/downloadermiddlewares/downloadtimeout.py +++ b/scrapy/downloadermiddlewares/downloadtimeout.py @@ -10,6 +10,7 @@ from typing import TYPE_CHECKING from scrapy import Request, Spider, signals from scrapy.utils.decorators import _warn_spider_arg +from scrapy.utils.deprecate import warn_on_deprecated_spider_attribute if TYPE_CHECKING: # typing.Self requires Python 3.11 @@ -30,6 +31,8 @@ class DownloadTimeoutMiddleware: return o def spider_opened(self, spider: Spider) -> None: + if hasattr(spider, "download_timeout"): # pragma: no cover + warn_on_deprecated_spider_attribute("download_timeout", "DOWNLOAD_TIMEOUT") self._timeout = getattr(spider, "download_timeout", self._timeout) @_warn_spider_arg diff --git a/scrapy/downloadermiddlewares/httpcompression.py b/scrapy/downloadermiddlewares/httpcompression.py index e81888d9b..d4fa2d4d7 100644 --- a/scrapy/downloadermiddlewares/httpcompression.py +++ b/scrapy/downloadermiddlewares/httpcompression.py @@ -6,7 +6,7 @@ from logging import getLogger from typing import TYPE_CHECKING, Any from scrapy import Request, Spider, signals -from scrapy.exceptions import IgnoreRequest, NotConfigured, ScrapyDeprecationWarning +from scrapy.exceptions import IgnoreRequest, NotConfigured from scrapy.http import Response, TextResponse from scrapy.responsetypes import responsetypes from scrapy.utils._compression import ( @@ -16,6 +16,7 @@ from scrapy.utils._compression import ( _unzstd, ) from scrapy.utils.decorators import _warn_spider_arg +from scrapy.utils.deprecate import warn_on_deprecated_spider_attribute from scrapy.utils.gz import gunzip if TYPE_CHECKING: @@ -85,21 +86,11 @@ class HttpCompressionMiddleware: def open_spider(self, spider: Spider) -> None: if hasattr(spider, "download_maxsize"): - warnings.warn( - "The 'download_maxsize' spider attribute is deprecated. " - "Use Spider.custom_settings or Spider.update_settings() instead. " - "The corresponding setting name is 'DOWNLOAD_MAXSIZE'.", - category=ScrapyDeprecationWarning, - stacklevel=2, - ) + warn_on_deprecated_spider_attribute("download_maxsize", "DOWNLOAD_MAXSIZE") self._max_size = spider.download_maxsize if hasattr(spider, "download_warnsize"): - warnings.warn( - "The 'download_warnsize' spider attribute is deprecated. " - "Use Spider.custom_settings or Spider.update_settings() instead. " - "The corresponding setting name is 'DOWNLOAD_WARNSIZE'.", - category=ScrapyDeprecationWarning, - stacklevel=2, + warn_on_deprecated_spider_attribute( + "download_warnsize", "DOWNLOAD_WARNSIZE" ) self._warn_size = spider.download_warnsize diff --git a/scrapy/downloadermiddlewares/useragent.py b/scrapy/downloadermiddlewares/useragent.py index c43a0195c..61f84b518 100644 --- a/scrapy/downloadermiddlewares/useragent.py +++ b/scrapy/downloadermiddlewares/useragent.py @@ -6,6 +6,7 @@ from typing import TYPE_CHECKING from scrapy import Request, Spider, signals from scrapy.utils.decorators import _warn_spider_arg +from scrapy.utils.deprecate import warn_on_deprecated_spider_attribute if TYPE_CHECKING: # typing.Self requires Python 3.11 @@ -28,6 +29,9 @@ class UserAgentMiddleware: return o def spider_opened(self, spider: Spider) -> None: + if hasattr(spider, "user_agent"): # pragma: no cover + warn_on_deprecated_spider_attribute("user_agent", "USER_AGENT") + self.user_agent = getattr(spider, "user_agent", self.user_agent) @_warn_spider_arg diff --git a/scrapy/utils/deprecate.py b/scrapy/utils/deprecate.py index 1f529b2cb..e6e6becd6 100644 --- a/scrapy/utils/deprecate.py +++ b/scrapy/utils/deprecate.py @@ -219,3 +219,13 @@ def argument_is_required(func: Callable[..., Any], arg_name: str) -> bool: args = get_func_args_dict(func) param = args.get(arg_name) return param is not None and param.default is inspect.Parameter.empty + + +def warn_on_deprecated_spider_attribute(attribute_name: str, setting_name: str) -> None: + warnings.warn( + f"The '{attribute_name}' spider attribute is deprecated. " + "Use Spider.custom_settings or Spider.update_settings() instead. " + f"The corresponding setting name is '{setting_name}'.", + category=ScrapyDeprecationWarning, + stacklevel=2, + ) diff --git a/tests/test_downloader_handler_twisted_http2.py b/tests/test_downloader_handler_twisted_http2.py index 3e3e677a2..1e638392f 100644 --- a/tests/test_downloader_handler_twisted_http2.py +++ b/tests/test_downloader_handler_twisted_http2.py @@ -15,6 +15,8 @@ from twisted.web.http import H2_ENABLED from scrapy.http import Request from scrapy.spiders import Spider from scrapy.utils.defer import deferred_f_from_coro_f, maybe_deferred_to_future +from scrapy.utils.misc import build_from_crawler +from scrapy.utils.test import get_crawler from tests.test_downloader_handlers_http_base import ( TestHttpProxyBase, TestHttps11Base, @@ -31,7 +33,6 @@ if TYPE_CHECKING: from tests.mockserver.http import MockServer from tests.mockserver.proxy_echo import ProxyEchoMockServer - pytestmark = pytest.mark.skipif( not H2_ENABLED, reason="HTTP/2 support in Twisted is not enabled" ) @@ -63,10 +64,13 @@ class TestHttps2(H2DownloadHandlerMixin, TestHttps11Base): @deferred_f_from_coro_f async def test_download_with_maxsize_very_large_file( - self, mockserver: MockServer, download_handler: DownloadHandlerProtocol + self, mockserver: MockServer ) -> None: from twisted.internet import reactor + crawler = get_crawler(settings_dict={"DOWNLOAD_MAXSIZE": 1_500}) + download_handler = build_from_crawler(self.download_handler_cls, crawler) + with mock.patch("scrapy.core.http2.stream.logger") as logger: request = Request( mockserver.url("/largechunkedfile", is_secure=self.is_secure) @@ -76,9 +80,7 @@ class TestHttps2(H2DownloadHandlerMixin, TestHttps11Base): logger.error.assert_called_once_with(mock.ANY) with pytest.raises((defer.CancelledError, error.ConnectionAborted)): - await download_request( - download_handler, request, Spider("foo", download_maxsize=1500) - ) + await download_request(download_handler, request, Spider("foo")) # As the error message is logged in the dataReceived callback, we # have to give a bit of time to the reactor to process the queue diff --git a/tests/test_downloader_handlers_http_base.py b/tests/test_downloader_handlers_http_base.py index 8fefc0dd7..a2459911f 100644 --- a/tests/test_downloader_handlers_http_base.py +++ b/tests/test_downloader_handlers_http_base.py @@ -453,28 +453,29 @@ class TestHttp11Base(TestHttpBase): assert type(response) is TextResponse # pylint: disable=unidiomatic-typecheck @deferred_f_from_coro_f - async def test_download_with_maxsize( - self, mockserver: MockServer, download_handler: DownloadHandlerProtocol - ) -> None: + async def test_download_with_maxsize(self, mockserver: MockServer) -> None: request = Request(mockserver.url("/text", is_secure=self.is_secure)) # 10 is minimal size for this request and the limit is only counted on # response body. (regardless of headers) - response = await download_request( - download_handler, request, Spider("foo", download_maxsize=5) - ) + crawler = get_crawler(settings_dict={"DOWNLOAD_MAXSIZE": 5}) + download_handler = build_from_crawler(self.download_handler_cls, crawler) + response = await download_request(download_handler, request, Spider("foo")) assert response.body == b"Works" + crawler = get_crawler(settings_dict={"DOWNLOAD_MAXSIZE": 4}) + download_handler = build_from_crawler(self.download_handler_cls, crawler) + with pytest.raises((defer.CancelledError, error.ConnectionAborted)): - await download_request( - download_handler, request, Spider("foo", download_maxsize=4) - ) + await download_request(download_handler, request, Spider("foo")) @deferred_f_from_coro_f async def test_download_with_maxsize_very_large_file( - self, mockserver: MockServer, download_handler: DownloadHandlerProtocol + self, mockserver: MockServer ) -> None: # TODO: the logger check is specific to scrapy.core.downloader.handlers.http11 + crawler = get_crawler(settings_dict={"DOWNLOAD_MAXSIZE": 1_500}) + download_handler = build_from_crawler(self.download_handler_cls, crawler) with mock.patch("scrapy.core.downloader.handlers.http11.logger") as logger: request = Request( mockserver.url("/largechunkedfile", is_secure=self.is_secure) @@ -484,9 +485,7 @@ class TestHttp11Base(TestHttpBase): logger.warning.assert_called_once_with(mock.ANY, mock.ANY) with pytest.raises((defer.CancelledError, error.ConnectionAborted)): - await download_request( - download_handler, request, Spider("foo", download_maxsize=1500) - ) + await download_request(download_handler, request, Spider("foo")) # As the error message is logged in the dataReceived callback, we # have to give a bit of time to the reactor to process the queue @@ -506,23 +505,23 @@ class TestHttp11Base(TestHttpBase): await download_request(download_handler, request) @deferred_f_from_coro_f - async def test_download_with_small_maxsize_per_spider( - self, mockserver: MockServer, download_handler: DownloadHandlerProtocol + async def test_download_with_small_maxsize_via_setting( + self, mockserver: MockServer ) -> None: + crawler = get_crawler(settings_dict={"DOWNLOAD_MAXSIZE": 2}) + download_handler = build_from_crawler(self.download_handler_cls, crawler) request = Request(mockserver.url("/text", is_secure=self.is_secure)) with pytest.raises((defer.CancelledError, error.ConnectionAborted)): - await download_request( - download_handler, request, Spider("foo", download_maxsize=2) - ) + await download_request(download_handler, request, Spider("foo")) @deferred_f_from_coro_f - async def test_download_with_large_maxsize_per_spider( - self, mockserver: MockServer, download_handler: DownloadHandlerProtocol + async def test_download_with_large_maxsize_via_setting( + self, mockserver: MockServer ) -> None: + crawler = get_crawler(settings_dict={"DOWNLOAD_MAXSIZE": 5}) + download_handler = build_from_crawler(self.download_handler_cls, crawler) request = Request(mockserver.url("/text", is_secure=self.is_secure)) - response = await download_request( - download_handler, request, Spider("foo", download_maxsize=100) - ) + response = await download_request(download_handler, request, Spider("foo")) assert response.body == b"Works" @deferred_f_from_coro_f diff --git a/tests/test_downloadermiddleware_downloadtimeout.py b/tests/test_downloadermiddleware_downloadtimeout.py index 3707cee18..c744d259c 100644 --- a/tests/test_downloadermiddleware_downloadtimeout.py +++ b/tests/test_downloadermiddleware_downloadtimeout.py @@ -23,16 +23,14 @@ class TestDownloadTimeoutMiddleware: assert mw.process_request(req) is None assert req.meta.get("download_timeout") == 20.1 - def test_spider_has_download_timeout(self): - req, spider, mw = self.get_request_spider_mw() - spider.download_timeout = 2 + def test_setting_has_download_timeout(self): + req, spider, mw = self.get_request_spider_mw({"DOWNLOAD_TIMEOUT": 2}) mw.spider_opened(spider) assert mw.process_request(req) is None assert req.meta.get("download_timeout") == 2 def test_request_has_download_timeout(self): - req, spider, mw = self.get_request_spider_mw() - spider.download_timeout = 2 + req, spider, mw = self.get_request_spider_mw({"DOWNLOAD_TIMEOUT": 2}) mw.spider_opened(spider) req.meta["download_timeout"] = 1 assert mw.process_request(req) is None diff --git a/tests/test_downloadermiddleware_useragent.py b/tests/test_downloadermiddleware_useragent.py index 60dc2ae7a..539183cd6 100644 --- a/tests/test_downloadermiddleware_useragent.py +++ b/tests/test_downloadermiddleware_useragent.py @@ -16,26 +16,8 @@ class TestUserAgentMiddleware: assert mw.process_request(req) is None assert req.headers["User-Agent"] == b"default_useragent" - def test_remove_agent(self): - # settings USER_AGENT to None should remove the user agent - spider, mw = self.get_spider_and_mw("default_useragent") - spider.user_agent = None - mw.spider_opened(spider) - req = Request("http://scrapytest.org/") - assert mw.process_request(req) is None - assert req.headers.get("User-Agent") is None - - def test_spider_agent(self): - spider, mw = self.get_spider_and_mw("default_useragent") - spider.user_agent = "spider_useragent" - mw.spider_opened(spider) - req = Request("http://scrapytest.org/") - assert mw.process_request(req) is None - assert req.headers["User-Agent"] == b"spider_useragent" - def test_header_agent(self): spider, mw = self.get_spider_and_mw("default_useragent") - spider.user_agent = "spider_useragent" mw.spider_opened(spider) req = Request( "http://scrapytest.org/", headers={"User-Agent": "header_useragent"} @@ -45,7 +27,6 @@ class TestUserAgentMiddleware: def test_no_agent(self): spider, mw = self.get_spider_and_mw(None) - spider.user_agent = None mw.spider_opened(spider) req = Request("http://scrapytest.org/") assert mw.process_request(req) is None From 483e059d59534ddcbca5a4db102a984f600e3b15 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Tue, 9 Dec 2025 19:03:32 +0500 Subject: [PATCH 004/248] Deprecate returning Deferreds from spider callbacks and errbacks. (#7151) --- scrapy/core/scraper.py | 14 ++++++++++++++ tests/test_crawl.py | 30 ++++++++++++++++++++++++++++-- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/scrapy/core/scraper.py b/scrapy/core/scraper.py index d29dc2c06..455326a0a 100644 --- a/scrapy/core/scraper.py +++ b/scrapy/core/scraper.py @@ -318,6 +318,13 @@ class Scraper: callback = result.request.callback or self.crawler.spider._parse warn_on_generator_with_return_value(self.crawler.spider, callback) output = callback(result, **result.request.cb_kwargs) + if isinstance(output, Deferred): + warnings.warn( + f"{callback} returned a Deferred." + f" Returning Deferreds from spider callbacks is deprecated.", + ScrapyDeprecationWarning, + stacklevel=2, + ) else: # result is a Failure # TODO: properly type adding this attribute to a Failure result.request = request # type: ignore[attr-defined] @@ -329,6 +336,13 @@ class Scraper: output.raiseException() # else the errback returned actual output (like a callback), # which needs to be passed to iterate_spider_output() + if isinstance(output, Deferred): + warnings.warn( + f"{request.errback} returned a Deferred." + f" Returning Deferreds from spider errbacks is deprecated.", + ScrapyDeprecationWarning, + stacklevel=2, + ) return await ensure_awaitable(iterate_spider_output(output)) @_warn_spider_arg diff --git a/tests/test_crawl.py b/tests/test_crawl.py index dc85d9d68..b0060a76a 100644 --- a/tests/test_crawl.py +++ b/tests/test_crawl.py @@ -9,13 +9,13 @@ from urllib.parse import urlencode, urlparse import pytest from testfixtures import LogCapture -from twisted.internet.defer import inlineCallbacks +from twisted.internet.defer import inlineCallbacks, succeed from twisted.internet.ssl import Certificate from twisted.python.failure import Failure from scrapy import Spider, signals from scrapy.crawler import CrawlerRunner -from scrapy.exceptions import CloseSpider, StopDownload +from scrapy.exceptions import CloseSpider, ScrapyDeprecationWarning, StopDownload from scrapy.http import Request from scrapy.http.response import Response from scrapy.utils.defer import deferred_f_from_coro_f, maybe_deferred_to_future @@ -720,6 +720,18 @@ class TestCrawlSpider: "failure" ].value.response.headers == crawler.spider.meta.get("headers_received") + @inlineCallbacks + def test_spider_callback_deferred_deprecated(self): + def cb(response: Response) -> Any: + return succeed(None) + + crawler = get_crawler(SingleRequestSpider) + with pytest.warns( + ScrapyDeprecationWarning, + match="Returning Deferreds from spider callbacks is deprecated", + ): + yield crawler.crawl(seed=self.mockserver.url("/"), callback_func=cb) + @inlineCallbacks def test_spider_errback(self): failures = [] @@ -851,6 +863,20 @@ class TestCrawlSpider: assert "Spider error processing" not in str(log) assert "Crawled (200)" in str(log) + @inlineCallbacks + def test_spider_errback_deferred_deprecated(self): + def eb(failure: Failure) -> Any: + return succeed(None) + + crawler = get_crawler(SingleRequestSpider) + with pytest.warns( + ScrapyDeprecationWarning, + match="Returning Deferreds from spider errbacks is deprecated", + ): + yield crawler.crawl( + seed=self.mockserver.url("/status?n=400"), errback_func=eb + ) + @inlineCallbacks def test_raise_closespider(self): def cb(response): From 7ed20ee7f32ee105c19243c6cf02cf54714c0c45 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Tue, 9 Dec 2025 19:24:40 +0500 Subject: [PATCH 005/248] Limit the queue size in _parallel_asyncio(). (#7159) --- scrapy/utils/asyncio.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scrapy/utils/asyncio.py b/scrapy/utils/asyncio.py index 15e7736ec..25237d0ba 100644 --- a/scrapy/utils/asyncio.py +++ b/scrapy/utils/asyncio.py @@ -80,7 +80,7 @@ async def _parallel_asyncio( assumes that neither *callable* nor iterating *iterable* will raise an exception. """ - queue: asyncio.Queue[_T | None] = asyncio.Queue() + queue: asyncio.Queue[_T | None] = asyncio.Queue(count * 2) async def worker() -> None: while True: From 5105f55a980640aa749fdf788fb50f82f385de60 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Tue, 9 Dec 2025 20:07:36 +0500 Subject: [PATCH 006/248] Add `DownloaderMiddlewareManager.download_async()`, deprecate returning Deferreds from downloader mws (#7069) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Restore coverage in MiddlewareManager spider arg handling. * Move MiddlewareManager._warn_spider_arg() tests. * Add DownloaderMiddlewareManager.download_async(). * Make download_func async. * Inline MiddlewareManager._warn_spider_arg(). * Deprecate returning deferreds from downloader middlewares. * Update the downloader mw doc about async def. * Use global_object_name() instead of repr(). * Update docs/topics/downloader-middleware.rst Co-authored-by: Adrián Chaves * Update downloader-middleware.rst --------- Co-authored-by: Adrián Chaves --- docs/topics/downloader-middleware.rst | 3 +- scrapy/core/downloader/__init__.py | 14 ++-- scrapy/core/downloader/middleware.py | 109 ++++++++++++++------------ scrapy/core/spidermw.py | 12 ++- scrapy/middleware.py | 13 --- scrapy/utils/defer.py | 13 ++- tests/test_downloadermiddleware.py | 45 ++++------- 7 files changed, 104 insertions(+), 105 deletions(-) diff --git a/docs/topics/downloader-middleware.rst b/docs/topics/downloader-middleware.rst index 6f36acc68..6d24482ec 100644 --- a/docs/topics/downloader-middleware.rst +++ b/docs/topics/downloader-middleware.rst @@ -68,7 +68,8 @@ defines one or more of these methods: .. class:: DownloaderMiddleware - .. note:: Any of the downloader middleware methods may also return a deferred. + .. note:: Any of the downloader middleware methods may be defined as a + coroutine function (``async def``). .. method:: process_request(request) diff --git a/scrapy/core/downloader/__init__.py b/scrapy/core/downloader/__init__.py index 0d23aa85c..109a6eaec 100644 --- a/scrapy/core/downloader/__init__.py +++ b/scrapy/core/downloader/__init__.py @@ -23,6 +23,7 @@ from scrapy.utils.decorators import _warn_spider_arg from scrapy.utils.defer import ( _defer_sleep_async, _schedule_coro, + deferred_from_coro, maybe_deferred_to_future, ) from scrapy.utils.deprecate import warn_on_deprecated_spider_attribute @@ -139,7 +140,11 @@ class Downloader: ) -> Generator[Deferred[Any], Any, Response | Request]: self.active.add(request) try: - return (yield self.middleware.download(self._enqueue_request, request)) + return ( + yield deferred_from_coro( + self.middleware.download_async(self._enqueue_request, request) + ) + ) finally: self.active.remove(request) @@ -178,10 +183,7 @@ class Downloader: return key # passed as download_func into self.middleware.download() in self.fetch() - @inlineCallbacks - def _enqueue_request( - self, request: Request - ) -> Generator[Deferred[Any], Any, Response]: + async def _enqueue_request(self, request: Request) -> Response: key, slot = self._get_slot(request) request.meta[self.DOWNLOAD_SLOT] = key slot.active.add(request) @@ -194,7 +196,7 @@ class Downloader: slot.queue.append((request, d)) self._process_queue(slot) try: - return (yield d) # fired in _wait_for_download() + return await maybe_deferred_to_future(d) # fired in _wait_for_download() finally: slot.active.remove(request) diff --git a/scrapy/core/downloader/middleware.py b/scrapy/core/downloader/middleware.py index a822ba811..431579856 100644 --- a/scrapy/core/downloader/middleware.py +++ b/scrapy/core/downloader/middleware.py @@ -7,19 +7,25 @@ See documentation in docs/topics/downloader-middleware.rst from __future__ import annotations import warnings +from functools import wraps from typing import TYPE_CHECKING, Any, cast -from twisted.internet.defer import Deferred, inlineCallbacks - from scrapy.exceptions import ScrapyDeprecationWarning, _InvalidOutput from scrapy.http import Request, Response from scrapy.middleware import MiddlewareManager from scrapy.utils.conf import build_component_list -from scrapy.utils.defer import _defer_sleep, deferred_from_coro -from scrapy.utils.deprecate import argument_is_required +from scrapy.utils.defer import ( + _defer_sleep_async, + deferred_from_coro, + ensure_awaitable, + maybe_deferred_to_future, +) +from scrapy.utils.python import global_object_name if TYPE_CHECKING: - from collections.abc import Callable, Generator + from collections.abc import Callable, Coroutine + + from twisted.internet.defer import Deferred from scrapy import Spider from scrapy.settings import BaseSettings @@ -43,36 +49,42 @@ class DownloaderMiddlewareManager(MiddlewareManager): self.methods["process_exception"].appendleft(mw.process_exception) self._check_mw_method_spider_arg(mw.process_exception) - @inlineCallbacks def download( self, - download_func: Callable[[Request], Deferred[Response]], + download_func: Callable[[Request, Spider], Deferred[Response]], request: Request, - spider: Spider | None = None, - ) -> Generator[Deferred[Any], Any, Response | Request]: - if argument_is_required(download_func, "spider"): - warnings.warn( - "The spider argument of download_func is deprecated" - " and will not be passed in future Scrapy versions.", - ScrapyDeprecationWarning, - stacklevel=2, - ) - need_spider_arg = True - else: - need_spider_arg = False + spider: Spider, + ) -> Deferred[Response | Request]: + warnings.warn( + "DownloaderMiddlewareManager.download() is deprecated, use download_async() instead", + ScrapyDeprecationWarning, + stacklevel=2, + ) - @inlineCallbacks - def process_request( - request: Request, - ) -> Generator[Deferred[Any], Any, Response | Request]: + @wraps(download_func) + async def download_func_wrapped(request: Request) -> Response: + return await maybe_deferred_to_future(download_func(request, spider)) + + self._set_compat_spider(spider) + return deferred_from_coro(self.download_async(download_func_wrapped, request)) + + async def download_async( + self, + download_func: Callable[[Request], Coroutine[Any, Any, Response]], + request: Request, + ) -> Response | Request: + async def process_request(request: Request) -> Response | Request: for method in self.methods["process_request"]: method = cast("Callable", method) if method in self._mw_methods_requiring_spider: - response = yield deferred_from_coro( - method(request=request, spider=self._spider) + response = await ensure_awaitable( + method(request=request, spider=self._spider), + _warn=global_object_name(method), ) else: - response = yield deferred_from_coro(method(request=request)) + response = await ensure_awaitable( + method(request=request), _warn=global_object_name(method) + ) if response is not None and not isinstance( response, (Response, Request) ): @@ -82,14 +94,9 @@ class DownloaderMiddlewareManager(MiddlewareManager): ) if response: return response - if need_spider_arg: - return (yield download_func(request, self._spider)) # type: ignore[call-arg] - return (yield download_func(request)) + return await download_func(request) - @inlineCallbacks - def process_response( - response: Response | Request, - ) -> Generator[Deferred[Any], Any, Response | Request]: + async def process_response(response: Response | Request) -> Response | Request: if response is None: raise TypeError("Received None in process_response") if isinstance(response, Request): @@ -98,12 +105,14 @@ class DownloaderMiddlewareManager(MiddlewareManager): for method in self.methods["process_response"]: method = cast("Callable", method) if method in self._mw_methods_requiring_spider: - response = yield deferred_from_coro( - method(request=request, response=response, spider=self._spider) + response = await ensure_awaitable( + method(request=request, response=response, spider=self._spider), + _warn=global_object_name(method), ) else: - response = yield deferred_from_coro( - method(request=request, response=response) + response = await ensure_awaitable( + method(request=request, response=response), + _warn=global_object_name(method), ) if not isinstance(response, (Response, Request)): raise _InvalidOutput( @@ -114,21 +123,20 @@ class DownloaderMiddlewareManager(MiddlewareManager): return response return response - @inlineCallbacks - def process_exception( - exception: Exception, - ) -> Generator[Deferred[Any], Any, Response | Request]: + async def process_exception(exception: Exception) -> Response | Request: for method in self.methods["process_exception"]: method = cast("Callable", method) if method in self._mw_methods_requiring_spider: - response = yield deferred_from_coro( + response = await ensure_awaitable( method( request=request, exception=exception, spider=self._spider - ) + ), + _warn=global_object_name(method), ) else: - response = yield deferred_from_coro( - method(request=request, exception=exception) + response = await ensure_awaitable( + method(request=request, exception=exception), + _warn=global_object_name(method), ) if response is not None and not isinstance( response, (Response, Request) @@ -141,14 +149,11 @@ class DownloaderMiddlewareManager(MiddlewareManager): return response raise exception - if spider: - self._warn_spider_arg("download") - self._set_compat_spider(spider) try: - result: Response | Request = yield process_request(request) + result: Response | Request = await process_request(request) except Exception as ex: - yield _defer_sleep() + await _defer_sleep_async() # either returns a request or response (which we pass to process_response()) # or reraises the exception - result = yield process_exception(ex) - return (yield process_response(result)) + result = await process_exception(ex) + return await process_response(result) diff --git a/scrapy/core/spidermw.py b/scrapy/core/spidermw.py index 6f694753a..61fecfc60 100644 --- a/scrapy/core/spidermw.py +++ b/scrapy/core/spidermw.py @@ -436,7 +436,17 @@ class SpiderMiddlewareManager(MiddlewareManager): self, spider: Spider | None = None ) -> AsyncIterator[Any] | None: if spider: - self._warn_spider_arg("process_start") + if self.crawler: + msg = ( + "Passing a spider argument to SpiderMiddlewareManager.process_start() is deprecated" + " and the passed value is ignored." + ) + else: + msg = ( + "Passing a spider argument to SpiderMiddlewareManager.process_start() is deprecated," + " SpiderMiddlewareManager should be instantiated with a Crawler instance instead." + ) + warn(msg, category=ScrapyDeprecationWarning, stacklevel=2) self._set_compat_spider(spider) self._check_deprecated_start_requests_use() if self._use_start_requests: diff --git a/scrapy/middleware.py b/scrapy/middleware.py index 21a2ebfa3..83362784b 100644 --- a/scrapy/middleware.py +++ b/scrapy/middleware.py @@ -82,19 +82,6 @@ class MiddlewareManager(ABC): f" {self._compat_spider} and {spider}" ) - def _warn_spider_arg(self, method_name: str) -> None: - if self.crawler: - msg = ( - f"Passing a spider argument to {type(self).__name__}.{method_name}() is deprecated" - " and the passed value is ignored." - ) - else: - msg = ( - f"Passing a spider argument to {type(self).__name__}.{method_name}() is deprecated," - f" {type(self).__name__} should be instantiated with a Crawler instance instead." - ) - warnings.warn(msg, category=ScrapyDeprecationWarning, stacklevel=3) - @classmethod @abstractmethod def _get_mwlist_from_settings(cls, settings: Settings) -> list[Any]: diff --git a/scrapy/utils/defer.py b/scrapy/utils/defer.py index 8ca3bc0bd..c27f8bb34 100644 --- a/scrapy/utils/defer.py +++ b/scrapy/utils/defer.py @@ -517,14 +517,14 @@ def _schedule_coro(coro: Coroutine[Any, Any, Any]) -> None: @overload -def ensure_awaitable(o: Awaitable[_T]) -> Awaitable[_T]: ... +def ensure_awaitable(o: Awaitable[_T], _warn: str | None = None) -> Awaitable[_T]: ... @overload -def ensure_awaitable(o: _T) -> Awaitable[_T]: ... +def ensure_awaitable(o: _T, _warn: str | None = None) -> Awaitable[_T]: ... -def ensure_awaitable(o: _T | Awaitable[_T]) -> Awaitable[_T]: +def ensure_awaitable(o: _T | Awaitable[_T], _warn: str | None = None) -> Awaitable[_T]: """Convert any value to an awaitable object. For a :class:`~twisted.internet.defer.Deferred` object, use @@ -535,6 +535,13 @@ def ensure_awaitable(o: _T | Awaitable[_T]) -> Awaitable[_T]: .. versionadded:: VERSION """ if isinstance(o, Deferred): + if _warn: + warnings.warn( + f"{_warn} returned a Deferred, this is deprecated." + f" Please refactor this function to return a coroutine.", + ScrapyDeprecationWarning, + stacklevel=2, + ) return maybe_deferred_to_future(o) if inspect.isawaitable(o): return o diff --git a/tests/test_downloadermiddleware.py b/tests/test_downloadermiddleware.py index 03b941e4f..bc1748dfb 100644 --- a/tests/test_downloadermiddleware.py +++ b/tests/test_downloadermiddleware.py @@ -50,10 +50,10 @@ class TestManagerBase: if not response: response = Response(request.url) - def download_func(request: Request) -> Deferred[Response]: - return succeed(response) + async def download_func(request: Request) -> Response: + return response - return await maybe_deferred_to_future(mwman.download(download_func, request)) + return await mwman.download_async(download_func, request) class TestDefaults(TestManagerBase): @@ -135,7 +135,7 @@ class TestResponseFromProcessRequest(TestManagerBase): async with self.get_mwman() as mwman: mwman._add_middleware(ResponseMiddleware()) - result = await maybe_deferred_to_future(mwman.download(download_func, req)) + result = await mwman.download_async(download_func, req) assert result is resp assert not download_func.called @@ -163,7 +163,7 @@ class TestResponseFromProcessException(TestManagerBase): async with self.get_mwman() as mwman: mwman._add_middleware(ResponseMiddleware()) - result = await maybe_deferred_to_future(mwman.download(download_func, req)) + result = await mwman.download_async(download_func, req) assert result is resp assert calls == [ "process_exception", @@ -219,7 +219,7 @@ class TestInvalidOutput(TestManagerBase): class TestMiddlewareUsingDeferreds(TestManagerBase): - """Middlewares using Deferreds should work""" + """Middlewares using Deferreds (deprecated) should work""" @deferred_f_from_coro_f async def test_deferred(self): @@ -239,7 +239,11 @@ class TestMiddlewareUsingDeferreds(TestManagerBase): async with self.get_mwman() as mwman: mwman._add_middleware(DeferredMiddleware()) - result = await maybe_deferred_to_future(mwman.download(download_func, req)) + with pytest.warns( + ScrapyDeprecationWarning, + match="returned a Deferred, this is deprecated", + ): + result = await mwman.download_async(download_func, req) assert result is resp assert not download_func.called @@ -260,7 +264,7 @@ class TestMiddlewareUsingCoro(TestManagerBase): async with self.get_mwman() as mwman: mwman._add_middleware(CoroMiddleware()) - result = await maybe_deferred_to_future(mwman.download(download_func, req)) + result = await mwman.download_async(download_func, req) assert result is resp assert not download_func.called @@ -278,14 +282,14 @@ class TestMiddlewareUsingCoro(TestManagerBase): async with self.get_mwman() as mwman: mwman._add_middleware(CoroMiddleware()) - result = await maybe_deferred_to_future(mwman.download(download_func, req)) + result = await mwman.download_async(download_func, req) assert result is resp assert not download_func.called class TestDownloadDeprecated(TestManagerBase): @deferred_f_from_coro_f - async def test_download_func_spider_arg(self): + async def test_mwman_download(self): req = Request("http://example.com/index.html") resp = Response(req.url, status=200) @@ -295,24 +299,7 @@ class TestDownloadDeprecated(TestManagerBase): async with self.get_mwman() as mwman: with pytest.warns( ScrapyDeprecationWarning, - match="The spider argument of download_func is deprecated", - ): - ret = await maybe_deferred_to_future(mwman.download(download_func, req)) - assert isinstance(ret, Response) - - @deferred_f_from_coro_f - async def test_mwman_download_spider_arg(self): - req = Request("http://example.com/index.html") - resp = Response(req.url, status=200) - - def download_func(request: Request) -> Deferred[Response]: - return succeed(resp) - - async with self.get_mwman() as mwman: - with pytest.warns( - ScrapyDeprecationWarning, - match=r"Passing a spider argument to DownloaderMiddlewareManager.download\(\)" - r" is deprecated and the passed value is ignored.", + match=r"DownloaderMiddlewareManager.download\(\) is deprecated, use download_async\(\) instead", ): ret = await maybe_deferred_to_future( mwman.download(download_func, req, mwman.crawler.spider) @@ -353,6 +340,6 @@ class TestDeprecatedSpiderArg(TestManagerBase): ), ): mwman._add_middleware(DeprecatedSpiderArgMiddleware()) - result = await maybe_deferred_to_future(mwman.download(download_func, req)) + result = await mwman.download_async(download_func, req) assert result is resp assert not download_func.called From 9bfa58e36ccac20193c2175f3fc55636cd44f585 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Wed, 10 Dec 2025 14:42:49 +0500 Subject: [PATCH 007/248] Deprecate send_catch_log_deferred(). (#7161) * Add a test for not having pending tasks. * Refactor TestFeedExporterSignals. * Refactor FeedExporter.close_spider(). * More engine start/stop robustness. * Refactor send_catch_log_async(), deprecate send_catch_log_deferred(). * Add pragma: no cover. * Warn on signal handlers returning a Deferred. * Make _pending_close_coros an instance attribute. * Remove an unused function. * Remove the outdated TODO. --- docs/topics/coroutines.rst | 10 +-- scrapy/core/downloader/handlers/__init__.py | 14 ++-- scrapy/core/engine.py | 27 ++++-- scrapy/extensions/feedexport.py | 87 +++++++++----------- scrapy/signalmanager.py | 12 ++- scrapy/utils/signal.py | 91 +++++++++++++++++++-- tests/test_engine.py | 1 + tests/test_feedexport.py | 45 +++++----- tests/test_utils_signal.py | 11 ++- tests/test_zz_resources.py | 9 ++ 10 files changed, 204 insertions(+), 103 deletions(-) diff --git a/docs/topics/coroutines.rst b/docs/topics/coroutines.rst index bf2daf47c..ed2b25252 100644 --- a/docs/topics/coroutines.rst +++ b/docs/topics/coroutines.rst @@ -74,7 +74,7 @@ In addition to native coroutine APIs Scrapy has some APIs that return a function that returns a :class:`~twisted.internet.defer.Deferred` object. These APIs are also asynchronous but don't yet support native ``async def`` syntax. In the future we plan to add support for the ``async def`` syntax to these APIs -or replace them with other APIs where changing the existing ones is +or replace them with other APIs where changing the existing ones isn't possible. These APIs don't have a coroutine-based counterpart: @@ -99,14 +99,6 @@ These APIs have a coroutine-based implementation and a Deferred-based one: doesn't support non-default reactors and so the latter should be used with those. -- :class:`scrapy.signalmanager.SignalManager`: - - - :meth:`~scrapy.signalmanager.SignalManager.send_catch_log_async` - (coroutine-based) and - :meth:`~scrapy.signalmanager.SignalManager.send_catch_log_deferred` - (Deferred-based): the latter will be deprecated in a later Scrapy - version. - The following user-supplied methods can return :class:`~twisted.internet.defer.Deferred` objects (the methods that can also return coroutines are listed in :ref:`coroutine-support`): diff --git a/scrapy/core/downloader/handlers/__init__.py b/scrapy/core/downloader/handlers/__init__.py index 6352ede3c..2d3d40dae 100644 --- a/scrapy/core/downloader/handlers/__init__.py +++ b/scrapy/core/downloader/handlers/__init__.py @@ -5,17 +5,16 @@ from __future__ import annotations import logging from typing import TYPE_CHECKING, Any, Protocol, cast -from twisted.internet import defer - from scrapy import Request, Spider, signals from scrapy.exceptions import NotConfigured, NotSupported from scrapy.utils.decorators import _warn_spider_arg +from scrapy.utils.defer import ensure_awaitable from scrapy.utils.httpobj import urlparse_cached from scrapy.utils.misc import build_from_crawler, load_object from scrapy.utils.python import without_none_values if TYPE_CHECKING: - from collections.abc import Callable, Generator + from collections.abc import Callable from twisted.internet.defer import Deferred @@ -107,8 +106,9 @@ class DownloadHandlers: assert self._crawler.spider return handler.download_request(request, self._crawler.spider) - @defer.inlineCallbacks - def _close(self, *_a: Any, **_kw: Any) -> Generator[Deferred[Any], Any, None]: + async def _close(self) -> None: for dh in self._handlers.values(): - if hasattr(dh, "close"): - yield dh.close() + if not hasattr(dh, "close"): + continue + + await ensure_awaitable(dh.close()) diff --git a/scrapy/core/engine.py b/scrapy/core/engine.py index 9679d780d..ffaddc2c4 100644 --- a/scrapy/core/engine.py +++ b/scrapy/core/engine.py @@ -115,6 +115,8 @@ class ExecutionEngine: self._slot: _Slot | None = None self.spider: Spider | None = None self.running: bool = False + self._starting: bool = False + self._stopping: bool = False self.paused: bool = False self._spider_closed_callback: Callable[ [Spider], Coroutine[Any, Any, None] | Deferred[None] | None @@ -172,10 +174,14 @@ class ExecutionEngine: .. versionadded:: VERSION """ - if self.running: + if self._starting: raise RuntimeError("Engine already running") self.start_time = time() + self._starting = True await self.signals.send_catch_log_async(signal=signals.engine_started) + if self._stopping: + # band-aid until https://github.com/scrapy/scrapy/issues/6916 + return if _start_request_processing and self.spider is None: # require an opened spider when not run in scrapy shell return @@ -205,12 +211,21 @@ class ExecutionEngine: .. versionadded:: VERSION """ - if not self.running: + if not self._starting: raise RuntimeError("Engine not running") - self.running = False + self.running = self._starting = False + self._stopping = True if self._start_request_processing_awaitable is not None: - self._start_request_processing_awaitable.cancel() + if ( + not is_asyncio_available() + or self._start_request_processing_awaitable + is not asyncio.current_task() + ): + # If using the asyncio loop and stop_async() was called from + # start() itself, we can't cancel it, and _start_request_processing() + # will exit via the self.running check. + self._start_request_processing_awaitable.cancel() self._start_request_processing_awaitable = None if self.spider is not None: await self.close_spider_async(reason="shutdown") @@ -285,7 +300,7 @@ class ExecutionEngine: self._slot.nextcall.schedule() self._slot.heartbeat.start(self._SLOT_HEARTBEAT_INTERVAL) - while self._start and self.spider: + while self._start and self.spider and self.running: await self._process_start_next() if not self.needs_backout(): # Give room for the outcome of self._process_start_next() to be @@ -293,7 +308,7 @@ class ExecutionEngine: self._slot.nextcall.schedule() await self._slot.nextcall.wait() except (asyncio.exceptions.CancelledError, CancelledError): - # self.stop() has cancelled us, nothing to do + # self.stop_async() has cancelled us, nothing to do return except Exception: # an error happened, log it and stop the engine diff --git a/scrapy/extensions/feedexport.py b/scrapy/extensions/feedexport.py index adb6fdc33..cb56d1c92 100644 --- a/scrapy/extensions/feedexport.py +++ b/scrapy/extensions/feedexport.py @@ -6,20 +6,21 @@ See documentation in docs/topics/feed-exports.rst from __future__ import annotations +import asyncio import contextlib import logging import re import sys import warnings from abc import ABC, abstractmethod -from collections.abc import Callable +from collections.abc import Callable, Coroutine from datetime import datetime, timezone from pathlib import Path, PureWindowsPath from tempfile import NamedTemporaryFile from typing import IO, TYPE_CHECKING, Any, Protocol, TypeAlias, cast from urllib.parse import unquote, urlparse -from twisted.internet.defer import Deferred, DeferredList, maybeDeferred +from twisted.internet.defer import Deferred, DeferredList from twisted.internet.threads import deferToThread from w3lib.url import file_uri_to_path from zope.interface import Interface, implementer @@ -27,16 +28,15 @@ from zope.interface import Interface, implementer from scrapy import Spider, signals from scrapy.exceptions import NotConfigured, ScrapyDeprecationWarning from scrapy.extensions.postprocessing import PostProcessingManager +from scrapy.utils.asyncio import is_asyncio_available from scrapy.utils.conf import feed_complete_default_values_from_settings -from scrapy.utils.defer import maybe_deferred_to_future +from scrapy.utils.defer import deferred_from_coro, ensure_awaitable from scrapy.utils.ftp import ftp_store_file -from scrapy.utils.log import failure_to_exc_info from scrapy.utils.misc import build_from_crawler, load_object from scrapy.utils.python import without_none_values if TYPE_CHECKING: from _typeshed import OpenBinaryMode - from twisted.python.failure import Failure # typing.Self requires Python 3.11 from typing_extensions import Self @@ -431,8 +431,6 @@ class FeedSlot: class FeedExporter: - _pending_deferreds: list[Deferred[None]] = [] - @classmethod def from_crawler(cls, crawler: Crawler) -> Self: exporter = cls(crawler) @@ -447,6 +445,7 @@ class FeedExporter: self.feeds = {} self.slots: list[FeedSlot] = [] self.filters: dict[str, ItemFilter] = {} + self._pending_close_coros: list[Coroutine[Any, Any, None]] = [] if not self.settings["FEEDS"] and not self.settings["FEED_URI"]: raise NotConfigured @@ -506,17 +505,24 @@ class FeedExporter: ) async def close_spider(self, spider: Spider) -> None: - for slot in self.slots: - self._close_slot(slot, spider) + self._pending_close_coros.extend( + self._close_slot(slot, spider) for slot in self.slots + ) - # Await all deferreds - if self._pending_deferreds: - await maybe_deferred_to_future(DeferredList(self._pending_deferreds)) + if self._pending_close_coros: + if is_asyncio_available(): + await asyncio.wait( + [asyncio.create_task(coro) for coro in self._pending_close_coros] + ) + else: + await DeferredList( + deferred_from_coro(coro) for coro in self._pending_close_coros + ) # Send FEED_EXPORTER_CLOSED signal await self.crawler.signals.send_catch_log_async(signals.feed_exporter_closed) - def _close_slot(self, slot: FeedSlot, spider: Spider) -> Deferred[None] | None: + async def _close_slot(self, slot: FeedSlot, spider: Spider) -> None: def get_file(slot_: FeedSlot) -> IO[bytes]: assert slot_.file if isinstance(slot_.file, PostProcessingManager): @@ -533,45 +539,28 @@ class FeedExporter: slot.finish_exporting() else: # In this case, the file is not stored, so no processing is required. - return None + return logmsg = f"{slot.format} feed ({slot.itemcount} items) in: {slot.uri}" - d: Deferred[None] = maybeDeferred(slot.storage.store, get_file(slot)) # type: ignore[call-overload] - - d.addCallback( - self._handle_store_success, logmsg, spider, type(slot.storage).__name__ - ) - d.addErrback( - self._handle_store_error, logmsg, spider, type(slot.storage).__name__ - ) - self._pending_deferreds.append(d) - d.addCallback( - lambda _: self.crawler.signals.send_catch_log_deferred( - signals.feed_slot_closed, slot=slot + slot_type = type(slot.storage).__name__ + assert self.crawler.stats + try: + await ensure_awaitable(slot.storage.store(get_file(slot))) + except Exception: + logger.error( + "Error storing %s", + logmsg, + exc_info=True, + extra={"spider": spider}, ) + self.crawler.stats.inc_value(f"feedexport/failed_count/{slot_type}") + else: + logger.info("Stored %s", logmsg, extra={"spider": spider}) + self.crawler.stats.inc_value(f"feedexport/success_count/{slot_type}") + + await self.crawler.signals.send_catch_log_async( + signals.feed_slot_closed, slot=slot ) - d.addBoth(lambda _: self._pending_deferreds.remove(d)) - - return d - - def _handle_store_error( - self, f: Failure, logmsg: str, spider: Spider, slot_type: str - ) -> None: - logger.error( - "Error storing %s", - logmsg, - exc_info=failure_to_exc_info(f), - extra={"spider": spider}, - ) - assert self.crawler.stats - self.crawler.stats.inc_value(f"feedexport/failed_count/{slot_type}") - - def _handle_store_success( - self, result: Any, logmsg: str, spider: Spider, slot_type: str - ) -> None: - logger.info("Stored %s", logmsg, extra={"spider": spider}) - assert self.crawler.stats - self.crawler.stats.inc_value(f"feedexport/success_count/{slot_type}") def _start_new_batch( self, @@ -627,7 +616,7 @@ class FeedExporter: uri_params = self._get_uri_params( spider, self.feeds[slot.uri_template]["uri_params"], slot ) - self._close_slot(slot, spider) + self._pending_close_coros.append(self._close_slot(slot, spider)) slots.append( self._start_new_batch( batch_id=slot.batch_id + 1, diff --git a/scrapy/signalmanager.py b/scrapy/signalmanager.py index 347eddfdb..30afa9168 100644 --- a/scrapy/signalmanager.py +++ b/scrapy/signalmanager.py @@ -1,10 +1,12 @@ 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 as _signal from scrapy.utils.defer import maybe_deferred_to_future @@ -51,7 +53,7 @@ class SignalManager: def send_catch_log_deferred( self, signal: Any, **kwargs: Any - ) -> Deferred[list[tuple[Any, Any]]]: + ) -> Deferred[list[tuple[Any, Any]]]: # pragma: no cover """ Like :meth:`send_catch_log` but supports :ref:`asynchronous signal handlers `. @@ -63,7 +65,12 @@ class SignalManager: through the :meth:`connect` method). """ kwargs.setdefault("sender", self.sender) - return _signal.send_catch_log_deferred(signal, **kwargs) + warnings.warn( + "send_catch_log_deferred() is deprecated, use send_catch_log_async() instead", + ScrapyDeprecationWarning, + stacklevel=2, + ) + return _signal._send_catch_log_deferred(signal, **kwargs) async def send_catch_log_async( self, signal: Any, **kwargs: Any @@ -80,6 +87,7 @@ class SignalManager: .. versionadded:: VERSION """ + # note that this returns exceptions instead of Failures in the second tuple member kwargs.setdefault("sender", self.sender) return await _signal.send_catch_log_async(signal, **kwargs) diff --git a/scrapy/utils/signal.py b/scrapy/utils/signal.py index 1b890933b..2c6aa9e67 100644 --- a/scrapy/utils/signal.py +++ b/scrapy/utils/signal.py @@ -2,8 +2,10 @@ from __future__ import annotations +import asyncio import logging -from collections.abc import Generator, Sequence +import warnings +from collections.abc import Awaitable, Callable, Generator, Sequence from typing import Any as TypingAny from pydispatch.dispatcher import ( @@ -17,9 +19,15 @@ from pydispatch.robustapply import robustApply from twisted.internet.defer import Deferred, DeferredList, inlineCallbacks from twisted.python.failure import Failure -from scrapy.exceptions import StopDownload -from scrapy.utils.defer import maybe_deferred_to_future, maybeDeferred_coro +from scrapy.exceptions import ScrapyDeprecationWarning, StopDownload +from scrapy.utils.asyncio import is_asyncio_available +from scrapy.utils.defer import ( + ensure_awaitable, + maybe_deferred_to_future, + maybeDeferred_coro, +) from scrapy.utils.log import failure_to_exc_info +from scrapy.utils.python import global_object_name logger = logging.getLogger(__name__) @@ -66,19 +74,32 @@ def send_catch_log( return responses -@inlineCallbacks def send_catch_log_deferred( signal: TypingAny = Any, sender: TypingAny = Anonymous, *arguments: TypingAny, **named: TypingAny, -) -> Generator[Deferred[TypingAny], TypingAny, list[tuple[TypingAny, TypingAny]]]: +) -> Deferred[list[tuple[TypingAny, TypingAny]]]: """Like :func:`send_catch_log` but supports :ref:`asynchronous signal handlers `. Returns a deferred that gets fired once all signal handlers have finished. """ + warnings.warn( + "send_catch_log_deferred() is deprecated, use send_catch_log_async() instead", + ScrapyDeprecationWarning, + stacklevel=2, + ) + return _send_catch_log_deferred(signal, sender, *arguments, **named) + +@inlineCallbacks +def _send_catch_log_deferred( + signal: TypingAny, + sender: TypingAny, + *arguments: TypingAny, + **named: TypingAny, +) -> Generator[Deferred[TypingAny], TypingAny, list[tuple[TypingAny, TypingAny]]]: def logerror(failure: Failure, recv: TypingAny) -> Failure: if dont_log is None or not isinstance(failure.value, dont_log): logger.error( @@ -123,9 +144,65 @@ async def send_catch_log_async( .. versionadded:: VERSION """ - return await maybe_deferred_to_future( - send_catch_log_deferred(signal, sender, *arguments, **named) + # note that this returns exceptions instead of Failures in the second tuple member + if is_asyncio_available(): + return await _send_catch_log_asyncio(signal, sender, *arguments, **named) + results = await maybe_deferred_to_future( + _send_catch_log_deferred(signal, sender, *arguments, **named) ) + return [ + (receiver, result.value if isinstance(result, Failure) else result) + for receiver, result in results + ] + + +async def _send_catch_log_asyncio( + signal: TypingAny = Any, + sender: TypingAny = Anonymous, + *arguments: TypingAny, + **named: TypingAny, +) -> list[tuple[TypingAny, TypingAny]]: + """Like :func:`send_catch_log` but supports :ref:`asynchronous signal handlers + `. + + Returns a coroutine that completes once all signal handlers have finished. + + This function requires + :class:`~twisted.internet.asyncioreactor.AsyncioSelectorReactor` to be + installed. + + .. versionadded:: VERSION + """ + dont_log = named.pop("dont_log", ()) + 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)): + + async def handler(receiver: Callable) -> TypingAny: + result: TypingAny + try: + result = await ensure_awaitable( + robustApply( + receiver, signal=signal, sender=sender, *arguments, **named + ), + _warn=global_object_name(receiver), + ) + except dont_log as ex: # pylint: disable=catching-non-exception + result = ex + except Exception as ex: + logger.error( + "Error caught on signal handler: %(receiver)s", + {"receiver": receiver}, + exc_info=True, + extra={"spider": spider}, + ) + result = ex + return (receiver, result) + + handlers.append(handler(receiver)) + + return await asyncio.gather(*handlers, return_exceptions=True) def disconnect_all(signal: TypingAny = Any, sender: TypingAny = Any) -> None: diff --git a/tests/test_engine.py b/tests/test_engine.py index 430d599c8..7e550d661 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -659,6 +659,7 @@ class TestEngineCloseSpider: engine = ExecutionEngine(crawler, lambda _: None) with pytest.raises(RuntimeError, match="Spider not opened"): await engine.close_spider_async() + engine.downloader.close() # cleanup @deferred_f_from_coro_f async def test_exception_slot( diff --git a/tests/test_feedexport.py b/tests/test_feedexport.py index 7eb9f78e8..a423f5328 100644 --- a/tests/test_feedexport.py +++ b/tests/test_feedexport.py @@ -29,7 +29,6 @@ import lxml.etree import pytest from packaging.version import Version from testfixtures import LogCapture -from twisted.internet import defer from twisted.internet.defer import inlineCallbacks from w3lib.url import file_uri_to_path, path_to_file_uri from zope.interface import implementer @@ -59,7 +58,7 @@ from tests.mockserver.http import MockServer from tests.spiders import ItemSpider if TYPE_CHECKING: - from collections.abc import Iterable + from collections.abc import Callable, Iterable from os import PathLike @@ -2762,6 +2761,12 @@ class TestBatchDeliveries(TestFeedExportBase): assert len(CustomS3FeedStorage.stubs) == len(items) for stub in CustomS3FeedStorage.stubs[:-1]: stub.assert_no_pending_responses() + assert ( + "feedexport/success_count/CustomS3FeedStorage" in crawler.stats.get_stats() + ) + assert ( + crawler.stats.get_value("feedexport/success_count/CustomS3FeedStorage") == 3 + ) # Test that the FeedExporer sends the feed_exporter_closed and feed_slot_closed signals @@ -2787,21 +2792,15 @@ class TestFeedExporterSignals: def feed_slot_closed_signal_handler(self, slot): self.feed_slot_closed_received = True - def feed_exporter_closed_signal_handler_deferred(self): - d = defer.Deferred() - d.addCallback(lambda _: setattr(self, "feed_exporter_closed_received", True)) - d.callback(None) - return d + async def feed_exporter_closed_signal_handler_async(self): + self.feed_exporter_closed_received = True - def feed_slot_closed_signal_handler_deferred(self, slot): - d = defer.Deferred() - d.addCallback(lambda _: setattr(self, "feed_slot_closed_received", True)) - d.callback(None) - return d + async def feed_slot_closed_signal_handler_async(self, slot): + self.feed_slot_closed_received = True - def run_signaled_feed_exporter( - self, feed_exporter_signal_handler, feed_slot_signal_handler - ): + async def run_signaled_feed_exporter( + self, feed_exporter_signal_handler: Callable, feed_slot_signal_handler: Callable + ) -> None: crawler = get_crawler(settings_dict=self.settings) feed_exporter = FeedExporter.from_crawler(crawler) spider = scrapy.Spider("default") @@ -2816,26 +2815,28 @@ class TestFeedExporterSignals: feed_exporter.open_spider(spider) for item in self.items: feed_exporter.item_scraped(item, spider) - defer.ensureDeferred(feed_exporter.close_spider(spider)) + await feed_exporter.close_spider(spider) - def test_feed_exporter_signals_sent(self): + @deferred_f_from_coro_f + async def test_feed_exporter_signals_sent(self) -> None: self.feed_exporter_closed_received = False self.feed_slot_closed_received = False - self.run_signaled_feed_exporter( + await self.run_signaled_feed_exporter( self.feed_exporter_closed_signal_handler, self.feed_slot_closed_signal_handler, ) assert self.feed_slot_closed_received assert self.feed_exporter_closed_received - def test_feed_exporter_signals_sent_deferred(self): + @deferred_f_from_coro_f + async def test_feed_exporter_signals_sent_async(self) -> None: self.feed_exporter_closed_received = False self.feed_slot_closed_received = False - self.run_signaled_feed_exporter( - self.feed_exporter_closed_signal_handler_deferred, - self.feed_slot_closed_signal_handler_deferred, + await self.run_signaled_feed_exporter( + self.feed_exporter_closed_signal_handler_async, + self.feed_slot_closed_signal_handler_async, ) assert self.feed_slot_closed_received assert self.feed_exporter_closed_received diff --git a/tests/test_utils_signal.py b/tests/test_utils_signal.py index 6e4bdb49a..51e496575 100644 --- a/tests/test_utils_signal.py +++ b/tests/test_utils_signal.py @@ -18,6 +18,9 @@ from scrapy.utils.test import get_from_asyncio_queue class TestSendCatchLog: + # whether the function being tested returns exceptions or failures + returns_exceptions: bool = False + @inlineCallbacks def test_send_catch_log(self): test_signal = object() @@ -40,7 +43,9 @@ class TestSendCatchLog: assert "error_handler" in record.getMessage() assert record.levelname == "ERROR" assert result[0][0] == self.error_handler # pylint: disable=comparison-with-callable - assert isinstance(result[0][1], Failure) + assert isinstance( + result[0][1], Exception if self.returns_exceptions else Failure + ) assert result[1] == (self.ok_handler, "OK") dispatcher.disconnect(self.error_handler, signal=test_signal) @@ -59,6 +64,7 @@ class TestSendCatchLog: return "OK" +@pytest.mark.filterwarnings("ignore::scrapy.exceptions.ScrapyDeprecationWarning") class TestSendCatchLogDeferred(TestSendCatchLog): def _get_result(self, signal, *a, **kw): return send_catch_log_deferred(signal, *a, **kw) @@ -91,10 +97,13 @@ class TestSendCatchLogDeferredAsyncio(TestSendCatchLogDeferred): class TestSendCatchLogAsync(TestSendCatchLog): + returns_exceptions = True + def _get_result(self, signal, *a, **kw): return deferred_from_coro(send_catch_log_async(signal, *a, **kw)) +@pytest.mark.filterwarnings("ignore::scrapy.exceptions.ScrapyDeprecationWarning") class TestSendCatchLogAsync2(TestSendCatchLogAsync): def ok_handler(self, arg, handlers_called): handlers_called.add(self.ok_handler) diff --git a/tests/test_zz_resources.py b/tests/test_zz_resources.py index 2560f8d7f..e2c8ed205 100644 --- a/tests/test_zz_resources.py +++ b/tests/test_zz_resources.py @@ -2,8 +2,11 @@ from __future__ import annotations +import asyncio import logging +import pytest + from scrapy.utils.log import LogCounterHandler @@ -25,3 +28,9 @@ def test_stderr_log_handler() -> None: """ c = sum(1 for h in logging.root.handlers if type(h) is logging.StreamHandler) # pylint: disable=unidiomatic-typecheck assert c == 0 + + +@pytest.mark.only_asyncio +def test_pending_asyncio_tasks() -> None: + """Test that there are no pending asyncio tasks.""" + assert not asyncio.all_tasks() From 6ba6b032ad6828473510e07f8423f6c1e27c043e Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Thu, 11 Dec 2025 13:46:51 +0500 Subject: [PATCH 008/248] Fix handling meta["download_slot"] == None. (#7172) --- scrapy/core/downloader/__init__.py | 6 +++--- tests/test_downloaderslotssettings.py | 15 +++++++++------ 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/scrapy/core/downloader/__init__.py b/scrapy/core/downloader/__init__.py index 109a6eaec..be5efba87 100644 --- a/scrapy/core/downloader/__init__.py +++ b/scrapy/core/downloader/__init__.py @@ -4,7 +4,7 @@ import random from collections import deque from datetime import datetime from time import time -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any from twisted.internet.defer import Deferred, inlineCallbacks from twisted.python.failure import Failure @@ -173,8 +173,8 @@ class Downloader: return key, self.slots[key] def get_slot_key(self, request: Request) -> str: - if self.DOWNLOAD_SLOT in request.meta: - return cast("str", request.meta[self.DOWNLOAD_SLOT]) + if (meta_slot := request.meta.get(self.DOWNLOAD_SLOT)) is not None: + return meta_slot key = urlparse_cached(request).hostname or "" if self.ip_concurrency: diff --git a/tests/test_downloaderslotssettings.py b/tests/test_downloaderslotssettings.py index 960fcb30a..1d76ee611 100644 --- a/tests/test_downloaderslotssettings.py +++ b/tests/test_downloaderslotssettings.py @@ -1,4 +1,5 @@ import time +from typing import Any from twisted.internet.defer import inlineCallbacks @@ -28,24 +29,26 @@ class DownloaderSlotsSettingsTestSpider(MetaSpider): }, } + def __init__(self, *args: Any, **kwargs: Any): + super().__init__(*args, **kwargs) + self.default_slot = self.mockserver.host + self.times: dict[str, list[float]] = {} + async def start(self): - self.times = {None: []} - slots = [*self.custom_settings.get("DOWNLOAD_SLOTS", {}), None] - for slot in slots: url = self.mockserver.url(f"/?downloader_slot={slot}") - self.times[slot] = [] + self.times[slot or self.default_slot] = [] yield Request(url, callback=self.parse, meta={"download_slot": slot}) def parse(self, response): - slot = response.meta.get("download_slot", None) + slot = response.meta.get("download_slot", self.default_slot) self.times[slot].append(time.time()) url = self.mockserver.url(f"/?downloader_slot={slot}&req=2") yield Request(url, callback=self.not_parse, meta={"download_slot": slot}) def not_parse(self, response): - slot = response.meta.get("download_slot", None) + slot = response.meta.get("download_slot", self.default_slot) self.times[slot].append(time.time()) From 1a3e343dc41c6a639f8f719e1bfd9ae5181eacc7 Mon Sep 17 00:00:00 2001 From: DenisC Date: Thu, 11 Dec 2025 03:48:25 -0500 Subject: [PATCH 009/248] Update conftest.py to refactor autouse pytest.skip() fixtures into pytest_runtest_setup() (#7173) --- conftest.py | 88 ++++++++++++++++------------------------------------- 1 file changed, 27 insertions(+), 61 deletions(-) diff --git a/conftest.py b/conftest.py index 0dd9d1ee0..55b5a28c1 100644 --- a/conftest.py +++ b/conftest.py @@ -1,5 +1,6 @@ from __future__ import annotations +import importlib from pathlib import Path from typing import TYPE_CHECKING @@ -66,67 +67,6 @@ def reactor_pytest(request) -> str: return request.config.getoption("--reactor") -@pytest.fixture(autouse=True) -def only_asyncio(request, reactor_pytest): - if request.node.get_closest_marker("only_asyncio") and reactor_pytest != "asyncio": - pytest.skip("This test is only run with --reactor=asyncio") - - -@pytest.fixture(autouse=True) -def only_not_asyncio(request, reactor_pytest): - if ( - request.node.get_closest_marker("only_not_asyncio") - and reactor_pytest == "asyncio" - ): - pytest.skip("This test is only run without --reactor=asyncio") - - -@pytest.fixture(autouse=True) -def requires_uvloop(request): - if not request.node.get_closest_marker("requires_uvloop"): - return - try: - import uvloop # noqa: PLC0415 - - del uvloop - except ImportError: - pytest.skip("uvloop is not installed") - - -@pytest.fixture(autouse=True) -def requires_botocore(request): - if not request.node.get_closest_marker("requires_botocore"): - return - try: - import botocore # noqa: PLC0415 - - del botocore - except ImportError: - pytest.skip("botocore is not installed") - - -@pytest.fixture(autouse=True) -def requires_boto3(request): - if not request.node.get_closest_marker("requires_boto3"): - return - try: - import boto3 # noqa: PLC0415 - - del boto3 - except ImportError: - pytest.skip("boto3 is not installed") - - -@pytest.fixture(autouse=True) -def requires_mitmproxy(request): - if not request.node.get_closest_marker("requires_mitmproxy"): - return - try: - import mitmproxy # noqa: F401, PLC0415 - except ImportError: - pytest.skip("mitmproxy is not installed") - - def pytest_configure(config): if config.getoption("--reactor") == "asyncio": # Needed on Windows to switch from proactor to selector for Twisted reactor compatibility. @@ -134,5 +74,31 @@ def pytest_configure(config): set_asyncio_event_loop_policy() +def pytest_runtest_setup(item): + # Skip tests based on reactor markers + reactor = item.config.getoption("--reactor") + + if item.get_closest_marker("only_asyncio") and reactor != "asyncio": + pytest.skip("This test is only run with --reactor=asyncio") + + if item.get_closest_marker("only_not_asyncio") and reactor == "asyncio": + pytest.skip("This test is only run without --reactor=asyncio") + + # Skip tests requiring optional dependencies + optional_deps = [ + "uvloop", + "botocore", + "boto3", + "mitmproxy", + ] + + for module in optional_deps: + if item.get_closest_marker(f"requires_{module}"): + try: + importlib.import_module(module) + except ImportError: + pytest.skip(f"{module} is not installed") + + # Generate localhost certificate files, needed by some tests generate_keys() From d8583a89c7f9d5a444ef48039af1101c0cbfcef8 Mon Sep 17 00:00:00 2001 From: Thalison Fernandes Date: Thu, 11 Dec 2025 07:25:17 -0300 Subject: [PATCH 010/248] Change default priority queue to DownloaderAwarePriorityQueue (#6940) * Change default priority queue to DownloaderAwarePriorityQueue * Fix documentation building * Simplify test_start_already_running_exception changes. * Modernize the test. * Fix TestEngineCloseSpider. * Fix typing. * Remove special slot=None handling. --------- Co-authored-by: Andrey Rakhmatullin --- docs/topics/broad-crawls.rst | 13 ----------- docs/topics/settings.rst | 4 ++-- scrapy/settings/default_settings.py | 2 +- tests/test_downloaderslotssettings.py | 32 +++++++++++++++++++++++++++ tests/test_engine.py | 11 +++++++++ 5 files changed, 46 insertions(+), 16 deletions(-) diff --git a/docs/topics/broad-crawls.rst b/docs/topics/broad-crawls.rst index b4d058754..cace1f883 100644 --- a/docs/topics/broad-crawls.rst +++ b/docs/topics/broad-crawls.rst @@ -41,19 +41,6 @@ efficient broad crawl. .. _broad-crawls-scheduler-priority-queue: -Use the right :setting:`SCHEDULER_PRIORITY_QUEUE` -================================================= - -Scrapy’s default scheduler priority queue is ``'scrapy.pqueues.ScrapyPriorityQueue'``. -It works best during single-domain crawl. It does not work well with crawling -many different domains in parallel - -To apply the recommended priority queue use: - -.. code-block:: python - - SCHEDULER_PRIORITY_QUEUE = "scrapy.pqueues.DownloaderAwarePriorityQueue" - .. _broad-crawls-concurrency: Increase concurrency diff --git a/docs/topics/settings.rst b/docs/topics/settings.rst index a92070918..48ba573d7 100644 --- a/docs/topics/settings.rst +++ b/docs/topics/settings.rst @@ -1739,10 +1739,10 @@ Type of in-memory queue used by the scheduler. Other available type is: SCHEDULER_PRIORITY_QUEUE ------------------------ -Default: ``'scrapy.pqueues.ScrapyPriorityQueue'`` +Default: ``'scrapy.pqueues.DownloaderAwarePriorityQueue'`` Type of priority queue used by the scheduler. Another available type is -``scrapy.pqueues.DownloaderAwarePriorityQueue``. +``scrapy.pqueues.ScrapyPriorityQueue``. ``scrapy.pqueues.DownloaderAwarePriorityQueue`` works better than ``scrapy.pqueues.ScrapyPriorityQueue`` when you crawl many different domains in parallel. diff --git a/scrapy/settings/default_settings.py b/scrapy/settings/default_settings.py index e66f78c47..d741bf3ac 100644 --- a/scrapy/settings/default_settings.py +++ b/scrapy/settings/default_settings.py @@ -479,7 +479,7 @@ SCHEDULER = "scrapy.core.scheduler.Scheduler" SCHEDULER_DEBUG = False SCHEDULER_DISK_QUEUE = "scrapy.squeues.PickleLifoDiskQueue" SCHEDULER_MEMORY_QUEUE = "scrapy.squeues.LifoMemoryQueue" -SCHEDULER_PRIORITY_QUEUE = "scrapy.pqueues.ScrapyPriorityQueue" +SCHEDULER_PRIORITY_QUEUE = "scrapy.pqueues.DownloaderAwarePriorityQueue" SCHEDULER_START_DISK_QUEUE = "scrapy.squeues.PickleFifoDiskQueue" SCHEDULER_START_MEMORY_QUEUE = "scrapy.squeues.FifoMemoryQueue" diff --git a/tests/test_downloaderslotssettings.py b/tests/test_downloaderslotssettings.py index 1d76ee611..ff9604c36 100644 --- a/tests/test_downloaderslotssettings.py +++ b/tests/test_downloaderslotssettings.py @@ -1,11 +1,13 @@ import time from typing import Any +import pytest from twisted.internet.defer import inlineCallbacks from scrapy import Request from scrapy.core.downloader import Downloader, Slot from scrapy.crawler import CrawlerRunner +from scrapy.utils.defer import deferred_f_from_coro_f, maybe_deferred_to_future from scrapy.utils.spider import DefaultSpider from scrapy.utils.test import get_crawler from tests.mockserver.http import MockServer @@ -104,3 +106,33 @@ def test_params(): assert getattr(expected, param) == getattr(actual, param), ( f"Slot.{param}: {getattr(expected, param)!r} != {getattr(actual, param)!r}" ) + + +@pytest.mark.parametrize( + "priority_queue_class", + [ + "scrapy.pqueues.ScrapyPriorityQueue", + "scrapy.pqueues.DownloaderAwarePriorityQueue", + ], +) +@deferred_f_from_coro_f +async def test_none_slot_with_priority_queue( + mockserver: MockServer, priority_queue_class: str +) -> None: + """Test specific cases for None slot handling with different priority queues.""" + crawler = get_crawler( + DownloaderSlotsSettingsTestSpider, + settings_dict={"SCHEDULER_PRIORITY_QUEUE": priority_queue_class}, + ) + await maybe_deferred_to_future(crawler.crawl(mockserver=mockserver)) + assert isinstance(crawler.spider, DownloaderSlotsSettingsTestSpider) + + assert hasattr(crawler.spider, "times") + assert None not in crawler.spider.times + assert crawler.spider.default_slot in crawler.spider.times + assert len(crawler.spider.times[crawler.spider.default_slot]) == 2 + + assert crawler.stats + stats = crawler.stats + assert stats.get_value("spider_exceptions", 0) == 0 + assert stats.get_value("downloader/exception_count", 0) == 0 diff --git a/tests/test_engine.py b/tests/test_engine.py index 7e550d661..dc98364fc 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -438,6 +438,7 @@ class TestEngine(TestEngineBase): crawler = get_crawler(DefaultSpider) crawler.spider = crawler._create_spider() e = ExecutionEngine(crawler, lambda _: None) + crawler.engine = e yield deferred_from_coro(e.open_spider_async()) _schedule_coro(e.start_async()) with pytest.raises(RuntimeError, match="Engine already running"): @@ -450,6 +451,7 @@ class TestEngine(TestEngineBase): crawler = get_crawler(DefaultSpider) crawler.spider = crawler._create_spider() e = ExecutionEngine(crawler, lambda _: None) + crawler.engine = e await e.open_spider_async() with pytest.raises(RuntimeError, match="Engine already running"): await asyncio.gather(e.start_async(), e.start_async()) @@ -645,6 +647,7 @@ class TestEngineCloseSpider: @deferred_f_from_coro_f async def test_no_slot(self, crawler: Crawler) -> None: engine = ExecutionEngine(crawler, lambda _: None) + crawler.engine = engine await engine.open_spider_async() slot = engine._slot engine._slot = None @@ -666,6 +669,7 @@ class TestEngineCloseSpider: self, crawler: Crawler, caplog: pytest.LogCaptureFixture ) -> None: engine = ExecutionEngine(crawler, lambda _: None) + crawler.engine = engine await engine.open_spider_async() assert engine._slot del engine._slot.heartbeat @@ -677,6 +681,7 @@ class TestEngineCloseSpider: self, crawler: Crawler, caplog: pytest.LogCaptureFixture ) -> None: engine = ExecutionEngine(crawler, lambda _: None) + crawler.engine = engine await engine.open_spider_async() del engine.downloader.slots await engine.close_spider_async() @@ -687,6 +692,7 @@ class TestEngineCloseSpider: self, crawler: Crawler, caplog: pytest.LogCaptureFixture ) -> None: engine = ExecutionEngine(crawler, lambda _: None) + crawler.engine = engine await engine.open_spider_async() engine.scraper.slot = None await engine.close_spider_async() @@ -697,6 +703,7 @@ class TestEngineCloseSpider: self, crawler: Crawler, caplog: pytest.LogCaptureFixture ) -> None: engine = ExecutionEngine(crawler, lambda _: None) + crawler.engine = engine await engine.open_spider_async() assert engine._slot del cast("Scheduler", engine._slot.scheduler).dqs @@ -708,6 +715,7 @@ class TestEngineCloseSpider: self, crawler: Crawler, caplog: pytest.LogCaptureFixture ) -> None: engine = ExecutionEngine(crawler, lambda _: None) + crawler.engine = engine await engine.open_spider_async() signal_manager = engine.signals del engine.signals @@ -725,6 +733,7 @@ class TestEngineCloseSpider: self, crawler: Crawler, caplog: pytest.LogCaptureFixture ) -> None: engine = ExecutionEngine(crawler, lambda _: None) + crawler.engine = engine await engine.open_spider_async() del cast("MemoryStatsCollector", crawler.stats).spider_stats await engine.close_spider_async() @@ -735,6 +744,7 @@ class TestEngineCloseSpider: self, crawler: Crawler, caplog: pytest.LogCaptureFixture ) -> None: engine = ExecutionEngine(crawler, lambda _: defer.fail(ValueError())) + crawler.engine = engine await engine.open_spider_async() await engine.close_spider_async() assert "Error running spider_closed_callback" in caplog.text @@ -747,6 +757,7 @@ class TestEngineCloseSpider: raise ValueError engine = ExecutionEngine(crawler, cb) + crawler.engine = engine await engine.open_spider_async() await engine.close_spider_async() assert "Error running spider_closed_callback" in caplog.text From 588f3d4f65e292424d227e8a0498bcba2d395781 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Mon, 15 Dec 2025 13:51:56 +0500 Subject: [PATCH 011/248] Remove docs mentioning deprecated spider attrs. (#7176) --- docs/topics/commands.rst | 4 ++-- docs/topics/downloader-middleware.rst | 12 +++++------- docs/topics/settings.rst | 19 ++++++++++--------- docs/topics/spiders.rst | 6 ++---- 4 files changed, 19 insertions(+), 22 deletions(-) diff --git a/docs/topics/commands.rst b/docs/topics/commands.rst index 4994fe1d6..b8bf41402 100644 --- a/docs/topics/commands.rst +++ b/docs/topics/commands.rst @@ -163,8 +163,8 @@ information on which commands must be run from inside projects, and which not. Also keep in mind that some commands may have slightly different behaviours when running them from inside projects. For example, the fetch command will use -spider-overridden behaviours (such as the ``user_agent`` attribute to override -the user-agent) if the url being fetched is associated with some specific +spider-overridden behaviours (such as the ``custom_settings`` attribute to +override settings) if the url being fetched is associated with some specific spider. This is intentional, as the ``fetch`` command is meant to be used to check how spiders are downloading pages. diff --git a/docs/topics/downloader-middleware.rst b/docs/topics/downloader-middleware.rst index 6d24482ec..44c7ac17b 100644 --- a/docs/topics/downloader-middleware.rst +++ b/docs/topics/downloader-middleware.rst @@ -290,13 +290,12 @@ DownloadTimeoutMiddleware .. class:: DownloadTimeoutMiddleware This middleware sets the download timeout for requests specified in the - :setting:`DOWNLOAD_TIMEOUT` setting or :attr:`download_timeout` - spider attribute. + :setting:`DOWNLOAD_TIMEOUT` setting. .. note:: - You can also set download timeout per-request using - :reqmeta:`download_timeout` Request.meta key; this is supported + You can also set download timeout per-request using the + :reqmeta:`download_timeout` :attr:`.Request.meta` key; this is supported even when DownloadTimeoutMiddleware is disabled. HttpAuthMiddleware @@ -1212,9 +1211,8 @@ UserAgentMiddleware .. class:: UserAgentMiddleware - Middleware that allows spiders to override the default user agent. + Middleware that sets the ``User-Agent`` header. - In order for a spider to override the default user agent, its ``user_agent`` - attribute must be set. + The header value is taken from the :setting:`USER_AGENT` setting. .. _DBM: https://en.wikipedia.org/wiki/Dbm diff --git a/docs/topics/settings.rst b/docs/topics/settings.rst index 48ba573d7..64e5af250 100644 --- a/docs/topics/settings.rst +++ b/docs/topics/settings.rst @@ -1007,9 +1007,8 @@ The amount of time (in secs) that the downloader will wait before timing out. .. note:: - This timeout can be set per spider using :attr:`download_timeout` - spider attribute and per-request using :reqmeta:`download_timeout` - Request.meta key. + This timeout can be per-request using the :reqmeta:`download_timeout` + :attr:`.Request.meta` key. .. setting:: DOWNLOAD_MAXSIZE .. reqmeta:: download_maxsize @@ -1028,9 +1027,10 @@ ignored. Use ``0`` to disable this limit. -This limit can be set per spider using the :attr:`download_maxsize` spider -attribute and per request using the :reqmeta:`download_maxsize` Request.meta -key. +.. note:: + + This limit can be set per-request using the :reqmeta:`download_maxsize` + :attr:`.Request.meta` key. .. setting:: DOWNLOAD_WARNSIZE .. reqmeta:: download_warnsize @@ -1045,9 +1045,10 @@ warning will be logged about it. Use ``0`` to disable this limit. -This limit can be set per spider using the :attr:`download_warnsize` spider -attribute and per request using the :reqmeta:`download_warnsize` Request.meta -key. +.. note:: + + This limit can be set per-request using the :reqmeta:`download_warnsize` + :attr:`.Request.meta` key. .. setting:: DOWNLOAD_FAIL_ON_DATALOSS diff --git a/docs/topics/spiders.rst b/docs/topics/spiders.rst index 0a83b248e..b4dfb3757 100644 --- a/docs/topics/spiders.rst +++ b/docs/topics/spiders.rst @@ -355,11 +355,9 @@ Otherwise, you would cause iteration over a ``start_urls`` string resulting in each character being seen as a separate url. A valid use case is to set the http auth credentials -used by :class:`~scrapy.downloadermiddlewares.httpauth.HttpAuthMiddleware` -or the user agent -used by :class:`~scrapy.downloadermiddlewares.useragent.UserAgentMiddleware`:: +used by :class:`~scrapy.downloadermiddlewares.httpauth.HttpAuthMiddleware`:: - scrapy crawl myspider -a http_user=myuser -a http_pass=mypassword -a user_agent=mybot + scrapy crawl myspider -a http_user=myuser -a http_pass=mypassword Spider arguments can also be passed through the Scrapyd ``schedule.json`` API. See `Scrapyd documentation`_. From c49ae2115afd05afd7d0175f392e262e01b2779e Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Mon, 15 Dec 2025 14:02:04 +0500 Subject: [PATCH 012/248] Refactor MediaPipeline.process_item() to async def (#7177) --- docs/news.rst | 5 ++ scrapy/pipelines/files.py | 17 ++++-- scrapy/pipelines/images.py | 18 ++++-- scrapy/pipelines/media.py | 93 +++++++++++++++------------- scrapy/utils/defer.py | 17 ++---- tests/test_pipeline_files.py | 22 +++---- tests/test_pipeline_media.py | 113 +++++++++++++++-------------------- 7 files changed, 148 insertions(+), 137 deletions(-) diff --git a/docs/news.rst b/docs/news.rst index 96e5cb2b1..19d990a50 100644 --- a/docs/news.rst +++ b/docs/news.rst @@ -74,6 +74,11 @@ Backward-incompatible changes :class:`~scrapy.downloadermiddlewares.robotstxt.RobotsTxtMiddleware` or calls its methods directly. +- :meth:`scrapy.pipelines.media.MediaPipeline.process_item` now returns a + coroutine, previously it returned a + :class:`~twisted.internet.defer.Deferred` object. This + change only impacts code that calls this method directly. + .. _release-2.13.4: Scrapy 2.13.4 (2025-11-17) diff --git a/scrapy/pipelines/files.py b/scrapy/pipelines/files.py index 25af0aa72..ad4abbbb6 100644 --- a/scrapy/pipelines/files.py +++ b/scrapy/pipelines/files.py @@ -12,6 +12,7 @@ import hashlib import logging import mimetypes import time +import warnings from collections import defaultdict from contextlib import suppress from ftplib import FTP @@ -24,7 +25,7 @@ from itemadapter import ItemAdapter from twisted.internet.defer import Deferred, maybeDeferred from twisted.internet.threads import deferToThread -from scrapy.exceptions import IgnoreRequest, NotConfigured +from scrapy.exceptions import IgnoreRequest, NotConfigured, ScrapyDeprecationWarning from scrapy.http import Request, Response from scrapy.http.request import NO_CALLBACK from scrapy.pipelines.media import FileInfo, FileInfoOrError, MediaPipeline @@ -36,7 +37,6 @@ from scrapy.utils.python import to_bytes from scrapy.utils.request import referer_str if TYPE_CHECKING: - from collections.abc import Callable from os import PathLike from twisted.python.failure import Failure @@ -44,7 +44,6 @@ if TYPE_CHECKING: # typing.Self requires Python 3.11 from typing_extensions import Self - from scrapy import Spider from scrapy.crawler import Crawler from scrapy.settings import BaseSettings @@ -443,10 +442,18 @@ class FilesPipeline(MediaPipeline): def __init__( self, store_uri: str | PathLike[str], - download_func: Callable[[Request, Spider], Response] | None = None, + download_func: None = None, *, crawler: Crawler, ): + if download_func is not None: # pragma: no cover + warnings.warn( + "The download_func argument of FilesPipeline.__init__() is ignored" + " and will be removed in a future Scrapy version.", + category=ScrapyDeprecationWarning, + stacklevel=2, + ) + if not (store_uri and (store_uri := _to_string(store_uri))): from scrapy.pipelines.images import ImagesPipeline # noqa: PLC0415 @@ -476,7 +483,7 @@ class FilesPipeline(MediaPipeline): resolve("FILES_RESULT_FIELD"), self.FILES_RESULT_FIELD ) - super().__init__(download_func=download_func, crawler=crawler) + super().__init__(crawler=crawler) @classmethod def from_crawler(cls, crawler: Crawler) -> Self: diff --git a/scrapy/pipelines/images.py b/scrapy/pipelines/images.py index 687d29fa5..f5d1ad1b9 100644 --- a/scrapy/pipelines/images.py +++ b/scrapy/pipelines/images.py @@ -8,20 +8,21 @@ from __future__ import annotations import functools import hashlib +import warnings from contextlib import suppress from io import BytesIO from typing import TYPE_CHECKING, Any from itemadapter import ItemAdapter -from scrapy.exceptions import NotConfigured +from scrapy.exceptions import NotConfigured, ScrapyDeprecationWarning from scrapy.http import Request, Response from scrapy.http.request import NO_CALLBACK from scrapy.pipelines.files import FileException, FilesPipeline, _md5sum from scrapy.utils.python import to_bytes if TYPE_CHECKING: - from collections.abc import Callable, Iterable + from collections.abc import Iterable from os import PathLike from PIL import Image @@ -29,7 +30,6 @@ if TYPE_CHECKING: # typing.Self requires Python 3.11 from typing_extensions import Self - from scrapy import Spider from scrapy.crawler import Crawler from scrapy.pipelines.media import FileInfoOrError, MediaPipeline @@ -55,10 +55,18 @@ class ImagesPipeline(FilesPipeline): def __init__( self, store_uri: str | PathLike[str], - download_func: Callable[[Request, Spider], Response] | None = None, + download_func: None = None, *, crawler: Crawler, ): + if download_func is not None: # pragma: no cover + warnings.warn( + "The download_func argument of ImagesPipeline.__init__() is ignored" + " and will be removed in a future Scrapy version.", + category=ScrapyDeprecationWarning, + stacklevel=2, + ) + try: from PIL import Image, ImageOps # noqa: PLC0415 @@ -69,7 +77,7 @@ class ImagesPipeline(FilesPipeline): "ImagesPipeline requires installing Pillow 8.3.2 or later" ) - super().__init__(store_uri, download_func=download_func, crawler=crawler) + super().__init__(store_uri, crawler=crawler) settings = crawler.settings resolve = functools.partial( diff --git a/scrapy/pipelines/media.py b/scrapy/pipelines/media.py index 438e5e7d6..cea6b9c35 100644 --- a/scrapy/pipelines/media.py +++ b/scrapy/pipelines/media.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio import functools import logging import warnings @@ -8,28 +9,27 @@ from collections import defaultdict from typing import TYPE_CHECKING, Any, Literal, TypeAlias, TypedDict, cast from twisted import version as twisted_version -from twisted.internet.defer import ( - Deferred, - DeferredList, - inlineCallbacks, - maybeDeferred, -) +from twisted.internet.defer import Deferred, DeferredList from twisted.python.failure import Failure from twisted.python.versions import Version from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.http.request import NO_CALLBACK, Request -from scrapy.utils.asyncio import call_later +from scrapy.utils.asyncio import call_later, is_asyncio_available from scrapy.utils.datatypes import SequenceExclude from scrapy.utils.decorators import _warn_spider_arg -from scrapy.utils.defer import _DEFER_DELAY, _defer_sleep, deferred_from_coro +from scrapy.utils.defer import ( + _DEFER_DELAY, + _defer_sleep_async, + deferred_from_coro, + ensure_awaitable, + maybe_deferred_to_future, +) from scrapy.utils.log import failure_to_exc_info from scrapy.utils.misc import arg_to_iter from scrapy.utils.python import global_object_name if TYPE_CHECKING: - from collections.abc import Callable, Generator - # typing.Self requires Python 3.11 from typing_extensions import Self @@ -68,16 +68,22 @@ class MediaPipeline(ABC): def __init__( self, - download_func: Callable[[Request, Spider], Response] | None = None, + download_func: None = None, *, crawler: Crawler, ): + if download_func is not None: # pragma: no cover + warnings.warn( + "The download_func argument of MediaPipeline.__init__() is ignored" + " and will be removed in a future Scrapy version.", + category=ScrapyDeprecationWarning, + stacklevel=2, + ) self.crawler: Crawler = crawler assert crawler.request_fingerprinter self._fingerprinter: RequestFingerprinterProtocol = ( crawler.request_fingerprinter ) - self.download_func = download_func settings = crawler.settings resolve = functools.partial( @@ -119,21 +125,31 @@ class MediaPipeline(ABC): self.spiderinfo = self.SpiderInfo(self.crawler.spider) @_warn_spider_arg - def process_item( - self, item: Any, spider: Spider | None = None - ) -> Deferred[list[FileInfoOrError]]: + async def process_item(self, item: Any, spider: Spider | None = None) -> Any: info = self.spiderinfo requests = arg_to_iter(self.get_media_requests(item, info)) - dlist = [self._process_request(r, info, item) for r in requests] - dfd = cast( - "Deferred[list[FileInfoOrError]]", DeferredList(dlist, consumeErrors=True) - ) - return dfd.addCallback(self.item_completed, item, info) + coros = [self._process_request(r, info, item) for r in requests] + results: list[FileInfoOrError] = [] + if coros: + if is_asyncio_available(): + results_asyncio = await asyncio.gather(*coros, return_exceptions=True) + for res in results_asyncio: + if isinstance(res, BaseException): + results.append((False, Failure(res))) + else: + results.append((True, res)) + else: + results = await cast( + "Deferred[list[FileInfoOrError]]", + DeferredList( + (deferred_from_coro(coro) for coro in coros), consumeErrors=True + ), + ) + return self.item_completed(results, item, info) - @inlineCallbacks - def _process_request( + async def _process_request( self, request: Request, info: SpiderInfo, item: Any - ) -> Generator[Deferred[Any], Any, FileInfo]: + ) -> FileInfo: fp = self._fingerprinter.fingerprint(request) eb = request.errback @@ -142,7 +158,7 @@ class MediaPipeline(ABC): # Return cached result if request was already seen if fp in info.downloaded: - yield _defer_sleep() + await _defer_sleep_async() cached_result = info.downloaded[fp] if isinstance(cached_result, Failure): if eb: @@ -158,27 +174,27 @@ class MediaPipeline(ABC): # Check if request is downloading right now to avoid doing it twice if fp in info.downloading: - return (yield wad) + return await maybe_deferred_to_future(wad) # Download request checking media_to_download hook output first info.downloading.add(fp) - yield _defer_sleep() + await _defer_sleep_async() result: FileInfo | Failure try: - file_info = yield maybeDeferred( - self.media_to_download, request, info, item=item + file_info: FileInfo | None = await ensure_awaitable( + self.media_to_download(request, info, item=item) ) if file_info: # got a result without downloading result = file_info else: # download the result - result = yield self._check_media_to_download(request, info, item=item) + result = await self._check_media_to_download(request, info, item=item) except Exception: result = Failure() logger.exception(result) self._cache_result_and_execute_waiters(result, fp, info) - return (yield wad) # it must return wad at last + return await maybe_deferred_to_future(wad) # it must return wad at last def _modify_media_request(self, request: Request) -> None: if self.handle_httpstatus_list: @@ -186,20 +202,13 @@ class MediaPipeline(ABC): else: request.meta["handle_httpstatus_all"] = True - @inlineCallbacks - def _check_media_to_download( # pylint: disable=inconsistent-return-statements + async def _check_media_to_download( self, request: Request, info: SpiderInfo, item: Any - ) -> Generator[Deferred[Any], Any, FileInfo]: + ) -> FileInfo: try: - if self.download_func: - # this ugly code was left only to support tests. TODO: remove - response = yield maybeDeferred(self.download_func, request, info.spider) - else: - self._modify_media_request(request) - assert self.crawler.engine - response = yield deferred_from_coro( - self.crawler.engine.download_async(request) - ) + self._modify_media_request(request) + assert self.crawler.engine + response = await self.crawler.engine.download_async(request) return self.media_downloaded(response, request, info, item=item) except Exception: failure = self.media_failed(Failure(), request, info) diff --git a/scrapy/utils/defer.py b/scrapy/utils/defer.py index c27f8bb34..2a09f99bb 100644 --- a/scrapy/utils/defer.py +++ b/scrapy/utils/defer.py @@ -26,7 +26,7 @@ from twisted.internet.task import Cooperator from twisted.python import failure from scrapy.exceptions import ScrapyDeprecationWarning -from scrapy.utils.asyncio import call_later, is_asyncio_available +from scrapy.utils.asyncio import is_asyncio_available if TYPE_CHECKING: from collections.abc import AsyncIterator, Callable @@ -84,15 +84,6 @@ def defer_succeed(result: _T) -> Deferred[_T]: return d -def _defer_sleep() -> Deferred[None]: - """Delay by _DEFER_DELAY so reactor has a chance to go through readers and writers - before attending pending delayed calls, so do not set delay to zero. - """ - d: Deferred[None] = Deferred() - call_later(_DEFER_DELAY, d.callback, None) - return d - - async def _defer_sleep_async() -> None: """Delay by _DEFER_DELAY so reactor has a chance to go through readers and writers before attending pending delayed calls, so do not set delay to zero. @@ -100,7 +91,11 @@ async def _defer_sleep_async() -> None: if is_asyncio_available(): await asyncio.sleep(_DEFER_DELAY) else: - await _defer_sleep() + from twisted.internet import reactor + + d: Deferred[None] = Deferred() + reactor.callLater(_DEFER_DELAY, d.callback, None) + await d def defer_result(result: Any) -> Deferred[Any]: diff --git a/tests/test_pipeline_files.py b/tests/test_pipeline_files.py index 966cb320e..6ab5d9a8e 100644 --- a/tests/test_pipeline_files.py +++ b/tests/test_pipeline_files.py @@ -13,6 +13,7 @@ from shutil import rmtree from tempfile import mkdtemp from typing import Any from unittest import mock +from unittest.mock import MagicMock from urllib.parse import urlparse import attr @@ -31,6 +32,7 @@ from scrapy.pipelines.files import ( S3FilesStore, ) from scrapy.settings import Settings +from scrapy.utils.defer import deferred_f_from_coro_f from scrapy.utils.spider import DefaultSpider from scrapy.utils.test import get_crawler from tests.mockserver.ftp import MockFTPServer @@ -83,8 +85,8 @@ class TestFilesPipeline: settings_dict = {"FILES_STORE": self.tempdir} crawler = get_crawler(DefaultSpider, settings_dict=settings_dict) crawler.spider = crawler._create_spider() + crawler.engine = MagicMock(download_async=_mocked_download_func) self.pipeline = FilesPipeline.from_crawler(crawler) - self.pipeline.download_func = _mocked_download_func self.pipeline.open_spider() def teardown_method(self): @@ -160,8 +162,8 @@ class TestFilesPipeline: fullpath = Path(self.tempdir, "some", "image", "key.jpg") assert self.pipeline.store._get_filesystem_path(path) == fullpath - @inlineCallbacks - def test_file_not_expired(self): + @deferred_f_from_coro_f + async def test_file_not_expired(self): item_url = "http://example.com/file.pdf" item = _create_item_with_files(item_url) patchers = [ @@ -180,15 +182,15 @@ class TestFilesPipeline: for p in patchers: p.start() - result = yield self.pipeline.process_item(item) + result = await self.pipeline.process_item(item) assert result["files"][0]["checksum"] == "abc" assert result["files"][0]["status"] == "uptodate" for p in patchers: p.stop() - @inlineCallbacks - def test_file_expired(self): + @deferred_f_from_coro_f + async def test_file_expired(self): item_url = "http://example.com/file2.pdf" item = _create_item_with_files(item_url) patchers = [ @@ -211,15 +213,15 @@ class TestFilesPipeline: for p in patchers: p.start() - result = yield self.pipeline.process_item(item) + result = await self.pipeline.process_item(item) assert result["files"][0]["checksum"] != "abc" assert result["files"][0]["status"] == "downloaded" for p in patchers: p.stop() - @inlineCallbacks - def test_file_cached(self): + @deferred_f_from_coro_f + async def test_file_cached(self): item_url = "http://example.com/file3.pdf" item = _create_item_with_files(item_url) patchers = [ @@ -242,7 +244,7 @@ class TestFilesPipeline: for p in patchers: p.start() - result = yield self.pipeline.process_item(item) + result = await self.pipeline.process_item(item) assert result["files"][0]["checksum"] != "abc" assert result["files"][0]["status"] == "cached" diff --git a/tests/test_pipeline_media.py b/tests/test_pipeline_media.py index 9f174cc5f..05ad43d16 100644 --- a/tests/test_pipeline_media.py +++ b/tests/test_pipeline_media.py @@ -1,10 +1,10 @@ from __future__ import annotations import warnings +from unittest.mock import MagicMock import pytest from testfixtures import LogCapture -from twisted.internet.defer import Deferred, inlineCallbacks from twisted.python.failure import Failure from scrapy import signals @@ -13,17 +13,21 @@ from scrapy.http import Request, Response from scrapy.http.request import NO_CALLBACK from scrapy.pipelines.files import FileException from scrapy.pipelines.media import MediaPipeline -from scrapy.utils.asyncio import call_later +from scrapy.utils.defer import _defer_sleep_async, deferred_f_from_coro_f from scrapy.utils.log import failure_to_exc_info from scrapy.utils.signal import disconnect_all from scrapy.utils.spider import DefaultSpider from scrapy.utils.test import get_crawler -def _mocked_download_func(request, info): +async def _mocked_download_func(request): assert request.callback is NO_CALLBACK response = request.meta.get("response") - return response() if callable(response) else response + if callable(response): + response = await response() + if isinstance(response, Exception): + raise response + return response class UserDefinedPipeline(MediaPipeline): @@ -50,8 +54,8 @@ class TestBaseMediaPipeline: def setup_method(self): crawler = get_crawler(DefaultSpider, self.settings) crawler.spider = crawler._create_spider() + crawler.engine = MagicMock(download_async=_mocked_download_func) self.pipe = self.pipeline_class.from_crawler(crawler) - self.pipe.download_func = _mocked_download_func self.pipe.open_spider() self.info = self.pipe.spiderinfo self.fingerprint = crawler.request_fingerprinter.fingerprint @@ -158,10 +162,10 @@ class TestBaseMediaPipeline: assert new_item is item assert len(log.records) == 0 - @inlineCallbacks - def test_default_process_item(self): + @deferred_f_from_coro_f + async def test_default_process_item(self): item = {"name": "name"} - new_item = yield self.pipe.process_item(item) + new_item = await self.pipe.process_item(item) assert new_item is item @@ -206,8 +210,8 @@ class TestMediaPipeline(TestBaseMediaPipeline): self.pipe._mockcalled.append("request_errback") return result - @inlineCallbacks - def test_result_succeed(self): + @deferred_f_from_coro_f + async def test_result_succeed(self): rsp = Response("http://url1") req = Request( "http://url1", @@ -215,7 +219,7 @@ class TestMediaPipeline(TestBaseMediaPipeline): errback=self._errback, ) item = {"requests": req} - new_item = yield self.pipe.process_item(item) + new_item = await self.pipe.process_item(item) assert new_item["results"] == [(True, {})] assert self.pipe._mockcalled == [ "get_media_requests", @@ -224,18 +228,17 @@ class TestMediaPipeline(TestBaseMediaPipeline): "item_completed", ] - @inlineCallbacks - def test_result_failure(self): + @deferred_f_from_coro_f + async def test_result_failure(self): self.pipe.LOG_FAILED_RESULTS = False exc = Exception("foo") - fail = Failure(exc) req = Request( "http://url1", - meta={"response": fail}, + meta={"response": exc}, errback=self._errback, ) item = {"requests": req} - new_item = yield self.pipe.process_item(item) + new_item = await self.pipe.process_item(item) assert len(new_item["results"]) == 1 assert new_item["results"][0][0] is False assert isinstance(new_item["results"][0][1], Failure) @@ -248,16 +251,15 @@ class TestMediaPipeline(TestBaseMediaPipeline): "item_completed", ] - @inlineCallbacks - def test_mix_of_success_and_failure(self): + @deferred_f_from_coro_f + async def test_mix_of_success_and_failure(self): self.pipe.LOG_FAILED_RESULTS = False rsp1 = Response("http://url1") req1 = Request("http://url1", meta={"response": rsp1}) exc = Exception("foo") - fail = Failure(exc) - req2 = Request("http://url2", meta={"response": fail}) + req2 = Request("http://url2", meta={"response": exc}) item = {"requests": [req1, req2]} - new_item = yield self.pipe.process_item(item) + new_item = await self.pipe.process_item(item) assert len(new_item["results"]) == 2 assert new_item["results"][0] == (True, {}) assert new_item["results"][1][0] is False @@ -275,12 +277,12 @@ class TestMediaPipeline(TestBaseMediaPipeline): assert m.count("media_downloaded") == 1 assert m.count("media_failed") == 1 - @inlineCallbacks - def test_get_media_requests(self): + @deferred_f_from_coro_f + async def test_get_media_requests(self): # returns single Request (without callback) req = Request("http://url") item = {"requests": req} # pass a single item - new_item = yield self.pipe.process_item(item) + new_item = await self.pipe.process_item(item) assert new_item is item assert self.fingerprint(req) in self.info.downloaded @@ -288,17 +290,17 @@ class TestMediaPipeline(TestBaseMediaPipeline): req1 = Request("http://url1") req2 = Request("http://url2") item = {"requests": iter([req1, req2])} - new_item = yield self.pipe.process_item(item) + new_item = await self.pipe.process_item(item) assert new_item is item assert self.fingerprint(req1) in self.info.downloaded assert self.fingerprint(req2) in self.info.downloaded - @inlineCallbacks - def test_results_are_cached_across_multiple_items(self): + @deferred_f_from_coro_f + async def test_results_are_cached_across_multiple_items(self): rsp1 = Response("http://url1") req1 = Request("http://url1", meta={"response": rsp1}) item = {"requests": req1} - new_item = yield self.pipe.process_item(item) + new_item = await self.pipe.process_item(item) assert new_item is item assert new_item["results"] == [(True, {})] @@ -307,25 +309,25 @@ class TestMediaPipeline(TestBaseMediaPipeline): req1.url, meta={"response": Response("http://donot.download.me")} ) item = {"requests": req2} - new_item = yield self.pipe.process_item(item) + new_item = await self.pipe.process_item(item) assert new_item is item assert self.fingerprint(req1) == self.fingerprint(req2) assert new_item["results"] == [(True, {})] - @inlineCallbacks - def test_results_are_cached_for_requests_of_single_item(self): + @deferred_f_from_coro_f + async def test_results_are_cached_for_requests_of_single_item(self): rsp1 = Response("http://url1") req1 = Request("http://url1", meta={"response": rsp1}) req2 = Request( req1.url, meta={"response": Response("http://donot.download.me")} ) item = {"requests": [req1, req2]} - new_item = yield self.pipe.process_item(item) + new_item = await self.pipe.process_item(item) assert new_item is item assert new_item["results"] == [(True, {}), (True, {})] - @inlineCallbacks - def test_wait_if_request_is_downloading(self): + @deferred_f_from_coro_f + async def test_wait_if_request_is_downloading(self): def _check_downloading(response): fp = self.fingerprint(req1) assert fp in self.info.downloading @@ -336,25 +338,24 @@ class TestMediaPipeline(TestBaseMediaPipeline): rsp1 = Response("http://url") - def rsp1_func(): - dfd = Deferred().addCallback(_check_downloading) - call_later(0.1, dfd.callback, rsp1) - return dfd + async def rsp1_func(): + await _defer_sleep_async() + _check_downloading(rsp1) - def rsp2_func(): + async def rsp2_func(): pytest.fail("it must cache rsp1 result and must not try to redownload") req1 = Request("http://url", meta={"response": rsp1_func}) req2 = Request(req1.url, meta={"response": rsp2_func}) item = {"requests": [req1, req2]} - new_item = yield self.pipe.process_item(item) + new_item = await self.pipe.process_item(item) assert new_item["results"] == [(True, {}), (True, {})] - @inlineCallbacks - def test_use_media_to_download_result(self): + @deferred_f_from_coro_f + async def test_use_media_to_download_result(self): req = Request("http://url", meta={"result": "ITSME"}) item = {"requests": req} - new_item = yield self.pipe.process_item(item) + new_item = await self.pipe.process_item(item) assert new_item["results"] == [(True, "ITSME")] assert self.pipe._mockcalled == [ "get_media_requests", @@ -469,45 +470,29 @@ class MediaFailedFailurePipeline(MockedMediaPipeline): return failure # deprecated -class TestMediaFailedFailure: +class TestMediaFailedFailure(TestBaseMediaPipeline): """Test that media_failed() can return a failure instead of raising.""" pipeline_class = MediaFailedFailurePipeline - settings = None - - def setup_method(self): - crawler = get_crawler(DefaultSpider, self.settings) - crawler.spider = crawler._create_spider() - self.pipe = self.pipeline_class.from_crawler(crawler) - self.pipe.download_func = _mocked_download_func - self.pipe.open_spider() - self.info = self.pipe.spiderinfo - self.fingerprint = crawler.request_fingerprinter.fingerprint - - def teardown_method(self): - for name, signal in vars(signals).items(): - if not name.startswith("_"): - disconnect_all(signal) def _errback(self, result): self.pipe._mockcalled.append("request_errback") return result - @inlineCallbacks - def test_result_failure(self): + @deferred_f_from_coro_f + async def test_result_failure(self): self.pipe.LOG_FAILED_RESULTS = False exc = Exception("foo") - fail = Failure(exc) req = Request( "http://url1", - meta={"response": fail}, + meta={"response": exc}, errback=self._errback, ) item = {"requests": req} with pytest.warns( ScrapyDeprecationWarning, match="media_failed returned a Failure instance" ): - new_item = yield self.pipe.process_item(item) + new_item = await self.pipe.process_item(item) assert len(new_item["results"]) == 1 assert new_item["results"][0][0] is False assert isinstance(new_item["results"][0][1], Failure) From 5a7e132486f3337956f684fffcac77cb6ad5a8d2 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Mon, 15 Dec 2025 15:42:24 +0500 Subject: [PATCH 013/248] Restore and deprecate the spider arg of Downloader._get_slot(). (#7178) --- scrapy/core/downloader/__init__.py | 5 ++++- tests/test_downloaderslotssettings.py | 19 +++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/scrapy/core/downloader/__init__.py b/scrapy/core/downloader/__init__.py index be5efba87..db4e41c6a 100644 --- a/scrapy/core/downloader/__init__.py +++ b/scrapy/core/downloader/__init__.py @@ -151,7 +151,10 @@ class Downloader: def needs_backout(self) -> bool: return len(self.active) >= self.total_concurrency - def _get_slot(self, request: Request) -> tuple[str, Slot]: + @_warn_spider_arg + def _get_slot( + self, request: Request, spider: Spider | None = None + ) -> tuple[str, Slot]: key = self.get_slot_key(request) if key not in self.slots: assert self.crawler.spider diff --git a/tests/test_downloaderslotssettings.py b/tests/test_downloaderslotssettings.py index ff9604c36..5d88b59c1 100644 --- a/tests/test_downloaderslotssettings.py +++ b/tests/test_downloaderslotssettings.py @@ -7,6 +7,7 @@ from twisted.internet.defer import inlineCallbacks from scrapy import Request from scrapy.core.downloader import Downloader, Slot from scrapy.crawler import CrawlerRunner +from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.utils.defer import deferred_f_from_coro_f, maybe_deferred_to_future from scrapy.utils.spider import DefaultSpider from scrapy.utils.test import get_crawler @@ -108,6 +109,24 @@ def test_params(): ) +def test_get_slot_deprecated_spider_arg(): + crawler = get_crawler(DefaultSpider) + crawler.spider = crawler._create_spider() + downloader = Downloader(crawler) + downloader._slot_gc_loop.stop() # Prevent an unclean reactor. + request = Request("https://example.com") + + with pytest.warns( + ScrapyDeprecationWarning, + match=r"Passing a 'spider' argument to Downloader\._get_slot\(\) is deprecated", + ): + key1, slot1 = downloader._get_slot(request, spider=crawler.spider) + key2, slot2 = downloader._get_slot(request) + + assert key1 == key2 + assert slot1 == slot2 + + @pytest.mark.parametrize( "priority_queue_class", [ From 180ca39b230590a2a2862c8d18c574661b1d16ad Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Tue, 16 Dec 2025 13:35:07 +0500 Subject: [PATCH 014/248] Deprecate returning Deferreds from pipeline methods (#7179) * Add tests for exceptions in pipelines. * Deprecate returning Deferreds from pipeline process_item(). * Deprecate returning Deferreds from pipeline {open,close}_spider(). * Update the custom pipeline docs. --- docs/topics/item-pipeline.rst | 7 +- scrapy/middleware.py | 8 +- scrapy/pipelines/__init__.py | 59 ++++++++++--- scrapy/utils/defer.py | 7 ++ tests/test_pipelines.py | 161 +++++++++++++++++++++++++--------- 5 files changed, 182 insertions(+), 60 deletions(-) diff --git a/docs/topics/item-pipeline.rst b/docs/topics/item-pipeline.rst index e67cf06c8..8194fe043 100644 --- a/docs/topics/item-pipeline.rst +++ b/docs/topics/item-pipeline.rst @@ -33,9 +33,8 @@ implement the following method: `item` is an :ref:`item object `, see :ref:`supporting-item-types`. - :meth:`process_item` must either: return an :ref:`item object `, - return a :class:`~twisted.internet.defer.Deferred` or raise a - :exc:`~scrapy.exceptions.DropItem` exception. + :meth:`process_item` must either return an :ref:`item object ` + or raise a :exc:`~scrapy.exceptions.DropItem` exception. Dropped items are no longer processed by further pipeline components. @@ -52,6 +51,8 @@ Additionally, they may also implement the following methods: This method is called when the spider is closed. +Any of these methods may be defined as a coroutine function (``async def``). + Item pipeline example ===================== diff --git a/scrapy/middleware.py b/scrapy/middleware.py index 83362784b..c4e0ce16e 100644 --- a/scrapy/middleware.py +++ b/scrapy/middleware.py @@ -138,17 +138,21 @@ class MiddlewareManager(ABC): *args: Any, add_spider: bool = False, always_add_spider: bool = False, + warn_deferred: bool = False, ) -> _T: methods = cast( "Iterable[Callable[Concatenate[_T, _P], _T]]", self.methods[methodname] ) for method in methods: + warn = global_object_name(method) if warn_deferred else None if always_add_spider or ( add_spider and method in self._mw_methods_requiring_spider ): - obj = await ensure_awaitable(method(obj, *(*args, self._spider))) + obj = await ensure_awaitable( + method(obj, *(*args, self._spider)), _warn=warn + ) else: - obj = await ensure_awaitable(method(obj, *args)) + obj = await ensure_awaitable(method(obj, *args), _warn=warn) return obj def open_spider(self, spider: Spider) -> Deferred[list[None]]: # pragma: no cover diff --git a/scrapy/pipelines/__init__.py b/scrapy/pipelines/__init__.py index 398e895b8..21ad6fb1a 100644 --- a/scrapy/pipelines/__init__.py +++ b/scrapy/pipelines/__init__.py @@ -6,6 +6,7 @@ See documentation in docs/item-pipeline.rst from __future__ import annotations +import asyncio import warnings from typing import TYPE_CHECKING, Any, cast @@ -13,16 +14,13 @@ from twisted.internet.defer import Deferred, DeferredList from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.middleware import MiddlewareManager +from scrapy.utils.asyncio import is_asyncio_available from scrapy.utils.conf import build_component_list -from scrapy.utils.defer import ( - deferred_from_coro, - maybe_deferred_to_future, - maybeDeferred_coro, -) +from scrapy.utils.defer import deferred_from_coro, ensure_awaitable, maybeDeferred_coro from scrapy.utils.python import global_object_name if TYPE_CHECKING: - from collections.abc import Callable, Iterable + from collections.abc import Awaitable, Callable, Coroutine, Iterable from twisted.python.failure import Failure @@ -58,12 +56,19 @@ class ItemPipelineManager(MiddlewareManager): return deferred_from_coro(self.process_item_async(item)) async def process_item_async(self, item: Any) -> Any: - return await self._process_chain("process_item", item, add_spider=True) + return await self._process_chain( + "process_item", item, add_spider=True, warn_deferred=True + ) - def _process_parallel(self, methodname: str) -> Deferred[list[None]]: - methods = cast("Iterable[Callable[..., None]]", self.methods[methodname]) + def _process_parallel_dfd(self, methodname: str) -> Deferred[list[None]]: + methods = cast( + "Iterable[Callable[..., Coroutine[Any, Any, None] | Deferred[None] | None]]", + self.methods[methodname], + ) - def get_dfd(method: Callable[..., None]) -> Deferred[None]: + def get_dfd( + method: Callable[..., Coroutine[Any, Any, None] | Deferred[None] | None], + ) -> Deferred[None]: if method in self._mw_methods_requiring_spider: return maybeDeferred_coro(method, self._spider) return maybeDeferred_coro(method) @@ -80,6 +85,32 @@ class ItemPipelineManager(MiddlewareManager): d2.addErrback(eb) return d2 + async def _process_parallel_asyncio(self, methodname: str) -> list[None]: + methods = cast( + "Iterable[Callable[..., Coroutine[Any, Any, None] | Deferred[None] | None]]", + self.methods[methodname], + ) + if not methods: + return [] + + def get_awaitable( + method: Callable[..., Coroutine[Any, Any, None] | Deferred[None] | None], + ) -> Awaitable[None]: + if method in self._mw_methods_requiring_spider: + result = method(self._spider) + else: + result = method() + return ensure_awaitable(result, _warn=global_object_name(method)) + + awaitables = [get_awaitable(m) for m in methods] + await asyncio.gather(*awaitables) + return [None for _ in methods] + + async def _process_parallel(self, methodname: str) -> list[None]: + if is_asyncio_available(): + return await self._process_parallel_asyncio(methodname) + return await self._process_parallel_dfd(methodname) + def open_spider(self, spider: Spider) -> Deferred[list[None]]: warnings.warn( f"{global_object_name(type(self))}.open_spider() is deprecated, use open_spider_async() instead.", @@ -87,10 +118,10 @@ class ItemPipelineManager(MiddlewareManager): stacklevel=2, ) self._set_compat_spider(spider) - return self._process_parallel("open_spider") + return deferred_from_coro(self._process_parallel("open_spider")) async def open_spider_async(self) -> None: - await maybe_deferred_to_future(self._process_parallel("open_spider")) + await self._process_parallel("open_spider") def close_spider(self, spider: Spider) -> Deferred[list[None]]: warnings.warn( @@ -99,7 +130,7 @@ class ItemPipelineManager(MiddlewareManager): stacklevel=2, ) self._set_compat_spider(spider) - return self._process_parallel("close_spider") + return deferred_from_coro(self._process_parallel("close_spider")) async def close_spider_async(self) -> None: - await maybe_deferred_to_future(self._process_parallel("close_spider")) + await self._process_parallel("close_spider") diff --git a/scrapy/utils/defer.py b/scrapy/utils/defer.py index 2a09f99bb..afbe680ef 100644 --- a/scrapy/utils/defer.py +++ b/scrapy/utils/defer.py @@ -27,6 +27,7 @@ from twisted.python import failure from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.utils.asyncio import is_asyncio_available +from scrapy.utils.python import global_object_name if TYPE_CHECKING: from collections.abc import AsyncIterator, Callable @@ -426,6 +427,12 @@ def maybeDeferred_coro( return fail(failure.Failure(captureVars=Deferred.debug)) if isinstance(result, Deferred): + warnings.warn( + f"{global_object_name(f)} returned a Deferred, this is deprecated." + f" Please refactor this function to return a coroutine.", + ScrapyDeprecationWarning, + stacklevel=2, + ) return result if asyncio.isfuture(result) or inspect.isawaitable(result): return deferred_from_coro(result) diff --git a/tests/test_pipelines.py b/tests/test_pipelines.py index 8db9431ea..fc61d61ec 100644 --- a/tests/test_pipelines.py +++ b/tests/test_pipelines.py @@ -1,7 +1,8 @@ import asyncio +from typing import Any import pytest -from twisted.internet.defer import Deferred, inlineCallbacks, succeed +from twisted.internet.defer import Deferred, fail, succeed from scrapy import Request, Spider, signals from scrapy.crawler import Crawler @@ -42,6 +43,12 @@ class DeferredPipeline: item["pipeline_passed"] = True return item + def open_spider(self): + return succeed(None) + + def close_spider(self): + return succeed(None) + def process_item(self, item): d = Deferred() d.addCallback(self.cb) @@ -83,6 +90,36 @@ class AsyncDefNotAsyncioPipeline: return item +class ProcessItemExceptionPipeline: + def process_item(self, item): + raise ValueError("process_item error") + + +class ProcessItemExceptionDeferredPipeline: + def process_item(self, item): + return fail(ValueError("process_item error")) + + +class ProcessItemExceptionAsyncPipeline: + async def process_item(self, item): + raise ValueError("process_item error") + + +class OpenSpiderExceptionPipeline: + def open_spider(self): + raise ValueError("open_spider error") + + +class OpenSpiderExceptionDeferredPipeline: + def open_spider(self): + return fail(ValueError("open_spider error")) + + +class OpenSpiderExceptionAsyncPipeline: + async def open_spider(self): + raise ValueError("open_spider error") + + class ItemSpider(Spider): name = "itemspider" @@ -94,59 +131,55 @@ class ItemSpider(Spider): class TestPipeline: - @classmethod - def setup_class(cls): - cls.mockserver = MockServer() - cls.mockserver.__enter__() - - @classmethod - def teardown_class(cls): - cls.mockserver.__exit__(None, None, None) - def _on_item_scraped(self, item): assert isinstance(item, dict) assert item.get("pipeline_passed") self.items.append(item) - def _create_crawler(self, pipeline_class): + def _create_crawler(self, pipeline_class: type) -> Crawler: settings = { "ITEM_PIPELINES": {pipeline_class: 1}, } crawler = get_crawler(ItemSpider, settings) crawler.signals.connect(self._on_item_scraped, signals.item_scraped) - self.items = [] + self.items: list[Any] = [] return crawler - @inlineCallbacks - def test_simple_pipeline(self): - crawler = self._create_crawler(SimplePipeline) - yield crawler.crawl(mockserver=self.mockserver) + @pytest.mark.parametrize( + "pipeline_class", + [ + SimplePipeline, + AsyncDefPipeline, + pytest.param(AsyncDefAsyncioPipeline, marks=pytest.mark.only_asyncio), + pytest.param( + AsyncDefNotAsyncioPipeline, marks=pytest.mark.only_not_asyncio + ), + ], + ) + @deferred_f_from_coro_f + async def test_pipeline(self, mockserver: MockServer, pipeline_class: type) -> None: + crawler = self._create_crawler(pipeline_class) + await maybe_deferred_to_future(crawler.crawl(mockserver=mockserver)) assert len(self.items) == 1 - @inlineCallbacks - def test_deferred_pipeline(self): + @deferred_f_from_coro_f + async def test_pipeline_deferred(self, mockserver: MockServer) -> None: crawler = self._create_crawler(DeferredPipeline) - yield crawler.crawl(mockserver=self.mockserver) - assert len(self.items) == 1 - - @inlineCallbacks - def test_asyncdef_pipeline(self): - crawler = self._create_crawler(AsyncDefPipeline) - yield crawler.crawl(mockserver=self.mockserver) - assert len(self.items) == 1 - - @pytest.mark.only_asyncio - @inlineCallbacks - def test_asyncdef_asyncio_pipeline(self): - crawler = self._create_crawler(AsyncDefAsyncioPipeline) - yield crawler.crawl(mockserver=self.mockserver) - assert len(self.items) == 1 - - @pytest.mark.only_not_asyncio - @inlineCallbacks - def test_asyncdef_not_asyncio_pipeline(self): - crawler = self._create_crawler(AsyncDefNotAsyncioPipeline) - yield crawler.crawl(mockserver=self.mockserver) + with ( + pytest.warns( + ScrapyDeprecationWarning, + match="DeferredPipeline.open_spider returned a Deferred", + ), + pytest.warns( + ScrapyDeprecationWarning, + match="DeferredPipeline.close_spider returned a Deferred", + ), + pytest.warns( + ScrapyDeprecationWarning, + match="DeferredPipeline.process_item returned a Deferred", + ), + ): + await maybe_deferred_to_future(crawler.crawl(mockserver=mockserver)) assert len(self.items) == 1 @deferred_f_from_coro_f @@ -170,6 +203,52 @@ class TestPipeline: assert len(self.items) == 1 + @pytest.mark.parametrize( + "pipeline_class", + [ + ProcessItemExceptionPipeline, + pytest.param( + ProcessItemExceptionDeferredPipeline, + marks=pytest.mark.filterwarnings( + "ignore::scrapy.exceptions.ScrapyDeprecationWarning" + ), + ), + ProcessItemExceptionAsyncPipeline, + ], + ) + @deferred_f_from_coro_f + async def test_process_item_exception( + self, + caplog: pytest.LogCaptureFixture, + mockserver: MockServer, + pipeline_class: type, + ) -> None: + crawler = self._create_crawler(pipeline_class) + await maybe_deferred_to_future(crawler.crawl(mockserver=mockserver)) + assert "Error processing {'field': 42}" in caplog.text + assert "process_item error" in caplog.text + + @pytest.mark.parametrize( + "pipeline_class", + [ + OpenSpiderExceptionPipeline, + pytest.param( + OpenSpiderExceptionDeferredPipeline, + marks=pytest.mark.filterwarnings( + "ignore::scrapy.exceptions.ScrapyDeprecationWarning" + ), + ), + OpenSpiderExceptionAsyncPipeline, + ], + ) + @deferred_f_from_coro_f + async def test_open_spider_exception( + self, mockserver: MockServer, pipeline_class: type + ) -> None: + crawler = self._create_crawler(pipeline_class) + with pytest.raises(ValueError, match="open_spider error"): + await maybe_deferred_to_future(crawler.crawl(mockserver=mockserver)) + class TestCustomPipelineManager: def test_deprecated_process_item_spider_arg(self) -> None: @@ -374,7 +453,7 @@ class TestMiddlewareManagerSpider: match=r"ItemPipelineManager needs to access self\.crawler\.spider but it is None", ), ): - mwman.open_spider(DefaultSpider()) + await maybe_deferred_to_future(mwman.open_spider(DefaultSpider())) with pytest.raises( ValueError, match=r"ItemPipelineManager needs to access self\.crawler\.spider but it is None", @@ -390,7 +469,7 @@ class TestMiddlewareManagerSpider: match=r"ItemPipelineManager needs to access self\.crawler\.spider but it is None", ), ): - mwman.close_spider(DefaultSpider()) + await maybe_deferred_to_future(mwman.close_spider(DefaultSpider())) with pytest.raises( ValueError, match=r"ItemPipelineManager needs to access self\.crawler\.spider but it is None", From 9381ad893dfea0e8dac73fc78cb609b6017bde9a Mon Sep 17 00:00:00 2001 From: Adham 3llam <102502789+Ad7amstein@users.noreply.github.com> Date: Mon, 22 Dec 2025 11:00:39 +0200 Subject: [PATCH 015/248] Remove outdated version marker directives (< 2.8.0) from documentation (#7198) --- docs/topics/asyncio.rst | 2 -- docs/topics/commands.rst | 5 ----- docs/topics/coroutines.rst | 12 ----------- docs/topics/downloader-middleware.rst | 4 ---- docs/topics/exceptions.rst | 2 -- docs/topics/exporters.rst | 3 --- docs/topics/feed-exports.rst | 26 ---------------------- docs/topics/items.rst | 4 ---- docs/topics/link-extractors.rst | 4 ---- docs/topics/loaders.rst | 3 --- docs/topics/media-pipeline.rst | 10 --------- docs/topics/request-response.rst | 31 --------------------------- docs/topics/settings.rst | 13 ----------- docs/topics/signals.rst | 6 ------ docs/topics/spider-middleware.rst | 6 ------ docs/topics/spiders.rst | 3 --- 16 files changed, 134 deletions(-) diff --git a/docs/topics/asyncio.rst b/docs/topics/asyncio.rst index ca5bc9a5c..36f9a1a53 100644 --- a/docs/topics/asyncio.rst +++ b/docs/topics/asyncio.rst @@ -4,8 +4,6 @@ asyncio ======= -.. versionadded:: 2.0 - Scrapy has partial support for :mod:`asyncio`. After you :ref:`install the asyncio reactor `, you may use :mod:`asyncio` and :mod:`asyncio`-powered libraries in any :doc:`coroutine `. diff --git a/docs/topics/commands.rst b/docs/topics/commands.rst index b8bf41402..8d1351eb9 100644 --- a/docs/topics/commands.rst +++ b/docs/topics/commands.rst @@ -233,9 +233,6 @@ genspider * Syntax: ``scrapy genspider [-t template] `` * Requires project: *no* -.. versionadded:: 2.6.0 - The ability to pass a URL instead of a domain. - Creates a new spider in the current folder or in the current project's ``spiders`` folder, if called from inside a project. The ```` parameter is set as the spider's ``name``, while ```` is used to generate the ``allowed_domains`` and ``start_urls`` spider's attributes. Usage example:: @@ -509,8 +506,6 @@ Supported options: * ``--output`` or ``-o``: dump scraped items to a file - .. versionadded:: 2.3 - .. skip: start Usage example:: diff --git a/docs/topics/coroutines.rst b/docs/topics/coroutines.rst index ed2b25252..9c324a031 100644 --- a/docs/topics/coroutines.rst +++ b/docs/topics/coroutines.rst @@ -4,8 +4,6 @@ Coroutines ========== -.. versionadded:: 2.0 - Scrapy :ref:`supports ` the :ref:`coroutine syntax ` (i.e. ``async def``). @@ -28,10 +26,6 @@ hence use coroutine syntax (e.g. ``await``, ``async for``, ``async with``): If you are using any custom or third-party :ref:`spider middleware `, see :ref:`sync-async-spider-middleware`. - .. versionchanged:: 2.7 - Output of async callbacks is now processed asynchronously instead of - collecting all of it first. - - The :meth:`process_item` method of :ref:`item pipelines `. @@ -53,8 +47,6 @@ hence use coroutine syntax (e.g. ``await``, ``async for``, ``async with``): See also :ref:`sync-async-spider-middleware` and :ref:`universal-spider-middleware`. - .. versionadded:: 2.7 - - The :meth:`~scrapy.spidermiddlewares.SpiderMiddleware.process_start` method of :ref:`spider middlewares `, which *must* be defined as an :term:`asynchronous generator`. @@ -307,8 +299,6 @@ You can also send multiple requests in parallel: Mixing synchronous and asynchronous spider middlewares ====================================================== -.. versionadded:: 2.7 - The output of a :class:`~scrapy.Request` callback is passed as the ``result`` parameter to the :meth:`~scrapy.spidermiddlewares.SpiderMiddleware.process_spider_output` method @@ -396,8 +386,6 @@ option. Otherwise, it's better to choose the second option. Universal spider middlewares ---------------------------- -.. versionadded:: 2.7 - To allow writing a spider middleware that supports asynchronous execution of its ``process_spider_output`` method in Scrapy 2.7 and later (avoiding :ref:`asynchronous-to-synchronous conversions `) diff --git a/docs/topics/downloader-middleware.rst b/docs/topics/downloader-middleware.rst index 44c7ac17b..c78045c4b 100644 --- a/docs/topics/downloader-middleware.rst +++ b/docs/topics/downloader-middleware.rst @@ -917,10 +917,6 @@ Default: ``[]`` Meta tags within these tags are ignored. -.. versionchanged:: 2.0 - The default value of :setting:`METAREFRESH_IGNORE_TAGS` changed from - ``["script", "noscript"]`` to ``[]``. - .. versionchanged:: 2.11.2 The default value of :setting:`METAREFRESH_IGNORE_TAGS` changed from ``[]`` to ``["noscript"]``. diff --git a/docs/topics/exceptions.rst b/docs/topics/exceptions.rst index 0b572ff95..cc690d609 100644 --- a/docs/topics/exceptions.rst +++ b/docs/topics/exceptions.rst @@ -83,8 +83,6 @@ This exception is raised to indicate an unsupported feature. StopDownload ------------- -.. versionadded:: 2.2 - .. exception:: StopDownload(fail=True) Raised from a :class:`~scrapy.signals.bytes_received` or :class:`~scrapy.signals.headers_received` diff --git a/docs/topics/exporters.rst b/docs/topics/exporters.rst index d3186ef5c..74256eef4 100644 --- a/docs/topics/exporters.rst +++ b/docs/topics/exporters.rst @@ -163,9 +163,6 @@ BaseItemExporter populate their respective instance attributes: :attr:`fields_to_export`, :attr:`export_empty_fields`, :attr:`encoding`, :attr:`indent`. - .. versionadded:: 2.0 - The *dont_fail* parameter. - .. method:: export_item(item) Exports the given item. This method must be implemented in subclasses. diff --git a/docs/topics/feed-exports.rst b/docs/topics/feed-exports.rst index 2184f2d0e..a5537c39e 100644 --- a/docs/topics/feed-exports.rst +++ b/docs/topics/feed-exports.rst @@ -236,8 +236,6 @@ This storage backend uses :ref:`delayed file delivery `. Google Cloud Storage (GCS) -------------------------- -.. versionadded:: 2.3 - The feeds are stored on `Google Cloud Storage`_. - URI scheme: ``gs`` @@ -303,8 +301,6 @@ feed URI, allowing item delivery to start way before the end of the crawl. Item filtering ============== -.. versionadded:: 2.6.0 - You can filter items that you want to allow for a particular feed by using the ``item_classes`` option in :ref:`feeds options `. Only items of the specified types will be added to the feed. @@ -344,8 +340,6 @@ ItemFilter Post-Processing =============== -.. versionadded:: 2.6.0 - Scrapy provides an option to activate plugins to post-process feeds before they are exported to feed storages. In addition to using :ref:`builtin plugins `, you can create your own :ref:`plugins `. @@ -425,8 +419,6 @@ These are the settings used for configuring the feed exports: FEEDS ----- -.. versionadded:: 2.1 - Default: ``{}`` A dictionary in which every key is a feed URI (or a :class:`pathlib.Path` @@ -479,8 +471,6 @@ as a fallback value if that key is not provided for a specific feed definition: - ``batch_item_count``: falls back to :setting:`FEED_EXPORT_BATCH_ITEM_COUNT`. - .. versionadded:: 2.3.0 - - ``encoding``: falls back to :setting:`FEED_EXPORT_ENCODING`. - ``fields``: falls back to :setting:`FEED_EXPORT_FIELDS`. @@ -489,20 +479,14 @@ as a fallback value if that key is not provided for a specific feed definition: If undefined or empty, all items are exported. - .. versionadded:: 2.6.0 - - ``item_filter``: a :ref:`filter class ` to filter items to export. :class:`~scrapy.extensions.feedexport.ItemFilter` is used be default. - .. versionadded:: 2.6.0 - - ``indent``: falls back to :setting:`FEED_EXPORT_INDENT`. - ``item_export_kwargs``: :class:`dict` with keyword arguments for the corresponding :ref:`item exporter class `. - .. versionadded:: 2.4.0 - - ``overwrite``: whether to overwrite the file if it already exists (``True``) or append to its content (``False``). @@ -522,8 +506,6 @@ as a fallback value if that key is not provided for a specific feed definition: - :ref:`topics-feed-storage-stdout`: ``False`` (overwriting is not supported) - .. versionadded:: 2.4.0 - - ``store_empty``: falls back to :setting:`FEED_STORE_EMPTY`. - ``uri_params``: falls back to :setting:`FEED_URI_PARAMS`. @@ -532,8 +514,6 @@ as a fallback value if that key is not provided for a specific feed definition: The plugins will be used in the order of the list passed. - .. versionadded:: 2.6.0 - .. setting:: FEED_EXPORT_ENCODING FEED_EXPORT_ENCODING @@ -700,8 +680,6 @@ format in :setting:`FEED_EXPORTERS`. E.g., to disable the built-in CSV exporter FEED_EXPORT_BATCH_ITEM_COUNT ---------------------------- -.. versionadded:: 2.3.0 - Default: ``0`` If assigned an integer number higher than ``0``, Scrapy generates multiple output files @@ -771,15 +749,11 @@ The function signature should be as follows: If :setting:`FEED_EXPORT_BATCH_ITEM_COUNT` is ``0``, ``batch_id`` is always ``1``. - .. versionadded:: 2.3.0 - - ``batch_time``: UTC date and time, in ISO format with ``:`` replaced with ``-``. See :setting:`FEED_EXPORT_BATCH_ITEM_COUNT`. - .. versionadded:: 2.3.0 - - ``time``: ``batch_time``, with microseconds set to ``0``. :type params: dict diff --git a/docs/topics/items.rst b/docs/topics/items.rst index 3588d033e..f05746934 100644 --- a/docs/topics/items.rst +++ b/docs/topics/items.rst @@ -80,8 +80,6 @@ Example: Dataclass objects ----------------- -.. versionadded:: 2.2 - :func:`~dataclasses.dataclass` allows the defining of item classes with field names, so that :ref:`item exporters ` can export all fields by default even if the first scraped object does not have values for all of them. @@ -112,8 +110,6 @@ Example: attr.s objects -------------- -.. versionadded:: 2.2 - :func:`attr.s` allows the defining of item classes with field names, so that :ref:`item exporters ` can export all fields by default even if the first scraped object does not have values for all of them. diff --git a/docs/topics/link-extractors.rst b/docs/topics/link-extractors.rst index f9744ed16..7e3517937 100644 --- a/docs/topics/link-extractors.rst +++ b/docs/topics/link-extractors.rst @@ -76,10 +76,6 @@ LxmlLinkExtractor If not given, it will default to :data:`scrapy.linkextractors.IGNORED_EXTENSIONS`. - .. versionchanged:: 2.0 - :data:`~scrapy.linkextractors.IGNORED_EXTENSIONS` now includes - ``7z``, ``7zip``, ``apk``, ``bz2``, ``cdr``, ``dmg``, ``ico``, - ``iso``, ``tar``, ``tar.gz``, ``webm``, and ``xz``. :type deny_extensions: list :param restrict_xpaths: is an XPath (or list of XPath's) which defines diff --git a/docs/topics/loaders.rst b/docs/topics/loaders.rst index b7ce33616..a43edb28c 100644 --- a/docs/topics/loaders.rst +++ b/docs/topics/loaders.rst @@ -174,9 +174,6 @@ with the data to be parsed, and return a parsed value. So you can use any function as input or output processor. The only requirement is that they must accept one (and only one) positional argument, which will be an iterable. -.. versionchanged:: 2.0 - Processors no longer need to be methods. - .. note:: Both input and output processors must receive an iterable as their first argument. The output of those functions can be anything. The result of input processors will be appended to an internal list (in the Loader) diff --git a/docs/topics/media-pipeline.rst b/docs/topics/media-pipeline.rst index fa17dfda6..8636e29c9 100644 --- a/docs/topics/media-pipeline.rst +++ b/docs/topics/media-pipeline.rst @@ -212,8 +212,6 @@ Where: FTP server storage ------------------ -.. versionadded:: 2.0 - :setting:`FILES_STORE` and :setting:`IMAGES_STORE` can point to an FTP server. Scrapy will automatically upload the files to the server. @@ -547,9 +545,6 @@ See here the methods that you can override in your custom Files Pipeline: By default the :meth:`file_path` method returns ``full/.``. - .. versionadded:: 2.4 - The *item* parameter. - .. method:: FilesPipeline.get_media_requests(item, info) As seen on the workflow, the pipeline will get the URLs of the images to @@ -590,8 +585,6 @@ See here the methods that you can override in your custom Files Pipeline: * ``status`` - the file status indication. - .. versionadded:: 2.2 - It can be one of the following: * ``downloaded`` - file was downloaded. @@ -705,9 +698,6 @@ See here the methods that you can override in your custom Images Pipeline: By default the :meth:`file_path` method returns ``full/.``. - .. versionadded:: 2.4 - The *item* parameter. - .. method:: ImagesPipeline.thumb_path(self, request, thumb_id, response=None, info=None, *, item=None) This method is called for every item of :setting:`IMAGES_THUMBS` per downloaded item. It returns the diff --git a/docs/topics/request-response.rst b/docs/topics/request-response.rst index 8a907e377..cb2410207 100644 --- a/docs/topics/request-response.rst +++ b/docs/topics/request-response.rst @@ -32,10 +32,6 @@ Request objects :type url: str :param callback: sets :attr:`callback`, defaults to ``None``. - - .. versionchanged:: 2.0 - The *callback* parameter is no longer required when the *errback* - parameter is specified. :type callback: Callable[Concatenate[Response, ...], Any] | None :param method: the HTTP method of this request. Defaults to ``'GET'``. @@ -116,10 +112,6 @@ Request objects :class:`scrapy.Request.cookies ` parameter. This is a known current limitation that is being worked on. - .. versionadded:: 2.6.0 - Cookie values that are :class:`bool`, :class:`float` or :class:`int` - are casted to :class:`str`. - :type cookies: dict or list :param encoding: the encoding of this request (defaults to ``'utf-8'``). @@ -134,10 +126,6 @@ Request objects :type dont_filter: bool :param errback: sets :attr:`errback`, defaults to ``None``. - - .. versionchanged:: 2.0 - The *callback* parameter is no longer required when the *errback* - parameter is specified. :type errback: Callable[[Failure], Any] | None :param flags: Flags sent to the request, can be used for logging or similar purposes. @@ -448,8 +436,6 @@ To change how request fingerprints are built for your requests, use the REQUEST_FINGERPRINTER_CLASS ~~~~~~~~~~~~~~~~~~~~~~~~~~~ -.. versionadded:: 2.7 - Default: :class:`scrapy.utils.request.RequestFingerprinter` A :ref:`request fingerprinter class ` or its @@ -988,15 +974,6 @@ Response objects For instance: "HTTP/1.0", "HTTP/1.1", "h2" :type protocol: :class:`str` - .. versionadded:: 2.0.0 - The ``certificate`` parameter. - - .. versionadded:: 2.1.0 - The ``ip_address`` parameter. - - .. versionadded:: 2.5.0 - The ``protocol`` parameter. - .. attribute:: Response.url A string containing the URL of the response. @@ -1062,8 +1039,6 @@ Response objects .. attribute:: Response.cb_kwargs - .. versionadded:: 2.0 - A shortcut to the :attr:`~scrapy.Request.cb_kwargs` attribute of the :attr:`Response.request` object (i.e. ``self.request.cb_kwargs``). @@ -1082,8 +1057,6 @@ Response objects .. attribute:: Response.certificate - .. versionadded:: 2.0.0 - A :class:`twisted.internet.ssl.Certificate` object representing the server's SSL certificate. @@ -1091,8 +1064,6 @@ Response objects .. attribute:: Response.ip_address - .. versionadded:: 2.1.0 - The IP address of the server from which the Response originated. This attribute is currently only populated by the HTTP 1.1 download @@ -1101,8 +1072,6 @@ Response objects .. attribute:: Response.protocol - .. versionadded:: 2.5.0 - The protocol that was used to download the response. For instance: "HTTP/1.0", "HTTP/1.1" diff --git a/docs/topics/settings.rst b/docs/topics/settings.rst index 64e5af250..27a8764f3 100644 --- a/docs/topics/settings.rst +++ b/docs/topics/settings.rst @@ -182,8 +182,6 @@ Setting values must be :ref:`picklable `. Import paths and classes ======================== -.. versionadded:: 2.4.0 - When a setting references a callable object to be imported by Scrapy, such as a class or a function, there are two different ways you can specify that object: @@ -667,8 +665,6 @@ DNS in-memory cache size. DNS_RESOLVER ------------ -.. versionadded:: 2.0 - Default: ``'scrapy.resolver.CachingThreadedResolver'`` The class to be used to resolve DNS names. The default ``scrapy.resolver.CachingThreadedResolver`` @@ -1798,8 +1794,6 @@ For available choices, see :setting:`SCHEDULER_MEMORY_QUEUE`. SCRAPER_SLOT_MAX_ACTIVE_SIZE ---------------------------- -.. versionadded:: 2.0 - Default: ``5_000_000`` Soft limit (in bytes) for response data being processed. @@ -1974,8 +1968,6 @@ in the ``project`` subdirectory. TWISTED_REACTOR --------------- -.. versionadded:: 2.0 - Default: ``"twisted.internet.asyncioreactor.AsyncioSelectorReactor"`` Import path of a given :mod:`~twisted.internet.reactor`. @@ -2068,11 +2060,6 @@ If this setting is set ``None``, Scrapy will use the existing reactor if one is already installed, or install the default reactor defined by Twisted for the current platform. -.. versionchanged:: 2.7 - The :command:`startproject` command now sets this setting to - ``twisted.internet.asyncioreactor.AsyncioSelectorReactor`` in the generated - ``settings.py`` file. - .. versionchanged:: 2.13 The default value was changed from ``None`` to ``"twisted.internet.asyncioreactor.AsyncioSelectorReactor"``. diff --git a/docs/topics/signals.rst b/docs/topics/signals.rst index aa27e62dd..efe3bb8fc 100644 --- a/docs/topics/signals.rst +++ b/docs/topics/signals.rst @@ -423,8 +423,6 @@ request_left_downloader .. signal:: request_left_downloader .. function:: request_left_downloader(request, spider) - .. versionadded:: 2.0 - Sent when a :class:`~scrapy.Request` leaves the downloader, even in case of failure. @@ -442,8 +440,6 @@ bytes_received .. signal:: bytes_received .. function:: bytes_received(data, request, spider) - .. versionadded:: 2.2 - Sent by the HTTP 1.1 and S3 download handlers when a group of bytes is received for a specific request. This signal might be fired multiple times for the same request, with partial data each time. For instance, @@ -472,8 +468,6 @@ headers_received .. signal:: headers_received .. function:: headers_received(headers, body_length, request, spider) - .. versionadded:: 2.5 - Sent by the HTTP 1.1 and S3 download handlers when the response headers are available for a given request, before downloading any additional content. diff --git a/docs/topics/spider-middleware.rst b/docs/topics/spider-middleware.rst index 0f81e409e..799d2f99c 100644 --- a/docs/topics/spider-middleware.rst +++ b/docs/topics/spider-middleware.rst @@ -125,10 +125,6 @@ one or more of these methods: :class:`~scrapy.Request` objects and :ref:`item objects `. - .. versionchanged:: 2.7 - This method may be defined as an :term:`asynchronous generator`, in - which case ``result`` is an :term:`asynchronous iterable`. - Consider defining this method as an :term:`asynchronous generator`, which will be a requirement in a future version of Scrapy. However, if you plan on sharing your spider middleware with other people, consider @@ -148,8 +144,6 @@ one or more of these methods: .. method:: process_spider_output_async(response, result) :async: - .. versionadded:: 2.7 - If defined, this method must be an :term:`asynchronous generator`, which will be called instead of :meth:`process_spider_output` if ``result`` is an :term:`asynchronous iterable`. diff --git a/docs/topics/spiders.rst b/docs/topics/spiders.rst index b4dfb3757..699b141c4 100644 --- a/docs/topics/spiders.rst +++ b/docs/topics/spiders.rst @@ -547,9 +547,6 @@ Crawling rules callbacks for new requests when writing :class:`CrawlSpider`-based spiders; unexpected behaviour can occur otherwise. - .. versionadded:: 2.0 - The *errback* parameter. - CrawlSpider example ~~~~~~~~~~~~~~~~~~~ From a2463325db2fd128e27da45d6ee36e21126c1bf5 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Wed, 31 Dec 2025 22:33:59 +0500 Subject: [PATCH 016/248] Allow Crawler.crawl_async() without asyncio. (#7202) --- docs/topics/coroutines.rst | 4 ++-- scrapy/crawler.py | 7 ------- tests/test_core_downloader.py | 2 +- tests/test_core_scraper.py | 4 ++-- tests/test_crawl.py | 4 ++-- tests/test_crawler.py | 3 +-- tests/test_downloaderslotssettings.py | 4 ++-- tests/test_engine_loop.py | 6 +++--- tests/test_feedexport.py | 6 +++--- tests/test_pipelines.py | 16 ++++++++-------- tests/test_signals.py | 4 ++-- tests/test_spider.py | 4 ++-- tests/test_spider_start.py | 2 +- tests/test_spidermiddleware_output_chain.py | 4 ++-- tests/test_spidermiddleware_process_start.py | 2 +- 15 files changed, 32 insertions(+), 40 deletions(-) diff --git a/docs/topics/coroutines.rst b/docs/topics/coroutines.rst index 9c324a031..c82e6c2f3 100644 --- a/docs/topics/coroutines.rst +++ b/docs/topics/coroutines.rst @@ -81,8 +81,8 @@ These APIs have a coroutine-based implementation and a Deferred-based one: - :meth:`~scrapy.crawler.Crawler.crawl_async` (coroutine-based) and :meth:`~scrapy.crawler.Crawler.crawl` (Deferred-based): the former - doesn't support non-default reactors and so the latter should be used - with those. + may be inconvenient to use in Deferred-based code so both are available, + this may change in a future Scrapy version. - :class:`scrapy.crawler.AsyncCrawlerRunner` and its subclass :class:`scrapy.crawler.AsyncCrawlerProcess` (coroutine-based) and diff --git a/scrapy/crawler.py b/scrapy/crawler.py index ffbebe152..e21207222 100644 --- a/scrapy/crawler.py +++ b/scrapy/crawler.py @@ -19,7 +19,6 @@ from scrapy.extension import ExtensionManager from scrapy.settings import Settings, overridden_settings from scrapy.signalmanager import SignalManager from scrapy.spiderloader import SpiderLoaderProtocol, get_spider_loader -from scrapy.utils.asyncio import is_asyncio_available from scrapy.utils.defer import deferred_from_coro from scrapy.utils.log import ( configure_logging, @@ -169,13 +168,7 @@ class Crawler: .. versionadded:: VERSION Complete when the crawl is finished. - - This function requires - :class:`~twisted.internet.asyncioreactor.AsyncioSelectorReactor` to be - installed. """ - if not is_asyncio_available(): - raise RuntimeError("Crawler.crawl_async() requires AsyncioSelectorReactor.") if self.crawling: raise RuntimeError("Crawling already taking place") if self._started: diff --git a/tests/test_core_downloader.py b/tests/test_core_downloader.py index a84bb9d8d..a35bfd590 100644 --- a/tests/test_core_downloader.py +++ b/tests/test_core_downloader.py @@ -187,4 +187,4 @@ async def test_fetch_deprecated_spider_arg(): ScrapyDeprecationWarning, match=r"The fetch\(\) method of .+\.CustomDownloader requires a spider argument", ): - await maybe_deferred_to_future(crawler.crawl()) + await crawler.crawl_async() diff --git a/tests/test_core_scraper.py b/tests/test_core_scraper.py index c819e246e..47dee1271 100644 --- a/tests/test_core_scraper.py +++ b/tests/test_core_scraper.py @@ -2,7 +2,7 @@ from __future__ import annotations from typing import TYPE_CHECKING -from scrapy.utils.defer import deferred_f_from_coro_f, maybe_deferred_to_future +from scrapy.utils.defer import deferred_f_from_coro_f from scrapy.utils.test import get_crawler from tests.spiders import SimpleSpider @@ -23,5 +23,5 @@ async def test_scraper_exception( "scrapy.core.engine.Scraper.handle_spider_output_async", lambda *args, **kwargs: 1 / 0, ) - await maybe_deferred_to_future(crawler.crawl(url=mockserver.url("/"))) + await crawler.crawl_async(url=mockserver.url("/")) assert "Scraper bug processing" in caplog.text diff --git a/tests/test_crawl.py b/tests/test_crawl.py index b0060a76a..699ac3911 100644 --- a/tests/test_crawl.py +++ b/tests/test_crawl.py @@ -98,7 +98,7 @@ class TestCrawl: settings = {"DOWNLOAD_DELAY": delay, "RANDOMIZE_DOWNLOAD_DELAY": randomize} crawler = get_crawler(FollowAllSpider, settings) - await maybe_deferred_to_future(crawler.crawl(**crawl_kwargs)) + await crawler.crawl_async(**crawl_kwargs) assert crawler.spider assert isinstance(crawler.spider, FollowAllSpider) times = crawler.spider.times @@ -112,7 +112,7 @@ class TestCrawl: # code above to have any meaning. settings["DOWNLOAD_DELAY"] = 0 crawler = get_crawler(FollowAllSpider, settings) - await maybe_deferred_to_future(crawler.crawl(**crawl_kwargs)) + await crawler.crawl_async(**crawl_kwargs) assert crawler.spider assert isinstance(crawler.spider, FollowAllSpider) times = crawler.spider.times diff --git a/tests/test_crawler.py b/tests/test_crawler.py index 4f1045786..5204c9a73 100644 --- a/tests/test_crawler.py +++ b/tests/test_crawler.py @@ -111,7 +111,6 @@ class TestCrawler(TestBaseCrawler): with pytest.raises(RuntimeError, match="more than once on the same instance"): yield crawler.crawl() - @pytest.mark.only_asyncio @deferred_f_from_coro_f async def test_crawler_crawl_async_twice_seq_unsupported(self): crawler = get_raw_crawler(NoRequestsSpider, BASE_SETTINGS) @@ -552,7 +551,7 @@ class TestCrawlerLogging: assert get_scrapy_root_handler().level == logging.DEBUG crawler = get_crawler(MySpider) assert get_scrapy_root_handler().level == logging.INFO - await maybe_deferred_to_future(crawler.crawl()) + await crawler.crawl_async() finally: _uninstall_scrapy_root_handler() diff --git a/tests/test_downloaderslotssettings.py b/tests/test_downloaderslotssettings.py index 5d88b59c1..22127a4da 100644 --- a/tests/test_downloaderslotssettings.py +++ b/tests/test_downloaderslotssettings.py @@ -8,7 +8,7 @@ from scrapy import Request from scrapy.core.downloader import Downloader, Slot from scrapy.crawler import CrawlerRunner from scrapy.exceptions import ScrapyDeprecationWarning -from scrapy.utils.defer import deferred_f_from_coro_f, maybe_deferred_to_future +from scrapy.utils.defer import deferred_f_from_coro_f from scrapy.utils.spider import DefaultSpider from scrapy.utils.test import get_crawler from tests.mockserver.http import MockServer @@ -143,7 +143,7 @@ async def test_none_slot_with_priority_queue( DownloaderSlotsSettingsTestSpider, settings_dict={"SCHEDULER_PRIORITY_QUEUE": priority_queue_class}, ) - await maybe_deferred_to_future(crawler.crawl(mockserver=mockserver)) + await crawler.crawl_async(mockserver=mockserver) assert isinstance(crawler.spider, DownloaderSlotsSettingsTestSpider) assert hasattr(crawler.spider, "times") diff --git a/tests/test_engine_loop.py b/tests/test_engine_loop.py index ddf1f1fe0..c219a0f6f 100644 --- a/tests/test_engine_loop.py +++ b/tests/test_engine_loop.py @@ -81,7 +81,7 @@ class TestMain: settings = {"SCHEDULER": MemoryScheduler} crawler = get_crawler(TestSpider, settings_dict=settings) crawler.signals.connect(track_url, signals.request_reached_downloader) - await maybe_deferred_to_future(crawler.crawl()) + await crawler.crawl_async() assert crawler.stats.get_value("finish_reason") == "finished" expected_urls = ["data:,a", "data:,b", "data:,c", "data:,d"] assert actual_urls == expected_urls, f"{actual_urls=} != {expected_urls=}" @@ -112,7 +112,7 @@ class TestMain: caplog.clear() with caplog.at_level(ERROR): - await maybe_deferred_to_future(crawler.crawl()) + await crawler.crawl_async() assert not caplog.records assert crawler.stats @@ -182,7 +182,7 @@ class TestRequestSendOrder: crawler = get_crawler(TestSpider, settings_dict=settings) crawler.signals.connect(track_num, signals.request_reached_downloader) - await maybe_deferred_to_future(crawler.crawl()) + await crawler.crawl_async() assert crawler.stats.get_value("finish_reason") == "finished" expected_nums = sorted(start_nums + cb_nums) assert actual_nums == expected_nums, f"{actual_nums=} != {expected_nums=}" diff --git a/tests/test_feedexport.py b/tests/test_feedexport.py index a423f5328..a9d608e52 100644 --- a/tests/test_feedexport.py +++ b/tests/test_feedexport.py @@ -862,7 +862,7 @@ class TestFeedExport(TestFeedExportBase): try: spider_cls.start_urls = [self.mockserver.url("/")] crawler = get_crawler(spider_cls, settings) - await maybe_deferred_to_future(crawler.crawl()) + await crawler.crawl_async() for file_path, feed_options in FEEDS.items(): content[feed_options["format"]] = ( @@ -1866,7 +1866,7 @@ class TestFeedPostProcessedExports(TestFeedExportBase): try: spider_cls.start_urls = [self.mockserver.url("/")] crawler = get_crawler(spider_cls, settings) - await maybe_deferred_to_future(crawler.crawl()) + await crawler.crawl_async() for file_path in FEEDS: content[str(file_path)] = ( @@ -2363,7 +2363,7 @@ class TestBatchDeliveries(TestFeedExportBase): content: defaultdict[str, list[bytes]] = defaultdict(list) spider_cls.start_urls = [self.mockserver.url("/")] crawler = get_crawler(spider_cls, settings) - await maybe_deferred_to_future(crawler.crawl()) + await crawler.crawl_async() for path, feed in FEEDS.items(): dir_name = Path(path).parent diff --git a/tests/test_pipelines.py b/tests/test_pipelines.py index fc61d61ec..f315388cd 100644 --- a/tests/test_pipelines.py +++ b/tests/test_pipelines.py @@ -159,7 +159,7 @@ class TestPipeline: @deferred_f_from_coro_f async def test_pipeline(self, mockserver: MockServer, pipeline_class: type) -> None: crawler = self._create_crawler(pipeline_class) - await maybe_deferred_to_future(crawler.crawl(mockserver=mockserver)) + await crawler.crawl_async(mockserver=mockserver) assert len(self.items) == 1 @deferred_f_from_coro_f @@ -179,7 +179,7 @@ class TestPipeline: match="DeferredPipeline.process_item returned a Deferred", ), ): - await maybe_deferred_to_future(crawler.crawl(mockserver=mockserver)) + await crawler.crawl_async(mockserver=mockserver) assert len(self.items) == 1 @deferred_f_from_coro_f @@ -199,7 +199,7 @@ class TestPipeline: match=r"DeprecatedSpiderArgPipeline.process_item\(\) requires a spider argument", ), ): - await maybe_deferred_to_future(crawler.crawl(mockserver=mockserver)) + await crawler.crawl_async(mockserver=mockserver) assert len(self.items) == 1 @@ -224,7 +224,7 @@ class TestPipeline: pipeline_class: type, ) -> None: crawler = self._create_crawler(pipeline_class) - await maybe_deferred_to_future(crawler.crawl(mockserver=mockserver)) + await crawler.crawl_async(mockserver=mockserver) assert "Error processing {'field': 42}" in caplog.text assert "process_item error" in caplog.text @@ -247,7 +247,7 @@ class TestPipeline: ) -> None: crawler = self._create_crawler(pipeline_class) with pytest.raises(ValueError, match="open_spider error"): - await maybe_deferred_to_future(crawler.crawl(mockserver=mockserver)) + await crawler.crawl_async(mockserver=mockserver) class TestCustomPipelineManager: @@ -287,7 +287,7 @@ class TestCustomPipelineManager: ) crawler.spider = crawler._create_spider() crawler.signals.connect(_on_item_scraped, signals.item_scraped) - await maybe_deferred_to_future(crawler.crawl(mockserver=mockserver)) + await crawler.crawl_async(mockserver=mockserver) assert len(items) == 1 @@ -345,7 +345,7 @@ class TestCustomPipelineManager: match=r"CustomPipelineManager overrides process_item\(\) but doesn't override process_item_async\(\)", ), ): - await maybe_deferred_to_future(crawler.crawl(mockserver=mockserver)) + await crawler.crawl_async(mockserver=mockserver) assert len(items) == 1 @@ -407,7 +407,7 @@ class TestCustomPipelineManager: match=r"CustomPipelineManager doesn't define a process_item_async\(\) method", ), ): - await maybe_deferred_to_future(crawler.crawl(mockserver=mockserver)) + await crawler.crawl_async(mockserver=mockserver) assert len(items) == 1 diff --git a/tests/test_signals.py b/tests/test_signals.py index 2e4f9ffb1..89b7a74dc 100644 --- a/tests/test_signals.py +++ b/tests/test_signals.py @@ -2,7 +2,7 @@ import pytest from twisted.internet.defer import inlineCallbacks from scrapy import Request, Spider, signals -from scrapy.utils.defer import deferred_f_from_coro_f, maybe_deferred_to_future +from scrapy.utils.defer import deferred_f_from_coro_f from scrapy.utils.test import get_crawler, get_from_asyncio_queue from tests.mockserver.http import MockServer @@ -30,7 +30,7 @@ class TestMain: calls.append(object()) crawler.signals.connect(track_call, signals.scheduler_empty) - await maybe_deferred_to_future(crawler.crawl()) + await crawler.crawl_async() assert len(calls) >= 1 diff --git a/tests/test_spider.py b/tests/test_spider.py index dcc9d1e82..a1649aa6f 100644 --- a/tests/test_spider.py +++ b/tests/test_spider.py @@ -29,7 +29,7 @@ from scrapy.spiders import ( XMLFeedSpider, ) from scrapy.spiders.init import InitSpider -from scrapy.utils.defer import deferred_f_from_coro_f, maybe_deferred_to_future +from scrapy.utils.defer import deferred_f_from_coro_f from scrapy.utils.test import get_crawler, get_reactor_settings from tests import get_testdata, tests_datadir @@ -155,7 +155,7 @@ class TestInitSpider(TestSpider): responses.append(response) crawler = get_crawler(TestSpider) - await maybe_deferred_to_future(crawler.crawl()) + await crawler.crawl_async() assert len(responses) == 1 assert responses[0].url == "data:," diff --git a/tests/test_spider_start.py b/tests/test_spider_start.py index d4eca85b8..e608d8975 100644 --- a/tests/test_spider_start.py +++ b/tests/test_spider_start.py @@ -32,7 +32,7 @@ class TestMain: crawler = get_crawler(spider) crawler.signals.connect(track_item, signals.item_scraped) - await maybe_deferred_to_future(crawler.crawl()) + await crawler.crawl_async() assert crawler.stats assert crawler.stats.get_value("finish_reason") == "finished" assert actual_items == expected_items diff --git a/tests/test_spidermiddleware_output_chain.py b/tests/test_spidermiddleware_output_chain.py index 1808e087c..97d9d6470 100644 --- a/tests/test_spidermiddleware_output_chain.py +++ b/tests/test_spidermiddleware_output_chain.py @@ -1,7 +1,7 @@ from testfixtures import LogCapture from scrapy import Request, Spider -from scrapy.utils.defer import deferred_f_from_coro_f, maybe_deferred_to_future +from scrapy.utils.defer import deferred_f_from_coro_f from scrapy.utils.test import get_crawler from tests.mockserver.http import MockServer @@ -333,7 +333,7 @@ class TestSpiderMiddleware: async def crawl_log(self, spider: type[Spider]) -> LogCapture: crawler = get_crawler(spider) with LogCapture() as log: - await maybe_deferred_to_future(crawler.crawl(mockserver=self.mockserver)) + await crawler.crawl_async(mockserver=self.mockserver) return log @deferred_f_from_coro_f diff --git a/tests/test_spidermiddleware_process_start.py b/tests/test_spidermiddleware_process_start.py index a525f991d..18868bf8c 100644 --- a/tests/test_spidermiddleware_process_start.py +++ b/tests/test_spidermiddleware_process_start.py @@ -117,7 +117,7 @@ class TestMain: } crawler = get_crawler(spider_cls, settings_dict=settings) crawler.signals.connect(track_item, signals.item_scraped) - await maybe_deferred_to_future(crawler.crawl()) + await crawler.crawl_async() assert crawler.stats.get_value("finish_reason") == "finished" assert actual_items == expected_items, f"{actual_items=} != {expected_items=}" From d1bd8eb49f7aba9289e4ff692006cead8bcd9080 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Wed, 31 Dec 2025 23:03:16 +0500 Subject: [PATCH 017/248] Async API for download handlers. (#7164) --- docs/index.rst | 4 + docs/news.rst | 12 +- docs/requirements.txt | 1 + docs/topics/addons.rst | 11 +- docs/topics/coroutines.rst | 20 +- docs/topics/download-handlers.rst | 219 ++++++++++ docs/topics/downloader-middleware.rst | 7 +- docs/topics/settings.rst | 160 ++++---- scrapy/core/downloader/__init__.py | 4 +- scrapy/core/downloader/handlers/__init__.py | 92 ++++- scrapy/core/downloader/handlers/base.py | 32 ++ scrapy/core/downloader/handlers/datauri.py | 11 +- scrapy/core/downloader/handlers/file.py | 11 +- scrapy/core/downloader/handlers/ftp.py | 78 ++-- scrapy/core/downloader/handlers/http10.py | 12 +- scrapy/core/downloader/handlers/http11.py | 62 +-- scrapy/core/downloader/handlers/http2.py | 32 +- scrapy/core/downloader/handlers/s3.py | 71 +--- scrapy/extensions/telnet.py | 7 +- scrapy/utils/decorators.py | 7 +- tests/test_crawl.py | 6 + tests/test_downloader_handler_twisted_ftp.py | 61 +-- .../test_downloader_handler_twisted_http10.py | 13 +- .../test_downloader_handler_twisted_http2.py | 86 ++-- tests/test_downloader_handlers.py | 156 ++++--- tests/test_downloader_handlers_http_base.py | 387 ++++++++---------- 26 files changed, 896 insertions(+), 666 deletions(-) create mode 100644 docs/topics/download-handlers.rst create mode 100644 scrapy/core/downloader/handlers/base.py diff --git a/docs/index.rst b/docs/index.rst index 71047f9ef..fe61f3513 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -229,6 +229,7 @@ Extending Scrapy topics/signals topics/scheduler topics/exporters + topics/download-handlers topics/components topics/api @@ -257,6 +258,9 @@ Extending Scrapy :doc:`topics/exporters` Quickly export your scraped items to a file (XML, CSV, etc). +:doc:`topics/download-handlers` + Customize how requests are downloaded or add support for new URL schemes. + :doc:`topics/components` Learn the common API and some good practices when building custom Scrapy components. diff --git a/docs/news.rst b/docs/news.rst index 19d990a50..7e15c2167 100644 --- a/docs/news.rst +++ b/docs/news.rst @@ -74,6 +74,12 @@ Backward-incompatible changes :class:`~scrapy.downloadermiddlewares.robotstxt.RobotsTxtMiddleware` or calls its methods directly. +- The built-in :ref:`download handlers ` were + refactored, changing signatures of their methods. This change should only + affect user code that subclasses any of these handlers or calls their + methods directly. + (:issue:`6778`, :issue:`7164`) + - :meth:`scrapy.pipelines.media.MediaPipeline.process_item` now returns a coroutine, previously it returned a :class:`~twisted.internet.defer.Deferred` object. This @@ -2616,7 +2622,7 @@ Modified requirements ~~~~~~~~~~~~~~~~~~~~~ - The h2_ dependency is now optional, only needed to - :ref:`enable HTTP/2 support `. (:issue:`5113`) + :ref:`enable HTTP/2 support `. (:issue:`5113`) .. _h2: https://pypi.org/project/h2/ @@ -2998,7 +3004,7 @@ Highlights: - Official Python 3.9 support -- Experimental :ref:`HTTP/2 support ` +- Experimental :ref:`HTTP/2 support ` - New :func:`~scrapy.downloadermiddlewares.retry.get_retry_request` function to retry requests from spider callbacks @@ -3029,7 +3035,7 @@ Deprecations New features ~~~~~~~~~~~~ -- Experimental :ref:`HTTP/2 support ` through a new download handler +- Experimental :ref:`HTTP/2 support ` through a new download handler that can be assigned to the ``https`` protocol in the :setting:`DOWNLOAD_HANDLERS` setting. (:issue:`1854`, :issue:`4769`, :issue:`5058`, :issue:`5059`, :issue:`5066`) diff --git a/docs/requirements.txt b/docs/requirements.txt index 81a3119e4..500db7a7e 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,3 +1,4 @@ +h2==4.3.0 pydantic==2.12.3 scrapy-spider-metadata==0.2.0 sphinx==8.1.3 diff --git a/docs/topics/addons.rst b/docs/topics/addons.rst index dba14e749..64f00f622 100644 --- a/docs/topics/addons.rst +++ b/docs/topics/addons.rst @@ -175,16 +175,19 @@ Use a fallback component: class MyHandler: lazy = False - def __init__(self, settings, crawler): - dhcls = load_object(settings.get(FALLBACK_SETTING)) + def __init__(self, crawler): + dhcls = load_object(crawler.settings.get(FALLBACK_SETTING)) self._fallback_handler = build_from_crawler(dhcls, crawler) - def download_request(self, request, spider): + async def download_request(self, request): if request.meta.get("my_params"): # handle the request ... else: - return self._fallback_handler.download_request(request, spider) + return await self._fallback_handler.download_request(request) + + async def close(self): + pass class MyAddon: diff --git a/docs/topics/coroutines.rst b/docs/topics/coroutines.rst index c82e6c2f3..a78e98828 100644 --- a/docs/topics/coroutines.rst +++ b/docs/topics/coroutines.rst @@ -55,6 +55,10 @@ hence use coroutine syntax (e.g. ``await``, ``async for``, ``async with``): - :ref:`Signal handlers that support deferreds `. +- Methods of :ref:`download handlers `. + + .. versionadded:: VERSION + .. _coroutine-deferred-apis: @@ -95,12 +99,6 @@ The following user-supplied methods can return :class:`~twisted.internet.defer.Deferred` objects (the methods that can also return coroutines are listed in :ref:`coroutine-support`): -- Custom download handlers (see :setting:`DOWNLOAD_HANDLERS`): - - - ``download_request()`` - - - ``close()`` - - Custom downloader implementations (see :setting:`DOWNLOADER`): - ``fetch()`` @@ -146,11 +144,11 @@ For example: email is sent. You can use this object directly in Deferred-based code or convert it into a :class:`~asyncio.Future` object with :func:`~scrapy.utils.defer.maybe_deferred_to_future`. -- A custom download handler needs to define a ``download_request()`` method - that returns a :class:`~twisted.internet.defer.Deferred` object. You can - write a method that works with Deferreds and returns one directly, or you - can write a coroutine and convert it into a function that returns a - Deferred with :func:`~scrapy.utils.defer.deferred_f_from_coro_f`. +- A custom scheduler needs to define an ``open()`` method that can return a + :class:`~twisted.internet.defer.Deferred` object. You can write a method + that works with Deferreds and returns one directly, or you can write a + coroutine and convert it into a function that returns a Deferred with + :func:`~scrapy.utils.defer.deferred_f_from_coro_f`. General usage diff --git a/docs/topics/download-handlers.rst b/docs/topics/download-handlers.rst new file mode 100644 index 000000000..5f00d7131 --- /dev/null +++ b/docs/topics/download-handlers.rst @@ -0,0 +1,219 @@ +.. _topics-download-handlers: + +================= +Download handlers +================= + +Download handlers are Scrapy :ref:`components ` used to +download :ref:`requests ` and produce responses from +them. + +Using download handlers +======================= + +The :setting:`DOWNLOAD_HANDLERS_BASE` and :setting:`DOWNLOAD_HANDLERS` settings +tell Scrapy which handler is responsible for a given URL scheme. Their values +are merged into a mapping from scheme names to handler classes. When Scrapy +initializes it creates instances of all configured download handlers (except +for :ref:`lazy ones `) and stores them in a similar +mapping. When Scrapy needs to download a request it extracts the scheme from +its URL, finds the handler for this scheme, passes the request to it and gets a +response from it. If there is no handler for the scheme, the request is not +downloaded and a :exc:`~scrapy.exceptions.NotSupported` exception is raised. + +The :setting:`DOWNLOAD_HANDLERS_BASE` setting contains the default mapping of +handlers. You can use the :setting:`DOWNLOAD_HANDLERS` setting to add handlers +for additional schemes and to replace or disable default ones: + +.. code-block:: python + + DOWNLOAD_HANDLERS = { + # disable support for ftp:// requests + "ftp": None, + # replace the default one for http:// + "http": "my.download_handlers.HttpHandler", + # http:// and https:// are different schemes, + # even though they may use the same handler + "https": "my.download_handlers.HttpHandler", + # support for any custom scheme can be added + "sftp": "my.download_handlers.SftpHandler", + } + +Replacing HTTP(S) download handlers +----------------------------------- + +While Scrapy provides a default handler for ``http`` and ``https`` schemes, +users may want to use a different handler, provided by Scrapy or by some +3rd-party package. There are several considerations to keep in mind related to +this. + +First of all, as ``http`` and ``https`` are separate schemes, they need +separate entries in the :setting:`DOWNLOAD_HANDLERS` setting, even though it's +likely that the same handler class will be used for both schemes. + +Additionally, some of the Scrapy settings, like :setting:`DOWNLOAD_MAXSIZE`, +are honored by the default HTTP(S) handler but not necessarily by alternative +ones. The same may apply to other Scrapy features, e.g. the +:signal:`bytes_received` and :signal:`headers_received` signals. + +.. _lazy-download-handlers: + +Lazy instantiation of download handlers +--------------------------------------- + +A download handler can be marked as "lazy" by setting its ``lazy`` class +attribute to ``True``. Such handlers are only instantiated when they need to +download their first request. This may be useful when the instantiation is slow +or requires dependencies that are not always available, and the handler is not +needed on every spider run. For example, :class:`the built-in S3 handler +<.S3DownloadHandler>` is lazy. + +Writing your own download handler +================================= + +A download handler is a :ref:`component ` that defines +the following methods: + +.. class:: SampleDownloadHandler + + .. attribute:: lazy + :type: bool + + If ``False``, the handler will be instantiated when Scrapy is + initialized. + + If ``True``, the handler will only be instantiated when the first + request handled by it needs to be downloaded. + + .. method:: download_request(request: Request) -> Response: + :async: + + Download the given request and return a response. + + .. method:: close() -> None + :async: + + Clean up any resources used by the handler. + +An optional base class for custom handlers is provided: + +.. autoclass:: scrapy.core.downloader.handlers.base.BaseDownloadHandler + :members: + :undoc-members: + :member-order: bysource + +.. _download-handlers-ref: + +Built-in download handlers reference +==================================== + +DataURIDownloadHandler +---------------------- + +.. autoclass:: scrapy.core.downloader.handlers.datauri.DataURIDownloadHandler + +| Supported scheme: ``data``. +| Lazy: no. + +This handler supports RFC 2397 ``data:content/type;base64,`` data URIs. + +FileDownloadHandler +------------------- + +.. autoclass:: scrapy.core.downloader.handlers.file.FileDownloadHandler + +| Supported scheme: ``file``. +| Lazy: no. + +This handler supports ``file:///path`` local file URIs. It doesn't +support remote files. + +FTPDownloadHandler +------------------ + +.. autoclass:: scrapy.core.downloader.handlers.ftp.FTPDownloadHandler + +| Supported scheme: ``ftp``. +| Lazy: no. + +This handler supports ``ftp://host/path`` FTP URIs. + +It's implemented using :mod:`twisted.protocols.ftp`. + +.. _twisted-http2-handler: + +H2DownloadHandler +----------------- + +.. autoclass:: scrapy.core.downloader.handlers.http2.H2DownloadHandler + +| Supported scheme: ``https``. +| Lazy: yes. + +This handler supports ``https://host/path`` URLs and uses the HTTP/2 protocol +for them. + +It's implemented using :mod:`twisted.web.client` and the ``h2`` library. + +For this handler to work you need to install the ``Twisted[http2]`` extra +dependency. + +If you want to use this handler you need to replace the default one for the +``https`` scheme: + +.. code-block:: python + + DOWNLOAD_HANDLERS = { + "https": "scrapy.core.downloader.handlers.http2.H2DownloadHandler", + } + +.. warning:: + + This handler is experimental, and not yet recommended for production + environments. Future Scrapy versions may introduce related changes without + a deprecation period or warning. + +.. note:: + + Known limitations of the HTTP/2 implementation in this handler include: + + - No support for HTTP/2 Cleartext (h2c), since no major browser supports + HTTP/2 unencrypted (refer `http2 faq`_). + + - No setting to specify a maximum `frame size`_ larger than the default + value, 16384. Connections to servers that send a larger frame will + fail. + + - No support for `server pushes`_, which are ignored. + + - No support for the :signal:`bytes_received` and + :signal:`headers_received` signals. + +.. _frame size: https://datatracker.ietf.org/doc/html/rfc7540#section-4.2 +.. _http2 faq: https://http2.github.io/faq/#does-http2-require-encryption +.. _server pushes: https://datatracker.ietf.org/doc/html/rfc7540#section-8.2 + +HTTP11DownloadHandler +--------------------- + +.. autoclass:: scrapy.core.downloader.handlers.http11.HTTP11DownloadHandler + +| Supported schemes: ``http``, ``https``. +| Lazy: no. + +This handler supports ``http://host/path`` and ``https://host/path`` URLs and +uses the HTTP/1.1 protocol for them. + +It's implemented using :mod:`twisted.web.client`. + +S3DownloadHandler +----------------- + +.. autoclass:: scrapy.core.downloader.handlers.s3.S3DownloadHandler + +| Supported scheme: ``s3``. +| Lazy: yes. + +This handler supports ``s3://bucket/path`` S3 URIs. + +It's implemented using the ``botocore`` library and needs it to be installed. diff --git a/docs/topics/downloader-middleware.rst b/docs/topics/downloader-middleware.rst index c78045c4b..9be984f5d 100644 --- a/docs/topics/downloader-middleware.rst +++ b/docs/topics/downloader-middleware.rst @@ -129,9 +129,10 @@ defines one or more of these methods: .. method:: process_exception(request, exception) - Scrapy calls :meth:`process_exception` when a download handler - or a :meth:`process_request` (from a downloader middleware) raises an - exception (including an :exc:`~scrapy.exceptions.IgnoreRequest` exception) + Scrapy calls :meth:`process_exception` when a :ref:`download handler + ` or a :meth:`process_request` (from a + downloader middleware) raises an exception (including an + :exc:`~scrapy.exceptions.IgnoreRequest` exception). :meth:`process_exception` should return: either ``None``, a :class:`~scrapy.http.Response` object, or a :class:`~scrapy.Request` object. diff --git a/docs/topics/settings.rst b/docs/topics/settings.rst index 27a8764f3..b446f197f 100644 --- a/docs/topics/settings.rst +++ b/docs/topics/settings.rst @@ -691,24 +691,6 @@ Default: ``'scrapy.core.downloader.Downloader'`` The downloader to use for crawling. -.. setting:: DOWNLOADER_HTTPCLIENTFACTORY - -DOWNLOADER_HTTPCLIENTFACTORY ----------------------------- - -Default: ``'scrapy.core.downloader.webclient.ScrapyHTTPClientFactory'`` - -Defines a Twisted ``protocol.ClientFactory`` class to use for HTTP/1.0 -connections (for ``HTTP10DownloadHandler``). - -.. note:: - - HTTP/1.0 is rarely used nowadays and its Scrapy support is deprecated, - so you can safely ignore this setting, - unless you really want to use HTTP/1.0 and override - :setting:`DOWNLOAD_HANDLERS` for ``http(s)`` scheme accordingly, - i.e. to ``'scrapy.core.downloader.handlers.http10.HTTP10DownloadHandler'``. - .. setting:: DOWNLOADER_CLIENTCONTEXTFACTORY DOWNLOADER_CLIENTCONTEXTFACTORY @@ -738,6 +720,12 @@ accepts a ``method`` parameter (this is the ``OpenSSL.SSL`` method mapping parameter (``bool``) and a ``tls_ciphers`` parameter (see :setting:`DOWNLOADER_CLIENT_TLS_CIPHERS`). +.. note:: + + This setting is specific to the built-in Twisted-based download handlers: + :class:`scrapy.core.downloader.handlers.http11.HTTP11DownloadHandler` and + :class:`scrapy.core.downloader.handlers.http2.H2DownloadHandler`. + .. setting:: DOWNLOADER_CLIENT_TLS_CIPHERS DOWNLOADER_CLIENT_TLS_CIPHERS @@ -745,8 +733,8 @@ DOWNLOADER_CLIENT_TLS_CIPHERS Default: ``'DEFAULT'`` -Use this setting to customize the TLS/SSL ciphers used by the default -HTTP/1.1 downloader. +Use this setting to customize the TLS/SSL ciphers used by the HTTPS download +handler. The setting should contain a string in the `OpenSSL cipher list format`_, these ciphers will be used as client ciphers. Changing this setting may be @@ -756,6 +744,16 @@ specific cipher that is not included in ``DEFAULT`` if a website requires it. .. _OpenSSL cipher list format: https://docs.openssl.org/master/man1/openssl-ciphers/#cipher-list-format +.. note:: + + Handling of this setting needs to be implemented inside the :ref:`download + handler `, so it's not guaranteed to be supported + by all 3rd-party handlers. Moreover, for the built-in Twisted-based + download handlers + (:class:`scrapy.core.downloader.handlers.http11.HTTP11DownloadHandler` and + :class:`scrapy.core.downloader.handlers.http2.H2DownloadHandler`) it needs + to be implemented in the :setting:`DOWNLOADER_CLIENTCONTEXTFACTORY` class. + .. setting:: DOWNLOADER_CLIENT_TLS_METHOD DOWNLOADER_CLIENT_TLS_METHOD @@ -763,8 +761,8 @@ DOWNLOADER_CLIENT_TLS_METHOD Default: ``'TLS'`` -Use this setting to customize the TLS/SSL method used by the default -HTTP/1.1 downloader. +Use this setting to customize the TLS/SSL method used by the HTTPS download +handler. This setting must be one of these string values: @@ -776,6 +774,15 @@ This setting must be one of these string values: - ``'TLSv1.1'``: forces TLS version 1.1 - ``'TLSv1.2'``: forces TLS version 1.2 +.. note:: + + Handling of this setting needs to be implemented inside the :ref:`download + handler `, so it's not guaranteed to be supported + by all 3rd-party handlers. Moreover, for the built-in Twisted-based + download handlers + (:class:`scrapy.core.downloader.handlers.http11.HTTP11DownloadHandler` and + :class:`scrapy.core.downloader.handlers.http2.H2DownloadHandler`) it needs + to be implemented in the :setting:`DOWNLOADER_CLIENTCONTEXTFACTORY` class. .. setting:: DOWNLOADER_CLIENT_TLS_VERBOSE_LOGGING @@ -786,10 +793,18 @@ Default: ``False`` Setting this to ``True`` will enable DEBUG level messages about TLS connection parameters after establishing HTTPS connections. The kind of information logged -depends on the versions of OpenSSL and pyOpenSSL. +depends on the implementation of the download handler and the versions of +the TLS-related libraries. -This setting is only used for the default -:setting:`DOWNLOADER_CLIENTCONTEXTFACTORY`. +.. note:: + + Handling of this setting needs to be implemented inside the :ref:`download + handler `, so it's not guaranteed to be supported + by all 3rd-party handlers. Moreover, for the built-in Twisted-based + download handlers + (:class:`scrapy.core.downloader.handlers.http11.HTTP11DownloadHandler` and + :class:`scrapy.core.downloader.handlers.http2.H2DownloadHandler`) it needs + to be implemented in the :setting:`DOWNLOADER_CLIENTCONTEXTFACTORY` class. .. setting:: DOWNLOADER_MIDDLEWARES @@ -882,7 +897,6 @@ It is also possible to change this setting per domain, although it requires non-trivial code. See the implementation of the :ref:`AutoThrottle ` extension for an example. - .. setting:: DOWNLOAD_HANDLERS DOWNLOAD_HANDLERS @@ -890,7 +904,9 @@ DOWNLOAD_HANDLERS Default: ``{}`` -A dict containing the request downloader handlers enabled in your project. +A dict containing the :ref:`download handlers ` +enabled in your project. + See :setting:`DOWNLOAD_HANDLERS_BASE` for example format. .. setting:: DOWNLOAD_HANDLERS_BASE @@ -912,9 +928,9 @@ Default: } -A dict containing the request download handlers enabled by default in Scrapy. -You should never modify this setting in your project, modify -:setting:`DOWNLOAD_HANDLERS` instead. +A dict containing the :ref:`download handlers ` +enabled by default in Scrapy. You should never modify this setting in your +project, modify :setting:`DOWNLOAD_HANDLERS` instead. You can disable any of these download handlers by assigning ``None`` to their URI scheme in :setting:`DOWNLOAD_HANDLERS`. E.g., to disable the built-in FTP @@ -926,46 +942,6 @@ handler (without replacement), place this in your ``settings.py``: "ftp": None, } -.. _http2: - -The default HTTPS handler uses HTTP/1.1. To use HTTP/2: - -#. Install ``Twisted[http2]>=17.9.0`` to install the packages required to - enable HTTP/2 support in Twisted. - -#. Update :setting:`DOWNLOAD_HANDLERS` as follows: - - .. code-block:: python - - DOWNLOAD_HANDLERS = { - "https": "scrapy.core.downloader.handlers.http2.H2DownloadHandler", - } - -.. warning:: - - HTTP/2 support in Scrapy is experimental, and not yet recommended for - production environments. Future Scrapy versions may introduce related - changes without a deprecation period or warning. - -.. note:: - - Known limitations of the current HTTP/2 implementation of Scrapy include: - - - No support for HTTP/2 Cleartext (h2c), since no major browser supports - HTTP/2 unencrypted (refer `http2 faq`_). - - - No setting to specify a maximum `frame size`_ larger than the default - value, 16384. Connections to servers that send a larger frame will - fail. - - - No support for `server pushes`_, which are ignored. - - - No support for the :signal:`bytes_received` and - :signal:`headers_received` signals. - -.. _frame size: https://datatracker.ietf.org/doc/html/rfc7540#section-4.2 -.. _http2 faq: https://http2.github.io/faq/#does-http2-require-encryption -.. _server pushes: https://datatracker.ietf.org/doc/html/rfc7540#section-8.2 .. setting:: DOWNLOAD_SLOTS @@ -1006,6 +982,12 @@ The amount of time (in secs) that the downloader will wait before timing out. This timeout can be per-request using the :reqmeta:`download_timeout` :attr:`.Request.meta` key. +.. note:: + + Handling of this setting needs to be implemented inside the :ref:`download + handler `, so it's not guaranteed to be supported + by all 3rd-party handlers. + .. setting:: DOWNLOAD_MAXSIZE .. reqmeta:: download_maxsize @@ -1028,6 +1010,12 @@ Use ``0`` to disable this limit. This limit can be set per-request using the :reqmeta:`download_maxsize` :attr:`.Request.meta` key. +.. note:: + + Checking responses before decompressing them needs to be implemented inside + the :ref:`download handler `, so it's not + guaranteed to be supported by all 3rd-party handlers. + .. setting:: DOWNLOAD_WARNSIZE .. reqmeta:: download_warnsize @@ -1046,6 +1034,12 @@ Use ``0`` to disable this limit. This limit can be set per-request using the :reqmeta:`download_warnsize` :attr:`.Request.meta` key. +.. note:: + + Checking responses before decompressing them needs to be implemented inside + the :ref:`download handler `, so it's not + guaranteed to be supported by all 3rd-party handlers. + .. setting:: DOWNLOAD_FAIL_ON_DATALOSS DOWNLOAD_FAIL_ON_DATALOSS @@ -1072,11 +1066,17 @@ Optionally, this can be set per-request basis by using the If :setting:`RETRY_ENABLED` is ``True`` and this setting is set to ``True``, the ``ResponseFailed([_DataLoss])`` failure will be retried as usual. +.. note:: + + Handling of this setting needs to be implemented inside the :ref:`download + handler `, so it's not guaranteed to be supported + by all 3rd-party handlers. + .. warning:: This setting is ignored by the :class:`~scrapy.core.downloader.handlers.http2.H2DownloadHandler` - download handler (see :setting:`DOWNLOAD_HANDLERS`). In case of a data loss + :ref:`download handler `. In case of a data loss error, the corresponding HTTP/2 connection may be corrupted, affecting other requests that use the same connection; hence, a ``ResponseFailed([InvalidBodyLengthError])`` failure is always raised for every request that was using that connection. @@ -1269,6 +1269,12 @@ Default: ``True`` Whether or not to use passive mode when initiating FTP transfers. +.. note:: + + Handling of this setting needs to be implemented inside the :ref:`download + handler `, so it's not guaranteed to be supported + by all 3rd-party handlers. + .. reqmeta:: ftp_password .. setting:: FTP_PASSWORD @@ -1288,6 +1294,12 @@ in ``Request`` meta. .. _RFC 1635: https://datatracker.ietf.org/doc/html/rfc1635 +.. note:: + + Handling of this setting needs to be implemented inside the :ref:`download + handler `, so it's not guaranteed to be supported + by all 3rd-party handlers. + .. reqmeta:: ftp_user .. setting:: FTP_USER @@ -1299,6 +1311,12 @@ Default: ``"anonymous"`` The username to use for FTP connections when there is no ``"ftp_user"`` in ``Request`` meta. +.. note:: + + Handling of this setting needs to be implemented inside the :ref:`download + handler `, so it's not guaranteed to be supported + by all 3rd-party handlers. + .. setting:: GCS_PROJECT_ID GCS_PROJECT_ID diff --git a/scrapy/core/downloader/__init__.py b/scrapy/core/downloader/__init__.py index db4e41c6a..85fa8f3c2 100644 --- a/scrapy/core/downloader/__init__.py +++ b/scrapy/core/downloader/__init__.py @@ -236,9 +236,7 @@ class Downloader: slot.transferring.add(request) try: # 1. Download the response - response: Response = await maybe_deferred_to_future( - self.handlers.download_request(request) - ) + response: Response = await self.handlers.download_request_async(request) # 2. Notify response_downloaded listeners about the recent download # before querying queue for next request self.signals.send_catch_log( diff --git a/scrapy/core/downloader/handlers/__init__.py b/scrapy/core/downloader/handlers/__init__.py index 2d3d40dae..fb27cdb8b 100644 --- a/scrapy/core/downloader/handlers/__init__.py +++ b/scrapy/core/downloader/handlers/__init__.py @@ -2,16 +2,21 @@ from __future__ import annotations +import inspect import logging +import warnings from typing import TYPE_CHECKING, Any, Protocol, cast from scrapy import Request, Spider, signals -from scrapy.exceptions import NotConfigured, NotSupported -from scrapy.utils.decorators import _warn_spider_arg -from scrapy.utils.defer import ensure_awaitable +from scrapy.exceptions import NotConfigured, NotSupported, ScrapyDeprecationWarning +from scrapy.utils.defer import ( + deferred_from_coro, + ensure_awaitable, + maybe_deferred_to_future, +) from scrapy.utils.httpobj import urlparse_cached from scrapy.utils.misc import build_from_crawler, load_object -from scrapy.utils.python import without_none_values +from scrapy.utils.python import global_object_name, without_none_values if TYPE_CHECKING: from collections.abc import Callable @@ -25,10 +30,20 @@ if TYPE_CHECKING: logger = logging.getLogger(__name__) +# This is the official API but we temporarily support the old deprecated one: +# * lazy is not mandatory (defaults to True). +# * download_request() can return a Deferred[Response] instead of a coroutine, +# and takes a spider argument in this case. +# * close() can return None or Deferred[None] instead of a coroutine. +# * close() is not mandatory. + + class DownloadHandlerProtocol(Protocol): - def download_request( - self, request: Request, spider: Spider - ) -> Deferred[Response]: ... + lazy: bool + + async def download_request(self, request: Request) -> Response: ... + + async def close(self) -> None: ... class DownloadHandlers: @@ -40,6 +55,8 @@ class DownloadHandlers: self._handlers: dict[str, DownloadHandlerProtocol] = {} # remembers failed handlers self._notconfigured: dict[str, str] = {} + # remembers handlers with Deferred-based download_request() + self._old_style_handlers: set[str] = set() handlers: dict[str, str | Callable[..., Any]] = without_none_values( cast( "dict[str, str | Callable[..., Any]]", @@ -72,8 +89,17 @@ class DownloadHandlers: path = self._schemes[scheme] try: dhcls: type[DownloadHandlerProtocol] = load_object(path) - if skip_lazy and getattr(dhcls, "lazy", True): - return None + if skip_lazy: + if not hasattr(dhcls, "lazy"): + warnings.warn( + f"{global_object_name(dhcls)} doesn't define a 'lazy' attribute." + f" This is deprecated, please add 'lazy = True' (which is the current" + f" default value) to the class definition.", + category=ScrapyDeprecationWarning, + stacklevel=1, + ) + if getattr(dhcls, "lazy", True): + return None dh = build_from_crawler( dhcls, self._crawler, @@ -91,12 +117,28 @@ class DownloadHandlers: self._notconfigured[scheme] = str(ex) return None self._handlers[scheme] = dh + if not inspect.iscoroutinefunction(dh.download_request): # pragma: no cover + warnings.warn( + f"{global_object_name(dh.download_request)} is not a coroutine function." + f" This is deprecated, please rewrite it to return a coroutine and remove" + f" the 'spider' argument.", + category=ScrapyDeprecationWarning, + stacklevel=1, + ) + self._old_style_handlers.add(scheme) return dh - @_warn_spider_arg def download_request( self, request: Request, spider: Spider | None = None - ) -> Deferred[Response]: + ) -> Deferred[Response]: # pragma: no cover + warnings.warn( + "DownloadHandlers.download_request() is deprecated, use download_request_async() instead", + category=ScrapyDeprecationWarning, + stacklevel=2, + ) + return deferred_from_coro(self.download_request_async(request)) + + async def download_request_async(self, request: Request) -> Response: scheme = urlparse_cached(request).scheme handler = self._get_handler(scheme) if not handler: @@ -104,11 +146,33 @@ class DownloadHandlers: f"Unsupported URL scheme '{scheme}': {self._notconfigured[scheme]}" ) assert self._crawler.spider - return handler.download_request(request, self._crawler.spider) + if scheme in self._old_style_handlers: # pragma: no cover + return await maybe_deferred_to_future( + cast( + "Deferred[Response]", + handler.download_request(request, self._crawler.spider), # type: ignore[call-arg] + ) + ) + return await handler.download_request(request) async def _close(self) -> None: for dh in self._handlers.values(): - if not hasattr(dh, "close"): + if not hasattr(dh, "close"): # pragma: no cover + warnings.warn( + f"{global_object_name(dh)} doesn't define a close() method." + f" This is deprecated, please add an empty 'async def close()' method.", + category=ScrapyDeprecationWarning, + stacklevel=1, + ) continue - await ensure_awaitable(dh.close()) + if inspect.iscoroutinefunction(dh.close): + await dh.close() + else: # pragma: no cover + warnings.warn( + f"{global_object_name(dh.close)} is not a coroutine function." + f" This is deprecated, please rewrite it to return a coroutine.", + category=ScrapyDeprecationWarning, + stacklevel=1, + ) + await ensure_awaitable(dh.close()) diff --git a/scrapy/core/downloader/handlers/base.py b/scrapy/core/downloader/handlers/base.py new file mode 100644 index 000000000..999c003b4 --- /dev/null +++ b/scrapy/core/downloader/handlers/base.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + # typing.Self requires Python 3.11 + from typing_extensions import Self + + from scrapy import Request + from scrapy.crawler import Crawler + from scrapy.http import Response + + +class BaseDownloadHandler(ABC): + """Optional base class for download handlers.""" + + lazy: bool = False + + def __init__(self, crawler: Crawler): + self.crawler = crawler + + @classmethod + def from_crawler(cls, crawler: Crawler) -> Self: + return cls(crawler) + + @abstractmethod + async def download_request(self, request: Request) -> Response: + raise NotImplementedError + + async def close(self) -> None: # noqa: B027 + pass diff --git a/scrapy/core/downloader/handlers/datauri.py b/scrapy/core/downloader/handlers/datauri.py index b3f286d87..84f241d88 100644 --- a/scrapy/core/downloader/handlers/datauri.py +++ b/scrapy/core/downloader/handlers/datauri.py @@ -4,19 +4,16 @@ from typing import TYPE_CHECKING, Any from w3lib.url import parse_data_uri +from scrapy.core.downloader.handlers.base import BaseDownloadHandler from scrapy.http import Response, TextResponse from scrapy.responsetypes import responsetypes -from scrapy.utils.decorators import defers if TYPE_CHECKING: - from scrapy import Request, Spider + from scrapy import Request -class DataURIDownloadHandler: - lazy = False - - @defers - def download_request(self, request: Request, spider: Spider) -> Response: +class DataURIDownloadHandler(BaseDownloadHandler): + async def download_request(self, request: Request) -> Response: uri = parse_data_uri(request.url) respcls = responsetypes.from_mimetype(uri.media_type) diff --git a/scrapy/core/downloader/handlers/file.py b/scrapy/core/downloader/handlers/file.py index d55c516f0..21fd2c353 100644 --- a/scrapy/core/downloader/handlers/file.py +++ b/scrapy/core/downloader/handlers/file.py @@ -5,19 +5,16 @@ from typing import TYPE_CHECKING from w3lib.url import file_uri_to_path +from scrapy.core.downloader.handlers.base import BaseDownloadHandler from scrapy.responsetypes import responsetypes -from scrapy.utils.decorators import defers if TYPE_CHECKING: - from scrapy import Request, Spider + from scrapy import Request from scrapy.http import Response -class FileDownloadHandler: - lazy = False - - @defers - def download_request(self, request: Request, spider: Spider) -> Response: +class FileDownloadHandler(BaseDownloadHandler): + async def download_request(self, request: Request) -> Response: filepath = file_uri_to_path(request.url) body = Path(filepath).read_bytes() respcls = responsetypes.from_args(filename=filepath, body=body) diff --git a/scrapy/core/downloader/handlers/ftp.py b/scrapy/core/downloader/handlers/ftp.py index 1d947b1e3..f261e8e7f 100644 --- a/scrapy/core/downloader/handlers/ftp.py +++ b/scrapy/core/downloader/handlers/ftp.py @@ -33,27 +33,22 @@ from __future__ import annotations import re from io import BytesIO from pathlib import Path -from typing import TYPE_CHECKING, Any, BinaryIO +from typing import TYPE_CHECKING, BinaryIO from urllib.parse import unquote from twisted.internet.protocol import ClientCreator, Protocol +from scrapy.core.downloader.handlers.base import BaseDownloadHandler from scrapy.http import Response from scrapy.responsetypes import responsetypes +from scrapy.utils.defer import maybe_deferred_to_future from scrapy.utils.httpobj import urlparse_cached -from scrapy.utils.python import to_bytes if TYPE_CHECKING: - from twisted.internet.defer import Deferred from twisted.protocols.ftp import FTPClient - from twisted.python.failure import Failure - # typing.Self requires Python 3.11 - from typing_extensions import Self - - from scrapy import Request, Spider + from scrapy import Request from scrapy.crawler import Crawler - from scrapy.settings import BaseSettings class ReceivedDataProtocol(Protocol): @@ -82,26 +77,21 @@ class ReceivedDataProtocol(Protocol): _CODE_RE = re.compile(r"\d+") -class FTPDownloadHandler: - lazy = False - +class FTPDownloadHandler(BaseDownloadHandler): CODE_MAPPING: dict[str, int] = { "550": 404, "default": 503, } - def __init__(self, settings: BaseSettings): - self.default_user = settings["FTP_USER"] - self.default_password = settings["FTP_PASSWORD"] - self.passive_mode = settings["FTP_PASSIVE_MODE"] + def __init__(self, crawler: Crawler): + super().__init__(crawler) + self.default_user = crawler.settings["FTP_USER"] + self.default_password = crawler.settings["FTP_PASSWORD"] + self.passive_mode = crawler.settings["FTP_PASSIVE_MODE"] - @classmethod - def from_crawler(cls, crawler: Crawler) -> Self: - return cls(crawler.settings) - - def download_request(self, request: Request, spider: Spider) -> Deferred[Response]: + async def download_request(self, request: Request) -> Response: from twisted.internet import reactor - from twisted.protocols.ftp import FTPClient + from twisted.protocols.ftp import CommandFailed, FTPClient parsed_url = urlparse_cached(request) user = request.meta.get("ftp_user", self.default_user) @@ -112,43 +102,23 @@ class FTPDownloadHandler: creator = ClientCreator( reactor, FTPClient, user, password, passive=passive_mode ) - dfd: Deferred[FTPClient] = creator.connectTCP( - parsed_url.hostname, parsed_url.port or 21 + client: FTPClient = await maybe_deferred_to_future( + creator.connectTCP(parsed_url.hostname, parsed_url.port or 21) ) - return dfd.addCallback(self.gotClient, request, unquote(parsed_url.path)) - - def gotClient( - self, client: FTPClient, request: Request, filepath: str - ) -> Deferred[Response]: - self.client = client + filepath = unquote(parsed_url.path) protocol = ReceivedDataProtocol(request.meta.get("ftp_local_filename")) - d = client.retrieveFile(filepath, protocol) - d.addCallback(self._build_response, request, protocol) - d.addErrback(self._failed, request) - return d - - def _build_response( - self, result: Any, request: Request, protocol: ReceivedDataProtocol - ) -> Response: - self.result = result + try: + await maybe_deferred_to_future(client.retrieveFile(filepath, protocol)) + except CommandFailed as e: + message = str(e) + if m := _CODE_RE.search(message): + ftpcode = m.group() + httpcode = self.CODE_MAPPING.get(ftpcode, self.CODE_MAPPING["default"]) + return Response(url=request.url, status=httpcode, body=message.encode()) + raise protocol.close() headers = {"local filename": protocol.filename or b"", "size": protocol.size} body = protocol.filename or protocol.body.read() respcls = responsetypes.from_args(url=request.url, body=body) # hints for Headers-related types may need to be fixed to not use AnyStr return respcls(url=request.url, status=200, body=body, headers=headers) # type: ignore[arg-type] - - def _failed(self, result: Failure, request: Request) -> Response: - from twisted.protocols.ftp import CommandFailed - - message = result.getErrorMessage() - if result.type == CommandFailed: - m = _CODE_RE.search(message) - if m: - ftpcode = m.group() - httpcode = self.CODE_MAPPING.get(ftpcode, self.CODE_MAPPING["default"]) - return Response( - url=request.url, status=httpcode, body=to_bytes(message) - ) - assert result.type - raise result.type(result.value) diff --git a/scrapy/core/downloader/handlers/http10.py b/scrapy/core/downloader/handlers/http10.py index 0fbe5fc23..af3588019 100644 --- a/scrapy/core/downloader/handlers/http10.py +++ b/scrapy/core/downloader/handlers/http10.py @@ -6,17 +6,17 @@ import warnings from typing import TYPE_CHECKING from scrapy.exceptions import ScrapyDeprecationWarning +from scrapy.utils.defer import maybe_deferred_to_future from scrapy.utils.misc import build_from_crawler, load_object from scrapy.utils.python import to_unicode if TYPE_CHECKING: - from twisted.internet.defer import Deferred from twisted.internet.interfaces import IConnector # typing.Self requires Python 3.11 from typing_extensions import Self - from scrapy import Request, Spider + from scrapy import Request from scrapy.core.downloader.contextfactory import ScrapyClientContextFactory from scrapy.core.downloader.webclient import ScrapyHTTPClientFactory from scrapy.crawler import Crawler @@ -46,11 +46,10 @@ class HTTP10DownloadHandler: def from_crawler(cls, crawler: Crawler) -> Self: return cls(crawler.settings, crawler) - def download_request(self, request: Request, spider: Spider) -> Deferred[Response]: - """Return a deferred for the HTTP download""" + async def download_request(self, request: Request) -> Response: factory = self.HTTPClientFactory(request) self._connect(factory) - return factory.deferred + return await maybe_deferred_to_future(factory.deferred) def _connect(self, factory: ScrapyHTTPClientFactory) -> IConnector: from twisted.internet import reactor @@ -63,3 +62,6 @@ class HTTP10DownloadHandler: ) return reactor.connectSSL(host, port, factory, client_context_factory) return reactor.connectTCP(host, port, factory) + + async def close(self) -> None: + pass diff --git a/scrapy/core/downloader/handlers/http11.py b/scrapy/core/downloader/handlers/http11.py index 02aaf7c5c..cec73566a 100644 --- a/scrapy/core/downloader/handlers/http11.py +++ b/scrapy/core/downloader/handlers/http11.py @@ -30,11 +30,13 @@ from twisted.web.http_headers import Headers as TxHeaders from twisted.web.iweb import UNKNOWN_LENGTH, IBodyProducer, IPolicyForHTTPS, IResponse from zope.interface import implementer -from scrapy import Request, Spider, signals +from scrapy import Request, signals from scrapy.core.downloader.contextfactory import load_context_factory_from_settings +from scrapy.core.downloader.handlers.base import BaseDownloadHandler from scrapy.exceptions import StopDownload from scrapy.http import Headers, Response from scrapy.responsetypes import responsetypes +from scrapy.utils.defer import maybe_deferred_to_future from scrapy.utils.deprecate import warn_on_deprecated_spider_attribute from scrapy.utils.httpobj import urlparse_cached from scrapy.utils.python import to_bytes, to_unicode @@ -44,11 +46,10 @@ if TYPE_CHECKING: from twisted.internet.base import ReactorBase from twisted.internet.interfaces import IConsumer - # typing.NotRequired and typing.Self require Python 3.11 - from typing_extensions import NotRequired, Self + # typing.NotRequired requires Python 3.11 + from typing_extensions import NotRequired from scrapy.crawler import Crawler - from scrapy.settings import BaseSettings logger = logging.getLogger(__name__) @@ -65,37 +66,34 @@ class _ResultT(TypedDict): failure: NotRequired[Failure | None] -class HTTP11DownloadHandler: - lazy = False - - def __init__(self, settings: BaseSettings, crawler: Crawler): +class HTTP11DownloadHandler(BaseDownloadHandler): + def __init__(self, crawler: Crawler): + super().__init__(crawler) self._crawler = crawler from twisted.internet import reactor self._pool: HTTPConnectionPool = HTTPConnectionPool(reactor, persistent=True) - self._pool.maxPersistentPerHost = settings.getint( + self._pool.maxPersistentPerHost = crawler.settings.getint( "CONCURRENT_REQUESTS_PER_DOMAIN" ) self._pool._factory.noisy = False self._contextFactory: IPolicyForHTTPS = load_context_factory_from_settings( - settings, crawler + crawler.settings, crawler + ) + self._default_maxsize: int = crawler.settings.getint("DOWNLOAD_MAXSIZE") + self._default_warnsize: int = crawler.settings.getint("DOWNLOAD_WARNSIZE") + self._fail_on_dataloss: bool = crawler.settings.getbool( + "DOWNLOAD_FAIL_ON_DATALOSS" ) - self._default_maxsize: int = settings.getint("DOWNLOAD_MAXSIZE") - self._default_warnsize: int = settings.getint("DOWNLOAD_WARNSIZE") - self._fail_on_dataloss: bool = settings.getbool("DOWNLOAD_FAIL_ON_DATALOSS") self._disconnect_timeout: int = 1 - @classmethod - def from_crawler(cls, crawler: Crawler) -> Self: - return cls(crawler.settings, crawler) - - def download_request(self, request: Request, spider: Spider) -> Deferred[Response]: + async def download_request(self, request: Request) -> Response: """Return a deferred for the HTTP download""" - if hasattr(spider, "download_maxsize"): # pragma: no cover + if hasattr(self._crawler.spider, "download_maxsize"): # pragma: no cover warn_on_deprecated_spider_attribute("download_maxsize", "DOWNLOAD_MAXSIZE") - if hasattr(spider, "download_warnsize"): # pragma: no cover + if hasattr(self._crawler.spider, "download_warnsize"): # pragma: no cover warn_on_deprecated_spider_attribute( "download_warnsize", "DOWNLOAD_WARNSIZE" ) @@ -103,14 +101,18 @@ class HTTP11DownloadHandler: agent = ScrapyAgent( contextFactory=self._contextFactory, pool=self._pool, - maxsize=getattr(spider, "download_maxsize", self._default_maxsize), - warnsize=getattr(spider, "download_warnsize", self._default_warnsize), + maxsize=getattr( + self._crawler.spider, "download_maxsize", self._default_maxsize + ), + warnsize=getattr( + self._crawler.spider, "download_warnsize", self._default_warnsize + ), fail_on_dataloss=self._fail_on_dataloss, crawler=self._crawler, ) - return agent.download_request(request) + return await maybe_deferred_to_future(agent.download_request(request)) - def close(self) -> Deferred[None]: + async def close(self) -> None: from twisted.internet import reactor d: Deferred[None] = self._pool.closeCachedConnections() @@ -118,19 +120,19 @@ class HTTP11DownloadHandler: # we'll manually timeout the deferred. # # Twisted issue addressing this problem can be found here: - # https://twistedmatrix.com/trac/ticket/7738. + # https://github.com/twisted/twisted/issues/7738 # # closeCachedConnections doesn't handle external errbacks, so we'll # issue a callback after `_disconnect_timeout` seconds. + # + # See also https://github.com/scrapy/scrapy/issues/2653 delayed_call = reactor.callLater(self._disconnect_timeout, d.callback, []) - def cancel_delayed_call(result: _T) -> _T: + try: + await maybe_deferred_to_future(d) + finally: if delayed_call.active(): delayed_call.cancel() - return result - - d.addBoth(cancel_delayed_call) - return d class TunnelError(Exception): diff --git a/scrapy/core/downloader/handlers/http2.py b/scrapy/core/downloader/handlers/http2.py index 8e6236094..ff6e8ab98 100644 --- a/scrapy/core/downloader/handlers/http2.py +++ b/scrapy/core/downloader/handlers/http2.py @@ -8,7 +8,9 @@ from twisted.internet.error import TimeoutError as TxTimeoutError from twisted.web.client import URI from scrapy.core.downloader.contextfactory import load_context_factory_from_settings +from scrapy.core.downloader.handlers.base import BaseDownloadHandler from scrapy.core.http2.agent import H2Agent, H2ConnectionPool, ScrapyProxyH2Agent +from scrapy.utils.defer import maybe_deferred_to_future from scrapy.utils.httpobj import urlparse_cached from scrapy.utils.python import to_bytes @@ -17,37 +19,37 @@ if TYPE_CHECKING: from twisted.internet.defer import Deferred from twisted.web.iweb import IPolicyForHTTPS - # typing.Self requires Python 3.11 - from typing_extensions import Self - from scrapy.crawler import Crawler from scrapy.http import Request, Response - from scrapy.settings import Settings from scrapy.spiders import Spider -class H2DownloadHandler: - def __init__(self, settings: Settings, crawler: Crawler): +class H2DownloadHandler(BaseDownloadHandler): + lazy = True + + def __init__(self, crawler: Crawler): + super().__init__(crawler) self._crawler = crawler from twisted.internet import reactor - self._pool = H2ConnectionPool(reactor, settings) - self._context_factory = load_context_factory_from_settings(settings, crawler) + self._pool = H2ConnectionPool(reactor, crawler.settings) + self._context_factory = load_context_factory_from_settings( + crawler.settings, crawler + ) - @classmethod - def from_crawler(cls, crawler: Crawler) -> Self: - return cls(crawler.settings, crawler) - - def download_request(self, request: Request, spider: Spider) -> Deferred[Response]: + async def download_request(self, request: Request) -> Response: agent = ScrapyH2Agent( context_factory=self._context_factory, pool=self._pool, crawler=self._crawler, ) - return agent.download_request(request, spider) + assert self._crawler.spider + return await maybe_deferred_to_future( + agent.download_request(request, self._crawler.spider) + ) - def close(self) -> None: + async def close(self) -> None: self._pool.close_connections() diff --git a/scrapy/core/downloader/handlers/s3.py b/scrapy/core/downloader/handlers/s3.py index 05a71b742..a601bc2aa 100644 --- a/scrapy/core/downloader/handlers/s3.py +++ b/scrapy/core/downloader/handlers/s3.py @@ -1,7 +1,8 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING +from scrapy.core.downloader.handlers.base import BaseDownloadHandler from scrapy.core.downloader.handlers.http11 import HTTP11DownloadHandler from scrapy.exceptions import NotConfigured from scrapy.utils.boto import is_botocore_available @@ -9,57 +10,28 @@ from scrapy.utils.httpobj import urlparse_cached from scrapy.utils.misc import build_from_crawler if TYPE_CHECKING: - from twisted.internet.defer import Deferred - - # typing.Self requires Python 3.11 - from typing_extensions import Self - - from scrapy import Request, Spider + from scrapy import Request from scrapy.crawler import Crawler from scrapy.http import Response - from scrapy.settings import BaseSettings -class S3DownloadHandler: - def __init__( - self, - settings: BaseSettings, - *, - crawler: Crawler, - aws_access_key_id: str | None = None, - aws_secret_access_key: str | None = None, - aws_session_token: str | None = None, - httpdownloadhandler: type[HTTP11DownloadHandler] = HTTP11DownloadHandler, - **kw: Any, - ): +class S3DownloadHandler(BaseDownloadHandler): + lazy = True + + def __init__(self, crawler: Crawler): if not is_botocore_available(): raise NotConfigured("missing botocore library") - if not aws_access_key_id: - aws_access_key_id = settings["AWS_ACCESS_KEY_ID"] - if not aws_secret_access_key: - aws_secret_access_key = settings["AWS_SECRET_ACCESS_KEY"] - if not aws_session_token: - aws_session_token = settings["AWS_SESSION_TOKEN"] - - # If no credentials could be found anywhere, - # consider this an anonymous connection request by default; - # unless 'anon' was set explicitly (True/False). - anon = kw.get("anon") - if anon is None and not aws_access_key_id and not aws_secret_access_key: - kw["anon"] = True - self.anon = kw.get("anon") - + super().__init__(crawler) + aws_access_key_id = crawler.settings["AWS_ACCESS_KEY_ID"] + aws_secret_access_key = crawler.settings["AWS_SECRET_ACCESS_KEY"] + aws_session_token = crawler.settings["AWS_SESSION_TOKEN"] + self.anon = not aws_access_key_id and not aws_secret_access_key self._signer = None - import botocore.auth # noqa: PLC0415 - import botocore.credentials # noqa: PLC0415 - - kw.pop("anon", None) - if kw: - raise TypeError(f"Unexpected keyword arguments: {kw}") if not self.anon: - assert aws_access_key_id is not None - assert aws_secret_access_key is not None + import botocore.auth # noqa: PLC0415 + import botocore.credentials # noqa: PLC0415 + SignerCls = botocore.auth.AUTH_TYPE_MAPS["s3"] # botocore.auth.BaseSigner doesn't have an __init__() with args, only subclasses do self._signer = SignerCls( # type: ignore[call-arg] @@ -68,17 +40,10 @@ class S3DownloadHandler: ) ) - _http_handler = build_from_crawler( - httpdownloadhandler, - crawler, - ) + _http_handler = build_from_crawler(HTTP11DownloadHandler, crawler) self._download_http = _http_handler.download_request - @classmethod - def from_crawler(cls, crawler: Crawler, **kwargs: Any) -> Self: - return cls(crawler.settings, crawler=crawler, **kwargs) - - def download_request(self, request: Request, spider: Spider) -> Deferred[Response]: + async def download_request(self, request: Request) -> Response: p = urlparse_cached(request) scheme = "https" if request.meta.get("is_secure") else "http" bucket = p.hostname @@ -98,4 +63,4 @@ class S3DownloadHandler: assert self._signer self._signer.add_auth(awsrequest) request = request.replace(url=url, headers=awsrequest.headers.items()) - return self._download_http(request, spider) + return await self._download_http(request) diff --git a/scrapy/extensions/telnet.py b/scrapy/extensions/telnet.py index 094a0195e..d24c1b6c4 100644 --- a/scrapy/extensions/telnet.py +++ b/scrapy/extensions/telnet.py @@ -15,10 +15,10 @@ from typing import TYPE_CHECKING, Any from twisted.conch import telnet from twisted.conch.insults import insults from twisted.internet import protocol +from twisted.internet.defer import fail, succeed from scrapy import signals from scrapy.exceptions import NotConfigured -from scrapy.utils.decorators import defers from scrapy.utils.engine import print_engine_status from scrapy.utils.reactor import listen_tcp from scrapy.utils.trackref import print_live_refs @@ -80,20 +80,19 @@ class TelnetConsole(protocol.ServerFactory): class Portal: """An implementation of IPortal""" - @defers def login(self_, credentials, mind, *interfaces): # pylint: disable=no-self-argument if not ( credentials.username == self.username.encode("utf8") and credentials.checkPassword(self.password.encode("utf8")) ): - raise ValueError("Invalid credentials") + return fail(ValueError("Invalid credentials")) from twisted.conch import manhole protocol = telnet.TelnetBootstrapProtocol( insults.ServerProtocol, manhole.Manhole, self._get_telnet_vars() ) - return (interfaces[0], protocol, lambda: None) + return succeed((interfaces[0], protocol, lambda: None)) return telnet.TelnetTransport(telnet.AuthenticatingTelnetProtocol, Portal()) diff --git a/scrapy/utils/decorators.py b/scrapy/utils/decorators.py index 2dde0aa55..044d1d4c7 100644 --- a/scrapy/utils/decorators.py +++ b/scrapy/utils/decorators.py @@ -42,8 +42,13 @@ def deprecated( return deco -def defers(func: Callable[_P, _T]) -> Callable[_P, Deferred[_T]]: +def defers(func: Callable[_P, _T]) -> Callable[_P, Deferred[_T]]: # pragma: no cover """Decorator to make sure a function always returns a deferred""" + warnings.warn( + "@defers is deprecated, you can use maybeDeferred() directly if needed.", + category=ScrapyDeprecationWarning, + stacklevel=2, + ) @wraps(func) def wrapped(*a: _P.args, **kw: _P.kwargs) -> Deferred[_T]: diff --git a/tests/test_crawl.py b/tests/test_crawl.py index 699ac3911..9fd5a40df 100644 --- a/tests/test_crawl.py +++ b/tests/test_crawl.py @@ -420,6 +420,12 @@ with multiples lines self._assert_retried(caplog.text) assert "Got response 200" in caplog.text + @deferred_f_from_coro_f + async def test_unknown_url_scheme(self, caplog: pytest.LogCaptureFixture) -> None: + crawler = get_crawler(SimpleSpider) + await maybe_deferred_to_future(crawler.crawl("foo://bar")) + assert "NotSupported: Unsupported URL scheme 'foo'" in caplog.text + class TestCrawlSpider: mockserver: MockServer diff --git a/tests/test_downloader_handler_twisted_ftp.py b/tests/test_downloader_handler_twisted_ftp.py index a43fe318d..6cda607cc 100644 --- a/tests/test_downloader_handler_twisted_ftp.py +++ b/tests/test_downloader_handler_twisted_ftp.py @@ -2,6 +2,7 @@ from __future__ import annotations import os import sys +from abc import ABC, abstractmethod from pathlib import Path from tempfile import mkstemp from typing import TYPE_CHECKING, Any @@ -13,17 +14,18 @@ from twisted.cred import checkers, credentials, portal from scrapy.core.downloader.handlers.ftp import FTPDownloadHandler from scrapy.http import HtmlResponse, Request, Response from scrapy.http.response.text import TextResponse -from scrapy.utils.defer import deferred_f_from_coro_f, maybe_deferred_to_future +from scrapy.utils.defer import deferred_f_from_coro_f from scrapy.utils.misc import build_from_crawler from scrapy.utils.python import to_bytes -from scrapy.utils.spider import DefaultSpider from scrapy.utils.test import get_crawler if TYPE_CHECKING: from collections.abc import AsyncGenerator, Generator + from twisted.protocols.ftp import FTPFactory -class TestFTPBase: + +class TestFTPBase(ABC): username = "scrapy" password = "passwd" req_meta: dict[str, Any] = {"ftp_user": username, "ftp_password": password} @@ -34,21 +36,13 @@ class TestFTPBase: ("html-file-without-extension", b"\n."), ) + @abstractmethod def _create_files(self, root: Path) -> None: - userdir = root / self.username - userdir.mkdir() - for filename, content in self.test_files: - (userdir / filename).write_bytes(content) + raise NotImplementedError - def _get_factory(self, root): - from twisted.protocols.ftp import FTPFactory, FTPRealm - - realm = FTPRealm(anonymousRoot=str(root), userHome=str(root)) - p = portal.Portal(realm) - users_checker = checkers.InMemoryUsernamePasswordDatabaseDontUse() - users_checker.addUser(self.username, self.password) - p.registerChecker(users_checker, credentials.IUsernamePassword) - return FTPFactory(portal=p) + @abstractmethod + def _get_factory(self, tmp_path: Path) -> FTPFactory: + raise NotImplementedError @async_yield_fixture async def server_url(self, tmp_path: Path) -> AsyncGenerator[str]: @@ -76,18 +70,12 @@ class TestFTPBase: assert dh.client.transport dh.client.transport.loseConnection() - @staticmethod - async def download_request(dh: FTPDownloadHandler, request: Request) -> Response: - return await maybe_deferred_to_future( - dh.download_request(request, DefaultSpider()) - ) - @deferred_f_from_coro_f async def test_ftp_download_success( self, server_url: str, dh: FTPDownloadHandler ) -> None: request = Request(url=server_url + "file.txt", meta=self.req_meta) - r = await self.download_request(dh, request) + r = await dh.download_request(request) assert r.status == 200 assert r.body == b"I have the power!" assert r.headers == {b"Local Filename": [b""], b"Size": [b"17"]} @@ -101,7 +89,7 @@ class TestFTPBase: url=server_url + "file with spaces.txt", meta=self.req_meta, ) - r = await self.download_request(dh, request) + r = await dh.download_request(request) assert r.status == 200 assert r.body == b"Moooooooooo power!" assert r.headers == {b"Local Filename": [b""], b"Size": [b"18"]} @@ -111,8 +99,9 @@ class TestFTPBase: self, server_url: str, dh: FTPDownloadHandler ) -> None: request = Request(url=server_url + "nonexistent.txt", meta=self.req_meta) - r = await self.download_request(dh, request) + r = await dh.download_request(request) assert r.status == 404 + assert r.body == b"['550 nonexistent.txt: No such file or directory.']" @deferred_f_from_coro_f async def test_ftp_local_filename( @@ -125,7 +114,7 @@ class TestFTPBase: meta = {"ftp_local_filename": fname_bytes} meta.update(self.req_meta) request = Request(url=server_url + "file.txt", meta=meta) - r = await self.download_request(dh, request) + r = await dh.download_request(request) assert r.body == fname_bytes assert r.headers == {b"Local Filename": [fname_bytes], b"Size": [b"17"]} assert local_path.exists() @@ -153,12 +142,28 @@ class TestFTPBase: meta = {} meta.update(self.req_meta) request = Request(url=server_url + filename, meta=meta) - r = await self.download_request(dh, request) + r = await dh.download_request(request) assert type(r) is response_class # pylint: disable=unidiomatic-typecheck local_fname_path.unlink() class TestFTP(TestFTPBase): + def _create_files(self, root: Path) -> None: + userdir = root / self.username + userdir.mkdir() + for filename, content in self.test_files: + (userdir / filename).write_bytes(content) + + def _get_factory(self, root): + from twisted.protocols.ftp import FTPFactory, FTPRealm + + realm = FTPRealm(anonymousRoot=str(root), userHome=str(root)) + p = portal.Portal(realm) + users_checker = checkers.InMemoryUsernamePasswordDatabaseDontUse() + users_checker.addUser(self.username, self.password) + p.registerChecker(users_checker, credentials.IUsernamePassword) + return FTPFactory(portal=p) + @deferred_f_from_coro_f async def test_invalid_credentials( self, server_url: str, dh: FTPDownloadHandler, reactor_pytest: str @@ -174,7 +179,7 @@ class TestFTP(TestFTPBase): meta.update({"ftp_password": "invalid"}) request = Request(url=server_url + "file.txt", meta=meta) with pytest.raises(ConnectionLost): - await self.download_request(dh, request) + await dh.download_request(request) class TestAnonymousFTP(TestFTPBase): diff --git a/tests/test_downloader_handler_twisted_http10.py b/tests/test_downloader_handler_twisted_http10.py index c3e0aa7c4..4745869e4 100644 --- a/tests/test_downloader_handler_twisted_http10.py +++ b/tests/test_downloader_handler_twisted_http10.py @@ -9,11 +9,7 @@ import pytest from scrapy.core.downloader.handlers.http10 import HTTP10DownloadHandler from scrapy.http import Request from scrapy.utils.defer import deferred_f_from_coro_f -from tests.test_downloader_handlers_http_base import ( - TestHttpBase, - TestHttpProxyBase, - download_request, -) +from tests.test_downloader_handlers_http_base import TestHttpBase, TestHttpProxyBase if TYPE_CHECKING: from scrapy.core.downloader.handlers import DownloadHandlerProtocol @@ -31,13 +27,12 @@ class TestHttp10(HTTP10DownloadHandlerMixin, TestHttpBase): """HTTP 1.0 test case""" @deferred_f_from_coro_f - async def test_protocol( - self, mockserver: MockServer, download_handler: DownloadHandlerProtocol - ) -> None: + async def test_protocol(self, mockserver: MockServer) -> None: request = Request( mockserver.url("/host", is_secure=self.is_secure), method="GET" ) - response = await download_request(download_handler, request) + async with self.get_dh() as download_handler: + response = await download_handler.download_request(request) assert response.protocol == "HTTP/1.0" diff --git a/tests/test_downloader_handler_twisted_http2.py b/tests/test_downloader_handler_twisted_http2.py index 1e638392f..f60d5e7b5 100644 --- a/tests/test_downloader_handler_twisted_http2.py +++ b/tests/test_downloader_handler_twisted_http2.py @@ -13,10 +13,7 @@ from twisted.web.error import SchemeNotSupported from twisted.web.http import H2_ENABLED from scrapy.http import Request -from scrapy.spiders import Spider from scrapy.utils.defer import deferred_f_from_coro_f, maybe_deferred_to_future -from scrapy.utils.misc import build_from_crawler -from scrapy.utils.test import get_crawler from tests.test_downloader_handlers_http_base import ( TestHttpProxyBase, TestHttps11Base, @@ -25,7 +22,6 @@ from tests.test_downloader_handlers_http_base import ( TestHttpsInvalidDNSPatternBase, TestHttpsWrongHostnameBase, TestHttpWithCrawlerBase, - download_request, ) if TYPE_CHECKING: @@ -53,13 +49,12 @@ class TestHttps2(H2DownloadHandlerMixin, TestHttps11Base): HTTP2_DATALOSS_SKIP_REASON = "Content-Length mismatch raises InvalidBodyLengthError" @deferred_f_from_coro_f - async def test_protocol( - self, mockserver: MockServer, download_handler: DownloadHandlerProtocol - ) -> None: + async def test_protocol(self, mockserver: MockServer) -> None: request = Request( mockserver.url("/host", is_secure=self.is_secure), method="GET" ) - response = await download_request(download_handler, request) + async with self.get_dh() as download_handler: + response = await download_handler.download_request(request) assert response.protocol == "h2" @deferred_f_from_coro_f @@ -68,9 +63,6 @@ class TestHttps2(H2DownloadHandlerMixin, TestHttps11Base): ) -> None: from twisted.internet import reactor - crawler = get_crawler(settings_dict={"DOWNLOAD_MAXSIZE": 1_500}) - download_handler = build_from_crawler(self.download_handler_cls, crawler) - with mock.patch("scrapy.core.http2.stream.logger") as logger: request = Request( mockserver.url("/largechunkedfile", is_secure=self.is_secure) @@ -79,8 +71,9 @@ class TestHttps2(H2DownloadHandlerMixin, TestHttps11Base): def check(logger: mock.Mock) -> None: logger.error.assert_called_once_with(mock.ANY) - with pytest.raises((defer.CancelledError, error.ConnectionAborted)): - await download_request(download_handler, request, Spider("foo")) + async with self.get_dh({"DOWNLOAD_MAXSIZE": 1_500}) as download_handler: + with pytest.raises((defer.CancelledError, error.ConnectionAborted)): + await download_handler.download_request(request) # As the error message is logged in the dataReceived callback, we # have to give a bit of time to the reactor to process the queue @@ -91,12 +84,11 @@ class TestHttps2(H2DownloadHandlerMixin, TestHttps11Base): await maybe_deferred_to_future(d) @deferred_f_from_coro_f - async def test_unsupported_scheme( - self, download_handler: DownloadHandlerProtocol - ) -> None: + async def test_unsupported_scheme(self) -> None: request = Request("ftp://unsupported.scheme") - with pytest.raises(SchemeNotSupported): - await download_request(download_handler, request) + async with self.get_dh() as download_handler: + with pytest.raises(SchemeNotSupported): + await download_handler.download_request(request) def test_download_cause_data_loss(self) -> None: # type: ignore[override] pytest.skip(self.HTTP2_DATALOSS_SKIP_REASON) @@ -109,49 +101,46 @@ class TestHttps2(H2DownloadHandlerMixin, TestHttps11Base): @deferred_f_from_coro_f async def test_concurrent_requests_same_domain( - self, mockserver: MockServer, download_handler: DownloadHandlerProtocol + self, mockserver: MockServer ) -> None: request1 = Request(mockserver.url("/text", is_secure=self.is_secure)) - response1 = await download_request(download_handler, request1) - assert response1.body == b"Works" - request2 = Request( mockserver.url("/echo", is_secure=self.is_secure), method="POST" ) - response2 = await download_request(download_handler, request2) - assert response2.headers["Content-Length"] == b"79" + async with self.get_dh() as download_handler: + response1 = await download_handler.download_request(request1) + assert response1.body == b"Works" + response2 = await download_handler.download_request(request2) + assert response2.headers["Content-Length"] == b"79" @pytest.mark.xfail(reason="https://github.com/python-hyper/h2/issues/1247") @deferred_f_from_coro_f - async def test_connect_request( - self, mockserver: MockServer, download_handler: DownloadHandlerProtocol - ) -> None: + async def test_connect_request(self, mockserver: MockServer) -> None: request = Request( mockserver.url("/file", is_secure=self.is_secure), method="CONNECT" ) - response = await download_request(download_handler, request) + async with self.get_dh() as download_handler: + response = await download_handler.download_request(request) assert response.body == b"" @deferred_f_from_coro_f - async def test_custom_content_length_good( - self, mockserver: MockServer, download_handler: DownloadHandlerProtocol - ) -> None: + async def test_custom_content_length_good(self, mockserver: MockServer) -> None: request = Request(mockserver.url("/contentlength", is_secure=self.is_secure)) custom_content_length = str(len(request.body)) request.headers["Content-Length"] = custom_content_length - response = await download_request(download_handler, request) + async with self.get_dh() as download_handler: + response = await download_handler.download_request(request) assert response.text == custom_content_length @deferred_f_from_coro_f - async def test_custom_content_length_bad( - self, mockserver: MockServer, download_handler: DownloadHandlerProtocol - ) -> None: + async def test_custom_content_length_bad(self, mockserver: MockServer) -> None: request = Request(mockserver.url("/contentlength", is_secure=self.is_secure)) actual_content_length = str(len(request.body)) bad_content_length = str(len(request.body) + 1) request.headers["Content-Length"] = bad_content_length - with LogCapture() as log: - response = await download_request(download_handler, request) + async with self.get_dh() as download_handler: + with LogCapture() as log: + response = await download_handler.download_request(request) assert response.text == actual_content_length log.check_present( ( @@ -164,14 +153,13 @@ class TestHttps2(H2DownloadHandlerMixin, TestHttps11Base): ) @deferred_f_from_coro_f - async def test_duplicate_header( - self, mockserver: MockServer, download_handler: DownloadHandlerProtocol - ) -> None: + async def test_duplicate_header(self, mockserver: MockServer) -> None: request = Request(mockserver.url("/echo", is_secure=self.is_secure)) header, value1, value2 = "Custom-Header", "foo", "bar" request.headers.appendlist(header, value1) request.headers.appendlist(header, value2) - response = await download_request(download_handler, request) + async with self.get_dh() as download_handler: + response = await download_handler.download_request(request) assert json.loads(response.text)["headers"][header] == [value1, value2] @@ -213,26 +201,18 @@ class TestHttps2Proxy(H2DownloadHandlerMixin, TestHttpProxyBase): @deferred_f_from_coro_f async def test_download_with_proxy_https_timeout( - self, - proxy_mockserver: ProxyEchoMockServer, - download_handler: DownloadHandlerProtocol, + self, proxy_mockserver: ProxyEchoMockServer ) -> None: with pytest.raises(NotImplementedError): await maybe_deferred_to_future( - super().test_download_with_proxy_https_timeout( - proxy_mockserver, download_handler - ) + super().test_download_with_proxy_https_timeout(proxy_mockserver) ) @deferred_f_from_coro_f async def test_download_with_proxy_without_http_scheme( - self, - proxy_mockserver: ProxyEchoMockServer, - download_handler: DownloadHandlerProtocol, + self, proxy_mockserver: ProxyEchoMockServer ) -> None: with pytest.raises(SchemeNotSupported): await maybe_deferred_to_future( - super().test_download_with_proxy_without_http_scheme( - proxy_mockserver, download_handler - ) + super().test_download_with_proxy_without_http_scheme(proxy_mockserver) ) diff --git a/tests/test_downloader_handlers.py b/tests/test_downloader_handlers.py index e37932fbb..602b2186f 100644 --- a/tests/test_downloader_handlers.py +++ b/tests/test_downloader_handlers.py @@ -15,22 +15,26 @@ from scrapy.core.downloader.handlers import DownloadHandlers from scrapy.core.downloader.handlers.datauri import DataURIDownloadHandler from scrapy.core.downloader.handlers.file import FileDownloadHandler from scrapy.core.downloader.handlers.s3 import S3DownloadHandler -from scrapy.exceptions import NotConfigured -from scrapy.http import Request, Response +from scrapy.exceptions import NotConfigured, ScrapyDeprecationWarning +from scrapy.http import Request from scrapy.responsetypes import responsetypes -from scrapy.utils.defer import deferred_f_from_coro_f, maybe_deferred_to_future +from scrapy.utils.boto import is_botocore_available +from scrapy.utils.defer import deferred_f_from_coro_f from scrapy.utils.misc import build_from_crawler -from scrapy.utils.spider import DefaultSpider from scrapy.utils.test import get_crawler class DummyDH: lazy = False + async def download_request(self, request): + pass + class DummyLazyDH: - # Default is lazy for backward compatibility - pass + # Default (but deprecated) is lazy for backward compatibility + async def download_request(self, request): + pass class OffDH: @@ -44,6 +48,17 @@ class OffDH: return cls(crawler) +class BuggyDH: + lazy = False + + def __init__(self, crawler): + raise ValueError + + @classmethod + def from_crawler(cls, crawler): + return cls(crawler) + + class TestLoad: def test_enabled_handler(self): handlers = {"scheme": DummyDH} @@ -61,6 +76,18 @@ class TestLoad: assert "scheme" not in dh._handlers assert "scheme" in dh._notconfigured + def test_buggy_handler(self, caplog: pytest.LogCaptureFixture) -> None: + handlers = {"scheme": BuggyDH} + crawler = get_crawler(settings_dict={"DOWNLOAD_HANDLERS": handlers}) + dh = DownloadHandlers(crawler) + assert "scheme" in dh._schemes + assert "scheme" not in dh._handlers + assert "scheme" in dh._notconfigured + assert ( + 'Loading "" for scheme "scheme"' + in caplog.text + ) + def test_disabled_handler(self): handlers = {"scheme": None} crawler = get_crawler(settings_dict={"DOWNLOAD_HANDLERS": handlers}) @@ -74,7 +101,11 @@ class TestLoad: def test_lazy_handlers(self): handlers = {"scheme": DummyLazyDH} crawler = get_crawler(settings_dict={"DOWNLOAD_HANDLERS": handlers}) - dh = DownloadHandlers(crawler) + with pytest.warns( + ScrapyDeprecationWarning, + match="DummyLazyDH doesn't define a 'lazy' attribute", + ): + dh = DownloadHandlers(crawler) assert "scheme" in dh._schemes assert "scheme" not in dh._handlers for scheme in handlers: # force load lazy handler @@ -88,17 +119,13 @@ class TestFile: # add a special char to check that they are handled correctly self.fd, self.tmpname = mkstemp(suffix="^") Path(self.tmpname).write_text("0123456789", encoding="utf-8") - self.download_handler = build_from_crawler(FileDownloadHandler, get_crawler()) + download_handler = build_from_crawler(FileDownloadHandler, get_crawler()) + self.download_request = download_handler.download_request def teardown_method(self): os.close(self.fd) Path(self.tmpname).unlink() - async def download_request(self, request: Request) -> Response: - return await maybe_deferred_to_future( - self.download_handler.download_request(request, DefaultSpider()) - ) - @deferred_f_from_coro_f async def test_download(self): request = Request(path_to_file_uri(self.tmpname)) @@ -121,7 +148,7 @@ class HttpDownloadHandlerMock: def __init__(self, *args, **kwargs): pass - def download_request(self, request, spider): + async def download_request(self, request): return request @@ -129,18 +156,17 @@ class HttpDownloadHandlerMock: class TestS3Anon: def setup_method(self): crawler = get_crawler() - self.s3reqh = build_from_crawler( - S3DownloadHandler, - crawler, - httpdownloadhandler=HttpDownloadHandlerMock, - # anon=True, # implicit - ) + with mock.patch( + "scrapy.core.downloader.handlers.s3.HTTP11DownloadHandler", + HttpDownloadHandlerMock, + ): + self.s3reqh = build_from_crawler(S3DownloadHandler, crawler) self.download_request = self.s3reqh.download_request - self.spider = DefaultSpider() - def test_anon_request(self): + @deferred_f_from_coro_f + async def test_anon_request(self): req = Request("s3://aws-publicdatasets/") - httpreq = self.download_request(req, self.spider) + httpreq = await self.download_request(req) assert hasattr(self.s3reqh, "anon") assert self.s3reqh.anon assert httpreq.url == "http://aws-publicdatasets.s3.amazonaws.com/" @@ -148,26 +174,22 @@ class TestS3Anon: @pytest.mark.requires_botocore class TestS3: - download_handler_cls: type = S3DownloadHandler - - # test use same example keys than amazon developer guide - # http://s3.amazonaws.com/awsdocs/S3/20060301/s3-dg-20060301.pdf - # and the tests described here are the examples from that manual - - AWS_ACCESS_KEY_ID = "0PN5J17HBGZHT7JJ3X82" - AWS_SECRET_ACCESS_KEY = "uV3F3YluFJax1cknvbcGwgjvx4QpvB+leU8dUj2o" - def setup_method(self): - crawler = get_crawler() - s3reqh = build_from_crawler( - S3DownloadHandler, - crawler, - aws_access_key_id=self.AWS_ACCESS_KEY_ID, - aws_secret_access_key=self.AWS_SECRET_ACCESS_KEY, - httpdownloadhandler=HttpDownloadHandlerMock, + # test use same example keys than amazon developer guide + # http://s3.amazonaws.com/awsdocs/S3/20060301/s3-dg-20060301.pdf + # and the tests described here are the examples from that manual + crawler = get_crawler( + settings_dict={ + "AWS_ACCESS_KEY_ID": "0PN5J17HBGZHT7JJ3X82", + "AWS_SECRET_ACCESS_KEY": "uV3F3YluFJax1cknvbcGwgjvx4QpvB+leU8dUj2o", + } ) + with mock.patch( + "scrapy.core.downloader.handlers.s3.HTTP11DownloadHandler", + HttpDownloadHandlerMock, + ): + s3reqh = build_from_crawler(S3DownloadHandler, crawler) self.download_request = s3reqh.download_request - self.spider = DefaultSpider() @contextlib.contextmanager def _mocked_date(self, date): @@ -183,27 +205,20 @@ class TestS3: mock_formatdate.return_value = date yield - def test_extra_kw(self): - crawler = get_crawler() - with pytest.raises((TypeError, NotConfigured)): - build_from_crawler( - S3DownloadHandler, - crawler, - extra_kw=True, - ) - - def test_request_signing1(self): + @deferred_f_from_coro_f + async def test_request_signing1(self): # gets an object from the johnsmith bucket. date = "Tue, 27 Mar 2007 19:36:42 +0000" req = Request("s3://johnsmith/photos/puppy.jpg", headers={"Date": date}) with self._mocked_date(date): - httpreq = self.download_request(req, self.spider) + httpreq = await self.download_request(req) assert ( httpreq.headers["Authorization"] == b"AWS 0PN5J17HBGZHT7JJ3X82:xXjDGYUmKxnwqr5KXNPGldn5LbA=" ) - def test_request_signing2(self): + @deferred_f_from_coro_f + async def test_request_signing2(self): # puts an object into the johnsmith bucket. date = "Tue, 27 Mar 2007 21:15:45 +0000" req = Request( @@ -216,13 +231,14 @@ class TestS3: }, ) with self._mocked_date(date): - httpreq = self.download_request(req, self.spider) + httpreq = await self.download_request(req) assert ( httpreq.headers["Authorization"] == b"AWS 0PN5J17HBGZHT7JJ3X82:hcicpDDvL9SsO6AkvxqmIWkmOuQ=" ) - def test_request_signing3(self): + @deferred_f_from_coro_f + async def test_request_signing3(self): # lists the content of the johnsmith bucket. date = "Tue, 27 Mar 2007 19:42:41 +0000" req = Request( @@ -234,24 +250,26 @@ class TestS3: }, ) with self._mocked_date(date): - httpreq = self.download_request(req, self.spider) + httpreq = await self.download_request(req) assert ( httpreq.headers["Authorization"] == b"AWS 0PN5J17HBGZHT7JJ3X82:jsRt/rhG+Vtp88HrYL706QhE4w4=" ) - def test_request_signing4(self): + @deferred_f_from_coro_f + async def test_request_signing4(self): # fetches the access control policy sub-resource for the 'johnsmith' bucket. date = "Tue, 27 Mar 2007 19:44:46 +0000" req = Request("s3://johnsmith/?acl", method="GET", headers={"Date": date}) with self._mocked_date(date): - httpreq = self.download_request(req, self.spider) + httpreq = await self.download_request(req) assert ( httpreq.headers["Authorization"] == b"AWS 0PN5J17HBGZHT7JJ3X82:thdUi9VAkzhkniLj96JIrOPGi0g=" ) - def test_request_signing6(self): + @deferred_f_from_coro_f + async def test_request_signing6(self): # uploads an object to a CNAME style virtual hosted bucket with metadata. date = "Tue, 27 Mar 2007 21:06:08 +0000" req = Request( @@ -273,13 +291,14 @@ class TestS3: }, ) with self._mocked_date(date): - httpreq = self.download_request(req, self.spider) + httpreq = await self.download_request(req) assert ( httpreq.headers["Authorization"] == b"AWS 0PN5J17HBGZHT7JJ3X82:C0FlOtU8Ylb9KDTpZqYkZPX91iI=" ) - def test_request_signing7(self): + @deferred_f_from_coro_f + async def test_request_signing7(self): # ensure that spaces are quoted properly before signing date = "Tue, 27 Mar 2007 19:42:41 +0000" req = Request( @@ -288,22 +307,25 @@ class TestS3: headers={"Date": date}, ) with self._mocked_date(date): - httpreq = self.download_request(req, self.spider) + httpreq = await self.download_request(req) assert ( httpreq.headers["Authorization"] == b"AWS 0PN5J17HBGZHT7JJ3X82:+CfvG8EZ3YccOrRVMXNaK2eKZmM=" ) +@pytest.mark.skipif(is_botocore_available(), reason="Requires not having botocore") +def test_s3_no_botocore() -> None: + crawler = get_crawler() + with pytest.raises(NotConfigured, match="missing botocore library"): + build_from_crawler(S3DownloadHandler, crawler) + + class TestDataURI: def setup_method(self): crawler = get_crawler() - self.download_handler = build_from_crawler(DataURIDownloadHandler, crawler) - - async def download_request(self, request: Request) -> Response: - return await maybe_deferred_to_future( - self.download_handler.download_request(request, DefaultSpider()) - ) + download_handler = build_from_crawler(DataURIDownloadHandler, crawler) + self.download_request = download_handler.download_request @deferred_f_from_coro_f async def test_response_attrs(self): diff --git a/tests/test_downloader_handlers_http_base.py b/tests/test_downloader_handlers_http_base.py index a2459911f..e325c0463 100644 --- a/tests/test_downloader_handlers_http_base.py +++ b/tests/test_downloader_handlers_http_base.py @@ -6,19 +6,18 @@ import gzip import json import sys from abc import ABC, abstractmethod +from contextlib import asynccontextmanager from http import HTTPStatus from typing import TYPE_CHECKING, Any from unittest import mock import pytest -from pytest_twisted import async_yield_fixture from testfixtures import LogCapture from twisted.internet import defer, error from twisted.web._newclient import ResponseFailed from twisted.web.http import _DataLoss from scrapy.http import Headers, HtmlResponse, Request, Response, TextResponse -from scrapy.spiders import Spider from scrapy.utils.asyncio import call_later from scrapy.utils.defer import ( deferred_f_from_coro_f, @@ -40,27 +39,6 @@ if TYPE_CHECKING: from tests.mockserver.http import MockServer -async def download_request( - download_handler: DownloadHandlerProtocol, - request: Request, - spider: Spider = DefaultSpider(), -) -> Response: - return await maybe_deferred_to_future( - download_handler.download_request(request, spider) - ) - - -async def close_dh(dh: DownloadHandlerProtocol) -> None: - # needed because the interface of close() is not clearly defined - if not hasattr(dh, "close"): - return - c = dh.close() - if c is None: - return - # covers coroutines and Deferreds; won't work if close() uses Futures inside - await c - - class TestHttpBase(ABC): is_secure = False @@ -69,30 +47,32 @@ class TestHttpBase(ABC): def download_handler_cls(self) -> type[DownloadHandlerProtocol]: raise NotImplementedError - @async_yield_fixture - async def download_handler(self) -> AsyncGenerator[DownloadHandlerProtocol]: - dh = build_from_crawler(self.download_handler_cls, get_crawler()) - - yield dh - - await close_dh(dh) + @asynccontextmanager + async def get_dh( + self, settings_dict: dict[str, Any] | None = None + ) -> AsyncGenerator[DownloadHandlerProtocol]: + crawler = get_crawler(DefaultSpider, settings_dict) + crawler.spider = crawler._create_spider() + dh = build_from_crawler(self.download_handler_cls, crawler) + try: + yield dh + finally: + await dh.close() @deferred_f_from_coro_f - async def test_download( - self, mockserver: MockServer, download_handler: DownloadHandlerProtocol - ) -> None: + async def test_download(self, mockserver: MockServer) -> None: request = Request(mockserver.url("/text", is_secure=self.is_secure)) - response = await download_request(download_handler, request) + async with self.get_dh() as download_handler: + response = await download_handler.download_request(request) assert response.body == b"Works" @deferred_f_from_coro_f - async def test_download_head( - self, mockserver: MockServer, download_handler: DownloadHandlerProtocol - ) -> None: + async def test_download_head(self, mockserver: MockServer) -> None: request = Request( mockserver.url("/text", is_secure=self.is_secure), method="HEAD" ) - response = await download_request(download_handler, request) + async with self.get_dh() as download_handler: + response = await download_handler.download_request(request) assert response.body == b"" @pytest.mark.parametrize( @@ -105,22 +85,18 @@ class TestHttpBase(ABC): ) @deferred_f_from_coro_f async def test_download_has_correct_http_status_code( - self, - mockserver: MockServer, - download_handler: DownloadHandlerProtocol, - http_status: HTTPStatus, + self, mockserver: MockServer, http_status: HTTPStatus ) -> None: request = Request( mockserver.url(f"/status?n={http_status.value}", is_secure=self.is_secure) ) - response = await download_request(download_handler, request) + async with self.get_dh() as download_handler: + response = await download_handler.download_request(request) assert response.status == http_status.value @deferred_f_from_coro_f async def test_server_receives_correct_request_headers( - self, - mockserver: MockServer, - download_handler: DownloadHandlerProtocol, + self, mockserver: MockServer ) -> None: request_headers = { # common request headers @@ -136,7 +112,8 @@ class TestHttpBase(ABC): mockserver.url("/echo", is_secure=self.is_secure), headers=request_headers, ) - response = await download_request(download_handler, request) + async with self.get_dh() as download_handler: + response = await download_handler.download_request(request) assert response.status == HTTPStatus.OK body = json.loads(response.body.decode("utf-8")) assert "headers" in body @@ -146,9 +123,7 @@ class TestHttpBase(ABC): @deferred_f_from_coro_f async def test_server_receives_correct_request_body( - self, - mockserver: MockServer, - download_handler: DownloadHandlerProtocol, + self, mockserver: MockServer ) -> None: request_body = { "message": "It works!", @@ -157,16 +132,15 @@ class TestHttpBase(ABC): mockserver.url("/echo", is_secure=self.is_secure), body=json.dumps(request_body), ) - response = await download_request(download_handler, request) + async with self.get_dh() as download_handler: + response = await download_handler.download_request(request) assert response.status == HTTPStatus.OK body = json.loads(response.body.decode("utf-8")) assert json.loads(body["body"]) == request_body @deferred_f_from_coro_f async def test_download_has_correct_response_headers( - self, - mockserver: MockServer, - download_handler: DownloadHandlerProtocol, + self, mockserver: MockServer ) -> None: # these headers will be set on the response in the resource and returned response_headers = { @@ -192,7 +166,8 @@ class TestHttpBase(ABC): headers={"content-type": "application/json"}, body=json.dumps(response_headers), ) - response = await download_request(download_handler, request) + async with self.get_dh() as download_handler: + response = await download_handler.download_request(request) assert response.status == 200 for header_name, header_value in response_headers.items(): assert header_name in response.headers, ( @@ -203,29 +178,24 @@ class TestHttpBase(ABC): ) @deferred_f_from_coro_f - async def test_redirect_status( - self, mockserver: MockServer, download_handler: DownloadHandlerProtocol - ) -> None: + async def test_redirect_status(self, mockserver: MockServer) -> None: request = Request(mockserver.url("/redirect", is_secure=self.is_secure)) - response = await download_request(download_handler, request) + async with self.get_dh() as download_handler: + response = await download_handler.download_request(request) assert response.status == 302 @deferred_f_from_coro_f - async def test_redirect_status_head( - self, mockserver: MockServer, download_handler: DownloadHandlerProtocol - ) -> None: + async def test_redirect_status_head(self, mockserver: MockServer) -> None: request = Request( mockserver.url("/redirect", is_secure=self.is_secure), method="HEAD" ) - response = await download_request(download_handler, request) + async with self.get_dh() as download_handler: + response = await download_handler.download_request(request) assert response.status == 302 @deferred_f_from_coro_f async def test_timeout_download_from_spider_nodata_rcvd( - self, - mockserver: MockServer, - download_handler: DownloadHandlerProtocol, - reactor_pytest: str, + self, mockserver: MockServer, reactor_pytest: str ) -> None: if reactor_pytest == "asyncio" and sys.platform == "win32": # https://twistedmatrix.com/trac/ticket/10279 @@ -236,15 +206,15 @@ class TestHttpBase(ABC): # client connects but no data is received meta = {"download_timeout": 0.5} request = Request(mockserver.url("/wait", is_secure=self.is_secure), meta=meta) - d = deferred_from_coro(download_request(download_handler, request)) - with pytest.raises((defer.TimeoutError, error.TimeoutError)): - await maybe_deferred_to_future(d) + async with self.get_dh() as download_handler: + d = deferred_from_coro(download_handler.download_request(request)) + with pytest.raises((defer.TimeoutError, error.TimeoutError)): + await maybe_deferred_to_future(d) @deferred_f_from_coro_f async def test_timeout_download_from_spider_server_hangs( self, mockserver: MockServer, - download_handler: DownloadHandlerProtocol, reactor_pytest: str, ) -> None: if reactor_pytest == "asyncio" and sys.platform == "win32": @@ -257,24 +227,21 @@ class TestHttpBase(ABC): request = Request( mockserver.url("/hang-after-headers", is_secure=self.is_secure), meta=meta ) - d = deferred_from_coro(download_request(download_handler, request)) - with pytest.raises((defer.TimeoutError, error.TimeoutError)): - await maybe_deferred_to_future(d) + async with self.get_dh() as download_handler: + d = deferred_from_coro(download_handler.download_request(request)) + with pytest.raises((defer.TimeoutError, error.TimeoutError)): + await maybe_deferred_to_future(d) @pytest.mark.parametrize("send_header", [True, False]) @deferred_f_from_coro_f - async def test_host_header( - self, - send_header: bool, - mockserver: MockServer, - download_handler: DownloadHandlerProtocol, - ) -> None: + async def test_host_header(self, send_header: bool, mockserver: MockServer) -> None: host_port = f"{mockserver.host}:{mockserver.port(is_secure=self.is_secure)}" request = Request( mockserver.url("/host", is_secure=self.is_secure), headers={"Host": host_port} if send_header else {}, ) - response = await download_request(download_handler, request) + async with self.get_dh() as download_handler: + response = await download_handler.download_request(request) assert response.body == host_port.encode() if send_header: assert request.headers.get("Host") == host_port.encode() @@ -283,7 +250,7 @@ class TestHttpBase(ABC): @deferred_f_from_coro_f async def test_content_length_zero_bodyless_post_request_headers( - self, mockserver: MockServer, download_handler: DownloadHandlerProtocol + self, mockserver: MockServer ) -> None: """Tests if "Content-Length: 0" is sent for bodyless POST requests. @@ -298,43 +265,43 @@ class TestHttpBase(ABC): request = Request( mockserver.url("/contentlength", is_secure=self.is_secure), method="POST" ) - response = await download_request(download_handler, request) + async with self.get_dh() as download_handler: + response = await download_handler.download_request(request) assert response.body == b"0" @deferred_f_from_coro_f async def test_content_length_zero_bodyless_post_only_one( - self, mockserver: MockServer, download_handler: DownloadHandlerProtocol + self, mockserver: MockServer ) -> None: request = Request( mockserver.url("/echo", is_secure=self.is_secure), method="POST" ) - response = await download_request(download_handler, request) + async with self.get_dh() as download_handler: + response = await download_handler.download_request(request) headers = Headers(json.loads(response.text)["headers"]) contentlengths = headers.getlist("Content-Length") assert len(contentlengths) == 1 assert contentlengths == [b"0"] @deferred_f_from_coro_f - async def test_payload( - self, mockserver: MockServer, download_handler: DownloadHandlerProtocol - ) -> None: + async def test_payload(self, mockserver: MockServer) -> None: body = b"1" * 100 # PayloadResource requires body length to be 100 request = Request( mockserver.url("/payload", is_secure=self.is_secure), method="POST", body=body, ) - response = await download_request(download_handler, request) + async with self.get_dh() as download_handler: + response = await download_handler.download_request(request) assert response.body == body @deferred_f_from_coro_f - async def test_response_header_content_length( - self, mockserver: MockServer, download_handler: DownloadHandlerProtocol - ) -> None: + async def test_response_header_content_length(self, mockserver: MockServer) -> None: request = Request( mockserver.url("/text", is_secure=self.is_secure), method="GET" ) - response = await download_request(download_handler, request) + async with self.get_dh() as download_handler: + response = await download_handler.download_request(request) assert response.headers[b"content-length"] == b"5" @pytest.mark.parametrize( @@ -351,25 +318,24 @@ class TestHttpBase(ABC): body: bytes, response_class: type[Response], mockserver: MockServer, - download_handler: DownloadHandlerProtocol, ) -> None: request = Request( mockserver.url(f"/{filename}", is_secure=self.is_secure), body=body ) - response = await download_request(download_handler, request) + async with self.get_dh() as download_handler: + response = await download_handler.download_request(request) assert type(response) is response_class # pylint: disable=unidiomatic-typecheck @deferred_f_from_coro_f - async def test_get_duplicate_header( - self, mockserver: MockServer, download_handler: DownloadHandlerProtocol - ) -> None: + async def test_get_duplicate_header(self, mockserver: MockServer) -> None: request = Request(mockserver.url("/duplicate-header", is_secure=self.is_secure)) - response = await download_request(download_handler, request) + async with self.get_dh() as download_handler: + response = await download_handler.download_request(request) assert response.headers.getlist(b"Set-Cookie") == [b"a=b", b"c=d"] @deferred_f_from_coro_f async def test_download_is_not_automatically_gzip_decoded( - self, download_handler: DownloadHandlerProtocol, mockserver: MockServer + self, mockserver: MockServer ) -> None: """Test download handler does not automatically decode content using the scheme provided in Content-Encoding header""" @@ -382,7 +348,8 @@ class TestHttpBase(ABC): "accept-encoding": "gzip", }, ) - response = await download_request(download_handler, request) + async with self.get_dh() as download_handler: + response = await download_handler.download_request(request) assert response.status == 200 @@ -402,7 +369,7 @@ class TestHttpBase(ABC): @deferred_f_from_coro_f async def test_no_cookie_processing_or_persistence( - self, mockserver: MockServer, download_handler: DownloadHandlerProtocol + self, mockserver: MockServer ) -> None: cookie_name = "foo" cookie_value = "bar" @@ -413,34 +380,34 @@ class TestHttpBase(ABC): f"/set-cookie?{cookie_name}={cookie_value}", is_secure=self.is_secure ) ) - response = await download_request(download_handler, request) - assert response.status == 200 - set_cookie = response.headers.get(b"Set-Cookie") - assert set_cookie == f"{cookie_name}={cookie_value}".encode() + async with self.get_dh() as download_handler: + response = await download_handler.download_request(request) + assert response.status == 200 + set_cookie = response.headers.get(b"Set-Cookie") + assert set_cookie == f"{cookie_name}={cookie_value}".encode() - # check that cookies are not sent in the next request - request = Request(mockserver.url("/echo", is_secure=self.is_secure)) - response = await download_request(download_handler, request) - assert response.status == 200 - headers = Headers(json.loads(response.text)["headers"]) - assert "Cookie" not in headers - assert "cookie" not in headers + # check that cookies are not sent in the next request + request = Request(mockserver.url("/echo", is_secure=self.is_secure)) + response = await download_handler.download_request(request) + assert response.status == 200 + headers = Headers(json.loads(response.text)["headers"]) + assert "Cookie" not in headers + assert "cookie" not in headers class TestHttp11Base(TestHttpBase): """HTTP 1.1 test case""" @deferred_f_from_coro_f - async def test_download_without_maxsize_limit( - self, mockserver: MockServer, download_handler: DownloadHandlerProtocol - ) -> None: + async def test_download_without_maxsize_limit(self, mockserver: MockServer) -> None: request = Request(mockserver.url("/text", is_secure=self.is_secure)) - response = await download_request(download_handler, request) + async with self.get_dh() as download_handler: + response = await download_handler.download_request(request) assert response.body == b"Works" @deferred_f_from_coro_f async def test_response_class_choosing_request( - self, mockserver: MockServer, download_handler: DownloadHandlerProtocol + self, mockserver: MockServer ) -> None: """Tests choosing of correct response type in case of Content-Type is empty but body contains text. @@ -449,7 +416,8 @@ class TestHttp11Base(TestHttpBase): request = Request( mockserver.url("/nocontenttype", is_secure=self.is_secure), body=body ) - response = await download_request(download_handler, request) + async with self.get_dh() as download_handler: + response = await download_handler.download_request(request) assert type(response) is TextResponse # pylint: disable=unidiomatic-typecheck @deferred_f_from_coro_f @@ -458,24 +426,19 @@ class TestHttp11Base(TestHttpBase): # 10 is minimal size for this request and the limit is only counted on # response body. (regardless of headers) - crawler = get_crawler(settings_dict={"DOWNLOAD_MAXSIZE": 5}) - download_handler = build_from_crawler(self.download_handler_cls, crawler) - response = await download_request(download_handler, request, Spider("foo")) + async with self.get_dh({"DOWNLOAD_MAXSIZE": 5}) as download_handler: + response = await download_handler.download_request(request) assert response.body == b"Works" - crawler = get_crawler(settings_dict={"DOWNLOAD_MAXSIZE": 4}) - download_handler = build_from_crawler(self.download_handler_cls, crawler) - - with pytest.raises((defer.CancelledError, error.ConnectionAborted)): - await download_request(download_handler, request, Spider("foo")) + async with self.get_dh({"DOWNLOAD_MAXSIZE": 4}) as download_handler: + with pytest.raises((defer.CancelledError, error.ConnectionAborted)): + await download_handler.download_request(request) @deferred_f_from_coro_f async def test_download_with_maxsize_very_large_file( self, mockserver: MockServer ) -> None: # TODO: the logger check is specific to scrapy.core.downloader.handlers.http11 - crawler = get_crawler(settings_dict={"DOWNLOAD_MAXSIZE": 1_500}) - download_handler = build_from_crawler(self.download_handler_cls, crawler) with mock.patch("scrapy.core.downloader.handlers.http11.logger") as logger: request = Request( mockserver.url("/largechunkedfile", is_secure=self.is_secure) @@ -484,8 +447,9 @@ class TestHttp11Base(TestHttpBase): def check(logger: mock.Mock) -> None: logger.warning.assert_called_once_with(mock.ANY, mock.ANY) - with pytest.raises((defer.CancelledError, error.ConnectionAborted)): - await download_request(download_handler, request, Spider("foo")) + async with self.get_dh({"DOWNLOAD_MAXSIZE": 1_500}) as download_handler: + with pytest.raises((defer.CancelledError, error.ConnectionAborted)): + await download_handler.download_request(request) # As the error message is logged in the dataReceived callback, we # have to give a bit of time to the reactor to process the queue @@ -496,69 +460,61 @@ class TestHttp11Base(TestHttpBase): await maybe_deferred_to_future(d) @deferred_f_from_coro_f - async def test_download_with_maxsize_per_req( - self, mockserver: MockServer, download_handler: DownloadHandlerProtocol - ) -> None: + async def test_download_with_maxsize_per_req(self, mockserver: MockServer) -> None: meta = {"download_maxsize": 2} request = Request(mockserver.url("/text", is_secure=self.is_secure), meta=meta) - with pytest.raises((defer.CancelledError, error.ConnectionAborted)): - await download_request(download_handler, request) + async with self.get_dh() as download_handler: + with pytest.raises((defer.CancelledError, error.ConnectionAborted)): + await download_handler.download_request(request) @deferred_f_from_coro_f async def test_download_with_small_maxsize_via_setting( self, mockserver: MockServer ) -> None: - crawler = get_crawler(settings_dict={"DOWNLOAD_MAXSIZE": 2}) - download_handler = build_from_crawler(self.download_handler_cls, crawler) request = Request(mockserver.url("/text", is_secure=self.is_secure)) - with pytest.raises((defer.CancelledError, error.ConnectionAborted)): - await download_request(download_handler, request, Spider("foo")) + async with self.get_dh({"DOWNLOAD_MAXSIZE": 2}) as download_handler: + with pytest.raises((defer.CancelledError, error.ConnectionAborted)): + await download_handler.download_request(request) @deferred_f_from_coro_f async def test_download_with_large_maxsize_via_setting( self, mockserver: MockServer ) -> None: - crawler = get_crawler(settings_dict={"DOWNLOAD_MAXSIZE": 5}) - download_handler = build_from_crawler(self.download_handler_cls, crawler) request = Request(mockserver.url("/text", is_secure=self.is_secure)) - response = await download_request(download_handler, request, Spider("foo")) + async with self.get_dh({"DOWNLOAD_MAXSIZE": 100}) as download_handler: + response = await download_handler.download_request(request) assert response.body == b"Works" @deferred_f_from_coro_f - async def test_download_chunked_content( - self, mockserver: MockServer, download_handler: DownloadHandlerProtocol - ) -> None: + async def test_download_chunked_content(self, mockserver: MockServer) -> None: request = Request(mockserver.url("/chunked", is_secure=self.is_secure)) - response = await download_request(download_handler, request) + async with self.get_dh() as download_handler: + response = await download_handler.download_request(request) assert response.body == b"chunked content\n" @pytest.mark.parametrize("url", ["broken", "broken-chunked"]) @deferred_f_from_coro_f async def test_download_cause_data_loss( - self, - url: str, - mockserver: MockServer, - download_handler: DownloadHandlerProtocol, + self, url: str, mockserver: MockServer ) -> None: # TODO: this one checks for Twisted-specific exceptions request = Request(mockserver.url(f"/{url}", is_secure=self.is_secure)) - with pytest.raises(ResponseFailed) as exc_info: - await download_request(download_handler, request) + async with self.get_dh() as download_handler: + with pytest.raises(ResponseFailed) as exc_info: + await download_handler.download_request(request) assert any(r.check(_DataLoss) for r in exc_info.value.reasons) @pytest.mark.parametrize("url", ["broken", "broken-chunked"]) @deferred_f_from_coro_f async def test_download_allow_data_loss( - self, - url: str, - mockserver: MockServer, - download_handler: DownloadHandlerProtocol, + self, url: str, mockserver: MockServer ) -> None: request = Request( mockserver.url(f"/{url}", is_secure=self.is_secure), meta={"download_fail_on_dataloss": False}, ) - response = await download_request(download_handler, request) + async with self.get_dh() as download_handler: + response = await download_handler.download_request(request) assert response.flags == ["dataloss"] @pytest.mark.parametrize("url", ["broken", "broken-chunked"]) @@ -566,27 +522,20 @@ class TestHttp11Base(TestHttpBase): async def test_download_allow_data_loss_via_setting( self, url: str, mockserver: MockServer ) -> None: - crawler = get_crawler(settings_dict={"DOWNLOAD_FAIL_ON_DATALOSS": False}) - download_handler = build_from_crawler(self.download_handler_cls, crawler) request = Request(mockserver.url(f"/{url}", is_secure=self.is_secure)) - try: - response = await maybe_deferred_to_future( - download_handler.download_request(request, DefaultSpider()) - ) - finally: - d = download_handler.close() # type: ignore[attr-defined] - if d is not None: - await maybe_deferred_to_future(d) + async with self.get_dh( + {"DOWNLOAD_FAIL_ON_DATALOSS": False} + ) as download_handler: + response = await download_handler.download_request(request) assert response.flags == ["dataloss"] @deferred_f_from_coro_f - async def test_protocol( - self, mockserver: MockServer, download_handler: DownloadHandlerProtocol - ) -> None: + async def test_protocol(self, mockserver: MockServer) -> None: request = Request( mockserver.url("/host", is_secure=self.is_secure), method="GET" ) - response = await download_request(download_handler, request) + async with self.get_dh() as download_handler: + response = await download_handler.download_request(request) assert response.protocol == "HTTP/1.1" @@ -600,24 +549,16 @@ class TestHttps11Base(TestHttp11Base): @deferred_f_from_coro_f async def test_tls_logging(self, mockserver: MockServer) -> None: - crawler = get_crawler( - settings_dict={"DOWNLOADER_CLIENT_TLS_VERBOSE_LOGGING": True} - ) - download_handler = build_from_crawler(self.download_handler_cls, crawler) - try: + request = Request(mockserver.url("/text", is_secure=self.is_secure)) + async with self.get_dh( + {"DOWNLOADER_CLIENT_TLS_VERBOSE_LOGGING": True} + ) as download_handler: with LogCapture() as log_capture: - request = Request(mockserver.url("/text", is_secure=self.is_secure)) - response = await maybe_deferred_to_future( - download_handler.download_request(request, DefaultSpider()) - ) - assert response.body == b"Works" - log_capture.check_present( - ("scrapy.core.downloader.tls", "DEBUG", self.tls_log_message) - ) - finally: - d = download_handler.close() # type: ignore[attr-defined] - if d is not None: - await maybe_deferred_to_future(d) + response = await download_handler.download_request(request) + assert response.body == b"Works" + log_capture.check_present( + ("scrapy.core.downloader.tls", "DEBUG", self.tls_log_message) + ) class TestSimpleHttpsBase(ABC): @@ -645,25 +586,25 @@ class TestSimpleHttpsBase(ABC): def download_handler_cls(self) -> type[DownloadHandlerProtocol]: raise NotImplementedError - @async_yield_fixture - async def download_handler(self) -> AsyncGenerator[DownloadHandlerProtocol]: + @asynccontextmanager + async def get_dh(self) -> AsyncGenerator[DownloadHandlerProtocol]: if self.cipher_string is not None: settings_dict = {"DOWNLOADER_CLIENT_TLS_CIPHERS": self.cipher_string} else: settings_dict = None - crawler = get_crawler(settings_dict=settings_dict) + crawler = get_crawler(DefaultSpider, settings_dict=settings_dict) + crawler.spider = crawler._create_spider() dh = build_from_crawler(self.download_handler_cls, crawler) - - yield dh - - await close_dh(dh) + try: + yield dh + finally: + await dh.close() @deferred_f_from_coro_f - async def test_download( - self, url: str, download_handler: DownloadHandlerProtocol - ) -> None: + async def test_download(self, url: str) -> None: request = Request(url) - response = await download_request(download_handler, request) + async with self.get_dh() as download_handler: + response = await download_handler.download_request(request) assert response.body == b"0123456789" @@ -748,65 +689,63 @@ class TestHttpProxyBase(ABC): with ProxyEchoMockServer() as proxy: yield proxy - @async_yield_fixture - async def download_handler(self) -> AsyncGenerator[DownloadHandlerProtocol]: - dh = build_from_crawler(self.download_handler_cls, get_crawler()) - - yield dh - - await close_dh(dh) + @asynccontextmanager + async def get_dh(self) -> AsyncGenerator[DownloadHandlerProtocol]: + crawler = get_crawler(DefaultSpider) + crawler.spider = crawler._create_spider() + dh = build_from_crawler(self.download_handler_cls, crawler) + try: + yield dh + finally: + await dh.close() @deferred_f_from_coro_f async def test_download_with_proxy( - self, - proxy_mockserver: ProxyEchoMockServer, - download_handler: DownloadHandlerProtocol, + self, proxy_mockserver: ProxyEchoMockServer ) -> None: http_proxy = proxy_mockserver.url("", is_secure=self.is_secure) request = Request("http://example.com", meta={"proxy": http_proxy}) - response = await download_request(download_handler, request) + async with self.get_dh() as download_handler: + response = await download_handler.download_request(request) assert response.status == 200 assert response.url == request.url assert response.body == self.expected_http_proxy_request_body @deferred_f_from_coro_f async def test_download_without_proxy( - self, - proxy_mockserver: ProxyEchoMockServer, - download_handler: DownloadHandlerProtocol, + self, proxy_mockserver: ProxyEchoMockServer ) -> None: request = Request( proxy_mockserver.url("/path/to/resource", is_secure=self.is_secure) ) - response = await download_request(download_handler, request) + async with self.get_dh() as download_handler: + response = await download_handler.download_request(request) assert response.status == 200 assert response.url == request.url assert response.body == b"/path/to/resource" @deferred_f_from_coro_f async def test_download_with_proxy_https_timeout( - self, - proxy_mockserver: ProxyEchoMockServer, - download_handler: DownloadHandlerProtocol, + self, proxy_mockserver: ProxyEchoMockServer ) -> None: if NON_EXISTING_RESOLVABLE: pytest.skip("Non-existing hosts are resolvable") http_proxy = proxy_mockserver.url("", is_secure=self.is_secure) domain = "https://no-such-domain.nosuch" request = Request(domain, meta={"proxy": http_proxy, "download_timeout": 0.2}) - with pytest.raises(error.TimeoutError) as exc_info: - await download_request(download_handler, request) + async with self.get_dh() as download_handler: + with pytest.raises(error.TimeoutError) as exc_info: + await download_handler.download_request(request) assert domain in exc_info.value.osError @deferred_f_from_coro_f async def test_download_with_proxy_without_http_scheme( - self, - proxy_mockserver: ProxyEchoMockServer, - download_handler: DownloadHandlerProtocol, + self, proxy_mockserver: ProxyEchoMockServer ) -> None: http_proxy = f"{proxy_mockserver.host}:{proxy_mockserver.port()}" request = Request("http://example.com", meta={"proxy": http_proxy}) - response = await download_request(download_handler, request) + async with self.get_dh() as download_handler: + response = await download_handler.download_request(request) assert response.status == 200 assert response.url == request.url assert response.body == self.expected_http_proxy_request_body From 7bef98b4f14a29f8f760a67c21edf6a6bee4dad0 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Mon, 5 Jan 2026 14:51:05 +0500 Subject: [PATCH 018/248] Release notes for 2.14.0 (#7035) * Initial release notes for 2.14.0, up to ed63fa9. * Cover 2.14 in the release notes up to 393d715. * Cover 2.14 in the release notes up to eb49647. * Cover 2.14 in the release notes up to 426aafd. * Cover 2.14 in the release notes up to 1e8de24. * Cover 2.14 in the release notes up to 5a7e132. * Bump sphinx-lint. * Finalize the 2.14.0 release notes. * Drop more of the old versionadded directives. * Address feedback. --- .pre-commit-config.yaml | 2 +- docs/news.rst | 611 +++++++++++++++++++++++++++++- docs/topics/asyncio.rst | 1 + docs/topics/coroutines.rst | 2 +- docs/topics/download-handlers.rst | 2 +- docs/topics/spiders.rst | 2 + scrapy/core/engine.py | 8 +- scrapy/core/scraper.py | 6 +- scrapy/crawler.py | 4 +- scrapy/extensions/logcount.py | 2 +- scrapy/http/response/__init__.py | 5 - scrapy/http/response/text.py | 6 +- scrapy/logformatter.py | 9 +- scrapy/signalmanager.py | 2 +- scrapy/utils/asyncio.py | 2 +- scrapy/utils/defer.py | 8 +- scrapy/utils/deprecate.py | 2 +- scrapy/utils/python.py | 2 +- scrapy/utils/signal.py | 4 +- 19 files changed, 627 insertions(+), 53 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5ffe2a8ea..dc4276cff 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -23,6 +23,6 @@ repos: - id: end-of-file-fixer - id: trailing-whitespace - repo: https://github.com/sphinx-contrib/sphinx-lint - rev: v1.0.0 + rev: v1.0.2 hooks: - id: sphinx-lint diff --git a/docs/news.rst b/docs/news.rst index 7e15c2167..1b1c72c1b 100644 --- a/docs/news.rst +++ b/docs/news.rst @@ -3,8 +3,41 @@ Release notes ============= -Scrapy VERSION (unreleased) ---------------------------- +.. _release-2.14.0: + +Scrapy 2.14.0 (unreleased) +-------------------------- + +Highlights: + +- More coroutine-based replacements for Deferred-based APIs + +- The default priority queue is now ``DownloaderAwarePriorityQueue`` + +- Dropped support for Python 3.9 and PyPy 3.10 + +- Improved and documented the API for custom download handlers + +Modified requirements +~~~~~~~~~~~~~~~~~~~~~ + +- Dropped support for Python 3.9. + (:issue:`7121`) + +- Dropped support for PyPy 3.10. + (:issue:`7050`) + +- Increased the minimum versions of the following dependencies: + + - lxml_: 4.6.0 → 4.6.4 + + - Pillow_ (optional dependency): 8.0.0 → 8.3.2 + + - botocore_ (optional dependency): 1.4.87 → 1.13.45 + +- Restored support for ``brotlicffi`` dropped in Scrapy 2.13.4. Its minimum + supported version is now ``1.2.0.0``. + (:issue:`7160`) Backward-incompatible changes ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -14,6 +47,7 @@ Backward-incompatible changes may now need to set the :setting:`FORCE_CRAWLER_PROCESS` setting to ``True`` when running Scrapy via :ref:`its command-line tool ` to avoid a reactor mismatch exception. + (:issue:`6845`) - The ``log_count/*`` stats no longer count some of the early messages that they counted before. While the earliest log messages, emitted before the @@ -52,6 +86,17 @@ Backward-incompatible changes - :meth:`~scrapy.spidermiddlewares.referer.ReferrerPolicy.referrer` + (:issue:`6930`) + +- Scrapy no longer passes a ``spider`` argument to any methods of the + :setting:`stats collector `. It wasn't passed in many of the + calls even in older Scrapy versions, so we don't expect existing custom + stats collector implementations to require a ``spider`` argument. If your + implementation needs a :class:`~scrapy.Spider` instance, you can get it + from the :class:`~scrapy.crawler.Crawler` instance passed to the + constructor. + (:issue:`7011`) + - :class:`scrapy.middleware.MiddlewareManager` no longer includes code for handling ``open_spider()`` and ``close_spider()`` component methods. As this code was only used for pipelines it was moved into @@ -66,6 +111,8 @@ Backward-incompatible changes - Code in ``scrapy.middleware.MiddlewareManager._add_middleware()`` that processes ``open_spider()`` and ``close_spider()`` component methods. + (:issue:`7006`) + - :meth:`scrapy.downloadermiddlewares.robotstxt.RobotsTxtMiddleware.process_request` now returns a coroutine, previously it returned a :class:`~twisted.internet.defer.Deferred` object or ``None``. The @@ -73,10 +120,11 @@ Backward-incompatible changes change only impacts code that subclasses :class:`~scrapy.downloadermiddlewares.robotstxt.RobotsTxtMiddleware` or calls its methods directly. + (:issue:`6802`) -- The built-in :ref:`download handlers ` were - refactored, changing signatures of their methods. This change should only - affect user code that subclasses any of these handlers or calls their +- The built-in :ref:`download handlers ` have been + refactored, changing the signatures of their methods. This change should + only affect user code that subclasses any of these handlers or calls their methods directly. (:issue:`6778`, :issue:`7164`) @@ -84,6 +132,549 @@ Backward-incompatible changes coroutine, previously it returned a :class:`~twisted.internet.defer.Deferred` object. This change only impacts code that calls this method directly. + (:issue:`7177`) + +Deprecation removals +~~~~~~~~~~~~~~~~~~~~ + +- The ``from_settings()`` method of the following components, deprecated in + Scrapy 2.12.0, is removed. You should use ``from_crawler()`` instead. + + - :class:`scrapy.dupefilters.RFPDupeFilter` + - :class:`scrapy.mail.MailSender` + - :class:`scrapy.middleware.MiddlewareManager` + - :class:`scrapy.core.downloader.contextfactory.ScrapyClientContextFactory` + - :class:`scrapy.pipelines.files.FilesPipeline` + - :class:`scrapy.pipelines.images.ImagesPipeline` + + (:issue:`7126`) + +- Scrapy no longer calls ``from_settings()`` methods of 3rd-party + :ref:`components `, deprecated in Scrapy 2.12.0. You + should define a ``from_crawler()`` method instead. + (:issue:`7126`) + +- The initialization flow of :class:`scrapy.pipelines.media.MediaPipeline` + and its subclasses was simplified, it now mandates ``from_crawler()`` + methods and ``crawler`` arguments of ``__init__()`` methods. Not using + these was deprecated in Scrapy 2.12.0. + (:issue:`7126`) + +- The ``REQUEST_FINGERPRINTER_IMPLEMENTATION`` setting, deprecated in Scrapy + 2.12.0, is removed. + (:issue:`7126`) + +- The ``scrapy.utils.misc.create_instance()`` function, deprecated in Scrapy + 2.12.0, is removed. Use :func:`scrapy.utils.misc.build_from_crawler` + instead. + (:issue:`7126`) + +- The ``scrapy.core.downloader.Downloader._get_slot_key()`` function, + deprecated in Scrapy 2.12.0, is removed. Use + :meth:`scrapy.core.downloader.Downloader.get_slot_key` instead. + (:issue:`7126`) + +- The ``scrapy.twisted_version`` attribute, deprecated in Scrapy 2.12.0, is + removed. You should instead use the :attr:`twisted.version` attribute + directly. + (:issue:`7126`) + +- The following utility functions, deprecated in Scrapy 2.12.0, are removed: + + - ``scrapy.utils.defer.process_chain_both()`` + - ``scrapy.utils.python.equal_attributes()`` + - ``scrapy.utils.python.flatten()`` + - ``scrapy.utils.python.iflatten()`` + - ``scrapy.utils.request.request_authenticate()`` + - ``scrapy.utils.test.assert_samelines()`` + + (:issue:`7126`) + +- ``scrapy.utils.serialize.ScrapyJSONDecoder``, deprecated in Scrapy 2.12.0, + is removed. + (:issue:`7126`) + +- The ``scrapy.extensions.feedexport.build_storage()`` function, deprecated + in Scrapy 2.12.0, is removed, you can instead call the builder callable + directly. + (:issue:`7126`) + +- ``scrapy.spidermiddlewares.offsite.OffsiteMiddleware``, deprecated in + Scrapy 2.11.2, is removed. + :class:`scrapy.downloadermiddlewares.offsite.OffsiteMiddleware` should be + used instead. + (:issue:`6926`) + +Deprecations +~~~~~~~~~~~~ + +- The following methods that return a + :class:`~twisted.internet.defer.Deferred` are deprecated in favor of their + coroutine-based replacements: + + - :class:`scrapy.core.downloader.handlers.DownloadHandlers` + + - ``download_request()`` (use + :meth:`~scrapy.core.downloader.handlers.DownloadHandlers.download_request_async`) + + - :class:`scrapy.core.downloader.middleware.DownloaderMiddlewareManager` + + - ``download()`` (use + :meth:`~scrapy.core.downloader.middleware.DownloaderMiddlewareManager.download_async`) + + - :class:`scrapy.core.engine.ExecutionEngine` + + - ``start()`` (use + :meth:`~scrapy.core.engine.ExecutionEngine.start_async`) + + - ``stop()`` (use + :meth:`~scrapy.core.engine.ExecutionEngine.stop_async`) + + - ``close()`` (use + :meth:`~scrapy.core.engine.ExecutionEngine.close_async`) + + - ``open_spider()`` (use + :meth:`~scrapy.core.engine.ExecutionEngine.open_spider_async`) + + - ``close_spider()`` (use + :meth:`~scrapy.core.engine.ExecutionEngine.close_spider_async`) + + - ``download()`` (use + :meth:`~scrapy.core.engine.ExecutionEngine.download_async`) + + - :class:`scrapy.core.scraper.Scraper` + + - ``open_spider()`` (use + :meth:`~scrapy.core.scraper.Scraper.open_spider_async`) + + - ``call_spider()`` (use + :meth:`~scrapy.core.scraper.Scraper.call_spider_async`) + + - ``close_spider()`` (use + :meth:`~scrapy.core.scraper.Scraper.close_spider_async`) + + - ``handle_spider_output()`` (use + :meth:`~scrapy.core.scraper.Scraper.handle_spider_output_async`) + + - ``start_itemproc()`` (use + :meth:`~scrapy.core.scraper.Scraper.start_itemproc_async`) + + - :class:`scrapy.core.spidermw.SpiderMiddlewareManager` + + - ``scrape_response()`` (use + :meth:`~scrapy.core.spidermw.SpiderMiddlewareManager.scrape_response_async`) + + - :class:`scrapy.crawler.Crawler` + + - ``stop()`` (use :meth:`~scrapy.crawler.Crawler.stop_async`) + + - :class:`scrapy.pipelines.ItemPipelineManager` + + - ``process_item()`` (use + :meth:`~scrapy.pipelines.ItemPipelineManager.process_item_async`) + + - ``open_spider()`` (use + :meth:`~scrapy.pipelines.ItemPipelineManager.open_spider_async`) + + - ``close_spider()`` (use + :meth:`~scrapy.pipelines.ItemPipelineManager.close_spider_async`) + + - :class:`scrapy.signalmanager.SignalManager` + + - ``send_catch_log_deferred()`` (use + :meth:`~scrapy.signalmanager.SignalManager.send_catch_log_async`) + + - ``scrapy.utils.signal.send_catch_log_deferred()`` (use + :func:`scrapy.utils.signal.send_catch_log_async`) + + (:issue:`6791`, :issue:`6842`, :issue:`6979`, :issue:`6997`, :issue:`6999`, + :issue:`7005`, :issue:`7043`, :issue:`7069`, :issue:`7161`, :issue:`7164`) + +- The following spider attributes are deprecated in favor of settings: + + - ``download_maxsize`` (use :setting:`DOWNLOAD_MAXSIZE`) + + - ``download_timeout`` (use :setting:`DOWNLOAD_TIMEOUT`) + + - ``download_warnsize`` (use :setting:`DOWNLOAD_WARNSIZE`) + + - ``max_concurrent_requests`` (use :setting:`CONCURRENT_REQUESTS`) + + - ``user_agent`` (use :setting:`USER_AGENT`) + + (:issue:`6988`, :issue:`6994`, :issue:`7038`, :issue:`7039`, :issue:`7117`, + :issue:`7176`) + +- Returning a :class:`~twisted.internet.defer.Deferred` from the following + user-defined functions is deprecated in favor of defining them as coroutine + functions: + + - spider callbacks and errbacks (which was never officially supported and + may work incorrectly) + + - the ``process_request()``, ``process_response()`` and + ``process_exception()`` methods of custom downloader middlewares + + - the ``process_item()``, ``open_spider()`` and ``close_spider()`` methods + of custom pipelines + + - signal handlers + + - the ``download_request()`` and ``close()`` methods of custom download + handlers + + (:issue:`6718`, :issue:`6778`, :issue:`7069`, :issue:`7147`, :issue:`7148`, + :issue:`7149`, :issue:`7150`, :issue:`7151`, :issue:`7161`, :issue:`7164`, + :issue:`7179`) + +- Passing a ``spider`` argument to the following methods is deprecated: + + - :meth:`scrapy.core.spidermw.SpiderMiddlewareManager.process_start` + + - :meth:`scrapy.core.downloader.Downloader.fetch` + + - :meth:`scrapy.core.downloader.Downloader._get_slot` + + - :meth:`scrapy.core.downloader.handlers.DownloadHandlers.download_request` + + - all public methods of :class:`scrapy.statscollectors.StatsCollector` + + - :meth:`scrapy.spidermiddlewares.base.BaseSpiderMiddleware.process_spider_output` + + - :meth:`scrapy.spidermiddlewares.base.BaseSpiderMiddleware.process_spider_output_async` + + - all ``process_*()`` methods of built-in downloader middlewares + + - all ``process_*()`` methods of built-in spider middlewares + + - :meth:`scrapy.pipelines.media.MediaPipeline.open_spider` + + - :meth:`scrapy.pipelines.media.MediaPipeline.process_item` + + (:issue:`6750`, :issue:`6927`, :issue:`6984`, :issue:`7006`, :issue:`7011`, + :issue:`7033`, :issue:`7037`, :issue:`7045`, :issue:`7178`) + +- Instantiating subclasses of :class:`scrapy.middleware.MiddlewareManager` + without a :class:`~scrapy.crawler.Crawler` instance is deprecated. + (:issue:`6984`) + +- For the following user-defined functions and methods requiring a ``spider`` + argument is deprecated, if you need a :class:`~scrapy.Spider` instance + inside them you should get it from the :class:`~scrapy.crawler.Crawler` + instance (you may need to refactor your code to save that instance in e.g. + the ``from_crawler()`` method): + + - the ``process_request()``, ``process_response()`` and + ``process_exception()`` methods of custom downloader middlewares + + - the ``process_spider_input()``, ``process_spider_output()``, + ``process_spider_output_async()`` and ``process_spider_exception()`` + methods of custom spider middlewares + + - the ``process_item()`` method of custom pipelines + + - the ``fetch()`` method of a custom :setting:`DOWNLOADER` + + (:issue:`6927`, :issue:`6984`, :issue:`7006`, :issue:`7037`) + +- The following things in custom download handlers are deprecated: + + - not having a ``lazy`` attribute (you should define it as ``True`` if you + want to keep the current behavior) + + - returning a :class:`~twisted.internet.defer.Deferred` from the + ``download_request()`` method (you should refactor it to return a + coroutine; you also need to remove the ``spider`` argument when doing + this) + + - not having a ``close()`` method, having a synchronous one or one that + returns a :class:`~twisted.internet.defer.Deferred` (you should refactor + it to return a coroutine or add an empty one if you don't have it) + + (:issue:`6778`, :issue:`7164`) + +- Custom implementations of :setting:`ITEM_PROCESSOR` should now define + ``process_item_async()``, ``open_spider_async()`` and + ``close_spider_async()`` methods instead of, or in addition to, + ``process_item()``, ``open_spider()`` and ``close_spider()``. + (:issue:`7005`, :issue:`7043`) + +- The ``CONCURRENT_REQUESTS_PER_IP`` setting is deprecated, use + :setting:`CONCURRENT_REQUESTS_PER_DOMAIN` instead. + (:issue:`6917`, :issue:`6921`) + +- The ``scrapy.core.downloader.handlers.http`` module is deprecated. You + should import + :class:`scrapy.core.downloader.handlers.http11.HTTP11DownloadHandler` + directly instead of importing the + ``scrapy.core.downloader.handlers.http.HTTPDownloadHandler`` alias. + (:issue:`7079`) + +- The ``scrapy.utils.decorators.defers()`` decorator is deprecated, you can + use :func:`twisted.internet.defer.maybeDeferred` directly or reimplement + this decorator in your code. + (:issue:`7164`) + +- ``scrapy.spiders.CrawlSpider._parse_response()`` is deprecated, use + :meth:`scrapy.spiders.CrawlSpider.parse_with_rules` instead. + (:issue:`4463`, :issue:`6804`) + +- The functions that add a delay to a Deferred are deprecated, their + underlying Twisted functions can be used instead, either directly if a + delay isn't needed, or with some explicit way to add a delay if it's + needed: + + - ``scrapy.utils.defer.mustbe_deferred()`` (you can use + :func:`twisted.internet.defer.maybeDeferred`) + + - ``scrapy.utils.defer.defer_succeed()`` (you can use + :func:`twisted.internet.defer.succeed`) + + - ``scrapy.utils.defer.defer_fail()`` (you can use + :func:`twisted.internet.defer.fail`) + + - ``scrapy.utils.defer.defer_result()`` (you can use + :func:`twisted.internet.defer.succeed` and + :func:`twisted.internet.defer.fail`) + + (:issue:`6937`) + +New features +~~~~~~~~~~~~ + +- Added :class:`scrapy.crawler.AsyncCrawlerProcess` and + :class:`scrapy.crawler.AsyncCrawlerRunner` as counterparts to + :class:`~scrapy.crawler.CrawlerProcess` and + :class:`~scrapy.crawler.CrawlerRunner` that offer coroutine-based APIs. + (:issue:`6789`, :issue:`6790`, :issue:`6796`, :issue:`6817`, :issue:`6845`, + :issue:`7034`) + +- Added coroutine counterparts to some of the Deferred-based APIs: + + - :class:`scrapy.core.downloader.handlers.DownloadHandlers` + + - :meth:`~scrapy.core.downloader.handlers.DownloadHandlers.download_request_async` + (to ``download_request()``) + + - :class:`scrapy.core.downloader.middleware.DownloaderMiddlewareManager` + + - :meth:`~scrapy.core.downloader.middleware.DownloaderMiddlewareManager.download_async` + (to ``download()``) + + - :class:`scrapy.core.engine.ExecutionEngine` + + - :meth:`~scrapy.core.engine.ExecutionEngine.start_async` (to + ``start()``) + + - :meth:`~scrapy.core.engine.ExecutionEngine.stop_async` (to + ``stop()``) + + - :meth:`~scrapy.core.engine.ExecutionEngine.close_async` (to + ``close()``) + + - :meth:`~scrapy.core.engine.ExecutionEngine.open_spider_async` (to + ``open_spider()``) + + - :meth:`~scrapy.core.engine.ExecutionEngine.close_spider_async` (to + ``close_spider()``) + + - :meth:`~scrapy.core.engine.ExecutionEngine.download_async` (to + ``download()``) + + - :class:`scrapy.core.scraper.Scraper` + + - :meth:`~scrapy.core.scraper.Scraper.open_spider_async` (to + ``open_spider()``) + + - :meth:`~scrapy.core.scraper.Scraper.close_spider_async` (to + ``close_spider()``) + + - :meth:`~scrapy.core.scraper.Scraper.start_itemproc_async` (to + ``start_itemproc()``) + + - :class:`scrapy.crawler.Crawler` + + - :meth:`~scrapy.crawler.Crawler.crawl_async` (to ``crawl()``) + + - :meth:`~scrapy.crawler.Crawler.stop_async` (to ``stop()``) + + - :class:`scrapy.pipelines.ItemPipelineManager` + + - :meth:`~scrapy.pipelines.ItemPipelineManager.process_item_async` (to + ``process_item()``) + + - :meth:`~scrapy.pipelines.ItemPipelineManager.open_spider_async` (to + ``open_spider()``) + + - :meth:`~scrapy.pipelines.ItemPipelineManager.close_spider_async` (to + ``close_spider()``) + + - :class:`scrapy.signalmanager.SignalManager` + + - :meth:`~scrapy.signalmanager.SignalManager.send_catch_log_async` (to + ``send_catch_log_deferred()``) + + (:issue:`6781`, :issue:`6791`, :issue:`6792`, :issue:`6795`, :issue:`6801`, + :issue:`6817`, :issue:`6842`, :issue:`6997`, :issue:`7005`, :issue:`7043`, + :issue:`7069`,:issue:`7164`, :issue:`7202`) + +- The default value of the :setting:`SCHEDULER_PRIORITY_QUEUE` setting is now + ``'scrapy.pqueues.DownloaderAwarePriorityQueue'``. + (:issue:`6924`, :issue:`6940`) + +- Added :class:`scrapy.extensions.logcount.LogCount`, an enabled-by-default + extension that is responsible for the ``log_count/*`` stats. Previously, + this code was in :class:`scrapy.crawler.Crawler` and couldn't be disabled. + (:issue:`7046`) + +- Added :meth:`scrapy.spiders.CrawlSpider.parse_with_rules` as a public + replacement for ``_parse_response()``. + (:issue:`4463`, :issue:`6804`) + +- Added :func:`scrapy.utils.asyncio.is_asyncio_available` as an alternative + to :func:`scrapy.utils.defer.is_asyncio_reactor_installed` with a + future-proof name and semantics. + (:issue:`6827`) + +- The API for :ref:`download handlers `, previously + undocumented, has been modernized and documented. An optional base class, + :class:`scrapy.core.downloader.handlers.base.BaseDownloadHandler`, has been + added to simplify writing custom download handlers that conform to the + current API. + (:issue:`4944`, :issue:`6778`, :issue:`7164`) + +- Added :func:`scrapy.utils.defer.ensure_awaitable`, which can be helpful to + call user-defined functions that can return coroutines, Deferreds or + values directly. + (:issue:`7005`) + +- The ``requests.seen`` file, written by + :class:`~scrapy.dupefilters.RFPDupeFilter` when :ref:`job persistence + ` is enabled, now uses line buffering to reduce data loss in + spider crashes. + (:issue:`6019`, :issue:`7094`) + +- Images downloaded by :class:`~scrapy.pipelines.images.ImagesPipeline` are + now automatically transposed based on EXIF data. + (:issue:`6525`, :issue:`6975`) + +Improvements +~~~~~~~~~~~~ + +- Refactored internal functions to use coroutines instead of Deferreds. + (:issue:`6795`, :issue:`6852`, :issue:`6855`, :issue:`6858`, :issue:`7159`) + +- Commands that don't need a :class:`~scrapy.crawler.CrawlerProcess` instance + no longer create it. + (:issue:`6824`) + +- Improved :command:`shell` help formatting when using IPython 9+. + (:issue:`6915`, :issue:`6980`) + +Bug fixes +~~~~~~~~~ + +- Setting :setting:`FILES_STORE` or :setting:`IMAGES_STORE` to ``None`` now + correctly disables the respective pipeline. + (:issue:`6964`, :issue:`6969`) + +- :class:`~scrapy.downloadermiddlewares.redirect.MetaRefreshMiddleware` now + uses the URL set in the ```` tag as the base URL when redirecting to + a relative URL. + (:issue:`7042`, :issue:`7047`) + +- Passing ``None`` as a value of the :reqmeta:`download_slot` request meta + key is now handled in the same way as not setting this meta key at all. + (:issue:`7172`) + +- Fixed parsing of the first line of ``robots.txt`` files that have a BOM. + (:issue:`6195`, :issue:`7095`) + +Documentation +~~~~~~~~~~~~~ + +- Added :ref:`documentation ` about download + handlers, their API and built-in handlers. + (:issue:`4944`, :issue:`7164`) + +- Added a section about the `scrapy-spider-metadata`_ library to the + :ref:`spider argument docs `. + (:issue:`6676`, :issue:`6957`, :issue:`7116`) + + .. _scrapy-spider-metadata: https://scrapy-spider-metadata.readthedocs.io/en/latest/ + +- Improved :ref:`the docs ` about coroutine-based + and Deferred-based APIs. + (:issue:`6800`, :issue:`7146`) + +- Other documentation improvements and fixes. + (:issue:`7058`, :issue:`7076`, :issue:`7109`, :issue:`7195`, :issue:`7198`) + +Quality assurance +~~~~~~~~~~~~~~~~~ + +- Switched from ``twisted.trial`` to ``pytest-twisted`` and replaced + remaining ``unittest`` and ``twisted.trial`` features with ``pytest`` ones. + (:issue:`6658`, :issue:`6873`, :issue:`6884`, :issue:`6938`) + +- Enabled fancy ``pytest`` asserts. + (:issue:`6888`) + +- Added `Sphinx Lint`_ to the ``pre-commit`` configuration. + (:issue:`6920`) + + .. _Sphinx Lint: https://github.com/sphinx-contrib/sphinx-lint + +- CI and test improvements and fixes. + (:issue:`6649`, + :issue:`6769`, + :issue:`6821`, + :issue:`6835`, + :issue:`6836`, + :issue:`6846`, + :issue:`6883`, + :issue:`6885`, + :issue:`6889`, + :issue:`6905`, + :issue:`6928`, + :issue:`6933`, + :issue:`6941`, + :issue:`6942`, + :issue:`6945`, + :issue:`6947`, + :issue:`6960`, + :issue:`6968`, + :issue:`6972`, + :issue:`6974`, + :issue:`6996`, + :issue:`7003`, + :issue:`7012`, + :issue:`7013`, + :issue:`7050`, + :issue:`7059`, + :issue:`7070`, + :issue:`7073`, + :issue:`7118`, + :issue:`7127`, + :issue:`7141`, + :issue:`7143`, + :issue:`7145`, + :issue:`7173`) + +- Code cleanups. + (:issue:`6803`, + :issue:`6838`, + :issue:`6849`, + :issue:`6875`, + :issue:`6876`, + :issue:`6892`, + :issue:`6930`, + :issue:`6949`, + :issue:`6970`, + :issue:`6977`, + :issue:`6986`, + :issue:`7008`, + :issue:`7177`) .. _release-2.13.4: @@ -265,7 +856,7 @@ Highlights: - The asyncio reactor is now enabled by default - Replaced ``start_requests()`` (sync) with :meth:`~scrapy.Spider.start` - (async) and changed how it is iterated. + (async) and changed how it is iterated - Added the :reqmeta:`allow_offsite` request meta key @@ -466,9 +1057,9 @@ Deprecations - The following modules and functions used only in tests are deprecated: - - the ``scrapy/utils/testproc`` module + - the ``scrapy.utils.testproc`` module - - the ``scrapy/utils/testsite`` module + - the ``scrapy.utils.testsite`` module - ``scrapy.utils.test.assert_gcs_environ()`` @@ -6969,7 +7560,7 @@ Enhancements - Make ``RFPDupeFilter`` class easily subclassable (:issue:`533`) - Improve test coverage and forthcoming Python 3 support (:issue:`525`) - Promote startup info on settings and middleware to INFO level (:issue:`520`) -- Support partials in ``get_func_args`` util (:issue:`506`, issue:`504`) +- Support partials in ``get_func_args`` util (:issue:`506`, :issue:`504`) - Allow running individual tests via tox (:issue:`503`) - Update extensions ignored by link extractors (:issue:`498`) - Add middleware methods to get files/images/thumbs paths (:issue:`490`) @@ -7537,7 +8128,7 @@ API changes - ``scrapy.core.manager.ScrapyManager`` class renamed to ``scrapy.crawler.Crawler`` - ``scrapy.core.manager.scrapymanager`` singleton moved to ``scrapy.project.crawler`` - Moved module: ``scrapy.contrib.spidermanager`` to ``scrapy.spidermanager`` -- Spider Manager singleton moved from ``scrapy.spider.spiders`` to the ``spiders` attribute of ``scrapy.project.crawler`` singleton. +- Spider Manager singleton moved from ``scrapy.spider.spiders`` to the ``spiders`` attribute of ``scrapy.project.crawler`` singleton. - moved Stats Collector classes: (#204) - ``scrapy.stats.collector.StatsCollector`` to ``scrapy.statscol.StatsCollector`` - ``scrapy.stats.collector.SimpledbStatsCollector`` to ``scrapy.contrib.statscol.SimpledbStatsCollector`` diff --git a/docs/topics/asyncio.rst b/docs/topics/asyncio.rst index 36f9a1a53..b2b17a408 100644 --- a/docs/topics/asyncio.rst +++ b/docs/topics/asyncio.rst @@ -97,6 +97,7 @@ Scrapy API requires passing a Deferred to it) using the following helpers: .. autofunction:: scrapy.utils.defer.deferred_from_coro .. autofunction:: scrapy.utils.defer.deferred_f_from_coro_f +.. autofunction:: scrapy.utils.defer.ensure_awaitable .. _enforce-asyncio-requirement: diff --git a/docs/topics/coroutines.rst b/docs/topics/coroutines.rst index a78e98828..a2dd67f68 100644 --- a/docs/topics/coroutines.rst +++ b/docs/topics/coroutines.rst @@ -57,7 +57,7 @@ hence use coroutine syntax (e.g. ``await``, ``async for``, ``async with``): - Methods of :ref:`download handlers `. - .. versionadded:: VERSION + .. versionadded:: 2.14 .. _coroutine-deferred-apis: diff --git a/docs/topics/download-handlers.rst b/docs/topics/download-handlers.rst index 5f00d7131..7e672f0b4 100644 --- a/docs/topics/download-handlers.rst +++ b/docs/topics/download-handlers.rst @@ -72,7 +72,7 @@ Writing your own download handler ================================= A download handler is a :ref:`component ` that defines -the following methods: +the following API: .. class:: SampleDownloadHandler diff --git a/docs/topics/spiders.rst b/docs/topics/spiders.rst index 699b141c4..98374b6b4 100644 --- a/docs/topics/spiders.rst +++ b/docs/topics/spiders.rst @@ -362,6 +362,8 @@ used by :class:`~scrapy.downloadermiddlewares.httpauth.HttpAuthMiddleware`:: Spider arguments can also be passed through the Scrapyd ``schedule.json`` API. See `Scrapyd documentation`_. +.. _spiderargs-scrapy-spider-metadata: + scrapy-spider-metadata parameters --------------------------------- diff --git a/scrapy/core/engine.py b/scrapy/core/engine.py index ffaddc2c4..4f8d8e8a1 100644 --- a/scrapy/core/engine.py +++ b/scrapy/core/engine.py @@ -172,7 +172,7 @@ class ExecutionEngine: async def start_async(self, *, _start_request_processing: bool = True) -> None: """Start the execution engine. - .. versionadded:: VERSION + .. versionadded:: 2.14 """ if self._starting: raise RuntimeError("Engine already running") @@ -208,7 +208,7 @@ class ExecutionEngine: async def stop_async(self) -> None: """Gracefully stop the execution engine. - .. versionadded:: VERSION + .. versionadded:: 2.14 """ if not self._starting: @@ -459,7 +459,7 @@ class ExecutionEngine: Only downloader middlewares are applied. - .. versionadded:: VERSION + .. versionadded:: 2.14 """ if self.spider is None: raise RuntimeError(f"No open spider to crawl: {request}") @@ -575,7 +575,7 @@ class ExecutionEngine: async def close_spider_async(self, *, reason: str = "cancelled") -> None: """Close (cancel) spider and clear all its outstanding requests. - .. versionadded:: VERSION + .. versionadded:: 2.14 """ if self.spider is None: raise RuntimeError("Spider not opened") diff --git a/scrapy/core/scraper.py b/scrapy/core/scraper.py index 455326a0a..df6aee715 100644 --- a/scrapy/core/scraper.py +++ b/scrapy/core/scraper.py @@ -162,7 +162,7 @@ class Scraper: async def open_spider_async(self) -> None: """Open the spider for scraping and allocate resources for it. - .. versionadded:: VERSION + .. versionadded:: 2.14 """ self.slot = Slot(self.crawler.settings.getint("SCRAPER_SLOT_MAX_ACTIVE_SIZE")) if not self.crawler.spider: @@ -187,7 +187,7 @@ class Scraper: async def close_spider_async(self) -> None: """Close the spider being scraped and release its resources. - .. versionadded:: VERSION + .. versionadded:: 2.14 """ if self.slot is None: raise RuntimeError("Scraper slot not assigned") @@ -488,7 +488,7 @@ class Scraper: *response* is the source of the item data. If the item does not come from response data, e.g. it was hard-coded, set it to ``None``. - .. versionadded:: VERSION + .. versionadded:: 2.14 """ assert self.slot is not None # typing assert self.crawler.spider is not None # typing diff --git a/scrapy/crawler.py b/scrapy/crawler.py index e21207222..66148904b 100644 --- a/scrapy/crawler.py +++ b/scrapy/crawler.py @@ -165,7 +165,7 @@ class Crawler: *args* and *kwargs* arguments, while setting the execution engine in motion. Should be called only once. - .. versionadded:: VERSION + .. versionadded:: 2.14 Complete when the crawl is finished. """ @@ -209,7 +209,7 @@ class Crawler: async def stop_async(self) -> None: """Start a graceful stop of the crawler and complete when the crawler is stopped. - .. versionadded:: VERSION + .. versionadded:: 2.14 """ if self.crawling: self.crawling = False diff --git a/scrapy/extensions/logcount.py b/scrapy/extensions/logcount.py index 04e570bbf..e6d51a7d8 100644 --- a/scrapy/extensions/logcount.py +++ b/scrapy/extensions/logcount.py @@ -22,7 +22,7 @@ class LogCount: The handler installed is :class:`scrapy.utils.log.LogCounterHandler`. The counts are stored in stats as ``log_count/``. - .. versionadded:: VERSION + .. versionadded:: 2.14 """ def __init__(self, crawler: Crawler): diff --git a/scrapy/http/response/__init__.py b/scrapy/http/response/__init__.py index de2188ceb..2cc35fef4 100644 --- a/scrapy/http/response/__init__.py +++ b/scrapy/http/response/__init__.py @@ -205,9 +205,6 @@ class Response(object_ref): :class:`~.TextResponse` provides a :meth:`~.TextResponse.follow` method which supports selectors in addition to absolute/relative URLs and Link objects. - - .. versionadded:: 2.0 - The *flags* parameter. """ if encoding is None: raise ValueError("encoding can't be None") @@ -250,8 +247,6 @@ class Response(object_ref): flags: list[str] | None = None, ) -> Iterable[Request]: """ - .. versionadded:: 2.0 - Return an iterable of :class:`~.Request` instances to follow all links in ``urls``. It accepts the same arguments as ``Request.__init__()`` method, but elements of ``urls`` can be relative URLs or :class:`~scrapy.link.Link` objects, diff --git a/scrapy/http/response/text.py b/scrapy/http/response/text.py index 9c4e4c151..012ead519 100644 --- a/scrapy/http/response/text.py +++ b/scrapy/http/response/text.py @@ -77,11 +77,7 @@ class TextResponse(Response): ) def json(self) -> Any: - """ - .. versionadded:: 2.2 - - Deserialize a JSON document to a Python object. - """ + """Deserialize a JSON document to a Python object.""" if self._cached_decoded_json is _NONE: self._cached_decoded_json = json.loads(self.body) return self._cached_decoded_json diff --git a/scrapy/logformatter.py b/scrapy/logformatter.py index e81a9ec93..a50064e08 100644 --- a/scrapy/logformatter.py +++ b/scrapy/logformatter.py @@ -142,8 +142,6 @@ class LogFormatter: ) -> LogFormatterResult: """Logs a message when an item causes an error while it is passing through the item pipeline. - - .. versionadded:: 2.0 """ return { "level": logging.ERROR, @@ -160,10 +158,7 @@ class LogFormatter: response: Response | Failure, spider: Spider, ) -> LogFormatterResult: - """Logs an error message from a spider. - - .. versionadded:: 2.0 - """ + """Logs an error message from a spider.""" return { "level": logging.ERROR, "msg": SPIDERERRORMSG, @@ -182,8 +177,6 @@ class LogFormatter: ) -> LogFormatterResult: """Logs a download error message from a spider (typically coming from the engine). - - .. versionadded:: 2.0 """ args: dict[str, Any] = {"request": request} if errmsg: diff --git a/scrapy/signalmanager.py b/scrapy/signalmanager.py index 30afa9168..3ca9718d7 100644 --- a/scrapy/signalmanager.py +++ b/scrapy/signalmanager.py @@ -85,7 +85,7 @@ class SignalManager: The keyword arguments are passed to the signal handlers (connected through the :meth:`connect` method). - .. versionadded:: VERSION + .. versionadded:: 2.14 """ # note that this returns exceptions instead of Failures in the second tuple member kwargs.setdefault("sender", self.sender) diff --git a/scrapy/utils/asyncio.py b/scrapy/utils/asyncio.py index 25237d0ba..a8216bc2b 100644 --- a/scrapy/utils/asyncio.py +++ b/scrapy/utils/asyncio.py @@ -33,7 +33,7 @@ logger = logging.getLogger(__name__) def is_asyncio_available() -> bool: """Check if it's possible to call asyncio code that relies on the asyncio event loop. - .. versionadded:: VERSION + .. versionadded:: 2.14 Currently this function is identical to :func:`scrapy.utils.reactor.is_asyncio_reactor_installed`: it returns diff --git a/scrapy/utils/defer.py b/scrapy/utils/defer.py index afbe680ef..af7c50d5c 100644 --- a/scrapy/utils/defer.py +++ b/scrapy/utils/defer.py @@ -461,9 +461,7 @@ def deferred_to_future(d: Deferred[_T]) -> Future[_T]: deferred = self.crawler.engine.download(additional_request) additional_response = await deferred_to_future(deferred) - .. versionadded:: 2.6.0 - - .. versionchanged:: VERSION + .. versionchanged:: 2.14 This function no longer installs an asyncio loop if called before the Twisted asyncio reactor is installed. A :exc:`RuntimeError` is raised in this case. @@ -495,8 +493,6 @@ def maybe_deferred_to_future(d: Deferred[_T]) -> Deferred[_T] | Future[_T]: additional_request = scrapy.Request('https://example.org/price') deferred = self.crawler.engine.download(additional_request) additional_response = await maybe_deferred_to_future(deferred) - - .. versionadded:: 2.6.0 """ if not is_asyncio_available(): return d @@ -534,7 +530,7 @@ def ensure_awaitable(o: _T | Awaitable[_T], _warn: str | None = None) -> Awaitab awaitable object of a different type, return it as is. For any other value, return a coroutine that completes with that value. - .. versionadded:: VERSION + .. versionadded:: 2.14 """ if isinstance(o, Deferred): if _warn: diff --git a/scrapy/utils/deprecate.py b/scrapy/utils/deprecate.py index e6e6becd6..e5b2ae23d 100644 --- a/scrapy/utils/deprecate.py +++ b/scrapy/utils/deprecate.py @@ -203,7 +203,7 @@ def argument_is_required(func: Callable[..., Any], arg_name: str) -> bool: """ Check if a function argument is required (exists and doesn't have a default value). - .. versionadded:: VERSION + .. versionadded:: 2.14 >>> def func(a, b=1, c=None): ... pass diff --git a/scrapy/utils/python.py b/scrapy/utils/python.py index ca4b4bf28..04da75a30 100644 --- a/scrapy/utils/python.py +++ b/scrapy/utils/python.py @@ -174,7 +174,7 @@ def get_func_args_dict( ) -> Mapping[str, inspect.Parameter]: """Return the argument dict of a callable object. - .. versionadded:: VERSION + .. versionadded:: 2.14 """ if not callable(func): raise TypeError(f"func must be callable, got '{type(func).__name__}'") diff --git a/scrapy/utils/signal.py b/scrapy/utils/signal.py index 2c6aa9e67..a06936544 100644 --- a/scrapy/utils/signal.py +++ b/scrapy/utils/signal.py @@ -142,7 +142,7 @@ async def send_catch_log_async( Returns a coroutine that completes once all signal handlers have finished. - .. versionadded:: VERSION + .. versionadded:: 2.14 """ # note that this returns exceptions instead of Failures in the second tuple member if is_asyncio_available(): @@ -171,7 +171,7 @@ async def _send_catch_log_asyncio( :class:`~twisted.internet.asyncioreactor.AsyncioSelectorReactor` to be installed. - .. versionadded:: VERSION + .. versionadded:: 2.14 """ dont_log = named.pop("dont_log", ()) dont_log = tuple(dont_log) if isinstance(dont_log, Sequence) else (dont_log,) From 3ac1192f35fc32d01c69c929eed85601fb0405e8 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Mon, 5 Jan 2026 15:06:13 +0500 Subject: [PATCH 019/248] =?UTF-8?q?Bump=20version:=202.13.4=20=E2=86=92=20?= =?UTF-8?q?2.14.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- SECURITY.md | 4 ++-- docs/news.rst | 2 +- pyproject.toml | 2 +- scrapy/VERSION | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index a5a5c7fb3..db67dea5d 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,8 +4,8 @@ | Version | Supported | | ------- | ------------------ | -| 2.13.x | :white_check_mark: | -| < 2.13.x | :x: | +| 2.14.x | :white_check_mark: | +| < 2.14.x | :x: | ## Reporting a Vulnerability diff --git a/docs/news.rst b/docs/news.rst index 1b1c72c1b..f07153402 100644 --- a/docs/news.rst +++ b/docs/news.rst @@ -5,7 +5,7 @@ Release notes .. _release-2.14.0: -Scrapy 2.14.0 (unreleased) +Scrapy 2.14.0 (2026-01-05) -------------------------- Highlights: diff --git a/pyproject.toml b/pyproject.toml index 81351bab7..d15e85fa7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -111,7 +111,7 @@ module = "twisted" implicit_reexport = true [tool.bumpversion] -current_version = "2.13.4" +current_version = "2.14.0" commit = true tag = true tag_name = "{new_version}" diff --git a/scrapy/VERSION b/scrapy/VERSION index 965a689ec..edcfe40d1 100644 --- a/scrapy/VERSION +++ b/scrapy/VERSION @@ -1 +1 @@ -2.13.4 +2.14.0 From 1af283387fdaee8b9f96f9bde8a00c14a0e88728 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Fri, 9 Jan 2026 00:09:39 +0400 Subject: [PATCH 020/248] Deprecate maybeDeferred_coro(). (#7212) --- scrapy/pipelines/__init__.py | 6 +++--- scrapy/utils/defer.py | 35 +++++++++++++++++++++++++++++------ scrapy/utils/signal.py | 12 +++++++++--- 3 files changed, 41 insertions(+), 12 deletions(-) diff --git a/scrapy/pipelines/__init__.py b/scrapy/pipelines/__init__.py index 21ad6fb1a..14d53f1c5 100644 --- a/scrapy/pipelines/__init__.py +++ b/scrapy/pipelines/__init__.py @@ -16,7 +16,7 @@ from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.middleware import MiddlewareManager from scrapy.utils.asyncio import is_asyncio_available from scrapy.utils.conf import build_component_list -from scrapy.utils.defer import deferred_from_coro, ensure_awaitable, maybeDeferred_coro +from scrapy.utils.defer import _maybeDeferred_coro, deferred_from_coro, ensure_awaitable from scrapy.utils.python import global_object_name if TYPE_CHECKING: @@ -70,8 +70,8 @@ class ItemPipelineManager(MiddlewareManager): method: Callable[..., Coroutine[Any, Any, None] | Deferred[None] | None], ) -> Deferred[None]: if method in self._mw_methods_requiring_spider: - return maybeDeferred_coro(method, self._spider) - return maybeDeferred_coro(method) + return _maybeDeferred_coro(method, True, self._spider) + return _maybeDeferred_coro(method, True) dfds = [get_dfd(m) for m in methods] d: Deferred[list[tuple[bool, None]]] = DeferredList( diff --git a/scrapy/utils/defer.py b/scrapy/utils/defer.py index af7c50d5c..218dc8d7a 100644 --- a/scrapy/utils/defer.py +++ b/scrapy/utils/defer.py @@ -419,6 +419,18 @@ def deferred_f_from_coro_f( def maybeDeferred_coro( f: Callable[_P, Any], *args: _P.args, **kw: _P.kwargs +) -> Deferred[Any]: # pragma: no cover + """Copy of defer.maybeDeferred that also converts coroutines to Deferreds.""" + warnings.warn( + "maybeDeferred_coro() is deprecated and will be removed in a future Scrapy version.", + ScrapyDeprecationWarning, + stacklevel=2, + ) + return _maybeDeferred_coro(f, False, *args, **kw) + + +def _maybeDeferred_coro( + f: Callable[_P, Any], warn: bool, *args: _P.args, **kw: _P.kwargs ) -> Deferred[Any]: """Copy of defer.maybeDeferred that also converts coroutines to Deferreds.""" try: @@ -426,17 +438,28 @@ def maybeDeferred_coro( except: # noqa: E722 # pylint: disable=bare-except return fail(failure.Failure(captureVars=Deferred.debug)) + # when the deprecation period has ended we need to make sure the behavior + # of the public maybeDeferred_coro() function isn't changed, or drop it in + # the same release if isinstance(result, Deferred): - warnings.warn( - f"{global_object_name(f)} returned a Deferred, this is deprecated." - f" Please refactor this function to return a coroutine.", - ScrapyDeprecationWarning, - stacklevel=2, - ) + if warn: + warnings.warn( + f"{global_object_name(f)} returned a Deferred, this is deprecated." + f" Please refactor this function to return a coroutine.", + ScrapyDeprecationWarning, + stacklevel=2, + ) return result if asyncio.isfuture(result) or inspect.isawaitable(result): return deferred_from_coro(result) if isinstance(result, failure.Failure): + if warn: + warnings.warn( + f"{global_object_name(f)} returned a Failure, this is deprecated." + f" Please refactor this function to return a coroutine.", + ScrapyDeprecationWarning, + stacklevel=2, + ) return fail(result) return succeed(result) diff --git a/scrapy/utils/signal.py b/scrapy/utils/signal.py index a06936544..1f7426e59 100644 --- a/scrapy/utils/signal.py +++ b/scrapy/utils/signal.py @@ -22,9 +22,9 @@ from twisted.python.failure import Failure from scrapy.exceptions import ScrapyDeprecationWarning, StopDownload from scrapy.utils.asyncio import is_asyncio_available from scrapy.utils.defer import ( + _maybeDeferred_coro, ensure_awaitable, maybe_deferred_to_future, - maybeDeferred_coro, ) from scrapy.utils.log import failure_to_exc_info from scrapy.utils.python import global_object_name @@ -114,8 +114,14 @@ def _send_catch_log_deferred( spider = named.get("spider") dfds: list[Deferred[tuple[TypingAny, TypingAny]]] = [] for receiver in liveReceivers(getAllReceivers(sender, signal)): - d: Deferred[TypingAny] = maybeDeferred_coro( - robustApply, receiver, signal=signal, sender=sender, *arguments, **named + d: Deferred[TypingAny] = _maybeDeferred_coro( + robustApply, + True, + receiver, + signal=signal, + sender=sender, + *arguments, + **named, ) d.addErrback(logerror, receiver) # TODO https://pylint.readthedocs.io/en/latest/user_guide/messages/warning/cell-var-from-loop.html From 0f07b2e38c0bbbef64d5bb35c6fe6ad504f2162f Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Fri, 9 Jan 2026 19:57:53 +0400 Subject: [PATCH 021/248] Pass the spider arg to custom stat collectors {open,close}_spider(). (#7213) --- scrapy/core/engine.py | 24 +++++++++++++-- tests/test_stats.py | 71 +++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 91 insertions(+), 4 deletions(-) diff --git a/scrapy/core/engine.py b/scrapy/core/engine.py index 4f8d8e8a1..b29c5e80e 100644 --- a/scrapy/core/engine.py +++ b/scrapy/core/engine.py @@ -534,7 +534,16 @@ class ExecutionEngine: await maybe_deferred_to_future(d) await self.scraper.open_spider_async() assert self.crawler.stats - self.crawler.stats.open_spider() + if argument_is_required(self.crawler.stats.open_spider, "spider"): + warnings.warn( + f"The open_spider() method of {global_object_name(type(self.crawler.stats))} requires a spider argument," + f" this is deprecated and the argument will not be passed in future Scrapy versions.", + ScrapyDeprecationWarning, + stacklevel=2, + ) + self.crawler.stats.open_spider(spider=self.crawler.spider) + else: + self.crawler.stats.open_spider() await self.signals.send_catch_log_async( signals.spider_opened, spider=self.crawler.spider ) @@ -629,7 +638,18 @@ class ExecutionEngine: assert self.crawler.stats try: - self.crawler.stats.close_spider(reason=reason) + if argument_is_required(self.crawler.stats.close_spider, "spider"): + warnings.warn( + f"The close_spider() method of {global_object_name(type(self.crawler.stats))} requires a spider argument," + f" this is deprecated and the argument will not be passed in future Scrapy versions.", + ScrapyDeprecationWarning, + stacklevel=2, + ) + self.crawler.stats.close_spider( + spider=self.crawler.spider, reason=reason + ) + else: + self.crawler.stats.close_spider(reason=reason) except Exception: log_failure("Stats close failure") diff --git a/tests/test_stats.py b/tests/test_stats.py index 6814a652e..b5ab06901 100644 --- a/tests/test_stats.py +++ b/tests/test_stats.py @@ -10,7 +10,9 @@ from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.extensions.corestats import CoreStats from scrapy.spiders import Spider from scrapy.statscollectors import DummyStatsCollector, StatsCollector +from scrapy.utils.defer import deferred_f_from_coro_f from scrapy.utils.test import get_crawler +from tests.spiders import SimpleSpider if TYPE_CHECKING: from scrapy.crawler import Crawler @@ -109,12 +111,77 @@ class TestStatsCollector: stats = StatsCollector(crawler) with pytest.warns( ScrapyDeprecationWarning, - match=r"Passing a 'spider' argument to StatsCollector.set_value\(\) is deprecated", + match=r"Passing a 'spider' argument to StatsCollector\.set_value\(\) is deprecated", ): stats.set_value("test", "value", spider=spider) assert stats.get_stats() == {"test": "value"} with pytest.warns( ScrapyDeprecationWarning, - match=r"Passing a 'spider' argument to StatsCollector.get_stats\(\) is deprecated", + match=r"Passing a 'spider' argument to StatsCollector\.get_stats\(\) is deprecated", ): assert stats.get_stats(spider) == {"test": "value"} + + @deferred_f_from_coro_f + async def test_deprecated_spider_arg_custom_collector(self) -> None: + class CustomStatsCollector: + def __init__(self, crawler): + self._stats = {} + + def open_spider(self, spider): + pass + + def get_stats(self, spider=None): + return self._stats + + def inc_value(self, key, count=1, start=0, spider=None): + d = self._stats + d[key] = d.setdefault(key, start) + count + + def close_spider(self, spider, reason): + pass + + crawler = get_crawler(SimpleSpider, {"STATS_CLASS": CustomStatsCollector}) + with ( + pytest.warns( + ScrapyDeprecationWarning, + match=r"The open_spider\(\) method of .*CustomStatsCollector requires a spider argument", + ), + pytest.warns( + ScrapyDeprecationWarning, + match=r"The close_spider\(\) method of .*CustomStatsCollector requires a spider argument", + ), + ): + await crawler.crawl_async(url="data:,") + + @deferred_f_from_coro_f + async def test_deprecated_spider_arg_custom_collector_subclass(self) -> None: + class CustomStatsCollector(StatsCollector): + def open_spider(self, spider): # pylint: disable=signature-differs + super().open_spider(spider) + + def inc_value(self, key, count=1, start=0, spider=None): # pylint: disable=useless-parent-delegation + super().inc_value(key, count, start, spider) + + def close_spider(self, spider, reason): # pylint: disable=signature-differs + super().close_spider(spider, reason) + + crawler = get_crawler(SimpleSpider, {"STATS_CLASS": CustomStatsCollector}) + with ( + pytest.warns( + ScrapyDeprecationWarning, + match=r"The open_spider\(\) method of .*CustomStatsCollector requires a spider argument", + ), + pytest.warns( + ScrapyDeprecationWarning, + match=r"Passing a 'spider' argument to .*CustomStatsCollector\.open_spider\(\) is deprecated", + ), + pytest.warns( + ScrapyDeprecationWarning, + match=r"The close_spider\(\) method of .*CustomStatsCollector requires a spider argument", + ), + pytest.warns( + ScrapyDeprecationWarning, + match=r"Passing a 'spider' argument to .*CustomStatsCollector\.close_spider\(\) is deprecated", + ), + ): + await crawler.crawl_async(url="data:,") From e30ba7d4caa5acfdf3f87eceb03da0a514377045 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Santiago=20Beltr=C3=A1n=20Ruiz?= <69373265+Santiago-Beltran@users.noreply.github.com> Date: Mon, 12 Jan 2026 02:31:53 -0500 Subject: [PATCH 022/248] (#7180) Update codecov test results action to v5 on all CI workflows. (#7215) --- .github/workflows/tests-macos.yml | 4 +++- .github/workflows/tests-ubuntu.yml | 4 +++- .github/workflows/tests-windows.yml | 4 +++- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/workflows/tests-macos.yml b/.github/workflows/tests-macos.yml index f061b1892..61bac1288 100644 --- a/.github/workflows/tests-macos.yml +++ b/.github/workflows/tests-macos.yml @@ -36,4 +36,6 @@ jobs: - name: Upload test results if: ${{ !cancelled() }} - uses: codecov/test-results-action@v1 + uses: codecov/codecov-action@v5 + with: + report_type: test_results diff --git a/.github/workflows/tests-ubuntu.yml b/.github/workflows/tests-ubuntu.yml index 1f1ef77ca..b85f5ea4f 100644 --- a/.github/workflows/tests-ubuntu.yml +++ b/.github/workflows/tests-ubuntu.yml @@ -91,4 +91,6 @@ jobs: - name: Upload test results if: ${{ !cancelled() }} - uses: codecov/test-results-action@v1 + uses: codecov/codecov-action@v5 + with: + report_type: test_results diff --git a/.github/workflows/tests-windows.yml b/.github/workflows/tests-windows.yml index 73b87f8ed..f202a2ffd 100644 --- a/.github/workflows/tests-windows.yml +++ b/.github/workflows/tests-windows.yml @@ -64,4 +64,6 @@ jobs: - name: Upload test results if: ${{ !cancelled() }} - uses: codecov/test-results-action@v1 + uses: codecov/codecov-action@v5 + with: + report_type: test_results From c9fbf6c59941b86ddeaa12ff48e15d75acc95d7f Mon Sep 17 00:00:00 2001 From: Eugenio Lacuesta <1731933+elacuesta@users.noreply.github.com> Date: Mon, 12 Jan 2026 16:23:51 -0300 Subject: [PATCH 023/248] Release notes for 2.14.1 (#7217) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Release notes for 2.14.1 * Update docs/news.rst Co-authored-by: Adrián Chaves * Update docs/news.rst Co-authored-by: Andrey Rakhmatullin * Update docs/news.rst Co-authored-by: Adrián Chaves * Update message about github action change --------- Co-authored-by: Adrián Chaves Co-authored-by: Andrey Rakhmatullin --- docs/news.rst | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/docs/news.rst b/docs/news.rst index f07153402..5734ae03b 100644 --- a/docs/news.rst +++ b/docs/news.rst @@ -3,6 +3,35 @@ Release notes ============= +.. _release-2.14.1: + +Scrapy 2.14.1 (unreleased) +-------------------------- + +Deprecations +~~~~~~~~~~~~ + +- ``scrapy.utils.defer.maybeDeferred_coro()`` is deprecated. (:issue:`7212`) + +Bug fixes +~~~~~~~~~ + +- Fixed custom stats collectors that require a ``spider`` argument in their + ``open_spider()`` and ``close_spider()`` methods not receiving the + argument when called by the engine. + + Note, however, that the ``spider`` argument is now deprecated and will stop + being passed in a future version of Scrapy. + + (:issue:`7213`) + +Quality assurance +~~~~~~~~~~~~~~~~~ + +- Replaced deprecated ``codecov/test-results-action@v1`` GitHub Action with + ``codecov/codecov-action@v5``. + (:issue:`7180`, :issue:`7215`) + .. _release-2.14.0: Scrapy 2.14.0 (2026-01-05) From 0b9d8da09dd2cb1b74ddf025107e6f584839fbff Mon Sep 17 00:00:00 2001 From: Eugenio Lacuesta Date: Mon, 12 Jan 2026 16:25:43 -0300 Subject: [PATCH 024/248] =?UTF-8?q?Bump=20version:=202.14.0=20=E2=86=92=20?= =?UTF-8?q?2.14.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/news.rst | 2 +- pyproject.toml | 2 +- scrapy/VERSION | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/news.rst b/docs/news.rst index 5734ae03b..bab2e5ff9 100644 --- a/docs/news.rst +++ b/docs/news.rst @@ -5,7 +5,7 @@ Release notes .. _release-2.14.1: -Scrapy 2.14.1 (unreleased) +Scrapy 2.14.1 (2026-01-12) -------------------------- Deprecations diff --git a/pyproject.toml b/pyproject.toml index d15e85fa7..7982b43b8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -111,7 +111,7 @@ module = "twisted" implicit_reexport = true [tool.bumpversion] -current_version = "2.14.0" +current_version = "2.14.1" commit = true tag = true tag_name = "{new_version}" diff --git a/scrapy/VERSION b/scrapy/VERSION index edcfe40d1..b70ae75a8 100644 --- a/scrapy/VERSION +++ b/scrapy/VERSION @@ -1 +1 @@ -2.14.0 +2.14.1 From b6e5c58ae707a3d4bb491537b5519534050047e0 Mon Sep 17 00:00:00 2001 From: Adrian Chaves Date: Mon, 19 Jan 2026 16:44:03 +0100 Subject: [PATCH 025/248] Fix arbitrary callable execution from the Referrer-Policy response header --- docs/news.rst | 20 +++++ docs/topics/spider-middleware.rst | 17 ++++ scrapy/spidermiddlewares/referer.py | 112 ++++++++++++++----------- scrapy/utils/python.py | 20 +++++ tests/test_spidermiddleware_referer.py | 101 ++++++++++++++++++++++ 5 files changed, 220 insertions(+), 50 deletions(-) diff --git a/docs/news.rst b/docs/news.rst index bab2e5ff9..dfa4a36f6 100644 --- a/docs/news.rst +++ b/docs/news.rst @@ -3,6 +3,26 @@ Release notes ============= +.. _release-2.14.2: + +Scrapy 2.14.2 (unreleased) +-------------------------- + +Security bug fixes +~~~~~~~~~~~~~~~~~~ + +- Values from the ``Referrer-Policy`` header of HTTP responses are no longer + executed as Python callables. See the `cwxj-rr6w-m6w7`_ security advisory + for details. + + .. _cwxj-rr6w-m6w7: https://github.com/scrapy/scrapy/security/advisories/GHSA-cwxj-rr6w-m6w7 + +New features +~~~~~~~~~~~~ + +- Added a new setting, :setting:`REFERER_POLICIES`, to allow customizing + supported referrer policies. + .. _release-2.14.1: Scrapy 2.14.1 (2026-01-12) diff --git a/docs/topics/spider-middleware.rst b/docs/topics/spider-middleware.rst index 799d2f99c..64ae988b5 100644 --- a/docs/topics/spider-middleware.rst +++ b/docs/topics/spider-middleware.rst @@ -403,6 +403,23 @@ String value Class name (as a string) .. _"strict-origin-when-cross-origin": https://www.w3.org/TR/referrer-policy/#referrer-policy-strict-origin-when-cross-origin .. _"unsafe-url": https://www.w3.org/TR/referrer-policy/#referrer-policy-unsafe-url +.. setting:: REFERRER_POLICIES + +REFERRER_POLICIES +^^^^^^^^^^^^^^^^^ + +Default: ``{}`` + +A dictionary mapping policy names to import paths of +:class:`scrapy.spidermiddlewares.referer.ReferrerPolicy` subclasses, or +``None`` to disable support for a given policy name. + +This allows to override the policies triggered by the ``Referrer-Policy`` +response header. + +Use ``""`` to override the policy for responses with `no referrer policy +`__. + StartSpiderMiddleware --------------------- diff --git a/scrapy/spidermiddlewares/referer.py b/scrapy/spidermiddlewares/referer.py index e4d0f4014..fe9b76500 100644 --- a/scrapy/spidermiddlewares/referer.py +++ b/scrapy/spidermiddlewares/referer.py @@ -17,7 +17,7 @@ from scrapy.exceptions import NotConfigured from scrapy.http import Request, Response from scrapy.spidermiddlewares.base import BaseSpiderMiddleware from scrapy.utils.misc import load_object -from scrapy.utils.python import to_unicode +from scrapy.utils.python import _looks_like_import_path, to_unicode from scrapy.utils.url import strip_url if TYPE_CHECKING: @@ -287,56 +287,38 @@ class DefaultReferrerPolicy(NoReferrerWhenDowngradePolicy): name: str = POLICY_SCRAPY_DEFAULT -_policy_classes: dict[str, type[ReferrerPolicy]] = { - p.name: p - for p in ( - NoReferrerPolicy, - NoReferrerWhenDowngradePolicy, - SameOriginPolicy, - OriginPolicy, - StrictOriginPolicy, - OriginWhenCrossOriginPolicy, - StrictOriginWhenCrossOriginPolicy, - UnsafeUrlPolicy, - DefaultReferrerPolicy, - ) -} - -# Reference: https://www.w3.org/TR/referrer-policy/#referrer-policy-empty-string -_policy_classes[""] = NoReferrerWhenDowngradePolicy - - -def _load_policy_class( - policy: str, warning_only: bool = False -) -> type[ReferrerPolicy] | None: - """ - Expect a string for the path to the policy class, - otherwise try to interpret the string as a standard value - from https://www.w3.org/TR/referrer-policy/#referrer-policies - """ - try: - return cast("type[ReferrerPolicy]", load_object(policy)) - except ValueError: - tokens = [token.strip() for token in policy.lower().split(",")] - # https://www.w3.org/TR/referrer-policy/#parse-referrer-policy-from-header - for token in tokens[::-1]: - if token in _policy_classes: - return _policy_classes[token] - - msg = f"Could not load referrer policy {policy!r}" - if not warning_only: - raise RuntimeError(msg) - warnings.warn(msg, RuntimeWarning) - return None - - class RefererMiddleware(BaseSpiderMiddleware): def __init__(self, settings: BaseSettings | None = None): # pylint: disable=super-init-not-called self.default_policy: type[ReferrerPolicy] = DefaultReferrerPolicy - if settings is not None: - settings_policy = _load_policy_class(settings.get("REFERRER_POLICY")) - assert settings_policy - self.default_policy = settings_policy + self.policies: dict[str, type[ReferrerPolicy]] = { + p.name: p + for p in ( + NoReferrerPolicy, + NoReferrerWhenDowngradePolicy, + SameOriginPolicy, + OriginPolicy, + StrictOriginPolicy, + OriginWhenCrossOriginPolicy, + StrictOriginWhenCrossOriginPolicy, + UnsafeUrlPolicy, + DefaultReferrerPolicy, + ) + } + # Reference: https://www.w3.org/TR/referrer-policy/#referrer-policy-empty-string + self.policies[""] = NoReferrerWhenDowngradePolicy + if settings is None: + return + setting_policies = settings.getdict("REFERRER_POLICIES") + for policy_name, policy_class_import_path in setting_policies.items(): + if policy_class_import_path is None: + del self.policies[policy_name] + else: + self.policies[policy_name] = load_object(policy_class_import_path) + settings_policy = self._load_policy_class( + settings.get("REFERRER_POLICY"), allow_import_path=True + ) + assert settings_policy + self.default_policy = settings_policy @classmethod def from_crawler(cls, crawler: Crawler) -> Self: @@ -362,17 +344,47 @@ class RefererMiddleware(BaseSpiderMiddleware): it is used if valid - otherwise, the policy from settings is used. """ + allow_import_path = True policy_name = request.meta.get("referrer_policy") if policy_name is None and isinstance(resp_or_url, Response): policy_header = resp_or_url.headers.get("Referrer-Policy") if policy_header is not None: policy_name = to_unicode(policy_header.decode("latin1")) + allow_import_path = False if policy_name is None: return self.default_policy() - - cls = _load_policy_class(policy_name, warning_only=True) + cls = self._load_policy_class( + policy_name, warning_only=True, allow_import_path=allow_import_path + ) return cls() if cls else self.default_policy() + def _load_policy_class( + self, + policy: str, + warning_only: bool = False, + *, + allow_import_path: bool = False, + ) -> type[ReferrerPolicy] | None: + if allow_import_path: + try: + return cast("type[ReferrerPolicy]", load_object(policy)) + except ValueError: + pass + policy_names = [ + policy_name.strip() for policy_name in policy.lower().split(",") + ] + # https://www.w3.org/TR/referrer-policy/#parse-referrer-policy-from-header + for policy_name in policy_names[::-1]: + if policy_name in self.policies: + return self.policies[policy_name] + msg = f"Could not load referrer policy {policy!r}" + if not allow_import_path and _looks_like_import_path(policy): + msg += " (import paths from the response Referrer-Policy header are not allowed)" + if not warning_only: + raise RuntimeError(msg) + warnings.warn(msg, RuntimeWarning) + return None + def get_processed_request( self, request: Request, response: Response | None ) -> Request | None: diff --git a/scrapy/utils/python.py b/scrapy/utils/python.py index 04da75a30..bf70fa7fc 100644 --- a/scrapy/utils/python.py +++ b/scrapy/utils/python.py @@ -335,3 +335,23 @@ class MutableAsyncChain(AsyncIterator[_T]): async def __anext__(self) -> _T: return await self.data.__anext__() + + +def _looks_like_import_path(value: str) -> bool: + """Return True if **value** looks like a valid Python import path or False + otherwise.""" + if not value: + return False + if any(c.isspace() for c in value): + return False + allowed_chars = set( + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_." + ) + if any(c not in allowed_chars for c in value): + return False + if value[0] == "." or value[-1] == ".": + return False + parts = value.split(".") + if any(part == "" for part in parts): + return False + return all(part.isidentifier() for part in parts) diff --git a/tests/test_spidermiddleware_referer.py b/tests/test_spidermiddleware_referer.py index 68fcad98c..0c7e360c9 100644 --- a/tests/test_spidermiddleware_referer.py +++ b/tests/test_spidermiddleware_referer.py @@ -31,6 +31,8 @@ from scrapy.spidermiddlewares.referer import ( StrictOriginWhenCrossOriginPolicy, UnsafeUrlPolicy, ) +from scrapy.utils.defer import deferred_f_from_coro_f +from scrapy.utils.misc import build_from_crawler from scrapy.utils.spider import DefaultSpider from scrapy.utils.test import get_crawler @@ -1358,3 +1360,102 @@ class TestReferrerOnRedirectStrictOriginWhenCrossOrigin(TestReferrerOnRedirect): None, ), ] + + +@deferred_f_from_coro_f +async def test_response_policy_only_supports_policy_names(): + crawler = get_crawler(settings_dict={"REFERRER_POLICY": "no-referrer"}) + mw = build_from_crawler(RefererMiddleware, crawler) + + async def input_result(): + yield Request("https://example.com/") + + response = Response( + "https://example.com/", + headers={ + "Referrer-Policy": "scrapy.spidermiddlewares.referer.NoReferrerWhenDowngradePolicy" + }, + ) + with pytest.warns( + RuntimeWarning, + match=r"Could not load referrer policy 'scrapy\.spidermiddlewares\.referer\.NoReferrerWhenDowngradePolicy' \(import paths from the response Referrer-Policy header are not allowed\)", + ): + output = [ + request + async for request in mw.process_spider_output_async( + response, input_result() + ) + ] + assert len(output) == 1 + assert b"Referer" not in output[0].headers + + response = Response( + "https://example.com/", + headers={"Referrer-Policy": "no-referrer-when-downgrade"}, + ) + output = [ + request + async for request in mw.process_spider_output_async(response, input_result()) + ] + assert len(output) == 1 + assert output[0].headers == {b"Referer": [b"https://example.com/"]} + + +@deferred_f_from_coro_f +async def test_referer_policies_setting(): + crawler = get_crawler( + settings_dict={ + "REFERRER_POLICY": "no-referrer", + "REFERRER_POLICIES": { + "no-referrer-when-downgrade": None, + "custom-policy": CustomPythonOrgPolicy, + "": CustomPythonOrgPolicy, + }, + } + ) + mw = build_from_crawler(RefererMiddleware, crawler) + + async def input_result(): + yield Request("https://example.com/") + + # "no-referrer-when-downgrade": None, + response = Response( + "https://example.com/", + headers={"Referrer-Policy": "no-referrer-when-downgrade"}, + ) + with pytest.warns( + RuntimeWarning, + match=r"Could not load referrer policy 'no-referrer-when-downgrade'", + ): + output = [ + request + async for request in mw.process_spider_output_async( + response, input_result() + ) + ] + assert len(output) == 1 + assert b"Referer" not in output[0].headers + + # "custom-policy": CustomPythonOrgPolicy, + response = Response( + "https://example.com/", + headers={"Referrer-Policy": "custom-policy"}, + ) + output = [ + request + async for request in mw.process_spider_output_async(response, input_result()) + ] + assert len(output) == 1 + assert output[0].headers == {b"Referer": [b"https://python.org/"]} + + # "": CustomPythonOrgPolicy, + response = Response( + "https://example.com/", + headers={"Referrer-Policy": ""}, + ) + output = [ + request + async for request in mw.process_spider_output_async(response, input_result()) + ] + assert len(output) == 1 + assert output[0].headers == {b"Referer": [b"https://python.org/"]} From 6f03f3250b93ed9a36f36b7ef7a59dc89cf593f4 Mon Sep 17 00:00:00 2001 From: Adrian Chaves Date: Mon, 19 Jan 2026 16:46:53 +0100 Subject: [PATCH 026/248] Add versionadded --- docs/topics/spider-middleware.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/topics/spider-middleware.rst b/docs/topics/spider-middleware.rst index 64ae988b5..bb8030d38 100644 --- a/docs/topics/spider-middleware.rst +++ b/docs/topics/spider-middleware.rst @@ -408,6 +408,8 @@ String value Class name (as a string) REFERRER_POLICIES ^^^^^^^^^^^^^^^^^ +.. versionadded:: 2.14.2 + Default: ``{}`` A dictionary mapping policy names to import paths of From f7f18123eb37d5bca520032b34233b3b92ddb74b Mon Sep 17 00:00:00 2001 From: Adrian Chaves Date: Mon, 19 Jan 2026 19:15:20 +0100 Subject: [PATCH 027/248] Remove body on 307/308 redirects and switch to GET on 301 --- scrapy/downloadermiddlewares/redirect.py | 10 ++++- tests/test_downloadermiddleware_redirect.py | 45 ++++++++++++++++++--- 2 files changed, 48 insertions(+), 7 deletions(-) diff --git a/scrapy/downloadermiddlewares/redirect.py b/scrapy/downloadermiddlewares/redirect.py index 61f62ecfc..0ab2f58bc 100644 --- a/scrapy/downloadermiddlewares/redirect.py +++ b/scrapy/downloadermiddlewares/redirect.py @@ -43,7 +43,8 @@ def _build_redirect_request( redirect_request.headers.pop(b"Proxy-Authorization", None) has_cookie_header = "Cookie" in redirect_request.headers has_authorization_header = "Authorization" in redirect_request.headers - if has_cookie_header or has_authorization_header: + has_request_body = bool(getattr(redirect_request, "body", None)) + if has_cookie_header or has_authorization_header or has_request_body: default_ports = {"http": 80, "https": 443} parsed_source_request = urlparse_cached(source_request) @@ -76,6 +77,11 @@ def _build_redirect_request( ): del redirect_request.headers["Authorization"] + if has_request_body and source_host != redirect_host: + redirect_request = redirect_request.replace(body=b"") + redirect_request.headers.pop("Content-Type", None) + redirect_request.headers.pop("Content-Length", None) + return redirect_request @@ -174,7 +180,7 @@ class RedirectMiddleware(BaseRedirectMiddleware): if urlparse_cached(redirected).scheme not in {"http", "https"}: return response - if response.status in (301, 307, 308) or request.method == "HEAD": + if response.status in (307, 308) or request.method == "HEAD": return self._redirect(redirected, request, response.status) redirected = self._redirect_request_using_get(request, redirected_url) diff --git a/tests/test_downloadermiddleware_redirect.py b/tests/test_downloadermiddleware_redirect.py index 281bc898a..b8cebba84 100644 --- a/tests/test_downloadermiddleware_redirect.py +++ b/tests/test_downloadermiddleware_redirect.py @@ -1004,7 +1004,7 @@ class TestRedirectMiddleware(Base.Test): return Response(request.url, status=status, headers=headers) def test_redirect_3xx_permanent(self): - def _test(method, status=301): + def _test(method, status: int): url = f"http://www.example.com/{status}" url2 = "http://www.example.com/redirected" req = Request(url, method=method) @@ -1019,10 +1019,6 @@ class TestRedirectMiddleware(Base.Test): del rsp.headers["Location"] assert self.mw.process_response(req, rsp) is rsp - _test("GET") - _test("POST") - _test("HEAD") - _test("GET", status=307) _test("POST", status=307) _test("HEAD", status=307) @@ -1031,6 +1027,45 @@ class TestRedirectMiddleware(Base.Test): _test("POST", status=308) _test("HEAD", status=308) + @pytest.mark.parametrize("status", [301, 302, 303]) + def test_post_method_converted_on_301_302_303(self, status): + source_url = f"http://www.example.com/{status}" + target_url = "http://www.example.com/redirected2" + request = Request( + source_url, + method="POST", + body="test", + headers={"Content-Type": "text/plain", "Content-length": "4"}, + ) + response = Response(source_url, headers={"Location": target_url}, status=status) + redirect_request = self.mw.process_response(request, response) + assert isinstance(redirect_request, Request) + assert redirect_request.url == target_url + assert redirect_request.method == "GET" + assert "Content-Type" not in redirect_request.headers + assert "Content-Length" not in redirect_request.headers + assert not redirect_request.body + + @pytest.mark.parametrize("status", [307, 308]) + def test_cross_origin_strip_body(self, status): + source_url = "https://example.com" + target_url = "https://attacker.example" + body = b"secret" + request = Request( + source_url, + method="POST", + body=body, + headers={"Content-Type": "application/json", "Content-Length": str(len(body))}, + ) + response1 = Response(source_url, headers={"Location": target_url}, status=status) + redirect_request = self.mw.process_response(request, response1) + assert isinstance(redirect_request, Request) + assert redirect_request.url == target_url + assert redirect_request.method == "POST" + assert not getattr(redirect_request, "body", None) + assert b"Content-Type" not in redirect_request.headers + assert b"Content-Length" not in redirect_request.headers + def test_redirect_302_head(self): url = "http://www.example.com/302" url2 = "http://www.example.com/redirected2" From 99d5d58e20f4b7a4e14b0502fcdfe609dd9b10ae Mon Sep 17 00:00:00 2001 From: Adrian Chaves Date: Mon, 19 Jan 2026 19:19:02 +0100 Subject: [PATCH 028/248] Add a release notes entry --- docs/news.rst | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/news.rst b/docs/news.rst index bab2e5ff9..b844389e4 100644 --- a/docs/news.rst +++ b/docs/news.rst @@ -3,6 +3,19 @@ Release notes ============= +.. _release-2.14.2: + +Scrapy 2.14.2 (unreleased) +-------------------------- + +Security bug fixes +~~~~~~~~~~~~~~~~~~ + +- Cross-origin 307/308 redirects now remove the request body. See the + 7j88-353p-36rg_ security advisory for details. + + .. _7j88-353p-36rg: https://github.com/scrapy/scrapy/security/advisories/GHSA-7j88-353p-36rg + .. _release-2.14.1: Scrapy 2.14.1 (2026-01-12) From 2651d48f20622fb4b2d2144c645f39eea4c5d791 Mon Sep 17 00:00:00 2001 From: Adrian Chaves Date: Mon, 19 Jan 2026 19:25:00 +0100 Subject: [PATCH 029/248] Cover the 301 behavior change in the release notes --- docs/news.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/news.rst b/docs/news.rst index b844389e4..23d64aa37 100644 --- a/docs/news.rst +++ b/docs/news.rst @@ -16,6 +16,10 @@ Security bug fixes .. _7j88-353p-36rg: https://github.com/scrapy/scrapy/security/advisories/GHSA-7j88-353p-36rg +- 301 redirects now force the request method to GET, in line with modern + browsers. The original request method is not only maintained on 307 and 308 + redirects. + .. _release-2.14.1: Scrapy 2.14.1 (2026-01-12) From 1b9c8b55dafc58bc4d05ffa6451c1e0a7160f633 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Tue, 20 Jan 2026 12:13:44 +0100 Subject: [PATCH 030/248] =?UTF-8?q?not=20=E2=86=92=20now?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/news.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/news.rst b/docs/news.rst index 23d64aa37..d8efa9ba0 100644 --- a/docs/news.rst +++ b/docs/news.rst @@ -17,7 +17,7 @@ Security bug fixes .. _7j88-353p-36rg: https://github.com/scrapy/scrapy/security/advisories/GHSA-7j88-353p-36rg - 301 redirects now force the request method to GET, in line with modern - browsers. The original request method is not only maintained on 307 and 308 + browsers. The original request method is now only maintained on 307 and 308 redirects. .. _release-2.14.1: From 842d0becf0f36152a1090c62c0e5d9c950241975 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Tue, 20 Jan 2026 12:15:25 +0100 Subject: [PATCH 031/248] Rename test function --- tests/test_downloadermiddleware_redirect.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_downloadermiddleware_redirect.py b/tests/test_downloadermiddleware_redirect.py index b8cebba84..421b654a7 100644 --- a/tests/test_downloadermiddleware_redirect.py +++ b/tests/test_downloadermiddleware_redirect.py @@ -1028,7 +1028,7 @@ class TestRedirectMiddleware(Base.Test): _test("HEAD", status=308) @pytest.mark.parametrize("status", [301, 302, 303]) - def test_post_method_converted_on_301_302_303(self, status): + def test_method_becomes_get(self, status): source_url = f"http://www.example.com/{status}" target_url = "http://www.example.com/redirected2" request = Request( From a39545195ea41f22d7bfdc3eab83ef564480e516 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Tue, 20 Jan 2026 12:20:37 +0100 Subject: [PATCH 032/248] =?UTF-8?q?allow=20to=20override=20=E2=86=92=20all?= =?UTF-8?q?ow=20overriding?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/topics/spider-middleware.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/topics/spider-middleware.rst b/docs/topics/spider-middleware.rst index bb8030d38..8b000697a 100644 --- a/docs/topics/spider-middleware.rst +++ b/docs/topics/spider-middleware.rst @@ -416,7 +416,7 @@ A dictionary mapping policy names to import paths of :class:`scrapy.spidermiddlewares.referer.ReferrerPolicy` subclasses, or ``None`` to disable support for a given policy name. -This allows to override the policies triggered by the ``Referrer-Policy`` +This allows overriding the policies triggered by the ``Referrer-Policy`` response header. Use ``""`` to override the policy for responses with `no referrer policy From 04db6a542407666de586d277acb1a651c389354e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Tue, 20 Jan 2026 12:35:00 +0100 Subject: [PATCH 033/248] Add a docstring to _load_policy_class() --- scrapy/spidermiddlewares/referer.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/scrapy/spidermiddlewares/referer.py b/scrapy/spidermiddlewares/referer.py index fe9b76500..d49c1814b 100644 --- a/scrapy/spidermiddlewares/referer.py +++ b/scrapy/spidermiddlewares/referer.py @@ -365,6 +365,29 @@ class RefererMiddleware(BaseSpiderMiddleware): *, allow_import_path: bool = False, ) -> type[ReferrerPolicy] | None: + """Load the :class:`ReferrerPolicy` class to use for *policy*. + + *policy* may be any of the following: + + - A standard policy name, e.g. ``"no-referrer"``, + ``"origin-when-cross-origin"``, etc. + + - The special ``"scrapy-default"`` policy. + + - The import path of a :class:`ReferrerPolicy` subclass, e.g. + ``"scrapy.spidermiddlewares.referer.NoReferrerPolicy"`` or + ``"myproject.policies.CustomReferrerPolicy"``. + + If *warning_only* is ``False`` (default) and *policy* cannot be turned + into a :class:`ReferrerPolicy` subclass, a :exc:`RuntimeError` is + raised. If *warning_only* is ``True``, a warning is logged and ``None`` + is returned instead. + + If *allow_import_path* is ``False`` (default), import paths are not + allowed, resulting in :exc:`RuntimeError` or ``None``. If ``True``, + they are allowed. Use ``True`` only if you trust the source of the + *policy* value. + """ if allow_import_path: try: return cast("type[ReferrerPolicy]", load_object(policy)) From 9bae1ee21f51813f0fcc5869284acc4fb22ff649 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Tue, 20 Jan 2026 15:58:09 +0400 Subject: [PATCH 034/248] Lazy creation of Downloader._slot_gc_loop. (#7210) --- scrapy/core/downloader/__init__.py | 20 +++++++++++++++----- tests/test_downloaderslotssettings.py | 4 ++-- tests/test_engine.py | 1 - 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/scrapy/core/downloader/__init__.py b/scrapy/core/downloader/__init__.py index 85fa8f3c2..c862ecdf9 100644 --- a/scrapy/core/downloader/__init__.py +++ b/scrapy/core/downloader/__init__.py @@ -108,6 +108,7 @@ def _get_concurrency_delay( class Downloader: DOWNLOAD_SLOT = "download_slot" + _SLOT_GC_INTERVAL: float = 60.0 # seconds def __init__(self, crawler: Crawler): self.crawler: Crawler = crawler @@ -125,10 +126,7 @@ class Downloader: self.middleware: DownloaderMiddlewareManager = ( DownloaderMiddlewareManager.from_crawler(crawler) ) - self._slot_gc_loop: AsyncioLoopingCall | LoopingCall = create_looping_call( - self._slot_gc - ) - self._slot_gc_loop.start(60) + self._slot_gc_loop: AsyncioLoopingCall | LoopingCall | None = None self.per_slot_settings: dict[str, dict[str, Any]] = self.settings.getdict( "DOWNLOAD_SLOTS" ) @@ -172,6 +170,7 @@ class Downloader: randomize_delay = slot_settings.get("randomize_delay", self.randomize_delay) new_slot = Slot(conc, delay, randomize_delay) self.slots[key] = new_slot + self._start_slot_gc() return key, self.slots[key] @@ -273,7 +272,7 @@ class Downloader: queue_dfd.callback(response) # awaited in _enqueue_request() def close(self) -> None: - self._slot_gc_loop.stop() + self._stop_slot_gc() for slot in self.slots.values(): slot.close() @@ -282,3 +281,14 @@ class Downloader: for key, slot in list(self.slots.items()): if not slot.active and slot.lastseen + slot.delay < mintime: self.slots.pop(key).close() + + def _start_slot_gc(self) -> None: + if self._slot_gc_loop: + return + self._slot_gc_loop = create_looping_call(self._slot_gc) + self._slot_gc_loop.start(self._SLOT_GC_INTERVAL, now=False) + + def _stop_slot_gc(self) -> None: + if self._slot_gc_loop: + self._slot_gc_loop.stop() + self._slot_gc_loop = None diff --git a/tests/test_downloaderslotssettings.py b/tests/test_downloaderslotssettings.py index 22127a4da..dffe95406 100644 --- a/tests/test_downloaderslotssettings.py +++ b/tests/test_downloaderslotssettings.py @@ -99,9 +99,9 @@ def test_params(): crawler = get_crawler(DefaultSpider, settings_dict=settings) crawler.spider = crawler._create_spider() downloader = Downloader(crawler) - downloader._slot_gc_loop.stop() # Prevent an unclean reactor. request = Request("https://example.com") _, actual = downloader._get_slot(request) + downloader.close() expected = Slot(**params) for param in params: assert getattr(expected, param) == getattr(actual, param), ( @@ -113,7 +113,6 @@ def test_get_slot_deprecated_spider_arg(): crawler = get_crawler(DefaultSpider) crawler.spider = crawler._create_spider() downloader = Downloader(crawler) - downloader._slot_gc_loop.stop() # Prevent an unclean reactor. request = Request("https://example.com") with pytest.warns( @@ -122,6 +121,7 @@ def test_get_slot_deprecated_spider_arg(): ): key1, slot1 = downloader._get_slot(request, spider=crawler.spider) key2, slot2 = downloader._get_slot(request) + downloader.close() assert key1 == key2 assert slot1 == slot2 diff --git a/tests/test_engine.py b/tests/test_engine.py index dc98364fc..d3485e344 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -614,7 +614,6 @@ def test_request_scheduled_signal(caplog): crawler = get_crawler(MySpider) engine = ExecutionEngine(crawler, lambda _: None) - engine.downloader._slot_gc_loop.stop() scheduler = TestScheduler() async def start(): From ba3d7bc7a8329d26862fcae248ececa386c1548a Mon Sep 17 00:00:00 2001 From: Adrian Chaves Date: Wed, 21 Jan 2026 18:44:52 +0100 Subject: [PATCH 035/248] Remove the non-standard 307/308 handling, and align other aspects with the standard --- docs/news.rst | 54 ++- scrapy/downloadermiddlewares/redirect.py | 198 +++++---- scrapy/spidermiddlewares/referer.py | 75 ++-- tests/test_downloadermiddleware_redirect.py | 312 +++++++++++++- tests/test_spidermiddleware_referer.py | 429 +++----------------- 5 files changed, 553 insertions(+), 515 deletions(-) diff --git a/docs/news.rst b/docs/news.rst index d8efa9ba0..db6749ae7 100644 --- a/docs/news.rst +++ b/docs/news.rst @@ -8,17 +8,55 @@ Release notes Scrapy 2.14.2 (unreleased) -------------------------- -Security bug fixes -~~~~~~~~~~~~~~~~~~ +Deprecations +~~~~~~~~~~~~ -- Cross-origin 307/308 redirects now remove the request body. See the - 7j88-353p-36rg_ security advisory for details. +- Using a response URL string as the first parameter on calls to + :meth:`scrapy.spidermiddlewares.referer.RefererMiddleware.policy` is + deprecated. Pass a :class:`~scrapy.http.Response` instead. - .. _7j88-353p-36rg: https://github.com/scrapy/scrapy/security/advisories/GHSA-7j88-353p-36rg + The parameter has also been renamed to ``response`` to reflect this change. + The old parameter name (``resp_or_url``) is deprecated. -- 301 redirects now force the request method to GET, in line with modern - browsers. The original request method is now only maintained on 307 and 308 - redirects. +Bug fixes +~~~~~~~~~ + +- Aligned redirect method conversions to ``GET`` with the `standard + `__: + + - 301 redirects of ``POST`` requests turn into ``GET`` requests. + + - Only ``POST`` 302 redirects turn into ``GET`` requests, other methods + are preserved. + + - ``HEAD`` 303 redirects do not turn into ``GET`` requests. + + - ``GET`` 303 redirects do not get their body or standard ``Content-*`` + headers removed. + + .. note:: Turning into a ``GET`` request implies not only a method change, + but also omitting the body and ``Content-*`` headers in the redirect + request. + +- Redirects where the original request body is dropped now also get their + ``Content-Encoding``, ``Content-Language`` and ``Content-Location`` headers + removed, in addition to ``Content-Type`` and ``Content-Length`` that were + already being removed. + +- Redirects now maintain the source URL fragment if the redirect URL doesn't + have one. This may be useful when using browser-based download handlers, + like those of `scrapy-playwright`_ or `scrapy-zyte-api`_, and letting + Scrapy handle redirects. + + .. _scrapy-playwright: https://github.com/scrapy-plugins/scrapy-playwright + .. _scrapy-zyte-api: https://scrapy-zyte-api.readthedocs.io/en/latest/ + +- The ``Referer`` header is now removed on redirect if + :class:`~scrapy.spidermiddlewares.referer.RefererMiddleware` is disabled. + +- The handling of the ``Referer`` header on redirects now accounts for the + ``Referer-Policy`` header of the response that triggers the redirect + request. .. _release-2.14.1: diff --git a/scrapy/downloadermiddlewares/redirect.py b/scrapy/downloadermiddlewares/redirect.py index 0ab2f58bc..9520a5cbb 100644 --- a/scrapy/downloadermiddlewares/redirect.py +++ b/scrapy/downloadermiddlewares/redirect.py @@ -2,14 +2,17 @@ from __future__ import annotations import logging from typing import TYPE_CHECKING, Any, cast -from urllib.parse import urljoin +from urllib.parse import urljoin, urlparse from w3lib.url import safe_url_string +from scrapy import signals from scrapy.exceptions import IgnoreRequest, NotConfigured from scrapy.http import HtmlResponse, Response +from scrapy.spidermiddlewares.referer import RefererMiddleware from scrapy.utils.decorators import _warn_spider_arg from scrapy.utils.httpobj import urlparse_cached +from scrapy.utils.python import global_object_name from scrapy.utils.response import get_meta_refresh if TYPE_CHECKING: @@ -24,67 +27,6 @@ if TYPE_CHECKING: logger = logging.getLogger(__name__) -def _build_redirect_request( - source_request: Request, *, url: str, **kwargs: Any -) -> Request: - redirect_request = source_request.replace( - url=url, - **kwargs, - cls=None, - cookies=None, - ) - if "_scheme_proxy" in redirect_request.meta: - source_request_scheme = urlparse_cached(source_request).scheme - redirect_request_scheme = urlparse_cached(redirect_request).scheme - if source_request_scheme != redirect_request_scheme: - redirect_request.meta.pop("_scheme_proxy") - redirect_request.meta.pop("proxy", None) - redirect_request.meta.pop("_auth_proxy", None) - redirect_request.headers.pop(b"Proxy-Authorization", None) - has_cookie_header = "Cookie" in redirect_request.headers - has_authorization_header = "Authorization" in redirect_request.headers - has_request_body = bool(getattr(redirect_request, "body", None)) - if has_cookie_header or has_authorization_header or has_request_body: - default_ports = {"http": 80, "https": 443} - - parsed_source_request = urlparse_cached(source_request) - source_scheme, source_host, source_port = ( - parsed_source_request.scheme, - parsed_source_request.hostname, - parsed_source_request.port - or default_ports.get(parsed_source_request.scheme), - ) - - parsed_redirect_request = urlparse_cached(redirect_request) - redirect_scheme, redirect_host, redirect_port = ( - parsed_redirect_request.scheme, - parsed_redirect_request.hostname, - parsed_redirect_request.port - or default_ports.get(parsed_redirect_request.scheme), - ) - - if has_cookie_header and ( - redirect_scheme not in {source_scheme, "https"} - or source_host != redirect_host - ): - del redirect_request.headers["Cookie"] - - # https://fetch.spec.whatwg.org/#ref-for-cors-non-wildcard-request-header-name - if has_authorization_header and ( - source_scheme != redirect_scheme - or source_host != redirect_host - or source_port != redirect_port - ): - del redirect_request.headers["Authorization"] - - if has_request_body and source_host != redirect_host: - redirect_request = redirect_request.replace(body=b"") - redirect_request.headers.pop("Content-Type", None) - redirect_request.headers.pop("Content-Length", None) - - return redirect_request - - class BaseRedirectMiddleware: crawler: Crawler enabled_setting: str = "REDIRECT_ENABLED" @@ -95,13 +37,59 @@ class BaseRedirectMiddleware: self.max_redirect_times: int = settings.getint("REDIRECT_MAX_TIMES") self.priority_adjust: int = settings.getint("REDIRECT_PRIORITY_ADJUST") + self._referer_spider_middleware: RefererMiddleware | None = None @classmethod def from_crawler(cls, crawler: Crawler) -> Self: o = cls(crawler.settings) o.crawler = crawler + crawler.signals.connect(o._engine_started, signal=signals.engine_started) return o + def handle_referer(self, request: Request, response: Response) -> None: + """Remove, modify or keep the Referer header of *request* based on the + *response* that triggered *request*. + + By default, this method finds a run-time instance of + scrapy.spidermiddlewares.referer.RefererMiddleware (or of a subclass) + and uses it to set the right Referer header. + + Override this method if you use a different Scrapy component to handle + Referer headers, of if you want to use a custom logic to set the + Referer header on redirects. + """ + request.headers.pop("Referer", None) + if not self._referer_spider_middleware: + return + self._referer_spider_middleware.get_processed_request( + request, response + ) + + def _engine_started(self) -> None: + self._referer_spider_middleware = self.crawler.get_spider_middleware(RefererMiddleware) + if self._referer_spider_middleware: + return + redirect_cls = global_object_name(self.__class__) + referer_cls = global_object_name(RefererMiddleware) + if self.__class__ in (RedirectMiddleware, MetaRefreshMiddleware): + replacement = ( + f"replace {redirect_cls} with a subclass that overrides the " + f"handle_referer() method" + ) + else: + replacement = ( + f"or edit {redirect_cls} (if defined in your code base) to " + f"override the handle_referer() method, or replace " + f"{redirect_cls} with a subclass that overrides the " + f"handle_referer() method." + ) + logger.warning( + f"{redirect_cls} found no {referer_cls} instance to handle " + f"Referer header handling, so the Referer header will be removed " + f"on redirects. To set a Referer header on redirects, enable " + f"{referer_cls} (or a subclass), or {replacement}.", + ) + def _redirect(self, redirected: Request, request: Request, reason: Any) -> Request: ttl = request.meta.setdefault("redirect_ttl", self.max_redirect_times) redirects = request.meta.get("redirect_times", 0) + 1 @@ -132,17 +120,79 @@ class BaseRedirectMiddleware: ) raise IgnoreRequest("max redirections reached") - def _redirect_request_using_get( - self, request: Request, redirect_url: str + + def _build_redirect_request( + self, source_request: Request, response: Response, *, url: str, **kwargs: Any ) -> Request: - redirect_request = _build_redirect_request( + redirect_request = source_request.replace( + url=url, + **kwargs, + cls=None, + cookies=None, + ) + if "_scheme_proxy" in redirect_request.meta: + source_request_scheme = urlparse_cached(source_request).scheme + redirect_request_scheme = urlparse_cached(redirect_request).scheme + if source_request_scheme != redirect_request_scheme: + redirect_request.meta.pop("_scheme_proxy") + redirect_request.meta.pop("proxy", None) + redirect_request.meta.pop("_auth_proxy", None) + redirect_request.headers.pop(b"Proxy-Authorization", None) + + has_cookie_header = "Cookie" in redirect_request.headers + has_authorization_header = "Authorization" in redirect_request.headers + if has_cookie_header or has_authorization_header: + default_ports = {"http": 80, "https": 443} + + parsed_source_request = urlparse_cached(source_request) + source_scheme, source_host, source_port = ( + parsed_source_request.scheme, + parsed_source_request.hostname, + parsed_source_request.port + or default_ports.get(parsed_source_request.scheme), + ) + + parsed_redirect_request = urlparse_cached(redirect_request) + redirect_scheme, redirect_host, redirect_port = ( + parsed_redirect_request.scheme, + parsed_redirect_request.hostname, + parsed_redirect_request.port + or default_ports.get(parsed_redirect_request.scheme), + ) + + if has_cookie_header and ( + redirect_scheme not in {source_scheme, "https"} + or source_host != redirect_host + ): + del redirect_request.headers["Cookie"] + + # https://fetch.spec.whatwg.org/#ref-for-cors-non-wildcard-request-header-name + if has_authorization_header and ( + source_scheme != redirect_scheme + or source_host != redirect_host + or source_port != redirect_port + ): + del redirect_request.headers["Authorization"] + + self.handle_referer(redirect_request, response) + + return redirect_request + + def _redirect_request_using_get( + self, request: Request, response: Response, redirect_url: str + ) -> Request: + redirect_request = self._build_redirect_request( request, + response, url=redirect_url, method="GET", body="", ) redirect_request.headers.pop("Content-Type", None) redirect_request.headers.pop("Content-Length", None) + redirect_request.headers.pop("Content-Encoding", None) + redirect_request.headers.pop("Content-Language", None) + redirect_request.headers.pop("Content-Location", None) return redirect_request @@ -176,14 +226,22 @@ class RedirectMiddleware(BaseRedirectMiddleware): location = request_scheme + "://" + location.lstrip("/") redirected_url = urljoin(request.url, location) - redirected = _build_redirect_request(request, url=redirected_url) + + if not urlparse(redirected_url).fragment: + fragment = urlparse_cached(request).fragment + if fragment: + redirected_url = urljoin(redirected_url, f"#{fragment}") + + redirected = self._build_redirect_request(request, response, url=redirected_url) if urlparse_cached(redirected).scheme not in {"http", "https"}: return response - if response.status in (307, 308) or request.method == "HEAD": - return self._redirect(redirected, request, response.status) + if ( + (response.status in (301, 302) and request.method == "POST") + or (response.status == 303 and request.method not in ("GET", "HEAD")) + ): + redirected = self._redirect_request_using_get(request, response, redirected_url) - redirected = self._redirect_request_using_get(request, redirected_url) return self._redirect(redirected, request, response.status) @@ -210,7 +268,7 @@ class MetaRefreshMiddleware(BaseRedirectMiddleware): interval, url = get_meta_refresh(response, ignore_tags=self._ignore_tags) if not url: return response - redirected = self._redirect_request_using_get(request, url) + redirected = self._redirect_request_using_get(request, response, url) if urlparse_cached(redirected).scheme not in {"http", "https"}: return response if cast("float", interval) < self._maxdelay: diff --git a/scrapy/spidermiddlewares/referer.py b/scrapy/spidermiddlewares/referer.py index e4d0f4014..5825f6467 100644 --- a/scrapy/spidermiddlewares/referer.py +++ b/scrapy/spidermiddlewares/referer.py @@ -9,6 +9,7 @@ import warnings from abc import ABC, abstractmethod from typing import TYPE_CHECKING, cast from urllib.parse import urlparse +from warnings import warn from w3lib.url import safe_url_string @@ -342,29 +343,47 @@ class RefererMiddleware(BaseSpiderMiddleware): def from_crawler(cls, crawler: Crawler) -> Self: if not crawler.settings.getbool("REFERER_ENABLED"): raise NotConfigured - mw = cls(crawler.settings) + return cls(crawler.settings) - # Note: this hook is a bit of a hack to intercept redirections - crawler.signals.connect(mw.request_scheduled, signal=signals.request_scheduled) - - return mw - - def policy(self, resp_or_url: Response | str, request: Request) -> ReferrerPolicy: - """ - Determine Referrer-Policy to use from a parent Response (or URL), - and a Request to be sent. + def policy( + self, + response: Response | str | None = None, + request: Request | None = None, + **kwargs, + ) -> ReferrerPolicy: + """Return the referrer policy to use for *request* based on *request* + meta, *response* and settings. - if a valid policy is set in Request meta, it is used. - - if the policy is set in meta but is wrong (e.g. a typo error), - the policy from settings is used - - if the policy is not set in Request meta, - but there is a Referrer-policy header in the parent response, - it is used if valid + - if the policy is set in meta but is wrong (e.g. a typo error), the + policy from settings is used + - if the policy is not set in Request meta, but there is a + Referrer-Policy header in the parent response, it is used if valid - otherwise, the policy from settings is used. """ + if "resp_or_url" in kwargs: + if response is not None: + raise TypeError("Cannot pass both 'response' and 'resp_or_url'") + response = kwargs.pop("resp_or_url") + warn( + "Passing 'resp_or_url' is deprecated, use 'response' instead.", + DeprecationWarning, + stacklevel=2, + ) + if response is None: + raise TypeError("Missing required argument: 'response'") + if request is None: + raise TypeError("Missing required argument: 'request'") + if isinstance(response, str): + warn( + "Passing a response URL to RefererMiddleware.policy() instead " + "of a Response object is deprecated.", + DeprecationWarning, + stacklevel=2, + ) policy_name = request.meta.get("referrer_policy") - if policy_name is None and isinstance(resp_or_url, Response): - policy_header = resp_or_url.headers.get("Referrer-Policy") + if policy_name is None and isinstance(response, Response): + policy_header = response.headers.get("Referrer-Policy") if policy_header is not None: policy_name = to_unicode(policy_header.decode("latin1")) if policy_name is None: @@ -383,25 +402,3 @@ class RefererMiddleware(BaseSpiderMiddleware): if referrer is not None: request.headers.setdefault("Referer", referrer) return request - - def request_scheduled(self, request: Request, spider: Spider) -> None: - # check redirected request to patch "Referer" header if necessary - redirected_urls = request.meta.get("redirect_urls", []) - if redirected_urls: - request_referrer = request.headers.get("Referer") - # we don't patch the referrer value if there is none - if request_referrer is not None: - # the request's referrer header value acts as a surrogate - # for the parent response URL - # - # Note: if the 3xx response contained a Referrer-Policy header, - # the information is not available using this hook - parent_url = safe_url_string(request_referrer) - policy_referrer = self.policy(parent_url, request).referrer( - parent_url, request.url - ) - if policy_referrer != request_referrer.decode("latin1"): - if policy_referrer is None: - request.headers.pop("Referer") - else: - request.headers["Referer"] = policy_referrer diff --git a/tests/test_downloadermiddleware_redirect.py b/tests/test_downloadermiddleware_redirect.py index 421b654a7..4197c7c86 100644 --- a/tests/test_downloadermiddleware_redirect.py +++ b/tests/test_downloadermiddleware_redirect.py @@ -1,4 +1,6 @@ +import logging from itertools import chain, product +from unittest.mock import MagicMock import pytest @@ -9,8 +11,14 @@ from scrapy.downloadermiddlewares.redirect import ( ) from scrapy.exceptions import IgnoreRequest from scrapy.http import HtmlResponse, Request, Response +from scrapy.spidermiddlewares.referer import ( + POLICY_NO_REFERRER, + POLICY_ORIGIN, + POLICY_UNSAFE_URL, + RefererMiddleware, +) from scrapy.spiders import Spider -from scrapy.utils.misc import set_environ +from scrapy.utils.misc import build_from_crawler, set_environ from scrapy.utils.spider import DefaultSpider from scrapy.utils.test import get_crawler @@ -18,8 +26,8 @@ from scrapy.utils.test import get_crawler class Base: class Test: def test_priority_adjust(self): - req = Request("http://a.com") - rsp = self.get_response(req, "http://a.com/redirected") + req = Request("http://a.example") + rsp = self.get_response(req, "http://a.example/redirected") req2 = self.mw.process_response(req, rsp) assert req2.priority > req.priority @@ -65,7 +73,7 @@ class Base: def test_max_redirect_times(self): self.mw.max_redirect_times = 1 - req = Request("http://scrapytest.org/302") + req = Request("http://a.example/302") rsp = self.get_response(req, "/redirected") req = self.mw.process_response(req, rsp) @@ -77,7 +85,7 @@ class Base: def test_ttl(self): self.mw.max_redirect_times = 100 - req = Request("http://scrapytest.org/302", meta={"redirect_ttl": 1}) + req = Request("http://a.example/302", meta={"redirect_ttl": 1}) rsp = self.get_response(req, "/a") req = self.mw.process_response(req, rsp) @@ -86,22 +94,22 @@ class Base: self.mw.process_response(req, rsp) def test_redirect_urls(self): - req1 = Request("http://scrapytest.org/first") + req1 = Request("http://a.example/first") rsp1 = self.get_response(req1, "/redirected") req2 = self.mw.process_response(req1, rsp1) rsp2 = self.get_response(req1, "/redirected2") req3 = self.mw.process_response(req2, rsp2) - assert req2.url == "http://scrapytest.org/redirected" - assert req2.meta["redirect_urls"] == ["http://scrapytest.org/first"] - assert req3.url == "http://scrapytest.org/redirected2" + assert req2.url == "http://a.example/redirected" + assert req2.meta["redirect_urls"] == ["http://a.example/first"] + assert req3.url == "http://a.example/redirected2" assert req3.meta["redirect_urls"] == [ - "http://scrapytest.org/first", - "http://scrapytest.org/redirected", + "http://a.example/first", + "http://a.example/redirected", ] def test_redirect_reasons(self): - req1 = Request("http://scrapytest.org/first") + req1 = Request("http://a.example/first") rsp1 = self.get_response(req1, "/redirected1") req2 = self.mw.process_response(req1, rsp1) rsp2 = self.get_response(req2, "/redirected2") @@ -1046,8 +1054,158 @@ class TestRedirectMiddleware(Base.Test): assert "Content-Length" not in redirect_request.headers assert not redirect_request.body + @pytest.mark.parametrize("status", [301, 302]) + @pytest.mark.parametrize("method", ["PUT", "DELETE"]) + def test_method_not_converted_on_301_302(self, status, method): + url = f"http://www.example.com/{status}" + url2 = "http://www.example.com/redirected" + body = b"test-body" + req = Request( + url, + method=method, + body=body, + headers={"Content-Type": "text/plain", "Content-Length": str(len(body))}, + ) + rsp = Response(url, headers={"Location": url2}, status=status) + + req2 = self.mw.process_response(req, rsp) + assert isinstance(req2, Request) + assert req2.url == url2 + assert req2.method == method + assert req2.body == body + assert req2.headers[b"Content-Type"] == b"text/plain" + assert req2.headers[b"Content-Length"] == str(len(body)).encode() + + @pytest.mark.parametrize("method", ["PUT", "DELETE"]) + def test_method_converted_on_303(self, method): + status = 303 + url = f"http://www.example.com/{status}" + url2 = "http://www.example.com/redirected" + req = Request(url, method=method) + rsp = Response(url, headers={"Location": url2}, status=status) + + req2 = self.mw.process_response(req, rsp) + assert isinstance(req2, Request) + assert req2.url == url2 + assert req2.method == "GET" + + def test_get_method_body_preserved_on_303(self): + status = 303 + url = f"http://www.example.com/{status}" + url2 = "http://www.example.com/redirected" + body = b"test-body" + req = Request( + url, + method="GET", + body=body, + headers={"Content-Type": "text/plain", "Content-Length": str(len(body))}, + ) + rsp = Response(url, headers={"Location": url2}, status=status) + + req2 = self.mw.process_response(req, rsp) + assert isinstance(req2, Request) + assert req2.url == url2 + assert req2.method == "GET" + assert req2.body == body + assert req2.headers[b"Content-Type"] == b"text/plain" + assert req2.headers[b"Content-Length"] == str(len(body)).encode() + + def test_redirect_strips_content_headers(self): + url = "http://www.example.com/303" + url2 = "http://www.example.com/redirected" + headers = { + "Content-Type": "application/json", + "Content-Length": "100", + "Content-Encoding": "gzip", + "Content-Language": "en", + "Content-Location": "http://www.example.com/original", + "X-Custom": "foo", + } + req = Request(url, method="POST", headers=headers, body=b"foo") + rsp = Response(url, headers={"Location": url2}, status=303) + + req2 = self.mw.process_response(req, rsp) + assert isinstance(req2, Request) + assert req2.url == url2 + assert req2.method == "GET" + assert req2.body == b"" + assert "Content-Type" not in req2.headers + assert "Content-Length" not in req2.headers + assert "Content-Encoding" not in req2.headers + assert "Content-Language" not in req2.headers + assert "Content-Location" not in req2.headers + assert req2.headers["X-Custom"] == b"foo" + + @pytest.mark.parametrize( + ( + "referer", + "source_url", + "target_url", + "policy_header", + "expected_referer", + ), + [ + ( + None, + "http://www.example.com/302", + "http://www.example.com/redirected", + None, + b"http://www.example.com/302", + ), + ( + "http://example.com/old", + "http://www.example.com/302", + "http://www.example.com/redirected", + None, + b"http://www.example.com/302", + ), + ( + "https://example.com/old", + "https://www.example.com/302", + "http://www.example.com/redirected", + None, + None, + ), + ( + "http://example.com/old", + "http://www.example.com/foo/bar", + "http://www.example.com/redirected", + "origin", + b"http://www.example.com/", + ), + ], + ) + def test_redirect_referer( + self, referer, source_url, target_url, policy_header, expected_referer + ): + headers = {"Referer": referer} if referer else {} + source_request = Request(source_url, headers=headers) + resp_headers = {"Location": target_url} + if policy_header: + resp_headers["Referrer-Policy"] = policy_header + response = Response(source_url, headers=resp_headers, status=302) + crawler = get_crawler() + referer_mw = build_from_crawler(RefererMiddleware, crawler) + redirect_mw = self.mwcls.from_crawler(crawler) + redirect_mw._referer_spider_middleware = referer_mw + redirect_request = redirect_mw.process_response(source_request, response) + if expected_referer: + assert redirect_request.headers.get("Referer") == expected_referer + else: + assert "Referer" not in redirect_request.headers + + def test_redirect_strips_referer_no_middleware(self): + source_url = "http://www.example.com/302" + redirect_url = "http://www.example.com/redirected" + source_request = Request(source_url, headers={"Referer": "http://example.com/old"}) + response = Response(source_url, headers={"Location": redirect_url}, status=302) + redirect_mw = self.mwcls.from_crawler(get_crawler()) + redirect_mw._referer_spider_middleware = None + redirect_request = redirect_mw.process_response(source_request, response) + assert "Referer" not in redirect_request.headers + @pytest.mark.parametrize("status", [307, 308]) - def test_cross_origin_strip_body(self, status): + def test_cross_origin_maintain_body(self, status): source_url = "https://example.com" target_url = "https://attacker.example" body = b"secret" @@ -1062,9 +1220,19 @@ class TestRedirectMiddleware(Base.Test): assert isinstance(redirect_request, Request) assert redirect_request.url == target_url assert redirect_request.method == "POST" - assert not getattr(redirect_request, "body", None) - assert b"Content-Type" not in redirect_request.headers - assert b"Content-Length" not in redirect_request.headers + assert redirect_request.body == body + assert redirect_request.headers[b"Content-Type"] == b"application/json" + assert redirect_request.headers[b"Content-Length"] == str(len(body)).encode() + + def test_redirect_keeps_fragment(self): + url = "http://www.example.com/301#frag" + url2 = "http://www.example.com/redirected" + req = Request(url) + rsp = Response(url, headers={"Location": url2}, status=302) + + req2 = self.mw.process_response(req, rsp) + assert isinstance(req2, Request) + assert req2.url == "http://www.example.com/redirected#frag" def test_redirect_302_head(self): url = "http://www.example.com/302" @@ -1311,4 +1479,114 @@ def test_meta_refresh_schemes(url, location, target): assert redirect == response else: assert isinstance(redirect, Request) - assert redirect.url == target + +@pytest.mark.parametrize( + ("policy", "source_url", "target_url", "expected_referrer"), + [ + # The policy header affects the outcome. + # (without it, the https → http switch would drop the referer) + ( + POLICY_UNSAFE_URL, + "https://a.example/1", + "http://a.example/2", + b"https://a.example/1", + ), + # The policy header can get the Referer header removed. + ( + POLICY_NO_REFERRER, + "http://a.example/1", + "http://a.example/2", + None, + ), + # The policy header can get the Referer header edited (path stripped). + ( + POLICY_ORIGIN, + "http://a.example/1", + "http://a.example/2", + b"http://a.example/", + ), + ], +) +def test_response_referrer_policy( + policy, source_url, target_url, expected_referrer +): + crawler = get_crawler() + referrer_mw = build_from_crawler(RefererMiddleware, crawler) + redirect_mw = build_from_crawler(RedirectMiddleware, crawler) + redirect_mw._referer_spider_middleware = referrer_mw + source_request = Request(source_url) + extra_headers = {} + if policy: + extra_headers["Referrer-Policy"] = policy + response_redirect = Response( + source_request.url, status=301, headers={"Location": target_url, **extra_headers} + ) + source_request = redirect_mw.process_response( + source_request, response_redirect + ) + assert isinstance(source_request, Request) + + assert source_request.headers.get("Referer") == expected_referrer + + +def test_no_warning_when_referer_middleware_present(caplog): + crawler = get_crawler() + crawler.get_spider_middleware = MagicMock(return_value=MagicMock()) + mw = build_from_crawler(RedirectMiddleware, crawler) + mw._engine_started() + assert not caplog.records + + +def test_warning_redirect_middleware(caplog): + crawler = get_crawler() + crawler.get_spider_middleware = MagicMock(return_value=None) + mw = build_from_crawler(RedirectMiddleware, crawler) + with caplog.at_level(logging.WARNING): + mw._engine_started() + assert ( + "scrapy.downloadermiddlewares.redirect.RedirectMiddleware found no " + "scrapy.spidermiddlewares.referer.RefererMiddleware" + ) in caplog.text + assert "enable scrapy.spidermiddlewares.referer.RefererMiddleware (or a subclass)" in caplog.text + assert ( + "replace scrapy.downloadermiddlewares.redirect.RedirectMiddleware " + "with a subclass that overrides the handle_referer() method" + ) in caplog.text + + +def test_warning_meta_refresh_middleware(caplog): + crawler = get_crawler() + crawler.get_spider_middleware = MagicMock(return_value=None) + mw = build_from_crawler(MetaRefreshMiddleware, crawler) + with caplog.at_level(logging.WARNING): + mw._engine_started() + assert ( + "scrapy.downloadermiddlewares.redirect.MetaRefreshMiddleware found no " + "scrapy.spidermiddlewares.referer.RefererMiddleware" + ) in caplog.text + assert "enable scrapy.spidermiddlewares.referer.RefererMiddleware (or a subclass)" in caplog.text + assert ( + "replace scrapy.downloadermiddlewares.redirect.MetaRefreshMiddleware " + "with a subclass that overrides the handle_referer() method" + ) in caplog.text + + +def test_warning_subclass(caplog): + class MyRedirectMiddleware(RedirectMiddleware): + pass + + crawler = get_crawler() + crawler.get_spider_middleware = MagicMock(return_value=None) + mw = build_from_crawler(MyRedirectMiddleware, crawler) + with caplog.at_level(logging.WARNING): + mw._engine_started() + assert ( + "test_warning_subclass..MyRedirectMiddleware found no " + "scrapy.spidermiddlewares.referer.RefererMiddleware" + ) in caplog.text + assert "enable scrapy.spidermiddlewares.referer.RefererMiddleware (or a subclass)" in caplog.text + assert "edit " in caplog.text + assert "test_warning_subclass..MyRedirectMiddleware" in caplog.text + assert ( + "(if defined in your code base) to override the handle_referer() method" + ) in caplog.text diff --git a/tests/test_spidermiddleware_referer.py b/tests/test_spidermiddleware_referer.py index 68fcad98c..b4090b78c 100644 --- a/tests/test_spidermiddleware_referer.py +++ b/tests/test_spidermiddleware_referer.py @@ -6,7 +6,6 @@ from urllib.parse import urlparse import pytest -from scrapy.downloadermiddlewares.redirect import RedirectMiddleware from scrapy.http import Request, Response from scrapy.settings import Settings from scrapy.spidermiddlewares.referer import ( @@ -31,6 +30,7 @@ from scrapy.spidermiddlewares.referer import ( StrictOriginWhenCrossOriginPolicy, UnsafeUrlPolicy, ) +from scrapy.utils.misc import build_from_crawler from scrapy.utils.spider import DefaultSpider from scrapy.utils.test import get_crawler @@ -970,391 +970,58 @@ class TestPolicyHeaderPrecedence004( resp_headers = {"Referrer-Policy": ""} -class TestReferrerOnRedirect(TestRefererMiddleware): - settings = {"REFERRER_POLICY": "scrapy.spidermiddlewares.referer.UnsafeUrlPolicy"} - scenarii: Sequence[ - tuple[str, str, tuple[tuple[int, str], ...], bytes | None, bytes | None] - ] = [ # type: ignore[assignment] - ( - "http://scrapytest.org/1", # parent - "http://scrapytest.org/2", # target - ( - # redirections: code, URL - (301, "http://scrapytest.org/3"), - (301, "http://scrapytest.org/4"), - ), - b"http://scrapytest.org/1", # expected initial referer - b"http://scrapytest.org/1", # expected referer for the redirection request - ), - ( - "https://scrapytest.org/1", - "https://scrapytest.org/2", - ( - # redirecting to non-secure URL - (301, "http://scrapytest.org/3"), - ), - b"https://scrapytest.org/1", - b"https://scrapytest.org/1", - ), - ( - "https://scrapytest.org/1", - "https://scrapytest.com/2", - ( - # redirecting to non-secure URL: different origin - (301, "http://scrapytest.com/3"), - ), - b"https://scrapytest.org/1", - b"https://scrapytest.org/1", - ), - ] +class TestPolicyMethodResponseParamRename: + def setup_method(self): + self.crawler = get_crawler() + self.mw = build_from_crawler(RefererMiddleware, self.crawler) + self.request = Request("http://www.example.com") + self.response = Response("http://www.example.com") - @pytest.fixture - def crawler(self) -> Crawler: - crawler = get_crawler(DefaultSpider, self.settings) - crawler.spider = crawler._create_spider() - return crawler + def test_pos_string(self): + with warnings.catch_warnings(record=True) as w: + self.mw.policy("http://old.com", self.request) + found = False + for warning in w: + if "Passing a response URL" in str(warning.message): + found = True + break + assert found - @pytest.fixture - def referrermw(self, crawler: Crawler) -> RefererMiddleware: - return RefererMiddleware.from_crawler(crawler) + def test_pos_response(self): + with warnings.catch_warnings(record=True) as w: + self.mw.policy(self.response, self.request) + for warning in w: + assert "resp_or_url" not in str(warning.message) - @pytest.fixture - def redirectmw(self, crawler: Crawler) -> RedirectMiddleware: - return RedirectMiddleware.from_crawler(crawler) + def test_key_resp_or_url(self): + with warnings.catch_warnings(record=True) as w: + self.mw.policy(resp_or_url=self.response, request=self.request) + found = False + for warning in w: + if "Passing 'resp_or_url' is deprecated, use 'response' instead" in str(warning.message): + found = True + break + assert found - def test( # type: ignore[override] - self, - crawler: Crawler, - referrermw: RefererMiddleware, - redirectmw: RedirectMiddleware, - ) -> None: - for ( - parent, - target, - redirections, - init_referrer, - final_referrer, - ) in self.scenarii: - response = self.get_response(parent) - request = self.get_request(target) + def test_key_response(self): + with warnings.catch_warnings(record=True) as w: + self.mw.policy(response=self.response, request=self.request) + for warning in w: + assert "resp_or_url" not in str(warning.message) - out = list(referrermw.process_spider_output(response, [request])) - assert out[0].headers.get("Referer") == init_referrer + def test_key_response_string(self): + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + self.mw.policy(response="http://old.com", request=self.request) + found = False + for warning in w: + if "Passing a response URL" in str(warning.message): + found = True + break + assert found - for status, url in redirections: - response = Response( - request.url, headers={"Location": url}, status=status - ) - request = cast( - "Request", redirectmw.process_response(request, response) - ) - assert crawler.spider - referrermw.request_scheduled(request, crawler.spider) - - assert isinstance(request, Request) - assert request.headers.get("Referer") == final_referrer + def test_both_resp_or_url_and_response(self): + with pytest.raises(TypeError, match="Cannot pass both 'response' and 'resp_or_url'"): + self.mw.policy(response=self.response, resp_or_url=self.response, request=self.request) -class TestReferrerOnRedirectNoReferrer(TestReferrerOnRedirect): - """ - No Referrer policy never sets the "Referer" header. - HTTP redirections should not change that. - """ - - settings = {"REFERRER_POLICY": "no-referrer"} - scenarii = [ - ( - "http://scrapytest.org/1", # parent - "http://scrapytest.org/2", # target - ( - # redirections: code, URL - (301, "http://scrapytest.org/3"), - (301, "http://scrapytest.org/4"), - ), - None, # expected initial "Referer" - None, # expected "Referer" for the redirection request - ), - ( - "https://scrapytest.org/1", - "https://scrapytest.org/2", - ((301, "http://scrapytest.org/3"),), - None, - None, - ), - ( - "https://scrapytest.org/1", - "https://example.com/2", # different origin - ((301, "http://scrapytest.com/3"),), - None, - None, - ), - ] - - -class TestReferrerOnRedirectSameOrigin(TestReferrerOnRedirect): - """ - Same Origin policy sends the full URL as "Referer" if the target origin - is the same as the parent response (same protocol, same domain, same port). - - HTTP redirections to a different domain or a lower secure level - should have the "Referer" removed. - """ - - settings = {"REFERRER_POLICY": "same-origin"} - scenarii = [ - ( - "http://scrapytest.org/101", # origin - "http://scrapytest.org/102", # target - ( - # redirections: code, URL - (301, "http://scrapytest.org/103"), - (301, "http://scrapytest.org/104"), - ), - b"http://scrapytest.org/101", # expected initial "Referer" - b"http://scrapytest.org/101", # expected referer for the redirection request - ), - ( - "https://scrapytest.org/201", - "https://scrapytest.org/202", - ( - # redirecting from secure to non-secure URL == different origin - (301, "http://scrapytest.org/203"), - ), - b"https://scrapytest.org/201", - None, - ), - ( - "https://scrapytest.org/301", - "https://scrapytest.org/302", - ( - # different domain == different origin - (301, "http://example.com/303"), - ), - b"https://scrapytest.org/301", - None, - ), - ] - - -class TestReferrerOnRedirectStrictOrigin(TestReferrerOnRedirect): - """ - Strict Origin policy will always send the "origin" as referrer - (think of it as the parent URL without the path part), - unless the security level is lower and no "Referer" is sent. - - Redirections from secure to non-secure URLs should have the - "Referrer" header removed if necessary. - """ - - settings = {"REFERRER_POLICY": POLICY_STRICT_ORIGIN} - scenarii = [ - ( - "http://scrapytest.org/101", - "http://scrapytest.org/102", - ( - (301, "http://scrapytest.org/103"), - (301, "http://scrapytest.org/104"), - ), - b"http://scrapytest.org/", # send origin - b"http://scrapytest.org/", # redirects to same origin: send origin - ), - ( - "https://scrapytest.org/201", - "https://scrapytest.org/202", - ( - # redirecting to non-secure URL: no referrer - (301, "http://scrapytest.org/203"), - ), - b"https://scrapytest.org/", - None, - ), - ( - "https://scrapytest.org/301", - "https://scrapytest.org/302", - ( - # redirecting to non-secure URL (different domain): no referrer - (301, "http://example.com/303"), - ), - b"https://scrapytest.org/", - None, - ), - ( - "http://scrapy.org/401", - "http://example.com/402", - ((301, "http://scrapytest.org/403"),), - b"http://scrapy.org/", - b"http://scrapy.org/", - ), - ( - "https://scrapy.org/501", - "https://example.com/502", - ( - # HTTPS all along, so origin referrer is kept as-is - (301, "https://google.com/503"), - (301, "https://facebook.com/504"), - ), - b"https://scrapy.org/", - b"https://scrapy.org/", - ), - ( - "https://scrapytest.org/601", - "http://scrapytest.org/602", # TLS to non-TLS: no referrer - ( - ( - 301, - "https://scrapytest.org/603", - ), # TLS URL again: (still) no referrer - ), - None, - None, - ), - ] - - -class TestReferrerOnRedirectOriginWhenCrossOrigin(TestReferrerOnRedirect): - """ - Origin When Cross-Origin policy sends the full URL as "Referer", - unless the target's origin is different (different domain, different protocol) - in which case only the origin is sent. - - Redirections to a different origin should strip the "Referer" - to the parent origin. - """ - - settings = {"REFERRER_POLICY": POLICY_ORIGIN_WHEN_CROSS_ORIGIN} - scenarii = [ - ( - "http://scrapytest.org/101", # origin - "http://scrapytest.org/102", # target + redirection - ( - # redirections: code, URL - (301, "http://scrapytest.org/103"), - (301, "http://scrapytest.org/104"), - ), - b"http://scrapytest.org/101", # expected initial referer - b"http://scrapytest.org/101", # expected referer for the redirection request - ), - ( - "https://scrapytest.org/201", - "https://scrapytest.org/202", - ( - # redirecting to non-secure URL: send origin - (301, "http://scrapytest.org/203"), - ), - b"https://scrapytest.org/201", - b"https://scrapytest.org/", - ), - ( - "https://scrapytest.org/301", - "https://scrapytest.org/302", - ( - # redirecting to non-secure URL (different domain): send origin - (301, "http://example.com/303"), - ), - b"https://scrapytest.org/301", - b"https://scrapytest.org/", - ), - ( - "http://scrapy.org/401", - "http://example.com/402", - ((301, "http://scrapytest.org/403"),), - b"http://scrapy.org/", - b"http://scrapy.org/", - ), - ( - "https://scrapy.org/501", - "https://example.com/502", - ( - # all different domains: send origin - (301, "https://google.com/503"), - (301, "https://facebook.com/504"), - ), - b"https://scrapy.org/", - b"https://scrapy.org/", - ), - ( - "https://scrapytest.org/301", - "http://scrapytest.org/302", # TLS to non-TLS: send origin - ((301, "https://scrapytest.org/303"),), # TLS URL again: send origin (also) - b"https://scrapytest.org/", - b"https://scrapytest.org/", - ), - ] - - -class TestReferrerOnRedirectStrictOriginWhenCrossOrigin(TestReferrerOnRedirect): - """ - Strict Origin When Cross-Origin policy sends the full URL as "Referer", - unless the target's origin is different (different domain, different protocol) - in which case only the origin is sent... - Unless there's also a downgrade in security and then the "Referer" header - is not sent. - - Redirections to a different origin should strip the "Referer" to the parent origin, - and from https:// to http:// will remove the "Referer" header. - """ - - settings = {"REFERRER_POLICY": POLICY_STRICT_ORIGIN_WHEN_CROSS_ORIGIN} - scenarii = [ - ( - "http://scrapytest.org/101", # origin - "http://scrapytest.org/102", # target + redirection - ( - # redirections: code, URL - (301, "http://scrapytest.org/103"), - (301, "http://scrapytest.org/104"), - ), - b"http://scrapytest.org/101", # expected initial referer - b"http://scrapytest.org/101", # expected referer for the redirection request - ), - ( - "https://scrapytest.org/201", - "https://scrapytest.org/202", - ( - # redirecting to non-secure URL: do not send the "Referer" header - (301, "http://scrapytest.org/203"), - ), - b"https://scrapytest.org/201", - None, - ), - ( - "https://scrapytest.org/301", - "https://scrapytest.org/302", - ( - # redirecting to non-secure URL (different domain): send origin - (301, "http://example.com/303"), - ), - b"https://scrapytest.org/301", - None, - ), - ( - "http://scrapy.org/401", - "http://example.com/402", - ((301, "http://scrapytest.org/403"),), - b"http://scrapy.org/", - b"http://scrapy.org/", - ), - ( - "https://scrapy.org/501", - "https://example.com/502", - ( - # all different domains: send origin - (301, "https://google.com/503"), - (301, "https://facebook.com/504"), - ), - b"https://scrapy.org/", - b"https://scrapy.org/", - ), - ( - "https://scrapytest.org/601", - "http://scrapytest.org/602", # TLS to non-TLS: do not send "Referer" - ( - ( - 301, - "https://scrapytest.org/603", - ), # TLS URL again: (still) send nothing - ), - None, - None, - ), - ] From 2347138ba484e00d1a44b802e2f31cda16a00614 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Wed, 21 Jan 2026 22:09:54 +0400 Subject: [PATCH 036/248] Unified download handler exceptions (#7208) * Add DownloadTimeoutError. * Don't expect ConnectionAborted in tests. * Add DownloadCancelledError. * Add ResponseDataLoss. * Add more download handler tests. * Add DownloadConnectionRefusedError. * Add UnsupportedURLScheme. * Add CannotResolveHostError. * Add DownloadFailedError. * Remove wrapped Twisted exceptions from lists. * Update a test expectation. * Extract wrap_twisted_exceptions(). * Wrap TxTimeoutError. * Rename ResponseDataLoss and UnsupportedURLScheme. --- docs/topics/downloader-middleware.rst | 11 ++- docs/topics/settings.rst | 15 ++-- scrapy/core/downloader/handlers/http11.py | 22 +++-- scrapy/core/downloader/handlers/http2.py | 12 +-- scrapy/core/downloader/webclient.py | 4 +- scrapy/core/http2/protocol.py | 10 ++- scrapy/core/http2/stream.py | 5 +- scrapy/downloadermiddlewares/httpcache.py | 29 +++---- scrapy/exceptions.py | 28 ++++++ scrapy/settings/default_settings.py | 11 ++- scrapy/utils/_download_handlers.py | 44 ++++++++++ .../caching_hostname_resolver_ipv6.py | 2 +- .../default_name_resolver.py | 2 +- .../caching_hostname_resolver_ipv6.py | 2 +- tests/CrawlerProcess/default_name_resolver.py | 2 +- tests/mockserver/http_resources.py | 9 +- tests/test_command_runspider.py | 2 +- tests/test_command_shell.py | 2 +- tests/test_crawler.py | 8 +- .../test_downloader_handler_twisted_http10.py | 3 + .../test_downloader_handler_twisted_http2.py | 37 +++++--- tests/test_downloader_handlers_http_base.py | 85 +++++++++++++++---- tests/test_downloadermiddleware_retry.py | 48 +++++------ tests/test_downloadermiddleware_robotstxt.py | 9 +- tests/test_http2_client_protocol.py | 13 +-- tests/test_webclient.py | 6 +- 26 files changed, 281 insertions(+), 140 deletions(-) create mode 100644 scrapy/utils/_download_handlers.py diff --git a/docs/topics/downloader-middleware.rst b/docs/topics/downloader-middleware.rst index 9be984f5d..1a4f91d93 100644 --- a/docs/topics/downloader-middleware.rst +++ b/docs/topics/downloader-middleware.rst @@ -1013,15 +1013,14 @@ RETRY_EXCEPTIONS Default:: [ - 'twisted.internet.defer.TimeoutError', - 'twisted.internet.error.TimeoutError', - 'twisted.internet.error.DNSLookupError', - 'twisted.internet.error.ConnectionRefusedError', + 'scrapy.exceptions.CannotResolveHostError', + 'scrapy.exceptions.DownloadConnectionRefusedError', + 'scrapy.exceptions.DownloadFailedError', + 'scrapy.exceptions.DownloadTimeoutError', + 'scrapy.exceptions.ResponseDataLossError', 'twisted.internet.error.ConnectionDone', 'twisted.internet.error.ConnectError', 'twisted.internet.error.ConnectionLost', - 'twisted.internet.error.TCPTimedOutError', - 'twisted.web.client.ResponseFailed', IOError, 'scrapy.core.downloader.handlers.http11.TunnelError', ] diff --git a/docs/topics/settings.rst b/docs/topics/settings.rst index b446f197f..5357fa72b 100644 --- a/docs/topics/settings.rst +++ b/docs/topics/settings.rst @@ -1047,12 +1047,12 @@ DOWNLOAD_FAIL_ON_DATALOSS Default: ``True`` -Whether or not to fail on broken responses, that is, declared -``Content-Length`` does not match content sent by the server or chunked -response was not properly finish. If ``True``, these responses raise a -``ResponseFailed([_DataLoss])`` error. If ``False``, these responses -are passed through and the flag ``dataloss`` is added to the response, i.e.: -``'dataloss' in response.flags`` is ``True``. +Whether or not to fail on broken responses, that is, when the declared +``Content-Length`` does not match content sent by the server or a chunked +response was not properly finished. If ``True``, these responses raise a +:exc:`~scrapy.exceptions.ResponseDataLossError` exception. If ``False``, these +responses are passed through and the flag ``dataloss`` is added to the +response, i.e.: ``'dataloss' in response.flags`` is ``True``. Optionally, this can be set per-request basis by using the :reqmeta:`download_fail_on_dataloss` Request.meta key to ``False``. @@ -1064,7 +1064,8 @@ Optionally, this can be set per-request basis by using the corruption. It is up to the user to decide if it makes sense to process broken responses considering they may contain partial or incomplete content. If :setting:`RETRY_ENABLED` is ``True`` and this setting is set to ``True``, - the ``ResponseFailed([_DataLoss])`` failure will be retried as usual. + the :exc:`~scrapy.exceptions.ResponseDataLossError` failure will be retried + as usual. .. note:: diff --git a/scrapy/core/downloader/handlers/http11.py b/scrapy/core/downloader/handlers/http11.py index cec73566a..312ecf229 100644 --- a/scrapy/core/downloader/handlers/http11.py +++ b/scrapy/core/downloader/handlers/http11.py @@ -12,9 +12,8 @@ from typing import TYPE_CHECKING, Any, TypedDict, TypeVar, cast from urllib.parse import urldefrag, urlparse from twisted.internet import ssl -from twisted.internet.defer import CancelledError, Deferred, succeed +from twisted.internet.defer import Deferred, succeed from twisted.internet.endpoints import TCP4ClientEndpoint -from twisted.internet.error import TimeoutError as TxTimeoutError from twisted.internet.protocol import Factory, Protocol, connectionDone from twisted.python.failure import Failure from twisted.web.client import ( @@ -33,9 +32,15 @@ from zope.interface import implementer from scrapy import Request, signals from scrapy.core.downloader.contextfactory import load_context_factory_from_settings from scrapy.core.downloader.handlers.base import BaseDownloadHandler -from scrapy.exceptions import StopDownload +from scrapy.exceptions import ( + DownloadCancelledError, + DownloadTimeoutError, + ResponseDataLossError, + StopDownload, +) from scrapy.http import Headers, Response from scrapy.responsetypes import responsetypes +from scrapy.utils._download_handlers import wrap_twisted_exceptions from scrapy.utils.defer import maybe_deferred_to_future from scrapy.utils.deprecate import warn_on_deprecated_spider_attribute from scrapy.utils.httpobj import urlparse_cached @@ -110,7 +115,8 @@ class HTTP11DownloadHandler(BaseDownloadHandler): fail_on_dataloss=self._fail_on_dataloss, crawler=self._crawler, ) - return await maybe_deferred_to_future(agent.download_request(request)) + with wrap_twisted_exceptions(): + return await maybe_deferred_to_future(agent.download_request(request)) async def close(self) -> None: from twisted.internet import reactor @@ -462,7 +468,7 @@ class ScrapyAgent: if self._txresponse: self._txresponse._transport.stopProducing() - raise TxTimeoutError(f"Getting {url} took longer than {timeout} seconds.") + raise DownloadTimeoutError(f"Getting {url} took longer than {timeout} seconds.") def _cb_latency(self, result: _T, request: Request, start_time: float) -> _T: request.meta["download_latency"] = time() - start_time @@ -534,7 +540,7 @@ class ScrapyAgent: logger.warning(warning_msg, warning_args) txresponse._transport.loseConnection() - raise CancelledError(warning_msg % warning_args) + raise DownloadCancelledError(warning_msg % warning_args) if warnsize and expected_size > warnsize: logger.warning( @@ -741,4 +747,8 @@ class _ResponseReader(Protocol): ) self._fail_on_dataloss_warned = True + exc = ResponseDataLossError() + exc.__cause__ = reason.value + reason = Failure(exc) + self._finished.errback(reason) diff --git a/scrapy/core/downloader/handlers/http2.py b/scrapy/core/downloader/handlers/http2.py index ff6e8ab98..75fa0772d 100644 --- a/scrapy/core/downloader/handlers/http2.py +++ b/scrapy/core/downloader/handlers/http2.py @@ -4,12 +4,13 @@ from time import time from typing import TYPE_CHECKING from urllib.parse import urldefrag -from twisted.internet.error import TimeoutError as TxTimeoutError from twisted.web.client import URI from scrapy.core.downloader.contextfactory import load_context_factory_from_settings from scrapy.core.downloader.handlers.base import BaseDownloadHandler from scrapy.core.http2.agent import H2Agent, H2ConnectionPool, ScrapyProxyH2Agent +from scrapy.exceptions import DownloadTimeoutError +from scrapy.utils._download_handlers import wrap_twisted_exceptions from scrapy.utils.defer import maybe_deferred_to_future from scrapy.utils.httpobj import urlparse_cached from scrapy.utils.python import to_bytes @@ -45,9 +46,10 @@ class H2DownloadHandler(BaseDownloadHandler): crawler=self._crawler, ) assert self._crawler.spider - return await maybe_deferred_to_future( - agent.download_request(request, self._crawler.spider) - ) + with wrap_twisted_exceptions(): + return await maybe_deferred_to_future( + agent.download_request(request, self._crawler.spider) + ) async def close(self) -> None: self._pool.close_connections() @@ -129,4 +131,4 @@ class ScrapyH2Agent: return response url = urldefrag(request.url)[0] - raise TxTimeoutError(f"Getting {url} took longer than {timeout} seconds.") + raise DownloadTimeoutError(f"Getting {url} took longer than {timeout} seconds.") diff --git a/scrapy/core/downloader/webclient.py b/scrapy/core/downloader/webclient.py index e5c2255af..52d287245 100644 --- a/scrapy/core/downloader/webclient.py +++ b/scrapy/core/downloader/webclient.py @@ -11,7 +11,7 @@ from twisted.internet import defer from twisted.internet.protocol import ClientFactory from twisted.web.http import HTTPClient -from scrapy.exceptions import ScrapyDeprecationWarning +from scrapy.exceptions import DownloadTimeoutError, ScrapyDeprecationWarning from scrapy.http import Headers, Response from scrapy.responsetypes import responsetypes from scrapy.utils.httpobj import urlparse_cached @@ -80,7 +80,7 @@ class ScrapyHTTPPageGetter(HTTPClient): self.transport.stopProducing() self.factory.noPage( - defer.TimeoutError( + DownloadTimeoutError( f"Getting {self.factory.url} took longer " f"than {self.factory.timeout} seconds." ) diff --git a/scrapy/core/http2/protocol.py b/scrapy/core/http2/protocol.py index ee9211efc..9f6216157 100644 --- a/scrapy/core/http2/protocol.py +++ b/scrapy/core/http2/protocol.py @@ -21,7 +21,6 @@ from h2.events import ( WindowUpdated, ) from h2.exceptions import FrameTooLargeError, H2Error -from twisted.internet.error import TimeoutError as TxTimeoutError from twisted.internet.interfaces import ( IAddress, IHandshakeListener, @@ -33,6 +32,7 @@ from twisted.protocols.policies import TimeoutMixin from zope.interface import implementer from scrapy.core.http2.stream import Stream, StreamCloseReason +from scrapy.exceptions import DownloadTimeoutError from scrapy.http import Request, Response from scrapy.utils.deprecate import warn_on_deprecated_spider_attribute @@ -313,7 +313,7 @@ class H2ClientProtocol(Protocol, TimeoutMixin): def timeoutConnection(self) -> None: """Called when the connection times out. - We lose the connection with TimeoutError""" + We lose the connection with DownloadTimeoutError""" # Check whether there are open streams. If there are, we're going to # want to use the error code PROTOCOL_ERROR. If there aren't, use @@ -330,7 +330,11 @@ class H2ClientProtocol(Protocol, TimeoutMixin): self._write_to_transport() self._lose_connection_with_error( - [TxTimeoutError(f"Connection was IDLE for more than {self.IDLE_TIMEOUT}s")] + [ + DownloadTimeoutError( + f"Connection was IDLE for more than {self.IDLE_TIMEOUT}s" + ) + ] ) def connectionLost(self, reason: Failure = connectionDone) -> None: diff --git a/scrapy/core/http2/stream.py b/scrapy/core/http2/stream.py index afca99dcf..291419d84 100644 --- a/scrapy/core/http2/stream.py +++ b/scrapy/core/http2/stream.py @@ -7,11 +7,12 @@ from typing import TYPE_CHECKING, Any from h2.errors import ErrorCodes from h2.exceptions import H2Error, ProtocolError, StreamClosedError -from twisted.internet.defer import CancelledError, Deferred +from twisted.internet.defer import Deferred from twisted.internet.error import ConnectionClosed from twisted.python.failure import Failure from twisted.web.client import ResponseFailed +from scrapy.exceptions import DownloadCancelledError from scrapy.http.headers import Headers from scrapy.responsetypes import responsetypes from scrapy.utils.httpobj import urlparse_cached @@ -422,7 +423,7 @@ class Stream: f"size ({expected_size}) larger than download max size ({self._download_maxsize})" ) logger.error(error_msg) - self._deferred_response.errback(CancelledError(error_msg)) + self._deferred_response.errback(DownloadCancelledError(error_msg)) elif reason is StreamCloseReason.ENDED: self._fire_response_deferred() diff --git a/scrapy/downloadermiddlewares/httpcache.py b/scrapy/downloadermiddlewares/httpcache.py index 453e8a6a3..a176dcd9f 100644 --- a/scrapy/downloadermiddlewares/httpcache.py +++ b/scrapy/downloadermiddlewares/httpcache.py @@ -3,20 +3,16 @@ from __future__ import annotations from email.utils import formatdate from typing import TYPE_CHECKING -from twisted.internet import defer -from twisted.internet.error import ( - ConnectError, - ConnectionDone, - ConnectionLost, - DNSLookupError, - TCPTimedOutError, -) -from twisted.internet.error import ConnectionRefusedError as TxConnectionRefusedError -from twisted.internet.error import TimeoutError as TxTimeoutError -from twisted.web.client import ResponseFailed +from twisted.internet.error import ConnectError, ConnectionDone, ConnectionLost from scrapy import signals -from scrapy.exceptions import IgnoreRequest, NotConfigured +from scrapy.exceptions import ( + DownloadConnectionRefusedError, + DownloadFailedError, + DownloadTimeoutError, + IgnoreRequest, + NotConfigured, +) from scrapy.utils.decorators import _warn_spider_arg from scrapy.utils.misc import load_object @@ -34,16 +30,13 @@ if TYPE_CHECKING: class HttpCacheMiddleware: DOWNLOAD_EXCEPTIONS = ( - defer.TimeoutError, - TxTimeoutError, - DNSLookupError, - TxConnectionRefusedError, ConnectionDone, ConnectError, ConnectionLost, - TCPTimedOutError, - ResponseFailed, OSError, + DownloadTimeoutError, + DownloadConnectionRefusedError, + DownloadFailedError, ) crawler: Crawler diff --git a/scrapy/exceptions.py b/scrapy/exceptions.py index f37f881a7..b25add8cf 100644 --- a/scrapy/exceptions.py +++ b/scrapy/exceptions.py @@ -54,6 +54,34 @@ class StopDownload(Exception): self.fail = fail +class DownloadConnectionRefusedError(Exception): + """Indicates that a connection was refused by the server.""" + + +class CannotResolveHostError(Exception): + """Indicates that the provided hostname cannot be resolved.""" + + +class DownloadTimeoutError(Exception): + """Indicates that a request download has timed out.""" + + +class DownloadCancelledError(Exception): + """Indicates that a request download was cancelled.""" + + +class DownloadFailedError(Exception): + """Indicates that a request download has failed.""" + + +class ResponseDataLossError(Exception): + """Indicates that Scrapy couldn't get a complete response.""" + + +class UnsupportedURLSchemeError(Exception): + """Indicates that the URL scheme is not supported.""" + + # Items diff --git a/scrapy/settings/default_settings.py b/scrapy/settings/default_settings.py index d741bf3ac..7ef5feb9f 100644 --- a/scrapy/settings/default_settings.py +++ b/scrapy/settings/default_settings.py @@ -453,15 +453,14 @@ REQUEST_FINGERPRINTER_CLASS = "scrapy.utils.request.RequestFingerprinter" RETRY_ENABLED = True RETRY_EXCEPTIONS = [ - "twisted.internet.defer.TimeoutError", - "twisted.internet.error.TimeoutError", - "twisted.internet.error.DNSLookupError", - "twisted.internet.error.ConnectionRefusedError", + "scrapy.exceptions.CannotResolveHostError", + "scrapy.exceptions.DownloadConnectionRefusedError", + "scrapy.exceptions.DownloadFailedError", + "scrapy.exceptions.DownloadTimeoutError", + "scrapy.exceptions.ResponseDataLossError", "twisted.internet.error.ConnectionDone", "twisted.internet.error.ConnectError", "twisted.internet.error.ConnectionLost", - "twisted.internet.error.TCPTimedOutError", - "twisted.web.client.ResponseFailed", # OSError is raised by the HttpCompression middleware when trying to # decompress an empty response OSError, diff --git a/scrapy/utils/_download_handlers.py b/scrapy/utils/_download_handlers.py new file mode 100644 index 000000000..4c7721483 --- /dev/null +++ b/scrapy/utils/_download_handlers.py @@ -0,0 +1,44 @@ +"""Utils for built-in HTTP download handlers.""" + +from __future__ import annotations + +from contextlib import contextmanager +from typing import TYPE_CHECKING + +from twisted.internet.defer import CancelledError +from twisted.internet.error import ConnectionRefusedError as TxConnectionRefusedError +from twisted.internet.error import DNSLookupError +from twisted.internet.error import TimeoutError as TxTimeoutError +from twisted.web.client import ResponseFailed +from twisted.web.error import SchemeNotSupported + +from scrapy.exceptions import ( + CannotResolveHostError, + DownloadCancelledError, + DownloadConnectionRefusedError, + DownloadFailedError, + DownloadTimeoutError, + UnsupportedURLSchemeError, +) + +if TYPE_CHECKING: + from collections.abc import Iterator + + +@contextmanager +def wrap_twisted_exceptions() -> Iterator[None]: + """Context manager that wraps Twisted exceptions into Scrapy exceptions.""" + try: + yield + except SchemeNotSupported as e: + raise UnsupportedURLSchemeError(str(e)) from e + except CancelledError as e: + raise DownloadCancelledError(str(e)) from e + except TxConnectionRefusedError as e: + raise DownloadConnectionRefusedError(str(e)) from e + except DNSLookupError as e: + raise CannotResolveHostError(str(e)) from e + except ResponseFailed as e: + raise DownloadFailedError(str(e)) from e + except TxTimeoutError as e: + raise DownloadTimeoutError(str(e)) from e diff --git a/tests/AsyncCrawlerProcess/caching_hostname_resolver_ipv6.py b/tests/AsyncCrawlerProcess/caching_hostname_resolver_ipv6.py index c43f0a9c2..07e2d3684 100644 --- a/tests/AsyncCrawlerProcess/caching_hostname_resolver_ipv6.py +++ b/tests/AsyncCrawlerProcess/caching_hostname_resolver_ipv6.py @@ -4,7 +4,7 @@ from scrapy.crawler import AsyncCrawlerProcess class CachingHostnameResolverSpider(scrapy.Spider): """ - Finishes without a twisted.internet.error.DNSLookupError exception + Finishes without a scrapy.exceptions.CannotResolveHostError exception """ name = "caching_hostname_resolver_spider" diff --git a/tests/AsyncCrawlerProcess/default_name_resolver.py b/tests/AsyncCrawlerProcess/default_name_resolver.py index af56ccd01..4c8897f8f 100644 --- a/tests/AsyncCrawlerProcess/default_name_resolver.py +++ b/tests/AsyncCrawlerProcess/default_name_resolver.py @@ -4,7 +4,7 @@ from scrapy.crawler import AsyncCrawlerProcess class IPv6Spider(scrapy.Spider): """ - Raises a twisted.internet.error.DNSLookupError: + Raises a scrapy.exceptions.CannotResolveHostError: the default name resolver does not handle IPv6 addresses. """ diff --git a/tests/CrawlerProcess/caching_hostname_resolver_ipv6.py b/tests/CrawlerProcess/caching_hostname_resolver_ipv6.py index d5d19e27e..5cca94bed 100644 --- a/tests/CrawlerProcess/caching_hostname_resolver_ipv6.py +++ b/tests/CrawlerProcess/caching_hostname_resolver_ipv6.py @@ -4,7 +4,7 @@ from scrapy.crawler import CrawlerProcess class CachingHostnameResolverSpider(scrapy.Spider): """ - Finishes without a twisted.internet.error.DNSLookupError exception + Finishes without a scrapy.exceptions.CannotResolveHostError exception """ name = "caching_hostname_resolver_spider" diff --git a/tests/CrawlerProcess/default_name_resolver.py b/tests/CrawlerProcess/default_name_resolver.py index cfc4b38b7..f4c129fdf 100644 --- a/tests/CrawlerProcess/default_name_resolver.py +++ b/tests/CrawlerProcess/default_name_resolver.py @@ -4,7 +4,7 @@ from scrapy.crawler import CrawlerProcess class IPv6Spider(scrapy.Spider): """ - Raises a twisted.internet.error.DNSLookupError: + Raises a scrapy.exceptions.CannotResolveHostError: the default name resolver does not handle IPv6 addresses. """ diff --git a/tests/mockserver/http_resources.py b/tests/mockserver/http_resources.py index d5687fc44..70a9b8ac6 100644 --- a/tests/mockserver/http_resources.py +++ b/tests/mockserver/http_resources.py @@ -193,10 +193,11 @@ class Drop(Partial): request.write(b"this connection will be dropped\n") tr = request.channel.transport try: - if abort and hasattr(tr, "abortConnection"): - tr.abortConnection() - else: - tr.loseConnection() + if tr: + if abort and hasattr(tr, "abortConnection"): + tr.abortConnection() + else: + tr.loseConnection() finally: request.finish() diff --git a/tests/test_command_runspider.py b/tests/test_command_runspider.py index 2fa4ce581..b5408ccfe 100644 --- a/tests/test_command_runspider.py +++ b/tests/test_command_runspider.py @@ -119,7 +119,7 @@ class MySpider(scrapy.Spider): log = self.get_log( tmp_path, dnscache_spider, args=("-s", "DNSCACHE_ENABLED=False") ) - assert "DNSLookupError" not in log + assert "CannotResolveHostError" not in log assert "INFO: Spider opened" in log @pytest.mark.parametrize("value", [False, True]) diff --git a/tests/test_command_shell.py b/tests/test_command_shell.py index d9fb96fb8..1417bc623 100644 --- a/tests/test_command_shell.py +++ b/tests/test_command_shell.py @@ -114,7 +114,7 @@ class TestShellCommand: url = "www.somedomainthatdoesntexi.st" ret, out, err = proc("shell", url, "-c", "item") assert ret == 1, out or err - assert "DNS lookup failed" in err + assert "CannotResolveHostError" in err def test_shell_fetch_async(self, mockserver: MockServer) -> None: url = mockserver.url("/html") diff --git a/tests/test_crawler.py b/tests/test_crawler.py index 5204c9a73..350c82a0b 100644 --- a/tests/test_crawler.py +++ b/tests/test_crawler.py @@ -844,18 +844,18 @@ class TestCrawlerProcessSubprocessBase(ScriptRunnerMixin): log = self.run_script("default_name_resolver.py") assert "Spider closed (finished)" in log assert ( - "'downloader/exception_type_count/twisted.internet.error.DNSLookupError': 1," + "'downloader/exception_type_count/scrapy.exceptions.CannotResolveHostError': 1," in log ) assert ( - "twisted.internet.error.DNSLookupError: DNS lookup failed: no results for hostname lookup: ::1." + "scrapy.exceptions.CannotResolveHostError: DNS lookup failed: no results for hostname lookup: ::1." in log ) def test_caching_hostname_resolver_ipv6(self): log = self.run_script("caching_hostname_resolver_ipv6.py") assert "Spider closed (finished)" in log - assert "twisted.internet.error.DNSLookupError" not in log + assert "scrapy.exceptions.CannotResolveHostError" not in log def test_caching_hostname_resolver_finite_execution( self, mockserver: MockServer @@ -864,7 +864,7 @@ class TestCrawlerProcessSubprocessBase(ScriptRunnerMixin): assert "Spider closed (finished)" in log assert "ERROR: Error downloading" not in log assert "TimeoutError" not in log - assert "twisted.internet.error.DNSLookupError" not in log + assert "scrapy.exceptions.CannotResolveHostError" not in log def test_twisted_reactor_asyncio(self): log = self.run_script("twisted_reactor_asyncio.py") diff --git a/tests/test_downloader_handler_twisted_http10.py b/tests/test_downloader_handler_twisted_http10.py index 4745869e4..939e2c8ad 100644 --- a/tests/test_downloader_handler_twisted_http10.py +++ b/tests/test_downloader_handler_twisted_http10.py @@ -26,6 +26,9 @@ class HTTP10DownloadHandlerMixin: class TestHttp10(HTTP10DownloadHandlerMixin, TestHttpBase): """HTTP 1.0 test case""" + def test_unsupported_scheme(self) -> None: # type: ignore[override] + pytest.skip("Check not implemented") + @deferred_f_from_coro_f async def test_protocol(self, mockserver: MockServer) -> None: request = Request( diff --git a/tests/test_downloader_handler_twisted_http2.py b/tests/test_downloader_handler_twisted_http2.py index f60d5e7b5..a9197645a 100644 --- a/tests/test_downloader_handler_twisted_http2.py +++ b/tests/test_downloader_handler_twisted_http2.py @@ -8,10 +8,10 @@ from unittest import mock import pytest from testfixtures import LogCapture -from twisted.internet import defer, error -from twisted.web.error import SchemeNotSupported +from twisted.internet import defer from twisted.web.http import H2_ENABLED +from scrapy.exceptions import DownloadCancelledError, UnsupportedURLSchemeError from scrapy.http import Request from scrapy.utils.defer import deferred_f_from_coro_f, maybe_deferred_to_future from tests.test_downloader_handlers_http_base import ( @@ -72,7 +72,7 @@ class TestHttps2(H2DownloadHandlerMixin, TestHttps11Base): logger.error.assert_called_once_with(mock.ANY) async with self.get_dh({"DOWNLOAD_MAXSIZE": 1_500}) as download_handler: - with pytest.raises((defer.CancelledError, error.ConnectionAborted)): + with pytest.raises(DownloadCancelledError): await download_handler.download_request(request) # As the error message is logged in the dataReceived callback, we @@ -83,13 +83,6 @@ class TestHttps2(H2DownloadHandlerMixin, TestHttps11Base): reactor.callLater(0.1, d.callback, logger) await maybe_deferred_to_future(d) - @deferred_f_from_coro_f - async def test_unsupported_scheme(self) -> None: - request = Request("ftp://unsupported.scheme") - async with self.get_dh() as download_handler: - with pytest.raises(SchemeNotSupported): - await download_handler.download_request(request) - def test_download_cause_data_loss(self) -> None: # type: ignore[override] pytest.skip(self.HTTP2_DATALOSS_SKIP_REASON) @@ -99,6 +92,28 @@ class TestHttps2(H2DownloadHandlerMixin, TestHttps11Base): def test_download_allow_data_loss_via_setting(self) -> None: # type: ignore[override] pytest.skip(self.HTTP2_DATALOSS_SKIP_REASON) + def test_download_conn_failed(self) -> None: # type: ignore[override] + # Unlike HTTP11DownloadHandler which raises it from download_request() + # (without any special handling), here ConnectionRefusedError (raised in + # twisted.internet.endpoints.startConnectionAttempts()) bubbles up as + # an unhandled exception in a Deferred and the handler waits until + # DOWNLOAD_TIMEOUT. + pytest.skip("The handler doesn't properly reraise ConnectionRefusedError") + + def test_download_conn_lost(self) -> None: # type: ignore[override] + pytest.skip(self.HTTP2_DATALOSS_SKIP_REASON) + + def test_download_conn_aborted(self) -> None: # type: ignore[override] + pytest.skip(self.HTTP2_DATALOSS_SKIP_REASON) + + def test_download_dns_error(self) -> None: # type: ignore[override] + # Unlike HTTP11DownloadHandler which raises it from download_request() + # (without any special handling), here DNSLookupError (raised in + # twisted.internet.endpoints.startConnectionAttempts()) bubbles up as + # an unhandled exception in a Deferred and the handler waits until + # DOWNLOAD_TIMEOUT. + pytest.skip("The handler doesn't properly reraise DNSLookupError") + @deferred_f_from_coro_f async def test_concurrent_requests_same_domain( self, mockserver: MockServer @@ -212,7 +227,7 @@ class TestHttps2Proxy(H2DownloadHandlerMixin, TestHttpProxyBase): async def test_download_with_proxy_without_http_scheme( self, proxy_mockserver: ProxyEchoMockServer ) -> None: - with pytest.raises(SchemeNotSupported): + with pytest.raises(UnsupportedURLSchemeError): await maybe_deferred_to_future( super().test_download_with_proxy_without_http_scheme(proxy_mockserver) ) diff --git a/tests/test_downloader_handlers_http_base.py b/tests/test_downloader_handlers_http_base.py index e325c0463..d33de8881 100644 --- a/tests/test_downloader_handlers_http_base.py +++ b/tests/test_downloader_handlers_http_base.py @@ -13,10 +13,17 @@ from unittest import mock import pytest from testfixtures import LogCapture -from twisted.internet import defer, error -from twisted.web._newclient import ResponseFailed -from twisted.web.http import _DataLoss +from twisted.internet import defer +from scrapy.exceptions import ( + CannotResolveHostError, + DownloadCancelledError, + DownloadConnectionRefusedError, + DownloadFailedError, + DownloadTimeoutError, + ResponseDataLossError, + UnsupportedURLSchemeError, +) from scrapy.http import Headers, HtmlResponse, Request, Response, TextResponse from scrapy.utils.asyncio import call_later from scrapy.utils.defer import ( @@ -59,6 +66,13 @@ class TestHttpBase(ABC): finally: await dh.close() + @deferred_f_from_coro_f + async def test_unsupported_scheme(self) -> None: + request = Request("ftp://unsupported.scheme") + async with self.get_dh() as download_handler: + with pytest.raises(UnsupportedURLSchemeError): + await download_handler.download_request(request) + @deferred_f_from_coro_f async def test_download(self, mockserver: MockServer) -> None: request = Request(mockserver.url("/text", is_secure=self.is_secure)) @@ -208,7 +222,7 @@ class TestHttpBase(ABC): request = Request(mockserver.url("/wait", is_secure=self.is_secure), meta=meta) async with self.get_dh() as download_handler: d = deferred_from_coro(download_handler.download_request(request)) - with pytest.raises((defer.TimeoutError, error.TimeoutError)): + with pytest.raises(DownloadTimeoutError): await maybe_deferred_to_future(d) @deferred_f_from_coro_f @@ -229,7 +243,7 @@ class TestHttpBase(ABC): ) async with self.get_dh() as download_handler: d = deferred_from_coro(download_handler.download_request(request)) - with pytest.raises((defer.TimeoutError, error.TimeoutError)): + with pytest.raises(DownloadTimeoutError): await maybe_deferred_to_future(d) @pytest.mark.parametrize("send_header", [True, False]) @@ -431,7 +445,7 @@ class TestHttp11Base(TestHttpBase): assert response.body == b"Works" async with self.get_dh({"DOWNLOAD_MAXSIZE": 4}) as download_handler: - with pytest.raises((defer.CancelledError, error.ConnectionAborted)): + with pytest.raises(DownloadCancelledError): await download_handler.download_request(request) @deferred_f_from_coro_f @@ -448,7 +462,7 @@ class TestHttp11Base(TestHttpBase): logger.warning.assert_called_once_with(mock.ANY, mock.ANY) async with self.get_dh({"DOWNLOAD_MAXSIZE": 1_500}) as download_handler: - with pytest.raises((defer.CancelledError, error.ConnectionAborted)): + with pytest.raises(DownloadCancelledError): await download_handler.download_request(request) # As the error message is logged in the dataReceived callback, we @@ -464,7 +478,7 @@ class TestHttp11Base(TestHttpBase): meta = {"download_maxsize": 2} request = Request(mockserver.url("/text", is_secure=self.is_secure), meta=meta) async with self.get_dh() as download_handler: - with pytest.raises((defer.CancelledError, error.ConnectionAborted)): + with pytest.raises(DownloadCancelledError): await download_handler.download_request(request) @deferred_f_from_coro_f @@ -473,7 +487,7 @@ class TestHttp11Base(TestHttpBase): ) -> None: request = Request(mockserver.url("/text", is_secure=self.is_secure)) async with self.get_dh({"DOWNLOAD_MAXSIZE": 2}) as download_handler: - with pytest.raises((defer.CancelledError, error.ConnectionAborted)): + with pytest.raises(DownloadCancelledError): await download_handler.download_request(request) @deferred_f_from_coro_f @@ -497,12 +511,10 @@ class TestHttp11Base(TestHttpBase): async def test_download_cause_data_loss( self, url: str, mockserver: MockServer ) -> None: - # TODO: this one checks for Twisted-specific exceptions request = Request(mockserver.url(f"/{url}", is_secure=self.is_secure)) async with self.get_dh() as download_handler: - with pytest.raises(ResponseFailed) as exc_info: + with pytest.raises(ResponseDataLossError): await download_handler.download_request(request) - assert any(r.check(_DataLoss) for r in exc_info.value.reasons) @pytest.mark.parametrize("url", ["broken", "broken-chunked"]) @deferred_f_from_coro_f @@ -529,6 +541,43 @@ class TestHttp11Base(TestHttpBase): response = await download_handler.download_request(request) assert response.flags == ["dataloss"] + @deferred_f_from_coro_f + async def test_download_conn_failed(self) -> None: + # copy of TestCrawl.test_retry_conn_failed() + scheme = "https" if self.is_secure else "http" + request = Request(f"{scheme}://localhost:65432/") + async with self.get_dh() as download_handler: + with pytest.raises(DownloadConnectionRefusedError): + await download_handler.download_request(request) + + @deferred_f_from_coro_f + async def test_download_conn_lost(self, mockserver: MockServer) -> None: + # copy of TestCrawl.test_retry_conn_lost() + request = Request(mockserver.url("/drop?abort=0", is_secure=self.is_secure)) + async with self.get_dh() as download_handler: + with pytest.raises(ResponseDataLossError): + await download_handler.download_request(request) + + @deferred_f_from_coro_f + async def test_download_conn_aborted(self, mockserver: MockServer) -> None: + # copy of TestCrawl.test_retry_conn_aborted() + request = Request(mockserver.url("/drop?abort=1", is_secure=self.is_secure)) + async with self.get_dh() as download_handler: + with pytest.raises(DownloadFailedError): + await download_handler.download_request(request) + + @pytest.mark.skipif( + NON_EXISTING_RESOLVABLE, reason="Non-existing hosts are resolvable" + ) + @deferred_f_from_coro_f + async def test_download_dns_error(self) -> None: + # copy of TestCrawl.test_retry_dns_error() + scheme = "https" if self.is_secure else "http" + request = Request(f"{scheme}://dns.resolution.invalid./") + async with self.get_dh() as download_handler: + with pytest.raises(CannotResolveHostError): + await download_handler.download_request(request) + @deferred_f_from_coro_f async def test_protocol(self, mockserver: MockServer) -> None: request = Request( @@ -547,6 +596,12 @@ class TestHttps11Base(TestHttp11Base): 'subject "/C=IE/O=Scrapy/CN=localhost"' ) + def test_download_conn_lost(self) -> None: # type: ignore[override] + # For some reason (maybe related to TLS shutdown flow, and maybe the + # mockserver resource can be fixed so that this works) HTTPS clients + # (not just Scrapy) hang on /drop?abort=0. + pytest.skip("Unable to test on HTTPS") + @deferred_f_from_coro_f async def test_tls_logging(self, mockserver: MockServer) -> None: request = Request(mockserver.url("/text", is_secure=self.is_secure)) @@ -658,7 +713,7 @@ class TestHttpWithCrawlerBase(ABC): ) assert crawler.spider failure = crawler.spider.meta["failure"] # type: ignore[attr-defined] - assert isinstance(failure.value, defer.CancelledError) + assert isinstance(failure.value, DownloadCancelledError) @deferred_f_from_coro_f async def test_download(self, mockserver: MockServer) -> None: @@ -734,9 +789,9 @@ class TestHttpProxyBase(ABC): domain = "https://no-such-domain.nosuch" request = Request(domain, meta={"proxy": http_proxy, "download_timeout": 0.2}) async with self.get_dh() as download_handler: - with pytest.raises(error.TimeoutError) as exc_info: + with pytest.raises(DownloadTimeoutError) as exc_info: await download_handler.download_request(request) - assert domain in exc_info.value.osError + assert domain in str(exc_info.value) @deferred_f_from_coro_f async def test_download_with_proxy_without_http_scheme( diff --git a/tests/test_downloadermiddleware_retry.py b/tests/test_downloadermiddleware_retry.py index affcc79c1..20e8fc407 100644 --- a/tests/test_downloadermiddleware_retry.py +++ b/tests/test_downloadermiddleware_retry.py @@ -2,20 +2,15 @@ import logging import pytest from testfixtures import LogCapture -from twisted.internet import defer -from twisted.internet.error import ( - ConnectError, - ConnectionDone, - ConnectionLost, - DNSLookupError, - TCPTimedOutError, -) -from twisted.internet.error import ConnectionRefusedError as TxConnectionRefusedError -from twisted.internet.error import TimeoutError as TxTimeoutError -from twisted.web.client import ResponseFailed +from twisted.internet.error import ConnectError, ConnectionDone, ConnectionLost from scrapy.downloadermiddlewares.retry import RetryMiddleware, get_retry_request -from scrapy.exceptions import IgnoreRequest +from scrapy.exceptions import ( + CannotResolveHostError, + DownloadConnectionRefusedError, + DownloadTimeoutError, + IgnoreRequest, +) from scrapy.http import Request, Response from scrapy.settings.default_settings import RETRY_EXCEPTIONS from scrapy.spiders import Spider @@ -62,7 +57,7 @@ class TestRetry: def test_dont_retry_exc(self): req = Request("http://www.scrapytest.org/503", meta={"dont_retry": True}) - r = self.mw.process_exception(req, DNSLookupError()) + r = self.mw.process_exception(req, CannotResolveHostError()) assert r is None def test_503(self): @@ -94,12 +89,9 @@ class TestRetry: ConnectError, ConnectionDone, ConnectionLost, - TxConnectionRefusedError, - defer.TimeoutError, - DNSLookupError, - ResponseFailed, - TCPTimedOutError, - TxTimeoutError, + DownloadTimeoutError, + DownloadConnectionRefusedError, + CannotResolveHostError, ] for exc in exceptions: @@ -110,7 +102,7 @@ class TestRetry: assert stats.get_value("retry/max_reached") == len(exceptions) assert stats.get_value("retry/count") == len(exceptions) * 2 assert ( - stats.get_value("retry/reason_count/twisted.internet.defer.TimeoutError") + stats.get_value("retry/reason_count/scrapy.exceptions.DownloadTimeoutError") == 2 ) @@ -159,7 +151,7 @@ class TestMaxRetryTimes: req = Request(self.invalid_url) self._test_retry( req, - DNSLookupError("foo"), + CannotResolveHostError("foo"), max_retry_times, middleware=middleware, ) @@ -171,7 +163,7 @@ class TestMaxRetryTimes: req = Request(self.invalid_url, meta=meta) self._test_retry( req, - DNSLookupError("foo"), + CannotResolveHostError("foo"), max_retry_times, middleware=middleware, ) @@ -183,7 +175,7 @@ class TestMaxRetryTimes: req = Request(self.invalid_url) self._test_retry( req, - DNSLookupError("foo"), + CannotResolveHostError("foo"), max_retry_times, middleware=middleware, ) @@ -200,13 +192,13 @@ class TestMaxRetryTimes: self._test_retry( req1, - DNSLookupError("foo"), + CannotResolveHostError("foo"), meta_max_retry_times, middleware=middleware, ) self._test_retry( req2, - DNSLookupError("foo"), + CannotResolveHostError("foo"), middleware_max_retry_times, middleware=middleware, ) @@ -223,13 +215,13 @@ class TestMaxRetryTimes: self._test_retry( req1, - DNSLookupError("foo"), + CannotResolveHostError("foo"), meta_max_retry_times, middleware=middleware, ) self._test_retry( req2, - DNSLookupError("foo"), + CannotResolveHostError("foo"), middleware_max_retry_times, middleware=middleware, ) @@ -244,7 +236,7 @@ class TestMaxRetryTimes: req = Request(self.invalid_url, meta=meta) self._test_retry( req, - DNSLookupError("foo"), + CannotResolveHostError("foo"), 0, middleware=middleware, ) diff --git a/tests/test_downloadermiddleware_robotstxt.py b/tests/test_downloadermiddleware_robotstxt.py index bedf40279..1e99d5162 100644 --- a/tests/test_downloadermiddleware_robotstxt.py +++ b/tests/test_downloadermiddleware_robotstxt.py @@ -5,12 +5,11 @@ from typing import TYPE_CHECKING from unittest import mock import pytest -from twisted.internet import error from twisted.internet.defer import Deferred, DeferredList from twisted.python import failure from scrapy.downloadermiddlewares.robotstxt import RobotsTxtMiddleware -from scrapy.exceptions import IgnoreRequest, NotConfigured +from scrapy.exceptions import CannotResolveHostError, IgnoreRequest, NotConfigured from scrapy.http import Request, Response, TextResponse from scrapy.http.request import NO_CALLBACK from scrapy.settings import Settings @@ -165,7 +164,7 @@ Disallow: /some/randome/page.html @deferred_f_from_coro_f async def test_robotstxt_error(self, caplog: pytest.LogCaptureFixture) -> None: self.crawler.settings.set("ROBOTSTXT_OBEY", True) - err = error.DNSLookupError("Robotstxt address not found") + err = CannotResolveHostError("Robotstxt address not found") async def return_failure(request): deferred = Deferred() @@ -176,12 +175,12 @@ Disallow: /some/randome/page.html middleware = RobotsTxtMiddleware(self.crawler) await middleware.process_request(Request("http://site.local")) - assert "DNS lookup failed: Robotstxt address not found" in caplog.text + assert "Robotstxt address not found" in caplog.text @deferred_f_from_coro_f async def test_robotstxt_immediate_error(self): self.crawler.settings.set("ROBOTSTXT_OBEY", True) - err = error.DNSLookupError("Robotstxt address not found") + err = CannotResolveHostError("Robotstxt address not found") async def immediate_failure(request): raise err diff --git a/tests/test_http2_client_protocol.py b/tests/test_http2_client_protocol.py index dcc067ec9..46ef75064 100644 --- a/tests/test_http2_client_protocol.py +++ b/tests/test_http2_client_protocol.py @@ -12,14 +12,8 @@ from urllib.parse import urlencode import pytest from pytest_twisted import async_yield_fixture -from twisted.internet.defer import ( - CancelledError, - Deferred, - DeferredList, - inlineCallbacks, -) +from twisted.internet.defer import Deferred, DeferredList, inlineCallbacks from twisted.internet.endpoints import SSL4ClientEndpoint, SSL4ServerEndpoint -from twisted.internet.error import TimeoutError as TxTimeoutError from twisted.internet.ssl import Certificate, PrivateCertificate, optionsForClientTLS from twisted.web.client import URI, ResponseFailed from twisted.web.http import H2_ENABLED @@ -27,6 +21,7 @@ from twisted.web.http import Request as TxRequest from twisted.web.server import NOT_DONE_YET, Site from twisted.web.static import File +from scrapy.exceptions import DownloadCancelledError, DownloadTimeoutError from scrapy.http import JsonRequest, Request, Response from scrapy.settings import Settings from scrapy.spiders import Spider @@ -477,7 +472,7 @@ class TestHttps2ClientProtocol: url=self.get_url(server_port, "/get-data-html-large"), meta={"download_maxsize": 1000}, ) - with pytest.raises(CancelledError) as exc_info: + with pytest.raises(DownloadCancelledError) as exc_info: await make_request(client, request) error_pattern = re.compile( rf"Cancelling download of {request.url}: received response " @@ -732,7 +727,7 @@ class TestHttps2ClientProtocol: for err in exc_info.value.reasons: from scrapy.core.http2.protocol import H2ClientProtocol # noqa: PLC0415 - if isinstance(err, TxTimeoutError): + if isinstance(err, DownloadTimeoutError): assert ( f"Connection was IDLE for more than {H2ClientProtocol.IDLE_TIMEOUT}s" in str(err) diff --git a/tests/test_webclient.py b/tests/test_webclient.py index 963cb606e..50cab9a60 100644 --- a/tests/test_webclient.py +++ b/tests/test_webclient.py @@ -9,7 +9,6 @@ from urllib.parse import urlparse import OpenSSL.SSL import pytest from pytest_twisted import async_yield_fixture -from twisted.internet import defer from twisted.internet.defer import inlineCallbacks from twisted.internet.testing import StringTransport from twisted.protocols.policies import WrappingFactory @@ -18,6 +17,7 @@ from twisted.web.client import _makeGetterFactory from scrapy.core.downloader import webclient as client from scrapy.core.downloader.contextfactory import ScrapyClientContextFactory +from scrapy.exceptions import DownloadTimeoutError from scrapy.http import Headers, Request from scrapy.utils.misc import build_from_crawler from scrapy.utils.python import to_bytes, to_unicode @@ -302,9 +302,9 @@ class TestWebClient: """ When a non-zero timeout is passed to L{getPage} and that many seconds elapse before the server responds to the request. the - L{Deferred} is errbacked with a L{error.TimeoutError}. + L{Deferred} is errbacked with a L{DownloadTimeoutError}. """ - with pytest.raises(defer.TimeoutError): + with pytest.raises(DownloadTimeoutError): yield getPage(server_url + "wait", timeout=0.000001) # Clean up the server which is hanging around not doing # anything. From 3ec6ae05c1b97f7c04c726cc4fb1c3ccaccd8e1b Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Wed, 21 Jan 2026 23:41:27 +0400 Subject: [PATCH 037/248] Fix omitting repeated dataloss warnings in HTTP11DownloadHandler. (#7222) --- scrapy/core/downloader/handlers/http11.py | 26 ++++++++++--------- .../test_downloader_handler_twisted_http2.py | 3 +++ tests/test_downloader_handlers_http_base.py | 15 +++++++++++ 3 files changed, 32 insertions(+), 12 deletions(-) diff --git a/scrapy/core/downloader/handlers/http11.py b/scrapy/core/downloader/handlers/http11.py index 312ecf229..21def678a 100644 --- a/scrapy/core/downloader/handlers/http11.py +++ b/scrapy/core/downloader/handlers/http11.py @@ -93,6 +93,7 @@ class HTTP11DownloadHandler(BaseDownloadHandler): "DOWNLOAD_FAIL_ON_DATALOSS" ) self._disconnect_timeout: int = 1 + self._fail_on_dataloss_warned: bool = False async def download_request(self, request: Request) -> Response: """Return a deferred for the HTTP download""" @@ -115,8 +116,19 @@ class HTTP11DownloadHandler(BaseDownloadHandler): fail_on_dataloss=self._fail_on_dataloss, crawler=self._crawler, ) - with wrap_twisted_exceptions(): - return await maybe_deferred_to_future(agent.download_request(request)) + try: + with wrap_twisted_exceptions(): + return await maybe_deferred_to_future(agent.download_request(request)) + except ResponseDataLossError: + if not self._fail_on_dataloss_warned: + logger.warning( + "Got data loss in %s. If you want to process broken " + "responses set the setting DOWNLOAD_FAIL_ON_DATALOSS = False" + " -- This message won't be shown in further requests", + request.url, + ) + self._fail_on_dataloss_warned = True + raise async def close(self) -> None: from twisted.internet import reactor @@ -633,7 +645,6 @@ class _ResponseReader(Protocol): self._maxsize: int = maxsize self._warnsize: int = warnsize self._fail_on_dataloss: bool = fail_on_dataloss - self._fail_on_dataloss_warned: bool = False self._reached_warnsize: bool = False self._bytes_received: int = 0 self._certificate: ssl.Certificate | None = None @@ -738,15 +749,6 @@ class _ResponseReader(Protocol): self._finish_response(flags=["dataloss"]) return - if not self._fail_on_dataloss_warned: - logger.warning( - "Got data loss in %s. If you want to process broken " - "responses set the setting DOWNLOAD_FAIL_ON_DATALOSS = False" - " -- This message won't be shown in further requests", - self._txresponse.request.absoluteURI.decode(), - ) - self._fail_on_dataloss_warned = True - exc = ResponseDataLossError() exc.__cause__ = reason.value reason = Failure(exc) diff --git a/tests/test_downloader_handler_twisted_http2.py b/tests/test_downloader_handler_twisted_http2.py index a9197645a..3fbc8d416 100644 --- a/tests/test_downloader_handler_twisted_http2.py +++ b/tests/test_downloader_handler_twisted_http2.py @@ -86,6 +86,9 @@ class TestHttps2(H2DownloadHandlerMixin, TestHttps11Base): def test_download_cause_data_loss(self) -> None: # type: ignore[override] pytest.skip(self.HTTP2_DATALOSS_SKIP_REASON) + def test_download_cause_data_loss_double_warning(self) -> None: # type: ignore[override] + pytest.skip(self.HTTP2_DATALOSS_SKIP_REASON) + def test_download_allow_data_loss(self) -> None: # type: ignore[override] pytest.skip(self.HTTP2_DATALOSS_SKIP_REASON) diff --git a/tests/test_downloader_handlers_http_base.py b/tests/test_downloader_handlers_http_base.py index d33de8881..5e3b49548 100644 --- a/tests/test_downloader_handlers_http_base.py +++ b/tests/test_downloader_handlers_http_base.py @@ -516,6 +516,21 @@ class TestHttp11Base(TestHttpBase): with pytest.raises(ResponseDataLossError): await download_handler.download_request(request) + @deferred_f_from_coro_f + async def test_download_cause_data_loss_double_warning( + self, caplog: pytest.LogCaptureFixture, mockserver: MockServer + ) -> None: + request = Request(mockserver.url("/broken", is_secure=self.is_secure)) + async with self.get_dh() as download_handler: + with pytest.raises(ResponseDataLossError): + await download_handler.download_request(request) + assert "Got data loss" in caplog.text + caplog.clear() + with pytest.raises(ResponseDataLossError): + await download_handler.download_request(request) + # no repeated warning + assert "Got data loss" not in caplog.text + @pytest.mark.parametrize("url", ["broken", "broken-chunked"]) @deferred_f_from_coro_f async def test_download_allow_data_loss( From 49930dfec505305aafcb6b7b7eded610ca1df178 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C3=A1draic=20Slattery?= Date: Fri, 23 Jan 2026 12:26:58 +0100 Subject: [PATCH 038/248] chore: Update outdated GitHub Actions versions (#7223) --- .github/workflows/checks.yml | 4 ++-- .github/workflows/publish.yml | 2 +- .github/workflows/tests-macos.yml | 2 +- .github/workflows/tests-ubuntu.yml | 2 +- .github/workflows/tests-windows.yml | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 101c6648e..d3715caf2 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -34,7 +34,7 @@ jobs: TOXENV: twinecheck steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v6 @@ -50,5 +50,5 @@ jobs: pre-commit: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - uses: pre-commit/action@v3.0.1 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 9894c8e7c..ad327e465 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -18,7 +18,7 @@ jobs: permissions: id-token: write steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - uses: actions/setup-python@v6 with: python-version: "3.13" diff --git a/.github/workflows/tests-macos.yml b/.github/workflows/tests-macos.yml index 61bac1288..4d199c960 100644 --- a/.github/workflows/tests-macos.yml +++ b/.github/workflows/tests-macos.yml @@ -19,7 +19,7 @@ jobs: python-version: ["3.10", "3.11", "3.12", "3.13"] steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v6 diff --git a/.github/workflows/tests-ubuntu.yml b/.github/workflows/tests-ubuntu.yml index b85f5ea4f..31ca6b2e9 100644 --- a/.github/workflows/tests-ubuntu.yml +++ b/.github/workflows/tests-ubuntu.yml @@ -67,7 +67,7 @@ jobs: TOXENV: mitmproxy steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v6 diff --git a/.github/workflows/tests-windows.yml b/.github/workflows/tests-windows.yml index f202a2ffd..5f4b383fd 100644 --- a/.github/workflows/tests-windows.yml +++ b/.github/workflows/tests-windows.yml @@ -46,7 +46,7 @@ jobs: TOXENV: extra-deps steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v6 From 4e1faf883d83781a15732d453981de988b0db300 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Sat, 31 Jan 2026 19:48:54 +0400 Subject: [PATCH 039/248] Foundations for the reactorless mode. (#7199) * Foundations for the reactorless mode. * Add simple subprocess tests for reactorless AsyncCrawler*. * More reactorless tests. * Refactor AsyncCrawlerProcess.start(). * More checks. * Fix test_reactorless_import_hook. * More tests. * Call install_reactor() before asyncio.run(). * Cleanup. * Rephrase. * Rephrasing. * Set TELNETCONSOLE_ENABLED=False in the reactorless mode. --- scrapy/crawler.py | 95 ++++++++++++++---- scrapy/extensions/telnet.py | 6 ++ scrapy/settings/default_settings.py | 2 + scrapy/utils/asyncio.py | 42 ++++++-- scrapy/utils/reactorless.py | 53 ++++++++++ .../reactorless_datauri.py | 27 ++++++ .../reactorless_import_hook.py | 27 ++++++ .../reactorless_reactor.py | 15 +++ .../AsyncCrawlerProcess/reactorless_simple.py | 27 ++++++ .../reactorless_telnetconsole_default.py | 25 +++++ .../reactorless_telnetconsole_disabled.py | 26 +++++ .../reactorless_telnetconsole_enabled.py | 26 +++++ tests/AsyncCrawlerProcess/simple.py | 2 + .../AsyncCrawlerRunner/reactorless_datauri.py | 33 +++++++ .../AsyncCrawlerRunner/reactorless_reactor.py | 33 +++++++ .../AsyncCrawlerRunner/reactorless_simple.py | 33 +++++++ tests/AsyncCrawlerRunner/simple.py | 2 + tests/CrawlerProcess/reactorless.py | 12 +++ tests/CrawlerProcess/simple.py | 2 + tests/CrawlerRunner/reactorless.py | 12 +++ tests/CrawlerRunner/simple.py | 2 + tests/test_crawler.py | 96 ++++++++++++++++++- 22 files changed, 572 insertions(+), 26 deletions(-) create mode 100644 scrapy/utils/reactorless.py create mode 100644 tests/AsyncCrawlerProcess/reactorless_datauri.py create mode 100644 tests/AsyncCrawlerProcess/reactorless_import_hook.py create mode 100644 tests/AsyncCrawlerProcess/reactorless_reactor.py create mode 100644 tests/AsyncCrawlerProcess/reactorless_simple.py create mode 100644 tests/AsyncCrawlerProcess/reactorless_telnetconsole_default.py create mode 100644 tests/AsyncCrawlerProcess/reactorless_telnetconsole_disabled.py create mode 100644 tests/AsyncCrawlerProcess/reactorless_telnetconsole_enabled.py create mode 100644 tests/AsyncCrawlerRunner/reactorless_datauri.py create mode 100644 tests/AsyncCrawlerRunner/reactorless_reactor.py create mode 100644 tests/AsyncCrawlerRunner/reactorless_simple.py create mode 100644 tests/CrawlerProcess/reactorless.py create mode 100644 tests/CrawlerRunner/reactorless.py diff --git a/scrapy/crawler.py b/scrapy/crawler.py index 66148904b..33c2bb44c 100644 --- a/scrapy/crawler.py +++ b/scrapy/crawler.py @@ -34,9 +34,11 @@ from scrapy.utils.reactor import ( install_reactor, is_asyncio_reactor_installed, is_reactor_installed, + set_asyncio_event_loop, verify_installed_asyncio_event_loop, verify_installed_reactor, ) +from scrapy.utils.reactorless import install_reactor_import_hook if TYPE_CHECKING: from collections.abc import Awaitable, Generator, Iterable @@ -103,22 +105,27 @@ class Crawler: self, ) - reactor_class: str = self.settings["TWISTED_REACTOR"] - event_loop: str = self.settings["ASYNCIO_EVENT_LOOP"] - if self._init_reactor: - # this needs to be done after the spider settings are merged, - # but before something imports twisted.internet.reactor + use_reactor = self.settings.getbool("TWISTED_ENABLED") + if use_reactor: + reactor_class: str = self.settings["TWISTED_REACTOR"] + event_loop: str = self.settings["ASYNCIO_EVENT_LOOP"] + if self._init_reactor: + # this needs to be done after the spider settings are merged, + # but before something imports twisted.internet.reactor + if reactor_class: + install_reactor(reactor_class, event_loop) + else: + from twisted.internet import reactor # noqa: F401 if reactor_class: - install_reactor(reactor_class, event_loop) - else: - from twisted.internet import reactor # noqa: F401 - if reactor_class: - verify_installed_reactor(reactor_class) - if is_asyncio_reactor_installed() and event_loop: - verify_installed_asyncio_event_loop(event_loop) + verify_installed_reactor(reactor_class) + if is_asyncio_reactor_installed() and event_loop: + verify_installed_asyncio_event_loop(event_loop) - if self._init_reactor or reactor_class: - log_reactor_info() + if self._init_reactor or reactor_class: + log_reactor_info() + else: + logger.debug("Not using a Twisted reactor") + self._apply_reactorless_default_settings() self.extensions = ExtensionManager.from_crawler(self) self.settings.freeze() @@ -128,6 +135,15 @@ class Crawler: "Overridden settings:\n%(settings)s", {"settings": pprint.pformat(d)} ) + def _apply_reactorless_default_settings(self): + """Change some setting defaults when not using a Twisted reactor. + + Some settings need different defaults when using and not using a + reactor, but as we can't put this logic into default_settings.py we + change them here when the reactor is not used. + """ + self.settings.set("TELNETCONSOLE_ENABLED", False, priority="default") + # Cannot use @deferred_f_from_coro_f because that relies on the reactor # being installed already, which is done within _apply_settings(), inside # this method. @@ -375,6 +391,10 @@ class CrawlerRunner(CrawlerRunnerBase): def __init__(self, settings: dict[str, Any] | Settings | None = None): super().__init__(settings) + if not self.settings.getbool("TWISTED_ENABLED"): + raise RuntimeError( + f"{type(self).__name__} doesn't support TWISTED_ENABLED=False." + ) self._active: set[Deferred[None]] = set() def crawl( @@ -499,9 +519,16 @@ class AsyncCrawlerRunner(CrawlerRunnerBase): "The crawler_or_spidercls argument cannot be a spider object, " "it must be a spider class (or a Crawler object)" ) - if not is_asyncio_reactor_installed(): + if self.settings.getbool("TWISTED_ENABLED"): + if not is_asyncio_reactor_installed(): + raise RuntimeError( + f"When TWISTED_ENABLED is True, {type(self).__name__} " + f"requires that the installed Twisted reactor is " + f'"twisted.internet.asyncioreactor.AsyncioSelectorReactor".' + ) + elif is_reactor_installed(): raise RuntimeError( - f"{type(self).__name__} requires AsyncioSelectorReactor." + "TWISTED_ENABLED is False but a Twisted reactor is installed." ) crawler = self.create_crawler(crawler_or_spidercls) return self._crawl(crawler, *args, **kwargs) @@ -730,7 +757,14 @@ class AsyncCrawlerProcess(CrawlerProcessBase, AsyncCrawlerRunner): # The ASYNCIO_EVENT_LOOP setting cannot be overridden by add-ons and # spiders when using AsyncCrawlerProcess. loop_path = self.settings["ASYNCIO_EVENT_LOOP"] - if is_reactor_installed(): + if not self.settings.getbool("TWISTED_ENABLED"): + if is_reactor_installed(): + raise RuntimeError( + "TWISTED_ENABLED is False but a Twisted reactor is installed." + ) + set_asyncio_event_loop(loop_path) + install_reactor_import_hook() + elif is_reactor_installed(): # The user could install a reactor before this class is instantiated. # We need to make sure the reactor is the correct one and the loop # type matches the setting. @@ -761,6 +795,33 @@ class AsyncCrawlerProcess(CrawlerProcessBase, AsyncCrawlerRunner): :param bool install_signal_handlers: whether to install the OS signal handlers from Twisted and Scrapy (default: True) """ + + if not self.settings.getbool("TWISTED_ENABLED"): + self._start_asyncio(stop_after_crawl, install_signal_handlers) + else: + self._start_twisted(stop_after_crawl, install_signal_handlers) + + def _start_asyncio( + self, stop_after_crawl: bool, install_signal_handlers: bool + ) -> None: + # Very basic and will need multiple improvements. + # TODO https://docs.python.org/3/library/asyncio-runner.html#handling-keyboard-interruption + # TODO various exception handling + # TODO consider asyncio.run() + + loop = asyncio.get_event_loop() + if stop_after_crawl: + join_task = loop.create_task(self.join()) + join_task.add_done_callback(lambda _: loop.stop()) + try: + loop.run_forever() # blocking call + finally: + loop.run_until_complete(loop.shutdown_asyncgens()) + loop.close() + + def _start_twisted( + self, stop_after_crawl: bool, install_signal_handlers: bool + ) -> None: from twisted.internet import reactor if stop_after_crawl: diff --git a/scrapy/extensions/telnet.py b/scrapy/extensions/telnet.py index d24c1b6c4..f95cdb040 100644 --- a/scrapy/extensions/telnet.py +++ b/scrapy/extensions/telnet.py @@ -44,6 +44,12 @@ class TelnetConsole(protocol.ServerFactory): if not crawler.settings.getbool("TELNETCONSOLE_ENABLED"): raise NotConfigured + if not crawler.settings.getbool("TWISTED_ENABLED"): + raise NotConfigured( + "The TelnetConsole extension requires a Twisted reactor." + " You can set the TELNETCONSOLE_ENABLED setting to False to remove this warning." + ) + self.crawler: Crawler = crawler self.noisy: bool = False self.portrange: list[int] = [ diff --git a/scrapy/settings/default_settings.py b/scrapy/settings/default_settings.py index 7ef5feb9f..385d5f933 100644 --- a/scrapy/settings/default_settings.py +++ b/scrapy/settings/default_settings.py @@ -184,6 +184,7 @@ __all__ = [ "TELNETCONSOLE_PORT", "TELNETCONSOLE_USERNAME", "TEMPLATES_DIR", + "TWISTED_ENABLED", "TWISTED_REACTOR", "URLLENGTH_LIMIT", "USER_AGENT", @@ -522,6 +523,7 @@ TELNETCONSOLE_PASSWORD = None TEMPLATES_DIR = str((Path(__file__).parent / ".." / "templates").resolve()) +TWISTED_ENABLED = True TWISTED_REACTOR = "twisted.internet.asyncioreactor.AsyncioSelectorReactor" URLLENGTH_LIMIT = 2083 diff --git a/scrapy/utils/asyncio.py b/scrapy/utils/asyncio.py index a8216bc2b..9ec090995 100644 --- a/scrapy/utils/asyncio.py +++ b/scrapy/utils/asyncio.py @@ -35,15 +35,16 @@ def is_asyncio_available() -> bool: .. versionadded:: 2.14 - Currently this function is identical to - :func:`scrapy.utils.reactor.is_asyncio_reactor_installed`: it returns - ``True`` if the Twisted reactor that is installed is + This function returns ``True`` if there is a running asyncio event loop. If + there is no such loop, it returns ``True`` if the Twisted reactor that is + installed is :class:`~twisted.internet.asyncioreactor.AsyncioSelectorReactor`, returns ``False`` if a different reactor is installed, and raises a - :exc:`RuntimeError` if no reactor is installed. In a future Scrapy version, - when Scrapy supports running without a Twisted reactor, this function will - also return ``True`` when running in that mode, so code that doesn't - directly require a Twisted reactor should use this function instead of + :exc:`RuntimeError` if no reactor is installed. + + Code that doesn't directly require a Twisted reactor should use this + function while code that requires + :class:`~twisted.internet.asyncioreactor.AsyncioSelectorReactor` should use :func:`~scrapy.utils.reactor.is_asyncio_reactor_installed`. When this returns ``True``, an asyncio loop is installed and used by @@ -56,10 +57,35 @@ def is_asyncio_available() -> bool: loop or await on :class:`asyncio.Future` objects in Scrapy-related code, but it's possible to await on :class:`~twisted.internet.defer.Deferred` objects. + + .. note:: As this function uses :func:`asyncio.get_running_loop()`, it will + only detect the event loop if called in the same thread and from the + code that runs inside that loop (this shouldn't be a problem when + calling it from code such as spiders and Scrapy components, if Scrapy + is run using one of the supported ways). + + .. versionchanged:: VERSION + This function now also returns ``True`` if there is a running asyncio + loop, even if no Twisted reactor is installed. """ + + # Check if there is a running asyncio loop. + # Can't easily check for an installed but not running one, and if we + # checked that there could be false positives due to some 3rd-party code + # installing it as a side effect (e.g. by calling get_event_loop()). + try: + asyncio.get_running_loop() + except RuntimeError: + pass + else: + return True + + # Check if there is an installed asyncio reactor (it doesn't need to be + # running). if not is_reactor_installed(): raise RuntimeError( - "is_asyncio_available() called without an installed reactor." + "is_asyncio_available() called without an installed reactor" + " or running asyncio loop." ) return is_asyncio_reactor_installed() diff --git a/scrapy/utils/reactorless.py b/scrapy/utils/reactorless.py new file mode 100644 index 000000000..cd60e6c7d --- /dev/null +++ b/scrapy/utils/reactorless.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +import sys +from importlib.abc import MetaPathFinder +from typing import TYPE_CHECKING + +from scrapy.utils.asyncio import is_asyncio_available +from scrapy.utils.reactor import is_reactor_installed + +if TYPE_CHECKING: + from collections.abc import Sequence + from importlib.machinery import ModuleSpec + from types import ModuleType + + +def is_reactorless() -> bool: + """Check if we are running in the reactorless mode, i.e. with + :setting:`TWISTED_ENABLED` set to ``False``. + + As this checks the runtime state and not the setting itself, it can be + wrong when executed very early, before the reactor and/or the asyncio event + loop are initialized. + + .. note:: As this function uses + :func:`scrapy.utils.asyncio.is_asyncio_available()`, it has the same + limitations for detecting a running asyncio event loop as that one. + + .. versionadded:: VERSION + """ + return is_asyncio_available() and not is_reactor_installed() + + +class ReactorImportHook(MetaPathFinder): + """Hook that prevents importing :mod:`twisted.internet.reactor`.""" + + def find_spec( + self, + fullname: str, + path: Sequence[str] | None, + target: ModuleType | None = None, + ) -> ModuleSpec | None: + if fullname == "twisted.internet.reactor": + raise ImportError( + f"Import of {fullname} is forbidden when running without a Twisted reactor," + f" as importing it installs the reactor, which can lead to unexpected behavior." + ) + return None + + +def install_reactor_import_hook() -> None: + """Prevent importing :mod:`twisted.internet.reactor`.""" + + sys.meta_path.insert(0, ReactorImportHook()) diff --git a/tests/AsyncCrawlerProcess/reactorless_datauri.py b/tests/AsyncCrawlerProcess/reactorless_datauri.py new file mode 100644 index 000000000..7906cc9de --- /dev/null +++ b/tests/AsyncCrawlerProcess/reactorless_datauri.py @@ -0,0 +1,27 @@ +from scrapy import Request, Spider +from scrapy.crawler import AsyncCrawlerProcess + + +class DataSpider(Spider): + name = "data" + + async def start(self): + yield Request("data:,foo") + + def parse(self, response): + return {"data": response.text} + + +process = AsyncCrawlerProcess( + settings={ + "TWISTED_ENABLED": False, + "DOWNLOAD_HANDLERS": { + "http": None, + "https": None, + "ftp": None, + }, + } +) + +process.crawl(DataSpider) +process.start() diff --git a/tests/AsyncCrawlerProcess/reactorless_import_hook.py b/tests/AsyncCrawlerProcess/reactorless_import_hook.py new file mode 100644 index 000000000..2f949cfc1 --- /dev/null +++ b/tests/AsyncCrawlerProcess/reactorless_import_hook.py @@ -0,0 +1,27 @@ +import scrapy +from scrapy.crawler import AsyncCrawlerProcess + + +class NoRequestsSpider(scrapy.Spider): + name = "no_request" + + async def start(self): + import twisted.internet.reactor # noqa: F401 + + return + yield + + +process = AsyncCrawlerProcess( + settings={ + "TWISTED_ENABLED": False, + "DOWNLOAD_HANDLERS": { + "http": None, + "https": None, + "ftp": None, + }, + } +) + +process.crawl(NoRequestsSpider) +process.start() diff --git a/tests/AsyncCrawlerProcess/reactorless_reactor.py b/tests/AsyncCrawlerProcess/reactorless_reactor.py new file mode 100644 index 000000000..a32beee22 --- /dev/null +++ b/tests/AsyncCrawlerProcess/reactorless_reactor.py @@ -0,0 +1,15 @@ +from scrapy.crawler import AsyncCrawlerProcess +from scrapy.utils.reactor import install_reactor + +install_reactor("twisted.internet.asyncioreactor.AsyncioSelectorReactor") + +AsyncCrawlerProcess( + settings={ + "TWISTED_ENABLED": False, + "DOWNLOAD_HANDLERS": { + "http": None, + "https": None, + "ftp": None, + }, + } +) diff --git a/tests/AsyncCrawlerProcess/reactorless_simple.py b/tests/AsyncCrawlerProcess/reactorless_simple.py new file mode 100644 index 000000000..dbe9c73b4 --- /dev/null +++ b/tests/AsyncCrawlerProcess/reactorless_simple.py @@ -0,0 +1,27 @@ +import scrapy +from scrapy.crawler import AsyncCrawlerProcess +from scrapy.utils.reactorless import is_reactorless + + +class NoRequestsSpider(scrapy.Spider): + name = "no_request" + + async def start(self): + self.logger.info(f"is_reactorless(): {is_reactorless()}") + return + yield + + +process = AsyncCrawlerProcess( + settings={ + "TWISTED_ENABLED": False, + "DOWNLOAD_HANDLERS": { + "http": None, + "https": None, + "ftp": None, + }, + } +) + +process.crawl(NoRequestsSpider) +process.start() diff --git a/tests/AsyncCrawlerProcess/reactorless_telnetconsole_default.py b/tests/AsyncCrawlerProcess/reactorless_telnetconsole_default.py new file mode 100644 index 000000000..1a4bc5148 --- /dev/null +++ b/tests/AsyncCrawlerProcess/reactorless_telnetconsole_default.py @@ -0,0 +1,25 @@ +import scrapy +from scrapy.crawler import AsyncCrawlerProcess + + +class NoRequestsSpider(scrapy.Spider): + name = "no_request" + + async def start(self): + return + yield + + +process = AsyncCrawlerProcess( + settings={ + "TWISTED_ENABLED": False, + "DOWNLOAD_HANDLERS": { + "http": None, + "https": None, + "ftp": None, + }, + } +) + +process.crawl(NoRequestsSpider) +process.start() diff --git a/tests/AsyncCrawlerProcess/reactorless_telnetconsole_disabled.py b/tests/AsyncCrawlerProcess/reactorless_telnetconsole_disabled.py new file mode 100644 index 000000000..1814071ee --- /dev/null +++ b/tests/AsyncCrawlerProcess/reactorless_telnetconsole_disabled.py @@ -0,0 +1,26 @@ +import scrapy +from scrapy.crawler import AsyncCrawlerProcess + + +class NoRequestsSpider(scrapy.Spider): + name = "no_request" + + async def start(self): + return + yield + + +process = AsyncCrawlerProcess( + settings={ + "TWISTED_ENABLED": False, + "DOWNLOAD_HANDLERS": { + "http": None, + "https": None, + "ftp": None, + }, + "TELNETCONSOLE_ENABLED": False, + } +) + +process.crawl(NoRequestsSpider) +process.start() diff --git a/tests/AsyncCrawlerProcess/reactorless_telnetconsole_enabled.py b/tests/AsyncCrawlerProcess/reactorless_telnetconsole_enabled.py new file mode 100644 index 000000000..0026a3f45 --- /dev/null +++ b/tests/AsyncCrawlerProcess/reactorless_telnetconsole_enabled.py @@ -0,0 +1,26 @@ +import scrapy +from scrapy.crawler import AsyncCrawlerProcess + + +class NoRequestsSpider(scrapy.Spider): + name = "no_request" + + async def start(self): + return + yield + + +process = AsyncCrawlerProcess( + settings={ + "TWISTED_ENABLED": False, + "DOWNLOAD_HANDLERS": { + "http": None, + "https": None, + "ftp": None, + }, + "TELNETCONSOLE_ENABLED": True, + } +) + +process.crawl(NoRequestsSpider) +process.start() diff --git a/tests/AsyncCrawlerProcess/simple.py b/tests/AsyncCrawlerProcess/simple.py index d24b4f193..368e05608 100644 --- a/tests/AsyncCrawlerProcess/simple.py +++ b/tests/AsyncCrawlerProcess/simple.py @@ -1,11 +1,13 @@ import scrapy from scrapy.crawler import AsyncCrawlerProcess +from scrapy.utils.reactorless import is_reactorless class NoRequestsSpider(scrapy.Spider): name = "no_request" async def start(self): + self.logger.info(f"is_reactorless(): {is_reactorless()}") return yield diff --git a/tests/AsyncCrawlerRunner/reactorless_datauri.py b/tests/AsyncCrawlerRunner/reactorless_datauri.py new file mode 100644 index 000000000..cd764b8db --- /dev/null +++ b/tests/AsyncCrawlerRunner/reactorless_datauri.py @@ -0,0 +1,33 @@ +import asyncio + +from scrapy import Request, Spider +from scrapy.crawler import AsyncCrawlerRunner +from scrapy.utils.log import configure_logging + + +class DataSpider(Spider): + name = "data" + + async def start(self): + yield Request("data:,foo") + + def parse(self, response): + return {"data": response.text} + + +async def main(): + configure_logging() + runner = AsyncCrawlerRunner( + settings={ + "TWISTED_ENABLED": False, + "DOWNLOAD_HANDLERS": { + "http": None, + "https": None, + "ftp": None, + }, + } + ) + await runner.crawl(DataSpider) + + +asyncio.run(main()) diff --git a/tests/AsyncCrawlerRunner/reactorless_reactor.py b/tests/AsyncCrawlerRunner/reactorless_reactor.py new file mode 100644 index 000000000..8266ffbc0 --- /dev/null +++ b/tests/AsyncCrawlerRunner/reactorless_reactor.py @@ -0,0 +1,33 @@ +import asyncio + +from scrapy import Spider +from scrapy.crawler import AsyncCrawlerRunner +from scrapy.utils.log import configure_logging +from scrapy.utils.reactor import install_reactor + + +class NoRequestsSpider(Spider): + name = "no_request" + + async def start(self): + return + yield + + +async def main(): + configure_logging() + runner = AsyncCrawlerRunner( + settings={ + "TWISTED_ENABLED": False, + "DOWNLOAD_HANDLERS": { + "http": None, + "https": None, + "ftp": None, + }, + } + ) + await runner.crawl(NoRequestsSpider) + + +install_reactor("twisted.internet.asyncioreactor.AsyncioSelectorReactor") +asyncio.run(main()) diff --git a/tests/AsyncCrawlerRunner/reactorless_simple.py b/tests/AsyncCrawlerRunner/reactorless_simple.py new file mode 100644 index 000000000..7a9e061e9 --- /dev/null +++ b/tests/AsyncCrawlerRunner/reactorless_simple.py @@ -0,0 +1,33 @@ +import asyncio + +from scrapy import Spider +from scrapy.crawler import AsyncCrawlerRunner +from scrapy.utils.log import configure_logging +from scrapy.utils.reactorless import is_reactorless + + +class NoRequestsSpider(Spider): + name = "no_request" + + async def start(self): + self.logger.info(f"is_reactorless(): {is_reactorless()}") + return + yield + + +async def main(): + configure_logging() + runner = AsyncCrawlerRunner( + settings={ + "TWISTED_ENABLED": False, + "DOWNLOAD_HANDLERS": { + "http": None, + "https": None, + "ftp": None, + }, + } + ) + await runner.crawl(NoRequestsSpider) + + +asyncio.run(main()) diff --git a/tests/AsyncCrawlerRunner/simple.py b/tests/AsyncCrawlerRunner/simple.py index 140777b4f..29b132d75 100644 --- a/tests/AsyncCrawlerRunner/simple.py +++ b/tests/AsyncCrawlerRunner/simple.py @@ -5,12 +5,14 @@ from scrapy.crawler import AsyncCrawlerRunner from scrapy.utils.defer import deferred_f_from_coro_f from scrapy.utils.log import configure_logging from scrapy.utils.reactor import install_reactor +from scrapy.utils.reactorless import is_reactorless class NoRequestsSpider(Spider): name = "no_request" async def start(self): + self.logger.info(f"is_reactorless(): {is_reactorless()}") return yield diff --git a/tests/CrawlerProcess/reactorless.py b/tests/CrawlerProcess/reactorless.py new file mode 100644 index 000000000..6cfafe605 --- /dev/null +++ b/tests/CrawlerProcess/reactorless.py @@ -0,0 +1,12 @@ +from scrapy.crawler import CrawlerProcess + +CrawlerProcess( + settings={ + "TWISTED_ENABLED": False, + "DOWNLOAD_HANDLERS": { + "http": None, + "https": None, + "ftp": None, + }, + } +) diff --git a/tests/CrawlerProcess/simple.py b/tests/CrawlerProcess/simple.py index 9e4ad70d9..d5a7ec53a 100644 --- a/tests/CrawlerProcess/simple.py +++ b/tests/CrawlerProcess/simple.py @@ -1,11 +1,13 @@ import scrapy from scrapy.crawler import CrawlerProcess +from scrapy.utils.reactorless import is_reactorless class NoRequestsSpider(scrapy.Spider): name = "no_request" async def start(self): + self.logger.info(f"is_reactorless(): {is_reactorless()}") return yield diff --git a/tests/CrawlerRunner/reactorless.py b/tests/CrawlerRunner/reactorless.py new file mode 100644 index 000000000..be4eb10fb --- /dev/null +++ b/tests/CrawlerRunner/reactorless.py @@ -0,0 +1,12 @@ +from scrapy.crawler import CrawlerRunner + +CrawlerRunner( + settings={ + "TWISTED_ENABLED": False, + "DOWNLOAD_HANDLERS": { + "http": None, + "https": None, + "ftp": None, + }, + } +) diff --git a/tests/CrawlerRunner/simple.py b/tests/CrawlerRunner/simple.py index d154dcde4..47c0fe04f 100644 --- a/tests/CrawlerRunner/simple.py +++ b/tests/CrawlerRunner/simple.py @@ -4,12 +4,14 @@ from scrapy import Spider from scrapy.crawler import CrawlerRunner from scrapy.utils.log import configure_logging from scrapy.utils.reactor import install_reactor +from scrapy.utils.reactorless import is_reactorless class NoRequestsSpider(Spider): name = "no_request" async def start(self): + self.logger.info(f"is_reactorless(): {is_reactorless()}") return yield diff --git a/tests/test_crawler.py b/tests/test_crawler.py index 350c82a0b..6d0c82825 100644 --- a/tests/test_crawler.py +++ b/tests/test_crawler.py @@ -800,6 +800,7 @@ class TestCrawlerProcessSubprocessBase(ScriptRunnerMixin): "Using reactor: twisted.internet.asyncioreactor.AsyncioSelectorReactor" in log ) + assert "is_reactorless(): False" in log def test_multi(self): log = self.run_script("multi.py") @@ -1042,6 +1043,12 @@ class TestCrawlerProcessSubprocess(TestCrawlerProcessSubprocessBase): in log ) + def test_reactorless(self): + log = self.run_script("reactorless.py") + assert ( + "RuntimeError: CrawlerProcess doesn't support TWISTED_ENABLED=False" in log + ) + class TestAsyncCrawlerProcessSubprocess(TestCrawlerProcessSubprocessBase): @property @@ -1076,6 +1083,59 @@ class TestAsyncCrawlerProcessSubprocess(TestCrawlerProcessSubprocessBase): "setting (uvloop.Loop)" ) in log + def test_reactorless_simple(self): + log = self.run_script("reactorless_simple.py") + assert "Not using a Twisted reactor" in log + assert "Spider closed (finished)" in log + assert "is_reactorless(): True" in log + assert "ERROR: " not in log + assert "WARNING: " not in log + + def test_reactorless_datauri(self): + log = self.run_script("reactorless_datauri.py") + assert "Not using a Twisted reactor" in log + assert "Spider closed (finished)" in log + assert "{'data': 'foo'}" in log + assert "'item_scraped_count': 1" in log + assert "ERROR: " not in log + assert "WARNING: " not in log + + def test_reactorless_import_hook(self): + log = self.run_script("reactorless_import_hook.py") + assert "Not using a Twisted reactor" in log + assert "Spider closed (finished)" in log + assert "ImportError: Import of twisted.internet.reactor is forbidden" in log + + def test_reactorless_telnetconsole_default(self): + """By default TWISTED_ENABLED=False silently sets TELNETCONSOLE_ENABLED=False.""" + log = self.run_script("reactorless_telnetconsole_default.py") + assert "Not using a Twisted reactor" in log + assert "Spider closed (finished)" in log + assert "The TelnetConsole extension requires a Twisted reactor" not in log + assert "scrapy.extensions.telnet.TelnetConsole" not in log + + def test_reactorless_telnetconsole_disabled(self): + """Explicit TELNETCONSOLE_ENABLED=False, there are no warnings.""" + log = self.run_script("reactorless_telnetconsole_disabled.py") + assert "Not using a Twisted reactor" in log + assert "Spider closed (finished)" in log + assert "The TelnetConsole extension requires a Twisted reactor" not in log + assert "scrapy.extensions.telnet.TelnetConsole" not in log + + def test_reactorless_telnetconsole_enabled(self): + """Explicit TELNETCONSOLE_ENABLED=True, the user gets a warning.""" + log = self.run_script("reactorless_telnetconsole_enabled.py") + assert "Not using a Twisted reactor" in log + assert "Spider closed (finished)" in log + assert "The TelnetConsole extension requires a Twisted reactor" in log + + def test_reactorless_reactor(self): + log = self.run_script("reactorless_reactor.py") + assert ( + "RuntimeError: TWISTED_ENABLED is False but a Twisted reactor is installed" + in log + ) + class TestCrawlerRunnerSubprocessBase(ScriptRunnerMixin): """Common tests between CrawlerRunner and AsyncCrawlerRunner, @@ -1089,6 +1149,7 @@ class TestCrawlerRunnerSubprocessBase(ScriptRunnerMixin): "Using reactor: twisted.internet.asyncioreactor.AsyncioSelectorReactor" in log ) + assert "is_reactorless(): False" in log def test_multi_parallel(self): log = self.run_script("multi_parallel.py") @@ -1164,6 +1225,12 @@ class TestCrawlerRunnerSubprocess(TestCrawlerRunnerSubprocessBase): ) assert "DEBUG: Using asyncio event loop" in log + def test_reactorless(self): + log = self.run_script("reactorless.py") + assert ( + "RuntimeError: CrawlerRunner doesn't support TWISTED_ENABLED=False" in log + ) + class TestAsyncCrawlerRunnerSubprocess(TestCrawlerRunnerSubprocessBase): @property @@ -1173,7 +1240,34 @@ class TestAsyncCrawlerRunnerSubprocess(TestCrawlerRunnerSubprocessBase): def test_simple_default_reactor(self): log = self.run_script("simple_default_reactor.py") assert "Spider closed (finished)" not in log - assert "RuntimeError: AsyncCrawlerRunner requires AsyncioSelectorReactor" in log + assert ( + "RuntimeError: When TWISTED_ENABLED is True, " + "AsyncCrawlerRunner requires that the installed Twisted reactor" + ) in log + + def test_reactorless_simple(self): + log = self.run_script("reactorless_simple.py") + assert "Not using a Twisted reactor" in log + assert "Spider closed (finished)" in log + assert "is_reactorless(): True" in log + assert "ERROR: " not in log + assert "WARNING: " not in log + + def test_reactorless_datauri(self): + log = self.run_script("reactorless_datauri.py") + assert "Not using a Twisted reactor" in log + assert "Spider closed (finished)" in log + assert "{'data': 'foo'}" in log + assert "'item_scraped_count': 1" in log + assert "ERROR: " not in log + assert "WARNING: " not in log + + def test_reactorless_reactor(self): + log = self.run_script("reactorless_reactor.py") + assert ( + "RuntimeError: TWISTED_ENABLED is False but a Twisted reactor is installed" + in log + ) @pytest.mark.parametrize( From 54d8562fb0f20b5378646deb6f284c10ea4b030e Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Mon, 2 Feb 2026 12:37:39 +0400 Subject: [PATCH 040/248] Assorted test improvements (#7232) * Await some missed deferreds. * Simplify test_engine_stop_download_*. * Fix the header value. * Extend TestHttpWithCrawlerBase. * Make test_tls_logging more universal. * Add more tests for header handling. * Clarify certificate and ip_address integration tests. * Make test_download_with_maxsize_very_large_file() more universal. * Fix test_coroutine_asyncio(). * Typo. --- tests/test_crawl.py | 65 +++++----- .../test_downloader_handler_twisted_http11.py | 11 +- .../test_downloader_handler_twisted_http2.py | 33 +---- tests/test_downloader_handlers_http_base.py | 115 +++++++++++++----- tests/test_engine_stop_download_bytes.py | 39 ++---- tests/test_engine_stop_download_headers.py | 39 ++---- tests/test_pipelines.py | 16 +-- tests/test_utils_defer.py | 2 + 8 files changed, 168 insertions(+), 152 deletions(-) diff --git a/tests/test_crawl.py b/tests/test_crawl.py index 9fd5a40df..1d64c2f39 100644 --- a/tests/test_crawl.py +++ b/tests/test_crawl.py @@ -633,43 +633,52 @@ class TestCrawlSpider: yield crawler.crawl(seed=url, mockserver=self.mockserver) assert crawler.spider.meta["responses"][0].certificate is None - @inlineCallbacks - def test_response_ssl_certificate(self): - crawler = get_crawler(SingleRequestSpider) - url = self.mockserver.url("/echo?body=test", is_secure=True) - yield crawler.crawl(seed=url, mockserver=self.mockserver) - cert = crawler.spider.meta["responses"][0].certificate - assert isinstance(cert, Certificate) - assert cert.getSubject().commonName == b"localhost" - assert cert.getIssuer().commonName == b"localhost" - - @pytest.mark.xfail( - reason="Responses with no body return early and contain no certificate" + @pytest.mark.parametrize( + "url", + [ + "/echo?body=test", + pytest.param( + "/status?n=200", + marks=pytest.mark.xfail( + reason="With HTTP11DownloadHandler, responses with no body are returned early and contain no certificate", + strict=True, + ), + ), + ], ) - @inlineCallbacks - def test_response_ssl_certificate_empty_response(self): + @deferred_f_from_coro_f + async def test_response_ssl_certificate( + self, mockserver: MockServer, url: str + ) -> None: crawler = get_crawler(SingleRequestSpider) - url = self.mockserver.url("/status?n=200", is_secure=True) - yield crawler.crawl(seed=url, mockserver=self.mockserver) + url = mockserver.url(url, is_secure=True) + await crawler.crawl_async(seed=url, mockserver=mockserver) + assert isinstance(crawler.spider, SingleRequestSpider) cert = crawler.spider.meta["responses"][0].certificate assert isinstance(cert, Certificate) assert cert.getSubject().commonName == b"localhost" assert cert.getIssuer().commonName == b"localhost" - @inlineCallbacks - def test_dns_server_ip_address_none(self): + @pytest.mark.parametrize( + "url", + [ + "/echo?body=test", + pytest.param( + "/status?n=200", + marks=pytest.mark.xfail( + reason="With HTTP11DownloadHandler, responses with no body are returned early and contain no ip_address", + strict=True, + ), + ), + ], + ) + @deferred_f_from_coro_f + async def test_response_ip_address(self, mockserver: MockServer, url: str) -> None: crawler = get_crawler(SingleRequestSpider) - url = self.mockserver.url("/status?n=200") - yield crawler.crawl(seed=url, mockserver=self.mockserver) - ip_address = crawler.spider.meta["responses"][0].ip_address - assert ip_address is None - - @inlineCallbacks - def test_dns_server_ip_address(self): - crawler = get_crawler(SingleRequestSpider) - url = self.mockserver.url("/echo?body=test") + url = mockserver.url(url) expected_netloc, _ = urlparse(url).netloc.split(":") - yield crawler.crawl(seed=url, mockserver=self.mockserver) + await crawler.crawl_async(seed=url, mockserver=mockserver) + assert isinstance(crawler.spider, SingleRequestSpider) ip_address = crawler.spider.meta["responses"][0].ip_address assert isinstance(ip_address, IPv4Address) assert str(ip_address) == gethostbyname(expected_netloc) diff --git a/tests/test_downloader_handler_twisted_http11.py b/tests/test_downloader_handler_twisted_http11.py index 79b2a6fc5..eb2735c4d 100644 --- a/tests/test_downloader_handler_twisted_http11.py +++ b/tests/test_downloader_handler_twisted_http11.py @@ -60,7 +60,16 @@ class TestHttps11CustomCiphers(HTTP11DownloadHandlerMixin, TestHttpsCustomCipher class TestHttp11WithCrawler(TestHttpWithCrawlerBase): @property def settings_dict(self) -> dict[str, Any] | None: - return None # default handler settings + return { + "DOWNLOAD_HANDLERS": { + "http": "scrapy.core.downloader.handlers.http11.HTTP11DownloadHandler", + "https": "scrapy.core.downloader.handlers.http11.HTTP11DownloadHandler", + } + } + + +class TestHttps11WithCrawler(TestHttp11WithCrawler): + is_secure = True class TestHttp11Proxy(HTTP11DownloadHandlerMixin, TestHttpProxyBase): diff --git a/tests/test_downloader_handler_twisted_http2.py b/tests/test_downloader_handler_twisted_http2.py index 3fbc8d416..5ae35a461 100644 --- a/tests/test_downloader_handler_twisted_http2.py +++ b/tests/test_downloader_handler_twisted_http2.py @@ -4,14 +4,12 @@ from __future__ import annotations import json from typing import TYPE_CHECKING, Any -from unittest import mock import pytest from testfixtures import LogCapture -from twisted.internet import defer from twisted.web.http import H2_ENABLED -from scrapy.exceptions import DownloadCancelledError, UnsupportedURLSchemeError +from scrapy.exceptions import UnsupportedURLSchemeError from scrapy.http import Request from scrapy.utils.defer import deferred_f_from_coro_f, maybe_deferred_to_future from tests.test_downloader_handlers_http_base import ( @@ -57,32 +55,6 @@ class TestHttps2(H2DownloadHandlerMixin, TestHttps11Base): response = await download_handler.download_request(request) assert response.protocol == "h2" - @deferred_f_from_coro_f - async def test_download_with_maxsize_very_large_file( - self, mockserver: MockServer - ) -> None: - from twisted.internet import reactor - - with mock.patch("scrapy.core.http2.stream.logger") as logger: - request = Request( - mockserver.url("/largechunkedfile", is_secure=self.is_secure) - ) - - def check(logger: mock.Mock) -> None: - logger.error.assert_called_once_with(mock.ANY) - - async with self.get_dh({"DOWNLOAD_MAXSIZE": 1_500}) as download_handler: - with pytest.raises(DownloadCancelledError): - await download_handler.download_request(request) - - # As the error message is logged in the dataReceived callback, we - # have to give a bit of time to the reactor to process the queue - # after closing the connection. - d: defer.Deferred[mock.Mock] = defer.Deferred() - d.addCallback(check) - reactor.callLater(0.1, d.callback, logger) - await maybe_deferred_to_future(d) - def test_download_cause_data_loss(self) -> None: # type: ignore[override] pytest.skip(self.HTTP2_DATALOSS_SKIP_REASON) @@ -206,7 +178,8 @@ class TestHttp2WithCrawler(TestHttpWithCrawlerBase): def settings_dict(self) -> dict[str, Any] | None: return { "DOWNLOAD_HANDLERS": { - "https": "scrapy.core.downloader.handlers.http2.H2DownloadHandler" + "http": None, + "https": "scrapy.core.downloader.handlers.http2.H2DownloadHandler", } } diff --git a/tests/test_downloader_handlers_http_base.py b/tests/test_downloader_handlers_http_base.py index 5e3b49548..7ad787346 100644 --- a/tests/test_downloader_handlers_http_base.py +++ b/tests/test_downloader_handlers_http_base.py @@ -8,12 +8,13 @@ import sys from abc import ABC, abstractmethod from contextlib import asynccontextmanager from http import HTTPStatus +from ipaddress import IPv4Address +from socket import gethostbyname from typing import TYPE_CHECKING, Any -from unittest import mock +from urllib.parse import urlparse import pytest -from testfixtures import LogCapture -from twisted.internet import defer +from twisted.internet.ssl import Certificate from scrapy.exceptions import ( CannotResolveHostError, @@ -25,7 +26,6 @@ from scrapy.exceptions import ( UnsupportedURLSchemeError, ) from scrapy.http import Headers, HtmlResponse, Request, Response, TextResponse -from scrapy.utils.asyncio import call_later from scrapy.utils.defer import ( deferred_f_from_coro_f, deferred_from_coro, @@ -135,6 +135,48 @@ class TestHttpBase(ABC): assert header_name in body["headers"] assert body["headers"][header_name] == [header_value] + @deferred_f_from_coro_f + async def test_request_header_none(self, mockserver: MockServer) -> None: + """Adding a header with None as the value should not send that header.""" + request_headers = { + "Cookie": None, + "X-Custom-Header": None, + } + request = Request( + mockserver.url("/echo", is_secure=self.is_secure), + headers=request_headers, + ) + async with self.get_dh() as download_handler: + response = await download_handler.download_request(request) + assert response.status == HTTPStatus.OK + body = json.loads(response.body.decode("utf-8")) + assert "headers" in body + for header_name in request_headers: + assert header_name not in body["headers"] + + @pytest.mark.parametrize( + "request_headers", + [ + {"X-Custom-Header": ["foo", "bar"]}, + [("X-Custom-Header", "foo"), ("X-Custom-Header", "bar")], + ], + ) + @deferred_f_from_coro_f + async def test_request_header_duplicate( + self, mockserver: MockServer, request_headers: Any + ) -> None: + """All values for a header should be sent.""" + request = Request( + mockserver.url("/echo", is_secure=self.is_secure), + headers=request_headers, + ) + async with self.get_dh() as download_handler: + response = await download_handler.download_request(request) + assert response.status == HTTPStatus.OK + body = json.loads(response.body.decode("utf-8")) + assert "headers" in body + assert body["headers"]["X-Custom-Header"] == ["foo", "bar"] + @deferred_f_from_coro_f async def test_server_receives_correct_request_body( self, mockserver: MockServer @@ -166,7 +208,7 @@ class TestHttpBase(ABC): "Content-Encoding": "gzip", "Content-MD5": "Q2hlY2sgSW50ZWdyaXR5IQ==", "Content-Type": "text/html; charset=utf-8", - "Date": "Date: Tue, 15 Nov 1994 08:12:31 GMT", + "Date": "Tue, 15 Nov 1994 08:12:31 GMT", "Pragma": "no-cache", "Retry-After": "120", "Set-Cookie": "CookieName=CookieValue; Max-Age=3600; Version=1", @@ -450,28 +492,14 @@ class TestHttp11Base(TestHttpBase): @deferred_f_from_coro_f async def test_download_with_maxsize_very_large_file( - self, mockserver: MockServer + self, mockserver: MockServer, caplog: pytest.LogCaptureFixture ) -> None: - # TODO: the logger check is specific to scrapy.core.downloader.handlers.http11 - with mock.patch("scrapy.core.downloader.handlers.http11.logger") as logger: - request = Request( - mockserver.url("/largechunkedfile", is_secure=self.is_secure) - ) + request = Request(mockserver.url("/largechunkedfile", is_secure=self.is_secure)) + async with self.get_dh({"DOWNLOAD_MAXSIZE": 1_500}) as download_handler: + with pytest.raises(DownloadCancelledError): + await download_handler.download_request(request) - def check(logger: mock.Mock) -> None: - logger.warning.assert_called_once_with(mock.ANY, mock.ANY) - - async with self.get_dh({"DOWNLOAD_MAXSIZE": 1_500}) as download_handler: - with pytest.raises(DownloadCancelledError): - await download_handler.download_request(request) - - # As the error message is logged in the dataReceived callback, we - # have to give a bit of time to the reactor to process the queue - # after closing the connection. - d: defer.Deferred[mock.Mock] = defer.Deferred() - d.addCallback(check) - call_later(0.1, d.callback, logger) - await maybe_deferred_to_future(d) + assert "larger than download max size" in caplog.text @deferred_f_from_coro_f async def test_download_with_maxsize_per_req(self, mockserver: MockServer) -> None: @@ -618,17 +646,17 @@ class TestHttps11Base(TestHttp11Base): pytest.skip("Unable to test on HTTPS") @deferred_f_from_coro_f - async def test_tls_logging(self, mockserver: MockServer) -> None: + async def test_tls_logging( + self, mockserver: MockServer, caplog: pytest.LogCaptureFixture + ) -> None: request = Request(mockserver.url("/text", is_secure=self.is_secure)) async with self.get_dh( {"DOWNLOADER_CLIENT_TLS_VERBOSE_LOGGING": True} ) as download_handler: - with LogCapture() as log_capture: + with caplog.at_level("DEBUG"): response = await download_handler.download_request(request) assert response.body == b"Works" - log_capture.check_present( - ("scrapy.core.downloader.tls", "DEBUG", self.tls_log_message) - ) + assert self.tls_log_message in caplog.text class TestSimpleHttpsBase(ABC): @@ -744,6 +772,33 @@ class TestHttpWithCrawlerBase(ABC): reason = crawler.spider.meta["close_reason"] # type: ignore[attr-defined] assert reason == "finished" + @deferred_f_from_coro_f + async def test_response_ssl_certificate(self, mockserver: MockServer) -> None: + if not self.is_secure: + pytest.skip("Only applies to HTTPS") + # copy of TestCrawl.test_response_ssl_certificate() + # the current test implementation can only work for Twisted-based download handlers + crawler = get_crawler(SingleRequestSpider, self.settings_dict) + url = mockserver.url("/echo?body=test", is_secure=self.is_secure) + await crawler.crawl_async(seed=url, mockserver=mockserver) + assert isinstance(crawler.spider, SingleRequestSpider) + cert = crawler.spider.meta["responses"][0].certificate + assert isinstance(cert, Certificate) + assert cert.getSubject().commonName == b"localhost" + assert cert.getIssuer().commonName == b"localhost" + + @deferred_f_from_coro_f + async def test_response_ip_address(self, mockserver: MockServer) -> None: + # copy of TestCrawl.test_response_ip_address() + crawler = get_crawler(SingleRequestSpider, self.settings_dict) + url = mockserver.url("/echo?body=test", is_secure=self.is_secure) + expected_netloc, _ = urlparse(url).netloc.split(":") + await crawler.crawl_async(seed=url, mockserver=mockserver) + assert isinstance(crawler.spider, SingleRequestSpider) + ip_address = crawler.spider.meta["responses"][0].ip_address + assert isinstance(ip_address, IPv4Address) + assert str(ip_address) == gethostbyname(expected_netloc) + class TestHttpProxyBase(ABC): is_secure = False diff --git a/tests/test_engine_stop_download_bytes.py b/tests/test_engine_stop_download_bytes.py index 1d7df70eb..b46ebe2d9 100644 --- a/tests/test_engine_stop_download_bytes.py +++ b/tests/test_engine_stop_download_bytes.py @@ -2,8 +2,6 @@ from __future__ import annotations from typing import TYPE_CHECKING -from testfixtures import LogCapture - from scrapy.exceptions import StopDownload from scrapy.utils.defer import deferred_f_from_coro_f from tests.test_engine import ( @@ -16,6 +14,8 @@ from tests.test_engine import ( ) if TYPE_CHECKING: + import pytest + from tests.mockserver.http import MockServer @@ -27,7 +27,9 @@ class BytesReceivedCrawlerRun(CrawlerRun): class TestBytesReceivedEngine(TestEngineBase): @deferred_f_from_coro_f - async def test_crawler(self, mockserver: MockServer) -> None: + async def test_crawler( + self, mockserver: MockServer, caplog: pytest.LogCaptureFixture + ) -> None: for spider in ( MySpider, DictItemsSpider, @@ -35,32 +37,13 @@ class TestBytesReceivedEngine(TestEngineBase): DataClassItemsSpider, ): run = BytesReceivedCrawlerRun(spider) - with LogCapture() as log: + with caplog.at_level("DEBUG"): await run.run(mockserver) - log.check_present( - ( - "scrapy.core.downloader.handlers.http11", - "DEBUG", - f"Download stopped for " - "from signal handler BytesReceivedCrawlerRun.bytes_received", - ) - ) - log.check_present( - ( - "scrapy.core.downloader.handlers.http11", - "DEBUG", - f"Download stopped for " - "from signal handler BytesReceivedCrawlerRun.bytes_received", - ) - ) - log.check_present( - ( - "scrapy.core.downloader.handlers.http11", - "DEBUG", - f"Download stopped for " - "from signal handler BytesReceivedCrawlerRun.bytes_received", - ) - ) + for url in ("/redirected", "/static/", "/numbers"): + assert ( + f"Download stopped for " + "from signal handler BytesReceivedCrawlerRun.bytes_received" + ) in caplog.text self._assert_visited_urls(run) self._assert_scheduled_requests(run, count=9) self._assert_downloaded_responses(run, count=9) diff --git a/tests/test_engine_stop_download_headers.py b/tests/test_engine_stop_download_headers.py index c01413d4e..76cfbe8ed 100644 --- a/tests/test_engine_stop_download_headers.py +++ b/tests/test_engine_stop_download_headers.py @@ -2,8 +2,6 @@ from __future__ import annotations from typing import TYPE_CHECKING -from testfixtures import LogCapture - from scrapy.exceptions import StopDownload from scrapy.utils.defer import deferred_f_from_coro_f from tests.test_engine import ( @@ -16,6 +14,8 @@ from tests.test_engine import ( ) if TYPE_CHECKING: + import pytest + from tests.mockserver.http import MockServer @@ -27,7 +27,9 @@ class HeadersReceivedCrawlerRun(CrawlerRun): class TestHeadersReceivedEngine(TestEngineBase): @deferred_f_from_coro_f - async def test_crawler(self, mockserver: MockServer) -> None: + async def test_crawler( + self, mockserver: MockServer, caplog: pytest.LogCaptureFixture + ) -> None: for spider in ( MySpider, DictItemsSpider, @@ -35,32 +37,13 @@ class TestHeadersReceivedEngine(TestEngineBase): DataClassItemsSpider, ): run = HeadersReceivedCrawlerRun(spider) - with LogCapture() as log: + with caplog.at_level("DEBUG"): await run.run(mockserver) - log.check_present( - ( - "scrapy.core.downloader.handlers.http11", - "DEBUG", - f"Download stopped for from" - " signal handler HeadersReceivedCrawlerRun.headers_received", - ) - ) - log.check_present( - ( - "scrapy.core.downloader.handlers.http11", - "DEBUG", - f"Download stopped for from signal" - " handler HeadersReceivedCrawlerRun.headers_received", - ) - ) - log.check_present( - ( - "scrapy.core.downloader.handlers.http11", - "DEBUG", - f"Download stopped for from" - " signal handler HeadersReceivedCrawlerRun.headers_received", - ) - ) + for url in ("/redirected", "/static/", "/numbers"): + assert ( + f"Download stopped for " + "from signal handler HeadersReceivedCrawlerRun.headers_received" + ) in caplog.text self._assert_visited_urls(run) self._assert_downloaded_responses(run, count=6) self._assert_signals_caught(run) diff --git a/tests/test_pipelines.py b/tests/test_pipelines.py index f315388cd..acf8dd412 100644 --- a/tests/test_pipelines.py +++ b/tests/test_pipelines.py @@ -476,7 +476,8 @@ class TestMiddlewareManagerSpider: ): await mwman.close_spider_async() - def test_deprecated_spider_arg_with_crawler(self, crawler: Crawler) -> None: + @deferred_f_from_coro_f + async def test_deprecated_spider_arg_with_crawler(self, crawler: Crawler) -> None: """Crawler is provided and has a spider, works. The instance passed to a deprecated method is ignored, even if mismatched.""" mwman = ItemPipelineManager(crawler=crawler) @@ -485,14 +486,15 @@ class TestMiddlewareManagerSpider: ScrapyDeprecationWarning, match=r"ItemPipelineManager.open_spider\(\) is deprecated, use open_spider_async\(\) instead", ): - mwman.open_spider(DefaultSpider()) + await maybe_deferred_to_future(mwman.open_spider(DefaultSpider())) with pytest.warns( ScrapyDeprecationWarning, match=r"ItemPipelineManager.close_spider\(\) is deprecated, use close_spider_async\(\) instead", ): - mwman.close_spider(DefaultSpider()) + await maybe_deferred_to_future(mwman.close_spider(DefaultSpider())) - def test_deprecated_spider_arg_without_crawler(self) -> None: + @deferred_f_from_coro_f + async def test_deprecated_spider_arg_without_crawler(self) -> None: """The first instance passed to a deprecated method is used. Mismatched ones raise an error.""" with pytest.warns( ScrapyDeprecationWarning, @@ -504,7 +506,7 @@ class TestMiddlewareManagerSpider: ScrapyDeprecationWarning, match=r"ItemPipelineManager.open_spider\(\) is deprecated, use open_spider_async\(\) instead", ): - mwman.open_spider(spider) + await maybe_deferred_to_future(mwman.open_spider(spider)) with ( pytest.warns( ScrapyDeprecationWarning, @@ -514,12 +516,12 @@ class TestMiddlewareManagerSpider: RuntimeError, match="Different instances of Spider were passed" ), ): - mwman.close_spider(DefaultSpider()) + await maybe_deferred_to_future(mwman.close_spider(DefaultSpider())) with pytest.warns( ScrapyDeprecationWarning, match=r"ItemPipelineManager.close_spider\(\) is deprecated, use close_spider_async\(\) instead", ): - mwman.close_spider(spider) + await maybe_deferred_to_future(mwman.close_spider(spider)) @deferred_f_from_coro_f async def test_no_spider_arg_without_crawler(self) -> None: diff --git a/tests/test_utils_defer.py b/tests/test_utils_defer.py index df7f1a20e..be623c3fb 100644 --- a/tests/test_utils_defer.py +++ b/tests/test_utils_defer.py @@ -321,9 +321,11 @@ class TestDeferredFFromCoroF: yield self._assert_result(c_f) + @pytest.mark.only_asyncio @inlineCallbacks def test_coroutine_asyncio(self): async def c_f() -> int: + await asyncio.sleep(0.01) return 42 yield self._assert_result(c_f) From 3186ccf5d5ad3beaf9e6594dd0a9c9cee902eb38 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Mon, 2 Feb 2026 18:25:30 +0400 Subject: [PATCH 041/248] Reactorless tests 1: the test env (#6952) * Reactorless tests. * Updates after merging master. * Update a new import. * Updates after merging master. * Update testenv:no-reactor-pinned. * Foundations for the reactorless mode. * Fixes after merging master. * Updates after merging reactorless-base. * Add simple subprocess tests for reactorless AsyncCrawler*. * More reactorless tests. * Refactor AsyncCrawlerProcess.start(). * More checks. * Fix test_reactorless_import_hook. * More tests. * Call install_reactor() before asyncio.run(). * Cleanup. * Rephrase. * Fix a test. * Fixes. * Rephrasing. * Set TELNETCONSOLE_ENABLED=False in the reactorless mode. * Fix a new import. * Clarify --reactor marks. * Cleanup. * More docs/comments. * Update test_fallback_workflow(). * Update TestDeferredFFromCoroF. * Typing improvements. --- .github/workflows/tests-ubuntu.yml | 6 +++ .github/workflows/tests-windows.yml | 3 ++ conftest.py | 33 +++++++++---- pyproject.toml | 5 +- scrapy/utils/test.py | 32 +++++++++---- tests/test_addons.py | 36 +++++++------- tests/test_closespider.py | 3 +- tests/test_contracts.py | 2 +- tests/test_core_downloader.py | 3 +- tests/test_core_scraper.py | 2 +- tests/test_crawl.py | 5 +- tests/test_crawler.py | 9 ++-- tests/test_downloader_handler_twisted_ftp.py | 3 ++ .../test_downloader_handler_twisted_http10.py | 3 ++ .../test_downloader_handler_twisted_http11.py | 5 ++ .../test_downloader_handler_twisted_http2.py | 10 ++-- tests/test_downloader_handlers.py | 2 +- tests/test_downloader_handlers_http_base.py | 7 +-- tests/test_downloadermiddleware.py | 3 +- tests/test_downloadermiddleware_robotstxt.py | 7 +-- tests/test_downloaderslotssettings.py | 5 +- tests/test_engine.py | 4 +- tests/test_engine_loop.py | 3 +- tests/test_engine_stop_download_bytes.py | 2 +- tests/test_engine_stop_download_headers.py | 2 +- tests/test_extension_periodic_log.py | 4 ++ tests/test_extension_telnet.py | 2 +- tests/test_feedexport.py | 4 +- tests/test_http2_client_protocol.py | 9 ++-- tests/test_logformatter.py | 2 +- tests/test_logstats.py | 1 + tests/test_mail.py | 2 + tests/test_pipeline_crawl.py | 2 +- tests/test_pipeline_files.py | 3 +- tests/test_pipeline_media.py | 3 +- tests/test_pipelines.py | 10 ++-- tests/test_proxy_connect.py | 2 +- tests/test_request_attribute_binding.py | 2 +- tests/test_request_cb_kwargs.py | 2 +- tests/test_request_left.py | 3 +- tests/test_scheduler.py | 8 +++- tests/test_scheduler_base.py | 2 +- tests/test_signals.py | 3 +- tests/test_spider.py | 3 +- tests/test_spider_start.py | 3 +- tests/test_spidermiddleware.py | 3 +- tests/test_spidermiddleware_httperror.py | 2 +- tests/test_spidermiddleware_output_chain.py | 2 +- tests/test_spidermiddleware_process_start.py | 3 +- tests/test_spidermiddleware_start.py | 2 +- tests/test_stats.py | 2 +- tests/test_utils_asyncgen.py | 2 +- tests/test_utils_asyncio.py | 4 +- tests/test_utils_defer.py | 15 +++++- tests/test_utils_python.py | 3 +- tests/test_utils_reactor.py | 4 +- tests/test_utils_signal.py | 2 +- tests/test_webclient.py | 2 + tests/test_zz_resources.py | 1 + tests/utils/decorators.py | 47 +++++++++++++++++++ tox.ini | 11 +++++ 61 files changed, 256 insertions(+), 114 deletions(-) create mode 100644 tests/utils/decorators.py diff --git a/.github/workflows/tests-ubuntu.yml b/.github/workflows/tests-ubuntu.yml index 31ca6b2e9..585cdd307 100644 --- a/.github/workflows/tests-ubuntu.yml +++ b/.github/workflows/tests-ubuntu.yml @@ -32,6 +32,9 @@ jobs: - python-version: "3.13" env: TOXENV: default-reactor + - python-version: "3.13" + env: + TOXENV: no-reactor - python-version: pypy3.11 env: TOXENV: pypy3 @@ -43,6 +46,9 @@ jobs: - python-version: "3.10.19" env: TOXENV: default-reactor-pinned + - python-version: "3.10.19" + env: + TOXENV: no-reactor-pinned - python-version: pypy3.11 env: TOXENV: pypy3-pinned diff --git a/.github/workflows/tests-windows.yml b/.github/workflows/tests-windows.yml index 5f4b383fd..9b03b594d 100644 --- a/.github/workflows/tests-windows.yml +++ b/.github/workflows/tests-windows.yml @@ -32,6 +32,9 @@ jobs: - python-version: "3.13" env: TOXENV: default-reactor + - python-version: "3.13" + env: + TOXENV: no-reactor # pinned deps - python-version: "3.10.11" diff --git a/conftest.py b/conftest.py index 55b5a28c1..b17789b3a 100644 --- a/conftest.py +++ b/conftest.py @@ -8,6 +8,7 @@ import pytest from twisted.web.http import H2_ENABLED from scrapy.utils.reactor import set_asyncio_event_loop_policy +from scrapy.utils.reactorless import install_reactor_import_hook from tests.keys import generate_keys from tests.mockserver.http import MockServer @@ -25,10 +26,6 @@ collect_ignore = [ # not a test, but looks like a test "scrapy/utils/testproc.py", "scrapy/utils/testsite.py", - "tests/ftpserver.py", - "tests/mockserver.py", - "tests/pipelines.py", - "tests/spiders.py", # contains scripts to be run by tests/test_crawler.py::AsyncCrawlerProcessSubprocess *_py_files("tests/AsyncCrawlerProcess"), # contains scripts to be run by tests/test_crawler.py::AsyncCrawlerRunnerSubprocess @@ -56,6 +53,17 @@ if not H2_ENABLED: ) +def pytest_addoption(parser, pluginmanager): + if pluginmanager.hasplugin("twisted"): + return + # add the full choice set so that pytest doesn't complain about invalid choices in some cases + parser.addoption( + "--reactor", + default="none", + choices=["asyncio", "default", "none"], + ) + + @pytest.fixture(scope="session") def mockserver() -> Generator[MockServer]: with MockServer() as mockserver: @@ -72,17 +80,26 @@ def pytest_configure(config): # Needed on Windows to switch from proactor to selector for Twisted reactor compatibility. # If we decide to run tests with both, we will need to add a new option and check it here. set_asyncio_event_loop_policy() + elif config.getoption("--reactor") == "none": + install_reactor_import_hook() def pytest_runtest_setup(item): # Skip tests based on reactor markers reactor = item.config.getoption("--reactor") - if item.get_closest_marker("only_asyncio") and reactor != "asyncio": - pytest.skip("This test is only run with --reactor=asyncio") + if item.get_closest_marker("requires_reactor") and reactor == "none": + pytest.skip('This test is only run when the --reactor value is not "none"') - if item.get_closest_marker("only_not_asyncio") and reactor == "asyncio": - pytest.skip("This test is only run without --reactor=asyncio") + if item.get_closest_marker("only_asyncio") and reactor not in {"asyncio", "none"}: + pytest.skip( + 'This test is only run when the --reactor value is "asyncio" (default) or "none"' + ) + + if item.get_closest_marker("only_not_asyncio") and reactor in {"asyncio", "none"}: + pytest.skip( + 'This test is only run when the --reactor value is not "asyncio" (default) or "none"' + ) # Skip tests requiring optional dependencies optional_deps = [ diff --git a/pyproject.toml b/pyproject.toml index 7982b43b8..a2ace0ec7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -227,8 +227,9 @@ addopts = [ xfail_strict = true python_files = ["test_*.py", "test_*/__init__.py"] markers = [ - "only_asyncio: marks tests as only enabled when --reactor=asyncio is passed", - "only_not_asyncio: marks tests as only enabled when --reactor=asyncio is not passed", + "only_asyncio: marks tests that require the asyncio loop to be used", + "only_not_asyncio: marks tests that require the asyncio loop to not be used", + "requires_reactor: marks tests that require a reactor", "requires_uvloop: marks tests as only enabled when uvloop is known to be working", "requires_botocore: marks tests that need botocore (but not boto3)", "requires_boto3: marks tests that need botocore and boto3", diff --git a/scrapy/utils/test.py b/scrapy/utils/test.py index 4e0356350..7d81868a9 100644 --- a/scrapy/utils/test.py +++ b/scrapy/utils/test.py @@ -17,7 +17,7 @@ from unittest import mock from twisted.trial.unittest import SkipTest from twisted.web.client import Agent -from scrapy.crawler import CrawlerRunner +from scrapy.crawler import AsyncCrawlerRunner, CrawlerRunner, CrawlerRunnerBase from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.utils.boto import is_botocore_available from scrapy.utils.deprecate import create_deprecated_class @@ -116,16 +116,24 @@ def get_reactor_settings() -> dict[str, Any]: ``Crawler._apply_settings()`` checks that the installed reactor matches the settings, so tests that run the crawler in the current process may need to - pass a correct ``"TWISTED_REACTOR"`` setting value when creating it. + pass a correct :setting:`TWISTED_REACTOR` setting value when creating it. """ - if not is_reactor_installed(): - raise RuntimeError( - "get_reactor_settings() called without an installed reactor," - " you may need to install a reactor explicitly when running your tests." - ) settings: dict[str, Any] = {} - if not is_asyncio_reactor_installed(): - settings["TWISTED_REACTOR"] = None + if is_reactor_installed(): + if not is_asyncio_reactor_installed(): + settings["TWISTED_REACTOR"] = None + else: + # We are either running Scrapy tests for the reactorless mode, or + # running some 3rd-party library tests for the reactorless mode, or + # running some 3rd-party library tests without initializing a reactor + # properly. The first two cases are fine, but we cannot distinguish the + # last one from them. + settings["TWISTED_ENABLED"] = False + settings["DOWNLOAD_HANDLERS"] = { + "ftp": None, + "http": None, + "https": None, + } return settings @@ -144,7 +152,11 @@ def get_crawler( **get_reactor_settings(), **(settings_dict or {}), } - runner = CrawlerRunner(settings) + runner: CrawlerRunnerBase + if is_reactor_installed(): + runner = CrawlerRunner(settings) + else: + runner = AsyncCrawlerRunner(settings) crawler = runner.create_crawler(spidercls or DefaultSpider) crawler._apply_settings() return crawler diff --git a/tests/test_addons.py b/tests/test_addons.py index 457945ea5..fd673b2a5 100644 --- a/tests/test_addons.py +++ b/tests/test_addons.py @@ -2,13 +2,12 @@ import itertools from typing import Any from unittest.mock import patch -from twisted.internet.defer import inlineCallbacks - from scrapy import Spider -from scrapy.crawler import Crawler, CrawlerRunner +from scrapy.crawler import AsyncCrawlerRunner, Crawler, CrawlerRunner from scrapy.exceptions import NotConfigured from scrapy.settings import BaseSettings, Settings from scrapy.utils.test import get_crawler, get_reactor_settings +from tests.utils.decorators import inlineCallbacks class SimpleAddon: @@ -109,9 +108,15 @@ class TestAddonManager: crawler = get_crawler(settings_dict=settings_dict) assert crawler.settings.getint("KEY") == 15 + runner_cls = ( + CrawlerRunner + if settings_dict.get("TWISTED_ENABLED", True) + else AsyncCrawlerRunner + ) + settings = Settings(settings_dict) settings.set("KEY", 0, priority="default") - runner = CrawlerRunner(settings) + runner = runner_cls(settings) crawler = runner.create_crawler(Spider) crawler._apply_settings() assert crawler.settings.getint("KEY") == 15 @@ -123,44 +128,39 @@ class TestAddonManager: } settings = Settings(settings_dict) settings.set("KEY", 0, priority="default") - runner = CrawlerRunner(settings) + runner = runner_cls(settings) crawler = runner.create_crawler(Spider) assert crawler.settings.getint("KEY") == 20 def test_fallback_workflow(self): - FALLBACK_SETTING = "MY_FALLBACK_DOWNLOAD_HANDLER" + FALLBACK_SETTING = "MY_FALLBACK_SCHEDULER" class AddonWithFallback: def update_settings(self, settings): if not settings.get(FALLBACK_SETTING): settings.set( FALLBACK_SETTING, - settings.getwithbase("DOWNLOAD_HANDLERS")["https"], + settings.get("SCHEDULER"), "addon", ) - settings["DOWNLOAD_HANDLERS"]["https"] = "AddonHandler" + settings["SCHEDULER"] = "AddonScheduler" settings_dict = { "ADDONS": {AddonWithFallback: 1}, } crawler = get_crawler(settings_dict=settings_dict) + assert crawler.settings.get("SCHEDULER") == "AddonScheduler" assert ( - crawler.settings.getwithbase("DOWNLOAD_HANDLERS")["https"] == "AddonHandler" - ) - assert ( - crawler.settings.get(FALLBACK_SETTING) - == "scrapy.core.downloader.handlers.http11.HTTP11DownloadHandler" + crawler.settings.get(FALLBACK_SETTING) == "scrapy.core.scheduler.Scheduler" ) settings_dict = { "ADDONS": {AddonWithFallback: 1}, - "DOWNLOAD_HANDLERS": {"https": "UserHandler"}, + "SCHEDULER": "UserScheduler", } crawler = get_crawler(settings_dict=settings_dict) - assert ( - crawler.settings.getwithbase("DOWNLOAD_HANDLERS")["https"] == "AddonHandler" - ) - assert crawler.settings.get(FALLBACK_SETTING) == "UserHandler" + assert crawler.settings.get("SCHEDULER") == "AddonScheduler" + assert crawler.settings.get(FALLBACK_SETTING) == "UserScheduler" def test_logging_message(self): class LoggedAddon: diff --git a/tests/test_closespider.py b/tests/test_closespider.py index 2243cd371..623ed89c8 100644 --- a/tests/test_closespider.py +++ b/tests/test_closespider.py @@ -1,5 +1,3 @@ -from twisted.internet.defer import inlineCallbacks - from scrapy.utils.test import get_crawler from tests.mockserver.http import MockServer from tests.spiders import ( @@ -9,6 +7,7 @@ from tests.spiders import ( MaxItemsAndRequestsSpider, SlowSpider, ) +from tests.utils.decorators import inlineCallbacks class TestCloseSpider: diff --git a/tests/test_contracts.py b/tests/test_contracts.py index 326be570e..505960685 100644 --- a/tests/test_contracts.py +++ b/tests/test_contracts.py @@ -1,7 +1,6 @@ from unittest import TextTestResult import pytest -from twisted.internet.defer import inlineCallbacks from twisted.python import failure from scrapy import FormRequest @@ -19,6 +18,7 @@ from scrapy.spidermiddlewares.httperror import HttpError from scrapy.spiders import Spider from scrapy.utils.test import get_crawler from tests.mockserver.http import MockServer +from tests.utils.decorators import inlineCallbacks class DemoItem(Item): diff --git a/tests/test_core_downloader.py b/tests/test_core_downloader.py index a35bfd590..d7940842a 100644 --- a/tests/test_core_downloader.py +++ b/tests/test_core_downloader.py @@ -18,13 +18,14 @@ from scrapy.core.downloader.contextfactory import ( from scrapy.core.downloader.handlers.http11 import _RequestBodyProducer from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.settings import Settings -from scrapy.utils.defer import deferred_f_from_coro_f, maybe_deferred_to_future +from scrapy.utils.defer import maybe_deferred_to_future from scrapy.utils.misc import build_from_crawler from scrapy.utils.python import to_bytes from scrapy.utils.spider import DefaultSpider from scrapy.utils.test import get_crawler from tests.mockserver.http_resources import PayloadResource from tests.mockserver.utils import ssl_context_factory +from tests.utils.decorators import deferred_f_from_coro_f if TYPE_CHECKING: from twisted.internet.defer import Deferred diff --git a/tests/test_core_scraper.py b/tests/test_core_scraper.py index 47dee1271..3986656f0 100644 --- a/tests/test_core_scraper.py +++ b/tests/test_core_scraper.py @@ -2,9 +2,9 @@ from __future__ import annotations from typing import TYPE_CHECKING -from scrapy.utils.defer import deferred_f_from_coro_f from scrapy.utils.test import get_crawler from tests.spiders import SimpleSpider +from tests.utils.decorators import deferred_f_from_coro_f if TYPE_CHECKING: import pytest diff --git a/tests/test_crawl.py b/tests/test_crawl.py index 1d64c2f39..c88526eea 100644 --- a/tests/test_crawl.py +++ b/tests/test_crawl.py @@ -9,7 +9,7 @@ from urllib.parse import urlencode, urlparse import pytest from testfixtures import LogCapture -from twisted.internet.defer import inlineCallbacks, succeed +from twisted.internet.defer import succeed from twisted.internet.ssl import Certificate from twisted.python.failure import Failure @@ -18,7 +18,7 @@ from scrapy.crawler import CrawlerRunner from scrapy.exceptions import CloseSpider, ScrapyDeprecationWarning, StopDownload from scrapy.http import Request from scrapy.http.response import Response -from scrapy.utils.defer import deferred_f_from_coro_f, maybe_deferred_to_future +from scrapy.utils.defer import maybe_deferred_to_future from scrapy.utils.engine import format_engine_status, get_engine_status from scrapy.utils.python import to_unicode from scrapy.utils.test import get_crawler, get_reactor_settings @@ -55,6 +55,7 @@ from tests.spiders import ( StartGoodAndBadOutput, StartItemSpider, ) +from tests.utils.decorators import deferred_f_from_coro_f, inlineCallbacks if TYPE_CHECKING: from scrapy.statscollectors import StatsCollector diff --git a/tests/test_crawler.py b/tests/test_crawler.py index 6d0c82825..9235ef885 100644 --- a/tests/test_crawler.py +++ b/tests/test_crawler.py @@ -14,7 +14,7 @@ from typing import Any import pytest from packaging.version import parse as parse_version from pexpect.popen_spawn import PopenSpawn -from twisted.internet.defer import Deferred, inlineCallbacks +from twisted.internet.defer import Deferred from w3lib import __version__ as w3lib_version from zope.interface.exceptions import MultipleInvalid @@ -31,11 +31,7 @@ from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.extensions.throttle import AutoThrottle from scrapy.settings import Settings, default_settings from scrapy.utils.asyncio import call_later -from scrapy.utils.defer import ( - deferred_f_from_coro_f, - deferred_from_coro, - maybe_deferred_to_future, -) +from scrapy.utils.defer import deferred_from_coro, maybe_deferred_to_future from scrapy.utils.log import ( _uninstall_scrapy_root_handler, configure_logging, @@ -45,6 +41,7 @@ from scrapy.utils.spider import DefaultSpider from scrapy.utils.test import get_crawler, get_reactor_settings from tests.mockserver.http import MockServer from tests.utils import get_script_run_env +from tests.utils.decorators import deferred_f_from_coro_f, inlineCallbacks BASE_SETTINGS: dict[str, Any] = {} diff --git a/tests/test_downloader_handler_twisted_ftp.py b/tests/test_downloader_handler_twisted_ftp.py index 6cda607cc..60d2dfd12 100644 --- a/tests/test_downloader_handler_twisted_ftp.py +++ b/tests/test_downloader_handler_twisted_ftp.py @@ -25,6 +25,9 @@ if TYPE_CHECKING: from twisted.protocols.ftp import FTPFactory +pytestmark = pytest.mark.requires_reactor + + class TestFTPBase(ABC): username = "scrapy" password = "passwd" diff --git a/tests/test_downloader_handler_twisted_http10.py b/tests/test_downloader_handler_twisted_http10.py index 939e2c8ad..99f3697f8 100644 --- a/tests/test_downloader_handler_twisted_http10.py +++ b/tests/test_downloader_handler_twisted_http10.py @@ -16,6 +16,9 @@ if TYPE_CHECKING: from tests.mockserver.http import MockServer +pytestmark = pytest.mark.requires_reactor + + class HTTP10DownloadHandlerMixin: @property def download_handler_cls(self) -> type[DownloadHandlerProtocol]: diff --git a/tests/test_downloader_handler_twisted_http11.py b/tests/test_downloader_handler_twisted_http11.py index eb2735c4d..ffaa2c569 100644 --- a/tests/test_downloader_handler_twisted_http11.py +++ b/tests/test_downloader_handler_twisted_http11.py @@ -4,6 +4,8 @@ from __future__ import annotations from typing import TYPE_CHECKING, Any +import pytest + from scrapy.core.downloader.handlers.http11 import HTTP11DownloadHandler from tests.test_downloader_handlers_http_base import ( TestHttp11Base, @@ -21,6 +23,9 @@ if TYPE_CHECKING: from scrapy.core.downloader.handlers import DownloadHandlerProtocol +pytestmark = pytest.mark.requires_reactor + + class HTTP11DownloadHandlerMixin: @property def download_handler_cls(self) -> type[DownloadHandlerProtocol]: diff --git a/tests/test_downloader_handler_twisted_http2.py b/tests/test_downloader_handler_twisted_http2.py index 5ae35a461..22eb56a94 100644 --- a/tests/test_downloader_handler_twisted_http2.py +++ b/tests/test_downloader_handler_twisted_http2.py @@ -27,9 +27,13 @@ if TYPE_CHECKING: from tests.mockserver.http import MockServer from tests.mockserver.proxy_echo import ProxyEchoMockServer -pytestmark = pytest.mark.skipif( - not H2_ENABLED, reason="HTTP/2 support in Twisted is not enabled" -) + +pytestmark = [ + pytest.mark.requires_reactor, + pytest.mark.skipif( + not H2_ENABLED, reason="HTTP/2 support in Twisted is not enabled" + ), +] class H2DownloadHandlerMixin: diff --git a/tests/test_downloader_handlers.py b/tests/test_downloader_handlers.py index 602b2186f..7844415f5 100644 --- a/tests/test_downloader_handlers.py +++ b/tests/test_downloader_handlers.py @@ -19,9 +19,9 @@ from scrapy.exceptions import NotConfigured, ScrapyDeprecationWarning from scrapy.http import Request from scrapy.responsetypes import responsetypes from scrapy.utils.boto import is_botocore_available -from scrapy.utils.defer import deferred_f_from_coro_f from scrapy.utils.misc import build_from_crawler from scrapy.utils.test import get_crawler +from tests.utils.decorators import deferred_f_from_coro_f class DummyDH: diff --git a/tests/test_downloader_handlers_http_base.py b/tests/test_downloader_handlers_http_base.py index 7ad787346..4d4b4ecaa 100644 --- a/tests/test_downloader_handlers_http_base.py +++ b/tests/test_downloader_handlers_http_base.py @@ -26,11 +26,7 @@ from scrapy.exceptions import ( UnsupportedURLSchemeError, ) from scrapy.http import Headers, HtmlResponse, Request, Response, TextResponse -from scrapy.utils.defer import ( - deferred_f_from_coro_f, - deferred_from_coro, - maybe_deferred_to_future, -) +from scrapy.utils.defer import deferred_from_coro, maybe_deferred_to_future from scrapy.utils.misc import build_from_crawler from scrapy.utils.spider import DefaultSpider from scrapy.utils.test import get_crawler @@ -38,6 +34,7 @@ from tests import NON_EXISTING_RESOLVABLE from tests.mockserver.proxy_echo import ProxyEchoMockServer from tests.mockserver.simple_https import SimpleMockServer from tests.spiders import SingleRequestSpider +from tests.utils.decorators import deferred_f_from_coro_f if TYPE_CHECKING: from collections.abc import AsyncGenerator, Generator diff --git a/tests/test_downloadermiddleware.py b/tests/test_downloadermiddleware.py index bc1748dfb..10ac77aa0 100644 --- a/tests/test_downloadermiddleware.py +++ b/tests/test_downloadermiddleware.py @@ -13,9 +13,10 @@ from scrapy.core.downloader.middleware import DownloaderMiddlewareManager from scrapy.exceptions import ScrapyDeprecationWarning, _InvalidOutput from scrapy.http import Request, Response from scrapy.spiders import Spider -from scrapy.utils.defer import deferred_f_from_coro_f, maybe_deferred_to_future +from scrapy.utils.defer import maybe_deferred_to_future from scrapy.utils.python import to_bytes from scrapy.utils.test import get_crawler, get_from_asyncio_queue +from tests.utils.decorators import deferred_f_from_coro_f if TYPE_CHECKING: from collections.abc import AsyncGenerator diff --git a/tests/test_downloadermiddleware_robotstxt.py b/tests/test_downloadermiddleware_robotstxt.py index 1e99d5162..9b4b2750b 100644 --- a/tests/test_downloadermiddleware_robotstxt.py +++ b/tests/test_downloadermiddleware_robotstxt.py @@ -14,12 +14,9 @@ from scrapy.http import Request, Response, TextResponse from scrapy.http.request import NO_CALLBACK from scrapy.settings import Settings from scrapy.utils.asyncio import call_later -from scrapy.utils.defer import ( - deferred_f_from_coro_f, - deferred_from_coro, - maybe_deferred_to_future, -) +from scrapy.utils.defer import deferred_from_coro, maybe_deferred_to_future from tests.test_robotstxt_interface import rerp_available +from tests.utils.decorators import deferred_f_from_coro_f if TYPE_CHECKING: from scrapy.crawler import Crawler diff --git a/tests/test_downloaderslotssettings.py b/tests/test_downloaderslotssettings.py index dffe95406..66dafd9b6 100644 --- a/tests/test_downloaderslotssettings.py +++ b/tests/test_downloaderslotssettings.py @@ -2,17 +2,16 @@ import time from typing import Any import pytest -from twisted.internet.defer import inlineCallbacks from scrapy import Request from scrapy.core.downloader import Downloader, Slot from scrapy.crawler import CrawlerRunner from scrapy.exceptions import ScrapyDeprecationWarning -from scrapy.utils.defer import deferred_f_from_coro_f from scrapy.utils.spider import DefaultSpider from scrapy.utils.test import get_crawler from tests.mockserver.http import MockServer from tests.spiders import MetaSpider +from tests.utils.decorators import deferred_f_from_coro_f, inlineCallbacks class DownloaderSlotsSettingsTestSpider(MetaSpider): @@ -85,6 +84,7 @@ class TestCrawl: assert max(list(error_delta.values())) < tolerance +@pytest.mark.requires_reactor # needs a reactor or an event loop for Downloader._slot_gc_loop def test_params(): params = { "concurrency": 1, @@ -109,6 +109,7 @@ def test_params(): ) +@pytest.mark.requires_reactor # needs a reactor or an event loop for Downloader._slot_gc_loop def test_get_slot_deprecated_spider_arg(): crawler = get_crawler(DefaultSpider) crawler.spider = crawler._create_spider() diff --git a/tests/test_engine.py b/tests/test_engine.py index d3485e344..d4f9fd794 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -17,7 +17,6 @@ from itemadapter import ItemAdapter from pydispatch import dispatcher from testfixtures import LogCapture from twisted.internet import defer -from twisted.internet.defer import inlineCallbacks from scrapy import signals from scrapy.core.engine import ExecutionEngine, _Slot @@ -29,7 +28,6 @@ from scrapy.linkextractors import LinkExtractor from scrapy.spiders import Spider from scrapy.utils.defer import ( _schedule_coro, - deferred_f_from_coro_f, deferred_from_coro, maybe_deferred_to_future, ) @@ -37,6 +35,7 @@ from scrapy.utils.signal import disconnect_all from scrapy.utils.spider import DefaultSpider from scrapy.utils.test import get_crawler from tests import get_testdata +from tests.utils.decorators import deferred_f_from_coro_f, inlineCallbacks if TYPE_CHECKING: from scrapy.core.scheduler import Scheduler @@ -599,6 +598,7 @@ class TestEngineDownload(TestEngineDownloadAsync): return await maybe_deferred_to_future(engine.download(request)) +@pytest.mark.requires_reactor # needs a reactor or an event loop for _Slot.heartbeat def test_request_scheduled_signal(caplog): class TestScheduler(BaseScheduler): def __init__(self): diff --git a/tests/test_engine_loop.py b/tests/test_engine_loop.py index c219a0f6f..f9cd437f7 100644 --- a/tests/test_engine_loop.py +++ b/tests/test_engine_loop.py @@ -7,10 +7,11 @@ from typing import TYPE_CHECKING from twisted.internet.defer import Deferred from scrapy import Request, Spider, signals -from scrapy.utils.defer import deferred_f_from_coro_f, maybe_deferred_to_future +from scrapy.utils.defer import maybe_deferred_to_future from scrapy.utils.test import get_crawler from tests.mockserver.http import MockServer from tests.test_scheduler import MemoryScheduler +from tests.utils.decorators import deferred_f_from_coro_f if TYPE_CHECKING: import pytest diff --git a/tests/test_engine_stop_download_bytes.py b/tests/test_engine_stop_download_bytes.py index b46ebe2d9..e330adb3f 100644 --- a/tests/test_engine_stop_download_bytes.py +++ b/tests/test_engine_stop_download_bytes.py @@ -3,7 +3,6 @@ from __future__ import annotations from typing import TYPE_CHECKING from scrapy.exceptions import StopDownload -from scrapy.utils.defer import deferred_f_from_coro_f from tests.test_engine import ( AttrsItemsSpider, CrawlerRun, @@ -12,6 +11,7 @@ from tests.test_engine import ( MySpider, TestEngineBase, ) +from tests.utils.decorators import deferred_f_from_coro_f if TYPE_CHECKING: import pytest diff --git a/tests/test_engine_stop_download_headers.py b/tests/test_engine_stop_download_headers.py index 76cfbe8ed..f46f46bd7 100644 --- a/tests/test_engine_stop_download_headers.py +++ b/tests/test_engine_stop_download_headers.py @@ -3,7 +3,6 @@ from __future__ import annotations from typing import TYPE_CHECKING from scrapy.exceptions import StopDownload -from scrapy.utils.defer import deferred_f_from_coro_f from tests.test_engine import ( AttrsItemsSpider, CrawlerRun, @@ -12,6 +11,7 @@ from tests.test_engine import ( MySpider, TestEngineBase, ) +from tests.utils.decorators import deferred_f_from_coro_f if TYPE_CHECKING: import pytest diff --git a/tests/test_extension_periodic_log.py b/tests/test_extension_periodic_log.py index 8d6c22ad7..4f368d5ec 100644 --- a/tests/test_extension_periodic_log.py +++ b/tests/test_extension_periodic_log.py @@ -3,6 +3,8 @@ from __future__ import annotations import datetime from typing import TYPE_CHECKING, Any +import pytest + from scrapy.extensions.periodic_log import PeriodicLog from scrapy.utils.test import get_crawler @@ -86,6 +88,7 @@ class TestPeriodicLog: assert extension({"PERIODIC_LOG_DELTA": True, "LOGSTATS_INTERVAL": 60}) assert extension({"PERIODIC_LOG_DELTA": "True", "LOGSTATS_INTERVAL": 60}) + @pytest.mark.requires_reactor # needs a reactor or an event loop for PeriodicLog.task def test_log_delta(self): def emulate(settings=None): spider = MetaSpider() @@ -143,6 +146,7 @@ class TestPeriodicLog: and ("downloader/" in k and "bytes" not in k), ) + @pytest.mark.requires_reactor # needs a reactor or an event loop for PeriodicLog.task def test_log_stats(self): def emulate(settings=None): spider = MetaSpider() diff --git a/tests/test_extension_telnet.py b/tests/test_extension_telnet.py index 6b4ad450f..15e5aacce 100644 --- a/tests/test_extension_telnet.py +++ b/tests/test_extension_telnet.py @@ -1,10 +1,10 @@ import pytest from twisted.conch.telnet import ITelnetProtocol from twisted.cred import credentials -from twisted.internet.defer import inlineCallbacks from scrapy.extensions.telnet import TelnetConsole from scrapy.utils.test import get_crawler +from tests.utils.decorators import inlineCallbacks class TestTelnetExtension: diff --git a/tests/test_feedexport.py b/tests/test_feedexport.py index a9d608e52..914d9a950 100644 --- a/tests/test_feedexport.py +++ b/tests/test_feedexport.py @@ -29,7 +29,6 @@ import lxml.etree import pytest from packaging.version import Version from testfixtures import LogCapture -from twisted.internet.defer import inlineCallbacks from w3lib.url import file_uri_to_path, path_to_file_uri from zope.interface import implementer from zope.interface.verify import verifyObject @@ -50,12 +49,13 @@ from scrapy.extensions.feedexport import ( StdoutFeedStorage, ) from scrapy.settings import Settings -from scrapy.utils.defer import deferred_f_from_coro_f, maybe_deferred_to_future +from scrapy.utils.defer import maybe_deferred_to_future from scrapy.utils.python import to_unicode from scrapy.utils.test import get_crawler from tests.mockserver.ftp import MockFTPServer from tests.mockserver.http import MockServer from tests.spiders import ItemSpider +from tests.utils.decorators import deferred_f_from_coro_f, inlineCallbacks if TYPE_CHECKING: from collections.abc import Callable, Iterable diff --git a/tests/test_http2_client_protocol.py b/tests/test_http2_client_protocol.py index 46ef75064..944a67920 100644 --- a/tests/test_http2_client_protocol.py +++ b/tests/test_http2_client_protocol.py @@ -39,9 +39,12 @@ if TYPE_CHECKING: from scrapy.core.http2.protocol import H2ClientProtocol -pytestmark = pytest.mark.skipif( - not H2_ENABLED, reason="HTTP/2 support in Twisted is not enabled" -) +pytestmark = [ + pytest.mark.requires_reactor, + pytest.mark.skipif( + not H2_ENABLED, reason="HTTP/2 support in Twisted is not enabled" + ), +] def generate_random_string(size: int) -> str: diff --git a/tests/test_logformatter.py b/tests/test_logformatter.py index 426be13a7..5cd816d76 100644 --- a/tests/test_logformatter.py +++ b/tests/test_logformatter.py @@ -2,7 +2,6 @@ import logging import pytest from testfixtures import LogCapture -from twisted.internet.defer import inlineCallbacks from twisted.python.failure import Failure from scrapy.exceptions import DropItem @@ -13,6 +12,7 @@ from scrapy.spiders import Spider from scrapy.utils.test import get_crawler from tests.mockserver.http import MockServer from tests.spiders import ItemSpider +from tests.utils.decorators import inlineCallbacks class CustomItem(Item): diff --git a/tests/test_logstats.py b/tests/test_logstats.py index d25bc0c93..c2e6d3456 100644 --- a/tests/test_logstats.py +++ b/tests/test_logstats.py @@ -16,6 +16,7 @@ class TestLogStats: self.stats.set_value("response_received_count", 4802) self.stats.set_value("item_scraped_count", 3201) + @pytest.mark.requires_reactor # needs a reactor or an event loop for LogStats.task def test_stats_calculations(self): logstats = LogStats.from_crawler(self.crawler) diff --git a/tests/test_mail.py b/tests/test_mail.py index 4e3523208..c845ba320 100644 --- a/tests/test_mail.py +++ b/tests/test_mail.py @@ -1,12 +1,14 @@ from email.charset import Charset from io import BytesIO +import pytest from twisted.internet import defer from twisted.internet._sslverify import ClientTLSOptions from scrapy.mail import MailSender +@pytest.mark.requires_reactor class TestMailSender: def test_send(self): mailsender = MailSender(debug=True) diff --git a/tests/test_pipeline_crawl.py b/tests/test_pipeline_crawl.py index 494833fe1..f29bc90e8 100644 --- a/tests/test_pipeline_crawl.py +++ b/tests/test_pipeline_crawl.py @@ -7,7 +7,6 @@ from typing import TYPE_CHECKING, Any import pytest from testfixtures import LogCapture -from twisted.internet.defer import inlineCallbacks from w3lib.url import add_or_replace_parameter from scrapy import Spider, signals @@ -15,6 +14,7 @@ from scrapy.utils.misc import load_object from scrapy.utils.test import get_crawler from tests.mockserver.http import MockServer from tests.spiders import SimpleSpider +from tests.utils.decorators import inlineCallbacks if TYPE_CHECKING: from scrapy.crawler import Crawler diff --git a/tests/test_pipeline_files.py b/tests/test_pipeline_files.py index 6ab5d9a8e..18fa6cdf2 100644 --- a/tests/test_pipeline_files.py +++ b/tests/test_pipeline_files.py @@ -19,7 +19,6 @@ from urllib.parse import urlparse import attr import pytest from itemadapter import ItemAdapter -from twisted.internet.defer import inlineCallbacks from scrapy.exceptions import NotConfigured from scrapy.http import Request, Response @@ -32,10 +31,10 @@ from scrapy.pipelines.files import ( S3FilesStore, ) from scrapy.settings import Settings -from scrapy.utils.defer import deferred_f_from_coro_f from scrapy.utils.spider import DefaultSpider from scrapy.utils.test import get_crawler from tests.mockserver.ftp import MockFTPServer +from tests.utils.decorators import deferred_f_from_coro_f, inlineCallbacks from .test_pipeline_media import _mocked_download_func diff --git a/tests/test_pipeline_media.py b/tests/test_pipeline_media.py index 05ad43d16..37e87f1a9 100644 --- a/tests/test_pipeline_media.py +++ b/tests/test_pipeline_media.py @@ -13,11 +13,12 @@ from scrapy.http import Request, Response from scrapy.http.request import NO_CALLBACK from scrapy.pipelines.files import FileException from scrapy.pipelines.media import MediaPipeline -from scrapy.utils.defer import _defer_sleep_async, deferred_f_from_coro_f +from scrapy.utils.defer import _defer_sleep_async from scrapy.utils.log import failure_to_exc_info from scrapy.utils.signal import disconnect_all from scrapy.utils.spider import DefaultSpider from scrapy.utils.test import get_crawler +from tests.utils.decorators import deferred_f_from_coro_f async def _mocked_download_func(request): diff --git a/tests/test_pipelines.py b/tests/test_pipelines.py index acf8dd412..5bdca3035 100644 --- a/tests/test_pipelines.py +++ b/tests/test_pipelines.py @@ -10,14 +10,11 @@ from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.pipelines import ItemPipelineManager from scrapy.utils.asyncio import call_later from scrapy.utils.conf import build_component_list -from scrapy.utils.defer import ( - deferred_f_from_coro_f, - deferred_to_future, - maybe_deferred_to_future, -) +from scrapy.utils.defer import deferred_to_future, maybe_deferred_to_future from scrapy.utils.spider import DefaultSpider from scrapy.utils.test import get_crawler, get_from_asyncio_queue from tests.mockserver.http import MockServer +from tests.utils.decorators import deferred_f_from_coro_f class SimplePipeline: @@ -251,6 +248,9 @@ class TestPipeline: class TestCustomPipelineManager: + # needs a reactor or an event loop for is_asyncio_available() + # (for ItemPipelineManager.process_item()) + @pytest.mark.requires_reactor def test_deprecated_process_item_spider_arg(self) -> None: class CustomPipelineManager(ItemPipelineManager): def process_item(self, item, spider): # pylint: disable=useless-parent-delegation diff --git a/tests/test_proxy_connect.py b/tests/test_proxy_connect.py index 27a78f802..4071224d6 100644 --- a/tests/test_proxy_connect.py +++ b/tests/test_proxy_connect.py @@ -8,12 +8,12 @@ from urllib.parse import urlsplit, urlunsplit import pytest from testfixtures import LogCapture -from twisted.internet.defer import inlineCallbacks from scrapy.http import Request from scrapy.utils.test import get_crawler from tests.mockserver.http import MockServer from tests.spiders import SimpleSpider, SingleRequestSpider +from tests.utils.decorators import inlineCallbacks class MitmProxy: diff --git a/tests/test_request_attribute_binding.py b/tests/test_request_attribute_binding.py index e2c1668d8..4065a5a2c 100644 --- a/tests/test_request_attribute_binding.py +++ b/tests/test_request_attribute_binding.py @@ -1,11 +1,11 @@ from testfixtures import LogCapture -from twisted.internet.defer import inlineCallbacks from scrapy import Request, signals from scrapy.http.response import Response from scrapy.utils.test import get_crawler from tests.mockserver.http import MockServer from tests.spiders import SingleRequestSpider +from tests.utils.decorators import inlineCallbacks OVERRIDDEN_URL = "https://example.org" diff --git a/tests/test_request_cb_kwargs.py b/tests/test_request_cb_kwargs.py index 67ad4ba1e..56d7264da 100644 --- a/tests/test_request_cb_kwargs.py +++ b/tests/test_request_cb_kwargs.py @@ -1,10 +1,10 @@ from testfixtures import LogCapture -from twisted.internet.defer import inlineCallbacks from scrapy.http import Request from scrapy.utils.test import get_crawler from tests.mockserver.http import MockServer from tests.spiders import MockServerSpider +from tests.utils.decorators import inlineCallbacks class InjectArgumentsDownloaderMiddleware: diff --git a/tests/test_request_left.py b/tests/test_request_left.py index 721bfc76e..a5d5efffa 100644 --- a/tests/test_request_left.py +++ b/tests/test_request_left.py @@ -1,9 +1,8 @@ -from twisted.internet.defer import inlineCallbacks - from scrapy.signals import request_left_downloader from scrapy.spiders import Spider from scrapy.utils.test import get_crawler from tests.mockserver.http import MockServer +from tests.utils.decorators import inlineCallbacks class SignalCatcherSpider(Spider): diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index ef1da9b3a..8730489f7 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -8,7 +8,6 @@ from collections import deque from typing import Any, NamedTuple import pytest -from twisted.internet.defer import inlineCallbacks from scrapy.core.downloader import Downloader from scrapy.core.scheduler import BaseScheduler, Scheduler @@ -20,6 +19,7 @@ from scrapy.utils.httpobj import urlparse_cached from scrapy.utils.misc import load_object from scrapy.utils.test import get_crawler from tests.mockserver.http import MockServer +from tests.utils.decorators import inlineCallbacks class MemoryScheduler(BaseScheduler): @@ -99,6 +99,9 @@ class MockCrawler(Crawler): self.stats = load_object(self.settings["STATS_CLASS"])(self) +# needs a reactor or an event loop for is_asyncio_available() +# (for _schedule_coro()) +@pytest.mark.requires_reactor class SchedulerHandler(ABC): jobdir = None @@ -243,6 +246,9 @@ _URLS_WITH_SLOTS = [ class TestMigration: + # needs a reactor or an event loop for is_asyncio_available() + # (for _schedule_coro()) + @pytest.mark.requires_reactor def test_migration(self, tmpdir): class PrevSchedulerHandler(SchedulerHandler): jobdir = tmpdir diff --git a/tests/test_scheduler_base.py b/tests/test_scheduler_base.py index 5dd2de8d8..538464788 100644 --- a/tests/test_scheduler_base.py +++ b/tests/test_scheduler_base.py @@ -5,7 +5,6 @@ from urllib.parse import urljoin import pytest from testfixtures import LogCapture from twisted.internet import defer -from twisted.internet.defer import inlineCallbacks from scrapy.core.scheduler import BaseScheduler from scrapy.http import Request @@ -14,6 +13,7 @@ from scrapy.utils.httpobj import urlparse_cached from scrapy.utils.request import fingerprint from scrapy.utils.test import get_crawler from tests.mockserver.http import MockServer +from tests.utils.decorators import inlineCallbacks PATHS = ["/a", "/b", "/c"] URLS = [urljoin("https://example.org", p) for p in PATHS] diff --git a/tests/test_signals.py b/tests/test_signals.py index 89b7a74dc..048af00ab 100644 --- a/tests/test_signals.py +++ b/tests/test_signals.py @@ -1,10 +1,9 @@ import pytest -from twisted.internet.defer import inlineCallbacks from scrapy import Request, Spider, signals -from scrapy.utils.defer import deferred_f_from_coro_f from scrapy.utils.test import get_crawler, get_from_asyncio_queue from tests.mockserver.http import MockServer +from tests.utils.decorators import deferred_f_from_coro_f, inlineCallbacks class ItemSpider(Spider): diff --git a/tests/test_spider.py b/tests/test_spider.py index a1649aa6f..dd1cc15b0 100644 --- a/tests/test_spider.py +++ b/tests/test_spider.py @@ -12,7 +12,6 @@ from unittest import mock import pytest from testfixtures import LogCapture -from twisted.internet.defer import inlineCallbacks from w3lib.url import safe_url_string from scrapy import signals @@ -29,9 +28,9 @@ from scrapy.spiders import ( XMLFeedSpider, ) from scrapy.spiders.init import InitSpider -from scrapy.utils.defer import deferred_f_from_coro_f from scrapy.utils.test import get_crawler, get_reactor_settings from tests import get_testdata, tests_datadir +from tests.utils.decorators import deferred_f_from_coro_f, inlineCallbacks class TestSpider: diff --git a/tests/test_spider_start.py b/tests/test_spider_start.py index e608d8975..d6767d74c 100644 --- a/tests/test_spider_start.py +++ b/tests/test_spider_start.py @@ -9,10 +9,11 @@ from testfixtures import LogCapture from scrapy import Spider, signals from scrapy.exceptions import ScrapyDeprecationWarning -from scrapy.utils.defer import deferred_f_from_coro_f, maybe_deferred_to_future +from scrapy.utils.defer import maybe_deferred_to_future from scrapy.utils.test import get_crawler from .utils import twisted_sleep +from .utils.decorators import deferred_f_from_coro_f SLEEP_SECONDS = 0.1 diff --git a/tests/test_spidermiddleware.py b/tests/test_spidermiddleware.py index 3ac6d9af7..17d897526 100644 --- a/tests/test_spidermiddleware.py +++ b/tests/test_spidermiddleware.py @@ -15,9 +15,10 @@ from scrapy.http import Request, Response from scrapy.spiders import Spider from scrapy.utils.asyncgen import collect_asyncgen from scrapy.utils.asyncio import call_later -from scrapy.utils.defer import deferred_f_from_coro_f, maybe_deferred_to_future +from scrapy.utils.defer import maybe_deferred_to_future from scrapy.utils.spider import DefaultSpider from scrapy.utils.test import get_crawler +from tests.utils.decorators import deferred_f_from_coro_f if TYPE_CHECKING: from twisted.python.failure import Failure diff --git a/tests/test_spidermiddleware_httperror.py b/tests/test_spidermiddleware_httperror.py index 9899a0cd7..c0284c5ed 100644 --- a/tests/test_spidermiddleware_httperror.py +++ b/tests/test_spidermiddleware_httperror.py @@ -4,7 +4,6 @@ import logging import pytest from testfixtures import LogCapture -from twisted.internet.defer import inlineCallbacks from scrapy.http import Request, Response from scrapy.spidermiddlewares.httperror import HttpError, HttpErrorMiddleware @@ -12,6 +11,7 @@ from scrapy.utils.spider import DefaultSpider from scrapy.utils.test import get_crawler from tests.mockserver.http import MockServer from tests.spiders import MockServerSpider +from tests.utils.decorators import inlineCallbacks class _HttpErrorSpider(MockServerSpider): diff --git a/tests/test_spidermiddleware_output_chain.py b/tests/test_spidermiddleware_output_chain.py index 97d9d6470..b35a4c704 100644 --- a/tests/test_spidermiddleware_output_chain.py +++ b/tests/test_spidermiddleware_output_chain.py @@ -1,9 +1,9 @@ from testfixtures import LogCapture from scrapy import Request, Spider -from scrapy.utils.defer import deferred_f_from_coro_f from scrapy.utils.test import get_crawler from tests.mockserver.http import MockServer +from tests.utils.decorators import deferred_f_from_coro_f class _BaseSpiderMiddleware: diff --git a/tests/test_spidermiddleware_process_start.py b/tests/test_spidermiddleware_process_start.py index 18868bf8c..250b58544 100644 --- a/tests/test_spidermiddleware_process_start.py +++ b/tests/test_spidermiddleware_process_start.py @@ -5,11 +5,12 @@ import pytest from scrapy import Spider, signals from scrapy.exceptions import ScrapyDeprecationWarning -from scrapy.utils.defer import deferred_f_from_coro_f, maybe_deferred_to_future +from scrapy.utils.defer import maybe_deferred_to_future from scrapy.utils.test import get_crawler from tests.test_spider_start import SLEEP_SECONDS from .utils import twisted_sleep +from .utils.decorators import deferred_f_from_coro_f ITEM_A = {"id": "a"} ITEM_B = {"id": "b"} diff --git a/tests/test_spidermiddleware_start.py b/tests/test_spidermiddleware_start.py index c2efe47c9..1f2209f60 100644 --- a/tests/test_spidermiddleware_start.py +++ b/tests/test_spidermiddleware_start.py @@ -1,9 +1,9 @@ from scrapy.http import Request from scrapy.spidermiddlewares.start import StartSpiderMiddleware from scrapy.spiders import Spider -from scrapy.utils.defer import deferred_f_from_coro_f from scrapy.utils.misc import build_from_crawler from scrapy.utils.test import get_crawler +from tests.utils.decorators import deferred_f_from_coro_f class TestMiddleware: diff --git a/tests/test_stats.py b/tests/test_stats.py index b5ab06901..187d240a8 100644 --- a/tests/test_stats.py +++ b/tests/test_stats.py @@ -10,9 +10,9 @@ from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.extensions.corestats import CoreStats from scrapy.spiders import Spider from scrapy.statscollectors import DummyStatsCollector, StatsCollector -from scrapy.utils.defer import deferred_f_from_coro_f from scrapy.utils.test import get_crawler from tests.spiders import SimpleSpider +from tests.utils.decorators import deferred_f_from_coro_f if TYPE_CHECKING: from scrapy.crawler import Crawler diff --git a/tests/test_utils_asyncgen.py b/tests/test_utils_asyncgen.py index b2d2b04c7..dba1ac5da 100644 --- a/tests/test_utils_asyncgen.py +++ b/tests/test_utils_asyncgen.py @@ -1,5 +1,5 @@ from scrapy.utils.asyncgen import as_async_generator, collect_asyncgen -from scrapy.utils.defer import deferred_f_from_coro_f +from tests.utils.decorators import deferred_f_from_coro_f class TestAsyncgenUtils: diff --git a/tests/test_utils_asyncio.py b/tests/test_utils_asyncio.py index b6b77c9e3..6706c1c80 100644 --- a/tests/test_utils_asyncio.py +++ b/tests/test_utils_asyncio.py @@ -14,13 +14,14 @@ from scrapy.utils.asyncio import ( _parallel_asyncio, is_asyncio_available, ) -from scrapy.utils.defer import deferred_f_from_coro_f +from tests.utils.decorators import deferred_f_from_coro_f if TYPE_CHECKING: from collections.abc import AsyncGenerator class TestAsyncio: + @pytest.mark.requires_reactor # needs a reactor or an event loop for is_asyncio_available() def test_is_asyncio_available(self, reactor_pytest: str) -> None: # the result should depend only on the pytest --reactor argument assert is_asyncio_available() == (reactor_pytest == "asyncio") @@ -103,6 +104,7 @@ class TestParallelAsyncio: assert max_parallel_count[0] <= self.CONCURRENT_ITEMS +@pytest.mark.requires_reactor # needs a running event loop for AsyncioLoopingCall.start() @pytest.mark.only_asyncio class TestAsyncioLoopingCall: def test_looping_call(self): diff --git a/tests/test_utils_defer.py b/tests/test_utils_defer.py index be623c3fb..ad317f3ae 100644 --- a/tests/test_utils_defer.py +++ b/tests/test_utils_defer.py @@ -6,7 +6,8 @@ from asyncio import Future from typing import TYPE_CHECKING, Any import pytest -from twisted.internet.defer import Deferred, inlineCallbacks, succeed +from twisted.internet.defer import Deferred, succeed +from twisted.internet.defer import inlineCallbacks as inlineCallbacks_orig from scrapy.utils.asyncgen import as_async_generator, collect_asyncgen from scrapy.utils.defer import ( @@ -19,6 +20,7 @@ from scrapy.utils.defer import ( mustbe_deferred, parallel_async, ) +from tests.utils.decorators import inlineCallbacks if TYPE_CHECKING: from collections.abc import AsyncGenerator, Awaitable, Callable, Generator @@ -107,6 +109,7 @@ class TestIterErrback: assert isinstance(errors[0].value, ZeroDivisionError) +@pytest.mark.requires_reactor class TestAiterErrback: @deferred_f_from_coro_f async def test_aiter_errback_good(self): @@ -134,6 +137,7 @@ class TestAiterErrback: assert isinstance(errors[0].value, ZeroDivisionError) +@pytest.mark.requires_reactor class TestAsyncDefTestsuite: @deferred_f_from_coro_f async def test_deferred_f_from_coro_f(self): @@ -304,7 +308,7 @@ class TestDeferredFromCoro: class TestDeferredFFromCoroF: - @inlineCallbacks + @inlineCallbacks_orig def _assert_result( self, c_f: Callable[[], Awaitable[int]] ) -> Generator[Deferred[Any], Any, None]: @@ -342,6 +346,7 @@ class TestDeferredFFromCoroF: @pytest.mark.only_asyncio +@pytest.mark.requires_reactor class TestDeferredToFuture: @deferred_f_from_coro_f async def test_deferred(self): @@ -377,6 +382,9 @@ class TestDeferredToFuture: @pytest.mark.only_asyncio +# needs a reactor or an event loop for is_asyncio_available() +# (for maybe_deferred_to_future()) +@pytest.mark.requires_reactor class TestMaybeDeferredToFutureAsyncio: @deferred_f_from_coro_f async def test_deferred(self): @@ -412,6 +420,9 @@ class TestMaybeDeferredToFutureAsyncio: @pytest.mark.only_not_asyncio +# needs a reactor or an event loop for is_asyncio_available() +# (for maybe_deferred_to_future()) +@pytest.mark.requires_reactor class TestMaybeDeferredToFutureNotAsyncio: def test_deferred(self): d = Deferred() diff --git a/tests/test_utils_python.py b/tests/test_utils_python.py index 34fca06c1..4a9e375fb 100644 --- a/tests/test_utils_python.py +++ b/tests/test_utils_python.py @@ -9,7 +9,7 @@ from typing import TYPE_CHECKING, TypeVar import pytest from scrapy.utils.asyncgen import as_async_generator, collect_asyncgen -from scrapy.utils.defer import aiter_errback, deferred_f_from_coro_f +from scrapy.utils.defer import aiter_errback from scrapy.utils.python import ( MutableAsyncChain, MutableChain, @@ -20,6 +20,7 @@ from scrapy.utils.python import ( to_unicode, without_none_values, ) +from tests.utils.decorators import deferred_f_from_coro_f if TYPE_CHECKING: from collections.abc import Iterable, Mapping diff --git a/tests/test_utils_reactor.py b/tests/test_utils_reactor.py index 6cbdcfccc..658e68016 100644 --- a/tests/test_utils_reactor.py +++ b/tests/test_utils_reactor.py @@ -3,20 +3,22 @@ import warnings import pytest -from scrapy.utils.defer import deferred_f_from_coro_f from scrapy.utils.reactor import ( _asyncio_reactor_path, install_reactor, is_asyncio_reactor_installed, set_asyncio_event_loop, ) +from tests.utils.decorators import deferred_f_from_coro_f class TestAsyncio: + @pytest.mark.requires_reactor def test_is_asyncio_reactor_installed(self, reactor_pytest: str) -> None: # the result should depend only on the pytest --reactor argument assert is_asyncio_reactor_installed() == (reactor_pytest == "asyncio") + @pytest.mark.requires_reactor def test_install_asyncio_reactor(self): from twisted.internet import reactor as original_reactor diff --git a/tests/test_utils_signal.py b/tests/test_utils_signal.py index 51e496575..e47e9013a 100644 --- a/tests/test_utils_signal.py +++ b/tests/test_utils_signal.py @@ -4,7 +4,6 @@ import pytest from pydispatch import dispatcher from testfixtures import LogCapture from twisted.internet import defer -from twisted.internet.defer import inlineCallbacks from twisted.python.failure import Failure from scrapy.utils.asyncio import call_later @@ -15,6 +14,7 @@ from scrapy.utils.signal import ( send_catch_log_deferred, ) from scrapy.utils.test import get_from_asyncio_queue +from tests.utils.decorators import inlineCallbacks class TestSendCatchLog: diff --git a/tests/test_webclient.py b/tests/test_webclient.py index 50cab9a60..0ad6bd9d1 100644 --- a/tests/test_webclient.py +++ b/tests/test_webclient.py @@ -30,6 +30,8 @@ from tests.mockserver.http_resources import ( from tests.mockserver.utils import ssl_context_factory from tests.test_core_downloader import TestContextFactoryBase +pytestmark = pytest.mark.requires_reactor + def getPage(url, contextFactory=None, response_transform=None, *args, **kwargs): """Adapted version of twisted.web.client.getPage""" diff --git a/tests/test_zz_resources.py b/tests/test_zz_resources.py index e2c8ed205..7b11aac9e 100644 --- a/tests/test_zz_resources.py +++ b/tests/test_zz_resources.py @@ -30,6 +30,7 @@ def test_stderr_log_handler() -> None: assert c == 0 +@pytest.mark.requires_reactor # needs a running event loop for asyncio.all_tasks() @pytest.mark.only_asyncio def test_pending_asyncio_tasks() -> None: """Test that there are no pending asyncio tasks.""" diff --git a/tests/utils/decorators.py b/tests/utils/decorators.py new file mode 100644 index 000000000..dc9d2e113 --- /dev/null +++ b/tests/utils/decorators.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from functools import wraps +from typing import TYPE_CHECKING, Any, ParamSpec + +import pytest +from twisted.internet.defer import Deferred +from twisted.internet.defer import inlineCallbacks as inlineCallbacks_orig + +from scrapy.utils.defer import deferred_from_coro + +if TYPE_CHECKING: + from collections.abc import Awaitable, Callable, Generator + + +_P = ParamSpec("_P") + + +def inlineCallbacks( + f: Callable[_P, Generator[Deferred[Any], Any, None]], +) -> Callable[_P, Deferred[None]]: + """Like :func:`twisted.internet.defer.inlineCallbacks`, but marks the + decorated function with ``@pytest.mark.requires_reactor``.""" + + @pytest.mark.requires_reactor + @wraps(f) + @inlineCallbacks_orig + def wrapper( + *args: _P.args, **kwargs: _P.kwargs + ) -> Generator[Deferred[Any], Any, None]: + return f(*args, **kwargs) + + return wrapper + + +def deferred_f_from_coro_f( + coro_f: Callable[_P, Awaitable[None]], +) -> Callable[_P, Deferred[None]]: + """Like :func:`scrapy.utils.defer.deferred_f_from_coro_f`, but marks the + decorated function with ``@pytest.mark.requires_reactor``.""" + + @pytest.mark.requires_reactor + @wraps(coro_f) + def f(*coro_args: _P.args, **coro_kwargs: _P.kwargs) -> Deferred[None]: + return deferred_from_coro(coro_f(*coro_args, **coro_kwargs)) + + return f diff --git a/tox.ini b/tox.ini index 5200b26e0..fc4d1b824 100644 --- a/tox.ini +++ b/tox.ini @@ -161,6 +161,10 @@ commands = {[pinned]commands} commands = {[testenv]commands} --reactor=default +[testenv:no-reactor] +commands = + {[testenv]commands} -p no:twisted --reactor=none + [testenv:default-reactor-pinned] basepython = {[pinned]basepython} deps = {[testenv:pinned]deps} @@ -168,6 +172,13 @@ commands = {[pinned]commands} --reactor=default setenv = {[pinned]setenv} +[testenv:no-reactor-pinned] +basepython = {[pinned]basepython} +deps = {[testenv:pinned]deps} +commands = {[pinned]commands} -p no:twisted --reactor=none +setenv = + {[pinned]setenv} + [testenv:pypy3] basepython = pypy3 commands = From c8aa429c9beccd271b12c0b385fdf8e9352838b9 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Mon, 2 Feb 2026 23:36:23 +0500 Subject: [PATCH 042/248] Reactorless tests 2: enable pytest-asyncio. (#7233) * Move no-reactor to pytest-asyncio. * Unify the style. * Skip a test that installs a reactor in no-reactor. * Fix pinned env deps. * Strict pytest-asyncio mode. --- conftest.py | 5 ++- pyproject.toml | 3 +- tests/test_addons.py | 7 ++- tests/test_closespider.py | 3 ++ tests/test_contracts.py | 1 + tests/test_core_downloader.py | 1 + tests/test_core_scraper.py | 5 ++- tests/test_crawl.py | 2 + tests/test_crawler.py | 11 +++-- .../test_downloader_handler_twisted_http2.py | 4 +- tests/test_downloaderslotssettings.py | 2 + tests/test_engine.py | 4 ++ tests/test_engine_loop.py | 5 ++- tests/test_engine_stop_download_bytes.py | 5 ++- tests/test_engine_stop_download_headers.py | 5 ++- tests/test_extension_telnet.py | 2 + tests/test_feedexport.py | 6 +++ tests/test_logformatter.py | 1 + tests/test_pipeline_crawl.py | 1 + tests/test_pipeline_files.py | 1 + tests/test_pipelines.py | 4 ++ tests/test_proxy_connect.py | 1 + tests/test_request_attribute_binding.py | 2 + tests/test_request_cb_kwargs.py | 2 + tests/test_scheduler.py | 1 + tests/test_scheduler_base.py | 1 + tests/test_signals.py | 1 + tests/test_spider_start.py | 1 + tests/test_spidermiddleware_httperror.py | 1 + tests/test_spidermiddleware_output_chain.py | 2 + tests/test_spidermiddleware_process_start.py | 2 + tests/test_utils_defer.py | 2 + tests/test_utils_reactor.py | 1 + tests/test_zz_resources.py | 12 ++++++ tests/utils/decorators.py | 43 ++++++++++++++----- tox.ini | 7 ++- 36 files changed, 129 insertions(+), 28 deletions(-) diff --git a/conftest.py b/conftest.py index b17789b3a..50925a311 100644 --- a/conftest.py +++ b/conftest.py @@ -88,7 +88,10 @@ def pytest_runtest_setup(item): # Skip tests based on reactor markers reactor = item.config.getoption("--reactor") - if item.get_closest_marker("requires_reactor") and reactor == "none": + if ( + item.get_closest_marker("requires_reactor") + or item.get_closest_marker("requires_http_handler") + ) and reactor == "none": pytest.skip('This test is only run when the --reactor value is not "none"') if item.get_closest_marker("only_asyncio") and reactor not in {"asyncio", "none"}: diff --git a/pyproject.toml b/pyproject.toml index a2ace0ec7..e2484b02a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -230,13 +230,14 @@ markers = [ "only_asyncio: marks tests that require the asyncio loop to be used", "only_not_asyncio: marks tests that require the asyncio loop to not be used", "requires_reactor: marks tests that require a reactor", + "requires_http_handler: marks tests that require a HTTP handler", "requires_uvloop: marks tests as only enabled when uvloop is known to be working", "requires_botocore: marks tests that need botocore (but not boto3)", "requires_boto3: marks tests that need botocore and boto3", "requires_mitmproxy: marks tests that need mitmproxy", ] filterwarnings = [ - "ignore::DeprecationWarning:twisted.web.static" + "ignore::DeprecationWarning:twisted.web.static", ] [tool.ruff.lint] diff --git a/tests/test_addons.py b/tests/test_addons.py index fd673b2a5..675574d48 100644 --- a/tests/test_addons.py +++ b/tests/test_addons.py @@ -199,7 +199,12 @@ class TestAddonManager: settings = Settings() settings.setdict(get_reactor_settings()) settings.set("KEY", "default", priority="default") - runner = CrawlerRunner(settings) + runner_cls = ( + CrawlerRunner + if settings.getbool("TWISTED_ENABLED", True) + else AsyncCrawlerRunner + ) + runner = runner_cls(settings) crawler = runner.create_crawler(MySpider) assert crawler.settings.get("KEY") == "default" yield crawler.crawl() diff --git a/tests/test_closespider.py b/tests/test_closespider.py index 623ed89c8..20c311c5a 100644 --- a/tests/test_closespider.py +++ b/tests/test_closespider.py @@ -1,3 +1,5 @@ +import pytest + from scrapy.utils.test import get_crawler from tests.mockserver.http import MockServer from tests.spiders import ( @@ -10,6 +12,7 @@ from tests.spiders import ( from tests.utils.decorators import inlineCallbacks +@pytest.mark.requires_http_handler class TestCloseSpider: @classmethod def setup_class(cls): diff --git a/tests/test_contracts.py b/tests/test_contracts.py index 505960685..9dface8aa 100644 --- a/tests/test_contracts.py +++ b/tests/test_contracts.py @@ -501,6 +501,7 @@ class TestContractsManager: assert not self.results.failures assert self.results.errors + @pytest.mark.requires_http_handler @inlineCallbacks def test_same_url(self): class TestSameUrlSpider(Spider): diff --git a/tests/test_core_downloader.py b/tests/test_core_downloader.py index d7940842a..c479e66c9 100644 --- a/tests/test_core_downloader.py +++ b/tests/test_core_downloader.py @@ -38,6 +38,7 @@ class TestSlot: assert repr(slot) == "Slot(concurrency=8, delay=0.10, randomize_delay=True)" +@pytest.mark.requires_reactor class TestContextFactoryBase: context_factory = None diff --git a/tests/test_core_scraper.py b/tests/test_core_scraper.py index 3986656f0..249daf943 100644 --- a/tests/test_core_scraper.py +++ b/tests/test_core_scraper.py @@ -2,16 +2,17 @@ from __future__ import annotations from typing import TYPE_CHECKING +import pytest + from scrapy.utils.test import get_crawler from tests.spiders import SimpleSpider from tests.utils.decorators import deferred_f_from_coro_f if TYPE_CHECKING: - import pytest - from tests.mockserver.http import MockServer +@pytest.mark.requires_http_handler @deferred_f_from_coro_f async def test_scraper_exception( mockserver: MockServer, diff --git a/tests/test_crawl.py b/tests/test_crawl.py index c88526eea..61a4d387f 100644 --- a/tests/test_crawl.py +++ b/tests/test_crawl.py @@ -61,6 +61,7 @@ if TYPE_CHECKING: from scrapy.statscollectors import StatsCollector +@pytest.mark.requires_http_handler # easier than marking many individual tests class TestCrawl: mockserver: MockServer @@ -428,6 +429,7 @@ with multiples lines assert "NotSupported: Unsupported URL scheme 'foo'" in caplog.text +@pytest.mark.requires_http_handler class TestCrawlSpider: mockserver: MockServer diff --git a/tests/test_crawler.py b/tests/test_crawler.py index 9235ef885..8de38114c 100644 --- a/tests/test_crawler.py +++ b/tests/test_crawler.py @@ -646,12 +646,16 @@ class TestCrawlerProcess(TestBaseCrawler): @pytest.mark.only_asyncio class TestAsyncCrawlerProcess(TestBaseCrawler): - def test_crawler_process_accepts_dict(self): - runner = AsyncCrawlerProcess({"foo": "bar"}, install_root_handler=False) + def test_crawler_process_accepts_dict(self, reactor_pytest: str) -> None: + runner = AsyncCrawlerProcess( + {"foo": "bar", "TWISTED_ENABLED": reactor_pytest != "none"}, + install_root_handler=False, + ) assert runner.settings["foo"] == "bar" self.assertOptionIsDefault(runner.settings, "RETRY_ENABLED") - def test_crawler_process_accepts_None(self): + @pytest.mark.requires_reactor # can't pass TWISTED_ENABLED=False + def test_crawler_process_accepts_None(self) -> None: runner = AsyncCrawlerProcess(install_root_handler=False) self.assertOptionIsDefault(runner.settings, "RETRY_ENABLED") @@ -672,6 +676,7 @@ class NoRequestsSpider(scrapy.Spider): yield +@pytest.mark.requires_reactor class TestCrawlerRunnerHasSpider: @staticmethod def _runner(): diff --git a/tests/test_downloader_handler_twisted_http2.py b/tests/test_downloader_handler_twisted_http2.py index 22eb56a94..df19646ff 100644 --- a/tests/test_downloader_handler_twisted_http2.py +++ b/tests/test_downloader_handler_twisted_http2.py @@ -200,7 +200,7 @@ class TestHttps2Proxy(H2DownloadHandlerMixin, TestHttpProxyBase): ) -> None: with pytest.raises(NotImplementedError): await maybe_deferred_to_future( - super().test_download_with_proxy_https_timeout(proxy_mockserver) + super().test_download_with_proxy_https_timeout(proxy_mockserver) # type: ignore[arg-type] ) @deferred_f_from_coro_f @@ -209,5 +209,5 @@ class TestHttps2Proxy(H2DownloadHandlerMixin, TestHttpProxyBase): ) -> None: with pytest.raises(UnsupportedURLSchemeError): await maybe_deferred_to_future( - super().test_download_with_proxy_without_http_scheme(proxy_mockserver) + super().test_download_with_proxy_without_http_scheme(proxy_mockserver) # type: ignore[arg-type] ) diff --git a/tests/test_downloaderslotssettings.py b/tests/test_downloaderslotssettings.py index 66dafd9b6..7d2001e47 100644 --- a/tests/test_downloaderslotssettings.py +++ b/tests/test_downloaderslotssettings.py @@ -67,6 +67,7 @@ class TestCrawl: def setup_method(self): self.runner = CrawlerRunner() + @pytest.mark.requires_http_handler @inlineCallbacks def test_delay(self): crawler = get_crawler(DownloaderSlotsSettingsTestSpider) @@ -128,6 +129,7 @@ def test_get_slot_deprecated_spider_arg(): assert slot1 == slot2 +@pytest.mark.requires_http_handler @pytest.mark.parametrize( "priority_queue_class", [ diff --git a/tests/test_engine.py b/tests/test_engine.py index d4f9fd794..939ea0f6f 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -373,6 +373,7 @@ class TestEngineBase: class TestEngine(TestEngineBase): + @pytest.mark.requires_http_handler @deferred_f_from_coro_f async def test_crawler(self, mockserver: MockServer) -> None: for spider in ( @@ -390,6 +391,7 @@ class TestEngine(TestEngineBase): self._assert_signals_caught(run) self._assert_bytes_received(run) + @pytest.mark.requires_http_handler @deferred_f_from_coro_f async def test_crawler_dupefilter(self, mockserver: MockServer) -> None: run = CrawlerRun(DupeFilterSpider) @@ -397,12 +399,14 @@ class TestEngine(TestEngineBase): self._assert_scheduled_requests(run, count=8) self._assert_dropped_requests(run) + @pytest.mark.requires_http_handler @deferred_f_from_coro_f async def test_crawler_itemerror(self, mockserver: MockServer) -> None: run = CrawlerRun(ItemZeroDivisionErrorSpider) await run.run(mockserver) self._assert_items_error(run) + @pytest.mark.requires_http_handler @deferred_f_from_coro_f async def test_crawler_change_close_reason_on_idle( self, mockserver: MockServer diff --git a/tests/test_engine_loop.py b/tests/test_engine_loop.py index f9cd437f7..f12409029 100644 --- a/tests/test_engine_loop.py +++ b/tests/test_engine_loop.py @@ -4,6 +4,7 @@ from collections import deque from logging import ERROR from typing import TYPE_CHECKING +import pytest from twisted.internet.defer import Deferred from scrapy import Request, Spider, signals @@ -14,8 +15,6 @@ from tests.test_scheduler import MemoryScheduler from tests.utils.decorators import deferred_f_from_coro_f if TYPE_CHECKING: - import pytest - from scrapy.http import Response @@ -28,6 +27,7 @@ async def sleep(seconds: float = 0.001) -> None: class TestMain: + @pytest.mark.requires_reactor # TODO @deferred_f_from_coro_f async def test_sleep(self): """Neither asynchronous sleeps on Spider.start() nor the equivalent on @@ -332,6 +332,7 @@ class TestRequestSendOrder: # Examples from the “Start requests” section of the documentation about # spiders. + @pytest.mark.requires_http_handler @deferred_f_from_coro_f async def test_lazy(self): start_nums = [1, 2, 4] diff --git a/tests/test_engine_stop_download_bytes.py b/tests/test_engine_stop_download_bytes.py index e330adb3f..bd29e9ef6 100644 --- a/tests/test_engine_stop_download_bytes.py +++ b/tests/test_engine_stop_download_bytes.py @@ -2,6 +2,8 @@ from __future__ import annotations from typing import TYPE_CHECKING +import pytest + from scrapy.exceptions import StopDownload from tests.test_engine import ( AttrsItemsSpider, @@ -14,8 +16,6 @@ from tests.test_engine import ( from tests.utils.decorators import deferred_f_from_coro_f if TYPE_CHECKING: - import pytest - from tests.mockserver.http import MockServer @@ -26,6 +26,7 @@ class BytesReceivedCrawlerRun(CrawlerRun): class TestBytesReceivedEngine(TestEngineBase): + @pytest.mark.requires_http_handler @deferred_f_from_coro_f async def test_crawler( self, mockserver: MockServer, caplog: pytest.LogCaptureFixture diff --git a/tests/test_engine_stop_download_headers.py b/tests/test_engine_stop_download_headers.py index f46f46bd7..7dbaa41d2 100644 --- a/tests/test_engine_stop_download_headers.py +++ b/tests/test_engine_stop_download_headers.py @@ -2,6 +2,8 @@ from __future__ import annotations from typing import TYPE_CHECKING +import pytest + from scrapy.exceptions import StopDownload from tests.test_engine import ( AttrsItemsSpider, @@ -14,8 +16,6 @@ from tests.test_engine import ( from tests.utils.decorators import deferred_f_from_coro_f if TYPE_CHECKING: - import pytest - from tests.mockserver.http import MockServer @@ -26,6 +26,7 @@ class HeadersReceivedCrawlerRun(CrawlerRun): class TestHeadersReceivedEngine(TestEngineBase): + @pytest.mark.requires_http_handler @deferred_f_from_coro_f async def test_crawler( self, mockserver: MockServer, caplog: pytest.LogCaptureFixture diff --git a/tests/test_extension_telnet.py b/tests/test_extension_telnet.py index 15e5aacce..fcb027832 100644 --- a/tests/test_extension_telnet.py +++ b/tests/test_extension_telnet.py @@ -6,6 +6,8 @@ from scrapy.extensions.telnet import TelnetConsole from scrapy.utils.test import get_crawler from tests.utils.decorators import inlineCallbacks +pytestmark = pytest.mark.requires_reactor + class TestTelnetExtension: def _get_console_and_portal(self, settings=None): diff --git a/tests/test_feedexport.py b/tests/test_feedexport.py index 914d9a950..c65bd9df9 100644 --- a/tests/test_feedexport.py +++ b/tests/test_feedexport.py @@ -163,6 +163,7 @@ class TestFileFeedStorage: assert storage.path == path +@pytest.mark.requires_reactor # needs a reactor for BlockingFeedStorage class TestFTPFeedStorage: def get_test_spider(self, settings=None): class TestSpider(scrapy.Spider): @@ -277,6 +278,7 @@ class TestBlockingFeedStorage: @pytest.mark.requires_boto3 +@pytest.mark.requires_reactor # needs a reactor for BlockingFeedStorage class TestS3FeedStorage: def test_parse_credentials(self): aws_credentials = { @@ -506,6 +508,7 @@ class TestS3FeedStorage: assert "S3 does not support appending to files" in str(log) +@pytest.mark.requires_reactor # needs a reactor for BlockingFeedStorage class TestGCSFeedStorage: def test_parse_settings(self): try: @@ -846,6 +849,7 @@ class ExceptionJsonItemExporter(JsonItemExporter): raise RuntimeError("foo") +@pytest.mark.requires_http_handler class TestFeedExport(TestFeedExportBase): async def run_and_export( self, spider_cls: type[Spider], settings: dict[str, Any] @@ -1830,6 +1834,7 @@ class TestFeedExport(TestFeedExportBase): assert not Storage.file_was_closed +@pytest.mark.requires_http_handler class TestFeedPostProcessedExports(TestFeedExportBase): items = [{"foo": "bar"}] expected = b"foo\r\nbar\r\n" @@ -2348,6 +2353,7 @@ class TestFeedPostProcessedExports(TestFeedExportBase): assert result == expected +@pytest.mark.requires_http_handler class TestBatchDeliveries(TestFeedExportBase): _file_mark = "_%(batch_time)s_#%(batch_id)02d_" diff --git a/tests/test_logformatter.py b/tests/test_logformatter.py index 5cd816d76..0602be0c8 100644 --- a/tests/test_logformatter.py +++ b/tests/test_logformatter.py @@ -253,6 +253,7 @@ class DropSomeItemsPipeline: self.drop = True +@pytest.mark.requires_http_handler class TestShowOrSkipMessages: @classmethod def setup_class(cls): diff --git a/tests/test_pipeline_crawl.py b/tests/test_pipeline_crawl.py index f29bc90e8..dddb6d335 100644 --- a/tests/test_pipeline_crawl.py +++ b/tests/test_pipeline_crawl.py @@ -57,6 +57,7 @@ class RedirectedMediaDownloadSpider(MediaDownloadSpider): ) +@pytest.mark.requires_http_handler class TestFileDownloadCrawl: pipeline_class = "scrapy.pipelines.files.FilesPipeline" store_setting_key = "FILES_STORE" diff --git a/tests/test_pipeline_files.py b/tests/test_pipeline_files.py index 18fa6cdf2..7a6e9a942 100644 --- a/tests/test_pipeline_files.py +++ b/tests/test_pipeline_files.py @@ -691,6 +691,7 @@ class TestGCSFilesStore: store.bucket.get_blob.assert_called_with(expected_blob_path) +@pytest.mark.requires_reactor # needs a reactor for FTPFilesStore class TestFTPFileStore: @inlineCallbacks def test_persist(self): diff --git a/tests/test_pipelines.py b/tests/test_pipelines.py index 5bdca3035..69a6c890d 100644 --- a/tests/test_pipelines.py +++ b/tests/test_pipelines.py @@ -127,6 +127,7 @@ class ItemSpider(Spider): return {"field": 42} +@pytest.mark.requires_http_handler class TestPipeline: def _on_item_scraped(self, item): assert isinstance(item, dict) @@ -265,6 +266,7 @@ class TestCustomPipelineManager: ): itemproc.process_item({}, crawler.spider) + @pytest.mark.requires_http_handler @deferred_f_from_coro_f async def test_integration_recommended(self, mockserver: MockServer) -> None: class CustomPipelineManager(ItemPipelineManager): @@ -291,6 +293,7 @@ class TestCustomPipelineManager: assert len(items) == 1 + @pytest.mark.requires_http_handler @deferred_f_from_coro_f async def test_integration_no_async_subclass(self, mockserver: MockServer) -> None: class CustomPipelineManager(ItemPipelineManager): @@ -349,6 +352,7 @@ class TestCustomPipelineManager: assert len(items) == 1 + @pytest.mark.requires_http_handler @deferred_f_from_coro_f async def test_integration_no_async_not_subclass( self, mockserver: MockServer diff --git a/tests/test_proxy_connect.py b/tests/test_proxy_connect.py index 4071224d6..daa635b54 100644 --- a/tests/test_proxy_connect.py +++ b/tests/test_proxy_connect.py @@ -61,6 +61,7 @@ def _wrong_credentials(proxy_url): return urlunsplit(bad_auth_proxy) +@pytest.mark.requires_http_handler @pytest.mark.requires_mitmproxy class TestProxyConnect: @classmethod diff --git a/tests/test_request_attribute_binding.py b/tests/test_request_attribute_binding.py index 4065a5a2c..48234adb3 100644 --- a/tests/test_request_attribute_binding.py +++ b/tests/test_request_attribute_binding.py @@ -1,3 +1,4 @@ +import pytest from testfixtures import LogCapture from scrapy import Request, signals @@ -62,6 +63,7 @@ class AlternativeCallbacksMiddleware: return response.replace(request=new_request) +@pytest.mark.requires_http_handler class TestCrawl: @classmethod def setup_class(cls): diff --git a/tests/test_request_cb_kwargs.py b/tests/test_request_cb_kwargs.py index 56d7264da..c91a670c1 100644 --- a/tests/test_request_cb_kwargs.py +++ b/tests/test_request_cb_kwargs.py @@ -1,3 +1,4 @@ +import pytest from testfixtures import LogCapture from scrapy.http import Request @@ -148,6 +149,7 @@ class KeywordArgumentsSpider(MockServerSpider): self.crawler.stats.inc_value("boolean_checks", 1) +@pytest.mark.requires_http_handler class TestCallbackKeywordArguments: @classmethod def setup_class(cls): diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index 8730489f7..f363b1cd5 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -369,6 +369,7 @@ class TestIntegrationWithDownloaderAwareInMemory: }, ) + @pytest.mark.requires_http_handler @inlineCallbacks def test_integration_downloader_aware_priority_queue(self): with MockServer() as mockserver: diff --git a/tests/test_scheduler_base.py b/tests/test_scheduler_base.py index 538464788..277c00623 100644 --- a/tests/test_scheduler_base.py +++ b/tests/test_scheduler_base.py @@ -144,6 +144,7 @@ class TestSimpleScheduler(InterfaceCheckMixin): assert close_result == "close" +@pytest.mark.requires_http_handler class TestMinimalSchedulerCrawl: scheduler_cls = MinimalScheduler diff --git a/tests/test_signals.py b/tests/test_signals.py index 048af00ab..5769f6588 100644 --- a/tests/test_signals.py +++ b/tests/test_signals.py @@ -50,6 +50,7 @@ class TestMockServer: item = await get_from_asyncio_queue(item) self.items.append(item) + @pytest.mark.requires_http_handler @pytest.mark.only_asyncio @inlineCallbacks def test_simple_pipeline(self): diff --git a/tests/test_spider_start.py b/tests/test_spider_start.py index d6767d74c..b84b0d8ac 100644 --- a/tests/test_spider_start.py +++ b/tests/test_spider_start.py @@ -162,6 +162,7 @@ class TestMain: await self._test_start(start, [ITEM_A]) + @pytest.mark.requires_reactor # needs a reactor for twisted_sleep() @deferred_f_from_coro_f async def test_twisted_delayed(self): async def start(spider): diff --git a/tests/test_spidermiddleware_httperror.py b/tests/test_spidermiddleware_httperror.py index c0284c5ed..d4d0a4fb1 100644 --- a/tests/test_spidermiddleware_httperror.py +++ b/tests/test_spidermiddleware_httperror.py @@ -191,6 +191,7 @@ class TestHttpErrorMiddlewareHandleAll: mw.process_spider_input(res402) +@pytest.mark.requires_http_handler class TestHttpErrorMiddlewareIntegrational: @classmethod def setup_class(cls): diff --git a/tests/test_spidermiddleware_output_chain.py b/tests/test_spidermiddleware_output_chain.py index b35a4c704..07df2ecc4 100644 --- a/tests/test_spidermiddleware_output_chain.py +++ b/tests/test_spidermiddleware_output_chain.py @@ -1,3 +1,4 @@ +import pytest from testfixtures import LogCapture from scrapy import Request, Spider @@ -318,6 +319,7 @@ class NotGeneratorOutputChainSpider(Spider): # ================================================================================ +@pytest.mark.requires_http_handler class TestSpiderMiddleware: mockserver: MockServer diff --git a/tests/test_spidermiddleware_process_start.py b/tests/test_spidermiddleware_process_start.py index 250b58544..e7c9640b8 100644 --- a/tests/test_spidermiddleware_process_start.py +++ b/tests/test_spidermiddleware_process_start.py @@ -341,10 +341,12 @@ class TestMain: [NoOpSpiderMiddleware, AsyncioSleepSpiderMiddleware, NoOpSpiderMiddleware] ) + @pytest.mark.requires_reactor @deferred_f_from_coro_f async def test_twisted_sleep_single(self): await self._test_sleep([TwistedSleepSpiderMiddleware]) + @pytest.mark.requires_reactor @deferred_f_from_coro_f async def test_twisted_sleep_multiple(self): await self._test_sleep( diff --git a/tests/test_utils_defer.py b/tests/test_utils_defer.py index ad317f3ae..ad4cc466d 100644 --- a/tests/test_utils_defer.py +++ b/tests/test_utils_defer.py @@ -26,6 +26,7 @@ if TYPE_CHECKING: from collections.abc import AsyncGenerator, Awaitable, Callable, Generator +@pytest.mark.requires_reactor @pytest.mark.filterwarnings("ignore::scrapy.exceptions.ScrapyDeprecationWarning") class TestMustbeDeferred: @inlineCallbacks @@ -153,6 +154,7 @@ class TestAsyncDefTestsuite: raise RuntimeError("This is expected to be raised") +@pytest.mark.requires_reactor class TestParallelAsync: """This tests _AsyncCooperatorAdapter by testing parallel_async which is its only usage. diff --git a/tests/test_utils_reactor.py b/tests/test_utils_reactor.py index 658e68016..a045c05a4 100644 --- a/tests/test_utils_reactor.py +++ b/tests/test_utils_reactor.py @@ -29,6 +29,7 @@ class TestAsyncio: assert original_reactor == reactor + @pytest.mark.requires_reactor @pytest.mark.only_asyncio @deferred_f_from_coro_f async def test_set_asyncio_event_loop(self): diff --git a/tests/test_zz_resources.py b/tests/test_zz_resources.py index 7b11aac9e..e6ff46f5c 100644 --- a/tests/test_zz_resources.py +++ b/tests/test_zz_resources.py @@ -8,6 +8,7 @@ import logging import pytest from scrapy.utils.log import LogCounterHandler +from scrapy.utils.reactor import is_asyncio_reactor_installed, is_reactor_installed def test_counter_handler() -> None: @@ -35,3 +36,14 @@ def test_stderr_log_handler() -> None: def test_pending_asyncio_tasks() -> None: """Test that there are no pending asyncio tasks.""" assert not asyncio.all_tasks() + + +def test_installed_reactor(reactor_pytest: str) -> None: + """Test that the correct reactor is installed.""" + match reactor_pytest: + case "asyncio": + assert is_asyncio_reactor_installed() + case "default": + assert not is_asyncio_reactor_installed() + case "none": + assert not is_reactor_installed() diff --git a/tests/utils/decorators.py b/tests/utils/decorators.py index dc9d2e113..633d8d97d 100644 --- a/tests/utils/decorators.py +++ b/tests/utils/decorators.py @@ -7,7 +7,8 @@ import pytest from twisted.internet.defer import Deferred from twisted.internet.defer import inlineCallbacks as inlineCallbacks_orig -from scrapy.utils.defer import deferred_from_coro +from scrapy.utils.defer import deferred_from_coro, deferred_to_future +from scrapy.utils.reactor import is_reactor_installed if TYPE_CHECKING: from collections.abc import Awaitable, Callable, Generator @@ -18,28 +19,48 @@ _P = ParamSpec("_P") def inlineCallbacks( f: Callable[_P, Generator[Deferred[Any], Any, None]], -) -> Callable[_P, Deferred[None]]: - """Like :func:`twisted.internet.defer.inlineCallbacks`, but marks the - decorated function with ``@pytest.mark.requires_reactor``.""" +) -> Callable[_P, Awaitable[None]]: + """Mark a test function written in a :func:`twisted.internet.defer.inlineCallbacks` style. + + This calls :func:`twisted.internet.defer.inlineCallbacks` and then: + + * with ``pytest-twisted`` this returns the resulting Deferred + * with ``pytest-asyncio`` this converts the resulting Deferred into a + coroutine + """ + + if not is_reactor_installed(): + + @pytest.mark.asyncio + @wraps(f) + async def wrapper_coro(*args: _P.args, **kwargs: _P.kwargs) -> None: + await deferred_to_future(inlineCallbacks_orig(f)(*args, **kwargs)) + + return wrapper_coro - @pytest.mark.requires_reactor @wraps(f) @inlineCallbacks_orig - def wrapper( + def wrapper_dfd( *args: _P.args, **kwargs: _P.kwargs ) -> Generator[Deferred[Any], Any, None]: return f(*args, **kwargs) - return wrapper + return wrapper_dfd def deferred_f_from_coro_f( coro_f: Callable[_P, Awaitable[None]], -) -> Callable[_P, Deferred[None]]: - """Like :func:`scrapy.utils.defer.deferred_f_from_coro_f`, but marks the - decorated function with ``@pytest.mark.requires_reactor``.""" +) -> Callable[_P, Awaitable[None]]: + """Mark a test function that returns a coroutine. + + * with ``pytest-twisted`` this converts a coroutine into a + :class:`twisted.internet.defer.Deferred` + * with ``pytest-asyncio`` this is a no-op + """ + + if not is_reactor_installed(): + return pytest.mark.asyncio(coro_f) - @pytest.mark.requires_reactor @wraps(coro_f) def f(*coro_args: _P.args, **coro_kwargs: _P.kwargs) -> Deferred[None]: return deferred_from_coro(coro_f(*coro_args, **coro_kwargs)) diff --git a/tox.ini b/tox.ini index fc4d1b824..83cd06561 100644 --- a/tox.ini +++ b/tox.ini @@ -162,6 +162,9 @@ commands = {[testenv]commands} --reactor=default [testenv:no-reactor] +deps = + {[testenv]deps} + pytest-asyncio commands = {[testenv]commands} -p no:twisted --reactor=none @@ -174,7 +177,9 @@ setenv = [testenv:no-reactor-pinned] basepython = {[pinned]basepython} -deps = {[testenv:pinned]deps} +deps = + {[testenv:pinned]deps} + pytest-asyncio commands = {[pinned]commands} -p no:twisted --reactor=none setenv = {[pinned]setenv} From 11977afba53a992f3564b0f85a57ba3ac24bea69 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Tue, 3 Feb 2026 00:39:43 +0500 Subject: [PATCH 043/248] Rename async test decorators. (#7234) --- tests/test_addons.py | 4 +- tests/test_closespider.py | 16 +-- tests/test_contracts.py | 4 +- tests/test_core_downloader.py | 14 +- tests/test_core_scraper.py | 4 +- tests/test_crawl.py | 122 ++++++++--------- tests/test_crawler.py | 42 +++--- tests/test_downloader_handlers.py | 34 ++--- tests/test_downloader_handlers_http_base.py | 100 +++++++------- tests/test_downloadermiddleware.py | 28 ++-- tests/test_downloadermiddleware_robotstxt.py | 24 ++-- tests/test_downloaderslotssettings.py | 6 +- tests/test_engine.py | 46 +++---- tests/test_engine_loop.py | 14 +- tests/test_engine_stop_download_bytes.py | 4 +- tests/test_engine_stop_download_headers.py | 4 +- tests/test_extension_telnet.py | 8 +- tests/test_feedexport.py | 136 +++++++++---------- tests/test_logformatter.py | 6 +- tests/test_pipeline_crawl.py | 12 +- tests/test_pipeline_files.py | 18 +-- tests/test_pipeline_media.py | 22 +-- tests/test_pipelines.py | 26 ++-- tests/test_proxy_connect.py | 8 +- tests/test_request_attribute_binding.py | 16 +-- tests/test_request_cb_kwargs.py | 4 +- tests/test_request_left.py | 10 +- tests/test_scheduler.py | 4 +- tests/test_scheduler_base.py | 6 +- tests/test_signals.py | 6 +- tests/test_spider.py | 10 +- tests/test_spider_start.py | 24 ++-- tests/test_spidermiddleware.py | 90 ++++++------ tests/test_spidermiddleware_httperror.py | 8 +- tests/test_spidermiddleware_output_chain.py | 24 ++-- tests/test_spidermiddleware_process_start.py | 48 +++---- tests/test_spidermiddleware_start.py | 6 +- tests/test_stats.py | 6 +- tests/test_utils_asyncgen.py | 6 +- tests/test_utils_asyncio.py | 6 +- tests/test_utils_defer.py | 27 ++-- tests/test_utils_python.py | 6 +- tests/test_utils_reactor.py | 4 +- tests/test_utils_signal.py | 4 +- tests/utils/decorators.py | 11 +- 45 files changed, 513 insertions(+), 515 deletions(-) diff --git a/tests/test_addons.py b/tests/test_addons.py index 675574d48..5c26e4651 100644 --- a/tests/test_addons.py +++ b/tests/test_addons.py @@ -7,7 +7,7 @@ from scrapy.crawler import AsyncCrawlerRunner, Crawler, CrawlerRunner from scrapy.exceptions import NotConfigured from scrapy.settings import BaseSettings, Settings from scrapy.utils.test import get_crawler, get_reactor_settings -from tests.utils.decorators import inlineCallbacks +from tests.utils.decorators import inline_callbacks_test class SimpleAddon: @@ -183,7 +183,7 @@ class TestAddonManager: extra={"crawler": crawler}, ) - @inlineCallbacks + @inline_callbacks_test def test_enable_addon_in_spider(self): class MySpider(Spider): name = "myspider" diff --git a/tests/test_closespider.py b/tests/test_closespider.py index 20c311c5a..a6fd6dd19 100644 --- a/tests/test_closespider.py +++ b/tests/test_closespider.py @@ -9,7 +9,7 @@ from tests.spiders import ( MaxItemsAndRequestsSpider, SlowSpider, ) -from tests.utils.decorators import inlineCallbacks +from tests.utils.decorators import inline_callbacks_test @pytest.mark.requires_http_handler @@ -23,7 +23,7 @@ class TestCloseSpider: def teardown_class(cls): cls.mockserver.__exit__(None, None, None) - @inlineCallbacks + @inline_callbacks_test def test_closespider_itemcount(self): close_on = 5 crawler = get_crawler(ItemSpider, {"CLOSESPIDER_ITEMCOUNT": close_on}) @@ -33,7 +33,7 @@ class TestCloseSpider: itemcount = crawler.stats.get_value("item_scraped_count") assert itemcount >= close_on - @inlineCallbacks + @inline_callbacks_test def test_closespider_pagecount(self): close_on = 5 crawler = get_crawler(FollowAllSpider, {"CLOSESPIDER_PAGECOUNT": close_on}) @@ -43,7 +43,7 @@ class TestCloseSpider: pagecount = crawler.stats.get_value("response_received_count") assert pagecount >= close_on - @inlineCallbacks + @inline_callbacks_test def test_closespider_pagecount_no_item(self): close_on = 5 max_items = 5 @@ -63,7 +63,7 @@ class TestCloseSpider: itemcount = crawler.stats.get_value("item_scraped_count") assert pagecount <= close_on + itemcount - @inlineCallbacks + @inline_callbacks_test def test_closespider_pagecount_no_item_with_pagecount(self): close_on_pagecount_no_item = 5 close_on_pagecount = 20 @@ -80,7 +80,7 @@ class TestCloseSpider: pagecount = crawler.stats.get_value("response_received_count") assert pagecount < close_on_pagecount - @inlineCallbacks + @inline_callbacks_test def test_closespider_errorcount(self): close_on = 5 crawler = get_crawler(ErrorSpider, {"CLOSESPIDER_ERRORCOUNT": close_on}) @@ -92,7 +92,7 @@ class TestCloseSpider: assert crawler.stats.get_value("spider_exceptions/count") >= close_on assert errorcount >= close_on - @inlineCallbacks + @inline_callbacks_test def test_closespider_timeout(self): close_on = 0.1 crawler = get_crawler(FollowAllSpider, {"CLOSESPIDER_TIMEOUT": close_on}) @@ -102,7 +102,7 @@ class TestCloseSpider: total_seconds = crawler.stats.get_value("elapsed_time_seconds") assert total_seconds >= close_on - @inlineCallbacks + @inline_callbacks_test def test_closespider_timeout_no_item(self): timeout = 1 crawler = get_crawler(SlowSpider, {"CLOSESPIDER_TIMEOUT_NO_ITEM": timeout}) diff --git a/tests/test_contracts.py b/tests/test_contracts.py index 9dface8aa..f1678f912 100644 --- a/tests/test_contracts.py +++ b/tests/test_contracts.py @@ -18,7 +18,7 @@ from scrapy.spidermiddlewares.httperror import HttpError from scrapy.spiders import Spider from scrapy.utils.test import get_crawler from tests.mockserver.http import MockServer -from tests.utils.decorators import inlineCallbacks +from tests.utils.decorators import inline_callbacks_test class DemoItem(Item): @@ -502,7 +502,7 @@ class TestContractsManager: assert self.results.errors @pytest.mark.requires_http_handler - @inlineCallbacks + @inline_callbacks_test def test_same_url(self): class TestSameUrlSpider(Spider): name = "test_same_url" diff --git a/tests/test_core_downloader.py b/tests/test_core_downloader.py index c479e66c9..2a098f9f3 100644 --- a/tests/test_core_downloader.py +++ b/tests/test_core_downloader.py @@ -25,7 +25,7 @@ from scrapy.utils.spider import DefaultSpider from scrapy.utils.test import get_crawler from tests.mockserver.http_resources import PayloadResource from tests.mockserver.utils import ssl_context_factory -from tests.utils.decorators import deferred_f_from_coro_f +from tests.utils.decorators import coroutine_test if TYPE_CHECKING: from twisted.internet.defer import Deferred @@ -97,7 +97,7 @@ class TestContextFactoryBase: class TestContextFactory(TestContextFactoryBase): - @deferred_f_from_coro_f + @coroutine_test async def testPayload(self, server_url: str) -> None: s = "0123456789" * 10 crawler = get_crawler() @@ -135,7 +135,7 @@ class TestContextFactoryTLSMethod(TestContextFactoryBase): ) assert body == to_bytes(s) - @deferred_f_from_coro_f + @coroutine_test async def test_setting_default(self, server_url: str) -> None: crawler = get_crawler() settings = Settings() @@ -155,7 +155,7 @@ class TestContextFactoryTLSMethod(TestContextFactoryBase): with pytest.raises(KeyError): load_context_factory_from_settings(settings, crawler) - @deferred_f_from_coro_f + @coroutine_test async def test_setting_explicit(self, server_url: str) -> None: crawler = get_crawler() settings = Settings({"DOWNLOADER_CLIENT_TLS_METHOD": "TLSv1.2"}) @@ -163,7 +163,7 @@ class TestContextFactoryTLSMethod(TestContextFactoryBase): assert client_context_factory._ssl_method == OpenSSL.SSL.TLSv1_2_METHOD await self._assert_factory_works(server_url, client_context_factory) - @deferred_f_from_coro_f + @coroutine_test async def test_direct_from_crawler(self, server_url: str) -> None: # the setting is ignored crawler = get_crawler(settings_dict={"DOWNLOADER_CLIENT_TLS_METHOD": "bad"}) @@ -171,14 +171,14 @@ class TestContextFactoryTLSMethod(TestContextFactoryBase): assert client_context_factory._ssl_method == OpenSSL.SSL.SSLv23_METHOD await self._assert_factory_works(server_url, client_context_factory) - @deferred_f_from_coro_f + @coroutine_test async def test_direct_init(self, server_url: str) -> None: client_context_factory = ScrapyClientContextFactory(OpenSSL.SSL.TLSv1_2_METHOD) assert client_context_factory._ssl_method == OpenSSL.SSL.TLSv1_2_METHOD await self._assert_factory_works(server_url, client_context_factory) -@deferred_f_from_coro_f +@coroutine_test async def test_fetch_deprecated_spider_arg(): class CustomDownloader(Downloader): def fetch(self, request, spider): # pylint: disable=signature-differs diff --git a/tests/test_core_scraper.py b/tests/test_core_scraper.py index 249daf943..f4ef5ec5d 100644 --- a/tests/test_core_scraper.py +++ b/tests/test_core_scraper.py @@ -6,14 +6,14 @@ import pytest from scrapy.utils.test import get_crawler from tests.spiders import SimpleSpider -from tests.utils.decorators import deferred_f_from_coro_f +from tests.utils.decorators import coroutine_test if TYPE_CHECKING: from tests.mockserver.http import MockServer @pytest.mark.requires_http_handler -@deferred_f_from_coro_f +@coroutine_test async def test_scraper_exception( mockserver: MockServer, caplog: pytest.LogCaptureFixture, diff --git a/tests/test_crawl.py b/tests/test_crawl.py index 61a4d387f..b206cfffa 100644 --- a/tests/test_crawl.py +++ b/tests/test_crawl.py @@ -55,7 +55,7 @@ from tests.spiders import ( StartGoodAndBadOutput, StartItemSpider, ) -from tests.utils.decorators import deferred_f_from_coro_f, inlineCallbacks +from tests.utils.decorators import coroutine_test, inline_callbacks_test if TYPE_CHECKING: from scrapy.statscollectors import StatsCollector @@ -74,17 +74,17 @@ class TestCrawl: def teardown_class(cls): cls.mockserver.__exit__(None, None, None) - @inlineCallbacks + @inline_callbacks_test def test_follow_all(self): crawler = get_crawler(FollowAllSpider) yield crawler.crawl(mockserver=self.mockserver) assert len(crawler.spider.urls_visited) == 11 # 10 + start_url - @deferred_f_from_coro_f + @coroutine_test async def test_fixed_delay(self): await self._test_delay(total=3, delay=0.2) - @deferred_f_from_coro_f + @coroutine_test async def test_randomized_delay(self): await self._test_delay(total=3, delay=0.1, randomize=True) @@ -122,7 +122,7 @@ class TestCrawl: average = total_time / (len(times) - 1) assert average <= delay / tolerance, "test total or delay values are too small" - @inlineCallbacks + @inline_callbacks_test def test_timeout_success(self): crawler = get_crawler(DelaySpider) yield crawler.crawl(n=0.5, mockserver=self.mockserver) @@ -130,7 +130,7 @@ class TestCrawl: assert crawler.spider.t2 > 0 assert crawler.spider.t2 > crawler.spider.t1 - @inlineCallbacks + @inline_callbacks_test def test_timeout_failure(self): crawler = get_crawler(DelaySpider, {"DOWNLOAD_TIMEOUT": 0.35}) yield crawler.crawl(n=0.5, mockserver=self.mockserver) @@ -147,7 +147,7 @@ class TestCrawl: assert crawler.spider.t2_err > 0 assert crawler.spider.t2_err > crawler.spider.t1 - @inlineCallbacks + @inline_callbacks_test def test_retry_503(self): crawler = get_crawler(SimpleSpider) with LogCapture() as log: @@ -156,7 +156,7 @@ class TestCrawl: ) self._assert_retried(log) - @inlineCallbacks + @inline_callbacks_test def test_retry_conn_failed(self): crawler = get_crawler(SimpleSpider) with LogCapture() as log: @@ -165,7 +165,7 @@ class TestCrawl: ) self._assert_retried(log) - @inlineCallbacks + @inline_callbacks_test def test_retry_dns_error(self): if NON_EXISTING_RESOLVABLE: pytest.skip("Non-existing hosts are resolvable") @@ -177,7 +177,7 @@ class TestCrawl: ) self._assert_retried(log) - @inlineCallbacks + @inline_callbacks_test def test_start_bug_before_yield(self): with LogCapture("scrapy", level=logging.ERROR) as log: crawler = get_crawler(BrokenStartSpider) @@ -188,7 +188,7 @@ class TestCrawl: assert record.exc_info is not None assert record.exc_info[0] is ZeroDivisionError - @inlineCallbacks + @inline_callbacks_test def test_start_bug_yielding(self): with LogCapture("scrapy", level=logging.ERROR) as log: crawler = get_crawler(BrokenStartSpider) @@ -199,7 +199,7 @@ class TestCrawl: assert record.exc_info is not None assert record.exc_info[0] is ZeroDivisionError - @inlineCallbacks + @inline_callbacks_test def test_start_items(self): items = [] @@ -214,7 +214,7 @@ class TestCrawl: assert len(log.records) == 0 assert items == [{"name": "test item"}] - @inlineCallbacks + @inline_callbacks_test def test_start_unsupported_output(self): """Anything that is not a request is assumed to be an item, avoiding a potentially expensive call to itemadapter.is_item(), and letting @@ -235,7 +235,7 @@ class TestCrawl: assert len(items) == 3 assert not any(isinstance(item, Request) for item in items) - @inlineCallbacks + @inline_callbacks_test def test_start_dupes(self): settings = {"CONCURRENT_REQUESTS": 1} crawler = get_crawler(DuplicateStartSpider, settings) @@ -253,7 +253,7 @@ class TestCrawl: ) assert crawler.spider.visited == 3 - @inlineCallbacks + @inline_callbacks_test def test_unbounded_response(self): # Completeness of responses without Content-Length or Transfer-Encoding # can not be determined, we treat them as valid but flagged as "partial" @@ -285,7 +285,7 @@ with multiples lines ) assert str(log).count("Got response 200") == 1 - @inlineCallbacks + @inline_callbacks_test def test_retry_conn_lost(self): # connection lost after receiving data crawler = get_crawler(SimpleSpider) @@ -295,7 +295,7 @@ with multiples lines ) self._assert_retried(log) - @inlineCallbacks + @inline_callbacks_test def test_retry_conn_aborted(self): # connection lost before receiving data crawler = get_crawler(SimpleSpider) @@ -309,7 +309,7 @@ with multiples lines assert str(log).count("Retrying") == 2 assert str(log).count("Gave up retrying") == 1 - @inlineCallbacks + @inline_callbacks_test def test_referer_header(self): """Referer header is set by RefererMiddleware unless it is already set""" req0 = Request(self.mockserver.url("/echo?headers=1&body=0"), dont_filter=1) @@ -337,7 +337,7 @@ with multiples lines echo3 = json.loads(to_unicode(crawler.spider.meta["responses"][3].body)) assert echo3["headers"].get("Referer") == ["http://example.com"] - @inlineCallbacks + @inline_callbacks_test def test_engine_status(self): est = [] @@ -353,7 +353,7 @@ with multiples lines assert s["engine.spider.name"] == crawler.spider.name assert s["len(engine.scraper.slot.active)"] == 1 - @inlineCallbacks + @inline_callbacks_test def test_format_engine_status(self): est = [] @@ -376,7 +376,7 @@ with multiples lines assert s["engine.spider.name"] == crawler.spider.name assert s["len(engine.scraper.slot.active)"] == "1" - @inlineCallbacks + @inline_callbacks_test def test_open_spider_error_on_faulty_pipeline(self): settings = { "ITEM_PIPELINES": { @@ -390,7 +390,7 @@ with multiples lines ) assert not crawler.crawling - @inlineCallbacks + @inline_callbacks_test def test_crawlerrunner_accepts_crawler(self): crawler = get_crawler(SimpleSpider) runner = CrawlerRunner() @@ -402,7 +402,7 @@ with multiples lines ) assert "Got response 200" in str(log) - @inlineCallbacks + @inline_callbacks_test def test_crawl_multiple(self, caplog: pytest.LogCaptureFixture): runner = CrawlerRunner(get_reactor_settings()) runner.crawl( @@ -422,7 +422,7 @@ with multiples lines self._assert_retried(caplog.text) assert "Got response 200" in caplog.text - @deferred_f_from_coro_f + @coroutine_test async def test_unknown_url_scheme(self, caplog: pytest.LogCaptureFixture) -> None: crawler = get_crawler(SimpleSpider) await maybe_deferred_to_future(crawler.crawl("foo://bar")) @@ -461,7 +461,7 @@ class TestCrawlSpider: assert crawler.stats return log, items, crawler.stats - @inlineCallbacks + @inline_callbacks_test def test_crawlspider_with_parse(self): crawler = get_crawler(CrawlSpiderWithParseMethod) with LogCapture() as log: @@ -471,7 +471,7 @@ class TestCrawlSpider: assert "[parse] status 201 (foo: None)" in str(log) assert "[parse] status 202 (foo: bar)" in str(log) - @inlineCallbacks + @inline_callbacks_test def test_crawlspider_with_async_callback(self): crawler = get_crawler(CrawlSpiderWithAsyncCallback) with LogCapture() as log: @@ -481,7 +481,7 @@ class TestCrawlSpider: assert "[parse_async] status 201 (foo: None)" in str(log) assert "[parse_async] status 202 (foo: bar)" in str(log) - @inlineCallbacks + @inline_callbacks_test def test_crawlspider_with_async_generator_callback(self): crawler = get_crawler(CrawlSpiderWithAsyncGeneratorCallback) with LogCapture() as log: @@ -491,7 +491,7 @@ class TestCrawlSpider: assert "[parse_async_gen] status 201 (foo: None)" in str(log) assert "[parse_async_gen] status 202 (foo: bar)" in str(log) - @inlineCallbacks + @inline_callbacks_test def test_crawlspider_with_errback(self): crawler = get_crawler(CrawlSpiderWithErrback) with LogCapture() as log: @@ -504,7 +504,7 @@ class TestCrawlSpider: assert "[errback] status 500" in str(log) assert "[errback] status 501" in str(log) - @inlineCallbacks + @inline_callbacks_test def test_crawlspider_process_request_cb_kwargs(self): crawler = get_crawler(CrawlSpiderWithProcessRequestCallbackKeywordArguments) with LogCapture() as log: @@ -514,7 +514,7 @@ class TestCrawlSpider: assert "[parse] status 201 (foo: process_request)" in str(log) assert "[parse] status 202 (foo: bar)" in str(log) - @inlineCallbacks + @inline_callbacks_test def test_async_def_parse(self): crawler = get_crawler(AsyncDefSpider) with LogCapture() as log: @@ -524,7 +524,7 @@ class TestCrawlSpider: assert "Got response 200" in str(log) @pytest.mark.only_asyncio - @inlineCallbacks + @inline_callbacks_test def test_async_def_asyncio_parse(self): crawler = get_crawler( AsyncDefAsyncioSpider, @@ -539,7 +539,7 @@ class TestCrawlSpider: assert "Got response 200" in str(log) @pytest.mark.only_asyncio - @deferred_f_from_coro_f + @coroutine_test async def test_async_def_asyncio_parse_items_list(self): log, items, _ = await self._run_spider(AsyncDefAsyncioReturnSpider) assert "Got response 200" in str(log) @@ -547,7 +547,7 @@ class TestCrawlSpider: assert {"id": 2} in items @pytest.mark.only_asyncio - @inlineCallbacks + @inline_callbacks_test def test_async_def_asyncio_parse_items_single_element(self): items = [] @@ -564,7 +564,7 @@ class TestCrawlSpider: assert {"foo": 42} in items @pytest.mark.only_asyncio - @deferred_f_from_coro_f + @coroutine_test async def test_async_def_asyncgen_parse(self): log, _, stats = await self._run_spider(AsyncDefAsyncioGenSpider) assert "Got response 200" in str(log) @@ -572,7 +572,7 @@ class TestCrawlSpider: assert itemcount == 1 @pytest.mark.only_asyncio - @deferred_f_from_coro_f + @coroutine_test async def test_async_def_asyncgen_parse_loop(self): log, items, stats = await self._run_spider(AsyncDefAsyncioGenLoopSpider) assert "Got response 200" in str(log) @@ -582,7 +582,7 @@ class TestCrawlSpider: assert {"foo": i} in items @pytest.mark.only_asyncio - @deferred_f_from_coro_f + @coroutine_test async def test_async_def_asyncgen_parse_exc(self): log, items, stats = await self._run_spider(AsyncDefAsyncioGenExcSpider) log = str(log) @@ -594,7 +594,7 @@ class TestCrawlSpider: assert {"foo": i} in items @pytest.mark.only_asyncio - @deferred_f_from_coro_f + @coroutine_test async def test_async_def_asyncgen_parse_complex(self): _, items, stats = await self._run_spider(AsyncDefAsyncioGenComplexSpider) itemcount = stats.get_value("item_scraped_count") @@ -606,30 +606,30 @@ class TestCrawlSpider: assert {"index2": i} in items @pytest.mark.only_asyncio - @deferred_f_from_coro_f + @coroutine_test async def test_async_def_asyncio_parse_reqs_list(self): log, *_ = await self._run_spider(AsyncDefAsyncioReqsReturnSpider) for req_id in range(3): assert f"Got response 200, req_id {req_id}" in str(log) @pytest.mark.only_not_asyncio - @deferred_f_from_coro_f + @coroutine_test async def test_async_def_deferred_direct(self): _, items, _ = await self._run_spider(AsyncDefDeferredDirectSpider) assert items == [{"code": 200}] @pytest.mark.only_asyncio - @deferred_f_from_coro_f + @coroutine_test async def test_async_def_deferred_wrapped(self): _, items, _ = await self._run_spider(AsyncDefDeferredWrappedSpider) assert items == [{"code": 200}] - @deferred_f_from_coro_f + @coroutine_test async def test_async_def_deferred_maybe_wrapped(self): _, items, _ = await self._run_spider(AsyncDefDeferredMaybeWrappedSpider) assert items == [{"code": 200}] - @inlineCallbacks + @inline_callbacks_test def test_response_ssl_certificate_none(self): crawler = get_crawler(SingleRequestSpider) url = self.mockserver.url("/echo?body=test", is_secure=False) @@ -649,7 +649,7 @@ class TestCrawlSpider: ), ], ) - @deferred_f_from_coro_f + @coroutine_test async def test_response_ssl_certificate( self, mockserver: MockServer, url: str ) -> None: @@ -675,7 +675,7 @@ class TestCrawlSpider: ), ], ) - @deferred_f_from_coro_f + @coroutine_test async def test_response_ip_address(self, mockserver: MockServer, url: str) -> None: crawler = get_crawler(SingleRequestSpider) url = mockserver.url(url) @@ -686,7 +686,7 @@ class TestCrawlSpider: assert isinstance(ip_address, IPv4Address) assert str(ip_address) == gethostbyname(expected_netloc) - @inlineCallbacks + @inline_callbacks_test def test_bytes_received_stop_download_callback(self): crawler = get_crawler(BytesReceivedCallbackSpider) yield crawler.crawl(mockserver=self.mockserver) @@ -700,7 +700,7 @@ class TestCrawlSpider: < crawler.spider.full_response_length ) - @inlineCallbacks + @inline_callbacks_test def test_bytes_received_stop_download_errback(self): crawler = get_crawler(BytesReceivedErrbackSpider) yield crawler.crawl(mockserver=self.mockserver) @@ -716,7 +716,7 @@ class TestCrawlSpider: < crawler.spider.full_response_length ) - @inlineCallbacks + @inline_callbacks_test def test_headers_received_stop_download_callback(self): crawler = get_crawler(HeadersReceivedCallbackSpider) yield crawler.crawl(mockserver=self.mockserver) @@ -726,7 +726,7 @@ class TestCrawlSpider: "headers_received" ) - @inlineCallbacks + @inline_callbacks_test def test_headers_received_stop_download_errback(self): crawler = get_crawler(HeadersReceivedErrbackSpider) yield crawler.crawl(mockserver=self.mockserver) @@ -738,7 +738,7 @@ class TestCrawlSpider: "failure" ].value.response.headers == crawler.spider.meta.get("headers_received") - @inlineCallbacks + @inline_callbacks_test def test_spider_callback_deferred_deprecated(self): def cb(response: Response) -> Any: return succeed(None) @@ -750,7 +750,7 @@ class TestCrawlSpider: ): yield crawler.crawl(seed=self.mockserver.url("/"), callback_func=cb) - @inlineCallbacks + @inline_callbacks_test def test_spider_errback(self): failures = [] @@ -767,7 +767,7 @@ class TestCrawlSpider: assert "HTTP status code is not handled or not allowed" in str(log) assert "Spider error processing" not in str(log) - @inlineCallbacks + @inline_callbacks_test def test_spider_errback_silence(self): failures = [] @@ -783,7 +783,7 @@ class TestCrawlSpider: assert "HTTP status code is not handled or not allowed" not in str(log) assert "Spider error processing" not in str(log) - @inlineCallbacks + @inline_callbacks_test def test_spider_errback_exception(self): def eb(failure: Failure) -> None: raise ValueError("foo") @@ -795,7 +795,7 @@ class TestCrawlSpider: ) assert "Spider error processing" in str(log) - @inlineCallbacks + @inline_callbacks_test def test_spider_errback_item(self): def eb(failure: Failure) -> Any: return {"foo": "bar"} @@ -809,7 +809,7 @@ class TestCrawlSpider: assert "Spider error processing" not in str(log) assert "'item_scraped_count': 1" in str(log) - @inlineCallbacks + @inline_callbacks_test def test_spider_errback_request(self): def eb(failure: Failure) -> Request: return Request(self.mockserver.url("/")) @@ -823,7 +823,7 @@ class TestCrawlSpider: assert "Spider error processing" not in str(log) assert "Crawled (200)" in str(log) - @inlineCallbacks + @inline_callbacks_test def test_spider_errback_downloader_error(self): failures = [] @@ -840,7 +840,7 @@ class TestCrawlSpider: assert "Error downloading" in str(log) assert "Spider error processing" not in str(log) - @inlineCallbacks + @inline_callbacks_test def test_spider_errback_downloader_error_exception(self): def eb(failure: Failure) -> None: raise ValueError("foo") @@ -853,7 +853,7 @@ class TestCrawlSpider: assert "Error downloading" in str(log) assert "Spider error processing" in str(log) - @inlineCallbacks + @inline_callbacks_test def test_spider_errback_downloader_error_item(self): def eb(failure: Failure) -> Any: return {"foo": "bar"} @@ -867,7 +867,7 @@ class TestCrawlSpider: assert "Spider error processing" not in str(log) assert "'item_scraped_count': 1" in str(log) - @inlineCallbacks + @inline_callbacks_test def test_spider_errback_downloader_error_request(self): def eb(failure: Failure) -> Request: return Request(self.mockserver.url("/")) @@ -881,7 +881,7 @@ class TestCrawlSpider: assert "Spider error processing" not in str(log) assert "Crawled (200)" in str(log) - @inlineCallbacks + @inline_callbacks_test def test_spider_errback_deferred_deprecated(self): def eb(failure: Failure) -> Any: return succeed(None) @@ -895,7 +895,7 @@ class TestCrawlSpider: seed=self.mockserver.url("/status?n=400"), errback_func=eb ) - @inlineCallbacks + @inline_callbacks_test def test_raise_closespider(self): def cb(response): raise CloseSpider @@ -906,7 +906,7 @@ class TestCrawlSpider: assert "Closing spider (cancelled)" in str(log) assert "Spider error processing" not in str(log) - @inlineCallbacks + @inline_callbacks_test def test_raise_closespider_reason(self): def cb(response): raise CloseSpider("my_reason") diff --git a/tests/test_crawler.py b/tests/test_crawler.py index 8de38114c..ba33d0bbe 100644 --- a/tests/test_crawler.py +++ b/tests/test_crawler.py @@ -41,7 +41,7 @@ from scrapy.utils.spider import DefaultSpider from scrapy.utils.test import get_crawler, get_reactor_settings from tests.mockserver.http import MockServer from tests.utils import get_script_run_env -from tests.utils.decorators import deferred_f_from_coro_f, inlineCallbacks +from tests.utils.decorators import coroutine_test, inline_callbacks_test BASE_SETTINGS: dict[str, Any] = {} @@ -101,21 +101,21 @@ class TestCrawler(TestBaseCrawler): with pytest.raises(ValueError, match="spidercls argument must be a class"): Crawler(DefaultSpider()) - @inlineCallbacks + @inline_callbacks_test def test_crawler_crawl_twice_seq_unsupported(self): crawler = get_raw_crawler(NoRequestsSpider, BASE_SETTINGS) yield crawler.crawl() with pytest.raises(RuntimeError, match="more than once on the same instance"): yield crawler.crawl() - @deferred_f_from_coro_f + @coroutine_test async def test_crawler_crawl_async_twice_seq_unsupported(self): crawler = get_raw_crawler(NoRequestsSpider, BASE_SETTINGS) await crawler.crawl_async() with pytest.raises(RuntimeError, match="more than once on the same instance"): await crawler.crawl_async() - @inlineCallbacks + @inline_callbacks_test def test_crawler_crawl_twice_parallel_unsupported(self): crawler = get_raw_crawler(NoRequestsSpider, BASE_SETTINGS) d1 = crawler.crawl() @@ -125,7 +125,7 @@ class TestCrawler(TestBaseCrawler): yield d2 @pytest.mark.only_asyncio - @deferred_f_from_coro_f + @coroutine_test async def test_crawler_crawl_async_twice_parallel_unsupported(self): crawler = get_raw_crawler(NoRequestsSpider, BASE_SETTINGS) t1 = asyncio.create_task(crawler.crawl_async()) @@ -172,7 +172,7 @@ class TestCrawler(TestBaseCrawler): addon = crawler.get_addon(ChildAddon) assert addon is None - @inlineCallbacks + @inline_callbacks_test def test_get_downloader_middleware(self): class ParentDownloaderMiddleware: pass @@ -235,7 +235,7 @@ class TestCrawler(TestBaseCrawler): with pytest.raises(RuntimeError): crawler.get_downloader_middleware(DefaultSpider) - @inlineCallbacks + @inline_callbacks_test def test_get_downloader_middleware_no_engine(self): class MySpider(Spider): name = "myspider" @@ -252,7 +252,7 @@ class TestCrawler(TestBaseCrawler): with pytest.raises(RuntimeError): yield crawler.crawl() - @inlineCallbacks + @inline_callbacks_test def test_get_extension(self): class ParentExtension: pass @@ -315,7 +315,7 @@ class TestCrawler(TestBaseCrawler): with pytest.raises(RuntimeError): crawler.get_extension(DefaultSpider) - @inlineCallbacks + @inline_callbacks_test def test_get_extension_no_engine(self): class MySpider(Spider): name = "myspider" @@ -332,7 +332,7 @@ class TestCrawler(TestBaseCrawler): with pytest.raises(RuntimeError): yield crawler.crawl() - @inlineCallbacks + @inline_callbacks_test def test_get_item_pipeline(self): class ParentItemPipeline: pass @@ -395,7 +395,7 @@ class TestCrawler(TestBaseCrawler): with pytest.raises(RuntimeError): crawler.get_item_pipeline(DefaultSpider) - @inlineCallbacks + @inline_callbacks_test def test_get_item_pipeline_no_engine(self): class MySpider(Spider): name = "myspider" @@ -412,7 +412,7 @@ class TestCrawler(TestBaseCrawler): with pytest.raises(RuntimeError): yield crawler.crawl() - @inlineCallbacks + @inline_callbacks_test def test_get_spider_middleware(self): class ParentSpiderMiddleware: pass @@ -475,7 +475,7 @@ class TestCrawler(TestBaseCrawler): with pytest.raises(RuntimeError): crawler.get_spider_middleware(DefaultSpider) - @inlineCallbacks + @inline_callbacks_test def test_get_spider_middleware_no_engine(self): class MySpider(Spider): name = "myspider" @@ -516,7 +516,7 @@ class TestCrawlerLogging: get_crawler(MySpider) assert get_scrapy_root_handler() is None - @deferred_f_from_coro_f + @coroutine_test async def test_spider_custom_settings_log_level(self, tmp_path): log_file = Path(tmp_path, "log.txt") log_file.write_text("previous message\n", encoding="utf-8") @@ -686,20 +686,20 @@ class TestCrawlerRunnerHasSpider: def _crawl(runner, spider): return runner.crawl(spider) - @inlineCallbacks + @inline_callbacks_test def test_crawler_runner_bootstrap_successful(self): runner = self._runner() yield self._crawl(runner, NoRequestsSpider) assert not runner.bootstrap_failed - @inlineCallbacks + @inline_callbacks_test def test_crawler_runner_bootstrap_successful_for_several(self): runner = self._runner() yield self._crawl(runner, NoRequestsSpider) yield self._crawl(runner, NoRequestsSpider) assert not runner.bootstrap_failed - @inlineCallbacks + @inline_callbacks_test def test_crawler_runner_bootstrap_failed(self): runner = self._runner() @@ -712,7 +712,7 @@ class TestCrawlerRunnerHasSpider: assert runner.bootstrap_failed - @inlineCallbacks + @inline_callbacks_test def test_crawler_runner_bootstrap_failed_for_several(self): runner = self._runner() @@ -727,7 +727,7 @@ class TestCrawlerRunnerHasSpider: assert runner.bootstrap_failed - @inlineCallbacks + @inline_callbacks_test def test_crawler_runner_asyncio_enabled_true( self, reactor_pytest: str ) -> Generator[Deferred[Any], Any, None]: @@ -959,7 +959,7 @@ class TestCrawlerProcessSubprocessBase(ScriptRunnerMixin): p.expect_exact("Spider closed (shutdown)") p.wait() - @inlineCallbacks + @inline_callbacks_test def test_shutdown_forced(self): sig = signal.SIGINT if sys.platform != "win32" else signal.SIGBREAK args = self.get_script_args("sleeping.py", "10") @@ -1297,7 +1297,7 @@ def test_log_scrapy_info(settings, items, caplog): assert re.search(r"^Versions:\n{'" + expected_items_pattern + "'}$", version_string) -@deferred_f_from_coro_f +@coroutine_test async def test_deprecated_crawler_stop() -> None: crawler = get_crawler(DefaultSpider) d = crawler.crawl() diff --git a/tests/test_downloader_handlers.py b/tests/test_downloader_handlers.py index 7844415f5..4589e667f 100644 --- a/tests/test_downloader_handlers.py +++ b/tests/test_downloader_handlers.py @@ -21,7 +21,7 @@ from scrapy.responsetypes import responsetypes from scrapy.utils.boto import is_botocore_available from scrapy.utils.misc import build_from_crawler from scrapy.utils.test import get_crawler -from tests.utils.decorators import deferred_f_from_coro_f +from tests.utils.decorators import coroutine_test class DummyDH: @@ -126,7 +126,7 @@ class TestFile: os.close(self.fd) Path(self.tmpname).unlink() - @deferred_f_from_coro_f + @coroutine_test async def test_download(self): request = Request(path_to_file_uri(self.tmpname)) assert request.url.upper().endswith("%5E") @@ -136,7 +136,7 @@ class TestFile: assert response.body == b"0123456789" assert response.protocol is None - @deferred_f_from_coro_f + @coroutine_test async def test_non_existent(self): request = Request(path_to_file_uri(mkdtemp())) # the specific exception differs between platforms @@ -163,7 +163,7 @@ class TestS3Anon: self.s3reqh = build_from_crawler(S3DownloadHandler, crawler) self.download_request = self.s3reqh.download_request - @deferred_f_from_coro_f + @coroutine_test async def test_anon_request(self): req = Request("s3://aws-publicdatasets/") httpreq = await self.download_request(req) @@ -205,7 +205,7 @@ class TestS3: mock_formatdate.return_value = date yield - @deferred_f_from_coro_f + @coroutine_test async def test_request_signing1(self): # gets an object from the johnsmith bucket. date = "Tue, 27 Mar 2007 19:36:42 +0000" @@ -217,7 +217,7 @@ class TestS3: == b"AWS 0PN5J17HBGZHT7JJ3X82:xXjDGYUmKxnwqr5KXNPGldn5LbA=" ) - @deferred_f_from_coro_f + @coroutine_test async def test_request_signing2(self): # puts an object into the johnsmith bucket. date = "Tue, 27 Mar 2007 21:15:45 +0000" @@ -237,7 +237,7 @@ class TestS3: == b"AWS 0PN5J17HBGZHT7JJ3X82:hcicpDDvL9SsO6AkvxqmIWkmOuQ=" ) - @deferred_f_from_coro_f + @coroutine_test async def test_request_signing3(self): # lists the content of the johnsmith bucket. date = "Tue, 27 Mar 2007 19:42:41 +0000" @@ -256,7 +256,7 @@ class TestS3: == b"AWS 0PN5J17HBGZHT7JJ3X82:jsRt/rhG+Vtp88HrYL706QhE4w4=" ) - @deferred_f_from_coro_f + @coroutine_test async def test_request_signing4(self): # fetches the access control policy sub-resource for the 'johnsmith' bucket. date = "Tue, 27 Mar 2007 19:44:46 +0000" @@ -268,7 +268,7 @@ class TestS3: == b"AWS 0PN5J17HBGZHT7JJ3X82:thdUi9VAkzhkniLj96JIrOPGi0g=" ) - @deferred_f_from_coro_f + @coroutine_test async def test_request_signing6(self): # uploads an object to a CNAME style virtual hosted bucket with metadata. date = "Tue, 27 Mar 2007 21:06:08 +0000" @@ -297,7 +297,7 @@ class TestS3: == b"AWS 0PN5J17HBGZHT7JJ3X82:C0FlOtU8Ylb9KDTpZqYkZPX91iI=" ) - @deferred_f_from_coro_f + @coroutine_test async def test_request_signing7(self): # ensure that spaces are quoted properly before signing date = "Tue, 27 Mar 2007 19:42:41 +0000" @@ -327,7 +327,7 @@ class TestDataURI: download_handler = build_from_crawler(DataURIDownloadHandler, crawler) self.download_request = download_handler.download_request - @deferred_f_from_coro_f + @coroutine_test async def test_response_attrs(self): uri = "data:,A%20brief%20note" request = Request(uri) @@ -335,7 +335,7 @@ class TestDataURI: assert response.url == uri assert not response.headers - @deferred_f_from_coro_f + @coroutine_test async def test_default_mediatype_encoding(self): request = Request("data:,A%20brief%20note") response = await self.download_request(request) @@ -343,7 +343,7 @@ class TestDataURI: assert type(response) is responsetypes.from_mimetype("text/plain") # pylint: disable=unidiomatic-typecheck assert response.encoding == "US-ASCII" - @deferred_f_from_coro_f + @coroutine_test async def test_default_mediatype(self): request = Request("data:;charset=iso-8859-7,%be%d3%be") response = await self.download_request(request) @@ -351,7 +351,7 @@ class TestDataURI: assert type(response) is responsetypes.from_mimetype("text/plain") # pylint: disable=unidiomatic-typecheck assert response.encoding == "iso-8859-7" - @deferred_f_from_coro_f + @coroutine_test async def test_text_charset(self): request = Request("data:text/plain;charset=iso-8859-7,%be%d3%be") response = await self.download_request(request) @@ -359,7 +359,7 @@ class TestDataURI: assert response.body == b"\xbe\xd3\xbe" assert response.encoding == "iso-8859-7" - @deferred_f_from_coro_f + @coroutine_test async def test_mediatype_parameters(self): request = Request( "data:text/plain;foo=%22foo;bar%5C%22%22;" @@ -371,13 +371,13 @@ class TestDataURI: assert type(response) is responsetypes.from_mimetype("text/plain") # pylint: disable=unidiomatic-typecheck assert response.encoding == "utf-8" - @deferred_f_from_coro_f + @coroutine_test async def test_base64(self): request = Request("data:text/plain;base64,SGVsbG8sIHdvcmxkLg%3D%3D") response = await self.download_request(request) assert response.text == "Hello, world." - @deferred_f_from_coro_f + @coroutine_test async def test_protocol(self): request = Request("data:,") response = await self.download_request(request) diff --git a/tests/test_downloader_handlers_http_base.py b/tests/test_downloader_handlers_http_base.py index 4d4b4ecaa..ff76c88fe 100644 --- a/tests/test_downloader_handlers_http_base.py +++ b/tests/test_downloader_handlers_http_base.py @@ -34,7 +34,7 @@ from tests import NON_EXISTING_RESOLVABLE from tests.mockserver.proxy_echo import ProxyEchoMockServer from tests.mockserver.simple_https import SimpleMockServer from tests.spiders import SingleRequestSpider -from tests.utils.decorators import deferred_f_from_coro_f +from tests.utils.decorators import coroutine_test if TYPE_CHECKING: from collections.abc import AsyncGenerator, Generator @@ -63,21 +63,21 @@ class TestHttpBase(ABC): finally: await dh.close() - @deferred_f_from_coro_f + @coroutine_test async def test_unsupported_scheme(self) -> None: request = Request("ftp://unsupported.scheme") async with self.get_dh() as download_handler: with pytest.raises(UnsupportedURLSchemeError): await download_handler.download_request(request) - @deferred_f_from_coro_f + @coroutine_test async def test_download(self, mockserver: MockServer) -> None: request = Request(mockserver.url("/text", is_secure=self.is_secure)) async with self.get_dh() as download_handler: response = await download_handler.download_request(request) assert response.body == b"Works" - @deferred_f_from_coro_f + @coroutine_test async def test_download_head(self, mockserver: MockServer) -> None: request = Request( mockserver.url("/text", is_secure=self.is_secure), method="HEAD" @@ -94,7 +94,7 @@ class TestHttpBase(ABC): if http_status.value == 200 or http_status.value // 100 in (4, 5) ], ) - @deferred_f_from_coro_f + @coroutine_test async def test_download_has_correct_http_status_code( self, mockserver: MockServer, http_status: HTTPStatus ) -> None: @@ -105,7 +105,7 @@ class TestHttpBase(ABC): response = await download_handler.download_request(request) assert response.status == http_status.value - @deferred_f_from_coro_f + @coroutine_test async def test_server_receives_correct_request_headers( self, mockserver: MockServer ) -> None: @@ -132,7 +132,7 @@ class TestHttpBase(ABC): assert header_name in body["headers"] assert body["headers"][header_name] == [header_value] - @deferred_f_from_coro_f + @coroutine_test async def test_request_header_none(self, mockserver: MockServer) -> None: """Adding a header with None as the value should not send that header.""" request_headers = { @@ -158,7 +158,7 @@ class TestHttpBase(ABC): [("X-Custom-Header", "foo"), ("X-Custom-Header", "bar")], ], ) - @deferred_f_from_coro_f + @coroutine_test async def test_request_header_duplicate( self, mockserver: MockServer, request_headers: Any ) -> None: @@ -174,7 +174,7 @@ class TestHttpBase(ABC): assert "headers" in body assert body["headers"]["X-Custom-Header"] == ["foo", "bar"] - @deferred_f_from_coro_f + @coroutine_test async def test_server_receives_correct_request_body( self, mockserver: MockServer ) -> None: @@ -191,7 +191,7 @@ class TestHttpBase(ABC): body = json.loads(response.body.decode("utf-8")) assert json.loads(body["body"]) == request_body - @deferred_f_from_coro_f + @coroutine_test async def test_download_has_correct_response_headers( self, mockserver: MockServer ) -> None: @@ -230,14 +230,14 @@ class TestHttpBase(ABC): header_value, encoding="utf-8" ) - @deferred_f_from_coro_f + @coroutine_test async def test_redirect_status(self, mockserver: MockServer) -> None: request = Request(mockserver.url("/redirect", is_secure=self.is_secure)) async with self.get_dh() as download_handler: response = await download_handler.download_request(request) assert response.status == 302 - @deferred_f_from_coro_f + @coroutine_test async def test_redirect_status_head(self, mockserver: MockServer) -> None: request = Request( mockserver.url("/redirect", is_secure=self.is_secure), method="HEAD" @@ -246,7 +246,7 @@ class TestHttpBase(ABC): response = await download_handler.download_request(request) assert response.status == 302 - @deferred_f_from_coro_f + @coroutine_test async def test_timeout_download_from_spider_nodata_rcvd( self, mockserver: MockServer, reactor_pytest: str ) -> None: @@ -264,7 +264,7 @@ class TestHttpBase(ABC): with pytest.raises(DownloadTimeoutError): await maybe_deferred_to_future(d) - @deferred_f_from_coro_f + @coroutine_test async def test_timeout_download_from_spider_server_hangs( self, mockserver: MockServer, @@ -286,7 +286,7 @@ class TestHttpBase(ABC): await maybe_deferred_to_future(d) @pytest.mark.parametrize("send_header", [True, False]) - @deferred_f_from_coro_f + @coroutine_test async def test_host_header(self, send_header: bool, mockserver: MockServer) -> None: host_port = f"{mockserver.host}:{mockserver.port(is_secure=self.is_secure)}" request = Request( @@ -301,7 +301,7 @@ class TestHttpBase(ABC): else: assert not request.headers - @deferred_f_from_coro_f + @coroutine_test async def test_content_length_zero_bodyless_post_request_headers( self, mockserver: MockServer ) -> None: @@ -322,7 +322,7 @@ class TestHttpBase(ABC): response = await download_handler.download_request(request) assert response.body == b"0" - @deferred_f_from_coro_f + @coroutine_test async def test_content_length_zero_bodyless_post_only_one( self, mockserver: MockServer ) -> None: @@ -336,7 +336,7 @@ class TestHttpBase(ABC): assert len(contentlengths) == 1 assert contentlengths == [b"0"] - @deferred_f_from_coro_f + @coroutine_test async def test_payload(self, mockserver: MockServer) -> None: body = b"1" * 100 # PayloadResource requires body length to be 100 request = Request( @@ -348,7 +348,7 @@ class TestHttpBase(ABC): response = await download_handler.download_request(request) assert response.body == body - @deferred_f_from_coro_f + @coroutine_test async def test_response_header_content_length(self, mockserver: MockServer) -> None: request = Request( mockserver.url("/text", is_secure=self.is_secure), method="GET" @@ -364,7 +364,7 @@ class TestHttpBase(ABC): ("foo", b"\n.", HtmlResponse), ], ) - @deferred_f_from_coro_f + @coroutine_test async def test_response_class( self, filename: str, @@ -379,14 +379,14 @@ class TestHttpBase(ABC): response = await download_handler.download_request(request) assert type(response) is response_class # pylint: disable=unidiomatic-typecheck - @deferred_f_from_coro_f + @coroutine_test async def test_get_duplicate_header(self, mockserver: MockServer) -> None: request = Request(mockserver.url("/duplicate-header", is_secure=self.is_secure)) async with self.get_dh() as download_handler: response = await download_handler.download_request(request) assert response.headers.getlist(b"Set-Cookie") == [b"a=b", b"c=d"] - @deferred_f_from_coro_f + @coroutine_test async def test_download_is_not_automatically_gzip_decoded( self, mockserver: MockServer ) -> None: @@ -420,7 +420,7 @@ class TestHttpBase(ABC): expected_decoding = bytes(data, encoding="utf-8") assert gzip.decompress(response.body) == expected_decoding - @deferred_f_from_coro_f + @coroutine_test async def test_no_cookie_processing_or_persistence( self, mockserver: MockServer ) -> None: @@ -451,14 +451,14 @@ class TestHttpBase(ABC): class TestHttp11Base(TestHttpBase): """HTTP 1.1 test case""" - @deferred_f_from_coro_f + @coroutine_test async def test_download_without_maxsize_limit(self, mockserver: MockServer) -> None: request = Request(mockserver.url("/text", is_secure=self.is_secure)) async with self.get_dh() as download_handler: response = await download_handler.download_request(request) assert response.body == b"Works" - @deferred_f_from_coro_f + @coroutine_test async def test_response_class_choosing_request( self, mockserver: MockServer ) -> None: @@ -473,7 +473,7 @@ class TestHttp11Base(TestHttpBase): response = await download_handler.download_request(request) assert type(response) is TextResponse # pylint: disable=unidiomatic-typecheck - @deferred_f_from_coro_f + @coroutine_test async def test_download_with_maxsize(self, mockserver: MockServer) -> None: request = Request(mockserver.url("/text", is_secure=self.is_secure)) @@ -487,7 +487,7 @@ class TestHttp11Base(TestHttpBase): with pytest.raises(DownloadCancelledError): await download_handler.download_request(request) - @deferred_f_from_coro_f + @coroutine_test async def test_download_with_maxsize_very_large_file( self, mockserver: MockServer, caplog: pytest.LogCaptureFixture ) -> None: @@ -498,7 +498,7 @@ class TestHttp11Base(TestHttpBase): assert "larger than download max size" in caplog.text - @deferred_f_from_coro_f + @coroutine_test async def test_download_with_maxsize_per_req(self, mockserver: MockServer) -> None: meta = {"download_maxsize": 2} request = Request(mockserver.url("/text", is_secure=self.is_secure), meta=meta) @@ -506,7 +506,7 @@ class TestHttp11Base(TestHttpBase): with pytest.raises(DownloadCancelledError): await download_handler.download_request(request) - @deferred_f_from_coro_f + @coroutine_test async def test_download_with_small_maxsize_via_setting( self, mockserver: MockServer ) -> None: @@ -515,7 +515,7 @@ class TestHttp11Base(TestHttpBase): with pytest.raises(DownloadCancelledError): await download_handler.download_request(request) - @deferred_f_from_coro_f + @coroutine_test async def test_download_with_large_maxsize_via_setting( self, mockserver: MockServer ) -> None: @@ -524,7 +524,7 @@ class TestHttp11Base(TestHttpBase): response = await download_handler.download_request(request) assert response.body == b"Works" - @deferred_f_from_coro_f + @coroutine_test async def test_download_chunked_content(self, mockserver: MockServer) -> None: request = Request(mockserver.url("/chunked", is_secure=self.is_secure)) async with self.get_dh() as download_handler: @@ -532,7 +532,7 @@ class TestHttp11Base(TestHttpBase): assert response.body == b"chunked content\n" @pytest.mark.parametrize("url", ["broken", "broken-chunked"]) - @deferred_f_from_coro_f + @coroutine_test async def test_download_cause_data_loss( self, url: str, mockserver: MockServer ) -> None: @@ -541,7 +541,7 @@ class TestHttp11Base(TestHttpBase): with pytest.raises(ResponseDataLossError): await download_handler.download_request(request) - @deferred_f_from_coro_f + @coroutine_test async def test_download_cause_data_loss_double_warning( self, caplog: pytest.LogCaptureFixture, mockserver: MockServer ) -> None: @@ -557,7 +557,7 @@ class TestHttp11Base(TestHttpBase): assert "Got data loss" not in caplog.text @pytest.mark.parametrize("url", ["broken", "broken-chunked"]) - @deferred_f_from_coro_f + @coroutine_test async def test_download_allow_data_loss( self, url: str, mockserver: MockServer ) -> None: @@ -570,7 +570,7 @@ class TestHttp11Base(TestHttpBase): assert response.flags == ["dataloss"] @pytest.mark.parametrize("url", ["broken", "broken-chunked"]) - @deferred_f_from_coro_f + @coroutine_test async def test_download_allow_data_loss_via_setting( self, url: str, mockserver: MockServer ) -> None: @@ -581,7 +581,7 @@ class TestHttp11Base(TestHttpBase): response = await download_handler.download_request(request) assert response.flags == ["dataloss"] - @deferred_f_from_coro_f + @coroutine_test async def test_download_conn_failed(self) -> None: # copy of TestCrawl.test_retry_conn_failed() scheme = "https" if self.is_secure else "http" @@ -590,7 +590,7 @@ class TestHttp11Base(TestHttpBase): with pytest.raises(DownloadConnectionRefusedError): await download_handler.download_request(request) - @deferred_f_from_coro_f + @coroutine_test async def test_download_conn_lost(self, mockserver: MockServer) -> None: # copy of TestCrawl.test_retry_conn_lost() request = Request(mockserver.url("/drop?abort=0", is_secure=self.is_secure)) @@ -598,7 +598,7 @@ class TestHttp11Base(TestHttpBase): with pytest.raises(ResponseDataLossError): await download_handler.download_request(request) - @deferred_f_from_coro_f + @coroutine_test async def test_download_conn_aborted(self, mockserver: MockServer) -> None: # copy of TestCrawl.test_retry_conn_aborted() request = Request(mockserver.url("/drop?abort=1", is_secure=self.is_secure)) @@ -609,7 +609,7 @@ class TestHttp11Base(TestHttpBase): @pytest.mark.skipif( NON_EXISTING_RESOLVABLE, reason="Non-existing hosts are resolvable" ) - @deferred_f_from_coro_f + @coroutine_test async def test_download_dns_error(self) -> None: # copy of TestCrawl.test_retry_dns_error() scheme = "https" if self.is_secure else "http" @@ -618,7 +618,7 @@ class TestHttp11Base(TestHttpBase): with pytest.raises(CannotResolveHostError): await download_handler.download_request(request) - @deferred_f_from_coro_f + @coroutine_test async def test_protocol(self, mockserver: MockServer) -> None: request = Request( mockserver.url("/host", is_secure=self.is_secure), method="GET" @@ -642,7 +642,7 @@ class TestHttps11Base(TestHttp11Base): # (not just Scrapy) hang on /drop?abort=0. pytest.skip("Unable to test on HTTPS") - @deferred_f_from_coro_f + @coroutine_test async def test_tls_logging( self, mockserver: MockServer, caplog: pytest.LogCaptureFixture ) -> None: @@ -695,7 +695,7 @@ class TestSimpleHttpsBase(ABC): finally: await dh.close() - @deferred_f_from_coro_f + @coroutine_test async def test_download(self, url: str) -> None: request = Request(url) async with self.get_dh() as download_handler: @@ -738,7 +738,7 @@ class TestHttpWithCrawlerBase(ABC): is_secure = False - @deferred_f_from_coro_f + @coroutine_test async def test_download_with_content_length(self, mockserver: MockServer) -> None: crawler = get_crawler(SingleRequestSpider, self.settings_dict) # http://localhost:8998/partial set Content-Length to 1024, use download_maxsize= 1000 to avoid @@ -755,7 +755,7 @@ class TestHttpWithCrawlerBase(ABC): failure = crawler.spider.meta["failure"] # type: ignore[attr-defined] assert isinstance(failure.value, DownloadCancelledError) - @deferred_f_from_coro_f + @coroutine_test async def test_download(self, mockserver: MockServer) -> None: crawler = get_crawler(SingleRequestSpider, self.settings_dict) await maybe_deferred_to_future( @@ -769,7 +769,7 @@ class TestHttpWithCrawlerBase(ABC): reason = crawler.spider.meta["close_reason"] # type: ignore[attr-defined] assert reason == "finished" - @deferred_f_from_coro_f + @coroutine_test async def test_response_ssl_certificate(self, mockserver: MockServer) -> None: if not self.is_secure: pytest.skip("Only applies to HTTPS") @@ -784,7 +784,7 @@ class TestHttpWithCrawlerBase(ABC): assert cert.getSubject().commonName == b"localhost" assert cert.getIssuer().commonName == b"localhost" - @deferred_f_from_coro_f + @coroutine_test async def test_response_ip_address(self, mockserver: MockServer) -> None: # copy of TestCrawl.test_response_ip_address() crawler = get_crawler(SingleRequestSpider, self.settings_dict) @@ -821,7 +821,7 @@ class TestHttpProxyBase(ABC): finally: await dh.close() - @deferred_f_from_coro_f + @coroutine_test async def test_download_with_proxy( self, proxy_mockserver: ProxyEchoMockServer ) -> None: @@ -833,7 +833,7 @@ class TestHttpProxyBase(ABC): assert response.url == request.url assert response.body == self.expected_http_proxy_request_body - @deferred_f_from_coro_f + @coroutine_test async def test_download_without_proxy( self, proxy_mockserver: ProxyEchoMockServer ) -> None: @@ -846,7 +846,7 @@ class TestHttpProxyBase(ABC): assert response.url == request.url assert response.body == b"/path/to/resource" - @deferred_f_from_coro_f + @coroutine_test async def test_download_with_proxy_https_timeout( self, proxy_mockserver: ProxyEchoMockServer ) -> None: @@ -860,7 +860,7 @@ class TestHttpProxyBase(ABC): await download_handler.download_request(request) assert domain in str(exc_info.value) - @deferred_f_from_coro_f + @coroutine_test async def test_download_with_proxy_without_http_scheme( self, proxy_mockserver: ProxyEchoMockServer ) -> None: diff --git a/tests/test_downloadermiddleware.py b/tests/test_downloadermiddleware.py index 10ac77aa0..d321c9047 100644 --- a/tests/test_downloadermiddleware.py +++ b/tests/test_downloadermiddleware.py @@ -16,7 +16,7 @@ from scrapy.spiders import Spider from scrapy.utils.defer import maybe_deferred_to_future from scrapy.utils.python import to_bytes from scrapy.utils.test import get_crawler, get_from_asyncio_queue -from tests.utils.decorators import deferred_f_from_coro_f +from tests.utils.decorators import coroutine_test if TYPE_CHECKING: from collections.abc import AsyncGenerator @@ -60,7 +60,7 @@ class TestManagerBase: class TestDefaults(TestManagerBase): """Tests default behavior with default settings""" - @deferred_f_from_coro_f + @coroutine_test async def test_request_response(self): req = Request("http://example.com/index.html") resp = Response(req.url, status=200) @@ -68,7 +68,7 @@ class TestDefaults(TestManagerBase): ret = await self._download(mwman, req, resp) assert isinstance(ret, Response), "Non-response returned" - @deferred_f_from_coro_f + @coroutine_test async def test_3xx_and_invalid_gzipped_body_must_redirect(self): """Regression test for a failure when redirecting a compressed request. @@ -101,7 +101,7 @@ class TestDefaults(TestManagerBase): "Not redirected to location header" ) - @deferred_f_from_coro_f + @coroutine_test async def test_200_and_invalid_gzipped_body_must_fail(self): req = Request("http://example.com") body = b"

You are being redirected

" @@ -124,7 +124,7 @@ class TestDefaults(TestManagerBase): class TestResponseFromProcessRequest(TestManagerBase): """Tests middleware returning a response from process_request.""" - @deferred_f_from_coro_f + @coroutine_test async def test_download_func_not_called(self): req = Request("http://example.com/index.html") resp = Response("http://example.com/index.html") @@ -144,7 +144,7 @@ class TestResponseFromProcessRequest(TestManagerBase): class TestResponseFromProcessException(TestManagerBase): """Tests middleware returning a response from process_exception.""" - @deferred_f_from_coro_f + @coroutine_test async def test_process_response_called(self): req = Request("http://example.com/index.html") resp = Response("http://example.com/index.html") @@ -173,7 +173,7 @@ class TestResponseFromProcessException(TestManagerBase): class TestInvalidOutput(TestManagerBase): - @deferred_f_from_coro_f + @coroutine_test async def test_invalid_process_request(self): """Invalid return value for process_request method should raise an exception""" req = Request("http://example.com/index.html") @@ -187,7 +187,7 @@ class TestInvalidOutput(TestManagerBase): with pytest.raises(_InvalidOutput): await self._download(mwman, req) - @deferred_f_from_coro_f + @coroutine_test async def test_invalid_process_response(self): """Invalid return value for process_response method should raise an exception""" req = Request("http://example.com/index.html") @@ -201,7 +201,7 @@ class TestInvalidOutput(TestManagerBase): with pytest.raises(_InvalidOutput): await self._download(mwman, req) - @deferred_f_from_coro_f + @coroutine_test async def test_invalid_process_exception(self): """Invalid return value for process_exception method should raise an exception""" req = Request("http://example.com/index.html") @@ -222,7 +222,7 @@ class TestInvalidOutput(TestManagerBase): class TestMiddlewareUsingDeferreds(TestManagerBase): """Middlewares using Deferreds (deprecated) should work""" - @deferred_f_from_coro_f + @coroutine_test async def test_deferred(self): req = Request("http://example.com/index.html") resp = Response("http://example.com/index.html") @@ -252,7 +252,7 @@ class TestMiddlewareUsingDeferreds(TestManagerBase): class TestMiddlewareUsingCoro(TestManagerBase): """Middlewares using asyncio coroutines should work""" - @deferred_f_from_coro_f + @coroutine_test async def test_asyncdef(self): req = Request("http://example.com/index.html") resp = Response("http://example.com/index.html") @@ -270,7 +270,7 @@ class TestMiddlewareUsingCoro(TestManagerBase): assert not download_func.called @pytest.mark.only_asyncio - @deferred_f_from_coro_f + @coroutine_test async def test_asyncdef_asyncio(self): req = Request("http://example.com/index.html") resp = Response("http://example.com/index.html") @@ -289,7 +289,7 @@ class TestMiddlewareUsingCoro(TestManagerBase): class TestDownloadDeprecated(TestManagerBase): - @deferred_f_from_coro_f + @coroutine_test async def test_mwman_download(self): req = Request("http://example.com/index.html") resp = Response(req.url, status=200) @@ -309,7 +309,7 @@ class TestDownloadDeprecated(TestManagerBase): class TestDeprecatedSpiderArg(TestManagerBase): - @deferred_f_from_coro_f + @coroutine_test async def test_deprecated_spider_arg(self): req = Request("http://example.com/index.html") resp = Response("http://example.com/index.html") diff --git a/tests/test_downloadermiddleware_robotstxt.py b/tests/test_downloadermiddleware_robotstxt.py index 9b4b2750b..15c68779d 100644 --- a/tests/test_downloadermiddleware_robotstxt.py +++ b/tests/test_downloadermiddleware_robotstxt.py @@ -16,7 +16,7 @@ from scrapy.settings import Settings from scrapy.utils.asyncio import call_later from scrapy.utils.defer import deferred_from_coro, maybe_deferred_to_future from tests.test_robotstxt_interface import rerp_available -from tests.utils.decorators import deferred_f_from_coro_f +from tests.utils.decorators import coroutine_test if TYPE_CHECKING: from scrapy.crawler import Crawler @@ -60,7 +60,7 @@ Disallow: /some/randome/page.html crawler.engine.download_async.side_effect = return_response return crawler - @deferred_f_from_coro_f + @coroutine_test async def test_robotstxt(self): middleware = RobotsTxtMiddleware(self._get_successful_crawler()) await self.assertNotIgnored(Request("http://site.local/allowed"), middleware) @@ -74,7 +74,7 @@ Disallow: /some/randome/page.html Request("http://site.local/wiki/Käyttäjä:"), middleware ) - @deferred_f_from_coro_f + @coroutine_test async def test_robotstxt_multiple_reqs(self) -> None: middleware = RobotsTxtMiddleware(self._get_successful_crawler()) d1 = deferred_from_coro( @@ -86,20 +86,20 @@ Disallow: /some/randome/page.html await maybe_deferred_to_future(DeferredList([d1, d2], fireOnOneErrback=True)) @pytest.mark.only_asyncio - @deferred_f_from_coro_f + @coroutine_test async def test_robotstxt_multiple_reqs_asyncio(self) -> None: middleware = RobotsTxtMiddleware(self._get_successful_crawler()) c1 = middleware.process_request(Request("http://site.local/allowed1")) c2 = middleware.process_request(Request("http://site.local/allowed2")) await asyncio.gather(c1, c2) - @deferred_f_from_coro_f + @coroutine_test async def test_robotstxt_ready_parser(self): middleware = RobotsTxtMiddleware(self._get_successful_crawler()) await self.assertNotIgnored(Request("http://site.local/allowed"), middleware) await self.assertNotIgnored(Request("http://site.local/allowed"), middleware) - @deferred_f_from_coro_f + @coroutine_test async def test_robotstxt_meta(self): middleware = RobotsTxtMiddleware(self._get_successful_crawler()) meta = {"dont_obey_robotstxt": True} @@ -128,7 +128,7 @@ Disallow: /some/randome/page.html crawler.engine.download_async.side_effect = return_response return crawler - @deferred_f_from_coro_f + @coroutine_test async def test_robotstxt_garbage(self): # garbage response should be discarded, equal 'allow all' middleware = RobotsTxtMiddleware(self._get_garbage_crawler()) @@ -150,7 +150,7 @@ Disallow: /some/randome/page.html crawler.engine.download_async.side_effect = return_response return crawler - @deferred_f_from_coro_f + @coroutine_test async def test_robotstxt_empty_response(self): # empty response should equal 'allow all' middleware = RobotsTxtMiddleware(self._get_emptybody_crawler()) @@ -158,7 +158,7 @@ Disallow: /some/randome/page.html await self.assertNotIgnored(Request("http://site.local/admin/main"), middleware) await self.assertNotIgnored(Request("http://site.local/static/"), middleware) - @deferred_f_from_coro_f + @coroutine_test async def test_robotstxt_error(self, caplog: pytest.LogCaptureFixture) -> None: self.crawler.settings.set("ROBOTSTXT_OBEY", True) err = CannotResolveHostError("Robotstxt address not found") @@ -174,7 +174,7 @@ Disallow: /some/randome/page.html await middleware.process_request(Request("http://site.local")) assert "Robotstxt address not found" in caplog.text - @deferred_f_from_coro_f + @coroutine_test async def test_robotstxt_immediate_error(self): self.crawler.settings.set("ROBOTSTXT_OBEY", True) err = CannotResolveHostError("Robotstxt address not found") @@ -187,7 +187,7 @@ Disallow: /some/randome/page.html middleware = RobotsTxtMiddleware(self.crawler) await self.assertNotIgnored(Request("http://site.local"), middleware) - @deferred_f_from_coro_f + @coroutine_test async def test_ignore_robotstxt_request(self): self.crawler.settings.set("ROBOTSTXT_OBEY", True) @@ -216,7 +216,7 @@ Disallow: /some/randome/page.html middleware.process_request_2(rp, Request("http://site.local/allowed")) rp.allowed.assert_called_once_with("http://site.local/allowed", "Examplebot") - @deferred_f_from_coro_f + @coroutine_test async def test_robotstxt_local_file(self): middleware = RobotsTxtMiddleware(self._get_emptybody_crawler()) middleware.process_request_2 = mock.MagicMock() diff --git a/tests/test_downloaderslotssettings.py b/tests/test_downloaderslotssettings.py index 7d2001e47..5ddb1cdfa 100644 --- a/tests/test_downloaderslotssettings.py +++ b/tests/test_downloaderslotssettings.py @@ -11,7 +11,7 @@ from scrapy.utils.spider import DefaultSpider from scrapy.utils.test import get_crawler from tests.mockserver.http import MockServer from tests.spiders import MetaSpider -from tests.utils.decorators import deferred_f_from_coro_f, inlineCallbacks +from tests.utils.decorators import coroutine_test, inline_callbacks_test class DownloaderSlotsSettingsTestSpider(MetaSpider): @@ -68,7 +68,7 @@ class TestCrawl: self.runner = CrawlerRunner() @pytest.mark.requires_http_handler - @inlineCallbacks + @inline_callbacks_test def test_delay(self): crawler = get_crawler(DownloaderSlotsSettingsTestSpider) yield crawler.crawl(mockserver=self.mockserver) @@ -137,7 +137,7 @@ def test_get_slot_deprecated_spider_arg(): "scrapy.pqueues.DownloaderAwarePriorityQueue", ], ) -@deferred_f_from_coro_f +@coroutine_test async def test_none_slot_with_priority_queue( mockserver: MockServer, priority_queue_class: str ) -> None: diff --git a/tests/test_engine.py b/tests/test_engine.py index 939ea0f6f..c77e3c5c9 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -35,7 +35,7 @@ from scrapy.utils.signal import disconnect_all from scrapy.utils.spider import DefaultSpider from scrapy.utils.test import get_crawler from tests import get_testdata -from tests.utils.decorators import deferred_f_from_coro_f, inlineCallbacks +from tests.utils.decorators import coroutine_test, inline_callbacks_test if TYPE_CHECKING: from scrapy.core.scheduler import Scheduler @@ -374,7 +374,7 @@ class TestEngineBase: class TestEngine(TestEngineBase): @pytest.mark.requires_http_handler - @deferred_f_from_coro_f + @coroutine_test async def test_crawler(self, mockserver: MockServer) -> None: for spider in ( MySpider, @@ -392,7 +392,7 @@ class TestEngine(TestEngineBase): self._assert_bytes_received(run) @pytest.mark.requires_http_handler - @deferred_f_from_coro_f + @coroutine_test async def test_crawler_dupefilter(self, mockserver: MockServer) -> None: run = CrawlerRun(DupeFilterSpider) await run.run(mockserver) @@ -400,14 +400,14 @@ class TestEngine(TestEngineBase): self._assert_dropped_requests(run) @pytest.mark.requires_http_handler - @deferred_f_from_coro_f + @coroutine_test async def test_crawler_itemerror(self, mockserver: MockServer) -> None: run = CrawlerRun(ItemZeroDivisionErrorSpider) await run.run(mockserver) self._assert_items_error(run) @pytest.mark.requires_http_handler - @deferred_f_from_coro_f + @coroutine_test async def test_crawler_change_close_reason_on_idle( self, mockserver: MockServer ) -> None: @@ -418,7 +418,7 @@ class TestEngine(TestEngineBase): "reason": "custom_reason", } == run.signals_caught[signals.spider_closed] - @deferred_f_from_coro_f + @coroutine_test async def test_close_downloader(self): e = ExecutionEngine(get_crawler(MySpider), lambda _: None) await e.close_async() @@ -436,7 +436,7 @@ class TestEngine(TestEngineBase): get_crawler(MySpider, {"DOWNLOADER": BadDownloader}), lambda _: None ) - @inlineCallbacks + @inline_callbacks_test def test_start_already_running_exception(self): crawler = get_crawler(DefaultSpider) crawler.spider = crawler._create_spider() @@ -449,7 +449,7 @@ class TestEngine(TestEngineBase): yield deferred_from_coro(e.stop_async()) @pytest.mark.only_asyncio - @deferred_f_from_coro_f + @coroutine_test async def test_start_already_running_exception_asyncio(self): crawler = get_crawler(DefaultSpider) crawler.spider = crawler._create_spider() @@ -460,7 +460,7 @@ class TestEngine(TestEngineBase): await asyncio.gather(e.start_async(), e.start_async()) await e.stop_async() - @inlineCallbacks + @inline_callbacks_test def test_start_request_processing_exception(self): class BadRequestFingerprinter: def fingerprint(self, request): @@ -527,7 +527,7 @@ class TestEngineDownloadAsync: async def _download(engine: ExecutionEngine, request: Request) -> Response: return await engine.download_async(request) - @deferred_f_from_coro_f + @coroutine_test async def test_download_async_success(self, engine): """Test basic successful async download of a request.""" request = Request("http://example.com") @@ -543,7 +543,7 @@ class TestEngineDownloadAsync: engine._slot.remove_request.assert_called_once_with(request) engine.downloader.fetch.assert_called_once_with(request) - @deferred_f_from_coro_f + @coroutine_test async def test_download_async_redirect(self, engine): """Test async download with a redirect request.""" original_request = Request("http://example.com") @@ -569,7 +569,7 @@ class TestEngineDownloadAsync: [call(original_request), call(redirect_request)] ) - @deferred_f_from_coro_f + @coroutine_test async def test_download_async_no_spider(self, engine): """Test async download attempt when no spider is available.""" request = Request("http://example.com") @@ -577,7 +577,7 @@ class TestEngineDownloadAsync: with pytest.raises(RuntimeError, match="No open spider to crawl:"): await self._download(engine, request) - @deferred_f_from_coro_f + @coroutine_test async def test_download_async_failure(self, engine): """Test async download when the downloader raises an exception.""" request = Request("http://example.com") @@ -647,7 +647,7 @@ class TestEngineCloseSpider: crawler.spider = crawler._create_spider() return crawler - @deferred_f_from_coro_f + @coroutine_test async def test_no_slot(self, crawler: Crawler) -> None: engine = ExecutionEngine(crawler, lambda _: None) crawler.engine = engine @@ -660,14 +660,14 @@ class TestEngineCloseSpider: engine._slot = slot await engine.close_spider_async() - @deferred_f_from_coro_f + @coroutine_test async def test_no_spider(self, crawler: Crawler) -> None: engine = ExecutionEngine(crawler, lambda _: None) with pytest.raises(RuntimeError, match="Spider not opened"): await engine.close_spider_async() engine.downloader.close() # cleanup - @deferred_f_from_coro_f + @coroutine_test async def test_exception_slot( self, crawler: Crawler, caplog: pytest.LogCaptureFixture ) -> None: @@ -679,7 +679,7 @@ class TestEngineCloseSpider: await engine.close_spider_async() assert "Slot close failure" in caplog.text - @deferred_f_from_coro_f + @coroutine_test async def test_exception_downloader( self, crawler: Crawler, caplog: pytest.LogCaptureFixture ) -> None: @@ -690,7 +690,7 @@ class TestEngineCloseSpider: await engine.close_spider_async() assert "Downloader close failure" in caplog.text - @deferred_f_from_coro_f + @coroutine_test async def test_exception_scraper( self, crawler: Crawler, caplog: pytest.LogCaptureFixture ) -> None: @@ -701,7 +701,7 @@ class TestEngineCloseSpider: await engine.close_spider_async() assert "Scraper close failure" in caplog.text - @deferred_f_from_coro_f + @coroutine_test async def test_exception_scheduler( self, crawler: Crawler, caplog: pytest.LogCaptureFixture ) -> None: @@ -713,7 +713,7 @@ class TestEngineCloseSpider: await engine.close_spider_async() assert "Scheduler close failure" in caplog.text - @deferred_f_from_coro_f + @coroutine_test async def test_exception_signal( self, crawler: Crawler, caplog: pytest.LogCaptureFixture ) -> None: @@ -731,7 +731,7 @@ class TestEngineCloseSpider: reason="cancelled", ) - @deferred_f_from_coro_f + @coroutine_test async def test_exception_stats( self, crawler: Crawler, caplog: pytest.LogCaptureFixture ) -> None: @@ -742,7 +742,7 @@ class TestEngineCloseSpider: await engine.close_spider_async() assert "Stats close failure" in caplog.text - @deferred_f_from_coro_f + @coroutine_test async def test_exception_callback( self, crawler: Crawler, caplog: pytest.LogCaptureFixture ) -> None: @@ -752,7 +752,7 @@ class TestEngineCloseSpider: await engine.close_spider_async() assert "Error running spider_closed_callback" in caplog.text - @deferred_f_from_coro_f + @coroutine_test async def test_exception_async_callback( self, crawler: Crawler, caplog: pytest.LogCaptureFixture ) -> None: diff --git a/tests/test_engine_loop.py b/tests/test_engine_loop.py index f12409029..9bdbfcc56 100644 --- a/tests/test_engine_loop.py +++ b/tests/test_engine_loop.py @@ -12,7 +12,7 @@ from scrapy.utils.defer import maybe_deferred_to_future from scrapy.utils.test import get_crawler from tests.mockserver.http import MockServer from tests.test_scheduler import MemoryScheduler -from tests.utils.decorators import deferred_f_from_coro_f +from tests.utils.decorators import coroutine_test if TYPE_CHECKING: from scrapy.http import Response @@ -28,7 +28,7 @@ async def sleep(seconds: float = 0.001) -> None: class TestMain: @pytest.mark.requires_reactor # TODO - @deferred_f_from_coro_f + @coroutine_test async def test_sleep(self): """Neither asynchronous sleeps on Spider.start() nor the equivalent on the scheduler (returning no requests while also returning True from @@ -87,7 +87,7 @@ class TestMain: expected_urls = ["data:,a", "data:,b", "data:,c", "data:,d"] assert actual_urls == expected_urls, f"{actual_urls=} != {expected_urls=}" - @deferred_f_from_coro_f + @coroutine_test async def test_close_during_start_iteration( self, caplog: pytest.LogCaptureFixture ) -> None: @@ -188,7 +188,7 @@ class TestRequestSendOrder: expected_nums = sorted(start_nums + cb_nums) assert actual_nums == expected_nums, f"{actual_nums=} != {expected_nums=}" - @deferred_f_from_coro_f + @coroutine_test async def test_default(self): """By default, callback requests take priority over start requests and are sent in order. Priority matters, but given the same priority, a @@ -228,7 +228,7 @@ class TestRequestSendOrder: parse_fn=parse, ) - @deferred_f_from_coro_f + @coroutine_test async def test_lifo_start(self): """Changing the queues of start requests to LIFO, matching the queues of non-start requests, does not cause all requests to be stored in the @@ -271,7 +271,7 @@ class TestRequestSendOrder: parse_fn=parse, ) - @deferred_f_from_coro_f + @coroutine_test async def test_shared_queues(self): """If SCHEDULER_START_*_QUEUE is falsy, start requests and other requests share the same queue, i.e. start requests are not priorized @@ -333,7 +333,7 @@ class TestRequestSendOrder: # spiders. @pytest.mark.requires_http_handler - @deferred_f_from_coro_f + @coroutine_test async def test_lazy(self): start_nums = [1, 2, 4] cb_nums = [3] diff --git a/tests/test_engine_stop_download_bytes.py b/tests/test_engine_stop_download_bytes.py index bd29e9ef6..970fffbc9 100644 --- a/tests/test_engine_stop_download_bytes.py +++ b/tests/test_engine_stop_download_bytes.py @@ -13,7 +13,7 @@ from tests.test_engine import ( MySpider, TestEngineBase, ) -from tests.utils.decorators import deferred_f_from_coro_f +from tests.utils.decorators import coroutine_test if TYPE_CHECKING: from tests.mockserver.http import MockServer @@ -27,7 +27,7 @@ class BytesReceivedCrawlerRun(CrawlerRun): class TestBytesReceivedEngine(TestEngineBase): @pytest.mark.requires_http_handler - @deferred_f_from_coro_f + @coroutine_test async def test_crawler( self, mockserver: MockServer, caplog: pytest.LogCaptureFixture ) -> None: diff --git a/tests/test_engine_stop_download_headers.py b/tests/test_engine_stop_download_headers.py index 7dbaa41d2..e0008a53a 100644 --- a/tests/test_engine_stop_download_headers.py +++ b/tests/test_engine_stop_download_headers.py @@ -13,7 +13,7 @@ from tests.test_engine import ( MySpider, TestEngineBase, ) -from tests.utils.decorators import deferred_f_from_coro_f +from tests.utils.decorators import coroutine_test if TYPE_CHECKING: from tests.mockserver.http import MockServer @@ -27,7 +27,7 @@ class HeadersReceivedCrawlerRun(CrawlerRun): class TestHeadersReceivedEngine(TestEngineBase): @pytest.mark.requires_http_handler - @deferred_f_from_coro_f + @coroutine_test async def test_crawler( self, mockserver: MockServer, caplog: pytest.LogCaptureFixture ) -> None: diff --git a/tests/test_extension_telnet.py b/tests/test_extension_telnet.py index fcb027832..a956ab184 100644 --- a/tests/test_extension_telnet.py +++ b/tests/test_extension_telnet.py @@ -4,7 +4,7 @@ from twisted.cred import credentials from scrapy.extensions.telnet import TelnetConsole from scrapy.utils.test import get_crawler -from tests.utils.decorators import inlineCallbacks +from tests.utils.decorators import inline_callbacks_test pytestmark = pytest.mark.requires_reactor @@ -23,7 +23,7 @@ class TestTelnetExtension: return console, portal - @inlineCallbacks + @inline_callbacks_test def test_bad_credentials(self): console, portal = self._get_console_and_portal() creds = credentials.UsernamePassword(b"username", b"password") @@ -32,7 +32,7 @@ class TestTelnetExtension: yield d console.stop_listening() - @inlineCallbacks + @inline_callbacks_test def test_good_credentials(self): console, portal = self._get_console_and_portal() creds = credentials.UsernamePassword( @@ -42,7 +42,7 @@ class TestTelnetExtension: yield d console.stop_listening() - @inlineCallbacks + @inline_callbacks_test def test_custom_credentials(self): settings = { "TELNETCONSOLE_USERNAME": "user", diff --git a/tests/test_feedexport.py b/tests/test_feedexport.py index c65bd9df9..3a9c37042 100644 --- a/tests/test_feedexport.py +++ b/tests/test_feedexport.py @@ -55,7 +55,7 @@ from scrapy.utils.test import get_crawler from tests.mockserver.ftp import MockFTPServer from tests.mockserver.http import MockServer from tests.spiders import ItemSpider -from tests.utils.decorators import deferred_f_from_coro_f, inlineCallbacks +from tests.utils.decorators import coroutine_test, inline_callbacks_test if TYPE_CHECKING: from collections.abc import Callable, Iterable @@ -192,7 +192,7 @@ class TestFTPFeedStorage: finally: path.unlink() - @deferred_f_from_coro_f + @coroutine_test async def test_append(self): with MockFTPServer() as ftp_server: filename = "file" @@ -202,7 +202,7 @@ class TestFTPFeedStorage: await self._store(url, b"bar", feed_options=feed_options) self._assert_stored(ftp_server.path / filename, b"foobar") - @deferred_f_from_coro_f + @coroutine_test async def test_overwrite(self): with MockFTPServer() as ftp_server: filename = "file" @@ -211,7 +211,7 @@ class TestFTPFeedStorage: await self._store(url, b"bar") self._assert_stored(ftp_server.path / filename, b"bar") - @deferred_f_from_coro_f + @coroutine_test async def test_append_active_mode(self): with MockFTPServer() as ftp_server: settings = {"FEED_STORAGE_FTP_ACTIVE": True} @@ -222,7 +222,7 @@ class TestFTPFeedStorage: await self._store(url, b"bar", feed_options=feed_options, settings=settings) self._assert_stored(ftp_server.path / filename, b"foobar") - @deferred_f_from_coro_f + @coroutine_test async def test_overwrite_active_mode(self): with MockFTPServer() as ftp_server: settings = {"FEED_STORAGE_FTP_ACTIVE": True} @@ -314,7 +314,7 @@ class TestS3FeedStorage: assert storage.access_key == "uri_key" assert storage.secret_key == "uri_secret" - @deferred_f_from_coro_f + @coroutine_test async def test_store(self): settings = { "AWS_ACCESS_KEY_ID": "access_key", @@ -455,7 +455,7 @@ class TestS3FeedStorage: assert storage.region_name == region_name assert storage.s3_client._client_config.region_name == region_name - @deferred_f_from_coro_f + @coroutine_test async def test_store_without_acl(self): storage = S3FeedStorage( "s3://mybucket/export.csv", @@ -475,7 +475,7 @@ class TestS3FeedStorage: ) assert acl is None - @deferred_f_from_coro_f + @coroutine_test async def test_store_with_acl(self): storage = S3FeedStorage( "s3://mybucket/export.csv", "access_key", "secret_key", "custom-acl" @@ -540,7 +540,7 @@ class TestGCSFeedStorage: storage = GCSFeedStorage.from_crawler(crawler, "gs://mybucket/export.csv") assert storage.acl is None - @deferred_f_from_coro_f + @coroutine_test async def test_store(self): try: from google.cloud.storage import Client # noqa: F401,PLC0415 @@ -1006,7 +1006,7 @@ class TestFeedExport(TestFeedExportBase): result = self._load_until_eof(data["marshal"], load_func=marshal.load) assert result == expected - @inlineCallbacks + @inline_callbacks_test def test_stats_file_success(self): settings = { "FEEDS": { @@ -1020,7 +1020,7 @@ class TestFeedExport(TestFeedExportBase): assert "feedexport/success_count/FileFeedStorage" in crawler.stats.get_stats() assert crawler.stats.get_value("feedexport/success_count/FileFeedStorage") == 1 - @inlineCallbacks + @inline_callbacks_test def test_stats_file_failed(self): settings = { "FEEDS": { @@ -1038,7 +1038,7 @@ class TestFeedExport(TestFeedExportBase): assert "feedexport/failed_count/FileFeedStorage" in crawler.stats.get_stats() assert crawler.stats.get_value("feedexport/failed_count/FileFeedStorage") == 1 - @inlineCallbacks + @inline_callbacks_test def test_stats_multiple_file(self): settings = { "FEEDS": { @@ -1060,7 +1060,7 @@ class TestFeedExport(TestFeedExportBase): crawler.stats.get_value("feedexport/success_count/StdoutFeedStorage") == 1 ) - @deferred_f_from_coro_f + @coroutine_test async def test_export_items(self): # feed exporters use field names from Item items = [ @@ -1074,7 +1074,7 @@ class TestFeedExport(TestFeedExportBase): header = self.MyItem.fields.keys() await self.assertExported(items, header, rows) - @deferred_f_from_coro_f + @coroutine_test async def test_export_no_items_not_store_empty(self): for fmt in ("json", "jsonlines", "xml", "csv"): settings = { @@ -1086,7 +1086,7 @@ class TestFeedExport(TestFeedExportBase): data = await self.exported_no_data(settings) assert data[fmt] is None - @deferred_f_from_coro_f + @coroutine_test async def test_start_finish_exporting_items(self): items = [ self.MyItem({"foo": "bar1", "egg": "spam1"}), @@ -1106,7 +1106,7 @@ class TestFeedExport(TestFeedExportBase): assert not listener.start_without_finish assert not listener.finish_without_start - @deferred_f_from_coro_f + @coroutine_test async def test_start_finish_exporting_no_items(self): items = [] settings = { @@ -1124,7 +1124,7 @@ class TestFeedExport(TestFeedExportBase): assert not listener.start_without_finish assert not listener.finish_without_start - @deferred_f_from_coro_f + @coroutine_test async def test_start_finish_exporting_items_exception(self): items = [ self.MyItem({"foo": "bar1", "egg": "spam1"}), @@ -1145,7 +1145,7 @@ class TestFeedExport(TestFeedExportBase): assert not listener.start_without_finish assert not listener.finish_without_start - @deferred_f_from_coro_f + @coroutine_test async def test_start_finish_exporting_no_items_exception(self): items = [] settings = { @@ -1164,7 +1164,7 @@ class TestFeedExport(TestFeedExportBase): assert not listener.start_without_finish assert not listener.finish_without_start - @deferred_f_from_coro_f + @coroutine_test async def test_export_no_items_store_empty(self): formats = ( ("json", b"[]"), @@ -1184,7 +1184,7 @@ class TestFeedExport(TestFeedExportBase): data = await self.exported_no_data(settings) assert expctd == data[fmt] - @deferred_f_from_coro_f + @coroutine_test async def test_export_no_items_multiple_feeds(self): """Make sure that `storage.store` is called for every feed.""" settings = { @@ -1202,7 +1202,7 @@ class TestFeedExport(TestFeedExportBase): assert str(log).count("Storage.store is called") == 0 - @deferred_f_from_coro_f + @coroutine_test async def test_export_multiple_item_classes(self): items = [ self.MyItem({"foo": "bar1", "egg": "spam1"}), @@ -1224,7 +1224,7 @@ class TestFeedExport(TestFeedExportBase): await self.assertExportedCsv(items, header, rows_csv) await self.assertExportedJsonLines(items, rows_jl) - @deferred_f_from_coro_f + @coroutine_test async def test_export_items_empty_field_list(self): # FEED_EXPORT_FIELDS==[] means the same as default None items = [{"foo": "bar"}] @@ -1234,7 +1234,7 @@ class TestFeedExport(TestFeedExportBase): await self.assertExportedCsv(items, header, rows) await self.assertExportedJsonLines(items, rows, settings) - @deferred_f_from_coro_f + @coroutine_test async def test_export_items_field_list(self): items = [{"foo": "bar"}] header = ["foo", "baz"] @@ -1242,7 +1242,7 @@ class TestFeedExport(TestFeedExportBase): settings = {"FEED_EXPORT_FIELDS": header} await self.assertExported(items, header, rows, settings=settings) - @deferred_f_from_coro_f + @coroutine_test async def test_export_items_comma_separated_field_list(self): items = [{"foo": "bar"}] header = ["foo", "baz"] @@ -1250,7 +1250,7 @@ class TestFeedExport(TestFeedExportBase): settings = {"FEED_EXPORT_FIELDS": ",".join(header)} await self.assertExported(items, header, rows, settings=settings) - @deferred_f_from_coro_f + @coroutine_test async def test_export_items_json_field_list(self): items = [{"foo": "bar"}] header = ["foo", "baz"] @@ -1258,7 +1258,7 @@ class TestFeedExport(TestFeedExportBase): settings = {"FEED_EXPORT_FIELDS": json.dumps(header)} await self.assertExported(items, header, rows, settings=settings) - @deferred_f_from_coro_f + @coroutine_test async def test_export_items_field_names(self): items = [{"foo": "bar"}] header = {"foo": "Foo"} @@ -1266,7 +1266,7 @@ class TestFeedExport(TestFeedExportBase): settings = {"FEED_EXPORT_FIELDS": header} await self.assertExported(items, list(header.values()), rows, settings=settings) - @deferred_f_from_coro_f + @coroutine_test async def test_export_items_dict_field_names(self): items = [{"foo": "bar"}] header = { @@ -1277,7 +1277,7 @@ class TestFeedExport(TestFeedExportBase): settings = {"FEED_EXPORT_FIELDS": header} await self.assertExported(items, ["Baz", "Foo"], rows, settings=settings) - @deferred_f_from_coro_f + @coroutine_test async def test_export_items_json_field_names(self): items = [{"foo": "bar"}] header = {"foo": "Foo"} @@ -1285,7 +1285,7 @@ class TestFeedExport(TestFeedExportBase): settings = {"FEED_EXPORT_FIELDS": json.dumps(header)} await self.assertExported(items, list(header.values()), rows, settings=settings) - @deferred_f_from_coro_f + @coroutine_test async def test_export_based_on_item_classes(self): items = [ self.MyItem({"foo": "bar1", "egg": "spam1"}), @@ -1331,7 +1331,7 @@ class TestFeedExport(TestFeedExportBase): for fmt, expected in formats.items(): assert data[fmt] == expected - @deferred_f_from_coro_f + @coroutine_test async def test_export_based_on_custom_filters(self): items = [ self.MyItem({"foo": "bar1", "egg": "spam1"}), @@ -1390,7 +1390,7 @@ class TestFeedExport(TestFeedExportBase): for fmt, expected in formats.items(): assert data[fmt] == expected - @deferred_f_from_coro_f + @coroutine_test async def test_export_dicts(self): # When dicts are used, only keys from the first row are used as # a header for CSV, and all fields are used for JSON Lines. @@ -1403,7 +1403,7 @@ class TestFeedExport(TestFeedExportBase): await self.assertExportedCsv(items, ["foo", "egg"], rows_csv) await self.assertExportedJsonLines(items, rows_jl) - @deferred_f_from_coro_f + @coroutine_test async def test_export_tuple(self): items = [ {"foo": "bar1", "egg": "spam1"}, @@ -1414,7 +1414,7 @@ class TestFeedExport(TestFeedExportBase): rows = [{"foo": "bar1", "baz": ""}, {"foo": "bar2", "baz": "quux"}] await self.assertExported(items, ["foo", "baz"], rows, settings=settings) - @deferred_f_from_coro_f + @coroutine_test async def test_export_feed_export_fields(self): # FEED_EXPORT_FIELDS option allows to order export fields # and to select a subset of fields to export, both for Items and dicts. @@ -1440,7 +1440,7 @@ class TestFeedExport(TestFeedExportBase): rows = [{"egg": "spam1", "baz": ""}, {"egg": "spam2", "baz": "quux2"}] await self.assertExported(items, ["egg", "baz"], rows, settings=settings) - @deferred_f_from_coro_f + @coroutine_test async def test_export_encoding(self): items = [{"foo": "Test\xd6"}] @@ -1485,7 +1485,7 @@ class TestFeedExport(TestFeedExportBase): data = await self.exported_data(items, settings) assert data[fmt] == expected - @deferred_f_from_coro_f + @coroutine_test async def test_export_multiple_configs(self): items = [{"foo": "FOO", "bar": "BAR"}] @@ -1525,7 +1525,7 @@ class TestFeedExport(TestFeedExportBase): for fmt, expected in formats.items(): assert data[fmt] == expected - @deferred_f_from_coro_f + @coroutine_test async def test_export_indentation(self): items = [ {"foo": ["bar"]}, @@ -1681,7 +1681,7 @@ class TestFeedExport(TestFeedExportBase): data = await self.exported_data(items, settings) assert data[row["format"]] == row["expected"] - @deferred_f_from_coro_f + @coroutine_test async def test_init_exporters_storages_with_crawler(self): settings = { "FEED_EXPORTERS": {"csv": FromCrawlerCsvItemExporter}, @@ -1694,7 +1694,7 @@ class TestFeedExport(TestFeedExportBase): assert FromCrawlerCsvItemExporter.init_with_crawler assert FromCrawlerFileFeedStorage.init_with_crawler - @deferred_f_from_coro_f + @coroutine_test async def test_str_uri(self): settings = { "FEED_STORE_EMPTY": True, @@ -1703,7 +1703,7 @@ class TestFeedExport(TestFeedExportBase): data = await self.exported_no_data(settings) assert data["csv"] == b"" - @deferred_f_from_coro_f + @coroutine_test async def test_multiple_feeds_success_logs_blocking_feed_storage(self): settings = { "FEEDS": { @@ -1723,7 +1723,7 @@ class TestFeedExport(TestFeedExportBase): for fmt in ["json", "xml", "csv"]: assert f"Stored {fmt} feed (2 items)" in str(log) - @deferred_f_from_coro_f + @coroutine_test async def test_multiple_feeds_failing_logs_blocking_feed_storage(self): settings = { "FEEDS": { @@ -1743,7 +1743,7 @@ class TestFeedExport(TestFeedExportBase): for fmt in ["json", "xml", "csv"]: assert f"Error storing {fmt} feed (2 items)" in str(log) - @deferred_f_from_coro_f + @coroutine_test async def test_extend_kwargs(self): items = [{"foo": "FOO", "bar": "BAR"}] @@ -1780,7 +1780,7 @@ class TestFeedExport(TestFeedExportBase): data = await self.exported_data(items, settings) assert data[feed_options["format"]] == row["expected"] - @deferred_f_from_coro_f + @coroutine_test async def test_storage_file_no_postprocessing(self): @implementer(IFeedStorage) class Storage: @@ -1802,7 +1802,7 @@ class TestFeedExport(TestFeedExportBase): await self.exported_no_data(settings) assert Storage.open_file is Storage.store_file - @deferred_f_from_coro_f + @coroutine_test async def test_storage_file_postprocessing(self): @implementer(IFeedStorage) class Storage: @@ -1901,7 +1901,7 @@ class TestFeedPostProcessedExports(TestFeedExportBase): data_stream.seek(0) return data_stream.read() - @deferred_f_from_coro_f + @coroutine_test async def test_gzip_plugin(self): filename = self._named_tempfile("gzip_file") @@ -1920,7 +1920,7 @@ class TestFeedPostProcessedExports(TestFeedExportBase): except OSError: pytest.fail("Received invalid gzip data.") - @deferred_f_from_coro_f + @coroutine_test async def test_gzip_plugin_compresslevel(self): filename_to_compressed = { self._named_tempfile("compresslevel_0"): self.get_gzip_compressed( @@ -1957,7 +1957,7 @@ class TestFeedPostProcessedExports(TestFeedExportBase): assert compressed == data[filename] assert result == self.expected - @deferred_f_from_coro_f + @coroutine_test async def test_gzip_plugin_mtime(self): filename_to_compressed = { self._named_tempfile("mtime_123"): self.get_gzip_compressed( @@ -1992,7 +1992,7 @@ class TestFeedPostProcessedExports(TestFeedExportBase): assert compressed == data[filename] assert result == self.expected - @deferred_f_from_coro_f + @coroutine_test async def test_gzip_plugin_filename(self): filename_to_compressed = { self._named_tempfile("filename_FILE1"): self.get_gzip_compressed( @@ -2027,7 +2027,7 @@ class TestFeedPostProcessedExports(TestFeedExportBase): assert compressed == data[filename] assert result == self.expected - @deferred_f_from_coro_f + @coroutine_test async def test_lzma_plugin(self): filename = self._named_tempfile("lzma_file") @@ -2046,7 +2046,7 @@ class TestFeedPostProcessedExports(TestFeedExportBase): except lzma.LZMAError: pytest.fail("Received invalid lzma data.") - @deferred_f_from_coro_f + @coroutine_test async def test_lzma_plugin_format(self): filename_to_compressed = { self._named_tempfile("format_FORMAT_XZ"): lzma.compress( @@ -2079,7 +2079,7 @@ class TestFeedPostProcessedExports(TestFeedExportBase): assert compressed == data[filename] assert result == self.expected - @deferred_f_from_coro_f + @coroutine_test async def test_lzma_plugin_check(self): filename_to_compressed = { self._named_tempfile("check_CHECK_NONE"): lzma.compress( @@ -2112,7 +2112,7 @@ class TestFeedPostProcessedExports(TestFeedExportBase): assert compressed == data[filename] assert result == self.expected - @deferred_f_from_coro_f + @coroutine_test async def test_lzma_plugin_preset(self): filename_to_compressed = { self._named_tempfile("preset_PRESET_0"): lzma.compress( @@ -2145,7 +2145,7 @@ class TestFeedPostProcessedExports(TestFeedExportBase): assert compressed == data[filename] assert result == self.expected - @deferred_f_from_coro_f + @coroutine_test async def test_lzma_plugin_filters(self): if "PyPy" in sys.version: # https://foss.heptapod.net/pypy/pypy/-/issues/3527 @@ -2170,7 +2170,7 @@ class TestFeedPostProcessedExports(TestFeedExportBase): result = lzma.decompress(data[filename]) assert result == self.expected - @deferred_f_from_coro_f + @coroutine_test async def test_bz2_plugin(self): filename = self._named_tempfile("bz2_file") @@ -2189,7 +2189,7 @@ class TestFeedPostProcessedExports(TestFeedExportBase): except OSError: pytest.fail("Received invalid bz2 data.") - @deferred_f_from_coro_f + @coroutine_test async def test_bz2_plugin_compresslevel(self): filename_to_compressed = { self._named_tempfile("compresslevel_1"): bz2.compress( @@ -2222,7 +2222,7 @@ class TestFeedPostProcessedExports(TestFeedExportBase): assert compressed == data[filename] assert result == self.expected - @deferred_f_from_coro_f + @coroutine_test async def test_custom_plugin(self): filename = self._named_tempfile("csv_file") @@ -2238,7 +2238,7 @@ class TestFeedPostProcessedExports(TestFeedExportBase): data = await self.exported_data(self.items, settings) assert data[filename] == self.expected - @deferred_f_from_coro_f + @coroutine_test async def test_custom_plugin_with_parameter(self): expected = b"foo\r\n\nbar\r\n\n" filename = self._named_tempfile("newline") @@ -2256,7 +2256,7 @@ class TestFeedPostProcessedExports(TestFeedExportBase): data = await self.exported_data(self.items, settings) assert data[filename] == expected - @deferred_f_from_coro_f + @coroutine_test async def test_custom_plugin_with_compression(self): expected = b"foo\r\n\nbar\r\n\n" @@ -2301,7 +2301,7 @@ class TestFeedPostProcessedExports(TestFeedExportBase): result = decompressor(data[filename]) assert result == expected - @deferred_f_from_coro_f + @coroutine_test async def test_exports_compatibility_with_postproc(self): filename_to_expected = { self._named_tempfile("csv"): b"foo\r\nbar\r\n", @@ -2511,7 +2511,7 @@ class TestBatchDeliveries(TestFeedExportBase): expected_batch, rows = rows[:batch_size], rows[batch_size:] assert got_batch == expected_batch - @deferred_f_from_coro_f + @coroutine_test async def test_export_items(self): """Test partial deliveries in all supported formats""" items = [ @@ -2540,7 +2540,7 @@ class TestBatchDeliveries(TestFeedExportBase): with pytest.raises(NotConfigured): FeedExporter(crawler) - @deferred_f_from_coro_f + @coroutine_test async def test_export_no_items_not_store_empty(self): for fmt in ("json", "jsonlines", "xml", "csv"): settings = { @@ -2556,7 +2556,7 @@ class TestBatchDeliveries(TestFeedExportBase): data = dict(data) assert len(data[fmt]) == 0 - @deferred_f_from_coro_f + @coroutine_test async def test_export_no_items_store_empty(self): formats = ( ("json", b"[]"), @@ -2580,7 +2580,7 @@ class TestBatchDeliveries(TestFeedExportBase): data = dict(data) assert data[fmt][0] == expctd - @deferred_f_from_coro_f + @coroutine_test async def test_export_multiple_configs(self): items = [ {"foo": "FOO", "bar": "BAR"}, @@ -2636,7 +2636,7 @@ class TestBatchDeliveries(TestFeedExportBase): for expected_batch, got_batch in zip(expected, data[fmt], strict=False): assert got_batch == expected_batch - @deferred_f_from_coro_f + @coroutine_test async def test_batch_item_count_feeds_setting(self): items = [{"foo": "FOO"}, {"foo": "FOO1"}] formats = { @@ -2660,7 +2660,7 @@ class TestBatchDeliveries(TestFeedExportBase): for expected_batch, got_batch in zip(expected, data[fmt], strict=False): assert got_batch == expected_batch - @deferred_f_from_coro_f + @coroutine_test async def test_batch_path_differ(self): """ Test that the name of all batch files differ from each other. @@ -2682,7 +2682,7 @@ class TestBatchDeliveries(TestFeedExportBase): data = await self.exported_data(items, settings) assert len(items) == len(data["json"]) - @inlineCallbacks + @inline_callbacks_test def test_stats_batch_file_success(self): settings = { "FEEDS": { @@ -2700,7 +2700,7 @@ class TestBatchDeliveries(TestFeedExportBase): assert crawler.stats.get_value("feedexport/success_count/FileFeedStorage") == 12 @pytest.mark.requires_boto3 - @inlineCallbacks + @inline_callbacks_test def test_s3_export(self): bucket = "mybucket" items = [ @@ -2823,7 +2823,7 @@ class TestFeedExporterSignals: feed_exporter.item_scraped(item, spider) await feed_exporter.close_spider(spider) - @deferred_f_from_coro_f + @coroutine_test async def test_feed_exporter_signals_sent(self) -> None: self.feed_exporter_closed_received = False self.feed_slot_closed_received = False @@ -2835,7 +2835,7 @@ class TestFeedExporterSignals: assert self.feed_slot_closed_received assert self.feed_exporter_closed_received - @deferred_f_from_coro_f + @coroutine_test async def test_feed_exporter_signals_sent_async(self) -> None: self.feed_exporter_closed_received = False self.feed_slot_closed_received = False diff --git a/tests/test_logformatter.py b/tests/test_logformatter.py index 0602be0c8..66544a508 100644 --- a/tests/test_logformatter.py +++ b/tests/test_logformatter.py @@ -12,7 +12,7 @@ from scrapy.spiders import Spider from scrapy.utils.test import get_crawler from tests.mockserver.http import MockServer from tests.spiders import ItemSpider -from tests.utils.decorators import inlineCallbacks +from tests.utils.decorators import inline_callbacks_test class CustomItem(Item): @@ -272,7 +272,7 @@ class TestShowOrSkipMessages: }, } - @inlineCallbacks + @inline_callbacks_test def test_show_messages(self): crawler = get_crawler(ItemSpider, self.base_settings) with LogCapture() as lc: @@ -281,7 +281,7 @@ class TestShowOrSkipMessages: assert "Crawled (200) None: crawler = self._create_crawler(pipeline_class) await crawler.crawl_async(mockserver=mockserver) assert len(self.items) == 1 - @deferred_f_from_coro_f + @coroutine_test async def test_pipeline_deferred(self, mockserver: MockServer) -> None: crawler = self._create_crawler(DeferredPipeline) with ( @@ -180,7 +180,7 @@ class TestPipeline: await crawler.crawl_async(mockserver=mockserver) assert len(self.items) == 1 - @deferred_f_from_coro_f + @coroutine_test async def test_deprecated_spider_arg(self, mockserver: MockServer) -> None: crawler = self._create_crawler(DeprecatedSpiderArgPipeline) with ( @@ -214,7 +214,7 @@ class TestPipeline: ProcessItemExceptionAsyncPipeline, ], ) - @deferred_f_from_coro_f + @coroutine_test async def test_process_item_exception( self, caplog: pytest.LogCaptureFixture, @@ -239,7 +239,7 @@ class TestPipeline: OpenSpiderExceptionAsyncPipeline, ], ) - @deferred_f_from_coro_f + @coroutine_test async def test_open_spider_exception( self, mockserver: MockServer, pipeline_class: type ) -> None: @@ -267,7 +267,7 @@ class TestCustomPipelineManager: itemproc.process_item({}, crawler.spider) @pytest.mark.requires_http_handler - @deferred_f_from_coro_f + @coroutine_test async def test_integration_recommended(self, mockserver: MockServer) -> None: class CustomPipelineManager(ItemPipelineManager): async def process_item_async(self, item): @@ -294,7 +294,7 @@ class TestCustomPipelineManager: assert len(items) == 1 @pytest.mark.requires_http_handler - @deferred_f_from_coro_f + @coroutine_test async def test_integration_no_async_subclass(self, mockserver: MockServer) -> None: class CustomPipelineManager(ItemPipelineManager): def open_spider(self, spider): @@ -353,7 +353,7 @@ class TestCustomPipelineManager: assert len(items) == 1 @pytest.mark.requires_http_handler - @deferred_f_from_coro_f + @coroutine_test async def test_integration_no_async_not_subclass( self, mockserver: MockServer ) -> None: @@ -425,7 +425,7 @@ class TestMiddlewareManagerSpider: def crawler(self) -> Crawler: return get_crawler(Spider) - @deferred_f_from_coro_f + @coroutine_test async def test_deprecated_spider_arg_no_crawler_spider( self, crawler: Crawler ) -> None: @@ -480,7 +480,7 @@ class TestMiddlewareManagerSpider: ): await mwman.close_spider_async() - @deferred_f_from_coro_f + @coroutine_test async def test_deprecated_spider_arg_with_crawler(self, crawler: Crawler) -> None: """Crawler is provided and has a spider, works. The instance passed to a deprecated method is ignored, even if mismatched.""" @@ -497,7 +497,7 @@ class TestMiddlewareManagerSpider: ): await maybe_deferred_to_future(mwman.close_spider(DefaultSpider())) - @deferred_f_from_coro_f + @coroutine_test async def test_deprecated_spider_arg_without_crawler(self) -> None: """The first instance passed to a deprecated method is used. Mismatched ones raise an error.""" with pytest.warns( @@ -527,7 +527,7 @@ class TestMiddlewareManagerSpider: ): await maybe_deferred_to_future(mwman.close_spider(spider)) - @deferred_f_from_coro_f + @coroutine_test async def test_no_spider_arg_without_crawler(self) -> None: """If no crawler and no spider arg, raise an error.""" with pytest.warns( diff --git a/tests/test_proxy_connect.py b/tests/test_proxy_connect.py index daa635b54..c78c882cf 100644 --- a/tests/test_proxy_connect.py +++ b/tests/test_proxy_connect.py @@ -13,7 +13,7 @@ from scrapy.http import Request from scrapy.utils.test import get_crawler from tests.mockserver.http import MockServer from tests.spiders import SimpleSpider, SingleRequestSpider -from tests.utils.decorators import inlineCallbacks +from tests.utils.decorators import inline_callbacks_test class MitmProxy: @@ -84,14 +84,14 @@ class TestProxyConnect: self._proxy.stop() os.environ = self._oldenv - @inlineCallbacks + @inline_callbacks_test def test_https_connect_tunnel(self): crawler = get_crawler(SimpleSpider) with LogCapture() as log: yield crawler.crawl(self.mockserver.url("/status?n=200", is_secure=True)) self._assert_got_response_code(200, log) - @inlineCallbacks + @inline_callbacks_test def test_https_tunnel_auth_error(self): os.environ["https_proxy"] = _wrong_credentials(os.environ["https_proxy"]) crawler = get_crawler(SimpleSpider) @@ -101,7 +101,7 @@ class TestProxyConnect: # he just sees a TunnelError. self._assert_got_tunnel_error(log) - @inlineCallbacks + @inline_callbacks_test def test_https_tunnel_without_leak_proxy_authorization_header(self): request = Request(self.mockserver.url("/echo", is_secure=True)) crawler = get_crawler(SingleRequestSpider) diff --git a/tests/test_request_attribute_binding.py b/tests/test_request_attribute_binding.py index 48234adb3..66dd48737 100644 --- a/tests/test_request_attribute_binding.py +++ b/tests/test_request_attribute_binding.py @@ -6,7 +6,7 @@ from scrapy.http.response import Response from scrapy.utils.test import get_crawler from tests.mockserver.http import MockServer from tests.spiders import SingleRequestSpider -from tests.utils.decorators import inlineCallbacks +from tests.utils.decorators import inline_callbacks_test OVERRIDDEN_URL = "https://example.org" @@ -74,7 +74,7 @@ class TestCrawl: def teardown_class(cls): cls.mockserver.__exit__(None, None, None) - @inlineCallbacks + @inline_callbacks_test def test_response_200(self): url = self.mockserver.url("/status?n=200") crawler = get_crawler(SingleRequestSpider) @@ -82,7 +82,7 @@ class TestCrawl: response = crawler.spider.meta["responses"][0] assert response.request.url == url - @inlineCallbacks + @inline_callbacks_test def test_response_error(self): for status in ("404", "500"): url = self.mockserver.url(f"/status?n={status}") @@ -93,7 +93,7 @@ class TestCrawl: assert failure.request.url == url assert response.request.url == url - @inlineCallbacks + @inline_callbacks_test def test_downloader_middleware_raise_exception(self): url = self.mockserver.url("/status?n=200") crawler = get_crawler( @@ -109,7 +109,7 @@ class TestCrawl: assert failure.request.url == url assert isinstance(failure.value, ZeroDivisionError) - @inlineCallbacks + @inline_callbacks_test def test_downloader_middleware_override_request_in_process_response(self): """ Downloader middleware which returns a response with an specific 'request' attribute. @@ -152,7 +152,7 @@ class TestCrawl: ), ) - @inlineCallbacks + @inline_callbacks_test def test_downloader_middleware_override_in_process_exception(self): """ An exception is raised but caught by the next middleware, which @@ -175,7 +175,7 @@ class TestCrawl: assert response.body == b"Caught ZeroDivisionError" assert response.request.url == OVERRIDDEN_URL - @inlineCallbacks + @inline_callbacks_test def test_downloader_middleware_do_not_override_in_process_exception(self): """ An exception is raised but caught by the next middleware, which @@ -198,7 +198,7 @@ class TestCrawl: assert response.body == b"Caught ZeroDivisionError" assert response.request.url == url - @inlineCallbacks + @inline_callbacks_test def test_downloader_middleware_alternative_callback(self): """ Downloader middleware which returns a response with a diff --git a/tests/test_request_cb_kwargs.py b/tests/test_request_cb_kwargs.py index c91a670c1..c8629df00 100644 --- a/tests/test_request_cb_kwargs.py +++ b/tests/test_request_cb_kwargs.py @@ -5,7 +5,7 @@ from scrapy.http import Request from scrapy.utils.test import get_crawler from tests.mockserver.http import MockServer from tests.spiders import MockServerSpider -from tests.utils.decorators import inlineCallbacks +from tests.utils.decorators import inline_callbacks_test class InjectArgumentsDownloaderMiddleware: @@ -160,7 +160,7 @@ class TestCallbackKeywordArguments: def teardown_class(cls): cls.mockserver.__exit__(None, None, None) - @inlineCallbacks + @inline_callbacks_test def test_callback_kwargs(self): crawler = get_crawler(KeywordArgumentsSpider) with LogCapture() as log: diff --git a/tests/test_request_left.py b/tests/test_request_left.py index a5d5efffa..726e0573a 100644 --- a/tests/test_request_left.py +++ b/tests/test_request_left.py @@ -2,7 +2,7 @@ from scrapy.signals import request_left_downloader from scrapy.spiders import Spider from scrapy.utils.test import get_crawler from tests.mockserver.http import MockServer -from tests.utils.decorators import inlineCallbacks +from tests.utils.decorators import inline_callbacks_test class SignalCatcherSpider(Spider): @@ -32,25 +32,25 @@ class TestCatching: def teardown_class(cls): cls.mockserver.__exit__(None, None, None) - @inlineCallbacks + @inline_callbacks_test def test_success(self): crawler = get_crawler(SignalCatcherSpider) yield crawler.crawl(self.mockserver.url("/status?n=200")) assert crawler.spider.caught_times == 1 - @inlineCallbacks + @inline_callbacks_test def test_timeout(self): crawler = get_crawler(SignalCatcherSpider, {"DOWNLOAD_TIMEOUT": 0.1}) yield crawler.crawl(self.mockserver.url("/delay?n=0.2")) assert crawler.spider.caught_times == 1 - @inlineCallbacks + @inline_callbacks_test def test_disconnect(self): crawler = get_crawler(SignalCatcherSpider) yield crawler.crawl(self.mockserver.url("/drop")) assert crawler.spider.caught_times == 1 - @inlineCallbacks + @inline_callbacks_test def test_noconnect(self): crawler = get_crawler(SignalCatcherSpider) yield crawler.crawl("http://thereisdefinetelynosuchdomain.com") diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index f363b1cd5..873be0b8f 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -19,7 +19,7 @@ from scrapy.utils.httpobj import urlparse_cached from scrapy.utils.misc import load_object from scrapy.utils.test import get_crawler from tests.mockserver.http import MockServer -from tests.utils.decorators import inlineCallbacks +from tests.utils.decorators import inline_callbacks_test class MemoryScheduler(BaseScheduler): @@ -370,7 +370,7 @@ class TestIntegrationWithDownloaderAwareInMemory: ) @pytest.mark.requires_http_handler - @inlineCallbacks + @inline_callbacks_test def test_integration_downloader_aware_priority_queue(self): with MockServer() as mockserver: url = mockserver.url("/status?n=200", is_secure=False) diff --git a/tests/test_scheduler_base.py b/tests/test_scheduler_base.py index 277c00623..6234bef86 100644 --- a/tests/test_scheduler_base.py +++ b/tests/test_scheduler_base.py @@ -13,7 +13,7 @@ from scrapy.utils.httpobj import urlparse_cached from scrapy.utils.request import fingerprint from scrapy.utils.test import get_crawler from tests.mockserver.http import MockServer -from tests.utils.decorators import inlineCallbacks +from tests.utils.decorators import inline_callbacks_test PATHS = ["/a", "/b", "/c"] URLS = [urljoin("https://example.org", p) for p in PATHS] @@ -118,7 +118,7 @@ class TestSimpleScheduler(InterfaceCheckMixin): def setup_method(self): self.scheduler = SimpleScheduler() - @inlineCallbacks + @inline_callbacks_test def test_enqueue_dequeue(self): open_result = yield self.scheduler.open(Spider("foo")) assert open_result == "open" @@ -148,7 +148,7 @@ class TestSimpleScheduler(InterfaceCheckMixin): class TestMinimalSchedulerCrawl: scheduler_cls = MinimalScheduler - @inlineCallbacks + @inline_callbacks_test def test_crawl(self): with MockServer() as mockserver: settings = { diff --git a/tests/test_signals.py b/tests/test_signals.py index 5769f6588..faf24a129 100644 --- a/tests/test_signals.py +++ b/tests/test_signals.py @@ -3,7 +3,7 @@ import pytest from scrapy import Request, Spider, signals from scrapy.utils.test import get_crawler, get_from_asyncio_queue from tests.mockserver.http import MockServer -from tests.utils.decorators import deferred_f_from_coro_f, inlineCallbacks +from tests.utils.decorators import coroutine_test, inline_callbacks_test class ItemSpider(Spider): @@ -20,7 +20,7 @@ class ItemSpider(Spider): class TestMain: - @deferred_f_from_coro_f + @coroutine_test async def test_scheduler_empty(self): crawler = get_crawler() calls = [] @@ -52,7 +52,7 @@ class TestMockServer: @pytest.mark.requires_http_handler @pytest.mark.only_asyncio - @inlineCallbacks + @inline_callbacks_test def test_simple_pipeline(self): crawler = get_crawler(ItemSpider) crawler.signals.connect(self._on_item_scraped, signals.item_scraped) diff --git a/tests/test_spider.py b/tests/test_spider.py index dd1cc15b0..68d4f85db 100644 --- a/tests/test_spider.py +++ b/tests/test_spider.py @@ -30,7 +30,7 @@ from scrapy.spiders import ( from scrapy.spiders.init import InitSpider from scrapy.utils.test import get_crawler, get_reactor_settings from tests import get_testdata, tests_datadir -from tests.utils.decorators import deferred_f_from_coro_f, inlineCallbacks +from tests.utils.decorators import coroutine_test, inline_callbacks_test class TestSpider: @@ -95,7 +95,7 @@ class TestSpider: assert settings.get("TEST2") == "spider" assert settings.get("TEST3") == "project" - @inlineCallbacks + @inline_callbacks_test def test_settings_in_from_crawler(self): spider_settings = {"TEST1": "spider", "TEST2": "spider"} project_settings = { @@ -142,7 +142,7 @@ class TestSpider: class TestInitSpider(TestSpider): spider_class = InitSpider - @deferred_f_from_coro_f + @coroutine_test async def test_start_urls(self): responses = [] @@ -458,7 +458,7 @@ class TestCrawlSpider(TestSpider): assert hasattr(spider, "_follow_links") assert not spider._follow_links - @inlineCallbacks + @inline_callbacks_test def test_start_url(self): class TestSpider(self.spider_class): name = "test" @@ -827,7 +827,7 @@ Sitemap: /sitemap-relative-url.xml ), ) - @deferred_f_from_coro_f + @coroutine_test async def test_sitemap_urls(self): class TestSpider(self.spider_class): name = "test" diff --git a/tests/test_spider_start.py b/tests/test_spider_start.py index b84b0d8ac..8ca588978 100644 --- a/tests/test_spider_start.py +++ b/tests/test_spider_start.py @@ -13,7 +13,7 @@ from scrapy.utils.defer import maybe_deferred_to_future from scrapy.utils.test import get_crawler from .utils import twisted_sleep -from .utils.decorators import deferred_f_from_coro_f +from .utils.decorators import coroutine_test SLEEP_SECONDS = 0.1 @@ -38,7 +38,7 @@ class TestMain: assert crawler.stats.get_value("finish_reason") == "finished" assert actual_items == expected_items - @deferred_f_from_coro_f + @coroutine_test async def test_start_urls(self): class TestSpider(Spider): name = "test" @@ -51,7 +51,7 @@ class TestMain: warnings.simplefilter("error") await self._test_spider(TestSpider, [ITEM_A]) - @deferred_f_from_coro_f + @coroutine_test async def test_start(self): class TestSpider(Spider): name = "test" @@ -63,7 +63,7 @@ class TestMain: warnings.simplefilter("error") await self._test_spider(TestSpider, [ITEM_A]) - @deferred_f_from_coro_f + @coroutine_test async def test_start_subclass(self): class BaseSpider(Spider): async def start(self): @@ -76,7 +76,7 @@ class TestMain: warnings.simplefilter("error") await self._test_spider(TestSpider, [ITEM_A]) - @deferred_f_from_coro_f + @coroutine_test async def test_deprecated(self): class TestSpider(Spider): name = "test" @@ -87,7 +87,7 @@ class TestMain: with pytest.warns(ScrapyDeprecationWarning): await self._test_spider(TestSpider, [ITEM_A]) - @deferred_f_from_coro_f + @coroutine_test async def test_deprecated_subclass(self): class BaseSpider(Spider): def start_requests(self): @@ -100,7 +100,7 @@ class TestMain: with pytest.warns(ScrapyDeprecationWarning, match="BaseSpider"): await self._test_spider(TestSpider, [ITEM_A]) - @deferred_f_from_coro_f + @coroutine_test async def test_universal(self): class TestSpider(Spider): name = "test" @@ -115,7 +115,7 @@ class TestMain: warnings.simplefilter("error") await self._test_spider(TestSpider, [ITEM_A]) - @deferred_f_from_coro_f + @coroutine_test async def test_universal_subclass(self): class BaseSpider(Spider): async def start(self): @@ -131,7 +131,7 @@ class TestMain: warnings.simplefilter("error") await self._test_spider(TestSpider, [ITEM_A]) - @deferred_f_from_coro_f + @coroutine_test async def test_start_deprecated_super(self): class TestSpider(Spider): name = "test" @@ -154,7 +154,7 @@ class TestMain: await self._test_spider(TestSpider, expected_items) @pytest.mark.only_asyncio - @deferred_f_from_coro_f + @coroutine_test async def test_asyncio_delayed(self): async def start(spider): await sleep(SLEEP_SECONDS) @@ -163,7 +163,7 @@ class TestMain: await self._test_start(start, [ITEM_A]) @pytest.mark.requires_reactor # needs a reactor for twisted_sleep() - @deferred_f_from_coro_f + @coroutine_test async def test_twisted_delayed(self): async def start(spider): await maybe_deferred_to_future(twisted_sleep(SLEEP_SECONDS)) @@ -173,7 +173,7 @@ class TestMain: # Exceptions - @deferred_f_from_coro_f + @coroutine_test async def test_deprecated_non_generator_exception(self): class TestSpider(Spider): name = "test" diff --git a/tests/test_spidermiddleware.py b/tests/test_spidermiddleware.py index 17d897526..9651e3f8b 100644 --- a/tests/test_spidermiddleware.py +++ b/tests/test_spidermiddleware.py @@ -18,7 +18,7 @@ from scrapy.utils.asyncio import call_later from scrapy.utils.defer import maybe_deferred_to_future from scrapy.utils.spider import DefaultSpider from scrapy.utils.test import get_crawler -from tests.utils.decorators import deferred_f_from_coro_f +from tests.utils.decorators import coroutine_test if TYPE_CHECKING: from twisted.python.failure import Failure @@ -53,7 +53,7 @@ class TestSpiderMiddleware: class TestProcessSpiderInputInvalidOutput(TestSpiderMiddleware): """Invalid return value for process_spider_input method""" - @deferred_f_from_coro_f + @coroutine_test async def test_invalid_process_spider_input(self): class InvalidProcessSpiderInputMiddleware: def process_spider_input(self, response): @@ -67,7 +67,7 @@ class TestProcessSpiderInputInvalidOutput(TestSpiderMiddleware): class TestProcessSpiderOutputInvalidOutput(TestSpiderMiddleware): """Invalid return value for process_spider_output method""" - @deferred_f_from_coro_f + @coroutine_test async def test_invalid_process_spider_output(self): class InvalidProcessSpiderOutputMiddleware: def process_spider_output(self, response, result): @@ -81,7 +81,7 @@ class TestProcessSpiderOutputInvalidOutput(TestSpiderMiddleware): class TestProcessSpiderExceptionInvalidOutput(TestSpiderMiddleware): """Invalid return value for process_spider_exception method""" - @deferred_f_from_coro_f + @coroutine_test async def test_invalid_process_spider_exception(self): class InvalidProcessSpiderOutputExceptionMiddleware: def process_spider_exception(self, response, exception): @@ -100,7 +100,7 @@ class TestProcessSpiderExceptionInvalidOutput(TestSpiderMiddleware): class TestProcessSpiderExceptionReRaise(TestSpiderMiddleware): """Re raise the exception by returning None""" - @deferred_f_from_coro_f + @coroutine_test async def test_process_spider_exception_return_none(self): class ProcessSpiderExceptionReturnNoneMiddleware: def process_spider_exception(self, response, exception): @@ -237,47 +237,47 @@ class TestProcessSpiderOutputSimple(TestBaseAsyncSpiderMiddleware): MW_ASYNCGEN = ProcessSpiderOutputAsyncGenMiddleware MW_UNIVERSAL = ProcessSpiderOutputUniversalMiddleware - @deferred_f_from_coro_f + @coroutine_test async def test_simple(self): """Simple mw""" await self._test_simple_base(self.MW_SIMPLE) - @deferred_f_from_coro_f + @coroutine_test async def test_asyncgen(self): """Asyncgen mw; upgrade""" await self._test_asyncgen_base(self.MW_ASYNCGEN) - @deferred_f_from_coro_f + @coroutine_test async def test_simple_asyncgen(self): """Simple mw -> asyncgen mw; upgrade""" await self._test_asyncgen_base(self.MW_ASYNCGEN, self.MW_SIMPLE) - @deferred_f_from_coro_f + @coroutine_test async def test_asyncgen_simple(self): """Asyncgen mw -> simple mw; upgrade then downgrade""" await self._test_simple_base(self.MW_SIMPLE, self.MW_ASYNCGEN, downgrade=True) - @deferred_f_from_coro_f + @coroutine_test async def test_universal(self): """Universal mw""" await self._test_simple_base(self.MW_UNIVERSAL) - @deferred_f_from_coro_f + @coroutine_test async def test_universal_simple(self): """Universal mw -> simple mw""" await self._test_simple_base(self.MW_SIMPLE, self.MW_UNIVERSAL) - @deferred_f_from_coro_f + @coroutine_test async def test_simple_universal(self): """Simple mw -> universal mw""" await self._test_simple_base(self.MW_UNIVERSAL, self.MW_SIMPLE) - @deferred_f_from_coro_f + @coroutine_test async def test_universal_asyncgen(self): """Universal mw -> asyncgen mw; upgrade""" await self._test_asyncgen_base(self.MW_ASYNCGEN, self.MW_UNIVERSAL) - @deferred_f_from_coro_f + @coroutine_test async def test_asyncgen_universal(self): """Asyncgen mw -> universal mw; upgrade""" await self._test_asyncgen_base(self.MW_UNIVERSAL, self.MW_ASYNCGEN) @@ -290,27 +290,27 @@ class TestProcessSpiderOutputAsyncGen(TestProcessSpiderOutputSimple): for item in super()._callback(): yield item - @deferred_f_from_coro_f + @coroutine_test async def test_simple(self): """Simple mw; downgrade""" await self._test_simple_base(self.MW_SIMPLE, downgrade=True) - @deferred_f_from_coro_f + @coroutine_test async def test_simple_asyncgen(self): """Simple mw -> asyncgen mw; downgrade then upgrade""" await self._test_asyncgen_base(self.MW_ASYNCGEN, self.MW_SIMPLE, downgrade=True) - @deferred_f_from_coro_f + @coroutine_test async def test_universal(self): """Universal mw""" await self._test_asyncgen_base(self.MW_UNIVERSAL) - @deferred_f_from_coro_f + @coroutine_test async def test_universal_simple(self): """Universal mw -> simple mw; downgrade""" await self._test_simple_base(self.MW_SIMPLE, self.MW_UNIVERSAL, downgrade=True) - @deferred_f_from_coro_f + @coroutine_test async def test_simple_universal(self): """Simple mw -> universal mw; downgrade""" await self._test_simple_base(self.MW_UNIVERSAL, self.MW_SIMPLE, downgrade=True) @@ -327,7 +327,7 @@ class ProcessSpiderOutputCoroutineMiddleware: class TestProcessSpiderOutputInvalidResult(TestBaseAsyncSpiderMiddleware): - @deferred_f_from_coro_f + @coroutine_test async def test_non_iterable(self): with pytest.raises( _InvalidOutput, @@ -335,7 +335,7 @@ class TestProcessSpiderOutputInvalidResult(TestBaseAsyncSpiderMiddleware): ): await self._get_middleware_result(ProcessSpiderOutputNonIterableMiddleware) - @deferred_f_from_coro_f + @coroutine_test async def test_coroutine(self): with pytest.raises( _InvalidOutput, @@ -375,7 +375,7 @@ class TestProcessStartSimple(TestBaseAsyncSpiderMiddleware): self.mwman = SpiderMiddlewareManager.from_crawler(self.crawler) return await self.mwman.process_start() - @deferred_f_from_coro_f + @coroutine_test async def test_simple(self): """Simple mw""" start = await self._get_processed_start(self.MW_SIMPLE) @@ -489,33 +489,33 @@ class TestBuiltinMiddlewareSimple(TestBaseAsyncSpiderMiddleware): self._scrape_func, self.response, self.request ) - @deferred_f_from_coro_f + @coroutine_test async def test_just_builtin(self): await self._test_simple_base() - @deferred_f_from_coro_f + @coroutine_test async def test_builtin_simple(self): await self._test_simple_base(self.MW_SIMPLE, start_index=1000) - @deferred_f_from_coro_f + @coroutine_test async def test_builtin_async(self): """Upgrade""" await self._test_asyncgen_base(self.MW_ASYNCGEN, start_index=1000) - @deferred_f_from_coro_f + @coroutine_test async def test_builtin_universal(self): await self._test_simple_base(self.MW_UNIVERSAL, start_index=1000) - @deferred_f_from_coro_f + @coroutine_test async def test_simple_builtin(self): await self._test_simple_base(self.MW_SIMPLE) - @deferred_f_from_coro_f + @coroutine_test async def test_async_builtin(self): """Upgrade""" await self._test_asyncgen_base(self.MW_ASYNCGEN) - @deferred_f_from_coro_f + @coroutine_test async def test_universal_builtin(self): await self._test_simple_base(self.MW_UNIVERSAL) @@ -525,33 +525,33 @@ class TestBuiltinMiddlewareAsyncGen(TestBuiltinMiddlewareSimple): for item in super()._callback(): yield item - @deferred_f_from_coro_f + @coroutine_test async def test_just_builtin(self): await self._test_asyncgen_base() - @deferred_f_from_coro_f + @coroutine_test async def test_builtin_simple(self): """Downgrade""" await self._test_simple_base(self.MW_SIMPLE, downgrade=True, start_index=1000) - @deferred_f_from_coro_f + @coroutine_test async def test_builtin_async(self): await self._test_asyncgen_base(self.MW_ASYNCGEN, start_index=1000) - @deferred_f_from_coro_f + @coroutine_test async def test_builtin_universal(self): await self._test_asyncgen_base(self.MW_UNIVERSAL, start_index=1000) - @deferred_f_from_coro_f + @coroutine_test async def test_simple_builtin(self): """Downgrade""" await self._test_simple_base(self.MW_SIMPLE, downgrade=True) - @deferred_f_from_coro_f + @coroutine_test async def test_async_builtin(self): await self._test_asyncgen_base(self.MW_ASYNCGEN) - @deferred_f_from_coro_f + @coroutine_test async def test_universal_builtin(self): await self._test_asyncgen_base(self.MW_UNIVERSAL) @@ -574,39 +574,39 @@ class TestProcessSpiderException(TestBaseAsyncSpiderMiddleware): ): await self._get_middleware_result(*mw_classes) - @deferred_f_from_coro_f + @coroutine_test async def test_exc_simple(self): """Simple exc mw""" await self._test_simple_base(self.MW_EXC_SIMPLE) - @deferred_f_from_coro_f + @coroutine_test async def test_exc_async(self): """Async exc mw""" await self._test_asyncgen_base(self.MW_EXC_ASYNCGEN) - @deferred_f_from_coro_f + @coroutine_test async def test_exc_simple_simple(self): """Simple exc mw -> simple output mw""" await self._test_simple_base(self.MW_SIMPLE, self.MW_EXC_SIMPLE) - @deferred_f_from_coro_f + @coroutine_test async def test_exc_async_async(self): """Async exc mw -> async output mw""" await self._test_asyncgen_base(self.MW_ASYNCGEN, self.MW_EXC_ASYNCGEN) - @deferred_f_from_coro_f + @coroutine_test async def test_exc_simple_async(self): """Simple exc mw -> async output mw; upgrade""" await self._test_asyncgen_base(self.MW_ASYNCGEN, self.MW_EXC_SIMPLE) - @deferred_f_from_coro_f + @coroutine_test async def test_exc_async_simple(self): """Async exc mw -> simple output mw; cannot work as downgrading is not supported""" await self._test_asyncgen_nodowngrade(self.MW_SIMPLE, self.MW_EXC_ASYNCGEN) class TestDeprecatedSpiderArg(TestSpiderMiddleware): - @deferred_f_from_coro_f + @coroutine_test async def test_deprecated_mw_spider_arg(self): class DeprecatedSpiderArgMiddleware: def process_spider_input(self, response, spider): @@ -635,7 +635,7 @@ class TestDeprecatedSpiderArg(TestSpiderMiddleware): self.mwman._add_middleware(DeprecatedSpiderArgMiddleware()) await self._scrape_response() - @deferred_f_from_coro_f + @coroutine_test async def test_deprecated_mwman_spider_arg(self): with pytest.warns( ScrapyDeprecationWarning, @@ -644,7 +644,7 @@ class TestDeprecatedSpiderArg(TestSpiderMiddleware): ): await self.mwman.process_start(DefaultSpider()) - @deferred_f_from_coro_f + @coroutine_test async def test_deprecated_mwman_spider_arg_no_crawler(self): with pytest.warns( ScrapyDeprecationWarning, diff --git a/tests/test_spidermiddleware_httperror.py b/tests/test_spidermiddleware_httperror.py index d4d0a4fb1..8dc6ff300 100644 --- a/tests/test_spidermiddleware_httperror.py +++ b/tests/test_spidermiddleware_httperror.py @@ -11,7 +11,7 @@ from scrapy.utils.spider import DefaultSpider from scrapy.utils.test import get_crawler from tests.mockserver.http import MockServer from tests.spiders import MockServerSpider -from tests.utils.decorators import inlineCallbacks +from tests.utils.decorators import inline_callbacks_test class _HttpErrorSpider(MockServerSpider): @@ -202,7 +202,7 @@ class TestHttpErrorMiddlewareIntegrational: def teardown_class(cls): cls.mockserver.__exit__(None, None, None) - @inlineCallbacks + @inline_callbacks_test def test_middleware_works(self): crawler = get_crawler(_HttpErrorSpider) yield crawler.crawl(mockserver=self.mockserver) @@ -216,7 +216,7 @@ class TestHttpErrorMiddlewareIntegrational: assert get_value("httperror/response_ignored_status_count/402") == 1 assert get_value("httperror/response_ignored_status_count/500") == 1 - @inlineCallbacks + @inline_callbacks_test def test_logging(self): crawler = get_crawler(_HttpErrorSpider) with LogCapture() as log: @@ -230,7 +230,7 @@ class TestHttpErrorMiddlewareIntegrational: assert "Ignoring response <200" not in str(log) assert "Ignoring response <402" not in str(log) - @inlineCallbacks + @inline_callbacks_test def test_logging_level(self): # HttpError logs ignored responses with level INFO crawler = get_crawler(_HttpErrorSpider) diff --git a/tests/test_spidermiddleware_output_chain.py b/tests/test_spidermiddleware_output_chain.py index 07df2ecc4..80f5828c9 100644 --- a/tests/test_spidermiddleware_output_chain.py +++ b/tests/test_spidermiddleware_output_chain.py @@ -4,7 +4,7 @@ from testfixtures import LogCapture from scrapy import Request, Spider from scrapy.utils.test import get_crawler from tests.mockserver.http import MockServer -from tests.utils.decorators import deferred_f_from_coro_f +from tests.utils.decorators import coroutine_test class _BaseSpiderMiddleware: @@ -338,7 +338,7 @@ class TestSpiderMiddleware: await crawler.crawl_async(mockserver=self.mockserver) return log - @deferred_f_from_coro_f + @coroutine_test async def test_recovery(self): """ (0) Recover from an exception in a spider callback. The final item count should be 3 @@ -351,7 +351,7 @@ class TestSpiderMiddleware: assert str(log).count("Middleware: TabError exception caught") == 1 assert "'item_scraped_count': 3" in str(log) - @deferred_f_from_coro_f + @coroutine_test async def test_recovery_asyncgen(self): """ Same as test_recovery but with an async callback. @@ -361,7 +361,7 @@ class TestSpiderMiddleware: assert str(log).count("Middleware: TabError exception caught") == 1 assert "'item_scraped_count': 3" in str(log) - @deferred_f_from_coro_f + @coroutine_test async def test_process_spider_input_without_errback(self): """ (1.1) An exception from the process_spider_input chain should be caught by the @@ -371,7 +371,7 @@ class TestSpiderMiddleware: assert "Middleware: will raise IndexError" in str(log1) assert "Middleware: IndexError exception caught" in str(log1) - @deferred_f_from_coro_f + @coroutine_test async def test_process_spider_input_with_errback(self): """ (1.2) An exception from the process_spider_input chain should not be caught by the @@ -385,7 +385,7 @@ class TestSpiderMiddleware: assert "{'from': 'callback'}" not in str(log1) assert "'item_scraped_count': 1" in str(log1) - @deferred_f_from_coro_f + @coroutine_test async def test_generator_callback(self): """ (2) An exception from a spider callback (returning a generator) should @@ -396,7 +396,7 @@ class TestSpiderMiddleware: assert "Middleware: ImportError exception caught" in str(log2) assert "'item_scraped_count': 2" in str(log2) - @deferred_f_from_coro_f + @coroutine_test async def test_async_generator_callback(self): """ Same as test_generator_callback but with an async callback. @@ -405,7 +405,7 @@ class TestSpiderMiddleware: assert "Middleware: ImportError exception caught" in str(log2) assert "'item_scraped_count': 2" in str(log2) - @deferred_f_from_coro_f + @coroutine_test async def test_generator_callback_right_after_callback(self): """ (2.1) Special case of (2): Exceptions should be caught @@ -415,7 +415,7 @@ class TestSpiderMiddleware: assert "Middleware: ImportError exception caught" in str(log21) assert "'item_scraped_count': 2" in str(log21) - @deferred_f_from_coro_f + @coroutine_test async def test_not_a_generator_callback(self): """ (3) An exception from a spider callback (returning a list) should @@ -425,7 +425,7 @@ class TestSpiderMiddleware: assert "Middleware: ZeroDivisionError exception caught" in str(log3) assert "item_scraped_count" not in str(log3) - @deferred_f_from_coro_f + @coroutine_test async def test_not_a_generator_callback_right_after_callback(self): """ (3.1) Special case of (3): Exceptions should be caught @@ -437,7 +437,7 @@ class TestSpiderMiddleware: assert "Middleware: ZeroDivisionError exception caught" in str(log31) assert "item_scraped_count" not in str(log31) - @deferred_f_from_coro_f + @coroutine_test async def test_generator_output_chain(self): """ (4) An exception from a middleware's process_spider_output method should be sent @@ -484,7 +484,7 @@ class TestSpiderMiddleware: assert str(item_recovered) in str(log4) assert "parse-second-item" not in str(log4) - @deferred_f_from_coro_f + @coroutine_test async def test_not_a_generator_output_chain(self): """ (5) An exception from a middleware's process_spider_output method should be sent diff --git a/tests/test_spidermiddleware_process_start.py b/tests/test_spidermiddleware_process_start.py index e7c9640b8..bccfc4432 100644 --- a/tests/test_spidermiddleware_process_start.py +++ b/tests/test_spidermiddleware_process_start.py @@ -10,7 +10,7 @@ from scrapy.utils.test import get_crawler from tests.test_spider_start import SLEEP_SECONDS from .utils import twisted_sleep -from .utils.decorators import deferred_f_from_coro_f +from .utils.decorators import coroutine_test ITEM_A = {"id": "a"} ITEM_B = {"id": "b"} @@ -130,45 +130,45 @@ class TestMain: expected_items = expected_items or [ITEM_A, ITEM_A, ITEM_B, ITEM_C, ITEM_C] await self._test([smw1, smw2], spider_cls, expected_items) - @deferred_f_from_coro_f + @coroutine_test async def test_modern_mw_modern_spider(self): with warnings.catch_warnings(): warnings.simplefilter("error") await self._test_wrap(ModernWrapSpiderMiddleware, ModernWrapSpider) - @deferred_f_from_coro_f + @coroutine_test async def test_modern_mw_universal_spider(self): with warnings.catch_warnings(): warnings.simplefilter("error") await self._test_wrap(ModernWrapSpiderMiddleware, UniversalWrapSpider) - @deferred_f_from_coro_f + @coroutine_test async def test_modern_mw_deprecated_spider(self): with pytest.warns( ScrapyDeprecationWarning, match=r"deprecated start_requests\(\)" ): await self._test_wrap(ModernWrapSpiderMiddleware, DeprecatedWrapSpider) - @deferred_f_from_coro_f + @coroutine_test async def test_universal_mw_modern_spider(self): with warnings.catch_warnings(): warnings.simplefilter("error") await self._test_wrap(UniversalWrapSpiderMiddleware, ModernWrapSpider) - @deferred_f_from_coro_f + @coroutine_test async def test_universal_mw_universal_spider(self): with warnings.catch_warnings(): warnings.simplefilter("error") await self._test_wrap(UniversalWrapSpiderMiddleware, UniversalWrapSpider) - @deferred_f_from_coro_f + @coroutine_test async def test_universal_mw_deprecated_spider(self): with pytest.warns( ScrapyDeprecationWarning, match=r"deprecated start_requests\(\)" ): await self._test_wrap(UniversalWrapSpiderMiddleware, DeprecatedWrapSpider) - @deferred_f_from_coro_f + @coroutine_test async def test_deprecated_mw_modern_spider(self): with ( pytest.warns( @@ -180,7 +180,7 @@ class TestMain: ): await self._test_wrap(DeprecatedWrapSpiderMiddleware, ModernWrapSpider) - @deferred_f_from_coro_f + @coroutine_test async def test_deprecated_mw_modern_spider_subclass(self): with ( pytest.warns( @@ -195,7 +195,7 @@ class TestMain: DeprecatedWrapSpiderMiddleware, ModernWrapSpiderSubclass ) - @deferred_f_from_coro_f + @coroutine_test async def test_deprecated_mw_universal_spider(self): with pytest.warns( ScrapyDeprecationWarning, match=r"deprecated process_start_requests\(\)" @@ -206,7 +206,7 @@ class TestMain: [ITEM_A, ITEM_D, ITEM_C], ) - @deferred_f_from_coro_f + @coroutine_test async def test_deprecated_mw_deprecated_spider(self): with ( pytest.warns( @@ -218,7 +218,7 @@ class TestMain: ): await self._test_wrap(DeprecatedWrapSpiderMiddleware, DeprecatedWrapSpider) - @deferred_f_from_coro_f + @coroutine_test async def test_modern_mw_universal_mw_modern_spider(self): with warnings.catch_warnings(): warnings.simplefilter("error") @@ -228,7 +228,7 @@ class TestMain: ModernWrapSpider, ) - @deferred_f_from_coro_f + @coroutine_test async def test_modern_mw_deprecated_mw_modern_spider(self): with pytest.raises(ValueError, match=r"trying to combine spider middlewares"): await self._test_douple_wrap( @@ -237,7 +237,7 @@ class TestMain: ModernWrapSpider, ) - @deferred_f_from_coro_f + @coroutine_test async def test_universal_mw_deprecated_mw_modern_spider(self): with ( pytest.warns( @@ -253,7 +253,7 @@ class TestMain: ModernWrapSpider, ) - @deferred_f_from_coro_f + @coroutine_test async def test_modern_mw_universal_mw_universal_spider(self): with warnings.catch_warnings(): warnings.simplefilter("error") @@ -263,7 +263,7 @@ class TestMain: UniversalWrapSpider, ) - @deferred_f_from_coro_f + @coroutine_test async def test_modern_mw_deprecated_mw_universal_spider(self): with pytest.raises(ValueError, match=r"trying to combine spider middlewares"): await self._test_douple_wrap( @@ -272,7 +272,7 @@ class TestMain: UniversalWrapSpider, ) - @deferred_f_from_coro_f + @coroutine_test async def test_universal_mw_deprecated_mw_universal_spider(self): with pytest.warns( ScrapyDeprecationWarning, match=r"deprecated process_start_requests\(\)" @@ -284,7 +284,7 @@ class TestMain: [ITEM_A, ITEM_A, ITEM_D, ITEM_C, ITEM_C], ) - @deferred_f_from_coro_f + @coroutine_test async def test_modern_mw_universal_mw_deprecated_spider(self): with pytest.warns( ScrapyDeprecationWarning, match=r"deprecated start_requests\(\)" @@ -295,7 +295,7 @@ class TestMain: DeprecatedWrapSpider, ) - @deferred_f_from_coro_f + @coroutine_test async def test_modern_mw_deprecated_mw_deprecated_spider(self): with pytest.raises(ValueError, match=r"trying to combine spider middlewares"): await self._test_douple_wrap( @@ -304,7 +304,7 @@ class TestMain: DeprecatedWrapSpider, ) - @deferred_f_from_coro_f + @coroutine_test async def test_universal_mw_deprecated_mw_deprecated_spider(self): with ( pytest.warns( @@ -330,24 +330,24 @@ class TestMain: await self._test(spider_middlewares, TestSpider, [ITEM_A]) @pytest.mark.only_asyncio - @deferred_f_from_coro_f + @coroutine_test async def test_asyncio_sleep_single(self): await self._test_sleep([AsyncioSleepSpiderMiddleware]) @pytest.mark.only_asyncio - @deferred_f_from_coro_f + @coroutine_test async def test_asyncio_sleep_multiple(self): await self._test_sleep( [NoOpSpiderMiddleware, AsyncioSleepSpiderMiddleware, NoOpSpiderMiddleware] ) @pytest.mark.requires_reactor - @deferred_f_from_coro_f + @coroutine_test async def test_twisted_sleep_single(self): await self._test_sleep([TwistedSleepSpiderMiddleware]) @pytest.mark.requires_reactor - @deferred_f_from_coro_f + @coroutine_test async def test_twisted_sleep_multiple(self): await self._test_sleep( [NoOpSpiderMiddleware, TwistedSleepSpiderMiddleware, NoOpSpiderMiddleware] diff --git a/tests/test_spidermiddleware_start.py b/tests/test_spidermiddleware_start.py index 1f2209f60..76976d962 100644 --- a/tests/test_spidermiddleware_start.py +++ b/tests/test_spidermiddleware_start.py @@ -3,11 +3,11 @@ from scrapy.spidermiddlewares.start import StartSpiderMiddleware from scrapy.spiders import Spider from scrapy.utils.misc import build_from_crawler from scrapy.utils.test import get_crawler -from tests.utils.decorators import deferred_f_from_coro_f +from tests.utils.decorators import coroutine_test class TestMiddleware: - @deferred_f_from_coro_f + @coroutine_test async def test_async(self): crawler = get_crawler(Spider) mw = build_from_crawler(StartSpiderMiddleware, crawler) @@ -24,7 +24,7 @@ class TestMiddleware: ] assert result == [True, True, False, "foo"] - @deferred_f_from_coro_f + @coroutine_test async def test_sync(self): crawler = get_crawler(Spider) mw = build_from_crawler(StartSpiderMiddleware, crawler) diff --git a/tests/test_stats.py b/tests/test_stats.py index 187d240a8..2869d302e 100644 --- a/tests/test_stats.py +++ b/tests/test_stats.py @@ -12,7 +12,7 @@ from scrapy.spiders import Spider from scrapy.statscollectors import DummyStatsCollector, StatsCollector from scrapy.utils.test import get_crawler from tests.spiders import SimpleSpider -from tests.utils.decorators import deferred_f_from_coro_f +from tests.utils.decorators import coroutine_test if TYPE_CHECKING: from scrapy.crawler import Crawler @@ -121,7 +121,7 @@ class TestStatsCollector: ): assert stats.get_stats(spider) == {"test": "value"} - @deferred_f_from_coro_f + @coroutine_test async def test_deprecated_spider_arg_custom_collector(self) -> None: class CustomStatsCollector: def __init__(self, crawler): @@ -153,7 +153,7 @@ class TestStatsCollector: ): await crawler.crawl_async(url="data:,") - @deferred_f_from_coro_f + @coroutine_test async def test_deprecated_spider_arg_custom_collector_subclass(self) -> None: class CustomStatsCollector(StatsCollector): def open_spider(self, spider): # pylint: disable=signature-differs diff --git a/tests/test_utils_asyncgen.py b/tests/test_utils_asyncgen.py index dba1ac5da..fc4e1c487 100644 --- a/tests/test_utils_asyncgen.py +++ b/tests/test_utils_asyncgen.py @@ -1,15 +1,15 @@ from scrapy.utils.asyncgen import as_async_generator, collect_asyncgen -from tests.utils.decorators import deferred_f_from_coro_f +from tests.utils.decorators import coroutine_test class TestAsyncgenUtils: - @deferred_f_from_coro_f + @coroutine_test async def test_as_async_generator(self): ag = as_async_generator(range(42)) results = [i async for i in ag] assert results == list(range(42)) - @deferred_f_from_coro_f + @coroutine_test async def test_collect_asyncgen(self): ag = as_async_generator(range(42)) results = await collect_asyncgen(ag) diff --git a/tests/test_utils_asyncio.py b/tests/test_utils_asyncio.py index 6706c1c80..5489fd948 100644 --- a/tests/test_utils_asyncio.py +++ b/tests/test_utils_asyncio.py @@ -14,7 +14,7 @@ from scrapy.utils.asyncio import ( _parallel_asyncio, is_asyncio_available, ) -from tests.utils.decorators import deferred_f_from_coro_f +from tests.utils.decorators import coroutine_test if TYPE_CHECKING: from collections.abc import AsyncGenerator @@ -67,7 +67,7 @@ class TestParallelAsyncio: await asyncio.sleep(random.random() / 20) yield i - @deferred_f_from_coro_f + @coroutine_test async def test_simple(self): for length in [20, 50, 100]: parallel_count = [0] @@ -85,7 +85,7 @@ class TestParallelAsyncio: assert list(range(length)) == sorted(results) assert max_parallel_count[0] <= self.CONCURRENT_ITEMS - @deferred_f_from_coro_f + @coroutine_test async def test_delays(self): for length in [20, 50, 100]: parallel_count = [0] diff --git a/tests/test_utils_defer.py b/tests/test_utils_defer.py index ad4cc466d..296bd6224 100644 --- a/tests/test_utils_defer.py +++ b/tests/test_utils_defer.py @@ -6,8 +6,7 @@ from asyncio import Future from typing import TYPE_CHECKING, Any import pytest -from twisted.internet.defer import Deferred, succeed -from twisted.internet.defer import inlineCallbacks as inlineCallbacks_orig +from twisted.internet.defer import Deferred, inlineCallbacks, succeed from scrapy.utils.asyncgen import as_async_generator, collect_asyncgen from scrapy.utils.defer import ( @@ -20,7 +19,7 @@ from scrapy.utils.defer import ( mustbe_deferred, parallel_async, ) -from tests.utils.decorators import inlineCallbacks +from tests.utils.decorators import inline_callbacks_test if TYPE_CHECKING: from collections.abc import AsyncGenerator, Awaitable, Callable, Generator @@ -29,7 +28,7 @@ if TYPE_CHECKING: @pytest.mark.requires_reactor @pytest.mark.filterwarnings("ignore::scrapy.exceptions.ScrapyDeprecationWarning") class TestMustbeDeferred: - @inlineCallbacks + @inline_callbacks_test def test_success_function(self) -> Generator[Deferred[Any], Any, None]: steps: list[int] = [] @@ -45,7 +44,7 @@ class TestMustbeDeferred: steps.append(2) # add another value, that should be caught by assertEqual yield dfd - @inlineCallbacks + @inline_callbacks_test def test_unfired_deferred(self) -> Generator[Deferred[Any], Any, None]: steps: list[int] = [] @@ -224,7 +223,7 @@ class TestParallelAsync: await maybe_deferred_to_future(dfd) yield i - @inlineCallbacks + @inline_callbacks_test def test_simple(self): for length in [20, 50, 100]: parallel_count = [0] @@ -244,7 +243,7 @@ class TestParallelAsync: assert parallel_count[0] == 0 assert max_parallel_count[0] <= self.CONCURRENT_ITEMS, max_parallel_count[0] - @inlineCallbacks + @inline_callbacks_test def test_delays(self): for length in [20, 50, 100]: parallel_count = [0] @@ -276,7 +275,7 @@ class TestDeferredFromCoro: result = deferred_from_coro(42) assert result == 42 - @inlineCallbacks + @inline_callbacks_test def test_coroutine(self): async def coroutine() -> int: return 42 @@ -287,7 +286,7 @@ class TestDeferredFromCoro: assert coro_result == 42 @pytest.mark.only_asyncio - @inlineCallbacks + @inline_callbacks_test def test_coroutine_asyncio(self): async def coroutine() -> int: await asyncio.sleep(0.01) @@ -299,7 +298,7 @@ class TestDeferredFromCoro: assert coro_result == 42 @pytest.mark.only_asyncio - @inlineCallbacks + @inline_callbacks_test def test_future(self): future = Future() result = deferred_from_coro(future) @@ -310,7 +309,7 @@ class TestDeferredFromCoro: class TestDeferredFFromCoroF: - @inlineCallbacks_orig + @inlineCallbacks def _assert_result( self, c_f: Callable[[], Awaitable[int]] ) -> Generator[Deferred[Any], Any, None]: @@ -320,7 +319,7 @@ class TestDeferredFFromCoroF: result = yield d assert result == 42 - @inlineCallbacks + @inline_callbacks_test def test_coroutine(self): async def c_f() -> int: return 42 @@ -328,7 +327,7 @@ class TestDeferredFFromCoroF: yield self._assert_result(c_f) @pytest.mark.only_asyncio - @inlineCallbacks + @inline_callbacks_test def test_coroutine_asyncio(self): async def c_f() -> int: await asyncio.sleep(0.01) @@ -337,7 +336,7 @@ class TestDeferredFFromCoroF: yield self._assert_result(c_f) @pytest.mark.only_asyncio - @inlineCallbacks + @inline_callbacks_test def test_future(self): def c_f() -> Future[int]: f: Future[int] = Future() diff --git a/tests/test_utils_python.py b/tests/test_utils_python.py index 4a9e375fb..8dfac5f02 100644 --- a/tests/test_utils_python.py +++ b/tests/test_utils_python.py @@ -20,7 +20,7 @@ from scrapy.utils.python import ( to_unicode, without_none_values, ) -from tests.utils.decorators import deferred_f_from_coro_f +from tests.utils.decorators import coroutine_test if TYPE_CHECKING: from collections.abc import Iterable, Mapping @@ -64,7 +64,7 @@ class TestMutableAsyncChain: for i in range(5, 7): yield i - @deferred_f_from_coro_f + @coroutine_test async def test_mutableasyncchain(self): m = MutableAsyncChain(self.g1(), as_async_generator(range(3, 7))) m.extend(self.g2()) @@ -74,7 +74,7 @@ class TestMutableAsyncChain: results = await collect_asyncgen(m) assert results == list(range(1, 10)) - @deferred_f_from_coro_f + @coroutine_test async def test_mutableasyncchain_exc(self): m = MutableAsyncChain(self.g1()) m.extend(self.g4()) diff --git a/tests/test_utils_reactor.py b/tests/test_utils_reactor.py index a045c05a4..3255d5940 100644 --- a/tests/test_utils_reactor.py +++ b/tests/test_utils_reactor.py @@ -9,7 +9,7 @@ from scrapy.utils.reactor import ( is_asyncio_reactor_installed, set_asyncio_event_loop, ) -from tests.utils.decorators import deferred_f_from_coro_f +from tests.utils.decorators import coroutine_test class TestAsyncio: @@ -31,7 +31,7 @@ class TestAsyncio: @pytest.mark.requires_reactor @pytest.mark.only_asyncio - @deferred_f_from_coro_f + @coroutine_test async def test_set_asyncio_event_loop(self): install_reactor(_asyncio_reactor_path) assert set_asyncio_event_loop(None) is asyncio.get_running_loop() diff --git a/tests/test_utils_signal.py b/tests/test_utils_signal.py index e47e9013a..9ac0fd0c0 100644 --- a/tests/test_utils_signal.py +++ b/tests/test_utils_signal.py @@ -14,14 +14,14 @@ from scrapy.utils.signal import ( send_catch_log_deferred, ) from scrapy.utils.test import get_from_asyncio_queue -from tests.utils.decorators import inlineCallbacks +from tests.utils.decorators import inline_callbacks_test class TestSendCatchLog: # whether the function being tested returns exceptions or failures returns_exceptions: bool = False - @inlineCallbacks + @inline_callbacks_test def test_send_catch_log(self): test_signal = object() handlers_called = set() diff --git a/tests/utils/decorators.py b/tests/utils/decorators.py index 633d8d97d..dd9043ab1 100644 --- a/tests/utils/decorators.py +++ b/tests/utils/decorators.py @@ -4,8 +4,7 @@ from functools import wraps from typing import TYPE_CHECKING, Any, ParamSpec import pytest -from twisted.internet.defer import Deferred -from twisted.internet.defer import inlineCallbacks as inlineCallbacks_orig +from twisted.internet.defer import Deferred, inlineCallbacks from scrapy.utils.defer import deferred_from_coro, deferred_to_future from scrapy.utils.reactor import is_reactor_installed @@ -17,7 +16,7 @@ if TYPE_CHECKING: _P = ParamSpec("_P") -def inlineCallbacks( +def inline_callbacks_test( f: Callable[_P, Generator[Deferred[Any], Any, None]], ) -> Callable[_P, Awaitable[None]]: """Mark a test function written in a :func:`twisted.internet.defer.inlineCallbacks` style. @@ -34,12 +33,12 @@ def inlineCallbacks( @pytest.mark.asyncio @wraps(f) async def wrapper_coro(*args: _P.args, **kwargs: _P.kwargs) -> None: - await deferred_to_future(inlineCallbacks_orig(f)(*args, **kwargs)) + await deferred_to_future(inlineCallbacks(f)(*args, **kwargs)) return wrapper_coro @wraps(f) - @inlineCallbacks_orig + @inlineCallbacks def wrapper_dfd( *args: _P.args, **kwargs: _P.kwargs ) -> Generator[Deferred[Any], Any, None]: @@ -48,7 +47,7 @@ def inlineCallbacks( return wrapper_dfd -def deferred_f_from_coro_f( +def coroutine_test( coro_f: Callable[_P, Awaitable[None]], ) -> Callable[_P, Awaitable[None]]: """Mark a test function that returns a coroutine. From 2e53d90e4c69a3196e7725e993f97fb7752e8d26 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Wed, 4 Feb 2026 00:13:30 +0500 Subject: [PATCH 044/248] Refactor HTTP download handlers (#7238) --- scrapy/core/downloader/handlers/http11.py | 164 +++++++------------- scrapy/core/http2/stream.py | 50 +++--- scrapy/exceptions.py | 7 +- scrapy/utils/_download_handlers.py | 106 ++++++++++++- tests/test_downloader_handlers_http_base.py | 44 +++++- tests/test_http2_client_protocol.py | 28 ++-- 6 files changed, 248 insertions(+), 151 deletions(-) diff --git a/scrapy/core/downloader/handlers/http11.py b/scrapy/core/downloader/handlers/http11.py index 21def678a..992c318c5 100644 --- a/scrapy/core/downloader/handlers/http11.py +++ b/scrapy/core/downloader/handlers/http11.py @@ -31,7 +31,6 @@ from zope.interface import implementer from scrapy import Request, signals from scrapy.core.downloader.contextfactory import load_context_factory_from_settings -from scrapy.core.downloader.handlers.base import BaseDownloadHandler from scrapy.exceptions import ( DownloadCancelledError, DownloadTimeoutError, @@ -39,8 +38,15 @@ from scrapy.exceptions import ( StopDownload, ) from scrapy.http import Headers, Response -from scrapy.responsetypes import responsetypes -from scrapy.utils._download_handlers import wrap_twisted_exceptions +from scrapy.utils._download_handlers import ( + BaseHttpDownloadHandler, + check_stop_download, + get_dataloss_msg, + get_maxsize_msg, + get_warnsize_msg, + make_response, + wrap_twisted_exceptions, +) from scrapy.utils.defer import maybe_deferred_to_future from scrapy.utils.deprecate import warn_on_deprecated_spider_attribute from scrapy.utils.httpobj import urlparse_cached @@ -64,14 +70,14 @@ _T = TypeVar("_T") class _ResultT(TypedDict): txresponse: TxResponse - body: bytes - flags: list[str] | None - certificate: ssl.Certificate | None - ip_address: ipaddress.IPv4Address | ipaddress.IPv6Address | None - failure: NotRequired[Failure | None] + body: NotRequired[bytes] + flags: NotRequired[list[str] | None] + certificate: NotRequired[ssl.Certificate | None] + ip_address: NotRequired[ipaddress.IPv4Address | ipaddress.IPv6Address | None] + stop_download: NotRequired[StopDownload | None] -class HTTP11DownloadHandler(BaseDownloadHandler): +class HTTP11DownloadHandler(BaseHttpDownloadHandler): def __init__(self, crawler: Crawler): super().__init__(crawler) self._crawler = crawler @@ -87,13 +93,7 @@ class HTTP11DownloadHandler(BaseDownloadHandler): self._contextFactory: IPolicyForHTTPS = load_context_factory_from_settings( crawler.settings, crawler ) - self._default_maxsize: int = crawler.settings.getint("DOWNLOAD_MAXSIZE") - self._default_warnsize: int = crawler.settings.getint("DOWNLOAD_WARNSIZE") - self._fail_on_dataloss: bool = crawler.settings.getbool( - "DOWNLOAD_FAIL_ON_DATALOSS" - ) self._disconnect_timeout: int = 1 - self._fail_on_dataloss_warned: bool = False async def download_request(self, request: Request) -> Response: """Return a deferred for the HTTP download""" @@ -121,12 +121,7 @@ class HTTP11DownloadHandler(BaseDownloadHandler): return await maybe_deferred_to_future(agent.download_request(request)) except ResponseDataLossError: if not self._fail_on_dataloss_warned: - logger.warning( - "Got data loss in %s. If you want to process broken " - "responses set the setting DOWNLOAD_FAIL_ON_DATALOSS = False" - " -- This message won't be shown in further requests", - request.url, - ) + logger.warning(get_dataloss_msg(request.url)) self._fail_on_dataloss_warned = True raise @@ -465,7 +460,7 @@ class ScrapyAgent: d.addCallback(self._cb_latency, request, start_time) # response body is ready to be consumed d2: Deferred[_ResultT] = d.addCallback(self._cb_bodyready, request) - d3: Deferred[Response] = d2.addCallback(self._cb_bodydone, request, url) + d3: Deferred[Response] = d2.addCallback(self._cb_bodydone, url) # check download timeout self._timeout_cl = reactor.callLater(timeout, d3.cancel) d3.addBoth(self._cb_timeout, request, url, timeout) @@ -497,68 +492,48 @@ class ScrapyAgent: def _cb_bodyready( self, txresponse: TxResponse, request: Request ) -> _ResultT | Deferred[_ResultT]: - headers_received_result = self._crawler.signals.send_catch_log( - signal=signals.headers_received, + if stop_download := check_stop_download( + signals.headers_received, + self._crawler, + request, headers=self._headers_from_twisted_response(txresponse), body_length=txresponse.length, - request=request, - spider=self._crawler.spider, - ) - for handler, result in headers_received_result: - if isinstance(result, Failure) and isinstance(result.value, StopDownload): - logger.debug( - "Download stopped for %(request)s from signal handler %(handler)s", - {"request": request, "handler": handler.__qualname__}, - ) - txresponse._transport.stopProducing() - txresponse._transport.loseConnection() - return { - "txresponse": txresponse, - "body": b"", - "flags": ["download_stopped"], - "certificate": None, - "ip_address": None, - "failure": result if result.value.fail else None, - } + ): + txresponse._transport.stopProducing() + txresponse._transport.loseConnection() + return { + "txresponse": txresponse, + "stop_download": stop_download, + } # deliverBody hangs for responses without body if txresponse.length == 0: return { "txresponse": txresponse, - "body": b"", - "flags": None, - "certificate": None, - "ip_address": None, } maxsize = request.meta.get("download_maxsize", self._maxsize) warnsize = request.meta.get("download_warnsize", self._warnsize) - expected_size = txresponse.length if txresponse.length != UNKNOWN_LENGTH else -1 + expected_size = ( + cast("int", txresponse.length) + if txresponse.length != UNKNOWN_LENGTH + else -1 + ) fail_on_dataloss = request.meta.get( "download_fail_on_dataloss", self._fail_on_dataloss ) if maxsize and expected_size > maxsize: - warning_msg = ( - "Cancelling download of %(url)s: expected response " - "size (%(size)s) larger than download max size (%(maxsize)s)." + warning_msg = get_maxsize_msg( + expected_size, maxsize, request, expected=True ) - warning_args = { - "url": request.url, - "size": expected_size, - "maxsize": maxsize, - } - - logger.warning(warning_msg, warning_args) - + logger.warning(warning_msg) txresponse._transport.loseConnection() - raise DownloadCancelledError(warning_msg % warning_args) + raise DownloadCancelledError(warning_msg) if warnsize and expected_size > warnsize: logger.warning( - "Expected response size (%(size)s) larger than " - "download warn size (%(warnsize)s) in request %(request)s.", - {"size": expected_size, "warnsize": warnsize, "request": request}, + get_warnsize_msg(expected_size, warnsize, request, expected=True) ) def _cancel(_: Any) -> None: @@ -583,31 +558,24 @@ class ScrapyAgent: return d - def _cb_bodydone( - self, result: _ResultT, request: Request, url: str - ) -> Response | Failure: + def _cb_bodydone(self, result: _ResultT, url: str) -> Response: headers = self._headers_from_twisted_response(result["txresponse"]) - respcls = responsetypes.from_args(headers=headers, url=url, body=result["body"]) try: version = result["txresponse"].version protocol = f"{to_unicode(version[0])}/{version[1]}.{version[2]}" except (AttributeError, TypeError, IndexError): protocol = None - response = respcls( + return make_response( url=url, status=int(result["txresponse"].code), headers=headers, - body=result["body"], - flags=result["flags"], - certificate=result["certificate"], - ip_address=result["ip_address"], + body=result.get("body", b""), + flags=result.get("flags"), + certificate=result.get("certificate"), + ip_address=result.get("ip_address"), protocol=protocol, + stop_download=result.get("stop_download"), ) - if result.get("failure"): - assert result["failure"] - result["failure"].value.response = response - return result["failure"] - return response @implementer(IBodyProducer) @@ -652,7 +620,7 @@ class _ResponseReader(Protocol): self._crawler: Crawler = crawler def _finish_response( - self, flags: list[str] | None = None, failure: Failure | None = None + self, flags: list[str] | None = None, stop_download: StopDownload | None = None ) -> None: self._finished.callback( { @@ -661,7 +629,7 @@ class _ResponseReader(Protocol): "flags": flags, "certificate": self._certificate, "ip_address": self._ip_address, - "failure": failure, + "stop_download": stop_download, } ) @@ -687,32 +655,18 @@ class _ResponseReader(Protocol): self._bodybuf.write(bodyBytes) self._bytes_received += len(bodyBytes) - bytes_received_result = self._crawler.signals.send_catch_log( - signal=signals.bytes_received, - data=bodyBytes, - request=self._request, - spider=self._crawler.spider, - ) - for handler, result in bytes_received_result: - if isinstance(result, Failure) and isinstance(result.value, StopDownload): - logger.debug( - "Download stopped for %(request)s from signal handler %(handler)s", - {"request": self._request, "handler": handler.__qualname__}, - ) - self.transport.stopProducing() - self.transport.loseConnection() - failure = result if result.value.fail else None - self._finish_response(flags=["download_stopped"], failure=failure) + if stop_download := check_stop_download( + signals.bytes_received, self._crawler, self._request, data=bodyBytes + ): + self.transport.stopProducing() + self.transport.loseConnection() + self._finish_response(stop_download=stop_download) if self._maxsize and self._bytes_received > self._maxsize: logger.warning( - "Received (%(bytes)s) bytes larger than download " - "max size (%(maxsize)s) in request %(request)s.", - { - "bytes": self._bytes_received, - "maxsize": self._maxsize, - "request": self._request, - }, + get_maxsize_msg( + self._bytes_received, self._maxsize, self._request, expected=False + ) ) # Clear buffer earlier to avoid keeping data in memory for a long time. self._bodybuf.truncate(0) @@ -725,9 +679,9 @@ class _ResponseReader(Protocol): ): self._reached_warnsize = True logger.warning( - "Received more bytes than download " - "warn size (%(warnsize)s) in request %(request)s.", - {"warnsize": self._warnsize, "request": self._request}, + get_warnsize_msg( + self._bytes_received, self._warnsize, self._request, expected=False + ) ) def connectionLost(self, reason: Failure = connectionDone) -> None: diff --git a/scrapy/core/http2/stream.py b/scrapy/core/http2/stream.py index 291419d84..39c2eaae8 100644 --- a/scrapy/core/http2/stream.py +++ b/scrapy/core/http2/stream.py @@ -14,7 +14,11 @@ from twisted.web.client import ResponseFailed from scrapy.exceptions import DownloadCancelledError from scrapy.http.headers import Headers -from scrapy.responsetypes import responsetypes +from scrapy.utils._download_handlers import ( + get_maxsize_msg, + get_warnsize_msg, + make_response, +) from scrapy.utils.httpobj import urlparse_cached if TYPE_CHECKING: @@ -75,6 +79,9 @@ class StreamCloseReason(Enum): # As a result sending this request will the end the connection INVALID_HOSTNAME = 7 + # Actual response body size is more than allowed limit + MAXSIZE_EXCEEDED_ACTUAL = 8 + class Stream: """Represents a single HTTP/2 Stream. @@ -334,14 +341,16 @@ class Stream: self._download_maxsize and self._response["flow_controlled_size"] > self._download_maxsize ): - self.reset_stream(StreamCloseReason.MAXSIZE_EXCEEDED) + self.reset_stream(StreamCloseReason.MAXSIZE_EXCEEDED_ACTUAL) return if self._log_warnsize: self.metadata["reached_warnsize"] = True - warning_msg = ( - f"Received more ({self._response['flow_controlled_size']}) bytes than download " - f"warn size ({self._download_warnsize}) in request {self._request}" + warning_msg = get_warnsize_msg( + self._response["flow_controlled_size"], + self._download_warnsize, + self._request, + expected=False, ) logger.warning(warning_msg) @@ -362,9 +371,8 @@ class Stream: if self._log_warnsize: self.metadata["reached_warnsize"] = True - warning_msg = ( - f"Expected response size ({expected_size}) larger than " - f"download warn size ({self._download_warnsize}) in request {self._request}" + warning_msg = get_warnsize_msg( + expected_size, self._download_warnsize, self._request, expected=True ) logger.warning(warning_msg) @@ -412,15 +420,20 @@ class Stream: # As we immediately cancel the request when maxsize is exceeded while # receiving DATA_FRAME's when we have received the headers (not # having Content-Length) - if reason is StreamCloseReason.MAXSIZE_EXCEEDED: + if reason in { + StreamCloseReason.MAXSIZE_EXCEEDED, + StreamCloseReason.MAXSIZE_EXCEEDED_ACTUAL, + }: expected_size = int( self._response["headers"].get( b"Content-Length", self._response["flow_controlled_size"] ) ) - error_msg = ( - f"Cancelling download of {self._request.url}: received response " - f"size ({expected_size}) larger than download max size ({self._download_maxsize})" + error_msg = get_maxsize_msg( + expected_size, + self._download_maxsize, + self._request, + expected=reason == StreamCloseReason.MAXSIZE_EXCEEDED, ) logger.error(error_msg) self._deferred_response.errback(DownloadCancelledError(error_msg)) @@ -475,22 +488,13 @@ class Stream: and fires the response deferred callback with the generated response instance""" - body = self._response["body"].getvalue() - response_cls = responsetypes.from_args( - headers=self._response["headers"], - url=self._request.url, - body=body, - ) - - response = response_cls( + response = make_response( url=self._request.url, status=int(self._response["headers"][":status"]), headers=self._response["headers"], - body=body, - request=self._request, + body=self._response["body"].getvalue(), certificate=self._protocol.metadata["certificate"], ip_address=self._protocol.metadata["ip_address"], protocol="h2", ) - self._deferred_response.callback(response) diff --git a/scrapy/exceptions.py b/scrapy/exceptions.py index b25add8cf..204132973 100644 --- a/scrapy/exceptions.py +++ b/scrapy/exceptions.py @@ -7,7 +7,10 @@ new exceptions here without documenting them there. from __future__ import annotations -from typing import Any +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from scrapy.http import Response # Internal @@ -49,6 +52,8 @@ class StopDownload(Exception): should be handled by the request errback. Note that 'fail' is a keyword-only argument. """ + response: Response | None + def __init__(self, *, fail: bool = True): super().__init__() self.fail = fail diff --git a/scrapy/utils/_download_handlers.py b/scrapy/utils/_download_handlers.py index 4c7721483..8b0f0fa53 100644 --- a/scrapy/utils/_download_handlers.py +++ b/scrapy/utils/_download_handlers.py @@ -2,27 +2,53 @@ from __future__ import annotations +from abc import ABC from contextlib import contextmanager -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from twisted.internet.defer import CancelledError from twisted.internet.error import ConnectionRefusedError as TxConnectionRefusedError from twisted.internet.error import DNSLookupError from twisted.internet.error import TimeoutError as TxTimeoutError +from twisted.python.failure import Failure from twisted.web.client import ResponseFailed from twisted.web.error import SchemeNotSupported +from scrapy import responsetypes +from scrapy.core.downloader.handlers.base import BaseDownloadHandler from scrapy.exceptions import ( CannotResolveHostError, DownloadCancelledError, DownloadConnectionRefusedError, DownloadFailedError, DownloadTimeoutError, + StopDownload, UnsupportedURLSchemeError, ) +from scrapy.utils.log import logger if TYPE_CHECKING: from collections.abc import Iterator + from ipaddress import IPv4Address, IPv6Address + + from twisted.internet.ssl import Certificate + + from scrapy import Request + from scrapy.crawler import Crawler + from scrapy.http import Headers, Response + + +class BaseHttpDownloadHandler(BaseDownloadHandler, ABC): + """Base class for built-in HTTP download handlers.""" + + def __init__(self, crawler: Crawler): + super().__init__(crawler) + self._default_maxsize: int = crawler.settings.getint("DOWNLOAD_MAXSIZE") + self._default_warnsize: int = crawler.settings.getint("DOWNLOAD_WARNSIZE") + self._fail_on_dataloss: bool = crawler.settings.getbool( + "DOWNLOAD_FAIL_ON_DATALOSS" + ) + self._fail_on_dataloss_warned: bool = False @contextmanager @@ -42,3 +68,81 @@ def wrap_twisted_exceptions() -> Iterator[None]: raise DownloadFailedError(str(e)) from e except TxTimeoutError as e: raise DownloadTimeoutError(str(e)) from e + + +def check_stop_download( + signal: object, crawler: Crawler, request: Request, **kwargs: Any +) -> StopDownload | None: + """Send the given signal and check if any of its handlers raised + :exc:`~scrapy.exceptions.StopDownload`. + + Return the raised exception or ``None``. + """ + signal_result = crawler.signals.send_catch_log( + signal=signal, + request=request, + spider=crawler.spider, + **kwargs, + ) + for handler, result in signal_result: + if isinstance(result, Failure) and isinstance(result.value, StopDownload): + logger.debug( + f"Download stopped for {request} from signal handler {handler.__qualname__}" + ) + return result.value + + return None + + +def make_response( + url: str, + status: int, + headers: Headers, + body: bytes = b"", + flags: list[str] | None = None, + certificate: Certificate | None = None, + ip_address: IPv4Address | IPv6Address | None = None, + protocol: str | None = None, + stop_download: StopDownload | None = None, +) -> Response: + respcls = responsetypes.responsetypes.from_args(headers=headers, url=url, body=body) + response = respcls( + url=url, + status=status, + headers=headers, + body=body, + flags=flags, + certificate=certificate, + ip_address=ip_address, + protocol=protocol, + ) + if stop_download: + response.flags.append("download_stopped") + if stop_download.fail: + stop_download.response = response + raise stop_download + return response + + +def get_maxsize_msg(size: int, limit: int, request: Request, *, expected: bool) -> str: + prefix = "Expected to receive" if expected else "Received" + return ( + f"{prefix} {size} bytes which is larger than download " + f"max size ({limit}) in request {request}." + ) + + +def get_warnsize_msg(size: int, limit: int, request: Request, *, expected: bool) -> str: + prefix = "Expected to receive" if expected else "Received" + return ( + f"{prefix} {size} bytes which is larger than download " + f"warn size ({limit}) in request {request}." + ) + + +def get_dataloss_msg(url: str) -> str: + return ( + f"Got data loss in {url}. If you want to process broken " + f"responses set the setting DOWNLOAD_FAIL_ON_DATALOSS = False" + f" -- This message won't be shown in further requests" + ) diff --git a/tests/test_downloader_handlers_http_base.py b/tests/test_downloader_handlers_http_base.py index ff76c88fe..bc7807b4d 100644 --- a/tests/test_downloader_handlers_http_base.py +++ b/tests/test_downloader_handlers_http_base.py @@ -4,6 +4,7 @@ from __future__ import annotations import gzip import json +import re import sys from abc import ABC, abstractmethod from contextlib import asynccontextmanager @@ -474,7 +475,9 @@ class TestHttp11Base(TestHttpBase): assert type(response) is TextResponse # pylint: disable=unidiomatic-typecheck @coroutine_test - async def test_download_with_maxsize(self, mockserver: MockServer) -> None: + async def test_download_with_maxsize( + self, caplog: pytest.LogCaptureFixture, mockserver: MockServer + ) -> None: request = Request(mockserver.url("/text", is_secure=self.is_secure)) # 10 is minimal size for this request and the limit is only counted on @@ -483,9 +486,12 @@ class TestHttp11Base(TestHttpBase): response = await download_handler.download_request(request) assert response.body == b"Works" + caplog.clear() + msg = "Expected to receive 5 bytes which is larger than download max size (4)" async with self.get_dh({"DOWNLOAD_MAXSIZE": 4}) as download_handler: - with pytest.raises(DownloadCancelledError): + with pytest.raises(DownloadCancelledError, match=re.escape(msg)): await download_handler.download_request(request) + assert msg in caplog.text @coroutine_test async def test_download_with_maxsize_very_large_file( @@ -495,8 +501,10 @@ class TestHttp11Base(TestHttpBase): async with self.get_dh({"DOWNLOAD_MAXSIZE": 1_500}) as download_handler: with pytest.raises(DownloadCancelledError): await download_handler.download_request(request) - - assert "larger than download max size" in caplog.text + assert ( + "Received 2048 bytes which is larger than download max size (1500)" + in caplog.text + ) @coroutine_test async def test_download_with_maxsize_per_req(self, mockserver: MockServer) -> None: @@ -524,6 +532,34 @@ class TestHttp11Base(TestHttpBase): response = await download_handler.download_request(request) assert response.body == b"Works" + @coroutine_test + async def test_download_with_warnsize( + self, caplog: pytest.LogCaptureFixture, mockserver: MockServer + ) -> None: + request = Request(mockserver.url("/text", is_secure=self.is_secure)) + async with self.get_dh({"DOWNLOAD_WARNSIZE": 4}) as download_handler: + response = await download_handler.download_request(request) + assert response.body == b"Works" + assert ( + "Expected to receive 5 bytes which is larger than download warn size (4)" + in caplog.text + ) + + @coroutine_test + async def test_download_with_warnsize_no_content_length( + self, caplog: pytest.LogCaptureFixture, mockserver: MockServer + ) -> None: + request = Request( + mockserver.url("/delay?n=0.1", is_secure=self.is_secure), + ) + async with self.get_dh({"DOWNLOAD_WARNSIZE": 10}) as download_handler: + response = await download_handler.download_request(request) + assert response.body == b"Response delayed for 0.100 seconds\n" + assert ( + "Received 35 bytes which is larger than download warn size (10)" + in caplog.text + ) + @coroutine_test async def test_download_chunked_content(self, mockserver: MockServer) -> None: request = Request(mockserver.url("/chunked", is_secure=self.is_secure)) diff --git a/tests/test_http2_client_protocol.py b/tests/test_http2_client_protocol.py index 944a67920..b5dab8861 100644 --- a/tests/test_http2_client_protocol.py +++ b/tests/test_http2_client_protocol.py @@ -292,7 +292,6 @@ class TestHttps2ClientProtocol: response = await make_request(client, request) assert response.status == expected_status assert response.body == expected_body - assert response.request == request content_length_header = response.headers.get("Content-Length") assert content_length_header is not None @@ -358,7 +357,6 @@ class TestHttps2ClientProtocol: response = await make_request(client, request) assert response.status == expected_status - assert response.request == request content_length_header = response.headers.get("Content-Length") assert content_length_header is not None @@ -465,23 +463,23 @@ class TestHttps2ClientProtocol: d.cancel() response = cast("Response", (yield d)) assert response.status == 499 - assert response.request == request @deferred_f_from_coro_f async def test_download_maxsize_exceeded( - self, server_port: int, client: H2ClientProtocol + self, + caplog: pytest.LogCaptureFixture, + server_port: int, + client: H2ClientProtocol, ) -> None: request = Request( url=self.get_url(server_port, "/get-data-html-large"), meta={"download_maxsize": 1000}, ) - with pytest.raises(DownloadCancelledError) as exc_info: + with pytest.raises( + DownloadCancelledError, + match=r"Expected to receive \d+ bytes which is larger than download max size \(1000\)", + ): await make_request(client, request) - error_pattern = re.compile( - rf"Cancelling download of {request.url}: received response " - rf"size \(\d*\) larger than download max size \(1000\)" - ) - assert len(re.findall(error_pattern, str(exc_info.value))) == 1 @inlineCallbacks def test_received_dataloss_response( @@ -508,7 +506,6 @@ class TestHttps2ClientProtocol: response = await make_request(client, request) assert response.status == 200 assert response.body == Data.NO_CONTENT_LENGTH - assert response.request == request assert "Content-Length" not in response.headers async def _check_log_warnsize( @@ -522,7 +519,6 @@ class TestHttps2ClientProtocol: with caplog.at_level("WARNING", "scrapy.core.http2.stream"): response = await make_request(client, request) assert response.status == 200 - assert response.request == request assert response.body == expected_body # Check the warning is raised only once for this request @@ -540,7 +536,7 @@ class TestHttps2ClientProtocol: meta={"download_warnsize": 1000}, ) warn_pattern = re.compile( - rf"Expected response size \(\d*\) larger than " + rf"Expected to receive \d+ bytes which is larger than " rf"download warn size \(1000\) in request {request}" ) @@ -560,8 +556,8 @@ class TestHttps2ClientProtocol: meta={"download_warnsize": 10}, ) warn_pattern = re.compile( - rf"Received more \(\d*\) bytes than download " - rf"warn size \(10\) in request {request}" + rf"Received \d+ bytes which is larger than " + rf"download warn size \(10\) in request {request}" ) await self._check_log_warnsize( @@ -671,7 +667,6 @@ class TestHttps2ClientProtocol: ) -> None: request = Request(self.get_url(server_port, "/status?n=200")) response = await make_request(client, request) - assert response.request == request assert isinstance(response.certificate, Certificate) assert response.certificate.original is not None assert response.certificate.getIssuer() == client_certificate.getIssuer() @@ -749,7 +744,6 @@ class TestHttps2ClientProtocol: ) response = await make_request(client, request) assert response.status == 200 - assert response.request == request response_headers = json.loads(str(response.body, "utf-8")) assert isinstance(response_headers, dict) From 8974580e438d18a105b8a0475e90bce2f1eb4dca Mon Sep 17 00:00:00 2001 From: Adrian Chaves Date: Tue, 3 Feb 2026 20:38:11 +0100 Subject: [PATCH 045/248] Reword the release note entry to consider the 301 redirect fix a security bug fix --- docs/news.rst | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/docs/news.rst b/docs/news.rst index db6749ae7..a7d8e50f3 100644 --- a/docs/news.rst +++ b/docs/news.rst @@ -8,6 +8,18 @@ Release notes Scrapy 2.14.2 (unreleased) -------------------------- +Security bug fixes +~~~~~~~~~~~~~~~~~~ + +- In line with the `standard + `__, 301 redirects of + ``POST`` requests turn into ``GET`` requests. + + Turning into a ``GET`` request implies not only a method change, but also + omitting the body and ``Content-*`` headers in the redirect request. On + cross-origin redirects (e.g. cross-domain), this is effectively a security + bug fix for scenarios where the body contains secrets. + Deprecations ~~~~~~~~~~~~ @@ -21,10 +33,8 @@ Deprecations Bug fixes ~~~~~~~~~ -- Aligned redirect method conversions to ``GET`` with the `standard - `__: - - - 301 redirects of ``POST`` requests turn into ``GET`` requests. +- Made additional redirect scenarios convert to ``GET`` in line with the + `standard `__: - Only ``POST`` 302 redirects turn into ``GET`` requests, other methods are preserved. @@ -34,10 +44,6 @@ Bug fixes - ``GET`` 303 redirects do not get their body or standard ``Content-*`` headers removed. - .. note:: Turning into a ``GET`` request implies not only a method change, - but also omitting the body and ``Content-*`` headers in the redirect - request. - - Redirects where the original request body is dropped now also get their ``Content-Encoding``, ``Content-Language`` and ``Content-Location`` headers removed, in addition to ``Content-Type`` and ``Content-Length`` that were From 294ee051ccf343d3c9cca803e952617427b746e5 Mon Sep 17 00:00:00 2001 From: "Albert Eduardovich N." Date: Thu, 5 Feb 2026 21:15:53 +0300 Subject: [PATCH 046/248] Reducing memory footprint of `Request` object: __slots__ and lazy evaluation (#7036) * reducing memory foorprint of `Request` object * restore flow * Test setters --------- Co-authored-by: Adrian Chaves --- scrapy/http/request/__init__.py | 74 +++++++++++++++++++++---- scrapy/http/request/form.py | 2 + scrapy/http/request/json_request.py | 2 + scrapy/http/request/rpc.py | 2 + tests/test_http_request.py | 83 +++++++++++++++++++++++++++++ 5 files changed, 154 insertions(+), 9 deletions(-) diff --git a/scrapy/http/request/__init__.py b/scrapy/http/request/__init__.py index f0d20843f..d306f1f6e 100644 --- a/scrapy/http/request/__init__.py +++ b/scrapy/http/request/__init__.py @@ -86,20 +86,17 @@ class Request(object_ref): executed by the Downloader, thus generating a :class:`~scrapy.http.Response`. """ + __attrs_and_slots = ("callback", "dont_filter", "errback", "method", "priority") attributes: tuple[str, ...] = ( "url", - "callback", - "method", "headers", "body", "cookies", "meta", "encoding", - "priority", - "dont_filter", - "errback", "flags", "cb_kwargs", + *__attrs_and_slots, ) """A tuple of :class:`str` objects containing the name of all public attributes of the class that are also keyword parameters of the @@ -109,6 +106,20 @@ class Request(object_ref): :func:`~scrapy.utils.request.request_from_dict`. """ + __slots__ = ( + "__weakref__", + "_body", + "_cb_kwargs", + "_cookies", + "_encoding", + "_flags", + "_headers", + "_meta", + "_url", + *__attrs_and_slots, + ) + del __attrs_and_slots + def __init__( self, url: str, @@ -189,8 +200,10 @@ class Request(object_ref): #: .. seealso:: :ref:`topics-request-response-ref-errbacks` self.errback: Callable[[Failure], Any] | None = errback - self.cookies: CookiesT = cookies or {} - self.headers: Headers = Headers(headers or {}, encoding=encoding) + self._cookies: CookiesT | None = cookies if cookies else None + self._headers: Headers | None = ( + Headers(headers, encoding=encoding) if headers else None + ) #: Whether this request may be filtered out by :ref:`components #: ` that support filtering out requests (``False``, @@ -207,7 +220,7 @@ class Request(object_ref): self._meta: dict[str, Any] | None = dict(meta) if meta else None self._cb_kwargs: dict[str, Any] | None = dict(cb_kwargs) if cb_kwargs else None - self.flags: list[str] = [] if flags is None else list(flags) + self._flags: list[str] | None = list(flags) if flags else None @property def cb_kwargs(self) -> dict[str, Any]: @@ -215,12 +228,20 @@ class Request(object_ref): self._cb_kwargs = {} return self._cb_kwargs + @cb_kwargs.setter + def cb_kwargs(self, value: dict[str, Any] | None) -> None: + self._cb_kwargs = value if value else None + @property def meta(self) -> dict[str, Any]: if self._meta is None: self._meta = {} return self._meta + @meta.setter + def meta(self, value: dict[str, Any] | None) -> None: + self._meta = value if value else None + @property def url(self) -> str: return self._url @@ -243,12 +264,47 @@ class Request(object_ref): return self._body def _set_body(self, body: str | bytes | None) -> None: - self._body = b"" if body is None else to_bytes(body, self.encoding) + self._body = b"" if not body else to_bytes(body, self.encoding) @property def encoding(self) -> str: return self._encoding + @property + def flags(self) -> list[str]: + if self._flags is None: + self._flags = [] + return self._flags + + @flags.setter + def flags(self, value: list[str] | None) -> None: + self._flags = value if value else None + + @property + def cookies(self) -> CookiesT: + if self._cookies is None: + self._cookies = {} + return self._cookies + + @cookies.setter + def cookies(self, value: CookiesT | None) -> None: + self._cookies = value if value else None + + @property + def headers(self) -> Headers: + if self._headers is None: + self._headers = Headers(encoding=self.encoding) + return self._headers + + @headers.setter + def headers( + self, value: Mapping[AnyStr, Any] | Iterable[tuple[AnyStr, Any]] | None + ) -> None: + if isinstance(value, Headers): + self._headers = value + else: + self._headers = Headers(value, encoding=self.encoding) if value else None + def __repr__(self) -> str: return f"<{self.method} {self.url}>" diff --git a/scrapy/http/request/form.py b/scrapy/http/request/form.py index 7b2e480c4..7cb8bfd5d 100644 --- a/scrapy/http/request/form.py +++ b/scrapy/http/request/form.py @@ -37,6 +37,8 @@ FormdataType: TypeAlias = dict[str, FormdataVType] | list[FormdataKVType] | None class FormRequest(Request): + __slots__ = () + valid_form_methods = ["GET", "POST"] def __init__( diff --git a/scrapy/http/request/json_request.py b/scrapy/http/request/json_request.py index e26cbe05b..1776bdca8 100644 --- a/scrapy/http/request/json_request.py +++ b/scrapy/http/request/json_request.py @@ -20,6 +20,8 @@ if TYPE_CHECKING: class JsonRequest(Request): + __slots__ = ("_dumps_kwargs",) + attributes: tuple[str, ...] = (*Request.attributes, "dumps_kwargs") def __init__( diff --git a/scrapy/http/request/rpc.py b/scrapy/http/request/rpc.py index 01fe740a8..c68c8aabd 100644 --- a/scrapy/http/request/rpc.py +++ b/scrapy/http/request/rpc.py @@ -21,6 +21,8 @@ DUMPS_ARGS = get_func_args(xmlrpclib.dumps) class XmlRpcRequest(Request): + __slots__ = () + def __init__(self, *args: Any, encoding: str | None = None, **kwargs: Any): if "body" not in kwargs and "params" in kwargs: kw = {k: kwargs.pop(k) for k in DUMPS_ARGS if k in kwargs} diff --git a/tests/test_http_request.py b/tests/test_http_request.py index 22f44bfd2..93cbcd1eb 100644 --- a/tests/test_http_request.py +++ b/tests/test_http_request.py @@ -329,6 +329,89 @@ class TestRequest: errback="a_function", ) + def test_setters(self): + request = self.request_class("http://example.com") + + request.cb_kwargs = {"a": 1} + assert request.cb_kwargs == {"a": 1} + + request.meta = {"k": "v"} + assert request.meta == {"k": "v"} + + request.flags = ["f1"] + assert request.flags == ["f1"] + + request.cookies = {"sid": "1"} + assert request.cookies == {"sid": "1"} + + headers = Headers({b"X-Test": b"1"}) + request.headers = headers + assert request._headers is headers + request.headers = {b"A": b"b"} + assert isinstance(request.headers, Headers) + assert request._headers[b"A"] == b"b" + + def test_setter_mutable_lazy_loading(self): + """Mutable attributes are set internally to None only until they are + read, then they always return the same falsy instance of the + corresponding mutable structure. + + Setting them to None causes the next read to return a different object. + """ + + request = self.request_class("http://example.com") + + assert request._cb_kwargs is None + assert request.cb_kwargs == {} + assert request.cb_kwargs is request.cb_kwargs + assert request._cb_kwargs == {} + original_cb_kwargs = request.cb_kwargs + request.cb_kwargs = None + assert request.cb_kwargs == {} + assert request.cb_kwargs is not original_cb_kwargs + + assert request._meta is None + assert request.meta == {} + assert request.meta is request.meta + assert request._meta == {} + original_meta = request.meta + request.meta = None + assert request.meta == {} + assert request.meta is not original_meta + + assert request._flags is None + assert request.flags == [] + assert request.flags is request.flags + assert request._flags == [] + original_flags = request.flags + request.flags = None + assert request.flags == [] + assert request.flags is not original_flags + + assert request._cookies is None + assert request.cookies == {} + assert request.cookies is request.cookies + assert request._cookies == {} + original_cookies = request.cookies + request.cookies = None + assert request.cookies == {} + assert request.cookies is not original_cookies + + if self.default_headers: + assert request._headers == self.default_headers + assert request._headers is not self.default_headers + assert request.headers == self.default_headers + else: + assert request._headers is None + assert request.headers == {} + assert request.headers is request.headers + assert isinstance(request.headers, Headers) + assert isinstance(request._headers, Headers) + original_headers = request.headers + request.headers = None + assert request.headers == {} + assert request.headers is not original_headers + def test_no_callback(self): with pytest.raises(RuntimeError): NO_CALLBACK() From 6a42bc645076b40450ae3c055f7c39d1c325b1fc Mon Sep 17 00:00:00 2001 From: Michael <38651219+staehlmich@users.noreply.github.com> Date: Thu, 5 Feb 2026 19:18:01 +0100 Subject: [PATCH 047/248] Item docs: mention Pydantic support (#6966) * Add Pydantic support documentation * Fix Sphinx references in Pydantic section for non-Sphinx docs * Minor reformatting --------- Co-authored-by: Adrian Chaves --- docs/topics/items.rst | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/docs/topics/items.rst b/docs/topics/items.rst index f05746934..7a1c1f907 100644 --- a/docs/topics/items.rst +++ b/docs/topics/items.rst @@ -136,6 +136,45 @@ Example: another_field = attr.ib() +.. _pydantic-items: + +Pydantic models +--------------- + +`Pydantic `_ models allow the defining of item +classes with field names, so that :ref:`item exporters ` can +export all fields by default even if the first scraped object does not have +values for all of them. + +Additionally, ``pydantic`` items also allow you to: + +* define the type and default value of each defined field with run-time type + validation. + +* define custom field metadata through `pydantic.Field + `_, which can be used to + :ref:`customize serialization `. + +* benefit from automatic data validation and conversion based on type + annotations. + +In order to use this type, the `pydantic package `_ +needs to be installed. + +Example: + +.. code-block:: python + + from pydantic import BaseModel, Field + + + class CustomItem(BaseModel): + one_field: str = Field(default="", description="First field") + another_field: int = Field(default=0, description="Second field") + +.. note:: Unlike other item types, Pydantic models enforce field types at + run time and will raise validation errors for invalid data types. + Working with Item objects ========================= From 16929c0991e143b6c5d3c8f1a192f8c25b9cd7d0 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Fri, 6 Feb 2026 13:15:07 +0500 Subject: [PATCH 048/248] Exclude deprecated code from coverage. (#7241) --- pyproject.toml | 2 +- scrapy/core/downloader/handlers/http.py | 1 + scrapy/core/engine.py | 16 +++++++++++----- scrapy/core/scheduler.py | 4 ++-- scrapy/core/scraper.py | 14 +++++++++----- scrapy/core/spidermw.py | 2 +- scrapy/utils/defer.py | 12 ++++++------ scrapy/utils/iterators.py | 2 +- scrapy/utils/test.py | 10 +++++----- scrapy/utils/testproc.py | 1 + scrapy/utils/testsite.py | 1 + scrapy/utils/versions.py | 2 +- 12 files changed, 40 insertions(+), 27 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e2484b02a..22e3f7b22 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -147,8 +147,8 @@ source = [ [tool.coverage.report] exclude_also = [ - "if TYPE_CHECKING:", "@(abc\\.)?abstractmethod", + '\A(?s:.*# pragma: no file cover.*)\Z', ] [tool.pylint.MASTER] diff --git a/scrapy/core/downloader/handlers/http.py b/scrapy/core/downloader/handlers/http.py index bc343e37f..a7b592d00 100644 --- a/scrapy/core/downloader/handlers/http.py +++ b/scrapy/core/downloader/handlers/http.py @@ -1,3 +1,4 @@ +# pragma: no file cover import warnings from scrapy.core.downloader.handlers.http10 import HTTP10DownloadHandler diff --git a/scrapy/core/engine.py b/scrapy/core/engine.py index b29c5e80e..93fc64cdc 100644 --- a/scrapy/core/engine.py +++ b/scrapy/core/engine.py @@ -159,7 +159,9 @@ class ExecutionEngine: ) return scheduler_cls - def start(self, _start_request_processing=True) -> Deferred[None]: + def start( + self, _start_request_processing=True + ) -> Deferred[None]: # pragma: no cover warnings.warn( "ExecutionEngine.start() is deprecated, use start_async() instead", ScrapyDeprecationWarning, @@ -197,7 +199,7 @@ class ExecutionEngine: self._start_request_processing_awaitable = Deferred.fromCoroutine(coro) await maybe_deferred_to_future(self._closewait) - def stop(self) -> Deferred[None]: + def stop(self) -> Deferred[None]: # pragma: no cover warnings.warn( "ExecutionEngine.stop() is deprecated, use stop_async() instead", ScrapyDeprecationWarning, @@ -233,7 +235,7 @@ class ExecutionEngine: if self._closewait: self._closewait.callback(None) - def close(self) -> Deferred[None]: + def close(self) -> Deferred[None]: # pragma: no cover warnings.warn( "ExecutionEngine.close() is deprecated, use close_async() instead", ScrapyDeprecationWarning, @@ -510,7 +512,9 @@ class ExecutionEngine: finally: self._slot.nextcall.schedule() - def open_spider(self, spider: Spider, close_if_idle: bool = True) -> Deferred[None]: + def open_spider( + self, spider: Spider, close_if_idle: bool = True + ) -> Deferred[None]: # pragma: no cover warnings.warn( "ExecutionEngine.open_spider() is deprecated, use open_spider_async() instead", ScrapyDeprecationWarning, @@ -573,7 +577,9 @@ class ExecutionEngine: assert isinstance(ex, CloseSpider) # typing _schedule_coro(self.close_spider_async(reason=ex.reason)) - def close_spider(self, spider: Spider, reason: str = "cancelled") -> Deferred[None]: + def close_spider( + self, spider: Spider, reason: str = "cancelled" + ) -> Deferred[None]: # pragma: no cover warnings.warn( "ExecutionEngine.close_spider() is deprecated, use close_spider_async() instead", ScrapyDeprecationWarning, diff --git a/scrapy/core/scheduler.py b/scrapy/core/scheduler.py index bfca9cad4..491e7a8a6 100644 --- a/scrapy/core/scheduler.py +++ b/scrapy/core/scheduler.py @@ -426,7 +426,7 @@ class Scheduler(BaseScheduler): key="", start_queue_cls=self._smqclass, ) - except TypeError: + except TypeError: # pragma: no cover warn( f"The __init__ method of {global_object_name(self.pqclass)} " f"does not support a `start_queue_cls` keyword-only " @@ -455,7 +455,7 @@ class Scheduler(BaseScheduler): startprios=state, start_queue_cls=self._sdqclass, ) - except TypeError: + except TypeError: # pragma: no cover warn( f"The __init__ method of {global_object_name(self.pqclass)} " f"does not support a `start_queue_cls` keyword-only " diff --git a/scrapy/core/scraper.py b/scrapy/core/scraper.py index df6aee715..695f6ef3e 100644 --- a/scrapy/core/scraper.py +++ b/scrapy/core/scraper.py @@ -151,7 +151,9 @@ class Scraper: else: self._itemproc_has_async[method] = True - def open_spider(self, spider: Spider | None = None) -> Deferred[None]: + def open_spider( + self, spider: Spider | None = None + ) -> Deferred[None]: # pragma: no cover warnings.warn( "Scraper.open_spider() is deprecated, use open_spider_async() instead", ScrapyDeprecationWarning, @@ -176,7 +178,9 @@ class Scraper: self.itemproc.open_spider(self.crawler.spider) ) - def close_spider(self, spider: Spider | None = None) -> Deferred[None]: + def close_spider( + self, spider: Spider | None = None + ) -> Deferred[None]: # pragma: no cover warnings.warn( "Scraper.close_spider() is deprecated, use close_spider_async() instead", ScrapyDeprecationWarning, @@ -294,7 +298,7 @@ class Scraper: def call_spider( self, result: Response | Failure, request: Request, spider: Spider | None = None - ) -> Deferred[Iterable[Any] | AsyncIterator[Any]]: + ) -> Deferred[Iterable[Any] | AsyncIterator[Any]]: # pragma: no cover warnings.warn( "Scraper.call_spider() is deprecated, use call_spider_async() instead", ScrapyDeprecationWarning, @@ -388,7 +392,7 @@ class Scraper: request: Request, response: Response | Failure, spider: Spider | None = None, - ) -> Deferred[None]: + ) -> Deferred[None]: # pragma: no cover """Pass items/requests produced by a callback to ``_process_spidermw_output()`` in parallel.""" warnings.warn( "Scraper.handle_spider_output() is deprecated, use handle_spider_output_async() instead", @@ -467,7 +471,7 @@ class Scraper: def start_itemproc( self, item: Any, *, response: Response | Failure | None - ) -> Deferred[None]: + ) -> Deferred[None]: # pragma: no cover """Send *item* to the item pipelines for processing. *response* is the source of the item data. If the item does not come diff --git a/scrapy/core/spidermw.py b/scrapy/core/spidermw.py index 61fecfc60..6eaf84642 100644 --- a/scrapy/core/spidermw.py +++ b/scrapy/core/spidermw.py @@ -383,7 +383,7 @@ class SpiderMiddlewareManager(MiddlewareManager): response: Response, request: Request, spider: Spider, - ) -> Deferred[MutableChain[_T] | MutableAsyncChain[_T]]: + ) -> Deferred[MutableChain[_T] | MutableAsyncChain[_T]]: # pragma: no cover warn( "SpiderMiddlewareManager.scrape_response() is deprecated, use scrape_response_async() instead", ScrapyDeprecationWarning, diff --git a/scrapy/utils/defer.py b/scrapy/utils/defer.py index 218dc8d7a..5bd4506d6 100644 --- a/scrapy/utils/defer.py +++ b/scrapy/utils/defer.py @@ -43,7 +43,7 @@ _P = ParamSpec("_P") _DEFER_DELAY = 0.1 -def defer_fail(_failure: Failure) -> Deferred[Any]: +def defer_fail(_failure: Failure) -> Deferred[Any]: # pragma: no cover """Same as twisted.internet.defer.fail but delay calling errback until next reactor loop @@ -64,7 +64,7 @@ def defer_fail(_failure: Failure) -> Deferred[Any]: return d -def defer_succeed(result: _T) -> Deferred[_T]: +def defer_succeed(result: _T) -> Deferred[_T]: # pragma: no cover """Same as twisted.internet.defer.succeed but delay calling callback until next reactor loop @@ -99,7 +99,7 @@ async def _defer_sleep_async() -> None: await d -def defer_result(result: Any) -> Deferred[Any]: +def defer_result(result: Any) -> Deferred[Any]: # pragma: no cover warnings.warn( "scrapy.utils.defer.defer_result() is deprecated, use" " twisted.internet.defer.success() and twisted.internet.defer.fail()," @@ -137,7 +137,7 @@ def mustbe_deferred( f: Callable[_P, Deferred[_T] | _T], *args: _P.args, **kw: _P.kwargs, -) -> Deferred[_T]: +) -> Deferred[_T]: # pragma: no cover """Same as twisted.internet.defer.maybeDeferred, but delay calling callback/errback to next reactor loop """ @@ -299,7 +299,7 @@ def process_chain( input: _T, # noqa: A002 *a: _P.args, **kw: _P.kwargs, -) -> Deferred[_T]: +) -> Deferred[_T]: # pragma: no cover """Return a Deferred built by chaining the given callbacks""" warnings.warn( "process_chain() is deprecated.", @@ -452,7 +452,7 @@ def _maybeDeferred_coro( return result if asyncio.isfuture(result) or inspect.isawaitable(result): return deferred_from_coro(result) - if isinstance(result, failure.Failure): + if isinstance(result, failure.Failure): # pragma: no cover if warn: warnings.warn( f"{global_object_name(f)} returned a Failure, this is deprecated." diff --git a/scrapy/utils/iterators.py b/scrapy/utils/iterators.py index ad9640e47..9bb64c021 100644 --- a/scrapy/utils/iterators.py +++ b/scrapy/utils/iterators.py @@ -177,7 +177,7 @@ def csviter( quotechar is the character used to enclosure fields on the given obj. """ - if encoding is not None: + if encoding is not None: # pragma: no cover warn( "The encoding argument of csviter() is ignored and will be removed" " in a future Scrapy version.", diff --git a/scrapy/utils/test.py b/scrapy/utils/test.py index 7d81868a9..e60ae18db 100644 --- a/scrapy/utils/test.py +++ b/scrapy/utils/test.py @@ -37,7 +37,7 @@ if TYPE_CHECKING: _T = TypeVar("_T") -def assert_gcs_environ() -> None: +def assert_gcs_environ() -> None: # pragma: no cover warnings.warn( "The assert_gcs_environ() function is deprecated and will be removed in a future version of Scrapy." " Check GCS_PROJECT_ID directly.", @@ -48,7 +48,7 @@ def assert_gcs_environ() -> None: raise SkipTest("GCS_PROJECT_ID not found") -def skip_if_no_boto() -> None: +def skip_if_no_boto() -> None: # pragma: no cover warnings.warn( "The skip_if_no_boto() function is deprecated and will be removed in a future version of Scrapy." " Check scrapy.utils.boto.is_botocore_available() directly.", @@ -61,7 +61,7 @@ def skip_if_no_boto() -> None: def get_gcs_content_and_delete( bucket: Any, path: str -) -> tuple[bytes, list[dict[str, str]], Any]: +) -> tuple[bytes, list[dict[str, str]], Any]: # pragma: no cover from google.cloud import storage # noqa: PLC0415 warnings.warn( @@ -85,7 +85,7 @@ def get_ftp_content_and_delete( username: str, password: str, use_active_mode: bool = False, -) -> bytes: +) -> bytes: # pragma: no cover warnings.warn( "The get_ftp_content_and_delete() function is deprecated and will be removed in a future version of Scrapy.", category=ScrapyDeprecationWarning, @@ -185,7 +185,7 @@ def get_from_asyncio_queue(value: _T) -> Awaitable[_T]: return getter -def mock_google_cloud_storage() -> tuple[Any, Any, Any]: +def mock_google_cloud_storage() -> tuple[Any, Any, Any]: # pragma: no cover """Creates autospec mocks for google-cloud-storage Client, Bucket and Blob classes and set their proper return values. """ diff --git a/scrapy/utils/testproc.py b/scrapy/utils/testproc.py index ce26b7f92..2f1acffd7 100644 --- a/scrapy/utils/testproc.py +++ b/scrapy/utils/testproc.py @@ -1,3 +1,4 @@ +# pragma: no file cover from __future__ import annotations import os diff --git a/scrapy/utils/testsite.py b/scrapy/utils/testsite.py index f12b301fd..e57eb802b 100644 --- a/scrapy/utils/testsite.py +++ b/scrapy/utils/testsite.py @@ -1,3 +1,4 @@ +# pragma: no file cover import warnings from urllib.parse import urljoin diff --git a/scrapy/utils/versions.py b/scrapy/utils/versions.py index 052321ae3..b15063900 100644 --- a/scrapy/utils/versions.py +++ b/scrapy/utils/versions.py @@ -34,7 +34,7 @@ def get_versions( return [(item, _version(item)) for item in software] -def scrapy_components_versions() -> list[tuple[str, str]]: +def scrapy_components_versions() -> list[tuple[str, str]]: # pragma: no cover warn( ( "scrapy.utils.versions.scrapy_components_versions() is deprecated, " From 66fe5de1395d55434e8690a1e1a0a68b8935ccb5 Mon Sep 17 00:00:00 2001 From: Martin Schimandl Date: Fri, 6 Feb 2026 19:33:02 +0100 Subject: [PATCH 049/248] Add Jobdir documentation (#5260) * Add Jobdir documentation * Fix link format * remove doubtful comments * some more edits * Keep the information in jobs.rst an example, and provide details in the reference docs of the corresponding components * Minor edits --------- Co-authored-by: Adrian Chaves --- docs/topics/jobs.rst | 76 +++++++++++++++++++++++++++++++++++---- docs/topics/scheduler.rst | 7 ++++ docs/topics/settings.rst | 18 +++++----- scrapy/core/scheduler.py | 44 +++++++++++++++++++---- scrapy/dupefilters.py | 12 +++++++ scrapy/pqueues.py | 51 +++++++++++++++++++++++--- 6 files changed, 183 insertions(+), 25 deletions(-) diff --git a/docs/topics/jobs.rst b/docs/topics/jobs.rst index 50bcaa6d6..2a976e91d 100644 --- a/docs/topics/jobs.rst +++ b/docs/topics/jobs.rst @@ -17,15 +17,21 @@ facilities: * an extension that keeps some spider state (key/value pairs) persistent between batches +.. _job-dir: + Job directory ============= -To enable persistence support you just need to define a *job directory* through -the ``JOBDIR`` setting. This directory will be for storing all required data to -keep the state of a single job (i.e. a spider run). It's important to note that -this directory must not be shared by different spiders, or even different -jobs/runs of the same spider, as it's meant to be used for storing the state of -a *single* job. +To enable persistence support, define a *job directory* through the +:setting:`JOBDIR` setting. + +The job directory will store all required data to keep the state of a *single* +job (i.e. a spider run), so that if stopped cleanly, it can be resumed later. + +.. warning:: This directory must *not* be shared by different spiders, or even + different jobs of the same spider. + +See also :ref:`job-dir-contents`. How to use it ============= @@ -65,6 +71,14 @@ Persistence gotchas There are a few things to keep in mind if you want to be able to use the Scrapy persistence support: +Pause limitations +----------------- + +Job pausing and resuming is only supported when the spider is paused by +stopping it cleanly. Forced, sudden or otherwise unclean shutdown can lead to +data corruption in the job directory, which may prevent the spider from +resuming correctly. + Cookies expiration ------------------ @@ -72,7 +86,6 @@ Cookies may expire. So, if you don't resume your spider quickly the requests scheduled may no longer work. This won't be an issue if your spider doesn't rely on cookies. - .. _request-serialization: Request serialization @@ -86,3 +99,52 @@ running :class:`~scrapy.Spider` class. If you wish to log the requests that couldn't be serialized, you can set the :setting:`SCHEDULER_DEBUG` setting to ``True`` in the project's settings page. It is ``False`` by default. + +.. _job-dir-contents: + +Job directory contents +====================== + +The contents of a job directory depend on the components used during the job. +Components known to write in the job directory include the :ref:`scheduler +` and the :class:`~scrapy.extensions.spiderstate.SpiderState` +extension. See the reference documentation of the corresponding components for +details. + +For example, with default settings, the job directory may look like this: + +.. code-block:: none + + ├── requests.queue + | ├── active.json + | └── {hostname}-{hash} + | └── {priority}{s?} + | ├── q{00000} + | └── info.json + ├── requests.seen + └── spider.state + +Where: + +- :class:`~scrapy.core.scheduler.Scheduler` creates the ``requests.queue/`` + directory and the ``active.json`` file, the latter containing the state + data returned by :meth:`DownloaderAwarePriorityQueue.close() + ` the last time the job + was paused. + +- :class:`~scrapy.pqueues.DownloaderAwarePriorityQueue` creates the + ``{hostname}-{hash}`` directories. + +- :class:`~scrapy.pqueues.ScrapyPriorityQueue` creates the ``{priority}{s?}`` + directories. + +- :class:`scrapy.squeues.PickleLifoDiskQueue`, a subclass of + :class:`queuelib.LifoDiskQueue` that uses :mod:`pickle` to serialize + :class:`dict` representations of :class:`scrapy.Request` objects, creates + the ``info.json`` and ``q{00000}`` files. + +- :class:`~scrapy.dupefilters.RFPDupeFilter` creates the ``requests.seen`` + file. + +- :class:`~scrapy.extensions.spiderstate.SpiderState` creates the + ``spider.state`` file. diff --git a/docs/topics/scheduler.rst b/docs/topics/scheduler.rst index b6e54ebd7..b79d6de1f 100644 --- a/docs/topics/scheduler.rst +++ b/docs/topics/scheduler.rst @@ -32,3 +32,10 @@ Default scheduler .. autoclass:: Scheduler() :members: :special-members: __init__, __len__ + + +Priority queues +=============== + +.. autoclass:: scrapy.pqueues.DownloaderAwarePriorityQueue +.. autoclass:: scrapy.pqueues.ScrapyPriorityQueue diff --git a/docs/topics/settings.rst b/docs/topics/settings.rst index 5357fa72b..5b4a9b5f9 100644 --- a/docs/topics/settings.rst +++ b/docs/topics/settings.rst @@ -1702,10 +1702,10 @@ the user agent to use in the robots.txt file. SCHEDULER --------- -Default: ``'scrapy.core.scheduler.Scheduler'`` +Default: :class:`~scrapy.core.scheduler.Scheduler` -The scheduler class to be used for crawling. -See the :ref:`topics-scheduler` topic for details. +The scheduler class to be used for crawling. See :ref:`topics-scheduler` for +details. .. setting:: SCHEDULER_DEBUG @@ -1755,12 +1755,14 @@ Type of in-memory queue used by the scheduler. Other available type is: SCHEDULER_PRIORITY_QUEUE ------------------------ -Default: ``'scrapy.pqueues.DownloaderAwarePriorityQueue'`` +Default: :class:`~scrapy.pqueues.DownloaderAwarePriorityQueue` -Type of priority queue used by the scheduler. Another available type is -``scrapy.pqueues.ScrapyPriorityQueue``. -``scrapy.pqueues.DownloaderAwarePriorityQueue`` works better than -``scrapy.pqueues.ScrapyPriorityQueue`` when you crawl many different +Type of priority queue used by the scheduler. + +Another available type is :class:`~scrapy.pqueues.ScrapyPriorityQueue`. + +:class:`~scrapy.pqueues.DownloaderAwarePriorityQueue` works better than +:class:`~scrapy.pqueues.ScrapyPriorityQueue` when you crawl many different domains in parallel. diff --git a/scrapy/core/scheduler.py b/scrapy/core/scheduler.py index 491e7a8a6..5a0aa2197 100644 --- a/scrapy/core/scheduler.py +++ b/scrapy/core/scheduler.py @@ -4,7 +4,7 @@ import json import logging from abc import abstractmethod from pathlib import Path -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any from warnings import warn # working around https://github.com/sphinx-doc/sphinx/issues/10400 @@ -128,7 +128,7 @@ class BaseScheduler(metaclass=BaseSchedulerMeta): class Scheduler(BaseScheduler): - """Default scheduler. + r"""Default scheduler. Requests are stored into priority queues (:setting:`SCHEDULER_PRIORITY_QUEUE`) that sort requests by @@ -190,7 +190,8 @@ class Scheduler(BaseScheduler): following :ref:`settings `: | :setting:`DEPTH_PRIORITY` = ``1`` - | :setting:`SCHEDULER_DISK_QUEUE` = ``"scrapy.squeues.PickleFifoDiskQueue"`` + | :setting:`SCHEDULER_DISK_QUEUE` = + ``"scrapy.squeues.PickleFifoDiskQueue"`` | :setting:`SCHEDULER_MEMORY_QUEUE` = ``"scrapy.squeues.FifoMemoryQueue"`` .. _BFO order: https://en.wikipedia.org/wiki/Breadth-first_search @@ -219,6 +220,37 @@ class Scheduler(BaseScheduler): order. Lowering those settings to ``1`` enforces the desired order except for the very first request, but it significantly slows down the crawl as a whole. + + Job directory contents + ====================== + + .. warning:: The files that this class generates in the :ref:`job directory + ` are an implementation detail, and may change without a + warning in a future version of Scrapy. Do not rely on the following + information for anything other than debugging purposes. + + When using :setting:`JOBDIR`, this scheduler class: + + - Creates a directory named ``requests.queue`` inside the :ref:`job + directory `, meant to keep track of all requests stored in + the scheduler (i.e. not downloaded yet). + + - Generates inside that directory an ``active.json`` file with a JSON + representation of the state (``startprios``) of + :setting:`SCHEDULER_PRIORITY_QUEUE`. + + The file is generated whenever the job stops (cleanly) and is loaded + when resuming the job. + + - Instantiates the configured :setting:`SCHEDULER_PRIORITY_QUEUE` with + ``requests.queue/`` as persistence directory (*key*) and + :setting:`SCHEDULER_DISK_QUEUE` as *downstream_queue_cls*. The priority + queue may create additional files and directories inside that + directory, directly or though instances of + :setting:`SCHEDULER_DISK_QUEUE`. + + This scheduler class also uses the configured :setting:`DUPEFILTER_CLASS`, + which may also write data inside the job directory. """ @classmethod @@ -486,13 +518,13 @@ class Scheduler(BaseScheduler): return str(dqdir) return None - def _read_dqs_state(self, dqdir: str) -> list[int]: + def _read_dqs_state(self, dqdir: str) -> Any: path = Path(dqdir, "active.json") if not path.exists(): return [] with path.open(encoding="utf-8") as f: - return cast("list[int]", json.load(f)) + return json.load(f) - def _write_dqs_state(self, dqdir: str, state: list[int]) -> None: + def _write_dqs_state(self, dqdir: str, state: Any) -> None: with Path(dqdir, "active.json").open("w", encoding="utf-8") as f: json.dump(state, f) diff --git a/scrapy/dupefilters.py b/scrapy/dupefilters.py index f0a6988c8..36fb0f97d 100644 --- a/scrapy/dupefilters.py +++ b/scrapy/dupefilters.py @@ -55,6 +55,18 @@ class RFPDupeFilter(BaseDupeFilter): filters out requests with the canonical (:func:`w3lib.url.canonicalize_url`) :attr:`~scrapy.http.Request.url`, :attr:`~scrapy.http.Request.method` and :attr:`~scrapy.http.Request.body`. + + Job directory contents + ====================== + + .. warning:: The files that this class generates in the :ref:`job directory + ` are an implementation detail, and may change without a + warning in a future version of Scrapy. Do not rely on the following + information for anything other than debugging purposes. + + When using :setting:`JOBDIR`, seen fingerprints are tracked in a file named + ``requests.seen`` in the :ref:`job directory `, which contains 1 + request fingerprint per line. """ def __init__( diff --git a/scrapy/pqueues.py b/scrapy/pqueues.py index 42c53a527..ed57091ba 100644 --- a/scrapy/pqueues.py +++ b/scrapy/pqueues.py @@ -51,16 +51,17 @@ class QueueProtocol(Protocol): class ScrapyPriorityQueue: """A priority queue implemented using multiple internal queues (typically, - FIFO queues). It uses one internal queue for each priority value. The internal - queue must implement the following methods: + FIFO queues). It uses one internal queue for each priority value. The + internal queue must implement the following methods: * push(obj) * pop() * close() * __len__() - Optionally, the queue could provide a ``peek`` method, that should return the - next object to be returned by ``pop``, but without removing it from the queue. + Optionally, the queue could provide a ``peek`` method, that should return + the next object to be returned by ``pop``, but without removing it from the + queue. ``__init__`` method of ScrapyPriorityQueue receives a downstream_queue_cls argument, which is a class used to instantiate a new (internal) queue when @@ -72,6 +73,28 @@ class ScrapyPriorityQueue: startprios is a sequence of priorities to start with. If the queue was previously closed leaving some priority buckets non-empty, those priorities should be passed in startprios. + + Disk persistence + ================ + + .. warning:: The files that this class generates on disk are an + implementation detail, and may change without a warning in a future + version of Scrapy. Do not rely on the following information for + anything other than debugging purposes. + + When a component instantiates this class with a non-empty *key* argument, + *key* is used as a persistence directory. + + For every request enqueued, this class checks: + + - Whether the request is a :ref:`start request ` or not. + + - The :data:`~scrapy.Request.priority` of the request. + + For each combination of the above seen, this class creates an instance of + *downstream_queue_cls* with *key* set to a subdirectory of the persistence + directory, named as the request priority (e.g. ``1``), with an ``s`` suffix + in case of a start request (e.g. ``1s``). """ @classmethod @@ -255,6 +278,26 @@ class DownloaderAwarePriorityQueue: """PriorityQueue which takes Downloader activity into account: domains (slots) with the least amount of active downloads are dequeued first. + + Disk persistence + ================ + + .. warning:: The files that this class generates on disk are an + implementation detail, and may change without a warning in a future + version of Scrapy. Do not rely on the following information for + anything other than debugging purposes. + + When a component instantiates this class with a non-empty *key* argument, + *key* is used as a persistence directory, and inside that directory this + class creates a subdirectory per download slot (domain). + + Those subdirectories are named after the corresponding download slot, with + path-unsafe characters replaced by underscores and an MD5 hash suffix to + avoid collisions. + + For each download slot, this class creates an instance of + :class:`ScrapyPriorityQueue` with the download slot subdirectory as *key* + and its own *downstream_queue_cls*. """ @classmethod From 06fb87f7bb0cd860536fc8d15415cb3d78314f15 Mon Sep 17 00:00:00 2001 From: Ryotaro <64832783+Ryotaro25@users.noreply.github.com> Date: Sat, 7 Feb 2026 05:17:55 +0900 Subject: [PATCH 050/248] Fix override behavior in getwithbase() issue #6912 (#6993) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix test for getwithbase() to ensure class-keyed overrides with None are handled correctly * Test actual import paths from default_settings * Improvements * Remove unused logger * Run pre-commit * Restore and improve duplicate key handling and warning * type → object --------- Co-authored-by: Adrian Chaves --- scrapy/settings/__init__.py | 43 ++++++++++++-- tests/test_settings/__init__.py | 101 +++++++++++++++++++++++++++++++- 2 files changed, 139 insertions(+), 5 deletions(-) diff --git a/scrapy/settings/__init__.py b/scrapy/settings/__init__.py index c330c4c35..598a746a9 100644 --- a/scrapy/settings/__init__.py +++ b/scrapy/settings/__init__.py @@ -5,12 +5,16 @@ import json import warnings from collections.abc import Iterable, Iterator, Mapping, MutableMapping from importlib import import_module +from logging import getLogger from pprint import pformat from typing import TYPE_CHECKING, Any, TypeAlias, cast from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.settings import default_settings from scrapy.utils.misc import load_object +from scrapy.utils.python import global_object_name + +logger = getLogger(__name__) # The key types are restricted in BaseSettings._get_key() to ones supported by JSON, # see https://github.com/scrapy/scrapy/issues/5383. @@ -319,10 +323,41 @@ class BaseSettings(MutableMapping[_SettingsKey, Any]): """ if not isinstance(name, str): raise ValueError(f"Base setting key must be a string, got {name}") - compbs = BaseSettings() - compbs.update(self[name + "_BASE"]) - compbs.update(self[name]) - return compbs + + normalized_keys = {} + obj_keys = set() + + def track_loaded_key(k: Any) -> None: + if k not in obj_keys: + obj_keys.add(k) + return + logger.warning( + f"Setting {name} contains multiple keys that refer to the " + f"same object: {global_object_name(k)}. Only the last one will " + f"be kept." + ) + + def normalize_key(key: Any) -> str: + try: + loaded_key = load_object(key) + except (AttributeError, TypeError, ValueError): + loaded_key = key + else: + import_path = global_object_name(loaded_key) + normalized_keys[import_path] = key + key = import_path + track_loaded_key(loaded_key) + return key + + def restore_key(k: str) -> Any: + return normalized_keys.get(k, k) + + result = dict(self[name + "_BASE"] or {}) + override = {normalize_key(k): v for k, v in (self[name] or {}).items()} + result.update(override) + return BaseSettings( + {restore_key(k): v for k, v in result.items() if v is not None} + ) def getpriority(self, name: _SettingsKey) -> int | None: """ diff --git a/tests/test_settings/__init__.py b/tests/test_settings/__init__.py index e97e114d6..4436f03ba 100644 --- a/tests/test_settings/__init__.py +++ b/tests/test_settings/__init__.py @@ -1,6 +1,7 @@ # pylint: disable=unsubscriptable-object,unsupported-membership-test,use-implicit-booleaness-not-comparison # (too many false positives) +import logging import warnings from unittest import mock @@ -14,11 +15,18 @@ from scrapy.settings import ( SettingsAttribute, get_settings_priority, ) -from scrapy.utils.misc import build_from_crawler +from scrapy.settings import default_settings as scrapy_default_settings +from scrapy.utils.misc import build_from_crawler, load_object from scrapy.utils.test import get_crawler from . import default_settings +NON_COMPONENT_PRIORITY_DICT_BASE_SETTING_NAMES = { + "DOWNLOAD_HANDLERS_BASE", + "FEED_EXPORTERS_BASE", + "FEED_STORAGES_BASE", +} + class TestSettingsGlobalFuncs: def test_get_settings_priority(self): @@ -402,6 +410,97 @@ class TestBaseSettings: assert frozencopy.frozen assert frozencopy is not self.settings + def test_getwithbase_override_none_by_type(self): + settings = BaseSettings() + setting_names = set() + for k, v in scrapy_default_settings.__dict__.items(): + if ( + not k.endswith("_BASE") + or k in NON_COMPONENT_PRIORITY_DICT_BASE_SETTING_NAMES + ): + continue + settings[k] = v + setting_name = k[: -len("_BASE")] + setting_names.add(setting_name) + settings[setting_name] = { + load_object(import_path): None for import_path in v + } + for setting_name in setting_names: + value = settings.getwithbase(setting_name) + assert not dict(value) + + def test_getwithbase_override_value_by_type(self): + settings = BaseSettings() + setting_names = set() + value = 0 + for k, v in scrapy_default_settings.__dict__.items(): + if ( + not k.endswith("_BASE") + or k in NON_COMPONENT_PRIORITY_DICT_BASE_SETTING_NAMES + ): + continue + settings[k] = v + setting_name = k[: -len("_BASE")] + setting_names.add(setting_name) + settings[setting_name] = { + load_object(import_path): value for import_path in v + } + for setting_name in setting_names: + assert settings.getwithbase(setting_name) == settings[setting_name] + + def test_getwithbase_for_non_component_priority_dicts(self): + settings = BaseSettings() + for base_name in NON_COMPONENT_PRIORITY_DICT_BASE_SETTING_NAMES: + base_value = getattr(scrapy_default_settings, base_name) + settings[base_name] = BaseSettings(base_value) + assert len(base_value) >= 2 + keys = list(base_value) + values = list(base_value.values()) + override = {keys[0]: values[1]} + expected = dict(base_value) + expected[keys[0]] = values[1] + name = base_name[: -len("_BASE")] + settings[name] = BaseSettings(override) + value = settings.getwithbase(name) + assert isinstance(value, BaseSettings) + assert dict(value) == expected + + def test_getwithbase_warns_on_duplicate_import_paths(self, caplog): + settings = BaseSettings() + settings["FOO"] = BaseSettings( + { + "scrapy.Request": 1, + "scrapy.http.Request": 2, + } + ) + with caplog.at_level(logging.WARNING): + value = settings.getwithbase("FOO") + assert isinstance(value, BaseSettings) + assert dict(value) == {"scrapy.http.Request": 2} + assert caplog.records, "Expected a warning to be logged" + msg = caplog.records[0].message + assert "scrapy.http.request.Request" in msg + + def test_getwithbase_warns_on_duplicate_mixed_type_and_path(self, caplog): + settings = BaseSettings() + settings["FOO"] = BaseSettings( + {Component1: 1, "tests.test_settings.Component1": 2} + ) + with caplog.at_level(logging.WARNING): + value = settings.getwithbase("FOO") + assert isinstance(value, BaseSettings) + assert dict(value) == {"tests.test_settings.Component1": 2} + assert caplog.records, "Expected a warning to be logged" + msg = caplog.records[0].message + assert "tests.test_settings.Component1" in msg + + def test_getwithbase_invalid_setting_name(self): + settings = BaseSettings() + with pytest.raises( + ValueError, match="Base setting key must be a string, got 123" + ): + settings.getwithbase(123) + class TestSettings: def setup_method(self): From 0cfc4e4386c6376087ddefa5e3852c4f835bb666 Mon Sep 17 00:00:00 2001 From: Varun Chawla <34209028+veeceey@users.noreply.github.com> Date: Tue, 10 Feb 2026 00:49:10 -0800 Subject: [PATCH 051/248] Improve dont_filter documentation (#7245) --- scrapy/http/request/__init__.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/scrapy/http/request/__init__.py b/scrapy/http/request/__init__.py index d306f1f6e..61e50927d 100644 --- a/scrapy/http/request/__init__.py +++ b/scrapy/http/request/__init__.py @@ -210,8 +210,22 @@ class Request(object_ref): #: default), or those components should not filter out this request #: (``True``). #: - #: This attribute is commonly set to ``True`` to prevent duplicate - #: requests from being filtered out. + #: The following built-in components check this attribute: + #: + #: - The :ref:`scheduler ` uses it to skip + #: duplicate request filtering (see + #: :setting:`DUPEFILTER_CLASS`). When set to ``True``, the + #: request is not checked against the duplicate filter, + #: allowing requests that would otherwise be considered duplicates + #: to be scheduled multiple times. + #: - :class:`~scrapy.downloadermiddlewares.offsite.OffsiteMiddleware` + #: uses it to allow requests to domains not in + #: :attr:`~scrapy.Spider.allowed_domains`. To skip only the offsite + #: filter without affecting other components, consider using the + #: :reqmeta:`allow_offsite` request meta key instead. + #: + #: Third-party components may also use this attribute to decide whether + #: to filter out a request. #: #: When defining the start URLs of a spider through #: :attr:`~scrapy.Spider.start_urls`, this attribute is enabled by From fc30c47f38e8ce742d7e35612e48dbb14db5c634 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Thu, 12 Feb 2026 11:30:44 +0300 Subject: [PATCH 052/248] Add HttpxDownloader. (#7239) --- conftest.py | 5 + docs/news.rst | 13 +- scrapy/core/downloader/handlers/_httpx.py | 269 ++++++++++++++++++ scrapy/http/headers.py | 11 + scrapy/utils/log.py | 6 + scrapy/utils/ssl.py | 51 +++- tests/spiders.py | 10 +- tests/test_downloader_handler_httpx.py | 127 +++++++++ .../test_downloader_handler_twisted_http2.py | 12 + tests/test_downloader_handlers_http_base.py | 78 ++++- tox.ini | 3 + 11 files changed, 579 insertions(+), 6 deletions(-) create mode 100644 scrapy/core/downloader/handlers/_httpx.py create mode 100644 tests/test_downloader_handler_httpx.py diff --git a/conftest.py b/conftest.py index 50925a311..26e0434ba 100644 --- a/conftest.py +++ b/conftest.py @@ -52,6 +52,11 @@ if not H2_ENABLED: ) ) +try: + import httpx # noqa: F401 +except ImportError: + collect_ignore.append("scrapy/core/downloader/handlers/_httpx.py") + def pytest_addoption(parser, pluginmanager): if pluginmanager.hasplugin("twisted"): diff --git a/docs/news.rst b/docs/news.rst index bab2e5ff9..ff8e6fb08 100644 --- a/docs/news.rst +++ b/docs/news.rst @@ -3,6 +3,17 @@ Release notes ============= +Scrapy VERSION (unreleased) +--------------------------- + +New features +~~~~~~~~~~~~ + +- Added + :meth:`Headers.to_tuple_list() ` + that returns headers as a list of ``(key, value)`` tuples. + (:issue:`7239`) + .. _release-2.14.1: Scrapy 2.14.1 (2026-01-12) @@ -2997,7 +3008,7 @@ Bug fixes that does not match the asyncio event loop actually installed (:issue:`5529`). -- Fixed :meth:`Headers.getlist ` +- Fixed :meth:`Headers.getlist() ` returning only the last header (:issue:`5515`, :issue:`5526`). - Fixed :class:`LinkExtractor diff --git a/scrapy/core/downloader/handlers/_httpx.py b/scrapy/core/downloader/handlers/_httpx.py new file mode 100644 index 000000000..1f5cf007e --- /dev/null +++ b/scrapy/core/downloader/handlers/_httpx.py @@ -0,0 +1,269 @@ +"""``httpx``-based HTTP(S) download handler. Currently not recommended for production use.""" + +from __future__ import annotations + +import ipaddress +import logging +import ssl +from http.cookiejar import Cookie, CookieJar +from io import BytesIO +from typing import TYPE_CHECKING, Any, NoReturn, TypedDict + +import httpx + +from scrapy import Request, signals +from scrapy.exceptions import ( + CannotResolveHostError, + DownloadCancelledError, + DownloadConnectionRefusedError, + DownloadFailedError, + DownloadTimeoutError, + NotConfigured, + ResponseDataLossError, + UnsupportedURLSchemeError, +) +from scrapy.http import Headers, Response +from scrapy.utils._download_handlers import ( + BaseHttpDownloadHandler, + check_stop_download, + get_dataloss_msg, + get_maxsize_msg, + get_warnsize_msg, + make_response, +) +from scrapy.utils.asyncio import is_asyncio_available +from scrapy.utils.ssl import _log_sslobj_debug_info, _make_ssl_context + +if TYPE_CHECKING: + from contextlib import AbstractAsyncContextManager + from http.client import HTTPResponse + from ipaddress import IPv4Address, IPv6Address + from urllib.request import Request as ULRequest + + from httpcore import AsyncNetworkStream + + from scrapy.crawler import Crawler + + +logger = logging.getLogger(__name__) + + +class _BaseResponseArgs(TypedDict): + status: int + url: str + headers: Headers + ip_address: IPv4Address | IPv6Address + protocol: str + + +# workaround for (and from) https://github.com/encode/httpx/issues/2992 +class _NullCookieJar(CookieJar): # pragma: no cover + """A CookieJar that rejects all cookies.""" + + def extract_cookies(self, response: HTTPResponse, request: ULRequest) -> None: + pass + + def set_cookie(self, cookie: Cookie) -> None: + pass + + +class HttpxDownloadHandler(BaseHttpDownloadHandler): + _DEFAULT_CONNECT_TIMEOUT = 10 + + def __init__(self, crawler: Crawler): + # we don't run extra-deps tests with the non-asyncio reactor + if not is_asyncio_available(): # pragma: no cover + raise NotConfigured( + f"{type(self).__name__} requires the asyncio support. Make" + f" sure that you have either enabled the asyncio Twisted" + f" reactor in the TWISTED_REACTOR setting or disabled the" + f" TWISTED_ENABLED setting. See the asyncio documentation" + f" of Scrapy for more information." + ) + super().__init__(crawler) + logger.warning( + "HttpxDownloadHandler is experimental and is not recommented for production use." + ) + self._tls_verbose_logging: bool = self.crawler.settings.getbool( + "DOWNLOADER_CLIENT_TLS_VERBOSE_LOGGING" + ) + self._client = httpx.AsyncClient( + verify=_make_ssl_context(crawler.settings), cookies=_NullCookieJar() + ) + + async def download_request(self, request: Request) -> Response: + self._warn_unsupported_meta(request.meta) + + timeout: float = request.meta.get( + "download_timeout", self._DEFAULT_CONNECT_TIMEOUT + ) + + try: + async with self._get_httpx_response(request, timeout) as httpx_response: + return await self._read_response(httpx_response, request) + except httpx.TimeoutException as e: + raise DownloadTimeoutError( + f"Getting {request.url} took longer than {timeout} seconds." + ) from e + except httpx.UnsupportedProtocol as e: + raise UnsupportedURLSchemeError(str(e)) from e + except httpx.ConnectError as e: + if "Name or service not known" in str(e) or "getaddrinfo failed" in str(e): + raise CannotResolveHostError(str(e)) from e + raise DownloadConnectionRefusedError(str(e)) from e + except httpx.NetworkError as e: + raise DownloadFailedError(str(e)) from e + except httpx.RemoteProtocolError as e: + raise DownloadFailedError(str(e)) from e + + def _warn_unsupported_meta(self, meta: dict[str, Any]) -> None: + if meta.get("bindaddress"): + # configurable only per-client: + # https://github.com/encode/httpx/issues/755#issuecomment-2746121794 + logger.error( + f"The 'bindaddress' request meta key is not supported by" + f" {type(self).__name__} and will be ignored." + ) + if meta.get("proxy"): + # configurable only per-client: + # https://github.com/encode/httpx/issues/486 + logger.error( + f"The 'proxy' request meta key is not supported by" + f" {type(self).__name__} and will be ignored." + ) + + def _get_httpx_response( + self, request: Request, timeout: float + ) -> AbstractAsyncContextManager[httpx.Response]: + return self._client.stream( + request.method, + request.url, + content=request.body, + headers=request.headers.to_tuple_list(), + timeout=timeout, + ) + + async def _read_response( + self, httpx_response: httpx.Response, request: Request + ) -> Response: + maxsize: int = request.meta.get("download_maxsize", self._default_maxsize) + warnsize: int = request.meta.get("download_warnsize", self._default_warnsize) + + content_length = httpx_response.headers.get("Content-Length") + expected_size = int(content_length) if content_length is not None else None + if maxsize and expected_size and expected_size > maxsize: + self._cancel_maxsize(expected_size, maxsize, request, expected=True) + + reached_warnsize = False + if warnsize and expected_size and expected_size > warnsize: + reached_warnsize = True + logger.warning( + get_warnsize_msg(expected_size, warnsize, request, expected=True) + ) + + headers = Headers(httpx_response.headers.multi_items()) + network_stream: AsyncNetworkStream = httpx_response.extensions["network_stream"] + + make_response_base_args: _BaseResponseArgs = { + "status": httpx_response.status_code, + "url": request.url, + "headers": headers, + "ip_address": self._get_server_ip(network_stream), + "protocol": httpx_response.http_version, + } + + self._log_tls_info(network_stream) + + if stop_download := check_stop_download( + signals.headers_received, + self.crawler, + request, + headers=headers, + body_length=expected_size, + ): + return make_response( + **make_response_base_args, + stop_download=stop_download, + ) + + response_body = BytesIO() + bytes_received = 0 + try: + async for chunk in httpx_response.aiter_raw(): + response_body.write(chunk) + bytes_received += len(chunk) + + if stop_download := check_stop_download( + signals.bytes_received, self.crawler, request, data=chunk + ): + return make_response( + **make_response_base_args, + body=response_body.getvalue(), + stop_download=stop_download, + ) + + if maxsize and bytes_received > maxsize: + response_body.truncate(0) + self._cancel_maxsize( + bytes_received, maxsize, request, expected=False + ) + + if warnsize and bytes_received > warnsize and not reached_warnsize: + reached_warnsize = True + logger.warning( + get_warnsize_msg( + bytes_received, warnsize, request, expected=False + ) + ) + except httpx.RemoteProtocolError as e: + # special handling of the dataloss case + if ( + "peer closed connection without sending complete message body" + not in str(e) + ): + raise + fail_on_dataloss: bool = request.meta.get( + "download_fail_on_dataloss", self._fail_on_dataloss + ) + if not fail_on_dataloss: + return make_response( + **make_response_base_args, + body=response_body.getvalue(), + flags=["dataloss"], + ) + self._log_dataloss_warning(request.url) + raise ResponseDataLossError(str(e)) from e + + return make_response( + **make_response_base_args, + body=response_body.getvalue(), + ) + + @staticmethod + def _get_server_ip(network_stream: AsyncNetworkStream) -> IPv4Address | IPv6Address: + extra_server_addr = network_stream.get_extra_info("server_addr") + return ipaddress.ip_address(extra_server_addr[0]) + + def _log_tls_info(self, network_stream: AsyncNetworkStream) -> None: + if not self._tls_verbose_logging: + return + extra_ssl_object = network_stream.get_extra_info("ssl_object") + if isinstance(extra_ssl_object, ssl.SSLObject): + _log_sslobj_debug_info(extra_ssl_object) + + def _log_dataloss_warning(self, url: str) -> None: + if self._fail_on_dataloss_warned: + return + logger.warning(get_dataloss_msg(url)) + self._fail_on_dataloss_warned = True + + @staticmethod + def _cancel_maxsize( + size: int, limit: int, request: Request, *, expected: bool + ) -> NoReturn: + warning_msg = get_maxsize_msg(size, limit, request, expected=expected) + logger.warning(warning_msg) + raise DownloadCancelledError(warning_msg) + + async def close(self): + await self._client.aclose() diff --git a/scrapy/http/headers.py b/scrapy/http/headers.py index f3f60bffb..34d4ec6f2 100644 --- a/scrapy/http/headers.py +++ b/scrapy/http/headers.py @@ -124,6 +124,17 @@ class Headers(CaselessDict): for key, value in self.items() ) + def to_tuple_list(self) -> list[tuple[str, str]]: + """Return headers as a list of ``(key, value)`` tuples. + + Multiple values are represented as multiple tuples with the same key. + """ + return [ + (key.decode(self.encoding), value.decode(self.encoding)) + for key, values in self.items() + for value in values + ] + def __copy__(self) -> Self: return self.__class__(self) diff --git a/scrapy/utils/log.py b/scrapy/utils/log.py index 6d3bb1db9..07a9e5ff7 100644 --- a/scrapy/utils/log.py +++ b/scrapy/utils/log.py @@ -72,6 +72,12 @@ DEFAULT_LOGGING = { "hpack": { "level": "ERROR", }, + "httpcore": { + "level": "ERROR", + }, + "httpx": { + "level": "WARNING", + }, "scrapy": { "level": "DEBUG", }, diff --git a/scrapy/utils/ssl.py b/scrapy/utils/ssl.py index 7d46cbd4f..0062da52b 100644 --- a/scrapy/utils/ssl.py +++ b/scrapy/utils/ssl.py @@ -1,5 +1,7 @@ from __future__ import annotations +import logging +import ssl from typing import TYPE_CHECKING, Any import OpenSSL._util as pyOpenSSLutil @@ -11,6 +13,54 @@ from scrapy.utils.python import to_unicode if TYPE_CHECKING: from OpenSSL.crypto import X509Name + from scrapy.settings import BaseSettings + +logger = logging.getLogger(__name__) + + +# stdlib ssl module utils + +# possible documented values for DOWNLOADER_CLIENT_TLS_METHOD +_STDLIB_PROTOCOL_MAP = { + "TLS": ssl.PROTOCOL_TLS_CLIENT, + "TLSv1.0": ssl.PROTOCOL_TLSv1, + "TLSv1.1": ssl.PROTOCOL_TLSv1_1, + "TLSv1.2": ssl.PROTOCOL_TLSv1_2, +} + + +def _make_ssl_context(settings: BaseSettings) -> ssl.SSLContext: + """Create an :class:`ssl.SSLContext` instance according to the settings. + + It's intended to be used in an HTTPS download handler. + """ + + method_setting: str = settings["DOWNLOADER_CLIENT_TLS_METHOD"] + if method_setting not in _STDLIB_PROTOCOL_MAP: + raise ValueError(f"Unsupported TLS method: {method_setting}") + ciphers_setting: str | None = settings["DOWNLOADER_CLIENT_TLS_CIPHERS"] + + ctx = ssl.SSLContext(_STDLIB_PROTOCOL_MAP[method_setting]) + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + if ciphers_setting: + ctx.set_ciphers(ciphers_setting) + return ctx + + +def _log_sslobj_debug_info(sslobj: ssl.SSLObject) -> None: + cipher = sslobj.cipher() + logger.debug( + f"SSL connection to {sslobj.server_hostname}" + f" using protocol {sslobj.version()}," + f" cipher {cipher[0] if cipher else None}" + ) + # The peer certificate is unavailable on SSLObject unless peer + # certificate verification is enabled, which we don't want. + + +# pyOpenSSL utils + def ffi_buf_to_string(buf: Any) -> str: return to_unicode(pyOpenSSLutil.ffi.string(buf)) @@ -22,7 +72,6 @@ def x509name_to_string(x509name: X509Name) -> str: pyOpenSSLutil.lib.X509_NAME_oneline( x509name._name, result_buffer, len(result_buffer) ) - return ffi_buf_to_string(result_buffer) diff --git a/tests/spiders.py b/tests/spiders.py index 43cdcaef3..de2e64242 100644 --- a/tests/spiders.py +++ b/tests/spiders.py @@ -22,9 +22,10 @@ from scrapy.utils.test import get_from_asyncio_queue, get_web_client_agent_req class MockServerSpider(Spider): - def __init__(self, mockserver=None, *args, **kwargs): + def __init__(self, *args, mockserver=None, is_secure=False, **kwargs): super().__init__(*args, **kwargs) self.mockserver = mockserver + self.is_secure = is_secure class MetaSpider(MockServerSpider): @@ -516,7 +517,7 @@ class BytesReceivedCallbackSpider(MetaSpider): async def start(self): body = b"a" * self.full_response_length - url = self.mockserver.url("/alpayload") + url = self.mockserver.url("/alpayload", is_secure=self.is_secure) yield Request(url, method="POST", body=body, errback=self.errback) def parse(self, response): @@ -544,7 +545,10 @@ class HeadersReceivedCallbackSpider(MetaSpider): return spider async def start(self): - yield Request(self.mockserver.url("/status"), errback=self.errback) + yield Request( + self.mockserver.url("/status", is_secure=self.is_secure), + errback=self.errback, + ) def parse(self, response): self.meta["response"] = response diff --git a/tests/test_downloader_handler_httpx.py b/tests/test_downloader_handler_httpx.py new file mode 100644 index 000000000..ad6c6d3c7 --- /dev/null +++ b/tests/test_downloader_handler_httpx.py @@ -0,0 +1,127 @@ +"""Tests for scrapy.core.downloader.handlers._httpx.HttpxDownloadHandler.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +import pytest + +from scrapy import Request +from tests.test_downloader_handlers_http_base import ( + TestHttp11Base, + TestHttpProxyBase, + TestHttps11Base, + TestHttpsCustomCiphersBase, + TestHttpsInvalidDNSIdBase, + TestHttpsInvalidDNSPatternBase, + TestHttpsWrongHostnameBase, + TestHttpWithCrawlerBase, + TestSimpleHttpsBase, +) +from tests.utils.decorators import coroutine_test + +if TYPE_CHECKING: + from scrapy.core.downloader.handlers import DownloadHandlerProtocol + from tests.mockserver.http import MockServer + + +pytest.importorskip("httpx") + + +class HttpxDownloadHandlerMixin: + @property + def download_handler_cls(self) -> type[DownloadHandlerProtocol]: + # the import will fail if httpx is not installed + from scrapy.core.downloader.handlers._httpx import ( # noqa: PLC0415 + HttpxDownloadHandler, + ) + + return HttpxDownloadHandler + + +class TestHttp11(HttpxDownloadHandlerMixin, TestHttp11Base): + @coroutine_test + async def test_unsupported_bindaddress( + self, caplog: pytest.LogCaptureFixture, mockserver: MockServer + ) -> None: + meta = {"bindaddress": "127.0.0.2"} + request = Request(mockserver.url("/text"), meta=meta) + async with self.get_dh() as download_handler: + response = await download_handler.download_request(request) + assert response.body == b"Works" + assert ( + "The 'bindaddress' request meta key is not supported by HttpxDownloadHandler" + in caplog.text + ) + + @coroutine_test + async def test_unsupported_proxy( + self, caplog: pytest.LogCaptureFixture, mockserver: MockServer + ) -> None: + meta = {"proxy": "127.0.0.2"} + request = Request(mockserver.url("/text"), meta=meta) + async with self.get_dh() as download_handler: + response = await download_handler.download_request(request) + assert response.body == b"Works" + assert ( + "The 'proxy' request meta key is not supported by HttpxDownloadHandler" + in caplog.text + ) + + +class TestHttps11(HttpxDownloadHandlerMixin, TestHttps11Base): + tls_log_message = "SSL connection to 127.0.0.1 using protocol TLSv1.3, cipher" + + +class TestSimpleHttps(HttpxDownloadHandlerMixin, TestSimpleHttpsBase): + pass + + +class Https11WrongHostnameTestCase( + HttpxDownloadHandlerMixin, TestHttpsWrongHostnameBase +): + pass + + +class Https11InvalidDNSId(HttpxDownloadHandlerMixin, TestHttpsInvalidDNSIdBase): + pass + + +class Https11InvalidDNSPattern( + HttpxDownloadHandlerMixin, TestHttpsInvalidDNSPatternBase +): + pass + + +class Https11CustomCiphers(HttpxDownloadHandlerMixin, TestHttpsCustomCiphersBase): + pass + + +class TestHttp11WithCrawler(TestHttpWithCrawlerBase): + @property + def settings_dict(self) -> dict[str, Any] | None: + return { + "DOWNLOAD_HANDLERS": { + "http": "scrapy.core.downloader.handlers._httpx.HttpxDownloadHandler", + "https": "scrapy.core.downloader.handlers._httpx.HttpxDownloadHandler", + } + } + + +class TestHttps11WithCrawler(TestHttp11WithCrawler): + is_secure = True + + @pytest.mark.skip(reason="response.certificate is not implemented") + @coroutine_test + async def test_response_ssl_certificate(self, mockserver: MockServer) -> None: + pass + + +@pytest.mark.skip(reason="Proxy support is not implemented yet") +class TestHttp11Proxy(HttpxDownloadHandlerMixin, TestHttpProxyBase): + pass + + +@pytest.mark.skip(reason="Proxy support is not implemented yet") +class TestHttps11Proxy(HttpxDownloadHandlerMixin, TestHttpProxyBase): + is_secure = True diff --git a/tests/test_downloader_handler_twisted_http2.py b/tests/test_downloader_handler_twisted_http2.py index df19646ff..1b9dc21e7 100644 --- a/tests/test_downloader_handler_twisted_http2.py +++ b/tests/test_downloader_handler_twisted_http2.py @@ -189,6 +189,18 @@ class TestHttp2WithCrawler(TestHttpWithCrawlerBase): is_secure = True + def test_bytes_received_stop_download_callback(self) -> None: # type: ignore[override] + pytest.skip("bytes_received support is not implemented") + + def test_bytes_received_stop_download_errback(self) -> None: # type: ignore[override] + pytest.skip("bytes_received support is not implemented") + + def test_headers_received_stop_download_callback(self) -> None: # type: ignore[override] + pytest.skip("headers_received support is not implemented") + + def test_headers_received_stop_download_errback(self) -> None: # type: ignore[override] + pytest.skip("headers_received support is not implemented") + class TestHttps2Proxy(H2DownloadHandlerMixin, TestHttpProxyBase): is_secure = True diff --git a/tests/test_downloader_handlers_http_base.py b/tests/test_downloader_handlers_http_base.py index bc7807b4d..8e9fa0ae1 100644 --- a/tests/test_downloader_handlers_http_base.py +++ b/tests/test_downloader_handlers_http_base.py @@ -16,6 +16,7 @@ from urllib.parse import urlparse import pytest from twisted.internet.ssl import Certificate +from twisted.python.failure import Failure from scrapy.exceptions import ( CannotResolveHostError, @@ -24,6 +25,7 @@ from scrapy.exceptions import ( DownloadFailedError, DownloadTimeoutError, ResponseDataLossError, + StopDownload, UnsupportedURLSchemeError, ) from scrapy.http import Headers, HtmlResponse, Request, Response, TextResponse @@ -34,7 +36,13 @@ from scrapy.utils.test import get_crawler from tests import NON_EXISTING_RESOLVABLE from tests.mockserver.proxy_echo import ProxyEchoMockServer from tests.mockserver.simple_https import SimpleMockServer -from tests.spiders import SingleRequestSpider +from tests.spiders import ( + BytesReceivedCallbackSpider, + BytesReceivedErrbackSpider, + HeadersReceivedCallbackSpider, + HeadersReceivedErrbackSpider, + SingleRequestSpider, +) from tests.utils.decorators import coroutine_test if TYPE_CHECKING: @@ -832,6 +840,74 @@ class TestHttpWithCrawlerBase(ABC): assert isinstance(ip_address, IPv4Address) assert str(ip_address) == gethostbyname(expected_netloc) + @coroutine_test + async def test_bytes_received_stop_download_callback( + self, mockserver: MockServer + ) -> None: + # copy of TestCrawl.test_bytes_received_stop_download_callback() + crawler = get_crawler(BytesReceivedCallbackSpider, self.settings_dict) + await crawler.crawl_async(mockserver=mockserver, is_secure=self.is_secure) + assert isinstance(crawler.spider, BytesReceivedCallbackSpider) + assert crawler.spider.meta.get("failure") is None + assert isinstance(crawler.spider.meta["response"], Response) + assert crawler.spider.meta["response"].body == crawler.spider.meta.get( + "bytes_received" + ) + assert ( + len(crawler.spider.meta["response"].body) + < crawler.spider.full_response_length + ) + + @coroutine_test + async def test_bytes_received_stop_download_errback( + self, mockserver: MockServer + ) -> None: + # copy of TestCrawl.test_bytes_received_stop_download_errback() + crawler = get_crawler(BytesReceivedErrbackSpider, self.settings_dict) + await crawler.crawl_async(mockserver=mockserver, is_secure=self.is_secure) + assert isinstance(crawler.spider, BytesReceivedErrbackSpider) + assert crawler.spider.meta.get("response") is None + assert isinstance(crawler.spider.meta["failure"], Failure) + assert isinstance(crawler.spider.meta["failure"].value, StopDownload) + assert isinstance(crawler.spider.meta["failure"].value.response, Response) + assert crawler.spider.meta[ + "failure" + ].value.response.body == crawler.spider.meta.get("bytes_received") + assert ( + len(crawler.spider.meta["failure"].value.response.body) + < crawler.spider.full_response_length + ) + + @coroutine_test + async def test_headers_received_stop_download_callback( + self, mockserver: MockServer + ) -> None: + # copy of TestCrawl.test_headers_received_stop_download_callback() + crawler = get_crawler(HeadersReceivedCallbackSpider, self.settings_dict) + await crawler.crawl_async(mockserver=mockserver, is_secure=self.is_secure) + assert isinstance(crawler.spider, HeadersReceivedCallbackSpider) + assert crawler.spider.meta.get("failure") is None + assert isinstance(crawler.spider.meta["response"], Response) + assert crawler.spider.meta["response"].headers == crawler.spider.meta.get( + "headers_received" + ) + + @coroutine_test + async def test_headers_received_stop_download_errback( + self, mockserver: MockServer + ) -> None: + # copy of TestCrawl.test_headers_received_stop_download_errback() + crawler = get_crawler(HeadersReceivedErrbackSpider, self.settings_dict) + await crawler.crawl_async(mockserver=mockserver, is_secure=self.is_secure) + assert isinstance(crawler.spider, HeadersReceivedErrbackSpider) + assert crawler.spider.meta.get("response") is None + assert isinstance(crawler.spider.meta["failure"], Failure) + assert isinstance(crawler.spider.meta["failure"].value, StopDownload) + assert isinstance(crawler.spider.meta["failure"].value.response, Response) + assert crawler.spider.meta[ + "failure" + ].value.response.headers == crawler.spider.meta.get("headers_received") + class TestHttpProxyBase(ABC): is_secure = False diff --git a/tox.ini b/tox.ini index 83cd06561..3be564502 100644 --- a/tox.ini +++ b/tox.ini @@ -48,6 +48,7 @@ deps = types-Pygments==2.19.0.20250809 botocore-stubs==1.40.59 boto3-stubs[s3]==1.40.59 + httpx==0.28.1 itemadapter==0.12.2 Protego==0.5.0 w3lib==2.3.1 @@ -134,6 +135,7 @@ deps = brotli >= 1.2.0; implementation_name != "pypy" # optional for HTTP compress downloader middleware tests brotlicffi >= 1.2.0.0; implementation_name == "pypy" # optional for HTTP compress downloader middleware tests google-cloud-storage + httpx ipython robotexclusionrulesparser uvloop; platform_system != "Windows" and implementation_name != "pypy" @@ -149,6 +151,7 @@ deps = brotli==1.2.0; implementation_name != "pypy" brotlicffi==1.2.0.0; implementation_name == "pypy" google-cloud-storage==1.29.0 + httpx==0.26.0 ipython==7.1.0 robotexclusionrulesparser==1.6.2 uvloop==0.16.0; platform_system != "Windows" and implementation_name != "pypy" From 2c3ecbff714c45215766fd2c8e141613054b2676 Mon Sep 17 00:00:00 2001 From: Anant Murmu Date: Fri, 13 Feb 2026 14:12:26 +0530 Subject: [PATCH 053/248] fix: missing self argument in ScreenshotPipeline constructor (#7248) added missing self argument in __init__ method constructor --- docs/topics/item-pipeline.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/topics/item-pipeline.rst b/docs/topics/item-pipeline.rst index 8194fe043..9a53f88fb 100644 --- a/docs/topics/item-pipeline.rst +++ b/docs/topics/item-pipeline.rst @@ -190,7 +190,7 @@ item. SPLASH_URL = "http://localhost:8050/render.png?url={}" - def __init__(crawler): + def __init__(self, crawler): self.crawler = crawler @classmethod From 0e1526ed30f375a169c68d36923bc9e60ce3258f Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Fri, 13 Feb 2026 11:45:09 +0300 Subject: [PATCH 054/248] Fix flaky test_download_with_proxy_https_timeout(). (#7250) --- tests/mockserver/http_resources.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/mockserver/http_resources.py b/tests/mockserver/http_resources.py index 70a9b8ac6..62a3146bf 100644 --- a/tests/mockserver/http_resources.py +++ b/tests/mockserver/http_resources.py @@ -308,7 +308,8 @@ class UriResource(resource.Resource): # ToDo: implement proper HTTPS proxy tests, not faking them. if request.method != b"CONNECT": return request.uri - return b"" + request.transport.write(b"HTTP/1.1 200 Connection established\r\n\r\n") + return NOT_DONE_YET class ResponseHeadersResource(resource.Resource): From 09bd8f42318bbd413e16d43900f4f27bdfa50962 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Fri, 13 Feb 2026 17:12:13 +0300 Subject: [PATCH 055/248] Properly close ftplib.FTP(). (#7256) --- scrapy/pipelines/files.py | 18 +++++++++--------- tests/test_pipeline_files.py | 24 ++++++++++++------------ 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/scrapy/pipelines/files.py b/scrapy/pipelines/files.py index ad4abbbb6..795a0143f 100644 --- a/scrapy/pipelines/files.py +++ b/scrapy/pipelines/files.py @@ -391,15 +391,15 @@ class FTPFilesStore: ) -> Deferred[StatInfo]: def _stat_file(path: str) -> StatInfo: try: - ftp = FTP() - ftp.connect(self.host, self.port) - ftp.login(self.username, self.password) - if self.USE_ACTIVE_MODE: - ftp.set_pasv(False) - file_path = f"{self.basedir}/{path}" - last_modified = float(ftp.voidcmd(f"MDTM {file_path}")[4:].strip()) - m = hashlib.md5() # noqa: S324 - ftp.retrbinary(f"RETR {file_path}", m.update) + with FTP() as ftp: + ftp.connect(self.host, self.port) + ftp.login(self.username, self.password) + if self.USE_ACTIVE_MODE: + ftp.set_pasv(False) + file_path = f"{self.basedir}/{path}" + last_modified = float(ftp.voidcmd(f"MDTM {file_path}")[4:].strip()) + m = hashlib.md5() # noqa: S324 + ftp.retrbinary(f"RETR {file_path}", m.update) return {"last_modified": last_modified, "checksum": m.hexdigest()} # The file doesn't exist except Exception: diff --git a/tests/test_pipeline_files.py b/tests/test_pipeline_files.py index 47d516796..6b63dc056 100644 --- a/tests/test_pipeline_files.py +++ b/tests/test_pipeline_files.py @@ -61,20 +61,20 @@ def get_ftp_content_and_delete( password: str, use_active_mode: bool = False, ) -> bytes: - ftp = FTP() - ftp.connect(host, port) - ftp.login(username, password) - if use_active_mode: - ftp.set_pasv(False) - ftp_data: list[bytes] = [] + with FTP() as ftp: + ftp.connect(host, port) + ftp.login(username, password) + if use_active_mode: + ftp.set_pasv(False) + ftp_data: list[bytes] = [] - def buffer_data(data: bytes) -> None: - ftp_data.append(data) + def buffer_data(data: bytes) -> None: + ftp_data.append(data) - ftp.retrbinary(f"RETR {path}", buffer_data) - dirname, filename = split(path) - ftp.cwd(dirname) - ftp.delete(filename) + ftp.retrbinary(f"RETR {path}", buffer_data) + dirname, filename = split(path) + ftp.cwd(dirname) + ftp.delete(filename) return b"".join(ftp_data) From 6e0a0e476a0957524f00ccc04d8d510518933e32 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Fri, 13 Feb 2026 20:12:57 +0300 Subject: [PATCH 056/248] Fix running tests with -n auto, enable it on CI (#7257) * Make test_start_deprecated_super() more robust. * Work around the FTPFilesStore.FTP_* initialization. * Enable xdist on CI. * Add PYTEST_ADDOPTS to tox passenv. --- .github/workflows/tests-macos.yml | 2 ++ .github/workflows/tests-ubuntu.yml | 2 ++ .github/workflows/tests-windows.yml | 2 ++ tests/test_pipeline_files.py | 4 ++++ tests/test_spider_start.py | 14 ++++++++++---- tox.ini | 1 + 6 files changed, 21 insertions(+), 4 deletions(-) diff --git a/.github/workflows/tests-macos.yml b/.github/workflows/tests-macos.yml index 4d199c960..3adf3de38 100644 --- a/.github/workflows/tests-macos.yml +++ b/.github/workflows/tests-macos.yml @@ -13,6 +13,8 @@ concurrency: jobs: tests: runs-on: macos-latest + env: + PYTEST_ADDOPTS: -n auto strategy: fail-fast: false matrix: diff --git a/.github/workflows/tests-ubuntu.yml b/.github/workflows/tests-ubuntu.yml index 585cdd307..c07cd49f0 100644 --- a/.github/workflows/tests-ubuntu.yml +++ b/.github/workflows/tests-ubuntu.yml @@ -13,6 +13,8 @@ concurrency: jobs: tests: runs-on: ubuntu-latest + env: + PYTEST_ADDOPTS: -n auto strategy: fail-fast: false matrix: diff --git a/.github/workflows/tests-windows.yml b/.github/workflows/tests-windows.yml index 9b03b594d..48aa56e15 100644 --- a/.github/workflows/tests-windows.yml +++ b/.github/workflows/tests-windows.yml @@ -13,6 +13,8 @@ concurrency: jobs: tests: runs-on: windows-latest + env: + PYTEST_ADDOPTS: -n auto strategy: fail-fast: false matrix: diff --git a/tests/test_pipeline_files.py b/tests/test_pipeline_files.py index 6b63dc056..7f5060701 100644 --- a/tests/test_pipeline_files.py +++ b/tests/test_pipeline_files.py @@ -700,6 +700,10 @@ class TestFTPFileStore: meta = {"foo": "bar"} path = "full/filename" with MockFTPServer() as ftp_server: + # normally set via FilesPipeline.from_crawler() + FTPFilesStore.FTP_USERNAME = "anonymous" + FTPFilesStore.FTP_PASSWORD = "guest" + store = FTPFilesStore(ftp_server.url("/")) empty_dict = yield store.stat_file(path, info=None) assert empty_dict == {} diff --git a/tests/test_spider_start.py b/tests/test_spider_start.py index 8ca588978..4e359fd33 100644 --- a/tests/test_spider_start.py +++ b/tests/test_spider_start.py @@ -1,5 +1,6 @@ from __future__ import annotations +import re import warnings from asyncio import sleep from typing import Any @@ -140,11 +141,16 @@ class TestMain: for item_or_request in super().start_requests(): yield item_or_request - with pytest.warns( - ScrapyDeprecationWarning, match=r"use Spider\.start\(\) instead" - ) as messages: + msg = "use Spider.start() instead" + with pytest.warns(ScrapyDeprecationWarning, match=re.escape(msg)) as ws: await self._test_spider(TestSpider, []) - assert messages[0].filename.endswith("test_spider_start.py") + + for w in ws: + if isinstance(w.message, ScrapyDeprecationWarning) and msg in str( + w.message + ): + assert w.filename.endswith("test_spider_start.py") + break async def _test_start(self, start_, expected_items=None): class TestSpider(Spider): diff --git a/tox.ini b/tox.ini index 3be564502..213ecbef6 100644 --- a/tox.ini +++ b/tox.ini @@ -26,6 +26,7 @@ deps = {[test-requirements]deps} pytest >= 8.4.1 # https://github.com/pytest-dev/pytest/pull/13502 passenv = + PYTEST_ADDOPTS S3_TEST_FILE_URI AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY From ccfa052fa19f712355fb17b863e8ff77f34ff3ac Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Fri, 13 Feb 2026 21:08:06 +0300 Subject: [PATCH 057/248] Enable in-process HTTP tests without a reactor. (#7254) --- conftest.py | 5 +--- pyproject.toml | 1 - scrapy/utils/test.py | 13 +++++++--- tests/spiders.py | 18 ++++++-------- tests/test_closespider.py | 3 --- tests/test_contracts.py | 1 - tests/test_core_scraper.py | 5 ++-- tests/test_crawl.py | 27 +++++++++++++++------ tests/test_downloader_handlers.py | 9 ++++--- tests/test_downloaderslotssettings.py | 2 -- tests/test_engine.py | 4 --- tests/test_engine_loop.py | 1 - tests/test_engine_stop_download_bytes.py | 5 ++-- tests/test_engine_stop_download_headers.py | 5 ++-- tests/test_feedexport.py | 4 +-- tests/test_logformatter.py | 1 - tests/test_pipeline_crawl.py | 1 - tests/test_pipelines.py | 4 --- tests/test_proxy_connect.py | 1 - tests/test_request_attribute_binding.py | 2 -- tests/test_request_cb_kwargs.py | 2 -- tests/test_scheduler.py | 1 - tests/test_scheduler_base.py | 1 - tests/test_signals.py | 1 - tests/test_spidermiddleware_httperror.py | 1 - tests/test_spidermiddleware_output_chain.py | 2 -- tox.ini | 2 ++ 27 files changed, 51 insertions(+), 71 deletions(-) diff --git a/conftest.py b/conftest.py index 26e0434ba..d49901a7c 100644 --- a/conftest.py +++ b/conftest.py @@ -93,10 +93,7 @@ def pytest_runtest_setup(item): # Skip tests based on reactor markers reactor = item.config.getoption("--reactor") - if ( - item.get_closest_marker("requires_reactor") - or item.get_closest_marker("requires_http_handler") - ) and reactor == "none": + if item.get_closest_marker("requires_reactor") and reactor == "none": pytest.skip('This test is only run when the --reactor value is not "none"') if item.get_closest_marker("only_asyncio") and reactor not in {"asyncio", "none"}: diff --git a/pyproject.toml b/pyproject.toml index 22e3f7b22..7cb83c483 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -230,7 +230,6 @@ markers = [ "only_asyncio: marks tests that require the asyncio loop to be used", "only_not_asyncio: marks tests that require the asyncio loop to not be used", "requires_reactor: marks tests that require a reactor", - "requires_http_handler: marks tests that require a HTTP handler", "requires_uvloop: marks tests as only enabled when uvloop is known to be working", "requires_botocore: marks tests that need botocore (but not boto3)", "requires_boto3: marks tests that need botocore and boto3", diff --git a/scrapy/utils/test.py b/scrapy/utils/test.py index e60ae18db..75f357d50 100644 --- a/scrapy/utils/test.py +++ b/scrapy/utils/test.py @@ -131,8 +131,8 @@ def get_reactor_settings() -> dict[str, Any]: settings["TWISTED_ENABLED"] = False settings["DOWNLOAD_HANDLERS"] = { "ftp": None, - "http": None, - "https": None, + "http": "scrapy.core.downloader.handlers._httpx.HttpxDownloadHandler", + "https": "scrapy.core.downloader.handlers._httpx.HttpxDownloadHandler", } return settings @@ -208,7 +208,14 @@ def mock_google_cloud_storage() -> tuple[Any, Any, Any]: # pragma: no cover return (client_mock, bucket_mock, blob_mock) -def get_web_client_agent_req(url: str) -> Deferred[TxResponse]: +def get_web_client_agent_req(url: str) -> Deferred[TxResponse]: # pragma: no cover + warnings.warn( + "The get_web_client_agent_req() function is deprecated" + " and will be removed in a future version of Scrapy.", + category=ScrapyDeprecationWarning, + stacklevel=2, + ) + from twisted.internet import reactor agent = Agent(reactor) diff --git a/tests/spiders.py b/tests/spiders.py index de2e64242..79565738d 100644 --- a/tests/spiders.py +++ b/tests/spiders.py @@ -18,7 +18,7 @@ from scrapy.linkextractors import LinkExtractor from scrapy.spiders import Spider from scrapy.spiders.crawl import CrawlSpider, Rule from scrapy.utils.defer import deferred_to_future, maybe_deferred_to_future -from scrapy.utils.test import get_from_asyncio_queue, get_web_client_agent_req +from scrapy.utils.test import get_from_asyncio_queue class MockServerSpider(Spider): @@ -199,28 +199,24 @@ class AsyncDefDeferredDirectSpider(SimpleSpider): name = "asyncdef_deferred_direct" async def parse(self, response): - resp = await get_web_client_agent_req(self.mockserver.url("/status?n=200")) - yield {"code": resp.code} + await defer.succeed(None) + yield {"code": 200} class AsyncDefDeferredWrappedSpider(SimpleSpider): name = "asyncdef_deferred_wrapped" async def parse(self, response): - resp = await deferred_to_future( - get_web_client_agent_req(self.mockserver.url("/status?n=200")) - ) - yield {"code": resp.code} + await deferred_to_future(defer.succeed(None)) + yield {"code": 200} class AsyncDefDeferredMaybeWrappedSpider(SimpleSpider): name = "asyncdef_deferred_wrapped" async def parse(self, response): - resp = await maybe_deferred_to_future( - get_web_client_agent_req(self.mockserver.url("/status?n=200")) - ) - yield {"code": resp.code} + await maybe_deferred_to_future(defer.succeed(None)) + yield {"code": 200} class AsyncDefAsyncioGenSpider(SimpleSpider): diff --git a/tests/test_closespider.py b/tests/test_closespider.py index a6fd6dd19..9a7447e16 100644 --- a/tests/test_closespider.py +++ b/tests/test_closespider.py @@ -1,5 +1,3 @@ -import pytest - from scrapy.utils.test import get_crawler from tests.mockserver.http import MockServer from tests.spiders import ( @@ -12,7 +10,6 @@ from tests.spiders import ( from tests.utils.decorators import inline_callbacks_test -@pytest.mark.requires_http_handler class TestCloseSpider: @classmethod def setup_class(cls): diff --git a/tests/test_contracts.py b/tests/test_contracts.py index f1678f912..a35ef7010 100644 --- a/tests/test_contracts.py +++ b/tests/test_contracts.py @@ -501,7 +501,6 @@ class TestContractsManager: assert not self.results.failures assert self.results.errors - @pytest.mark.requires_http_handler @inline_callbacks_test def test_same_url(self): class TestSameUrlSpider(Spider): diff --git a/tests/test_core_scraper.py b/tests/test_core_scraper.py index f4ef5ec5d..1b8ba08ab 100644 --- a/tests/test_core_scraper.py +++ b/tests/test_core_scraper.py @@ -2,17 +2,16 @@ from __future__ import annotations from typing import TYPE_CHECKING -import pytest - from scrapy.utils.test import get_crawler from tests.spiders import SimpleSpider from tests.utils.decorators import coroutine_test if TYPE_CHECKING: + import pytest + from tests.mockserver.http import MockServer -@pytest.mark.requires_http_handler @coroutine_test async def test_scraper_exception( mockserver: MockServer, diff --git a/tests/test_crawl.py b/tests/test_crawl.py index b206cfffa..7e9e029a1 100644 --- a/tests/test_crawl.py +++ b/tests/test_crawl.py @@ -14,11 +14,11 @@ from twisted.internet.ssl import Certificate from twisted.python.failure import Failure from scrapy import Spider, signals -from scrapy.crawler import CrawlerRunner +from scrapy.crawler import AsyncCrawlerRunner, CrawlerRunner from scrapy.exceptions import CloseSpider, ScrapyDeprecationWarning, StopDownload from scrapy.http import Request from scrapy.http.response import Response -from scrapy.utils.defer import maybe_deferred_to_future +from scrapy.utils.defer import ensure_awaitable, maybe_deferred_to_future from scrapy.utils.engine import format_engine_status, get_engine_status from scrapy.utils.python import to_unicode from scrapy.utils.test import get_crawler, get_reactor_settings @@ -61,7 +61,6 @@ if TYPE_CHECKING: from scrapy.statscollectors import StatsCollector -@pytest.mark.requires_http_handler # easier than marking many individual tests class TestCrawl: mockserver: MockServer @@ -402,9 +401,15 @@ with multiples lines ) assert "Got response 200" in str(log) - @inline_callbacks_test - def test_crawl_multiple(self, caplog: pytest.LogCaptureFixture): - runner = CrawlerRunner(get_reactor_settings()) + @coroutine_test + async def test_crawl_multiple(self, caplog: pytest.LogCaptureFixture) -> None: + settings_dict = get_reactor_settings() + runner_cls = ( + CrawlerRunner + if settings_dict.get("TWISTED_ENABLED", True) + else AsyncCrawlerRunner + ) + runner = runner_cls(settings_dict) runner.crawl( SimpleSpider, self.mockserver.url("/status?n=200"), @@ -417,7 +422,7 @@ with multiples lines ) with caplog.at_level(logging.DEBUG): - yield runner.join() + await ensure_awaitable(runner.join()) self._assert_retried(caplog.text) assert "Got response 200" in caplog.text @@ -429,7 +434,6 @@ with multiples lines assert "NotSupported: Unsupported URL scheme 'foo'" in caplog.text -@pytest.mark.requires_http_handler class TestCrawlSpider: mockserver: MockServer @@ -636,6 +640,11 @@ class TestCrawlSpider: yield crawler.crawl(seed=url, mockserver=self.mockserver) assert crawler.spider.meta["responses"][0].certificate is None + @pytest.mark.xfail( + 'config.getoption("--reactor") == "none"', + reason="Not implemented in HttpxDownloadHandler", + strict=True, + ) @pytest.mark.parametrize( "url", [ @@ -643,6 +652,7 @@ class TestCrawlSpider: pytest.param( "/status?n=200", marks=pytest.mark.xfail( + 'config.getoption("--reactor") != "none"', reason="With HTTP11DownloadHandler, responses with no body are returned early and contain no certificate", strict=True, ), @@ -669,6 +679,7 @@ class TestCrawlSpider: pytest.param( "/status?n=200", marks=pytest.mark.xfail( + 'config.getoption("--reactor") != "none"', reason="With HTTP11DownloadHandler, responses with no body are returned early and contain no ip_address", strict=True, ), diff --git a/tests/test_downloader_handlers.py b/tests/test_downloader_handlers.py index 4589e667f..a8e635707 100644 --- a/tests/test_downloader_handlers.py +++ b/tests/test_downloader_handlers.py @@ -93,10 +93,11 @@ class TestLoad: crawler = get_crawler(settings_dict={"DOWNLOAD_HANDLERS": handlers}) dh = DownloadHandlers(crawler) assert "scheme" not in dh._schemes - for scheme in handlers: # force load handlers - dh._get_handler(scheme) + assert dh._get_handler("scheme") is None assert "scheme" not in dh._handlers assert "scheme" in dh._notconfigured + # get the handler again to cover the code that gets it from dh._notconfigured + assert dh._get_handler("scheme") is None def test_lazy_handlers(self): handlers = {"scheme": DummyLazyDH} @@ -108,8 +109,8 @@ class TestLoad: dh = DownloadHandlers(crawler) assert "scheme" in dh._schemes assert "scheme" not in dh._handlers - for scheme in handlers: # force load lazy handler - dh._get_handler(scheme) + handler = dh._get_handler("scheme") # force load lazy handler + assert handler assert "scheme" in dh._handlers assert "scheme" not in dh._notconfigured diff --git a/tests/test_downloaderslotssettings.py b/tests/test_downloaderslotssettings.py index 5ddb1cdfa..5c31f68bc 100644 --- a/tests/test_downloaderslotssettings.py +++ b/tests/test_downloaderslotssettings.py @@ -67,7 +67,6 @@ class TestCrawl: def setup_method(self): self.runner = CrawlerRunner() - @pytest.mark.requires_http_handler @inline_callbacks_test def test_delay(self): crawler = get_crawler(DownloaderSlotsSettingsTestSpider) @@ -129,7 +128,6 @@ def test_get_slot_deprecated_spider_arg(): assert slot1 == slot2 -@pytest.mark.requires_http_handler @pytest.mark.parametrize( "priority_queue_class", [ diff --git a/tests/test_engine.py b/tests/test_engine.py index c77e3c5c9..4573c5001 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -373,7 +373,6 @@ class TestEngineBase: class TestEngine(TestEngineBase): - @pytest.mark.requires_http_handler @coroutine_test async def test_crawler(self, mockserver: MockServer) -> None: for spider in ( @@ -391,7 +390,6 @@ class TestEngine(TestEngineBase): self._assert_signals_caught(run) self._assert_bytes_received(run) - @pytest.mark.requires_http_handler @coroutine_test async def test_crawler_dupefilter(self, mockserver: MockServer) -> None: run = CrawlerRun(DupeFilterSpider) @@ -399,14 +397,12 @@ class TestEngine(TestEngineBase): self._assert_scheduled_requests(run, count=8) self._assert_dropped_requests(run) - @pytest.mark.requires_http_handler @coroutine_test async def test_crawler_itemerror(self, mockserver: MockServer) -> None: run = CrawlerRun(ItemZeroDivisionErrorSpider) await run.run(mockserver) self._assert_items_error(run) - @pytest.mark.requires_http_handler @coroutine_test async def test_crawler_change_close_reason_on_idle( self, mockserver: MockServer diff --git a/tests/test_engine_loop.py b/tests/test_engine_loop.py index 9bdbfcc56..115deb821 100644 --- a/tests/test_engine_loop.py +++ b/tests/test_engine_loop.py @@ -332,7 +332,6 @@ class TestRequestSendOrder: # Examples from the “Start requests” section of the documentation about # spiders. - @pytest.mark.requires_http_handler @coroutine_test async def test_lazy(self): start_nums = [1, 2, 4] diff --git a/tests/test_engine_stop_download_bytes.py b/tests/test_engine_stop_download_bytes.py index 970fffbc9..091485781 100644 --- a/tests/test_engine_stop_download_bytes.py +++ b/tests/test_engine_stop_download_bytes.py @@ -2,8 +2,6 @@ from __future__ import annotations from typing import TYPE_CHECKING -import pytest - from scrapy.exceptions import StopDownload from tests.test_engine import ( AttrsItemsSpider, @@ -16,6 +14,8 @@ from tests.test_engine import ( from tests.utils.decorators import coroutine_test if TYPE_CHECKING: + import pytest + from tests.mockserver.http import MockServer @@ -26,7 +26,6 @@ class BytesReceivedCrawlerRun(CrawlerRun): class TestBytesReceivedEngine(TestEngineBase): - @pytest.mark.requires_http_handler @coroutine_test async def test_crawler( self, mockserver: MockServer, caplog: pytest.LogCaptureFixture diff --git a/tests/test_engine_stop_download_headers.py b/tests/test_engine_stop_download_headers.py index e0008a53a..8ebc948e2 100644 --- a/tests/test_engine_stop_download_headers.py +++ b/tests/test_engine_stop_download_headers.py @@ -2,8 +2,6 @@ from __future__ import annotations from typing import TYPE_CHECKING -import pytest - from scrapy.exceptions import StopDownload from tests.test_engine import ( AttrsItemsSpider, @@ -16,6 +14,8 @@ from tests.test_engine import ( from tests.utils.decorators import coroutine_test if TYPE_CHECKING: + import pytest + from tests.mockserver.http import MockServer @@ -26,7 +26,6 @@ class HeadersReceivedCrawlerRun(CrawlerRun): class TestHeadersReceivedEngine(TestEngineBase): - @pytest.mark.requires_http_handler @coroutine_test async def test_crawler( self, mockserver: MockServer, caplog: pytest.LogCaptureFixture diff --git a/tests/test_feedexport.py b/tests/test_feedexport.py index 3a9c37042..1ca5f21f0 100644 --- a/tests/test_feedexport.py +++ b/tests/test_feedexport.py @@ -849,7 +849,6 @@ class ExceptionJsonItemExporter(JsonItemExporter): raise RuntimeError("foo") -@pytest.mark.requires_http_handler class TestFeedExport(TestFeedExportBase): async def run_and_export( self, spider_cls: type[Spider], settings: dict[str, Any] @@ -1703,6 +1702,7 @@ class TestFeedExport(TestFeedExportBase): data = await self.exported_no_data(settings) assert data["csv"] == b"" + @pytest.mark.requires_reactor # needs a reactor for BlockingFeedStorage @coroutine_test async def test_multiple_feeds_success_logs_blocking_feed_storage(self): settings = { @@ -1834,7 +1834,6 @@ class TestFeedExport(TestFeedExportBase): assert not Storage.file_was_closed -@pytest.mark.requires_http_handler class TestFeedPostProcessedExports(TestFeedExportBase): items = [{"foo": "bar"}] expected = b"foo\r\nbar\r\n" @@ -2353,7 +2352,6 @@ class TestFeedPostProcessedExports(TestFeedExportBase): assert result == expected -@pytest.mark.requires_http_handler class TestBatchDeliveries(TestFeedExportBase): _file_mark = "_%(batch_time)s_#%(batch_id)02d_" diff --git a/tests/test_logformatter.py b/tests/test_logformatter.py index 66544a508..9806315b4 100644 --- a/tests/test_logformatter.py +++ b/tests/test_logformatter.py @@ -253,7 +253,6 @@ class DropSomeItemsPipeline: self.drop = True -@pytest.mark.requires_http_handler class TestShowOrSkipMessages: @classmethod def setup_class(cls): diff --git a/tests/test_pipeline_crawl.py b/tests/test_pipeline_crawl.py index acc07f48b..0376d27c4 100644 --- a/tests/test_pipeline_crawl.py +++ b/tests/test_pipeline_crawl.py @@ -57,7 +57,6 @@ class RedirectedMediaDownloadSpider(MediaDownloadSpider): ) -@pytest.mark.requires_http_handler class TestFileDownloadCrawl: pipeline_class = "scrapy.pipelines.files.FilesPipeline" store_setting_key = "FILES_STORE" diff --git a/tests/test_pipelines.py b/tests/test_pipelines.py index fb1fccfc7..753fcd42e 100644 --- a/tests/test_pipelines.py +++ b/tests/test_pipelines.py @@ -127,7 +127,6 @@ class ItemSpider(Spider): return {"field": 42} -@pytest.mark.requires_http_handler class TestPipeline: def _on_item_scraped(self, item): assert isinstance(item, dict) @@ -266,7 +265,6 @@ class TestCustomPipelineManager: ): itemproc.process_item({}, crawler.spider) - @pytest.mark.requires_http_handler @coroutine_test async def test_integration_recommended(self, mockserver: MockServer) -> None: class CustomPipelineManager(ItemPipelineManager): @@ -293,7 +291,6 @@ class TestCustomPipelineManager: assert len(items) == 1 - @pytest.mark.requires_http_handler @coroutine_test async def test_integration_no_async_subclass(self, mockserver: MockServer) -> None: class CustomPipelineManager(ItemPipelineManager): @@ -352,7 +349,6 @@ class TestCustomPipelineManager: assert len(items) == 1 - @pytest.mark.requires_http_handler @coroutine_test async def test_integration_no_async_not_subclass( self, mockserver: MockServer diff --git a/tests/test_proxy_connect.py b/tests/test_proxy_connect.py index c78c882cf..912075401 100644 --- a/tests/test_proxy_connect.py +++ b/tests/test_proxy_connect.py @@ -61,7 +61,6 @@ def _wrong_credentials(proxy_url): return urlunsplit(bad_auth_proxy) -@pytest.mark.requires_http_handler @pytest.mark.requires_mitmproxy class TestProxyConnect: @classmethod diff --git a/tests/test_request_attribute_binding.py b/tests/test_request_attribute_binding.py index 66dd48737..38d56e9bd 100644 --- a/tests/test_request_attribute_binding.py +++ b/tests/test_request_attribute_binding.py @@ -1,4 +1,3 @@ -import pytest from testfixtures import LogCapture from scrapy import Request, signals @@ -63,7 +62,6 @@ class AlternativeCallbacksMiddleware: return response.replace(request=new_request) -@pytest.mark.requires_http_handler class TestCrawl: @classmethod def setup_class(cls): diff --git a/tests/test_request_cb_kwargs.py b/tests/test_request_cb_kwargs.py index c8629df00..8d3977452 100644 --- a/tests/test_request_cb_kwargs.py +++ b/tests/test_request_cb_kwargs.py @@ -1,4 +1,3 @@ -import pytest from testfixtures import LogCapture from scrapy.http import Request @@ -149,7 +148,6 @@ class KeywordArgumentsSpider(MockServerSpider): self.crawler.stats.inc_value("boolean_checks", 1) -@pytest.mark.requires_http_handler class TestCallbackKeywordArguments: @classmethod def setup_class(cls): diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index 873be0b8f..0d2d8be93 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -369,7 +369,6 @@ class TestIntegrationWithDownloaderAwareInMemory: }, ) - @pytest.mark.requires_http_handler @inline_callbacks_test def test_integration_downloader_aware_priority_queue(self): with MockServer() as mockserver: diff --git a/tests/test_scheduler_base.py b/tests/test_scheduler_base.py index 6234bef86..176fefbbf 100644 --- a/tests/test_scheduler_base.py +++ b/tests/test_scheduler_base.py @@ -144,7 +144,6 @@ class TestSimpleScheduler(InterfaceCheckMixin): assert close_result == "close" -@pytest.mark.requires_http_handler class TestMinimalSchedulerCrawl: scheduler_cls = MinimalScheduler diff --git a/tests/test_signals.py b/tests/test_signals.py index faf24a129..f5f13b992 100644 --- a/tests/test_signals.py +++ b/tests/test_signals.py @@ -50,7 +50,6 @@ class TestMockServer: item = await get_from_asyncio_queue(item) self.items.append(item) - @pytest.mark.requires_http_handler @pytest.mark.only_asyncio @inline_callbacks_test def test_simple_pipeline(self): diff --git a/tests/test_spidermiddleware_httperror.py b/tests/test_spidermiddleware_httperror.py index 8dc6ff300..b15f68dd0 100644 --- a/tests/test_spidermiddleware_httperror.py +++ b/tests/test_spidermiddleware_httperror.py @@ -191,7 +191,6 @@ class TestHttpErrorMiddlewareHandleAll: mw.process_spider_input(res402) -@pytest.mark.requires_http_handler class TestHttpErrorMiddlewareIntegrational: @classmethod def setup_class(cls): diff --git a/tests/test_spidermiddleware_output_chain.py b/tests/test_spidermiddleware_output_chain.py index 80f5828c9..8cf08dd94 100644 --- a/tests/test_spidermiddleware_output_chain.py +++ b/tests/test_spidermiddleware_output_chain.py @@ -1,4 +1,3 @@ -import pytest from testfixtures import LogCapture from scrapy import Request, Spider @@ -319,7 +318,6 @@ class NotGeneratorOutputChainSpider(Spider): # ================================================================================ -@pytest.mark.requires_http_handler class TestSpiderMiddleware: mockserver: MockServer diff --git a/tox.ini b/tox.ini index 213ecbef6..7656cb017 100644 --- a/tox.ini +++ b/tox.ini @@ -168,6 +168,7 @@ commands = [testenv:no-reactor] deps = {[testenv]deps} + httpx pytest-asyncio commands = {[testenv]commands} -p no:twisted --reactor=none @@ -183,6 +184,7 @@ setenv = basepython = {[pinned]basepython} deps = {[testenv:pinned]deps} + httpx==0.26.0 pytest-asyncio commands = {[pinned]commands} -p no:twisted --reactor=none setenv = From 3fe89a211be02ebecabfabdede90c40f142bd583 Mon Sep 17 00:00:00 2001 From: pierreeurope Date: Thu, 19 Feb 2026 11:21:59 +0100 Subject: [PATCH 058/248] Fix open_spider exception handling to set exit code 1 (#7255) --- scrapy/crawler.py | 16 ++++++++++++++-- .../test_cmdline_crawl_with_pipeline/__init__.py | 12 +++--------- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/scrapy/crawler.py b/scrapy/crawler.py index 33c2bb44c..95cd04b51 100644 --- a/scrapy/crawler.py +++ b/scrapy/crawler.py @@ -439,12 +439,16 @@ class CrawlerRunner(CrawlerRunnerBase): self.crawlers.add(crawler) d = crawler.crawl(*args, **kwargs) self._active.add(d) + failed = False try: yield d + except Exception: + failed = True + raise finally: self.crawlers.discard(crawler) self._active.discard(d) - self.bootstrap_failed |= not getattr(crawler, "spider", None) + self.bootstrap_failed |= not getattr(crawler, "spider", None) or failed def stop(self) -> Deferred[Any]: """ @@ -538,7 +542,15 @@ class AsyncCrawlerRunner(CrawlerRunnerBase): # or by AsyncCrawlerProcess (but it isn't running yet, so no asyncio.create_task()). loop = asyncio.get_event_loop() self.crawlers.add(crawler) - task = loop.create_task(crawler.crawl_async(*args, **kwargs)) + + async def _crawl_and_track() -> None: + try: + await crawler.crawl_async(*args, **kwargs) + except Exception: + self.bootstrap_failed = True + raise # re-raise so asyncio still logs it to stderr naturally + + task = loop.create_task(_crawl_and_track()) self._active.add(task) def _done(_: asyncio.Task[None]) -> None: diff --git a/tests/test_cmdline_crawl_with_pipeline/__init__.py b/tests/test_cmdline_crawl_with_pipeline/__init__.py index 2ad8fb8a4..f17543575 100644 --- a/tests/test_cmdline_crawl_with_pipeline/__init__.py +++ b/tests/test_cmdline_crawl_with_pipeline/__init__.py @@ -2,8 +2,6 @@ import sys from pathlib import Path from subprocess import PIPE, Popen -from tests import TWISTED_KEEPS_TRACEBACKS - class TestCmdlineCrawlPipeline: def _execute(self, spname): @@ -18,10 +16,6 @@ class TestCmdlineCrawlPipeline: assert returncode == 0 def test_exception_at_open_spider_in_pipeline(self): - returncode, stderr = self._execute("exception") - # An unhandled exception in a pipeline should not stop the crawl - assert returncode == 0 - if TWISTED_KEEPS_TRACEBACKS: - assert b'RuntimeError("exception")' in stderr - else: - assert b"RuntimeError: exception" in stderr + returncode, _ = self._execute("exception") + # An exception in pipeline's open_spider should result in a non-zero exit code + assert returncode == 1 From da1a6b7ebca5fc7d35458d821c93331e9080e993 Mon Sep 17 00:00:00 2001 From: Yuval Elbar <41901908+YuvalElbar6@users.noreply.github.com> Date: Fri, 20 Feb 2026 16:36:26 +0200 Subject: [PATCH 059/248] Add security warning about pickle files in JOBDIR (#7274) * Add security warning about pickle files in JOBDIR The job directory contains files serialized with pickle (spider.state, request queues), which can execute arbitrary code when loaded. Add a warning so users know to treat JOBDIR with the same trust level as their project code. Co-Authored-By: Claude Opus 4.6 * Make JOBDIR security warning more concise and accurate Simplify the warning to focus on treating the job directory as trusted, without mentioning specific serialization details. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- docs/topics/jobs.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/topics/jobs.rst b/docs/topics/jobs.rst index 2a976e91d..769925dd5 100644 --- a/docs/topics/jobs.rst +++ b/docs/topics/jobs.rst @@ -31,6 +31,10 @@ job (i.e. a spider run), so that if stopped cleanly, it can be resumed later. .. warning:: This directory must *not* be shared by different spiders, or even different jobs of the same spider. +.. warning:: Treat the job directory with the same security care as your + Scrapy project source code. Do not point ``JOBDIR`` to a path that + untrusted parties can write to. + See also :ref:`job-dir-contents`. How to use it From abd025f78eead2e7f3c397f88424f6270672cd40 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Mon, 23 Feb 2026 15:34:56 +0500 Subject: [PATCH 060/248] Pass correct env to PopenSpawn() in tests. (#7279) --- tests/test_crawler.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_crawler.py b/tests/test_crawler.py index ba33d0bbe..9ade8472f 100644 --- a/tests/test_crawler.py +++ b/tests/test_crawler.py @@ -951,7 +951,7 @@ class TestCrawlerProcessSubprocessBase(ScriptRunnerMixin): def test_shutdown_graceful(self): sig = signal.SIGINT if sys.platform != "win32" else signal.SIGBREAK args = self.get_script_args("sleeping.py", "3") - p = PopenSpawn(args, timeout=5) + p = PopenSpawn(args, timeout=5, env=get_script_run_env()) p.expect_exact("Spider opened") p.expect_exact("Crawled (200)") p.kill(sig) @@ -963,7 +963,7 @@ class TestCrawlerProcessSubprocessBase(ScriptRunnerMixin): def test_shutdown_forced(self): sig = signal.SIGINT if sys.platform != "win32" else signal.SIGBREAK args = self.get_script_args("sleeping.py", "10") - p = PopenSpawn(args, timeout=5) + p = PopenSpawn(args, timeout=5, env=get_script_run_env()) p.expect_exact("Spider opened") p.expect_exact("Crawled (200)") p.kill(sig) From 7010985e4f37150834bd25f5c7820b40df3399b4 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Mon, 23 Feb 2026 15:48:38 +0500 Subject: [PATCH 061/248] Bump ruff, fix some of rules (#7277) --- .pre-commit-config.yaml | 2 +- pyproject.toml | 41 ++++++++----------- scrapy/core/downloader/__init__.py | 4 +- scrapy/core/downloader/contextfactory.py | 6 +-- scrapy/core/downloader/handlers/file.py | 2 +- scrapy/core/engine.py | 4 +- scrapy/core/http2/agent.py | 4 +- scrapy/core/spidermw.py | 2 +- scrapy/http/request/__init__.py | 10 ++--- scrapy/linkextractors/lxmlhtml.py | 2 +- scrapy/responsetypes.py | 2 +- scrapy/shell.py | 1 - scrapy/spidermiddlewares/referer.py | 2 +- scrapy/utils/defer.py | 2 +- scrapy/utils/engine.py | 2 +- scrapy/utils/log.py | 4 +- scrapy/utils/reactor.py | 2 +- scrapy/utils/signal.py | 6 +-- tests/mockserver/http_resources.py | 2 +- tests/spiders.py | 2 +- tests/test_downloadermiddleware_retry.py | 2 +- tests/test_exporters.py | 4 +- tests/test_extension_periodic_log.py | 18 +++++--- tests/test_item.py | 2 +- tests/test_link.py | 2 +- tests/test_pipeline_files.py | 6 +-- tests/test_pipeline_images.py | 4 +- ...t_return_with_argument_inside_generator.py | 2 +- tests/test_utils_spider.py | 2 +- tests/test_webclient.py | 2 +- 30 files changed, 70 insertions(+), 76 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index dc4276cff..a2bf1be0c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -6,7 +6,7 @@ exclude: | ) repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.14.2 + rev: v0.15.2 hooks: - id: ruff-check args: [ --fix ] diff --git a/pyproject.toml b/pyproject.toml index 7cb83c483..e4fae8bf4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -174,7 +174,6 @@ disable = [ "disallowed-name", "duplicate-code", # https://github.com/pylint-dev/pylint/issues/214 "fixme", - "import-outside-toplevel", "inherit-non-class", # false positives with create_deprecated_class() "invalid-name", "invalid-overridden-method", @@ -188,12 +187,10 @@ disable = [ "no-value-for-parameter", # https://github.com/pylint-dev/pylint/issues/3268 "not-callable", "protected-access", - "redefined-builtin", "redefined-outer-name", "too-few-public-methods", "too-many-ancestors", "too-many-arguments", - "too-many-branches", "too-many-function-args", "too-many-instance-attributes", "too-many-lines", @@ -202,12 +199,22 @@ disable = [ "too-many-public-methods", "too-many-return-statements", "unused-argument", - "unused-import", "unused-variable", "useless-import-alias", # used as a hint to mypy "useless-return", # https://github.com/pylint-dev/pylint/issues/6530 "wrong-import-position", + # Ones that are implemented in ruff and need to be disabled for some lines (listed here to avoid two disable comments) + "bare-except", + "eval-used", + "global-statement", + "import-outside-toplevel", + "import-self", + "inconsistent-return-statements", + "redefined-builtin", + "too-many-branches", + "unused-import", + # Ones that we may want to address (fix, ignore per-line or move to "don't want to fix") "abstract-method", "arguments-differ", @@ -345,34 +352,22 @@ ignore = [ "D403", # `try`-`except` within a loop incurs performance overhead "PERF203", - # Import alias does not rename original package - "PLC0414", # Too many return statements "PLR0911", - # Too many branches - "PLR0912", # Too many arguments in function definition "PLR0913", - # Too many statements - "PLR0915", # Magic value used in comparison "PLR2004", - # `for` loop variable overwritten by assignment target - "PLW2901", # String contains ambiguous {}. "RUF001", # Docstring contains ambiguous {}. "RUF002", # Comment contains ambiguous {}. "RUF003", - # Mutable class attributes should be annotated with `typing.ClassVar` - "RUF012", # Use of `assert` detected; needed for mypy "S101", # FTP-related functions are being called; https://github.com/scrapy/scrapy/issues/4180 "S321", - # Argument default set to insecure SSL protocol - "S503", # Use a context manager for opening files "SIM115", # Yoda condition detected @@ -384,18 +379,16 @@ ignore = [ "B003", # Do not use mutable data structures for argument defaults. "B006", - # Loop control variable not used within the loop body. - "B007", - # Do not perform function calls in argument defaults. - "B008", # Found useless expression. "B018", - # Star-arg unpacking after a keyword argument is strongly discouraged. - "B026", # No explicit stacklevel argument found. "B028", # Within an `except` clause, raise exceptions with `raise ... from` "B904", + # `for` loop variable overwritten by assignment target + "PLW2901", + # Mutable class attributes should be annotated with `typing.ClassVar` + "RUF012", # Use capitalized environment variable "SIM112", ] @@ -417,8 +410,8 @@ split-on-trailing-comma = false "scrapy/linkextractors/__init__.py" = ["E402"] "scrapy/spiders/__init__.py" = ["E402"] -# Skip bandit in tests -"tests/**" = ["S"] +# Skip bandit and allow blocking file I/O in tests +"tests/**" = ["ASYNC240", "S"] # Issues pending a review: "docs/conf.py" = ["E402"] diff --git a/scrapy/core/downloader/__init__.py b/scrapy/core/downloader/__init__.py index c862ecdf9..59871fdc3 100644 --- a/scrapy/core/downloader/__init__.py +++ b/scrapy/core/downloader/__init__.py @@ -157,9 +157,7 @@ class Downloader: if key not in self.slots: assert self.crawler.spider slot_settings = self.per_slot_settings.get(key, {}) - conc = ( - self.ip_concurrency if self.ip_concurrency else self.domain_concurrency - ) + conc = self.ip_concurrency or self.domain_concurrency conc, delay = _get_concurrency_delay( conc, self.crawler.spider, self.settings ) diff --git a/scrapy/core/downloader/contextfactory.py b/scrapy/core/downloader/contextfactory.py index 5f7c9a8c3..252f509bf 100644 --- a/scrapy/core/downloader/contextfactory.py +++ b/scrapy/core/downloader/contextfactory.py @@ -49,7 +49,7 @@ class ScrapyClientContextFactory(BrowserLikePolicyForHTTPS): def __init__( self, - method: int = SSL.SSLv23_METHOD, + method: int = SSL.SSLv23_METHOD, # noqa: S503 tls_verbose_logging: bool = False, tls_ciphers: str | None = None, *args: Any, @@ -75,7 +75,7 @@ class ScrapyClientContextFactory(BrowserLikePolicyForHTTPS): def from_crawler( cls, crawler: Crawler, - method: int = SSL.SSLv23_METHOD, + method: int = SSL.SSLv23_METHOD, # noqa: S503 *args: Any, **kwargs: Any, ) -> Self: @@ -84,10 +84,10 @@ class ScrapyClientContextFactory(BrowserLikePolicyForHTTPS): ) tls_ciphers: str | None = crawler.settings["DOWNLOADER_CLIENT_TLS_CIPHERS"] return cls( # type: ignore[misc] + *args, method=method, tls_verbose_logging=tls_verbose_logging, tls_ciphers=tls_ciphers, - *args, **kwargs, ) diff --git a/scrapy/core/downloader/handlers/file.py b/scrapy/core/downloader/handlers/file.py index 21fd2c353..a59aa722b 100644 --- a/scrapy/core/downloader/handlers/file.py +++ b/scrapy/core/downloader/handlers/file.py @@ -16,6 +16,6 @@ if TYPE_CHECKING: class FileDownloadHandler(BaseDownloadHandler): async def download_request(self, request: Request) -> Response: filepath = file_uri_to_path(request.url) - body = Path(filepath).read_bytes() + body = Path(filepath).read_bytes() # noqa: ASYNC240 respcls = responsetypes.from_args(filename=filepath, body=body) return respcls(url=request.url, body=body) diff --git a/scrapy/core/engine.py b/scrapy/core/engine.py index 93fc64cdc..1555fc0d6 100644 --- a/scrapy/core/engine.py +++ b/scrapy/core/engine.py @@ -439,7 +439,7 @@ class ExecutionEngine: spider=self.spider, dont_log=IgnoreRequest, ) - for handler, result in request_scheduled_result: + for _, result in request_scheduled_result: if isinstance(result, Failure) and isinstance(result.value, IgnoreRequest): return if not self._slot.scheduler.enqueue_request(request): # type: ignore[union-attr] @@ -587,7 +587,7 @@ class ExecutionEngine: ) return deferred_from_coro(self.close_spider_async(reason=reason)) - async def close_spider_async(self, *, reason: str = "cancelled") -> None: + async def close_spider_async(self, *, reason: str = "cancelled") -> None: # noqa: PLR0912 """Close (cancel) spider and clear all its outstanding requests. .. versionadded:: 2.14 diff --git a/scrapy/core/http2/agent.py b/scrapy/core/http2/agent.py index 45f32daaa..8760b13ec 100644 --- a/scrapy/core/http2/agent.py +++ b/scrapy/core/http2/agent.py @@ -120,7 +120,7 @@ class H2Agent: self, reactor: ReactorBase, pool: H2ConnectionPool, - context_factory: BrowserLikePolicyForHTTPS = BrowserLikePolicyForHTTPS(), + context_factory: BrowserLikePolicyForHTTPS = BrowserLikePolicyForHTTPS(), # noqa: B008 connect_timeout: float | None = None, bind_address: bytes | None = None, ) -> None: @@ -164,7 +164,7 @@ class ScrapyProxyH2Agent(H2Agent): reactor: ReactorBase, proxy_uri: URI, pool: H2ConnectionPool, - context_factory: BrowserLikePolicyForHTTPS = BrowserLikePolicyForHTTPS(), + context_factory: BrowserLikePolicyForHTTPS = BrowserLikePolicyForHTTPS(), # noqa: B008 connect_timeout: float | None = None, bind_address: bytes | None = None, ) -> None: diff --git a/scrapy/core/spidermw.py b/scrapy/core/spidermw.py index 6eaf84642..476d2e504 100644 --- a/scrapy/core/spidermw.py +++ b/scrapy/core/spidermw.py @@ -259,7 +259,7 @@ class SpiderMiddlewareManager(MiddlewareManager): # being available immediately which doesn't work when it's a wrapped coroutine. # It also needs @inlineCallbacks only because of downgrading so it can be removed when downgrading is removed. @inlineCallbacks - def _process_spider_output( + def _process_spider_output( # noqa: PLR0912 self, response: Response, result: Iterable[_T] | AsyncIterator[_T], diff --git a/scrapy/http/request/__init__.py b/scrapy/http/request/__init__.py index 61e50927d..c4a0c8131 100644 --- a/scrapy/http/request/__init__.py +++ b/scrapy/http/request/__init__.py @@ -200,7 +200,7 @@ class Request(object_ref): #: .. seealso:: :ref:`topics-request-response-ref-errbacks` self.errback: Callable[[Failure], Any] | None = errback - self._cookies: CookiesT | None = cookies if cookies else None + self._cookies: CookiesT | None = cookies or None self._headers: Headers | None = ( Headers(headers, encoding=encoding) if headers else None ) @@ -244,7 +244,7 @@ class Request(object_ref): @cb_kwargs.setter def cb_kwargs(self, value: dict[str, Any] | None) -> None: - self._cb_kwargs = value if value else None + self._cb_kwargs = value or None @property def meta(self) -> dict[str, Any]: @@ -254,7 +254,7 @@ class Request(object_ref): @meta.setter def meta(self, value: dict[str, Any] | None) -> None: - self._meta = value if value else None + self._meta = value or None @property def url(self) -> str: @@ -292,7 +292,7 @@ class Request(object_ref): @flags.setter def flags(self, value: list[str] | None) -> None: - self._flags = value if value else None + self._flags = value or None @property def cookies(self) -> CookiesT: @@ -302,7 +302,7 @@ class Request(object_ref): @cookies.setter def cookies(self, value: CookiesT | None) -> None: - self._cookies = value if value else None + self._cookies = value or None @property def headers(self) -> Headers: diff --git a/scrapy/linkextractors/lxmlhtml.py b/scrapy/linkextractors/lxmlhtml.py index 0ea78e35a..96a0f523b 100644 --- a/scrapy/linkextractors/lxmlhtml.py +++ b/scrapy/linkextractors/lxmlhtml.py @@ -110,7 +110,7 @@ class LxmlParserLinkExtractor: ) -> list[Link]: links: list[Link] = [] # hacky way to get the underlying lxml parsed document - for el, attr, attr_val in self._iter_links(selector.root): + for el, _, attr_val in self._iter_links(selector.root): # pseudo lxml.html.HtmlElement.make_links_absolute(base_url) try: if self.strip: diff --git a/scrapy/responsetypes.py b/scrapy/responsetypes.py index 3f6f030a5..3aaf17b53 100644 --- a/scrapy/responsetypes.py +++ b/scrapy/responsetypes.py @@ -54,7 +54,7 @@ class ResponseTypes: return Response if mimetype in self.classes: return self.classes[mimetype] - basetype = f"{mimetype.split('/')[0]}/*" + basetype = f"{mimetype.split('/', maxsplit=1)[0]}/*" return self.classes.get(basetype, Response) def from_content_type( diff --git a/scrapy/shell.py b/scrapy/shell.py index 4b2bdf6cf..00097d224 100644 --- a/scrapy/shell.py +++ b/scrapy/shell.py @@ -73,7 +73,6 @@ class Shell: else: self.populate_vars() if self.code: - # pylint: disable-next=eval-used print(eval(self.code, globals(), self.vars)) # noqa: S307 else: # Detect interactive shell setting in scrapy.cfg diff --git a/scrapy/spidermiddlewares/referer.py b/scrapy/spidermiddlewares/referer.py index e4d0f4014..ac340324a 100644 --- a/scrapy/spidermiddlewares/referer.py +++ b/scrapy/spidermiddlewares/referer.py @@ -97,7 +97,7 @@ class ReferrerPolicy(ABC): def potentially_trustworthy(self, url: str) -> bool: # Note: this does not follow https://w3c.github.io/webappsec-secure-contexts/#is-url-trustworthy parsed_url = urlparse(url) - if parsed_url.scheme in ("data",): + if parsed_url.scheme == "data": return False return self.tls_protected(url) diff --git a/scrapy/utils/defer.py b/scrapy/utils/defer.py index 5bd4506d6..0d937fea5 100644 --- a/scrapy/utils/defer.py +++ b/scrapy/utils/defer.py @@ -435,7 +435,7 @@ def _maybeDeferred_coro( """Copy of defer.maybeDeferred that also converts coroutines to Deferreds.""" try: result = f(*args, **kw) - except: # noqa: E722 # pylint: disable=bare-except + except: # noqa: E722 return fail(failure.Failure(captureVars=Deferred.debug)) # when the deprecation period has ended we need to make sure the behavior diff --git a/scrapy/utils/engine.py b/scrapy/utils/engine.py index 1e0c53212..085720f66 100644 --- a/scrapy/utils/engine.py +++ b/scrapy/utils/engine.py @@ -32,7 +32,7 @@ def get_engine_status(engine: ExecutionEngine) -> list[tuple[str, Any]]: checks: list[tuple[str, Any]] = [] for test in tests: try: - checks += [(test, eval(test))] # noqa: S307 # pylint: disable=eval-used + checks += [(test, eval(test))] # noqa: S307 except Exception as e: checks += [(test, f"{type(e).__name__} (exception)")] diff --git a/scrapy/utils/log.py b/scrapy/utils/log.py index 07a9e5ff7..5f0d19913 100644 --- a/scrapy/utils/log.py +++ b/scrapy/utils/log.py @@ -138,7 +138,7 @@ _scrapy_root_handler: logging.Handler | None = None def install_scrapy_root_handler(settings: Settings) -> None: - global _scrapy_root_handler # noqa: PLW0603 # pylint: disable=global-statement + global _scrapy_root_handler # noqa: PLW0603 _uninstall_scrapy_root_handler() logging.root.setLevel(logging.NOTSET) @@ -147,7 +147,7 @@ def install_scrapy_root_handler(settings: Settings) -> None: def _uninstall_scrapy_root_handler() -> None: - global _scrapy_root_handler # noqa: PLW0603 # pylint: disable=global-statement + global _scrapy_root_handler # noqa: PLW0603 if ( _scrapy_root_handler is not None diff --git a/scrapy/utils/reactor.py b/scrapy/utils/reactor.py index f37c48a7d..6aa72064b 100644 --- a/scrapy/utils/reactor.py +++ b/scrapy/utils/reactor.py @@ -26,7 +26,7 @@ _T = TypeVar("_T") _P = ParamSpec("_P") -def listen_tcp(portrange: list[int], host: str, factory: ServerFactory) -> Port: # type: ignore[return] # pylint: disable=inconsistent-return-statements # noqa: RET503 +def listen_tcp(portrange: list[int], host: str, factory: ServerFactory) -> Port: # type: ignore[return] # noqa: RET503 """Like reactor.listenTCP but tries different ports in a range.""" from twisted.internet import reactor diff --git a/scrapy/utils/signal.py b/scrapy/utils/signal.py index 1f7426e59..997e8eba8 100644 --- a/scrapy/utils/signal.py +++ b/scrapy/utils/signal.py @@ -50,7 +50,7 @@ def send_catch_log( result: TypingAny try: response = robustApply( - receiver, signal=signal, sender=sender, *arguments, **named + receiver, *arguments, signal=signal, sender=sender, **named ) if isinstance(response, Deferred): logger.error( @@ -118,9 +118,9 @@ def _send_catch_log_deferred( robustApply, True, receiver, + *arguments, signal=signal, sender=sender, - *arguments, **named, ) d.addErrback(logerror, receiver) @@ -190,7 +190,7 @@ async def _send_catch_log_asyncio( try: result = await ensure_awaitable( robustApply( - receiver, signal=signal, sender=sender, *arguments, **named + receiver, *arguments, signal=signal, sender=sender, **named ), _warn=global_object_name(receiver), ) diff --git a/tests/mockserver/http_resources.py b/tests/mockserver/http_resources.py index 62a3146bf..91577aa6b 100644 --- a/tests/mockserver/http_resources.py +++ b/tests/mockserver/http_resources.py @@ -282,7 +282,7 @@ class LargeChunkedFileResource(resource.Resource): from twisted.internet import reactor def response(): - for i in range(1024): + for _ in range(1024): request.write(b"x" * 1024) request.finish() diff --git a/tests/spiders.py b/tests/spiders.py index 79565738d..363a09fad 100644 --- a/tests/spiders.py +++ b/tests/spiders.py @@ -389,7 +389,7 @@ class DuplicateStartSpider(MockServerSpider): async def start(self): for i in range(self.distinct_urls): - for j in range(self.dupe_factor): + for _ in range(self.dupe_factor): url = self.mockserver.url(f"/echo?headers=1&body=test{i}") yield Request(url, dont_filter=self.dont_filter) diff --git a/tests/test_downloadermiddleware_retry.py b/tests/test_downloadermiddleware_retry.py index 20e8fc407..50946899a 100644 --- a/tests/test_downloadermiddleware_retry.py +++ b/tests/test_downloadermiddleware_retry.py @@ -250,7 +250,7 @@ class TestMaxRetryTimes: ): middleware = middleware or self.mw - for i in range(max_retry_times): + for _ in range(max_retry_times): req = middleware.process_exception(req, exception) assert isinstance(req, Request) diff --git a/tests/test_exporters.py b/tests/test_exporters.py index 5719d5bb0..2fded613d 100644 --- a/tests/test_exporters.py +++ b/tests/test_exporters.py @@ -195,9 +195,7 @@ class TestPprintItemExporter(TestBaseItemExporter): return PprintItemExporter(self.output, **kwargs) def _check_output(self): - self._assert_expected_item( - eval(self.output.getvalue()) # pylint: disable=eval-used - ) + self._assert_expected_item(eval(self.output.getvalue())) class TestPprintItemExporterDataclass(TestPprintItemExporter): diff --git a/tests/test_extension_periodic_log.py b/tests/test_extension_periodic_log.py index 4f368d5ec..0e2d4be28 100644 --- a/tests/test_extension_periodic_log.py +++ b/tests/test_extension_periodic_log.py @@ -122,8 +122,10 @@ class TestPeriodicLog: # include multiple check( {"PERIODIC_LOG_DELTA": {"include": ["downloader/", "scheduler/"]}}, - lambda k, v: isinstance(v, (int, float)) - and ("downloader/" in k or "scheduler/" in k), + lambda k, v: ( + isinstance(v, (int, float)) + and ("downloader/" in k or "scheduler/" in k) + ), ) # exclude @@ -135,15 +137,19 @@ class TestPeriodicLog: # exclude multiple check( {"PERIODIC_LOG_DELTA": {"exclude": ["downloader/", "scheduler/"]}}, - lambda k, v: isinstance(v, (int, float)) - and ("downloader/" not in k and "scheduler/" not in k), + lambda k, v: ( + isinstance(v, (int, float)) + and ("downloader/" not in k and "scheduler/" not in k) + ), ) # include exclude combined check( {"PERIODIC_LOG_DELTA": {"include": ["downloader/"], "exclude": ["bytes"]}}, - lambda k, v: isinstance(v, (int, float)) - and ("downloader/" in k and "bytes" not in k), + lambda k, v: ( + isinstance(v, (int, float)) + and ("downloader/" in k and "bytes" not in k) + ), ) @pytest.mark.requires_reactor # needs a reactor or an event loop for PeriodicLog.task diff --git a/tests/test_item.py b/tests/test_item.py index 94742bfed..4eb37a344 100644 --- a/tests/test_item.py +++ b/tests/test_item.py @@ -60,7 +60,7 @@ class TestItem: assert itemrepr == "{'name': 'John Doe', 'number': 123}" - i2 = eval(itemrepr) # pylint: disable=eval-used + i2 = eval(itemrepr) assert i2["name"] == "John Doe" assert i2["number"] == 123 diff --git a/tests/test_link.py b/tests/test_link.py index f96961075..c49e5c090 100644 --- a/tests/test_link.py +++ b/tests/test_link.py @@ -49,7 +49,7 @@ class TestLink: l1 = Link( "http://www.example.com", text="test", fragment="something", nofollow=True ) - l2 = eval(repr(l1)) # pylint: disable=eval-used + l2 = eval(repr(l1)) self._assert_same_links(l1, l2) def test_bytes_url(self): diff --git a/tests/test_pipeline_files.py b/tests/test_pipeline_files.py index 7f5060701..57736e0ea 100644 --- a/tests/test_pipeline_files.py +++ b/tests/test_pipeline_files.py @@ -438,7 +438,7 @@ class TestFilesPipelineCustomSettings: """ pipe_cls = self._generate_fake_pipeline() pipe = pipe_cls.from_crawler(get_crawler(None, {"FILES_STORE": tmp_path})) - for pipe_attr, settings_attr, pipe_ins_attr in self.file_cls_attr_settings_map: + for pipe_attr, _, pipe_ins_attr in self.file_cls_attr_settings_map: custom_value = getattr(pipe, pipe_ins_attr) assert custom_value != self.default_cls_settings[pipe_attr] assert getattr(pipe, pipe_ins_attr) == getattr(pipe, pipe_attr) @@ -469,7 +469,7 @@ class TestFilesPipelineCustomSettings: user_pipeline = UserDefinedFilesPipeline.from_crawler( get_crawler(None, {"FILES_STORE": tmp_path}) ) - for pipe_attr, settings_attr, pipe_ins_attr in self.file_cls_attr_settings_map: + for pipe_attr, _, pipe_ins_attr in self.file_cls_attr_settings_map: # Values from settings for custom pipeline should be set on pipeline instance. custom_value = self.default_cls_settings.get(pipe_attr.upper()) assert getattr(user_pipeline, pipe_ins_attr) == custom_value @@ -541,7 +541,7 @@ class TestFilesPipelineCustomSettings: pipeline_cls = UserPipe.from_crawler(get_crawler(None, settings)) - for pipe_attr, settings_attr, pipe_inst_attr in self.file_cls_attr_settings_map: + for _, settings_attr, pipe_inst_attr in self.file_cls_attr_settings_map: expected_value = settings.get(settings_attr) assert getattr(pipeline_cls, pipe_inst_attr) == expected_value diff --git a/tests/test_pipeline_images.py b/tests/test_pipeline_images.py index 8108f7000..199ec5afa 100644 --- a/tests/test_pipeline_images.py +++ b/tests/test_pipeline_images.py @@ -435,7 +435,7 @@ class TestImagesPipelineCustomSettings: pipeline = pipeline_cls.from_crawler( get_crawler(None, {"IMAGES_STORE": tmp_path}) ) - for pipe_attr, settings_attr in self.img_cls_attribute_names: + for pipe_attr, _ in self.img_cls_attribute_names: # Instance attribute (lowercase) must be equal to class attribute (uppercase). attr_value = getattr(pipeline, pipe_attr.lower()) assert attr_value != self.default_pipeline_settings[pipe_attr] @@ -469,7 +469,7 @@ class TestImagesPipelineCustomSettings: user_pipeline = UserDefinedImagePipeline.from_crawler( get_crawler(None, {"IMAGES_STORE": tmp_path}) ) - for pipe_attr, settings_attr in self.img_cls_attribute_names: + for pipe_attr, _ in self.img_cls_attribute_names: # Values from settings for custom pipeline should be set on pipeline instance. custom_value = self.default_pipeline_settings.get(pipe_attr.upper()) assert getattr(user_pipeline, pipe_attr.lower()) == custom_value diff --git a/tests/test_utils_misc/test_return_with_argument_inside_generator.py b/tests/test_utils_misc/test_return_with_argument_inside_generator.py index ad31e5185..3783416b9 100644 --- a/tests/test_utils_misc/test_return_with_argument_inside_generator.py +++ b/tests/test_utils_misc/test_return_with_argument_inside_generator.py @@ -185,7 +185,7 @@ https://example.org warn_on_generator_with_return_value(mock_spider, l2) assert len(w) == 0 - def test_generators_return_none_with_decorator(self, mock_spider): + def test_generators_return_none_with_decorator(self, mock_spider): # noqa: PLR0915 def decorator(func): def inner_func(): func() diff --git a/tests/test_utils_spider.py b/tests/test_utils_spider.py index 5efb29a49..05971f423 100644 --- a/tests/test_utils_spider.py +++ b/tests/test_utils_spider.py @@ -24,7 +24,7 @@ def test_iterate_spider_output(): def test_iter_spider_classes(): - import tests.test_utils_spider # noqa: PLW0406,PLC0415 # pylint: disable=import-self + import tests.test_utils_spider # noqa: PLW0406,PLC0415 it = iter_spider_classes(tests.test_utils_spider) assert set(it) == {MySpider1, MySpider2} diff --git a/tests/test_webclient.py b/tests/test_webclient.py index 0ad6bd9d1..7834e3b31 100644 --- a/tests/test_webclient.py +++ b/tests/test_webclient.py @@ -48,8 +48,8 @@ def getPage(url, contextFactory=None, response_transform=None, *args, **kwargs): return _makeGetterFactory( to_bytes(url), _clientfactory, - contextFactory=contextFactory, *args, + contextFactory=contextFactory, **kwargs, ).deferred From e02ad08672a5946f659acf4874c4a315e7886346 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Mon, 2 Mar 2026 15:47:23 +0500 Subject: [PATCH 062/248] Move to mypy --strict with exceptions. (#7300) * Move to mypy --strict with exceptions. * Fix typing-tests. --- pyproject.toml | 54 ++++++++++++++++++-- scrapy/addons.py | 2 +- scrapy/commands/bench.py | 2 +- scrapy/commands/check.py | 7 ++- scrapy/commands/fetch.py | 7 +-- scrapy/contracts/__init__.py | 2 +- scrapy/core/downloader/handlers/_httpx.py | 2 +- scrapy/core/downloader/handlers/http11.py | 5 +- scrapy/core/engine.py | 6 ++- scrapy/core/http2/protocol.py | 5 +- scrapy/core/spidermw.py | 2 +- scrapy/crawler.py | 2 +- scrapy/extensions/debug.py | 6 +-- scrapy/extensions/feedexport.py | 8 +-- scrapy/extensions/telnet.py | 2 +- scrapy/http/cookies.py | 2 +- scrapy/http/request/form.py | 2 +- scrapy/pipelines/__init__.py | 3 +- scrapy/pipelines/files.py | 2 +- scrapy/pipelines/media.py | 1 + scrapy/shell.py | 2 +- scrapy/signalmanager.py | 6 +-- scrapy/statscollectors.py | 2 +- scrapy/utils/benchserver.py | 6 ++- scrapy/utils/console.py | 6 +-- scrapy/utils/datatypes.py | 4 +- scrapy/utils/defer.py | 3 +- scrapy/utils/deprecate.py | 2 +- scrapy/utils/log.py | 2 +- scrapy/utils/reactor.py | 4 +- scrapy/utils/url.py | 4 +- scrapy/utils/versions.py | 6 +-- tests/test_downloader_handler_twisted_ftp.py | 2 +- tests/test_downloadermiddleware_httpproxy.py | 2 - tests/test_http2_client_protocol.py | 4 +- tests/test_utils_iterators.py | 2 +- tests_typing/test_http_request.mypy-testing | 12 ++--- tests_typing/test_http_response.mypy-testing | 10 ++-- tox.ini | 35 +++++++------ 39 files changed, 148 insertions(+), 88 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e4fae8bf4..5138fda88 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -85,8 +85,20 @@ path = "scrapy/VERSION" pattern = "^(?P.+)$" [tool.mypy] -ignore_missing_imports = true -implicit_reexport = false +strict = true +allow_any_generics = true # 67 errors +allow_untyped_calls = true # 58 errors +extra_checks = false # weird addErrback() errors +untyped_calls_exclude = [ + "twisted", +] +warn_return_any = false # 37 errors + +[[tool.mypy.overrides]] +module = "tests.*" +allow_untyped_defs = true +allow_incomplete_defs = true # 48 errors +check_untyped_defs = false # Interface classes are hard to support [[tool.mypy.overrides]] @@ -101,14 +113,46 @@ ignore_errors = true module = "twisted.internet.reactor" follow_imports = "skip" -# FIXME: remove the following section once the issues are solved +# just for twisted.version +[[tool.mypy.overrides]] +module = "twisted" +implicit_reexport = true + +# TODO [[tool.mypy.overrides]] module = "scrapy.settings.default_settings" ignore_errors = true +# deprecated modules [[tool.mypy.overrides]] -module = "twisted" -implicit_reexport = true +module = [ + "scrapy.core.downloader.webclient", + "scrapy.spiders.init", + "scrapy.utils.testsite", + "tests.test_webclient", +] +allow_any_generics = true +allow_untyped_calls = true +allow_untyped_defs = true +check_untyped_defs = false +warn_return_any = false + +# usually no type hints +[[tool.mypy.overrides]] +module = [ +# "IPython.*", + "bpython", + "brotli", + "brotlicffi", + "google.*", + "pydispatch.*", + "pyftpdlib.*", + "pytest_twisted", + "robotexclusionrulesparser", + "testfixtures", + "zope.interface.*", +] +ignore_missing_imports = true [tool.bumpversion] current_version = "2.14.1" diff --git a/scrapy/addons.py b/scrapy/addons.py index 1024d2dcd..2e12f8c8a 100644 --- a/scrapy/addons.py +++ b/scrapy/addons.py @@ -55,7 +55,7 @@ class AddonManager: ) @classmethod - def load_pre_crawler_settings(cls, settings: BaseSettings): + def load_pre_crawler_settings(cls, settings: BaseSettings) -> None: """Update early settings that do not require a crawler instance, such as SPIDER_MODULES. Similar to the load_settings method, this loads each add-on configured in the diff --git a/scrapy/commands/bench.py b/scrapy/commands/bench.py index c4e277a60..cc39d344a 100644 --- a/scrapy/commands/bench.py +++ b/scrapy/commands/bench.py @@ -43,7 +43,7 @@ class _BenchServer: assert self.proc.stdout self.proc.stdout.readline() - def __exit__(self, exc_type, exc_value, traceback) -> None: + def __exit__(self, exc_type, exc_value, traceback) -> None: # type: ignore[no-untyped-def] self.proc.kill() self.proc.wait() time.sleep(0.2) diff --git a/scrapy/commands/check.py b/scrapy/commands/check.py index e9ada0fb6..17e66e20c 100644 --- a/scrapy/commands/check.py +++ b/scrapy/commands/check.py @@ -1,9 +1,12 @@ import argparse import time from collections import defaultdict +from collections.abc import AsyncIterator +from typing import Any from unittest import TextTestResult as _TextTestResult from unittest import TextTestRunner +from scrapy import Spider from scrapy.commands import ScrapyCommand from scrapy.contracts import ContractsManager from scrapy.utils.conf import build_component_list @@ -81,14 +84,14 @@ class Command(ScrapyCommand): assert self.crawler_process spider_loader = self.crawler_process.spider_loader - async def start(self): + async def start(self: Spider) -> AsyncIterator[Any]: for request in conman.from_spider(self, result): yield request with set_environ(SCRAPY_CHECK="true"): for spidername in args or spider_loader.list(): spidercls = spider_loader.load(spidername) - spidercls.start = start # type: ignore[assignment,method-assign,return-value] + spidercls.start = start # type: ignore[method-assign] tested_methods = conman.tested_methods_from_spidercls(spidercls) if opts.list: diff --git a/scrapy/commands/fetch.py b/scrapy/commands/fetch.py index 1b1d2442f..0b8311efb 100644 --- a/scrapy/commands/fetch.py +++ b/scrapy/commands/fetch.py @@ -2,7 +2,7 @@ from __future__ import annotations import sys from argparse import Namespace # noqa: TC003 -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from w3lib.url import is_url @@ -14,6 +14,7 @@ from scrapy.utils.spider import DefaultSpider, spidercls_for_request if TYPE_CHECKING: from argparse import ArgumentParser + from collections.abc import AsyncIterator from scrapy import Spider @@ -89,10 +90,10 @@ class Command(ScrapyCommand): else: spidercls = spidercls_for_request(spider_loader, request, spidercls) - async def start(self): + async def start(self: Spider) -> AsyncIterator[Any]: yield request - spidercls.start = start # type: ignore[method-assign,attr-defined] + spidercls.start = start # type: ignore[method-assign] self.crawler_process.crawl(spidercls) self.crawler_process.start() diff --git a/scrapy/contracts/__init__.py b/scrapy/contracts/__init__.py index f5ba5ba13..cbdb36d2f 100644 --- a/scrapy/contracts/__init__.py +++ b/scrapy/contracts/__init__.py @@ -189,7 +189,7 @@ class ContractsManager: def eb_wrapper(failure: Failure) -> None: case = _create_testcase(method, "errback") exc_info = failure.type, failure.value, failure.getTracebackObject() - results.addError(case, exc_info) + results.addError(case, exc_info) # type: ignore[arg-type] request.callback = cb_wrapper request.errback = eb_wrapper diff --git a/scrapy/core/downloader/handlers/_httpx.py b/scrapy/core/downloader/handlers/_httpx.py index 1f5cf007e..4a9617506 100644 --- a/scrapy/core/downloader/handlers/_httpx.py +++ b/scrapy/core/downloader/handlers/_httpx.py @@ -265,5 +265,5 @@ class HttpxDownloadHandler(BaseHttpDownloadHandler): logger.warning(warning_msg) raise DownloadCancelledError(warning_msg) - async def close(self): + async def close(self) -> None: await self._client.aclose() diff --git a/scrapy/core/downloader/handlers/http11.py b/scrapy/core/downloader/handlers/http11.py index 992c318c5..2fd3bd988 100644 --- a/scrapy/core/downloader/handlers/http11.py +++ b/scrapy/core/downloader/handlers/http11.py @@ -507,7 +507,7 @@ class ScrapyAgent: } # deliverBody hangs for responses without body - if txresponse.length == 0: + if cast("int", txresponse.length) == 0: return { "txresponse": txresponse, } @@ -697,7 +697,8 @@ class _ResponseReader(Protocol): return if reason.check(ResponseFailed) and any( - r.check(_DataLoss) for r in reason.value.reasons + r.check(_DataLoss) + for r in reason.value.reasons # type: ignore[union-attr] ): if not self._fail_on_dataloss: self._finish_response(flags=["dataloss"]) diff --git a/scrapy/core/engine.py b/scrapy/core/engine.py index 1555fc0d6..dc4106963 100644 --- a/scrapy/core/engine.py +++ b/scrapy/core/engine.py @@ -160,7 +160,7 @@ class ExecutionEngine: return scheduler_cls def start( - self, _start_request_processing=True + self, _start_request_processing: bool = True ) -> Deferred[None]: # pragma: no cover warnings.warn( "ExecutionEngine.start() is deprecated, use start_async() instead", @@ -263,12 +263,13 @@ class ExecutionEngine: def unpause(self) -> None: self.paused = False - async def _process_start_next(self): + async def _process_start_next(self) -> None: """Processes the next item or request from Spider.start(). If a request, it is scheduled. If an item, it is sent to item pipelines. """ + assert self._start is not None try: item_or_request = await self._start.__anext__() except StopAsyncIteration: @@ -286,6 +287,7 @@ class ExecutionEngine: if isinstance(item_or_request, Request): self.crawl(item_or_request) else: + assert self._slot is not None _schedule_coro( self.scraper.start_itemproc_async(item_or_request, response=None) ) diff --git a/scrapy/core/http2/protocol.py b/scrapy/core/http2/protocol.py index 9f6216157..fe4128a68 100644 --- a/scrapy/core/http2/protocol.py +++ b/scrapy/core/http2/protocol.py @@ -4,7 +4,7 @@ import ipaddress import itertools import logging from collections import deque -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast from h2.config import H2Configuration from h2.connection import H2Connection @@ -39,6 +39,7 @@ from scrapy.utils.deprecate import warn_on_deprecated_spider_attribute if TYPE_CHECKING: from ipaddress import IPv4Address, IPv6Address + from hpack import HeaderTuple from twisted.internet.defer import Deferred from twisted.python.failure import Failure from twisted.web.client import URI @@ -408,7 +409,7 @@ class H2ClientProtocol(Protocol, TimeoutMixin): except KeyError: pass # We ignore server-initiated events else: - stream.receive_headers(event.headers) + stream.receive_headers(cast("list[HeaderTuple]", event.headers)) def settings_acknowledged(self, event: SettingsAcknowledged) -> None: self.metadata["settings_acknowledged"] = True diff --git a/scrapy/core/spidermw.py b/scrapy/core/spidermw.py index 476d2e504..007d03a04 100644 --- a/scrapy/core/spidermw.py +++ b/scrapy/core/spidermw.py @@ -460,7 +460,7 @@ class SpiderMiddlewareManager(MiddlewareManager): start = await self._process_chain("process_start", start) return start - def _check_deprecated_start_requests_use(self): + def _check_deprecated_start_requests_use(self) -> None: start_requests_cls = None start_cls = None spidercls = self._spider.__class__ diff --git a/scrapy/crawler.py b/scrapy/crawler.py index 95cd04b51..b2f08450b 100644 --- a/scrapy/crawler.py +++ b/scrapy/crawler.py @@ -135,7 +135,7 @@ class Crawler: "Overridden settings:\n%(settings)s", {"settings": pprint.pformat(d)} ) - def _apply_reactorless_default_settings(self): + def _apply_reactorless_default_settings(self) -> None: """Change some setting defaults when not using a Twisted reactor. Some settings need different defaults when using and not using a diff --git a/scrapy/extensions/debug.py b/scrapy/extensions/debug.py index afaf81928..5def7509e 100644 --- a/scrapy/extensions/debug.py +++ b/scrapy/extensions/debug.py @@ -34,8 +34,8 @@ class StackTraceDump: def __init__(self, crawler: Crawler): self.crawler: Crawler = crawler try: - signal.signal(signal.SIGUSR2, self.dump_stacktrace) # type: ignore[attr-defined] - signal.signal(signal.SIGQUIT, self.dump_stacktrace) # type: ignore[attr-defined] + signal.signal(signal.SIGUSR2, self.dump_stacktrace) # type: ignore[attr-defined,unused-ignore] + signal.signal(signal.SIGQUIT, self.dump_stacktrace) # type: ignore[attr-defined,unused-ignore] except AttributeError: # win32 platforms don't support SIGUSR signals pass @@ -72,7 +72,7 @@ class Debugger: def __init__(self) -> None: # win32 platforms don't support SIGUSR signals with contextlib.suppress(AttributeError): - signal.signal(signal.SIGUSR2, self._enter_debugger) # type: ignore[attr-defined] + signal.signal(signal.SIGUSR2, self._enter_debugger) # type: ignore[attr-defined,unused-ignore] def _enter_debugger(self, signum: int, frame: FrameType | None) -> None: assert frame diff --git a/scrapy/extensions/feedexport.py b/scrapy/extensions/feedexport.py index cb56d1c92..86593aba8 100644 --- a/scrapy/extensions/feedexport.py +++ b/scrapy/extensions/feedexport.py @@ -89,20 +89,20 @@ class ItemFilter: return True # accept all items by default -class IFeedStorage(Interface): +class IFeedStorage(Interface): # type: ignore[misc] """Interface that all Feed Storages must implement""" # pylint: disable=no-self-argument - def __init__(uri, *, feed_options=None): # pylint: disable=super-init-not-called + def __init__(uri, *, feed_options=None): # type: ignore[no-untyped-def] # pylint: disable=super-init-not-called """Initialize the storage with the parameters given in the URI and the feed-specific options (see :setting:`FEEDS`)""" - def open(spider): + def open(spider): # type: ignore[no-untyped-def] """Open the storage for the given spider. It must return a file-like object that will be used for the exporters""" - def store(file): + def store(file): # type: ignore[no-untyped-def] """Store the given file stream""" diff --git a/scrapy/extensions/telnet.py b/scrapy/extensions/telnet.py index f95cdb040..fec6b2d56 100644 --- a/scrapy/extensions/telnet.py +++ b/scrapy/extensions/telnet.py @@ -86,7 +86,7 @@ class TelnetConsole(protocol.ServerFactory): class Portal: """An implementation of IPortal""" - def login(self_, credentials, mind, *interfaces): # pylint: disable=no-self-argument + def login(self_, credentials, mind, *interfaces): # type: ignore[no-untyped-def] # pylint: disable=no-self-argument if not ( credentials.username == self.username.encode("utf8") and credentials.checkPassword(self.password.encode("utf8")) diff --git a/scrapy/http/cookies.py b/scrapy/http/cookies.py index e5b4b28a7..13d1c85d0 100644 --- a/scrapy/http/cookies.py +++ b/scrapy/http/cookies.py @@ -74,7 +74,7 @@ class CookieJar: @property def _cookies(self) -> dict[str, dict[str, dict[str, Cookie]]]: - return self.jar._cookies # type: ignore[attr-defined,no-any-return] + return self.jar._cookies # type: ignore[attr-defined] def clear_session_cookies(self) -> None: return self.jar.clear_session_cookies() diff --git a/scrapy/http/request/form.py b/scrapy/http/request/form.py index 7cb8bfd5d..d2d13b8df 100644 --- a/scrapy/http/request/form.py +++ b/scrapy/http/request/form.py @@ -108,7 +108,7 @@ def _urlencode(seq: Iterable[FormdataKVType], enc: str) -> str: values = [ (to_bytes(k, enc), to_bytes(v, enc)) for k, vs in seq - for v in (cast("Iterable[str]", vs) if is_listlike(vs) else [cast("str", vs)]) + for v in (vs if is_listlike(vs) else [cast("str", vs)]) ] return urlencode(values, doseq=True) diff --git a/scrapy/pipelines/__init__.py b/scrapy/pipelines/__init__.py index 14d53f1c5..f58864471 100644 --- a/scrapy/pipelines/__init__.py +++ b/scrapy/pipelines/__init__.py @@ -10,7 +10,7 @@ import asyncio import warnings from typing import TYPE_CHECKING, Any, cast -from twisted.internet.defer import Deferred, DeferredList +from twisted.internet.defer import Deferred, DeferredList, FirstError from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.middleware import MiddlewareManager @@ -80,6 +80,7 @@ class ItemPipelineManager(MiddlewareManager): d2: Deferred[list[None]] = d.addCallback(lambda r: [x[1] for x in r]) def eb(failure: Failure) -> Failure: + assert isinstance(failure.value, FirstError) return failure.value.subFailure d2.addErrback(eb) diff --git a/scrapy/pipelines/files.py b/scrapy/pipelines/files.py index 795a0143f..55e01c4ab 100644 --- a/scrapy/pipelines/files.py +++ b/scrapy/pipelines/files.py @@ -305,7 +305,7 @@ class GCSFilesStore: def stat_file( self, path: str, info: MediaPipeline.SpiderInfo ) -> Deferred[StatInfo]: - def _onsuccess(blob) -> StatInfo: + def _onsuccess(blob: Any) -> StatInfo: if blob: checksum = base64.b64decode(blob.md5_hash).hex() last_modified = time.mktime(blob.updated.timetuple()) diff --git a/scrapy/pipelines/media.py b/scrapy/pipelines/media.py index cea6b9c35..7bc29f28f 100644 --- a/scrapy/pipelines/media.py +++ b/scrapy/pipelines/media.py @@ -250,6 +250,7 @@ class MediaPipeline(ABC): # the encapsulated exception when it is a StopIteration instance context = getattr(result.value, "__context__", None) if isinstance(context, StopIteration): + assert result.value is not None result.value.__context__ = None info.downloading.remove(fp) diff --git a/scrapy/shell.py b/scrapy/shell.py index 00097d224..6f33f3f14 100644 --- a/scrapy/shell.py +++ b/scrapy/shell.py @@ -104,7 +104,7 @@ class Shell: event_loop_path = self.crawler.settings["ASYNCIO_EVENT_LOOP"] set_asyncio_event_loop(event_loop_path) - def crawl_request(_): + def crawl_request(_: None) -> None: assert self.crawler.engine is not None self.crawler.engine.crawl(request) diff --git a/scrapy/signalmanager.py b/scrapy/signalmanager.py index 3ca9718d7..ca3b5b7dd 100644 --- a/scrapy/signalmanager.py +++ b/scrapy/signalmanager.py @@ -101,14 +101,14 @@ class SignalManager: kwargs.setdefault("sender", self.sender) _signal.disconnect_all(signal, **kwargs) - async def wait_for(self, signal): + async def wait_for(self, signal: Any) -> None: """Await the next *signal*. See :ref:`start-requests-lazy` for an example. """ - d = Deferred() + d: Deferred[None] = Deferred() - def handle(): + def handle() -> None: self.disconnect(handle, signal) d.callback(None) diff --git a/scrapy/statscollectors.py b/scrapy/statscollectors.py index f74842ac9..1f9894519 100644 --- a/scrapy/statscollectors.py +++ b/scrapy/statscollectors.py @@ -27,7 +27,7 @@ class StatsCollector: self._stats: StatsT = {} self._crawler: Crawler = crawler - def __getattribute__(self, name): + def __getattribute__(self, name: str) -> Any: cached_name = f"_cached_{name}" try: return super().__getattribute__(cached_name) diff --git a/scrapy/utils/benchserver.py b/scrapy/utils/benchserver.py index e34b7190f..6ac5e66b2 100644 --- a/scrapy/utils/benchserver.py +++ b/scrapy/utils/benchserver.py @@ -27,8 +27,10 @@ class Root(Resource): return b"" -def _getarg(request, name: bytes, default: Any = None, type_=str): - return type_(request.args[name][0]) if name in request.args else default +def _getarg( + request: Request, name: bytes, default: Any = None, type_: type = str +) -> Any: + return type_(request.args[name][0]) if name in request.args else default # type: ignore[index,operator] if __name__ == "__main__": diff --git a/scrapy/utils/console.py b/scrapy/utils/console.py index 644965cb5..87cfd3c28 100644 --- a/scrapy/utils/console.py +++ b/scrapy/utils/console.py @@ -20,10 +20,10 @@ def _embed_ipython_shell( from IPython.terminal.embed import InteractiveShellEmbed # noqa: T100,PLC0415 from IPython.terminal.ipapp import load_default_config # noqa: PLC0415 except ImportError: - from IPython.frontend.terminal.embed import ( # type: ignore[no-redef] # noqa: T100,PLC0415 + from IPython.frontend.terminal.embed import ( # type: ignore[import-not-found,no-redef] # noqa: T100,PLC0415 InteractiveShellEmbed, ) - from IPython.frontend.terminal.ipapp import ( # type: ignore[no-redef] # noqa: PLC0415 + from IPython.frontend.terminal.ipapp import ( # type: ignore[import-not-found,no-redef] # noqa: PLC0415 load_default_config, ) @@ -81,7 +81,7 @@ def _embed_standard_shell( else: import rlcompleter # noqa: F401,PLC0415 - readline.parse_and_bind("tab:complete") # type: ignore[attr-defined] + readline.parse_and_bind("tab:complete") # type: ignore[attr-defined,unused-ignore] @wraps(_embed_standard_shell) def wrapper(namespace: dict[str, Any] = namespace, banner: str = "") -> None: diff --git a/scrapy/utils/datatypes.py b/scrapy/utils/datatypes.py index ae7f627e1..64d2cde1b 100644 --- a/scrapy/utils/datatypes.py +++ b/scrapy/utils/datatypes.py @@ -83,7 +83,7 @@ class CaselessDict(dict): return dict.get(self, self.normkey(key), self.normvalue(def_val)) def setdefault(self, key: AnyStr, def_val: Any = None) -> Any: - return dict.setdefault(self, self.normkey(key), self.normvalue(def_val)) # type: ignore[arg-type] + return dict.setdefault(self, self.normkey(key), self.normvalue(def_val)) # doesn't fully implement MutableMapping.update() def update(self, seq: Mapping[AnyStr, Any] | Iterable[tuple[AnyStr, Any]]) -> None: # type: ignore[override] @@ -179,7 +179,7 @@ class LocalWeakReferencedCache(weakref.WeakKeyDictionary): with contextlib.suppress(TypeError): super().__setitem__(key, value) - def __getitem__(self, key: _KT) -> _VT | None: # type: ignore[override] + def __getitem__(self, key: _KT) -> _VT | None: try: return super().__getitem__(key) except (TypeError, KeyError): diff --git a/scrapy/utils/defer.py b/scrapy/utils/defer.py index 0d937fea5..1acb576b4 100644 --- a/scrapy/utils/defer.py +++ b/scrapy/utils/defer.py @@ -21,7 +21,7 @@ from typing import ( overload, ) -from twisted.internet.defer import Deferred, DeferredList, fail, succeed +from twisted.internet.defer import Deferred, DeferredList, FirstError, fail, succeed from twisted.internet.task import Cooperator from twisted.python import failure @@ -334,6 +334,7 @@ def process_parallel( d2: Deferred[list[_T2]] = d.addCallback(lambda r: [x[1] for x in r]) def eb(failure: Failure) -> Failure: + assert isinstance(failure.value, FirstError) return failure.value.subFailure d2.addErrback(eb) diff --git a/scrapy/utils/deprecate.py b/scrapy/utils/deprecate.py index e5b2ae23d..3bf6639c5 100644 --- a/scrapy/utils/deprecate.py +++ b/scrapy/utils/deprecate.py @@ -60,7 +60,7 @@ def create_deprecated_class( """ # https://github.com/python/mypy/issues/4177 - class DeprecatedClass(new_class.__class__): # type: ignore[misc, name-defined] + class DeprecatedClass(new_class.__class__): # type: ignore[misc,name-defined] # pylint: disable=no-self-argument deprecated_class: type | None = None warned_on_subclass: bool = False diff --git a/scrapy/utils/log.py b/scrapy/utils/log.py index 5f0d19913..102362506 100644 --- a/scrapy/utils/log.py +++ b/scrapy/utils/log.py @@ -189,7 +189,7 @@ def log_scrapy_info(settings: Settings) -> None: "Scrapy %(version)s started (bot: %(bot)s)", {"version": scrapy.__version__, "bot": settings["BOT_NAME"]}, ) - software = settings.getlist("LOG_VERSIONS") + software: list[str] = settings.getlist("LOG_VERSIONS") if not software: return versions = pprint.pformat(dict(get_versions(software)), sort_dicts=False) diff --git a/scrapy/utils/reactor.py b/scrapy/utils/reactor.py index 6aa72064b..419c552bf 100644 --- a/scrapy/utils/reactor.py +++ b/scrapy/utils/reactor.py @@ -80,11 +80,11 @@ class CallLaterOnce(Generic[_T]): return result - async def wait(self): + async def wait(self) -> None: # circular import from scrapy.utils.defer import maybe_deferred_to_future # noqa: PLC0415 - d = Deferred() + d: Deferred[None] = Deferred() self._deferreds.append(d) await maybe_deferred_to_future(d) diff --git a/scrapy/utils/url.py b/scrapy/utils/url.py index 8cfa5cce4..097809cac 100644 --- a/scrapy/utils/url.py +++ b/scrapy/utils/url.py @@ -8,7 +8,7 @@ from __future__ import annotations import re import warnings from importlib import import_module -from typing import TYPE_CHECKING, TypeAlias +from typing import TYPE_CHECKING, Any, TypeAlias from urllib.parse import ParseResult, urldefrag, urlparse, urlunparse from warnings import warn @@ -20,7 +20,7 @@ from w3lib.url import parse_url as _parse_url from scrapy.exceptions import ScrapyDeprecationWarning -def __getattr__(name: str): +def __getattr__(name: str) -> Any: if name in ("_unquotepath", "_safe_chars", "parse_url", *_public_w3lib_objects): obj_type = "attribute" if name == "_safe_chars" else "function" warnings.warn( diff --git a/scrapy/utils/versions.py b/scrapy/utils/versions.py index b15063900..6d3572cb1 100644 --- a/scrapy/utils/versions.py +++ b/scrapy/utils/versions.py @@ -11,10 +11,10 @@ from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.settings.default_settings import LOG_VERSIONS from scrapy.utils.ssl import get_openssl_version -_DEFAULT_SOFTWARE = ["Scrapy", *LOG_VERSIONS] +_DEFAULT_SOFTWARE: list[str] = ["Scrapy", *LOG_VERSIONS] -def _version(item): +def _version(item: str) -> str: lowercase_item = item.lower() if lowercase_item == "libxml2": return ".".join(map(str, lxml.etree.LIBXML_VERSION)) @@ -28,7 +28,7 @@ def _version(item): def get_versions( - software: list | None = None, + software: list[str] | None = None, ) -> list[tuple[str, str]]: software = software or _DEFAULT_SOFTWARE return [(item, _version(item)) for item in software] diff --git a/tests/test_downloader_handler_twisted_ftp.py b/tests/test_downloader_handler_twisted_ftp.py index 60d2dfd12..957ad35c9 100644 --- a/tests/test_downloader_handler_twisted_ftp.py +++ b/tests/test_downloader_handler_twisted_ftp.py @@ -47,7 +47,7 @@ class TestFTPBase(ABC): def _get_factory(self, tmp_path: Path) -> FTPFactory: raise NotImplementedError - @async_yield_fixture + @async_yield_fixture # type: ignore[untyped-decorator] async def server_url(self, tmp_path: Path) -> AsyncGenerator[str]: from twisted.internet import reactor diff --git a/tests/test_downloadermiddleware_httpproxy.py b/tests/test_downloadermiddleware_httpproxy.py index b9d368653..a2d421e39 100644 --- a/tests/test_downloadermiddleware_httpproxy.py +++ b/tests/test_downloadermiddleware_httpproxy.py @@ -10,8 +10,6 @@ from scrapy.utils.test import get_crawler class TestHttpProxyMiddleware: - failureException = AssertionError # type: ignore[assignment] - def setup_method(self): self._oldenv = os.environ.copy() diff --git a/tests/test_http2_client_protocol.py b/tests/test_http2_client_protocol.py index b5dab8861..af9941f3b 100644 --- a/tests/test_http2_client_protocol.py +++ b/tests/test_http2_client_protocol.py @@ -213,7 +213,7 @@ class TestHttps2ClientProtocol: r.putChild(b"request-headers", RequestHeaders()) return Site(r, timeout=None) - @async_yield_fixture + @async_yield_fixture # type: ignore[untyped-decorator] async def server_port(self, site: Site) -> AsyncGenerator[int]: from twisted.internet import reactor @@ -236,7 +236,7 @@ class TestHttps2ClientProtocol: ) + self.certificate_file.read_text(encoding="utf-8") return PrivateCertificate.loadPEM(pem) - @async_yield_fixture + @async_yield_fixture # type: ignore[untyped-decorator] async def client( self, server_port: int, client_certificate: PrivateCertificate ) -> AsyncGenerator[H2ClientProtocol]: diff --git a/tests/test_utils_iterators.py b/tests/test_utils_iterators.py index 73e55b736..1276c1392 100644 --- a/tests/test_utils_iterators.py +++ b/tests/test_utils_iterators.py @@ -527,7 +527,7 @@ class TestBodyOrStr: r3 = _body_or_str(obj, unicode=False) self._assert_type_and_value(r3, self.bbody, obj) assert type(r1) is type(r2) - assert type(r1) is not type(r3) + assert type(r1) is not type(r3) # type: ignore[comparison-overlap] @staticmethod def _assert_type_and_value( diff --git a/tests_typing/test_http_request.mypy-testing b/tests_typing/test_http_request.mypy-testing index 95f03e17c..a431091d5 100644 --- a/tests_typing/test_http_request.mypy-testing +++ b/tests_typing/test_http_request.mypy-testing @@ -15,7 +15,7 @@ class MyRequest2(Request): @pytest.mark.mypy_testing -def mypy_test_headers(): +def mypy_test_headers() -> None: Request("data:,", headers=1) # E: Argument "headers" to "Request" has incompatible type "int"; expected "Mapping[str, Any] | Iterable[tuple[str, Any]] | None" Request("data:,", headers=None) Request("data:,", headers={}) @@ -29,7 +29,7 @@ def mypy_test_headers(): @pytest.mark.mypy_testing -def mypy_test_copy(): +def mypy_test_copy() -> None: req = Request("data:,") reveal_type(req) # R: scrapy.http.request.Request req_copy = req.copy() @@ -37,7 +37,7 @@ def mypy_test_copy(): @pytest.mark.mypy_testing -def mypy_test_copy_subclass(): +def mypy_test_copy_subclass() -> None: req = MyRequest("data:,") reveal_type(req) # R: __main__.MyRequest req_copy = req.copy() @@ -45,7 +45,7 @@ def mypy_test_copy_subclass(): @pytest.mark.mypy_testing -def mypy_test_replace(): +def mypy_test_replace() -> None: req = Request("data:,") reveal_type(req) # R: scrapy.http.request.Request req_copy = req.replace(body=b"a") @@ -56,7 +56,7 @@ def mypy_test_replace(): @pytest.mark.mypy_testing -def mypy_test_replace_subclass(): +def mypy_test_replace_subclass() -> None: req = MyRequest("data:,") reveal_type(req) # R: __main__.MyRequest req_copy = req.replace(body=b"a") @@ -69,7 +69,7 @@ def mypy_test_replace_subclass(): @pytest.mark.mypy_testing -def mypy_test_jsonrequest_copy_replace(): +def mypy_test_jsonrequest_copy_replace() -> None: req = JsonRequest("data:,") reveal_type(req) # R: scrapy.http.request.json_request.JsonRequest req_copy = req.copy() diff --git a/tests_typing/test_http_response.mypy-testing b/tests_typing/test_http_response.mypy-testing index 630754c1e..d497c2470 100644 --- a/tests_typing/test_http_response.mypy-testing +++ b/tests_typing/test_http_response.mypy-testing @@ -6,7 +6,7 @@ from scrapy.http import HtmlResponse, Response, TextResponse @pytest.mark.mypy_testing -def mypy_test_headers(): +def mypy_test_headers() -> None: Response("data:,", headers=1) # E: Argument "headers" to "Response" has incompatible type "int"; expected "Mapping[str, Any] | Iterable[tuple[str, Any]] | None" Response("data:,", headers=None) Response("data:,", headers={}) @@ -20,7 +20,7 @@ def mypy_test_headers(): @pytest.mark.mypy_testing -def mypy_test_copy(): +def mypy_test_copy() -> None: resp = Response("data:,") reveal_type(resp) # R: scrapy.http.response.Response resp_copy = resp.copy() @@ -28,7 +28,7 @@ def mypy_test_copy(): @pytest.mark.mypy_testing -def mypy_test_copy_subclass(): +def mypy_test_copy_subclass() -> None: resp = HtmlResponse("data:,") reveal_type(resp) # R: scrapy.http.response.html.HtmlResponse resp_copy = resp.copy() @@ -36,7 +36,7 @@ def mypy_test_copy_subclass(): @pytest.mark.mypy_testing -def mypy_test_replace(): +def mypy_test_replace() -> None: resp = Response("data:,") reveal_type(resp) # R: scrapy.http.response.Response resp_copy = resp.replace(body=b"a") @@ -47,7 +47,7 @@ def mypy_test_replace(): @pytest.mark.mypy_testing -def mypy_test_replace_subclass(): +def mypy_test_replace_subclass() -> None: resp = HtmlResponse("data:,") reveal_type(resp) # R: scrapy.http.response.html.HtmlResponse resp_copy = resp.replace(body=b"a") diff --git a/tox.ini b/tox.ini index 7656cb017..1bebaf2aa 100644 --- a/tox.ini +++ b/tox.ini @@ -41,22 +41,27 @@ commands = [testenv:typing] basepython = python3.10 deps = - mypy==1.18.2 + mypy==1.19.1 typing-extensions==4.15.0 - types-defusedxml==0.7.0.20250822 - types-lxml==2025.8.25 - types-pexpect==4.9.0.20250916 - types-Pygments==2.19.0.20250809 - botocore-stubs==1.40.59 - boto3-stubs[s3]==1.40.59 + Pillow==12.1.1 + Protego==0.6.0 + attrs==25.4.0 + boto3-stubs[s3]==1.42.59 + botocore-stubs==1.42.41 + h2==4.3.0 httpx==0.28.1 - itemadapter==0.12.2 - Protego==0.5.0 - w3lib==2.3.1 - attrs >= 18.2.0 - Pillow >= 10.3.0 - pyOpenSSL >= 24.2.1 - pytest >= 8.2.0 + itemadapter==0.13.1 + ptpython==3.0.32 + ipython + pyOpenSSL==25.3.0 + pytest==9.0.2 + types-Pygments==2.19.0.20251121 + types-defusedxml==0.7.0.20250822 + types-lxml==2026.2.16 + types-pexpect==4.9.0.20260127 + uvloop==0.22.1 + w3lib==2.4.0 + zstandard==0.25.0 commands = mypy {posargs:scrapy tests} @@ -65,7 +70,7 @@ basepython = python3.10 deps = {[test-requirements]deps} {[testenv:typing]deps} - pytest-mypy-testing==0.1.3 + pytest-mypy-testing==0.2.0 commands = pytest {posargs:tests_typing} From 886131c7b2f2e792fc139e5660f908239836388c Mon Sep 17 00:00:00 2001 From: Adrian Chaves Date: Thu, 12 Mar 2026 13:54:00 +0100 Subject: [PATCH 063/248] Run pre-commit --- scrapy/downloadermiddlewares/redirect.py | 18 ++++----- tests/test_downloadermiddleware_redirect.py | 41 ++++++++++++++------- 2 files changed, 37 insertions(+), 22 deletions(-) diff --git a/scrapy/downloadermiddlewares/redirect.py b/scrapy/downloadermiddlewares/redirect.py index 9520a5cbb..69c58ca49 100644 --- a/scrapy/downloadermiddlewares/redirect.py +++ b/scrapy/downloadermiddlewares/redirect.py @@ -61,12 +61,12 @@ class BaseRedirectMiddleware: request.headers.pop("Referer", None) if not self._referer_spider_middleware: return - self._referer_spider_middleware.get_processed_request( - request, response - ) + self._referer_spider_middleware.get_processed_request(request, response) def _engine_started(self) -> None: - self._referer_spider_middleware = self.crawler.get_spider_middleware(RefererMiddleware) + self._referer_spider_middleware = self.crawler.get_spider_middleware( + RefererMiddleware + ) if self._referer_spider_middleware: return redirect_cls = global_object_name(self.__class__) @@ -120,7 +120,6 @@ class BaseRedirectMiddleware: ) raise IgnoreRequest("max redirections reached") - def _build_redirect_request( self, source_request: Request, response: Response, *, url: str, **kwargs: Any ) -> Request: @@ -236,11 +235,12 @@ class RedirectMiddleware(BaseRedirectMiddleware): if urlparse_cached(redirected).scheme not in {"http", "https"}: return response - if ( - (response.status in (301, 302) and request.method == "POST") - or (response.status == 303 and request.method not in ("GET", "HEAD")) + if (response.status in (301, 302) and request.method == "POST") or ( + response.status == 303 and request.method not in ("GET", "HEAD") ): - redirected = self._redirect_request_using_get(request, response, redirected_url) + redirected = self._redirect_request_using_get( + request, response, redirected_url + ) return self._redirect(redirected, request, response.status) diff --git a/tests/test_downloadermiddleware_redirect.py b/tests/test_downloadermiddleware_redirect.py index 4197c7c86..89185aafb 100644 --- a/tests/test_downloadermiddleware_redirect.py +++ b/tests/test_downloadermiddleware_redirect.py @@ -1197,7 +1197,9 @@ class TestRedirectMiddleware(Base.Test): def test_redirect_strips_referer_no_middleware(self): source_url = "http://www.example.com/302" redirect_url = "http://www.example.com/redirected" - source_request = Request(source_url, headers={"Referer": "http://example.com/old"}) + source_request = Request( + source_url, headers={"Referer": "http://example.com/old"} + ) response = Response(source_url, headers={"Location": redirect_url}, status=302) redirect_mw = self.mwcls.from_crawler(get_crawler()) redirect_mw._referer_spider_middleware = None @@ -1213,9 +1215,14 @@ class TestRedirectMiddleware(Base.Test): source_url, method="POST", body=body, - headers={"Content-Type": "application/json", "Content-Length": str(len(body))}, + headers={ + "Content-Type": "application/json", + "Content-Length": str(len(body)), + }, + ) + response1 = Response( + source_url, headers={"Location": target_url}, status=status ) - response1 = Response(source_url, headers={"Location": target_url}, status=status) redirect_request = self.mw.process_response(request, response1) assert isinstance(redirect_request, Request) assert redirect_request.url == target_url @@ -1480,6 +1487,7 @@ def test_meta_refresh_schemes(url, location, target): else: assert isinstance(redirect, Request) + @pytest.mark.parametrize( ("policy", "source_url", "target_url", "expected_referrer"), [ @@ -1507,9 +1515,7 @@ def test_meta_refresh_schemes(url, location, target): ), ], ) -def test_response_referrer_policy( - policy, source_url, target_url, expected_referrer -): +def test_response_referrer_policy(policy, source_url, target_url, expected_referrer): crawler = get_crawler() referrer_mw = build_from_crawler(RefererMiddleware, crawler) redirect_mw = build_from_crawler(RedirectMiddleware, crawler) @@ -1519,11 +1525,11 @@ def test_response_referrer_policy( if policy: extra_headers["Referrer-Policy"] = policy response_redirect = Response( - source_request.url, status=301, headers={"Location": target_url, **extra_headers} - ) - source_request = redirect_mw.process_response( - source_request, response_redirect + source_request.url, + status=301, + headers={"Location": target_url, **extra_headers}, ) + source_request = redirect_mw.process_response(source_request, response_redirect) assert isinstance(source_request, Request) assert source_request.headers.get("Referer") == expected_referrer @@ -1547,7 +1553,10 @@ def test_warning_redirect_middleware(caplog): "scrapy.downloadermiddlewares.redirect.RedirectMiddleware found no " "scrapy.spidermiddlewares.referer.RefererMiddleware" ) in caplog.text - assert "enable scrapy.spidermiddlewares.referer.RefererMiddleware (or a subclass)" in caplog.text + assert ( + "enable scrapy.spidermiddlewares.referer.RefererMiddleware (or a subclass)" + in caplog.text + ) assert ( "replace scrapy.downloadermiddlewares.redirect.RedirectMiddleware " "with a subclass that overrides the handle_referer() method" @@ -1564,7 +1573,10 @@ def test_warning_meta_refresh_middleware(caplog): "scrapy.downloadermiddlewares.redirect.MetaRefreshMiddleware found no " "scrapy.spidermiddlewares.referer.RefererMiddleware" ) in caplog.text - assert "enable scrapy.spidermiddlewares.referer.RefererMiddleware (or a subclass)" in caplog.text + assert ( + "enable scrapy.spidermiddlewares.referer.RefererMiddleware (or a subclass)" + in caplog.text + ) assert ( "replace scrapy.downloadermiddlewares.redirect.MetaRefreshMiddleware " "with a subclass that overrides the handle_referer() method" @@ -1584,7 +1596,10 @@ def test_warning_subclass(caplog): "test_warning_subclass..MyRedirectMiddleware found no " "scrapy.spidermiddlewares.referer.RefererMiddleware" ) in caplog.text - assert "enable scrapy.spidermiddlewares.referer.RefererMiddleware (or a subclass)" in caplog.text + assert ( + "enable scrapy.spidermiddlewares.referer.RefererMiddleware (or a subclass)" + in caplog.text + ) assert "edit " in caplog.text assert "test_warning_subclass..MyRedirectMiddleware" in caplog.text assert ( From 8e28f938d29a496c3bf9fbffb212e1808213d9c4 Mon Sep 17 00:00:00 2001 From: Adrian Chaves Date: Thu, 12 Mar 2026 14:29:51 +0100 Subject: [PATCH 064/248] Make test_no_warning_when_referer_middleware_present less brittle --- tests/test_downloadermiddleware_redirect.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/test_downloadermiddleware_redirect.py b/tests/test_downloadermiddleware_redirect.py index 89185aafb..ab63ad545 100644 --- a/tests/test_downloadermiddleware_redirect.py +++ b/tests/test_downloadermiddleware_redirect.py @@ -1539,8 +1539,14 @@ def test_no_warning_when_referer_middleware_present(caplog): crawler = get_crawler() crawler.get_spider_middleware = MagicMock(return_value=MagicMock()) mw = build_from_crawler(RedirectMiddleware, crawler) - mw._engine_started() - assert not caplog.records + caplog.clear() + with caplog.at_level(logging.WARNING): + mw._engine_started() + assert not [ + record + for record in caplog.records + if record.name == "scrapy.downloadermiddlewares.redirect" + ] def test_warning_redirect_middleware(caplog): From 378bb68039876c5e77b293cccd80eb5f306afd7e Mon Sep 17 00:00:00 2001 From: Adrian Chaves Date: Thu, 12 Mar 2026 15:44:54 +0100 Subject: [PATCH 065/248] Proofread the release notes --- docs/news.rst | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/docs/news.rst b/docs/news.rst index ee9d7c017..1407a321d 100644 --- a/docs/news.rst +++ b/docs/news.rst @@ -19,17 +19,18 @@ Security bug fixes - In line with the `standard `__, 301 redirects of - ``POST`` requests turn into ``GET`` requests. + ``POST`` requests are converted into ``GET`` requests. - Turning into a ``GET`` request implies not only a method change, but also + Converting to a ``GET`` request implies not only a method change, but also omitting the body and ``Content-*`` headers in the redirect request. On - cross-origin redirects (e.g. cross-domain), this is effectively a security - bug fix for scenarios where the body contains secrets. + cross-origin redirects (for example, cross-domain redirects), this is + effectively a security bug fix for scenarios where the body contains + secrets. Deprecations ~~~~~~~~~~~~ -- Using a response URL string as the first parameter on calls to +- Passing a response URL string as the first positional argument to :meth:`scrapy.spidermiddlewares.referer.RefererMiddleware.policy` is deprecated. Pass a :class:`~scrapy.http.Response` instead. @@ -48,23 +49,23 @@ Bug fixes - Made additional redirect scenarios convert to ``GET`` in line with the `standard `__: - - Only ``POST`` 302 redirects turn into ``GET`` requests, other methods - are preserved. + - Only ``POST`` 302 redirects are converted into ``GET`` requests; other + methods are preserved. - - ``HEAD`` 303 redirects do not turn into ``GET`` requests. + - ``HEAD`` 303 redirects are not converted into ``GET`` requests. - - ``GET`` 303 redirects do not get their body or standard ``Content-*`` + - ``GET`` 303 redirects do not have their body or standard ``Content-*`` headers removed. -- Redirects where the original request body is dropped now also get their +- Redirects where the original request body is dropped now also have their ``Content-Encoding``, ``Content-Language`` and ``Content-Location`` headers - removed, in addition to ``Content-Type`` and ``Content-Length`` that were - already being removed. + removed, in addition to the ``Content-Type`` and ``Content-Length`` headers + that were already being removed. -- Redirects now maintain the source URL fragment if the redirect URL doesn't - have one. This may be useful when using browser-based download handlers, - like those of `scrapy-playwright`_ or `scrapy-zyte-api`_, and letting - Scrapy handle redirects. +- Redirects now preserve the source URL fragment if the redirect URL does not + include one. This is useful when using browser-based download handlers, + such as `scrapy-playwright`_ or `scrapy-zyte-api`_, while letting Scrapy + handle redirects. .. _scrapy-playwright: https://github.com/scrapy-plugins/scrapy-playwright .. _scrapy-zyte-api: https://scrapy-zyte-api.readthedocs.io/en/latest/ @@ -72,9 +73,8 @@ Bug fixes - The ``Referer`` header is now removed on redirect if :class:`~scrapy.spidermiddlewares.referer.RefererMiddleware` is disabled. -- The handling of the ``Referer`` header on redirects now accounts for the - ``Referer-Policy`` header of the response that triggers the redirect - request. +- The handling of the ``Referer`` header on redirects now takes into account + the ``Referer-Policy`` header of the response that triggers the redirect. .. _release-2.14.1: From 498b4fc1a431c71ea699b2d7e0bd518c7ceca302 Mon Sep 17 00:00:00 2001 From: Adrian Chaves Date: Thu, 12 Mar 2026 15:45:16 +0100 Subject: [PATCH 066/248] =?UTF-8?q?Bump=20version:=202.14.1=20=E2=86=92=20?= =?UTF-8?q?2.14.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/news.rst | 2 +- pyproject.toml | 2 +- scrapy/VERSION | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/news.rst b/docs/news.rst index 1407a321d..45c2f48be 100644 --- a/docs/news.rst +++ b/docs/news.rst @@ -5,7 +5,7 @@ Release notes .. _release-2.14.2: -Scrapy 2.14.2 (unreleased) +Scrapy 2.14.2 (2026-03-12) -------------------------- Security bug fixes diff --git a/pyproject.toml b/pyproject.toml index 7982b43b8..98dd0b42c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -111,7 +111,7 @@ module = "twisted" implicit_reexport = true [tool.bumpversion] -current_version = "2.14.1" +current_version = "2.14.2" commit = true tag = true tag_name = "{new_version}" diff --git a/scrapy/VERSION b/scrapy/VERSION index b70ae75a8..7243b12cf 100644 --- a/scrapy/VERSION +++ b/scrapy/VERSION @@ -1 +1 @@ -2.14.1 +2.14.2 From 9dfe449d134bcd54c739703bab21ab019e597824 Mon Sep 17 00:00:00 2001 From: Adrian Chaves Date: Thu, 12 Mar 2026 16:46:32 +0100 Subject: [PATCH 067/248] Fix test issues with the no-reactor env --- tests/test_spidermiddleware_referer.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_spidermiddleware_referer.py b/tests/test_spidermiddleware_referer.py index 444839019..7431ea6ac 100644 --- a/tests/test_spidermiddleware_referer.py +++ b/tests/test_spidermiddleware_referer.py @@ -30,9 +30,9 @@ from scrapy.spidermiddlewares.referer import ( StrictOriginWhenCrossOriginPolicy, UnsafeUrlPolicy, ) -from scrapy.utils.defer import deferred_f_from_coro_f from scrapy.utils.misc import build_from_crawler from scrapy.utils.test import get_crawler +from tests.utils.decorators import coroutine_test class TestRefererMiddleware: @@ -1026,7 +1026,7 @@ class TestPolicyMethodResponseParamRename: ) -@deferred_f_from_coro_f +@coroutine_test async def test_response_policy_only_supports_policy_names(): crawler = get_crawler(settings_dict={"REFERRER_POLICY": "no-referrer"}) mw = build_from_crawler(RefererMiddleware, crawler) @@ -1065,7 +1065,7 @@ async def test_response_policy_only_supports_policy_names(): assert output[0].headers == {b"Referer": [b"https://example.com/"]} -@deferred_f_from_coro_f +@coroutine_test async def test_referer_policies_setting(): crawler = get_crawler( settings_dict={ From 4d2071f7b3c4838677431f1137fa313d9843f630 Mon Sep 17 00:00:00 2001 From: Adrian Chaves Date: Thu, 12 Mar 2026 16:49:29 +0100 Subject: [PATCH 068/248] Use fancy kwarg typing --- scrapy/spidermiddlewares/referer.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/scrapy/spidermiddlewares/referer.py b/scrapy/spidermiddlewares/referer.py index 6d0c657a0..c1c9044a5 100644 --- a/scrapy/spidermiddlewares/referer.py +++ b/scrapy/spidermiddlewares/referer.py @@ -20,11 +20,14 @@ from scrapy.utils.url import strip_url if TYPE_CHECKING: # typing.Self requires Python 3.11 - from typing_extensions import Self + from typing_extensions import Self, TypedDict, Unpack from scrapy.crawler import Crawler from scrapy.settings import BaseSettings + class _PolicyKwargs(TypedDict, total=False): + resp_or_url: Response | str + LOCAL_SCHEMES: tuple[str, ...] = ( "about", @@ -328,7 +331,7 @@ class RefererMiddleware(BaseSpiderMiddleware): self, response: Response | str | None = None, request: Request | None = None, - **kwargs, + **kwargs: Unpack[_PolicyKwargs], ) -> ReferrerPolicy: """Return the referrer policy to use for *request* based on *request* meta, *response* and settings. From 584d99af30b8b0ac35e0b913401e04d5bed2881f Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Fri, 13 Mar 2026 14:30:25 +0500 Subject: [PATCH 069/248] Split long test files (#7329) --- tests/test_crawler.py | 519 ------ tests/test_crawler_subprocess.py | 531 ++++++ tests/test_downloadermiddleware_redirect.py | 1169 +----------- ...test_downloadermiddleware_redirect_base.py | 1023 +++++++++++ ...wnloadermiddleware_redirect_metarefresh.py | 159 ++ tests/test_feedexport.py | 1631 +---------------- tests/test_feedexport_batch.py | 457 +++++ tests/test_feedexport_postprocess.py | 538 ++++++ tests/test_feedexport_storages.py | 558 ++++++ tests/test_feedexport_uri_params.py | 142 ++ tests/test_http_request.py | 1213 +----------- tests/test_http_request_form.py | 1027 +++++++++++ tests/test_http_request_json.py | 190 ++ tests/test_http_response.py | 689 +------ tests/test_http_response_text.py | 684 +++++++ tests/test_spider.py | 645 +------ tests/test_spider_crawl.py | 307 ++++ tests/test_spider_sitemap.py | 349 ++++ 18 files changed, 5984 insertions(+), 5847 deletions(-) create mode 100644 tests/test_crawler_subprocess.py create mode 100644 tests/test_downloadermiddleware_redirect_base.py create mode 100644 tests/test_downloadermiddleware_redirect_metarefresh.py create mode 100644 tests/test_feedexport_batch.py create mode 100644 tests/test_feedexport_postprocess.py create mode 100644 tests/test_feedexport_storages.py create mode 100644 tests/test_feedexport_uri_params.py create mode 100644 tests/test_http_request_form.py create mode 100644 tests/test_http_request_json.py create mode 100644 tests/test_http_response_text.py create mode 100644 tests/test_spider_crawl.py create mode 100644 tests/test_spider_sitemap.py diff --git a/tests/test_crawler.py b/tests/test_crawler.py index 9ade8472f..c14b3f13f 100644 --- a/tests/test_crawler.py +++ b/tests/test_crawler.py @@ -1,21 +1,13 @@ import asyncio import logging -import platform import re -import signal -import subprocess -import sys import warnings -from abc import ABC, abstractmethod from collections.abc import Generator from pathlib import Path from typing import Any import pytest -from packaging.version import parse as parse_version -from pexpect.popen_spawn import PopenSpawn from twisted.internet.defer import Deferred -from w3lib import __version__ as w3lib_version from zope.interface.exceptions import MultipleInvalid import scrapy @@ -30,7 +22,6 @@ from scrapy.crawler import ( from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.extensions.throttle import AutoThrottle from scrapy.settings import Settings, default_settings -from scrapy.utils.asyncio import call_later from scrapy.utils.defer import deferred_from_coro, maybe_deferred_to_future from scrapy.utils.log import ( _uninstall_scrapy_root_handler, @@ -39,8 +30,6 @@ from scrapy.utils.log import ( ) from scrapy.utils.spider import DefaultSpider from scrapy.utils.test import get_crawler, get_reactor_settings -from tests.mockserver.http import MockServer -from tests.utils import get_script_run_env from tests.utils.decorators import coroutine_test, inline_callbacks_test BASE_SETTINGS: dict[str, Any] = {} @@ -764,514 +753,6 @@ class TestAsyncCrawlerRunnerHasSpider(TestCrawlerRunnerHasSpider): pytest.skip("This test is only for CrawlerRunner") -class ScriptRunnerMixin(ABC): - @property - @abstractmethod - def script_dir(self) -> Path: - raise NotImplementedError - - @staticmethod - def get_script_dir(name: str) -> Path: - return Path(__file__).parent.resolve() / name - - def get_script_args(self, script_name: str, *script_args: str) -> list[str]: - script_path = self.script_dir / script_name - return [sys.executable, str(script_path), *script_args] - - def run_script(self, script_name: str, *script_args: str) -> str: - args = self.get_script_args(script_name, *script_args) - p = subprocess.Popen( - args, - env=get_script_run_env(), - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - _, stderr = p.communicate() - return stderr.decode("utf-8") - - -class TestCrawlerProcessSubprocessBase(ScriptRunnerMixin): - """Common tests between CrawlerProcess and AsyncCrawlerProcess, - with the same file names and expectations. - """ - - def test_simple(self): - log = self.run_script("simple.py") - assert "Spider closed (finished)" in log - assert ( - "Using reactor: twisted.internet.asyncioreactor.AsyncioSelectorReactor" - in log - ) - assert "is_reactorless(): False" in log - - def test_multi(self): - log = self.run_script("multi.py") - assert "Spider closed (finished)" in log - assert ( - "Using reactor: twisted.internet.asyncioreactor.AsyncioSelectorReactor" - in log - ) - assert "ReactorAlreadyInstalledError" not in log - - def test_reactor_default(self): - log = self.run_script("reactor_default.py") - assert "Spider closed (finished)" not in log - assert ( - "does not match the requested one " - "(twisted.internet.asyncioreactor.AsyncioSelectorReactor)" - ) in log - - def test_asyncio_enabled_no_reactor(self): - log = self.run_script("asyncio_enabled_no_reactor.py") - assert "Spider closed (finished)" in log - assert ( - "Using reactor: twisted.internet.asyncioreactor.AsyncioSelectorReactor" - in log - ) - assert "RuntimeError" not in log - - def test_asyncio_enabled_reactor(self): - log = self.run_script("asyncio_enabled_reactor.py") - assert "Spider closed (finished)" in log - assert ( - "Using reactor: twisted.internet.asyncioreactor.AsyncioSelectorReactor" - in log - ) - assert "RuntimeError" not in log - - @pytest.mark.skipif( - parse_version(w3lib_version) >= parse_version("2.0.0"), - reason="w3lib 2.0.0 and later do not allow invalid domains.", - ) - def test_ipv6_default_name_resolver(self): - log = self.run_script("default_name_resolver.py") - assert "Spider closed (finished)" in log - assert ( - "'downloader/exception_type_count/scrapy.exceptions.CannotResolveHostError': 1," - in log - ) - assert ( - "scrapy.exceptions.CannotResolveHostError: DNS lookup failed: no results for hostname lookup: ::1." - in log - ) - - def test_caching_hostname_resolver_ipv6(self): - log = self.run_script("caching_hostname_resolver_ipv6.py") - assert "Spider closed (finished)" in log - assert "scrapy.exceptions.CannotResolveHostError" not in log - - def test_caching_hostname_resolver_finite_execution( - self, mockserver: MockServer - ) -> None: - log = self.run_script("caching_hostname_resolver.py", mockserver.url("/")) - assert "Spider closed (finished)" in log - assert "ERROR: Error downloading" not in log - assert "TimeoutError" not in log - assert "scrapy.exceptions.CannotResolveHostError" not in log - - def test_twisted_reactor_asyncio(self): - log = self.run_script("twisted_reactor_asyncio.py") - assert "Spider closed (finished)" in log - assert ( - "Using reactor: twisted.internet.asyncioreactor.AsyncioSelectorReactor" - in log - ) - - def test_twisted_reactor_asyncio_custom_settings(self): - log = self.run_script("twisted_reactor_custom_settings.py") - assert "Spider closed (finished)" in log - assert ( - "Using reactor: twisted.internet.asyncioreactor.AsyncioSelectorReactor" - in log - ) - - def test_twisted_reactor_asyncio_custom_settings_same(self): - log = self.run_script("twisted_reactor_custom_settings_same.py") - assert "Spider closed (finished)" in log - assert ( - "Using reactor: twisted.internet.asyncioreactor.AsyncioSelectorReactor" - in log - ) - - @pytest.mark.requires_uvloop - def test_custom_loop_asyncio(self): - log = self.run_script("asyncio_custom_loop.py") - assert "Spider closed (finished)" in log - assert ( - "Using reactor: twisted.internet.asyncioreactor.AsyncioSelectorReactor" - in log - ) - assert "Using asyncio event loop: uvloop.Loop" in log - - @pytest.mark.requires_uvloop - def test_custom_loop_asyncio_deferred_signal(self): - log = self.run_script("asyncio_deferred_signal.py", "uvloop.Loop") - assert "Spider closed (finished)" in log - assert ( - "Using reactor: twisted.internet.asyncioreactor.AsyncioSelectorReactor" - in log - ) - assert "Using asyncio event loop: uvloop.Loop" in log - assert "async pipeline opened!" in log - - @pytest.mark.requires_uvloop - def test_asyncio_enabled_reactor_same_loop(self): - log = self.run_script("asyncio_enabled_reactor_same_loop.py") - assert "Spider closed (finished)" in log - assert ( - "Using reactor: twisted.internet.asyncioreactor.AsyncioSelectorReactor" - in log - ) - assert "Using asyncio event loop: uvloop.Loop" in log - - @pytest.mark.requires_uvloop - def test_asyncio_enabled_reactor_different_loop(self): - log = self.run_script("asyncio_enabled_reactor_different_loop.py") - assert "Spider closed (finished)" not in log - assert ( - "does not match the one specified in the ASYNCIO_EVENT_LOOP " - "setting (uvloop.Loop)" - ) in log - - def test_default_loop_asyncio_deferred_signal(self): - log = self.run_script("asyncio_deferred_signal.py") - assert "Spider closed (finished)" in log - assert ( - "Using reactor: twisted.internet.asyncioreactor.AsyncioSelectorReactor" - in log - ) - assert "Using asyncio event loop: uvloop.Loop" not in log - assert "async pipeline opened!" in log - - def test_args_change_settings(self): - log = self.run_script("args_settings.py") - assert "Spider closed (finished)" in log - assert "The value of FOO is 42" in log - - def test_shutdown_graceful(self): - sig = signal.SIGINT if sys.platform != "win32" else signal.SIGBREAK - args = self.get_script_args("sleeping.py", "3") - p = PopenSpawn(args, timeout=5, env=get_script_run_env()) - p.expect_exact("Spider opened") - p.expect_exact("Crawled (200)") - p.kill(sig) - p.expect_exact("shutting down gracefully") - p.expect_exact("Spider closed (shutdown)") - p.wait() - - @inline_callbacks_test - def test_shutdown_forced(self): - sig = signal.SIGINT if sys.platform != "win32" else signal.SIGBREAK - args = self.get_script_args("sleeping.py", "10") - p = PopenSpawn(args, timeout=5, env=get_script_run_env()) - p.expect_exact("Spider opened") - p.expect_exact("Crawled (200)") - p.kill(sig) - p.expect_exact("shutting down gracefully") - # sending the second signal too fast often causes problems - d = Deferred() - call_later(0.01, d.callback, None) - yield d - p.kill(sig) - p.expect_exact("forcing unclean shutdown") - p.wait() - - -class TestCrawlerProcessSubprocess(TestCrawlerProcessSubprocessBase): - @property - def script_dir(self) -> Path: - return self.get_script_dir("CrawlerProcess") - - def test_reactor_default_twisted_reactor_select(self): - log = self.run_script("reactor_default_twisted_reactor_select.py") - if platform.system() in ["Windows", "Darwin"]: - # The goal of this test function is to test that, when a reactor is - # installed (the default one here) and a different reactor is - # configured (select here), an error raises. - # - # In Windows the default reactor is the select reactor, so that - # error does not raise. - # - # If that ever becomes the case on more platforms (i.e. if Linux - # also starts using the select reactor by default in a future - # version of Twisted), then we will need to rethink this test. - assert "Spider closed (finished)" in log - else: - assert "Spider closed (finished)" not in log - assert ( - "does not match the requested one " - "(twisted.internet.selectreactor.SelectReactor)" - ) in log - - def test_reactor_select(self): - log = self.run_script("reactor_select.py") - assert "Spider closed (finished)" not in log - assert ( - "does not match the requested one " - "(twisted.internet.asyncioreactor.AsyncioSelectorReactor)" - ) in log - - def test_reactor_select_twisted_reactor_select(self): - log = self.run_script("reactor_select_twisted_reactor_select.py") - assert "Spider closed (finished)" in log - assert "ReactorAlreadyInstalledError" not in log - - def test_reactor_select_subclass_twisted_reactor_select(self): - log = self.run_script("reactor_select_subclass_twisted_reactor_select.py") - assert "Spider closed (finished)" not in log - assert ( - "does not match the requested one " - "(twisted.internet.selectreactor.SelectReactor)" - ) in log - - def test_twisted_reactor_select(self): - log = self.run_script("twisted_reactor_select.py") - assert "Spider closed (finished)" in log - assert "Using reactor: twisted.internet.selectreactor.SelectReactor" in log - - @pytest.mark.skipif( - platform.system() == "Windows", reason="PollReactor is not supported on Windows" - ) - def test_twisted_reactor_poll(self): - log = self.run_script("twisted_reactor_poll.py") - assert "Spider closed (finished)" in log - assert "Using reactor: twisted.internet.pollreactor.PollReactor" in log - - def test_twisted_reactor_asyncio_custom_settings_conflict(self): - log = self.run_script("twisted_reactor_custom_settings_conflict.py") - assert "Using reactor: twisted.internet.selectreactor.SelectReactor" in log - assert ( - "(twisted.internet.selectreactor.SelectReactor) does not match the requested one" - in log - ) - - def test_reactorless(self): - log = self.run_script("reactorless.py") - assert ( - "RuntimeError: CrawlerProcess doesn't support TWISTED_ENABLED=False" in log - ) - - -class TestAsyncCrawlerProcessSubprocess(TestCrawlerProcessSubprocessBase): - @property - def script_dir(self) -> Path: - return self.get_script_dir("AsyncCrawlerProcess") - - def test_twisted_reactor_custom_settings_select(self): - log = self.run_script("twisted_reactor_custom_settings_select.py") - assert "Spider closed (finished)" not in log - assert ( - "(twisted.internet.asyncioreactor.AsyncioSelectorReactor) " - "does not match the requested one " - "(twisted.internet.selectreactor.SelectReactor)" - ) in log - - @pytest.mark.requires_uvloop - def test_asyncio_enabled_reactor_same_loop(self): - log = self.run_script("asyncio_custom_loop_custom_settings_same.py") - assert "Spider closed (finished)" in log - assert ( - "Using reactor: twisted.internet.asyncioreactor.AsyncioSelectorReactor" - in log - ) - assert "Using asyncio event loop: uvloop.Loop" in log - - @pytest.mark.requires_uvloop - def test_asyncio_enabled_reactor_different_loop(self): - log = self.run_script("asyncio_custom_loop_custom_settings_different.py") - assert "Spider closed (finished)" not in log - assert ( - "does not match the one specified in the ASYNCIO_EVENT_LOOP " - "setting (uvloop.Loop)" - ) in log - - def test_reactorless_simple(self): - log = self.run_script("reactorless_simple.py") - assert "Not using a Twisted reactor" in log - assert "Spider closed (finished)" in log - assert "is_reactorless(): True" in log - assert "ERROR: " not in log - assert "WARNING: " not in log - - def test_reactorless_datauri(self): - log = self.run_script("reactorless_datauri.py") - assert "Not using a Twisted reactor" in log - assert "Spider closed (finished)" in log - assert "{'data': 'foo'}" in log - assert "'item_scraped_count': 1" in log - assert "ERROR: " not in log - assert "WARNING: " not in log - - def test_reactorless_import_hook(self): - log = self.run_script("reactorless_import_hook.py") - assert "Not using a Twisted reactor" in log - assert "Spider closed (finished)" in log - assert "ImportError: Import of twisted.internet.reactor is forbidden" in log - - def test_reactorless_telnetconsole_default(self): - """By default TWISTED_ENABLED=False silently sets TELNETCONSOLE_ENABLED=False.""" - log = self.run_script("reactorless_telnetconsole_default.py") - assert "Not using a Twisted reactor" in log - assert "Spider closed (finished)" in log - assert "The TelnetConsole extension requires a Twisted reactor" not in log - assert "scrapy.extensions.telnet.TelnetConsole" not in log - - def test_reactorless_telnetconsole_disabled(self): - """Explicit TELNETCONSOLE_ENABLED=False, there are no warnings.""" - log = self.run_script("reactorless_telnetconsole_disabled.py") - assert "Not using a Twisted reactor" in log - assert "Spider closed (finished)" in log - assert "The TelnetConsole extension requires a Twisted reactor" not in log - assert "scrapy.extensions.telnet.TelnetConsole" not in log - - def test_reactorless_telnetconsole_enabled(self): - """Explicit TELNETCONSOLE_ENABLED=True, the user gets a warning.""" - log = self.run_script("reactorless_telnetconsole_enabled.py") - assert "Not using a Twisted reactor" in log - assert "Spider closed (finished)" in log - assert "The TelnetConsole extension requires a Twisted reactor" in log - - def test_reactorless_reactor(self): - log = self.run_script("reactorless_reactor.py") - assert ( - "RuntimeError: TWISTED_ENABLED is False but a Twisted reactor is installed" - in log - ) - - -class TestCrawlerRunnerSubprocessBase(ScriptRunnerMixin): - """Common tests between CrawlerRunner and AsyncCrawlerRunner, - with the same file names and expectations. - """ - - def test_simple(self): - log = self.run_script("simple.py") - assert "Spider closed (finished)" in log - assert ( - "Using reactor: twisted.internet.asyncioreactor.AsyncioSelectorReactor" - in log - ) - assert "is_reactorless(): False" in log - - def test_multi_parallel(self): - log = self.run_script("multi_parallel.py") - assert "Spider closed (finished)" in log - assert ( - "Using reactor: twisted.internet.asyncioreactor.AsyncioSelectorReactor" - in log - ) - assert re.search( - r"Spider opened.+Spider opened.+Closing spider.+Closing spider", - log, - re.DOTALL, - ) - - def test_multi_seq(self): - log = self.run_script("multi_seq.py") - assert "Spider closed (finished)" in log - assert ( - "Using reactor: twisted.internet.asyncioreactor.AsyncioSelectorReactor" - in log - ) - assert re.search( - r"Spider opened.+Closing spider.+Spider opened.+Closing spider", - log, - re.DOTALL, - ) - - @pytest.mark.requires_uvloop - def test_custom_loop_same(self): - log = self.run_script("custom_loop_same.py") - assert "Spider closed (finished)" in log - assert ( - "Using reactor: twisted.internet.asyncioreactor.AsyncioSelectorReactor" - in log - ) - assert "Using asyncio event loop: uvloop.Loop" in log - - @pytest.mark.requires_uvloop - def test_custom_loop_different(self): - log = self.run_script("custom_loop_different.py") - assert "Spider closed (finished)" not in log - assert ( - "does not match the one specified in the ASYNCIO_EVENT_LOOP " - "setting (uvloop.Loop)" - ) in log - - -class TestCrawlerRunnerSubprocess(TestCrawlerRunnerSubprocessBase): - @property - def script_dir(self) -> Path: - return self.get_script_dir("CrawlerRunner") - - def test_explicit_default_reactor(self): - log = self.run_script("explicit_default_reactor.py") - assert "Spider closed (finished)" in log - assert ( - "Using reactor: twisted.internet.asyncioreactor.AsyncioSelectorReactor" - not in log - ) - - def test_response_ip_address(self): - log = self.run_script("ip_address.py") - assert "INFO: Spider closed (finished)" in log - assert "INFO: Host: not.a.real.domain" in log - assert "INFO: Type: " in log - assert "INFO: IP address: 127.0.0.1" in log - - def test_change_default_reactor(self): - log = self.run_script("change_reactor.py") - assert ( - "DEBUG: Using reactor: twisted.internet.asyncioreactor.AsyncioSelectorReactor" - in log - ) - assert "DEBUG: Using asyncio event loop" in log - - def test_reactorless(self): - log = self.run_script("reactorless.py") - assert ( - "RuntimeError: CrawlerRunner doesn't support TWISTED_ENABLED=False" in log - ) - - -class TestAsyncCrawlerRunnerSubprocess(TestCrawlerRunnerSubprocessBase): - @property - def script_dir(self) -> Path: - return self.get_script_dir("AsyncCrawlerRunner") - - def test_simple_default_reactor(self): - log = self.run_script("simple_default_reactor.py") - assert "Spider closed (finished)" not in log - assert ( - "RuntimeError: When TWISTED_ENABLED is True, " - "AsyncCrawlerRunner requires that the installed Twisted reactor" - ) in log - - def test_reactorless_simple(self): - log = self.run_script("reactorless_simple.py") - assert "Not using a Twisted reactor" in log - assert "Spider closed (finished)" in log - assert "is_reactorless(): True" in log - assert "ERROR: " not in log - assert "WARNING: " not in log - - def test_reactorless_datauri(self): - log = self.run_script("reactorless_datauri.py") - assert "Not using a Twisted reactor" in log - assert "Spider closed (finished)" in log - assert "{'data': 'foo'}" in log - assert "'item_scraped_count': 1" in log - assert "ERROR: " not in log - assert "WARNING: " not in log - - def test_reactorless_reactor(self): - log = self.run_script("reactorless_reactor.py") - assert ( - "RuntimeError: TWISTED_ENABLED is False but a Twisted reactor is installed" - in log - ) - - @pytest.mark.parametrize( ("settings", "items"), [ diff --git a/tests/test_crawler_subprocess.py b/tests/test_crawler_subprocess.py new file mode 100644 index 000000000..8d7b8f871 --- /dev/null +++ b/tests/test_crawler_subprocess.py @@ -0,0 +1,531 @@ +from __future__ import annotations + +import platform +import re +import signal +import subprocess +import sys +from abc import ABC, abstractmethod +from pathlib import Path +from typing import TYPE_CHECKING + +import pytest +from packaging.version import parse as parse_version +from pexpect.popen_spawn import PopenSpawn +from twisted.internet.defer import Deferred +from w3lib import __version__ as w3lib_version + +from scrapy.utils.asyncio import call_later +from tests.utils import get_script_run_env +from tests.utils.decorators import inline_callbacks_test + +if TYPE_CHECKING: + from tests.mockserver.http import MockServer + + +class ScriptRunnerMixin(ABC): + @property + @abstractmethod + def script_dir(self) -> Path: + raise NotImplementedError + + @staticmethod + def get_script_dir(name: str) -> Path: + return Path(__file__).parent.resolve() / name + + def get_script_args(self, script_name: str, *script_args: str) -> list[str]: + script_path = self.script_dir / script_name + return [sys.executable, str(script_path), *script_args] + + def run_script(self, script_name: str, *script_args: str) -> str: + args = self.get_script_args(script_name, *script_args) + p = subprocess.Popen( + args, + env=get_script_run_env(), + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + _, stderr = p.communicate() + return stderr.decode("utf-8") + + +class TestCrawlerProcessSubprocessBase(ScriptRunnerMixin): + """Common tests between CrawlerProcess and AsyncCrawlerProcess, + with the same file names and expectations. + """ + + def test_simple(self): + log = self.run_script("simple.py") + assert "Spider closed (finished)" in log + assert ( + "Using reactor: twisted.internet.asyncioreactor.AsyncioSelectorReactor" + in log + ) + assert "is_reactorless(): False" in log + + def test_multi(self): + log = self.run_script("multi.py") + assert "Spider closed (finished)" in log + assert ( + "Using reactor: twisted.internet.asyncioreactor.AsyncioSelectorReactor" + in log + ) + assert "ReactorAlreadyInstalledError" not in log + + def test_reactor_default(self): + log = self.run_script("reactor_default.py") + assert "Spider closed (finished)" not in log + assert ( + "does not match the requested one " + "(twisted.internet.asyncioreactor.AsyncioSelectorReactor)" + ) in log + + def test_asyncio_enabled_no_reactor(self): + log = self.run_script("asyncio_enabled_no_reactor.py") + assert "Spider closed (finished)" in log + assert ( + "Using reactor: twisted.internet.asyncioreactor.AsyncioSelectorReactor" + in log + ) + assert "RuntimeError" not in log + + def test_asyncio_enabled_reactor(self): + log = self.run_script("asyncio_enabled_reactor.py") + assert "Spider closed (finished)" in log + assert ( + "Using reactor: twisted.internet.asyncioreactor.AsyncioSelectorReactor" + in log + ) + assert "RuntimeError" not in log + + @pytest.mark.skipif( + parse_version(w3lib_version) >= parse_version("2.0.0"), + reason="w3lib 2.0.0 and later do not allow invalid domains.", + ) + def test_ipv6_default_name_resolver(self): + log = self.run_script("default_name_resolver.py") + assert "Spider closed (finished)" in log + assert ( + "'downloader/exception_type_count/scrapy.exceptions.CannotResolveHostError': 1," + in log + ) + assert ( + "scrapy.exceptions.CannotResolveHostError: DNS lookup failed: no results for hostname lookup: ::1." + in log + ) + + def test_caching_hostname_resolver_ipv6(self): + log = self.run_script("caching_hostname_resolver_ipv6.py") + assert "Spider closed (finished)" in log + assert "scrapy.exceptions.CannotResolveHostError" not in log + + def test_caching_hostname_resolver_finite_execution( + self, mockserver: MockServer + ) -> None: + log = self.run_script("caching_hostname_resolver.py", mockserver.url("/")) + assert "Spider closed (finished)" in log + assert "ERROR: Error downloading" not in log + assert "TimeoutError" not in log + assert "scrapy.exceptions.CannotResolveHostError" not in log + + def test_twisted_reactor_asyncio(self): + log = self.run_script("twisted_reactor_asyncio.py") + assert "Spider closed (finished)" in log + assert ( + "Using reactor: twisted.internet.asyncioreactor.AsyncioSelectorReactor" + in log + ) + + def test_twisted_reactor_asyncio_custom_settings(self): + log = self.run_script("twisted_reactor_custom_settings.py") + assert "Spider closed (finished)" in log + assert ( + "Using reactor: twisted.internet.asyncioreactor.AsyncioSelectorReactor" + in log + ) + + def test_twisted_reactor_asyncio_custom_settings_same(self): + log = self.run_script("twisted_reactor_custom_settings_same.py") + assert "Spider closed (finished)" in log + assert ( + "Using reactor: twisted.internet.asyncioreactor.AsyncioSelectorReactor" + in log + ) + + @pytest.mark.requires_uvloop + def test_custom_loop_asyncio(self): + log = self.run_script("asyncio_custom_loop.py") + assert "Spider closed (finished)" in log + assert ( + "Using reactor: twisted.internet.asyncioreactor.AsyncioSelectorReactor" + in log + ) + assert "Using asyncio event loop: uvloop.Loop" in log + + @pytest.mark.requires_uvloop + def test_custom_loop_asyncio_deferred_signal(self): + log = self.run_script("asyncio_deferred_signal.py", "uvloop.Loop") + assert "Spider closed (finished)" in log + assert ( + "Using reactor: twisted.internet.asyncioreactor.AsyncioSelectorReactor" + in log + ) + assert "Using asyncio event loop: uvloop.Loop" in log + assert "async pipeline opened!" in log + + @pytest.mark.requires_uvloop + def test_asyncio_enabled_reactor_same_loop(self): + log = self.run_script("asyncio_enabled_reactor_same_loop.py") + assert "Spider closed (finished)" in log + assert ( + "Using reactor: twisted.internet.asyncioreactor.AsyncioSelectorReactor" + in log + ) + assert "Using asyncio event loop: uvloop.Loop" in log + + @pytest.mark.requires_uvloop + def test_asyncio_enabled_reactor_different_loop(self): + log = self.run_script("asyncio_enabled_reactor_different_loop.py") + assert "Spider closed (finished)" not in log + assert ( + "does not match the one specified in the ASYNCIO_EVENT_LOOP " + "setting (uvloop.Loop)" + ) in log + + def test_default_loop_asyncio_deferred_signal(self): + log = self.run_script("asyncio_deferred_signal.py") + assert "Spider closed (finished)" in log + assert ( + "Using reactor: twisted.internet.asyncioreactor.AsyncioSelectorReactor" + in log + ) + assert "Using asyncio event loop: uvloop.Loop" not in log + assert "async pipeline opened!" in log + + def test_args_change_settings(self): + log = self.run_script("args_settings.py") + assert "Spider closed (finished)" in log + assert "The value of FOO is 42" in log + + def test_shutdown_graceful(self): + sig = signal.SIGINT if sys.platform != "win32" else signal.SIGBREAK + args = self.get_script_args("sleeping.py", "3") + p = PopenSpawn(args, timeout=5, env=get_script_run_env()) + p.expect_exact("Spider opened") + p.expect_exact("Crawled (200)") + p.kill(sig) + p.expect_exact("shutting down gracefully") + p.expect_exact("Spider closed (shutdown)") + p.wait() + + @inline_callbacks_test + def test_shutdown_forced(self): + sig = signal.SIGINT if sys.platform != "win32" else signal.SIGBREAK + args = self.get_script_args("sleeping.py", "10") + p = PopenSpawn(args, timeout=5, env=get_script_run_env()) + p.expect_exact("Spider opened") + p.expect_exact("Crawled (200)") + p.kill(sig) + p.expect_exact("shutting down gracefully") + # sending the second signal too fast often causes problems + d = Deferred() + call_later(0.01, d.callback, None) + yield d + p.kill(sig) + p.expect_exact("forcing unclean shutdown") + p.wait() + + +class TestCrawlerProcessSubprocess(TestCrawlerProcessSubprocessBase): + @property + def script_dir(self) -> Path: + return self.get_script_dir("CrawlerProcess") + + def test_reactor_default_twisted_reactor_select(self): + log = self.run_script("reactor_default_twisted_reactor_select.py") + if platform.system() in ["Windows", "Darwin"]: + # The goal of this test function is to test that, when a reactor is + # installed (the default one here) and a different reactor is + # configured (select here), an error raises. + # + # In Windows the default reactor is the select reactor, so that + # error does not raise. + # + # If that ever becomes the case on more platforms (i.e. if Linux + # also starts using the select reactor by default in a future + # version of Twisted), then we will need to rethink this test. + assert "Spider closed (finished)" in log + else: + assert "Spider closed (finished)" not in log + assert ( + "does not match the requested one " + "(twisted.internet.selectreactor.SelectReactor)" + ) in log + + def test_reactor_select(self): + log = self.run_script("reactor_select.py") + assert "Spider closed (finished)" not in log + assert ( + "does not match the requested one " + "(twisted.internet.asyncioreactor.AsyncioSelectorReactor)" + ) in log + + def test_reactor_select_twisted_reactor_select(self): + log = self.run_script("reactor_select_twisted_reactor_select.py") + assert "Spider closed (finished)" in log + assert "ReactorAlreadyInstalledError" not in log + + def test_reactor_select_subclass_twisted_reactor_select(self): + log = self.run_script("reactor_select_subclass_twisted_reactor_select.py") + assert "Spider closed (finished)" not in log + assert ( + "does not match the requested one " + "(twisted.internet.selectreactor.SelectReactor)" + ) in log + + def test_twisted_reactor_select(self): + log = self.run_script("twisted_reactor_select.py") + assert "Spider closed (finished)" in log + assert "Using reactor: twisted.internet.selectreactor.SelectReactor" in log + + @pytest.mark.skipif( + platform.system() == "Windows", reason="PollReactor is not supported on Windows" + ) + def test_twisted_reactor_poll(self): + log = self.run_script("twisted_reactor_poll.py") + assert "Spider closed (finished)" in log + assert "Using reactor: twisted.internet.pollreactor.PollReactor" in log + + def test_twisted_reactor_asyncio_custom_settings_conflict(self): + log = self.run_script("twisted_reactor_custom_settings_conflict.py") + assert "Using reactor: twisted.internet.selectreactor.SelectReactor" in log + assert ( + "(twisted.internet.selectreactor.SelectReactor) does not match the requested one" + in log + ) + + def test_reactorless(self): + log = self.run_script("reactorless.py") + assert ( + "RuntimeError: CrawlerProcess doesn't support TWISTED_ENABLED=False" in log + ) + + +class TestAsyncCrawlerProcessSubprocess(TestCrawlerProcessSubprocessBase): + @property + def script_dir(self) -> Path: + return self.get_script_dir("AsyncCrawlerProcess") + + def test_twisted_reactor_custom_settings_select(self): + log = self.run_script("twisted_reactor_custom_settings_select.py") + assert "Spider closed (finished)" not in log + assert ( + "(twisted.internet.asyncioreactor.AsyncioSelectorReactor) " + "does not match the requested one " + "(twisted.internet.selectreactor.SelectReactor)" + ) in log + + @pytest.mark.requires_uvloop + def test_asyncio_enabled_reactor_same_loop(self): + log = self.run_script("asyncio_custom_loop_custom_settings_same.py") + assert "Spider closed (finished)" in log + assert ( + "Using reactor: twisted.internet.asyncioreactor.AsyncioSelectorReactor" + in log + ) + assert "Using asyncio event loop: uvloop.Loop" in log + + @pytest.mark.requires_uvloop + def test_asyncio_enabled_reactor_different_loop(self): + log = self.run_script("asyncio_custom_loop_custom_settings_different.py") + assert "Spider closed (finished)" not in log + assert ( + "does not match the one specified in the ASYNCIO_EVENT_LOOP " + "setting (uvloop.Loop)" + ) in log + + def test_reactorless_simple(self): + log = self.run_script("reactorless_simple.py") + assert "Not using a Twisted reactor" in log + assert "Spider closed (finished)" in log + assert "is_reactorless(): True" in log + assert "ERROR: " not in log + assert "WARNING: " not in log + + def test_reactorless_datauri(self): + log = self.run_script("reactorless_datauri.py") + assert "Not using a Twisted reactor" in log + assert "Spider closed (finished)" in log + assert "{'data': 'foo'}" in log + assert "'item_scraped_count': 1" in log + assert "ERROR: " not in log + assert "WARNING: " not in log + + def test_reactorless_import_hook(self): + log = self.run_script("reactorless_import_hook.py") + assert "Not using a Twisted reactor" in log + assert "Spider closed (finished)" in log + assert "ImportError: Import of twisted.internet.reactor is forbidden" in log + + def test_reactorless_telnetconsole_default(self): + """By default TWISTED_ENABLED=False silently sets TELNETCONSOLE_ENABLED=False.""" + log = self.run_script("reactorless_telnetconsole_default.py") + assert "Not using a Twisted reactor" in log + assert "Spider closed (finished)" in log + assert "The TelnetConsole extension requires a Twisted reactor" not in log + assert "scrapy.extensions.telnet.TelnetConsole" not in log + + def test_reactorless_telnetconsole_disabled(self): + """Explicit TELNETCONSOLE_ENABLED=False, there are no warnings.""" + log = self.run_script("reactorless_telnetconsole_disabled.py") + assert "Not using a Twisted reactor" in log + assert "Spider closed (finished)" in log + assert "The TelnetConsole extension requires a Twisted reactor" not in log + assert "scrapy.extensions.telnet.TelnetConsole" not in log + + def test_reactorless_telnetconsole_enabled(self): + """Explicit TELNETCONSOLE_ENABLED=True, the user gets a warning.""" + log = self.run_script("reactorless_telnetconsole_enabled.py") + assert "Not using a Twisted reactor" in log + assert "Spider closed (finished)" in log + assert "The TelnetConsole extension requires a Twisted reactor" in log + + def test_reactorless_reactor(self): + log = self.run_script("reactorless_reactor.py") + assert ( + "RuntimeError: TWISTED_ENABLED is False but a Twisted reactor is installed" + in log + ) + + +class TestCrawlerRunnerSubprocessBase(ScriptRunnerMixin): + """Common tests between CrawlerRunner and AsyncCrawlerRunner, + with the same file names and expectations. + """ + + def test_simple(self): + log = self.run_script("simple.py") + assert "Spider closed (finished)" in log + assert ( + "Using reactor: twisted.internet.asyncioreactor.AsyncioSelectorReactor" + in log + ) + assert "is_reactorless(): False" in log + + def test_multi_parallel(self): + log = self.run_script("multi_parallel.py") + assert "Spider closed (finished)" in log + assert ( + "Using reactor: twisted.internet.asyncioreactor.AsyncioSelectorReactor" + in log + ) + assert re.search( + r"Spider opened.+Spider opened.+Closing spider.+Closing spider", + log, + re.DOTALL, + ) + + def test_multi_seq(self): + log = self.run_script("multi_seq.py") + assert "Spider closed (finished)" in log + assert ( + "Using reactor: twisted.internet.asyncioreactor.AsyncioSelectorReactor" + in log + ) + assert re.search( + r"Spider opened.+Closing spider.+Spider opened.+Closing spider", + log, + re.DOTALL, + ) + + @pytest.mark.requires_uvloop + def test_custom_loop_same(self): + log = self.run_script("custom_loop_same.py") + assert "Spider closed (finished)" in log + assert ( + "Using reactor: twisted.internet.asyncioreactor.AsyncioSelectorReactor" + in log + ) + assert "Using asyncio event loop: uvloop.Loop" in log + + @pytest.mark.requires_uvloop + def test_custom_loop_different(self): + log = self.run_script("custom_loop_different.py") + assert "Spider closed (finished)" not in log + assert ( + "does not match the one specified in the ASYNCIO_EVENT_LOOP " + "setting (uvloop.Loop)" + ) in log + + +class TestCrawlerRunnerSubprocess(TestCrawlerRunnerSubprocessBase): + @property + def script_dir(self) -> Path: + return self.get_script_dir("CrawlerRunner") + + def test_explicit_default_reactor(self): + log = self.run_script("explicit_default_reactor.py") + assert "Spider closed (finished)" in log + assert ( + "Using reactor: twisted.internet.asyncioreactor.AsyncioSelectorReactor" + not in log + ) + + def test_response_ip_address(self): + log = self.run_script("ip_address.py") + assert "INFO: Spider closed (finished)" in log + assert "INFO: Host: not.a.real.domain" in log + assert "INFO: Type: " in log + assert "INFO: IP address: 127.0.0.1" in log + + def test_change_default_reactor(self): + log = self.run_script("change_reactor.py") + assert ( + "DEBUG: Using reactor: twisted.internet.asyncioreactor.AsyncioSelectorReactor" + in log + ) + assert "DEBUG: Using asyncio event loop" in log + + def test_reactorless(self): + log = self.run_script("reactorless.py") + assert ( + "RuntimeError: CrawlerRunner doesn't support TWISTED_ENABLED=False" in log + ) + + +class TestAsyncCrawlerRunnerSubprocess(TestCrawlerRunnerSubprocessBase): + @property + def script_dir(self) -> Path: + return self.get_script_dir("AsyncCrawlerRunner") + + def test_simple_default_reactor(self): + log = self.run_script("simple_default_reactor.py") + assert "Spider closed (finished)" not in log + assert ( + "RuntimeError: When TWISTED_ENABLED is True, " + "AsyncCrawlerRunner requires that the installed Twisted reactor" + ) in log + + def test_reactorless_simple(self): + log = self.run_script("reactorless_simple.py") + assert "Not using a Twisted reactor" in log + assert "Spider closed (finished)" in log + assert "is_reactorless(): True" in log + assert "ERROR: " not in log + assert "WARNING: " not in log + + def test_reactorless_datauri(self): + log = self.run_script("reactorless_datauri.py") + assert "Not using a Twisted reactor" in log + assert "Spider closed (finished)" in log + assert "{'data': 'foo'}" in log + assert "'item_scraped_count': 1" in log + assert "ERROR: " not in log + assert "WARNING: " not in log + + def test_reactorless_reactor(self): + log = self.run_script("reactorless_reactor.py") + assert ( + "RuntimeError: TWISTED_ENABLED is False but a Twisted reactor is installed" + in log + ) diff --git a/tests/test_downloadermiddleware_redirect.py b/tests/test_downloadermiddleware_redirect.py index ab63ad545..42a25cd5b 100644 --- a/tests/test_downloadermiddleware_redirect.py +++ b/tests/test_downloadermiddleware_redirect.py @@ -1,16 +1,10 @@ import logging -from itertools import chain, product from unittest.mock import MagicMock import pytest -from scrapy.downloadermiddlewares.httpproxy import HttpProxyMiddleware -from scrapy.downloadermiddlewares.redirect import ( - MetaRefreshMiddleware, - RedirectMiddleware, -) -from scrapy.exceptions import IgnoreRequest -from scrapy.http import HtmlResponse, Request, Response +from scrapy.downloadermiddlewares.redirect import RedirectMiddleware +from scrapy.http import Request, Response from scrapy.spidermiddlewares.referer import ( POLICY_NO_REFERRER, POLICY_ORIGIN, @@ -18,984 +12,14 @@ from scrapy.spidermiddlewares.referer import ( RefererMiddleware, ) from scrapy.spiders import Spider -from scrapy.utils.misc import build_from_crawler, set_environ +from scrapy.utils.misc import build_from_crawler from scrapy.utils.spider import DefaultSpider from scrapy.utils.test import get_crawler - - -class Base: - class Test: - def test_priority_adjust(self): - req = Request("http://a.example") - rsp = self.get_response(req, "http://a.example/redirected") - req2 = self.mw.process_response(req, rsp) - assert req2.priority > req.priority - - def test_dont_redirect(self): - url = "http://www.example.com/301" - url2 = "http://www.example.com/redirected" - req = Request(url, meta={"dont_redirect": True}) - rsp = self.get_response(req, url2) - - r = self.mw.process_response(req, rsp) - assert isinstance(r, Response) - assert r is rsp - - # Test that it redirects when dont_redirect is False - req = Request(url, meta={"dont_redirect": False}) - rsp = self.get_response(req, url2) - - r = self.mw.process_response(req, rsp) - assert isinstance(r, Request) - - def test_post(self): - url = "http://www.example.com/302" - url2 = "http://www.example.com/redirected2" - req = Request( - url, - method="POST", - body="test", - headers={"Content-Type": "text/plain", "Content-length": "4"}, - ) - rsp = self.get_response(req, url2) - - req2 = self.mw.process_response(req, rsp) - assert isinstance(req2, Request) - assert req2.url == url2 - assert req2.method == "GET" - assert "Content-Type" not in req2.headers, ( - "Content-Type header must not be present in redirected request" - ) - assert "Content-Length" not in req2.headers, ( - "Content-Length header must not be present in redirected request" - ) - assert not req2.body, f"Redirected body must be empty, not '{req2.body}'" - - def test_max_redirect_times(self): - self.mw.max_redirect_times = 1 - req = Request("http://a.example/302") - rsp = self.get_response(req, "/redirected") - - req = self.mw.process_response(req, rsp) - assert isinstance(req, Request) - assert "redirect_times" in req.meta - assert req.meta["redirect_times"] == 1 - with pytest.raises(IgnoreRequest): - self.mw.process_response(req, rsp) - - def test_ttl(self): - self.mw.max_redirect_times = 100 - req = Request("http://a.example/302", meta={"redirect_ttl": 1}) - rsp = self.get_response(req, "/a") - - req = self.mw.process_response(req, rsp) - assert isinstance(req, Request) - with pytest.raises(IgnoreRequest): - self.mw.process_response(req, rsp) - - def test_redirect_urls(self): - req1 = Request("http://a.example/first") - rsp1 = self.get_response(req1, "/redirected") - req2 = self.mw.process_response(req1, rsp1) - rsp2 = self.get_response(req1, "/redirected2") - req3 = self.mw.process_response(req2, rsp2) - - assert req2.url == "http://a.example/redirected" - assert req2.meta["redirect_urls"] == ["http://a.example/first"] - assert req3.url == "http://a.example/redirected2" - assert req3.meta["redirect_urls"] == [ - "http://a.example/first", - "http://a.example/redirected", - ] - - def test_redirect_reasons(self): - req1 = Request("http://a.example/first") - rsp1 = self.get_response(req1, "/redirected1") - req2 = self.mw.process_response(req1, rsp1) - rsp2 = self.get_response(req2, "/redirected2") - req3 = self.mw.process_response(req2, rsp2) - assert req2.meta["redirect_reasons"] == [self.reason] - assert req3.meta["redirect_reasons"] == [self.reason, self.reason] - - def test_cross_origin_header_dropping(self): - safe_headers = {"A": "B"} - cookie_header = {"Cookie": "a=b"} - authorization_header = {"Authorization": "Bearer 123456"} - - original_request = Request( - "https://example.com", - headers={**safe_headers, **cookie_header, **authorization_header}, - ) - - # Redirects to the same origin (same scheme, same domain, same port) - # keep all headers. - internal_response = self.get_response( - original_request, "https://example.com/a" - ) - internal_redirect_request = self.mw.process_response( - original_request, internal_response - ) - assert isinstance(internal_redirect_request, Request) - assert original_request.headers == internal_redirect_request.headers - - # Redirects to the same origin (same scheme, same domain, same port) - # keep all headers also when the scheme is http. - http_request = Request( - "http://example.com", - headers={**safe_headers, **cookie_header, **authorization_header}, - ) - http_response = self.get_response(http_request, "http://example.com/a") - http_redirect_request = self.mw.process_response( - http_request, http_response - ) - assert isinstance(http_redirect_request, Request) - assert http_request.headers == http_redirect_request.headers - - # For default ports, whether the port is explicit or implicit does not - # affect the outcome, it is still the same origin. - to_explicit_port_response = self.get_response( - original_request, "https://example.com:443/a" - ) - to_explicit_port_redirect_request = self.mw.process_response( - original_request, to_explicit_port_response - ) - assert isinstance(to_explicit_port_redirect_request, Request) - assert original_request.headers == to_explicit_port_redirect_request.headers - - # For default ports, whether the port is explicit or implicit does not - # affect the outcome, it is still the same origin. - to_implicit_port_response = self.get_response( - original_request, "https://example.com/a" - ) - to_implicit_port_redirect_request = self.mw.process_response( - original_request, to_implicit_port_response - ) - assert isinstance(to_implicit_port_redirect_request, Request) - assert original_request.headers == to_implicit_port_redirect_request.headers - - # A port change drops the Authorization header because the origin - # changes, but keeps the Cookie header because the domain remains the - # same. - different_port_response = self.get_response( - original_request, "https://example.com:8080/a" - ) - different_port_redirect_request = self.mw.process_response( - original_request, different_port_response - ) - assert isinstance(different_port_redirect_request, Request) - assert { - **safe_headers, - **cookie_header, - } == different_port_redirect_request.headers.to_unicode_dict() - - # A domain change drops both the Authorization and the Cookie header. - external_response = self.get_response( - original_request, "https://example.org/a" - ) - external_redirect_request = self.mw.process_response( - original_request, external_response - ) - assert isinstance(external_redirect_request, Request) - assert safe_headers == external_redirect_request.headers.to_unicode_dict() - - # A scheme upgrade (http → https) drops the Authorization header - # because the origin changes, but keeps the Cookie header because the - # domain remains the same. - upgrade_response = self.get_response(http_request, "https://example.com/a") - upgrade_redirect_request = self.mw.process_response( - http_request, upgrade_response - ) - assert isinstance(upgrade_redirect_request, Request) - assert { - **safe_headers, - **cookie_header, - } == upgrade_redirect_request.headers.to_unicode_dict() - - # A scheme downgrade (https → http) drops the Authorization header - # because the origin changes, and the Cookie header because its value - # cannot indicate whether the cookies were secure (HTTPS-only) or not. - # - # Note: If the Cookie header is set by the cookie management - # middleware, as recommended in the docs, the dropping of Cookie on - # scheme downgrade is not an issue, because the cookie management - # middleware will add again the Cookie header to the new request if - # appropriate. - downgrade_response = self.get_response( - original_request, "http://example.com/a" - ) - downgrade_redirect_request = self.mw.process_response( - original_request, downgrade_response - ) - assert isinstance(downgrade_redirect_request, Request) - assert safe_headers == downgrade_redirect_request.headers.to_unicode_dict() - - def test_meta_proxy_http_absolute(self): - crawler = get_crawler() - redirect_mw = self.mwcls.from_crawler(crawler) - proxy_mw = HttpProxyMiddleware.from_crawler(crawler) - - meta = {"proxy": "https://a:@a.example"} - request1 = Request("http://example.com", meta=meta) - proxy_mw.process_request(request1) - - assert request1.headers["Proxy-Authorization"] == b"Basic YTo=" - assert request1.meta["_auth_proxy"] == "https://a.example" - assert request1.meta["proxy"] == "https://a.example" - - response1 = self.get_response(request1, "http://example.com") - request2 = redirect_mw.process_response(request1, response1) - - assert isinstance(request2, Request) - assert request2.headers["Proxy-Authorization"] == b"Basic YTo=" - assert request2.meta["_auth_proxy"] == "https://a.example" - assert request2.meta["proxy"] == "https://a.example" - - proxy_mw.process_request(request2) - - assert request2.headers["Proxy-Authorization"] == b"Basic YTo=" - assert request2.meta["_auth_proxy"] == "https://a.example" - assert request2.meta["proxy"] == "https://a.example" - - response2 = self.get_response(request2, "http://example.com") - request3 = redirect_mw.process_response(request2, response2) - - assert isinstance(request3, Request) - assert request3.headers["Proxy-Authorization"] == b"Basic YTo=" - assert request3.meta["_auth_proxy"] == "https://a.example" - assert request3.meta["proxy"] == "https://a.example" - - proxy_mw.process_request(request3) - - assert request3.headers["Proxy-Authorization"] == b"Basic YTo=" - assert request3.meta["_auth_proxy"] == "https://a.example" - assert request3.meta["proxy"] == "https://a.example" - - def test_meta_proxy_http_relative(self): - crawler = get_crawler() - redirect_mw = self.mwcls.from_crawler(crawler) - proxy_mw = HttpProxyMiddleware.from_crawler(crawler) - - meta = {"proxy": "https://a:@a.example"} - request1 = Request("http://example.com", meta=meta) - proxy_mw.process_request(request1) - - assert request1.headers["Proxy-Authorization"] == b"Basic YTo=" - assert request1.meta["_auth_proxy"] == "https://a.example" - assert request1.meta["proxy"] == "https://a.example" - - response1 = self.get_response(request1, "/a") - request2 = redirect_mw.process_response(request1, response1) - - assert isinstance(request2, Request) - assert request2.headers["Proxy-Authorization"] == b"Basic YTo=" - assert request2.meta["_auth_proxy"] == "https://a.example" - assert request2.meta["proxy"] == "https://a.example" - - proxy_mw.process_request(request2) - - assert request2.headers["Proxy-Authorization"] == b"Basic YTo=" - assert request2.meta["_auth_proxy"] == "https://a.example" - assert request2.meta["proxy"] == "https://a.example" - - response2 = self.get_response(request2, "/a") - request3 = redirect_mw.process_response(request2, response2) - - assert isinstance(request3, Request) - assert request3.headers["Proxy-Authorization"] == b"Basic YTo=" - assert request3.meta["_auth_proxy"] == "https://a.example" - assert request3.meta["proxy"] == "https://a.example" - - proxy_mw.process_request(request3) - - assert request3.headers["Proxy-Authorization"] == b"Basic YTo=" - assert request3.meta["_auth_proxy"] == "https://a.example" - assert request3.meta["proxy"] == "https://a.example" - - def test_meta_proxy_https_absolute(self): - crawler = get_crawler() - redirect_mw = self.mwcls.from_crawler(crawler) - proxy_mw = HttpProxyMiddleware.from_crawler(crawler) - - meta = {"proxy": "https://a:@a.example"} - request1 = Request("https://example.com", meta=meta) - proxy_mw.process_request(request1) - - assert request1.headers["Proxy-Authorization"] == b"Basic YTo=" - assert request1.meta["_auth_proxy"] == "https://a.example" - assert request1.meta["proxy"] == "https://a.example" - - response1 = self.get_response(request1, "https://example.com") - request2 = redirect_mw.process_response(request1, response1) - - assert isinstance(request2, Request) - assert request2.headers["Proxy-Authorization"] == b"Basic YTo=" - assert request2.meta["_auth_proxy"] == "https://a.example" - assert request2.meta["proxy"] == "https://a.example" - - proxy_mw.process_request(request2) - - assert request2.headers["Proxy-Authorization"] == b"Basic YTo=" - assert request2.meta["_auth_proxy"] == "https://a.example" - assert request2.meta["proxy"] == "https://a.example" - - response2 = self.get_response(request2, "https://example.com") - request3 = redirect_mw.process_response(request2, response2) - - assert isinstance(request3, Request) - assert request3.headers["Proxy-Authorization"] == b"Basic YTo=" - assert request3.meta["_auth_proxy"] == "https://a.example" - assert request3.meta["proxy"] == "https://a.example" - - proxy_mw.process_request(request3) - - assert request3.headers["Proxy-Authorization"] == b"Basic YTo=" - assert request3.meta["_auth_proxy"] == "https://a.example" - assert request3.meta["proxy"] == "https://a.example" - - def test_meta_proxy_https_relative(self): - crawler = get_crawler() - redirect_mw = self.mwcls.from_crawler(crawler) - proxy_mw = HttpProxyMiddleware.from_crawler(crawler) - - meta = {"proxy": "https://a:@a.example"} - request1 = Request("https://example.com", meta=meta) - proxy_mw.process_request(request1) - - assert request1.headers["Proxy-Authorization"] == b"Basic YTo=" - assert request1.meta["_auth_proxy"] == "https://a.example" - assert request1.meta["proxy"] == "https://a.example" - - response1 = self.get_response(request1, "/a") - request2 = redirect_mw.process_response(request1, response1) - - assert isinstance(request2, Request) - assert request2.headers["Proxy-Authorization"] == b"Basic YTo=" - assert request2.meta["_auth_proxy"] == "https://a.example" - assert request2.meta["proxy"] == "https://a.example" - - proxy_mw.process_request(request2) - - assert request2.headers["Proxy-Authorization"] == b"Basic YTo=" - assert request2.meta["_auth_proxy"] == "https://a.example" - assert request2.meta["proxy"] == "https://a.example" - - response2 = self.get_response(request2, "/a") - request3 = redirect_mw.process_response(request2, response2) - - assert isinstance(request3, Request) - assert request3.headers["Proxy-Authorization"] == b"Basic YTo=" - assert request3.meta["_auth_proxy"] == "https://a.example" - assert request3.meta["proxy"] == "https://a.example" - - proxy_mw.process_request(request3) - - assert request3.headers["Proxy-Authorization"] == b"Basic YTo=" - assert request3.meta["_auth_proxy"] == "https://a.example" - assert request3.meta["proxy"] == "https://a.example" - - def test_meta_proxy_http_to_https(self): - crawler = get_crawler() - redirect_mw = self.mwcls.from_crawler(crawler) - proxy_mw = HttpProxyMiddleware.from_crawler(crawler) - - meta = {"proxy": "https://a:@a.example"} - request1 = Request("http://example.com", meta=meta) - proxy_mw.process_request(request1) - - assert request1.headers["Proxy-Authorization"] == b"Basic YTo=" - assert request1.meta["_auth_proxy"] == "https://a.example" - assert request1.meta["proxy"] == "https://a.example" - - response1 = self.get_response(request1, "https://example.com") - request2 = redirect_mw.process_response(request1, response1) - - assert isinstance(request2, Request) - assert request2.headers["Proxy-Authorization"] == b"Basic YTo=" - assert request2.meta["_auth_proxy"] == "https://a.example" - assert request2.meta["proxy"] == "https://a.example" - - proxy_mw.process_request(request2) - - assert request2.headers["Proxy-Authorization"] == b"Basic YTo=" - assert request2.meta["_auth_proxy"] == "https://a.example" - assert request2.meta["proxy"] == "https://a.example" - - response2 = self.get_response(request2, "http://example.com") - request3 = redirect_mw.process_response(request2, response2) - - assert isinstance(request3, Request) - assert request3.headers["Proxy-Authorization"] == b"Basic YTo=" - assert request3.meta["_auth_proxy"] == "https://a.example" - assert request3.meta["proxy"] == "https://a.example" - - proxy_mw.process_request(request3) - - assert request3.headers["Proxy-Authorization"] == b"Basic YTo=" - assert request3.meta["_auth_proxy"] == "https://a.example" - assert request3.meta["proxy"] == "https://a.example" - - def test_meta_proxy_https_to_http(self): - crawler = get_crawler() - redirect_mw = self.mwcls.from_crawler(crawler) - proxy_mw = HttpProxyMiddleware.from_crawler(crawler) - - meta = {"proxy": "https://a:@a.example"} - request1 = Request("https://example.com", meta=meta) - proxy_mw.process_request(request1) - - assert request1.headers["Proxy-Authorization"] == b"Basic YTo=" - assert request1.meta["_auth_proxy"] == "https://a.example" - assert request1.meta["proxy"] == "https://a.example" - - response1 = self.get_response(request1, "http://example.com") - request2 = redirect_mw.process_response(request1, response1) - - assert isinstance(request2, Request) - assert request2.headers["Proxy-Authorization"] == b"Basic YTo=" - assert request2.meta["_auth_proxy"] == "https://a.example" - assert request2.meta["proxy"] == "https://a.example" - - proxy_mw.process_request(request2) - - assert request2.headers["Proxy-Authorization"] == b"Basic YTo=" - assert request2.meta["_auth_proxy"] == "https://a.example" - assert request2.meta["proxy"] == "https://a.example" - - response2 = self.get_response(request2, "https://example.com") - request3 = redirect_mw.process_response(request2, response2) - - assert isinstance(request3, Request) - assert request3.headers["Proxy-Authorization"] == b"Basic YTo=" - assert request3.meta["_auth_proxy"] == "https://a.example" - assert request3.meta["proxy"] == "https://a.example" - - proxy_mw.process_request(request3) - - assert request3.headers["Proxy-Authorization"] == b"Basic YTo=" - assert request3.meta["_auth_proxy"] == "https://a.example" - assert request3.meta["proxy"] == "https://a.example" - - def test_system_proxy_http_absolute(self): - crawler = get_crawler() - redirect_mw = self.mwcls.from_crawler(crawler) - env = { - "http_proxy": "https://a:@a.example", - } - with set_environ(**env): - proxy_mw = HttpProxyMiddleware.from_crawler(crawler) - - request1 = Request("http://example.com") - proxy_mw.process_request(request1) - - assert request1.headers["Proxy-Authorization"] == b"Basic YTo=" - assert request1.meta["_auth_proxy"] == "https://a.example" - assert request1.meta["proxy"] == "https://a.example" - - response1 = self.get_response(request1, "http://example.com") - request2 = redirect_mw.process_response(request1, response1) - - assert isinstance(request2, Request) - assert request2.headers["Proxy-Authorization"] == b"Basic YTo=" - assert request2.meta["_auth_proxy"] == "https://a.example" - assert request2.meta["proxy"] == "https://a.example" - - proxy_mw.process_request(request2) - - assert request2.headers["Proxy-Authorization"] == b"Basic YTo=" - assert request2.meta["_auth_proxy"] == "https://a.example" - assert request2.meta["proxy"] == "https://a.example" - - response2 = self.get_response(request2, "http://example.com") - request3 = redirect_mw.process_response(request2, response2) - - assert isinstance(request3, Request) - assert request3.headers["Proxy-Authorization"] == b"Basic YTo=" - assert request3.meta["_auth_proxy"] == "https://a.example" - assert request3.meta["proxy"] == "https://a.example" - - proxy_mw.process_request(request3) - - assert request3.headers["Proxy-Authorization"] == b"Basic YTo=" - assert request3.meta["_auth_proxy"] == "https://a.example" - assert request3.meta["proxy"] == "https://a.example" - - def test_system_proxy_http_relative(self): - crawler = get_crawler() - redirect_mw = self.mwcls.from_crawler(crawler) - env = { - "http_proxy": "https://a:@a.example", - } - with set_environ(**env): - proxy_mw = HttpProxyMiddleware.from_crawler(crawler) - - request1 = Request("http://example.com") - proxy_mw.process_request(request1) - - assert request1.headers["Proxy-Authorization"] == b"Basic YTo=" - assert request1.meta["_auth_proxy"] == "https://a.example" - assert request1.meta["proxy"] == "https://a.example" - - response1 = self.get_response(request1, "/a") - request2 = redirect_mw.process_response(request1, response1) - - assert isinstance(request2, Request) - assert request2.headers["Proxy-Authorization"] == b"Basic YTo=" - assert request2.meta["_auth_proxy"] == "https://a.example" - assert request2.meta["proxy"] == "https://a.example" - - proxy_mw.process_request(request2) - - assert request2.headers["Proxy-Authorization"] == b"Basic YTo=" - assert request2.meta["_auth_proxy"] == "https://a.example" - assert request2.meta["proxy"] == "https://a.example" - - response2 = self.get_response(request2, "/a") - request3 = redirect_mw.process_response(request2, response2) - - assert isinstance(request3, Request) - assert request3.headers["Proxy-Authorization"] == b"Basic YTo=" - assert request3.meta["_auth_proxy"] == "https://a.example" - assert request3.meta["proxy"] == "https://a.example" - - proxy_mw.process_request(request3) - - assert request3.headers["Proxy-Authorization"] == b"Basic YTo=" - assert request3.meta["_auth_proxy"] == "https://a.example" - assert request3.meta["proxy"] == "https://a.example" - - def test_system_proxy_https_absolute(self): - crawler = get_crawler() - redirect_mw = self.mwcls.from_crawler(crawler) - env = { - "https_proxy": "https://a:@a.example", - } - with set_environ(**env): - proxy_mw = HttpProxyMiddleware.from_crawler(crawler) - - request1 = Request("https://example.com") - proxy_mw.process_request(request1) - - assert request1.headers["Proxy-Authorization"] == b"Basic YTo=" - assert request1.meta["_auth_proxy"] == "https://a.example" - assert request1.meta["proxy"] == "https://a.example" - - response1 = self.get_response(request1, "https://example.com") - request2 = redirect_mw.process_response(request1, response1) - - assert isinstance(request2, Request) - assert request2.headers["Proxy-Authorization"] == b"Basic YTo=" - assert request2.meta["_auth_proxy"] == "https://a.example" - assert request2.meta["proxy"] == "https://a.example" - - proxy_mw.process_request(request2) - - assert request2.headers["Proxy-Authorization"] == b"Basic YTo=" - assert request2.meta["_auth_proxy"] == "https://a.example" - assert request2.meta["proxy"] == "https://a.example" - - response2 = self.get_response(request2, "https://example.com") - request3 = redirect_mw.process_response(request2, response2) - - assert isinstance(request3, Request) - assert request3.headers["Proxy-Authorization"] == b"Basic YTo=" - assert request3.meta["_auth_proxy"] == "https://a.example" - assert request3.meta["proxy"] == "https://a.example" - - proxy_mw.process_request(request3) - - assert request3.headers["Proxy-Authorization"] == b"Basic YTo=" - assert request3.meta["_auth_proxy"] == "https://a.example" - assert request3.meta["proxy"] == "https://a.example" - - def test_system_proxy_https_relative(self): - crawler = get_crawler() - redirect_mw = self.mwcls.from_crawler(crawler) - env = { - "https_proxy": "https://a:@a.example", - } - with set_environ(**env): - proxy_mw = HttpProxyMiddleware.from_crawler(crawler) - - request1 = Request("https://example.com") - proxy_mw.process_request(request1) - - assert request1.headers["Proxy-Authorization"] == b"Basic YTo=" - assert request1.meta["_auth_proxy"] == "https://a.example" - assert request1.meta["proxy"] == "https://a.example" - - response1 = self.get_response(request1, "/a") - request2 = redirect_mw.process_response(request1, response1) - - assert isinstance(request2, Request) - assert request2.headers["Proxy-Authorization"] == b"Basic YTo=" - assert request2.meta["_auth_proxy"] == "https://a.example" - assert request2.meta["proxy"] == "https://a.example" - - proxy_mw.process_request(request2) - - assert request2.headers["Proxy-Authorization"] == b"Basic YTo=" - assert request2.meta["_auth_proxy"] == "https://a.example" - assert request2.meta["proxy"] == "https://a.example" - - response2 = self.get_response(request2, "/a") - request3 = redirect_mw.process_response(request2, response2) - - assert isinstance(request3, Request) - assert request3.headers["Proxy-Authorization"] == b"Basic YTo=" - assert request3.meta["_auth_proxy"] == "https://a.example" - assert request3.meta["proxy"] == "https://a.example" - - proxy_mw.process_request(request3) - - assert request3.headers["Proxy-Authorization"] == b"Basic YTo=" - assert request3.meta["_auth_proxy"] == "https://a.example" - assert request3.meta["proxy"] == "https://a.example" - - def test_system_proxy_proxied_http_to_proxied_https(self): - crawler = get_crawler() - redirect_mw = self.mwcls.from_crawler(crawler) - env = { - "http_proxy": "https://a:@a.example", - "https_proxy": "https://b:@b.example", - } - with set_environ(**env): - proxy_mw = HttpProxyMiddleware.from_crawler(crawler) - - request1 = Request("http://example.com") - proxy_mw.process_request(request1) - - assert request1.headers["Proxy-Authorization"] == b"Basic YTo=" - assert request1.meta["_auth_proxy"] == "https://a.example" - assert request1.meta["proxy"] == "https://a.example" - - response1 = self.get_response(request1, "https://example.com") - request2 = redirect_mw.process_response(request1, response1) - - assert isinstance(request2, Request) - assert "Proxy-Authorization" not in request2.headers - assert "_auth_proxy" not in request2.meta - assert "proxy" not in request2.meta - - proxy_mw.process_request(request2) - - assert request2.headers["Proxy-Authorization"] == b"Basic Yjo=" - assert request2.meta["_auth_proxy"] == "https://b.example" - assert request2.meta["proxy"] == "https://b.example" - - response2 = self.get_response(request2, "http://example.com") - request3 = redirect_mw.process_response(request2, response2) - - assert isinstance(request3, Request) - assert "Proxy-Authorization" not in request3.headers - assert "_auth_proxy" not in request3.meta - assert "proxy" not in request3.meta - - proxy_mw.process_request(request3) - - assert request3.headers["Proxy-Authorization"] == b"Basic YTo=" - assert request3.meta["_auth_proxy"] == "https://a.example" - assert request3.meta["proxy"] == "https://a.example" - - def test_system_proxy_proxied_http_to_unproxied_https(self): - crawler = get_crawler() - redirect_mw = self.mwcls.from_crawler(crawler) - env = { - "http_proxy": "https://a:@a.example", - } - with set_environ(**env): - proxy_mw = HttpProxyMiddleware.from_crawler(crawler) - - request1 = Request("http://example.com") - proxy_mw.process_request(request1) - - assert request1.headers["Proxy-Authorization"] == b"Basic YTo=" - assert request1.meta["_auth_proxy"] == "https://a.example" - assert request1.meta["proxy"] == "https://a.example" - - response1 = self.get_response(request1, "https://example.com") - request2 = redirect_mw.process_response(request1, response1) - - assert isinstance(request2, Request) - assert "Proxy-Authorization" not in request2.headers - assert "_auth_proxy" not in request2.meta - assert "proxy" not in request2.meta - - proxy_mw.process_request(request2) - - assert "Proxy-Authorization" not in request2.headers - assert "_auth_proxy" not in request2.meta - assert "proxy" not in request2.meta - - response2 = self.get_response(request2, "http://example.com") - request3 = redirect_mw.process_response(request2, response2) - - assert isinstance(request3, Request) - assert "Proxy-Authorization" not in request3.headers - assert "_auth_proxy" not in request3.meta - assert "proxy" not in request3.meta - - proxy_mw.process_request(request3) - - assert request3.headers["Proxy-Authorization"] == b"Basic YTo=" - assert request3.meta["_auth_proxy"] == "https://a.example" - assert request3.meta["proxy"] == "https://a.example" - - def test_system_proxy_unproxied_http_to_proxied_https(self): - crawler = get_crawler() - redirect_mw = self.mwcls.from_crawler(crawler) - env = { - "https_proxy": "https://b:@b.example", - } - with set_environ(**env): - proxy_mw = HttpProxyMiddleware.from_crawler(crawler) - - request1 = Request("http://example.com") - proxy_mw.process_request(request1) - - assert "Proxy-Authorization" not in request1.headers - assert "_auth_proxy" not in request1.meta - assert "proxy" not in request1.meta - - response1 = self.get_response(request1, "https://example.com") - request2 = redirect_mw.process_response(request1, response1) - - assert isinstance(request2, Request) - assert "Proxy-Authorization" not in request2.headers - assert "_auth_proxy" not in request2.meta - assert "proxy" not in request2.meta - - proxy_mw.process_request(request2) - - assert request2.headers["Proxy-Authorization"] == b"Basic Yjo=" - assert request2.meta["_auth_proxy"] == "https://b.example" - assert request2.meta["proxy"] == "https://b.example" - - response2 = self.get_response(request2, "http://example.com") - request3 = redirect_mw.process_response(request2, response2) - - assert isinstance(request3, Request) - assert "Proxy-Authorization" not in request3.headers - assert "_auth_proxy" not in request3.meta - assert "proxy" not in request3.meta - - proxy_mw.process_request(request3) - - assert "Proxy-Authorization" not in request3.headers - assert "_auth_proxy" not in request3.meta - assert "proxy" not in request3.meta - - def test_system_proxy_unproxied_http_to_unproxied_https(self): - crawler = get_crawler() - redirect_mw = self.mwcls.from_crawler(crawler) - proxy_mw = HttpProxyMiddleware.from_crawler(crawler) - - request1 = Request("http://example.com") - proxy_mw.process_request(request1) - - assert "Proxy-Authorization" not in request1.headers - assert "_auth_proxy" not in request1.meta - assert "proxy" not in request1.meta - - response1 = self.get_response(request1, "https://example.com") - request2 = redirect_mw.process_response(request1, response1) - - assert isinstance(request2, Request) - assert "Proxy-Authorization" not in request2.headers - assert "_auth_proxy" not in request2.meta - assert "proxy" not in request2.meta - - proxy_mw.process_request(request2) - - assert "Proxy-Authorization" not in request2.headers - assert "_auth_proxy" not in request2.meta - assert "proxy" not in request2.meta - - response2 = self.get_response(request2, "http://example.com") - request3 = redirect_mw.process_response(request2, response2) - - assert isinstance(request3, Request) - assert "Proxy-Authorization" not in request3.headers - assert "_auth_proxy" not in request3.meta - assert "proxy" not in request3.meta - - proxy_mw.process_request(request3) - - assert "Proxy-Authorization" not in request3.headers - assert "_auth_proxy" not in request3.meta - assert "proxy" not in request3.meta - - def test_system_proxy_proxied_https_to_proxied_http(self): - crawler = get_crawler() - redirect_mw = self.mwcls.from_crawler(crawler) - env = { - "http_proxy": "https://a:@a.example", - "https_proxy": "https://b:@b.example", - } - with set_environ(**env): - proxy_mw = HttpProxyMiddleware.from_crawler(crawler) - - request1 = Request("https://example.com") - proxy_mw.process_request(request1) - - assert request1.headers["Proxy-Authorization"] == b"Basic Yjo=" - assert request1.meta["_auth_proxy"] == "https://b.example" - assert request1.meta["proxy"] == "https://b.example" - - response1 = self.get_response(request1, "http://example.com") - request2 = redirect_mw.process_response(request1, response1) - - assert isinstance(request2, Request) - assert "Proxy-Authorization" not in request2.headers - assert "_auth_proxy" not in request2.meta - assert "proxy" not in request2.meta - - proxy_mw.process_request(request2) - - assert request2.headers["Proxy-Authorization"] == b"Basic YTo=" - assert request2.meta["_auth_proxy"] == "https://a.example" - assert request2.meta["proxy"] == "https://a.example" - - response2 = self.get_response(request2, "https://example.com") - request3 = redirect_mw.process_response(request2, response2) - - assert isinstance(request3, Request) - assert "Proxy-Authorization" not in request3.headers - assert "_auth_proxy" not in request3.meta - assert "proxy" not in request3.meta - - proxy_mw.process_request(request3) - - assert request3.headers["Proxy-Authorization"] == b"Basic Yjo=" - assert request3.meta["_auth_proxy"] == "https://b.example" - assert request3.meta["proxy"] == "https://b.example" - - def test_system_proxy_proxied_https_to_unproxied_http(self): - crawler = get_crawler() - redirect_mw = self.mwcls.from_crawler(crawler) - env = { - "https_proxy": "https://b:@b.example", - } - with set_environ(**env): - proxy_mw = HttpProxyMiddleware.from_crawler(crawler) - - request1 = Request("https://example.com") - proxy_mw.process_request(request1) - - assert request1.headers["Proxy-Authorization"] == b"Basic Yjo=" - assert request1.meta["_auth_proxy"] == "https://b.example" - assert request1.meta["proxy"] == "https://b.example" - - response1 = self.get_response(request1, "http://example.com") - request2 = redirect_mw.process_response(request1, response1) - - assert isinstance(request2, Request) - assert "Proxy-Authorization" not in request2.headers - assert "_auth_proxy" not in request2.meta - assert "proxy" not in request2.meta - - proxy_mw.process_request(request2) - - assert "Proxy-Authorization" not in request2.headers - assert "_auth_proxy" not in request2.meta - assert "proxy" not in request2.meta - - response2 = self.get_response(request2, "https://example.com") - request3 = redirect_mw.process_response(request2, response2) - - assert isinstance(request3, Request) - assert "Proxy-Authorization" not in request3.headers - assert "_auth_proxy" not in request3.meta - assert "proxy" not in request3.meta - - proxy_mw.process_request(request3) - - assert request3.headers["Proxy-Authorization"] == b"Basic Yjo=" - assert request3.meta["_auth_proxy"] == "https://b.example" - assert request3.meta["proxy"] == "https://b.example" - - def test_system_proxy_unproxied_https_to_proxied_http(self): - crawler = get_crawler() - redirect_mw = self.mwcls.from_crawler(crawler) - env = { - "http_proxy": "https://a:@a.example", - } - with set_environ(**env): - proxy_mw = HttpProxyMiddleware.from_crawler(crawler) - - request1 = Request("https://example.com") - proxy_mw.process_request(request1) - - assert "Proxy-Authorization" not in request1.headers - assert "_auth_proxy" not in request1.meta - assert "proxy" not in request1.meta - - response1 = self.get_response(request1, "http://example.com") - request2 = redirect_mw.process_response(request1, response1) - - assert isinstance(request2, Request) - assert "Proxy-Authorization" not in request2.headers - assert "_auth_proxy" not in request2.meta - assert "proxy" not in request2.meta - - proxy_mw.process_request(request2) - - assert request2.headers["Proxy-Authorization"] == b"Basic YTo=" - assert request2.meta["_auth_proxy"] == "https://a.example" - assert request2.meta["proxy"] == "https://a.example" - - response2 = self.get_response(request2, "https://example.com") - request3 = redirect_mw.process_response(request2, response2) - - assert isinstance(request3, Request) - assert "Proxy-Authorization" not in request3.headers - assert "_auth_proxy" not in request3.meta - assert "proxy" not in request3.meta - - proxy_mw.process_request(request3) - - assert "Proxy-Authorization" not in request3.headers - assert "_auth_proxy" not in request3.meta - assert "proxy" not in request3.meta - - def test_system_proxy_unproxied_https_to_unproxied_http(self): - crawler = get_crawler() - redirect_mw = self.mwcls.from_crawler(crawler) - proxy_mw = HttpProxyMiddleware.from_crawler(crawler) - - request1 = Request("https://example.com") - proxy_mw.process_request(request1) - - assert "Proxy-Authorization" not in request1.headers - assert "_auth_proxy" not in request1.meta - assert "proxy" not in request1.meta - - response1 = self.get_response(request1, "http://example.com") - request2 = redirect_mw.process_response(request1, response1) - - assert isinstance(request2, Request) - assert "Proxy-Authorization" not in request2.headers - assert "_auth_proxy" not in request2.meta - assert "proxy" not in request2.meta - - proxy_mw.process_request(request2) - - assert "Proxy-Authorization" not in request2.headers - assert "_auth_proxy" not in request2.meta - assert "proxy" not in request2.meta - - response2 = self.get_response(request2, "https://example.com") - request3 = redirect_mw.process_response(request2, response2) - - assert isinstance(request3, Request) - assert "Proxy-Authorization" not in request3.headers - assert "_auth_proxy" not in request3.meta - assert "proxy" not in request3.meta - - proxy_mw.process_request(request3) - - assert "Proxy-Authorization" not in request3.headers - assert "_auth_proxy" not in request3.meta - assert "proxy" not in request3.meta +from tests.test_downloadermiddleware_redirect_base import ( + REDIRECT_SCHEME_CASES, + SCHEME_PARAMS, + Base, +) class TestRedirectMiddleware(Base.Test): @@ -1317,44 +341,6 @@ class TestRedirectMiddleware(Base.Test): assert self.mw.process_response(request, response) is response -SCHEME_PARAMS = ("url", "location", "target") -HTTP_SCHEMES = ("http", "https") -NON_HTTP_SCHEMES = ("data", "file", "ftp", "s3", "foo") -REDIRECT_SCHEME_CASES = ( - # http/https → http/https redirects - *( - ( - f"{input_scheme}://example.com/a", - f"{output_scheme}://example.com/b", - f"{output_scheme}://example.com/b", - ) - for input_scheme, output_scheme in product(HTTP_SCHEMES, repeat=2) - ), - # http/https → data/file/ftp/s3/foo does not redirect - *( - ( - f"{input_scheme}://example.com/a", - f"{output_scheme}://example.com/b", - None, - ) - for input_scheme in HTTP_SCHEMES - for output_scheme in NON_HTTP_SCHEMES - ), - # http/https → relative redirects - *( - ( - f"{scheme}://example.com/a", - location, - f"{scheme}://example.com/b", - ) - for scheme in HTTP_SCHEMES - for location in ("//example.com/b", "/b") - ), - # Note: We do not test data/file/ftp/s3 schemes for the initial URL - # because their download handlers cannot return a status code of 3xx. -) - - @pytest.mark.parametrize(SCHEME_PARAMS, REDIRECT_SCHEME_CASES) def test_redirect_schemes(url, location, target): crawler = get_crawler(Spider) @@ -1369,125 +355,6 @@ def test_redirect_schemes(url, location, target): assert redirect.url == target -def meta_refresh_body(url, interval=5): - html = f"""""" - return html.encode("utf-8") - - -class TestMetaRefreshMiddleware(Base.Test): - mwcls = MetaRefreshMiddleware - reason = "meta refresh" - - def setup_method(self): - crawler = get_crawler(Spider) - self.mw = self.mwcls.from_crawler(crawler) - - def _body(self, interval=5, url="http://example.org/newpage"): - return meta_refresh_body(url, interval) - - def get_response(self, request, location): - return HtmlResponse(request.url, body=self._body(url=location)) - - def test_meta_refresh(self): - req = Request(url="http://example.org") - rsp = HtmlResponse(req.url, body=self._body()) - req2 = self.mw.process_response(req, rsp) - assert isinstance(req2, Request) - assert req2.url == "http://example.org/newpage" - - def test_meta_refresh_with_high_interval(self): - # meta-refresh with high intervals don't trigger redirects - req = Request(url="http://example.org") - rsp = HtmlResponse( - url="http://example.org", body=self._body(interval=1000), encoding="utf-8" - ) - rsp2 = self.mw.process_response(req, rsp) - assert rsp is rsp2 - - def test_meta_refresh_trough_posted_request(self): - req = Request( - url="http://example.org", - method="POST", - body="test", - headers={"Content-Type": "text/plain", "Content-length": "4"}, - ) - rsp = HtmlResponse(req.url, body=self._body()) - req2 = self.mw.process_response(req, rsp) - - assert isinstance(req2, Request) - assert req2.url == "http://example.org/newpage" - assert req2.method == "GET" - assert "Content-Type" not in req2.headers, ( - "Content-Type header must not be present in redirected request" - ) - assert "Content-Length" not in req2.headers, ( - "Content-Length header must not be present in redirected request" - ) - assert not req2.body, f"Redirected body must be empty, not '{req2.body}'" - - def test_ignore_tags_default(self): - req = Request(url="http://example.org") - body = ( - """""" - ) - rsp = HtmlResponse(req.url, body=body.encode()) - response = self.mw.process_response(req, rsp) - assert isinstance(response, Response) - - def test_ignore_tags_1_x_list(self): - """Test that Scrapy 1.x behavior remains possible""" - settings = {"METAREFRESH_IGNORE_TAGS": ["script", "noscript"]} - crawler = get_crawler(Spider, settings) - mw = MetaRefreshMiddleware.from_crawler(crawler) - req = Request(url="http://example.org") - body = ( - """""" - ) - rsp = HtmlResponse(req.url, body=body.encode()) - response = mw.process_response(req, rsp) - assert isinstance(response, Response) - - -@pytest.mark.parametrize( - SCHEME_PARAMS, - [ - *REDIRECT_SCHEME_CASES, - # data/file/ftp/s3/foo → * does not redirect - *( - ( - f"{input_scheme}://example.com/a", - f"{output_scheme}://example.com/b", - None, - ) - for input_scheme in NON_HTTP_SCHEMES - for output_scheme in chain(HTTP_SCHEMES, NON_HTTP_SCHEMES) - ), - # data/file/ftp/s3/foo → relative does not redirect - *( - ( - f"{scheme}://example.com/a", - location, - None, - ) - for scheme in NON_HTTP_SCHEMES - for location in ("//example.com/b", "/b") - ), - ], -) -def test_meta_refresh_schemes(url, location, target): - crawler = get_crawler(Spider) - mw = MetaRefreshMiddleware.from_crawler(crawler) - request = Request(url) - response = HtmlResponse(url, body=meta_refresh_body(location)) - redirect = mw.process_response(request, response) - if target is None: - assert redirect == response - else: - assert isinstance(redirect, Request) - - @pytest.mark.parametrize( ("policy", "source_url", "target_url", "expected_referrer"), [ @@ -1569,26 +436,6 @@ def test_warning_redirect_middleware(caplog): ) in caplog.text -def test_warning_meta_refresh_middleware(caplog): - crawler = get_crawler() - crawler.get_spider_middleware = MagicMock(return_value=None) - mw = build_from_crawler(MetaRefreshMiddleware, crawler) - with caplog.at_level(logging.WARNING): - mw._engine_started() - assert ( - "scrapy.downloadermiddlewares.redirect.MetaRefreshMiddleware found no " - "scrapy.spidermiddlewares.referer.RefererMiddleware" - ) in caplog.text - assert ( - "enable scrapy.spidermiddlewares.referer.RefererMiddleware (or a subclass)" - in caplog.text - ) - assert ( - "replace scrapy.downloadermiddlewares.redirect.MetaRefreshMiddleware " - "with a subclass that overrides the handle_referer() method" - ) in caplog.text - - def test_warning_subclass(caplog): class MyRedirectMiddleware(RedirectMiddleware): pass diff --git a/tests/test_downloadermiddleware_redirect_base.py b/tests/test_downloadermiddleware_redirect_base.py new file mode 100644 index 000000000..44ade93b7 --- /dev/null +++ b/tests/test_downloadermiddleware_redirect_base.py @@ -0,0 +1,1023 @@ +from __future__ import annotations + +from itertools import product + +import pytest + +from scrapy.downloadermiddlewares.httpproxy import HttpProxyMiddleware +from scrapy.exceptions import IgnoreRequest +from scrapy.http import Request, Response +from scrapy.utils.misc import set_environ +from scrapy.utils.test import get_crawler + +SCHEME_PARAMS = ("url", "location", "target") +HTTP_SCHEMES = ("http", "https") +NON_HTTP_SCHEMES = ("data", "file", "ftp", "s3", "foo") +REDIRECT_SCHEME_CASES = ( + # http/https → http/https redirects + *( + ( + f"{input_scheme}://example.com/a", + f"{output_scheme}://example.com/b", + f"{output_scheme}://example.com/b", + ) + for input_scheme, output_scheme in product(HTTP_SCHEMES, repeat=2) + ), + # http/https → data/file/ftp/s3/foo does not redirect + *( + ( + f"{input_scheme}://example.com/a", + f"{output_scheme}://example.com/b", + None, + ) + for input_scheme in HTTP_SCHEMES + for output_scheme in NON_HTTP_SCHEMES + ), + # http/https → relative redirects + *( + ( + f"{scheme}://example.com/a", + location, + f"{scheme}://example.com/b", + ) + for scheme in HTTP_SCHEMES + for location in ("//example.com/b", "/b") + ), + # Note: We do not test data/file/ftp/s3 schemes for the initial URL + # because their download handlers cannot return a status code of 3xx. +) + + +class Base: + class Test: + def test_priority_adjust(self): + req = Request("http://a.example") + rsp = self.get_response(req, "http://a.example/redirected") + req2 = self.mw.process_response(req, rsp) + assert req2.priority > req.priority + + def test_dont_redirect(self): + url = "http://www.example.com/301" + url2 = "http://www.example.com/redirected" + req = Request(url, meta={"dont_redirect": True}) + rsp = self.get_response(req, url2) + + r = self.mw.process_response(req, rsp) + assert isinstance(r, Response) + assert r is rsp + + # Test that it redirects when dont_redirect is False + req = Request(url, meta={"dont_redirect": False}) + rsp = self.get_response(req, url2) + + r = self.mw.process_response(req, rsp) + assert isinstance(r, Request) + + def test_post(self): + url = "http://www.example.com/302" + url2 = "http://www.example.com/redirected2" + req = Request( + url, + method="POST", + body="test", + headers={"Content-Type": "text/plain", "Content-length": "4"}, + ) + rsp = self.get_response(req, url2) + + req2 = self.mw.process_response(req, rsp) + assert isinstance(req2, Request) + assert req2.url == url2 + assert req2.method == "GET" + assert "Content-Type" not in req2.headers, ( + "Content-Type header must not be present in redirected request" + ) + assert "Content-Length" not in req2.headers, ( + "Content-Length header must not be present in redirected request" + ) + assert not req2.body, f"Redirected body must be empty, not '{req2.body}'" + + def test_max_redirect_times(self): + self.mw.max_redirect_times = 1 + req = Request("http://a.example/302") + rsp = self.get_response(req, "/redirected") + + req = self.mw.process_response(req, rsp) + assert isinstance(req, Request) + assert "redirect_times" in req.meta + assert req.meta["redirect_times"] == 1 + with pytest.raises(IgnoreRequest): + self.mw.process_response(req, rsp) + + def test_ttl(self): + self.mw.max_redirect_times = 100 + req = Request("http://a.example/302", meta={"redirect_ttl": 1}) + rsp = self.get_response(req, "/a") + + req = self.mw.process_response(req, rsp) + assert isinstance(req, Request) + with pytest.raises(IgnoreRequest): + self.mw.process_response(req, rsp) + + def test_redirect_urls(self): + req1 = Request("http://a.example/first") + rsp1 = self.get_response(req1, "/redirected") + req2 = self.mw.process_response(req1, rsp1) + rsp2 = self.get_response(req1, "/redirected2") + req3 = self.mw.process_response(req2, rsp2) + + assert req2.url == "http://a.example/redirected" + assert req2.meta["redirect_urls"] == ["http://a.example/first"] + assert req3.url == "http://a.example/redirected2" + assert req3.meta["redirect_urls"] == [ + "http://a.example/first", + "http://a.example/redirected", + ] + + def test_redirect_reasons(self): + req1 = Request("http://a.example/first") + rsp1 = self.get_response(req1, "/redirected1") + req2 = self.mw.process_response(req1, rsp1) + rsp2 = self.get_response(req2, "/redirected2") + req3 = self.mw.process_response(req2, rsp2) + assert req2.meta["redirect_reasons"] == [self.reason] + assert req3.meta["redirect_reasons"] == [self.reason, self.reason] + + def test_cross_origin_header_dropping(self): + safe_headers = {"A": "B"} + cookie_header = {"Cookie": "a=b"} + authorization_header = {"Authorization": "Bearer 123456"} + + original_request = Request( + "https://example.com", + headers={**safe_headers, **cookie_header, **authorization_header}, + ) + + # Redirects to the same origin (same scheme, same domain, same port) + # keep all headers. + internal_response = self.get_response( + original_request, "https://example.com/a" + ) + internal_redirect_request = self.mw.process_response( + original_request, internal_response + ) + assert isinstance(internal_redirect_request, Request) + assert original_request.headers == internal_redirect_request.headers + + # Redirects to the same origin (same scheme, same domain, same port) + # keep all headers also when the scheme is http. + http_request = Request( + "http://example.com", + headers={**safe_headers, **cookie_header, **authorization_header}, + ) + http_response = self.get_response(http_request, "http://example.com/a") + http_redirect_request = self.mw.process_response( + http_request, http_response + ) + assert isinstance(http_redirect_request, Request) + assert http_request.headers == http_redirect_request.headers + + # For default ports, whether the port is explicit or implicit does not + # affect the outcome, it is still the same origin. + to_explicit_port_response = self.get_response( + original_request, "https://example.com:443/a" + ) + to_explicit_port_redirect_request = self.mw.process_response( + original_request, to_explicit_port_response + ) + assert isinstance(to_explicit_port_redirect_request, Request) + assert original_request.headers == to_explicit_port_redirect_request.headers + + # For default ports, whether the port is explicit or implicit does not + # affect the outcome, it is still the same origin. + to_implicit_port_response = self.get_response( + original_request, "https://example.com/a" + ) + to_implicit_port_redirect_request = self.mw.process_response( + original_request, to_implicit_port_response + ) + assert isinstance(to_implicit_port_redirect_request, Request) + assert original_request.headers == to_implicit_port_redirect_request.headers + + # A port change drops the Authorization header because the origin + # changes, but keeps the Cookie header because the domain remains the + # same. + different_port_response = self.get_response( + original_request, "https://example.com:8080/a" + ) + different_port_redirect_request = self.mw.process_response( + original_request, different_port_response + ) + assert isinstance(different_port_redirect_request, Request) + assert { + **safe_headers, + **cookie_header, + } == different_port_redirect_request.headers.to_unicode_dict() + + # A domain change drops both the Authorization and the Cookie header. + external_response = self.get_response( + original_request, "https://example.org/a" + ) + external_redirect_request = self.mw.process_response( + original_request, external_response + ) + assert isinstance(external_redirect_request, Request) + assert safe_headers == external_redirect_request.headers.to_unicode_dict() + + # A scheme upgrade (http → https) drops the Authorization header + # because the origin changes, but keeps the Cookie header because the + # domain remains the same. + upgrade_response = self.get_response(http_request, "https://example.com/a") + upgrade_redirect_request = self.mw.process_response( + http_request, upgrade_response + ) + assert isinstance(upgrade_redirect_request, Request) + assert { + **safe_headers, + **cookie_header, + } == upgrade_redirect_request.headers.to_unicode_dict() + + # A scheme downgrade (https → http) drops the Authorization header + # because the origin changes, and the Cookie header because its value + # cannot indicate whether the cookies were secure (HTTPS-only) or not. + # + # Note: If the Cookie header is set by the cookie management + # middleware, as recommended in the docs, the dropping of Cookie on + # scheme downgrade is not an issue, because the cookie management + # middleware will add again the Cookie header to the new request if + # appropriate. + downgrade_response = self.get_response( + original_request, "http://example.com/a" + ) + downgrade_redirect_request = self.mw.process_response( + original_request, downgrade_response + ) + assert isinstance(downgrade_redirect_request, Request) + assert safe_headers == downgrade_redirect_request.headers.to_unicode_dict() + + def test_meta_proxy_http_absolute(self): + crawler = get_crawler() + redirect_mw = self.mwcls.from_crawler(crawler) + proxy_mw = HttpProxyMiddleware.from_crawler(crawler) + + meta = {"proxy": "https://a:@a.example"} + request1 = Request("http://example.com", meta=meta) + proxy_mw.process_request(request1) + + assert request1.headers["Proxy-Authorization"] == b"Basic YTo=" + assert request1.meta["_auth_proxy"] == "https://a.example" + assert request1.meta["proxy"] == "https://a.example" + + response1 = self.get_response(request1, "http://example.com") + request2 = redirect_mw.process_response(request1, response1) + + assert isinstance(request2, Request) + assert request2.headers["Proxy-Authorization"] == b"Basic YTo=" + assert request2.meta["_auth_proxy"] == "https://a.example" + assert request2.meta["proxy"] == "https://a.example" + + proxy_mw.process_request(request2) + + assert request2.headers["Proxy-Authorization"] == b"Basic YTo=" + assert request2.meta["_auth_proxy"] == "https://a.example" + assert request2.meta["proxy"] == "https://a.example" + + response2 = self.get_response(request2, "http://example.com") + request3 = redirect_mw.process_response(request2, response2) + + assert isinstance(request3, Request) + assert request3.headers["Proxy-Authorization"] == b"Basic YTo=" + assert request3.meta["_auth_proxy"] == "https://a.example" + assert request3.meta["proxy"] == "https://a.example" + + proxy_mw.process_request(request3) + + assert request3.headers["Proxy-Authorization"] == b"Basic YTo=" + assert request3.meta["_auth_proxy"] == "https://a.example" + assert request3.meta["proxy"] == "https://a.example" + + def test_meta_proxy_http_relative(self): + crawler = get_crawler() + redirect_mw = self.mwcls.from_crawler(crawler) + proxy_mw = HttpProxyMiddleware.from_crawler(crawler) + + meta = {"proxy": "https://a:@a.example"} + request1 = Request("http://example.com", meta=meta) + proxy_mw.process_request(request1) + + assert request1.headers["Proxy-Authorization"] == b"Basic YTo=" + assert request1.meta["_auth_proxy"] == "https://a.example" + assert request1.meta["proxy"] == "https://a.example" + + response1 = self.get_response(request1, "/a") + request2 = redirect_mw.process_response(request1, response1) + + assert isinstance(request2, Request) + assert request2.headers["Proxy-Authorization"] == b"Basic YTo=" + assert request2.meta["_auth_proxy"] == "https://a.example" + assert request2.meta["proxy"] == "https://a.example" + + proxy_mw.process_request(request2) + + assert request2.headers["Proxy-Authorization"] == b"Basic YTo=" + assert request2.meta["_auth_proxy"] == "https://a.example" + assert request2.meta["proxy"] == "https://a.example" + + response2 = self.get_response(request2, "/a") + request3 = redirect_mw.process_response(request2, response2) + + assert isinstance(request3, Request) + assert request3.headers["Proxy-Authorization"] == b"Basic YTo=" + assert request3.meta["_auth_proxy"] == "https://a.example" + assert request3.meta["proxy"] == "https://a.example" + + proxy_mw.process_request(request3) + + assert request3.headers["Proxy-Authorization"] == b"Basic YTo=" + assert request3.meta["_auth_proxy"] == "https://a.example" + assert request3.meta["proxy"] == "https://a.example" + + def test_meta_proxy_https_absolute(self): + crawler = get_crawler() + redirect_mw = self.mwcls.from_crawler(crawler) + proxy_mw = HttpProxyMiddleware.from_crawler(crawler) + + meta = {"proxy": "https://a:@a.example"} + request1 = Request("https://example.com", meta=meta) + proxy_mw.process_request(request1) + + assert request1.headers["Proxy-Authorization"] == b"Basic YTo=" + assert request1.meta["_auth_proxy"] == "https://a.example" + assert request1.meta["proxy"] == "https://a.example" + + response1 = self.get_response(request1, "https://example.com") + request2 = redirect_mw.process_response(request1, response1) + + assert isinstance(request2, Request) + assert request2.headers["Proxy-Authorization"] == b"Basic YTo=" + assert request2.meta["_auth_proxy"] == "https://a.example" + assert request2.meta["proxy"] == "https://a.example" + + proxy_mw.process_request(request2) + + assert request2.headers["Proxy-Authorization"] == b"Basic YTo=" + assert request2.meta["_auth_proxy"] == "https://a.example" + assert request2.meta["proxy"] == "https://a.example" + + response2 = self.get_response(request2, "https://example.com") + request3 = redirect_mw.process_response(request2, response2) + + assert isinstance(request3, Request) + assert request3.headers["Proxy-Authorization"] == b"Basic YTo=" + assert request3.meta["_auth_proxy"] == "https://a.example" + assert request3.meta["proxy"] == "https://a.example" + + proxy_mw.process_request(request3) + + assert request3.headers["Proxy-Authorization"] == b"Basic YTo=" + assert request3.meta["_auth_proxy"] == "https://a.example" + assert request3.meta["proxy"] == "https://a.example" + + def test_meta_proxy_https_relative(self): + crawler = get_crawler() + redirect_mw = self.mwcls.from_crawler(crawler) + proxy_mw = HttpProxyMiddleware.from_crawler(crawler) + + meta = {"proxy": "https://a:@a.example"} + request1 = Request("https://example.com", meta=meta) + proxy_mw.process_request(request1) + + assert request1.headers["Proxy-Authorization"] == b"Basic YTo=" + assert request1.meta["_auth_proxy"] == "https://a.example" + assert request1.meta["proxy"] == "https://a.example" + + response1 = self.get_response(request1, "/a") + request2 = redirect_mw.process_response(request1, response1) + + assert isinstance(request2, Request) + assert request2.headers["Proxy-Authorization"] == b"Basic YTo=" + assert request2.meta["_auth_proxy"] == "https://a.example" + assert request2.meta["proxy"] == "https://a.example" + + proxy_mw.process_request(request2) + + assert request2.headers["Proxy-Authorization"] == b"Basic YTo=" + assert request2.meta["_auth_proxy"] == "https://a.example" + assert request2.meta["proxy"] == "https://a.example" + + response2 = self.get_response(request2, "/a") + request3 = redirect_mw.process_response(request2, response2) + + assert isinstance(request3, Request) + assert request3.headers["Proxy-Authorization"] == b"Basic YTo=" + assert request3.meta["_auth_proxy"] == "https://a.example" + assert request3.meta["proxy"] == "https://a.example" + + proxy_mw.process_request(request3) + + assert request3.headers["Proxy-Authorization"] == b"Basic YTo=" + assert request3.meta["_auth_proxy"] == "https://a.example" + assert request3.meta["proxy"] == "https://a.example" + + def test_meta_proxy_http_to_https(self): + crawler = get_crawler() + redirect_mw = self.mwcls.from_crawler(crawler) + proxy_mw = HttpProxyMiddleware.from_crawler(crawler) + + meta = {"proxy": "https://a:@a.example"} + request1 = Request("http://example.com", meta=meta) + proxy_mw.process_request(request1) + + assert request1.headers["Proxy-Authorization"] == b"Basic YTo=" + assert request1.meta["_auth_proxy"] == "https://a.example" + assert request1.meta["proxy"] == "https://a.example" + + response1 = self.get_response(request1, "https://example.com") + request2 = redirect_mw.process_response(request1, response1) + + assert isinstance(request2, Request) + assert request2.headers["Proxy-Authorization"] == b"Basic YTo=" + assert request2.meta["_auth_proxy"] == "https://a.example" + assert request2.meta["proxy"] == "https://a.example" + + proxy_mw.process_request(request2) + + assert request2.headers["Proxy-Authorization"] == b"Basic YTo=" + assert request2.meta["_auth_proxy"] == "https://a.example" + assert request2.meta["proxy"] == "https://a.example" + + response2 = self.get_response(request2, "http://example.com") + request3 = redirect_mw.process_response(request2, response2) + + assert isinstance(request3, Request) + assert request3.headers["Proxy-Authorization"] == b"Basic YTo=" + assert request3.meta["_auth_proxy"] == "https://a.example" + assert request3.meta["proxy"] == "https://a.example" + + proxy_mw.process_request(request3) + + assert request3.headers["Proxy-Authorization"] == b"Basic YTo=" + assert request3.meta["_auth_proxy"] == "https://a.example" + assert request3.meta["proxy"] == "https://a.example" + + def test_meta_proxy_https_to_http(self): + crawler = get_crawler() + redirect_mw = self.mwcls.from_crawler(crawler) + proxy_mw = HttpProxyMiddleware.from_crawler(crawler) + + meta = {"proxy": "https://a:@a.example"} + request1 = Request("https://example.com", meta=meta) + proxy_mw.process_request(request1) + + assert request1.headers["Proxy-Authorization"] == b"Basic YTo=" + assert request1.meta["_auth_proxy"] == "https://a.example" + assert request1.meta["proxy"] == "https://a.example" + + response1 = self.get_response(request1, "http://example.com") + request2 = redirect_mw.process_response(request1, response1) + + assert isinstance(request2, Request) + assert request2.headers["Proxy-Authorization"] == b"Basic YTo=" + assert request2.meta["_auth_proxy"] == "https://a.example" + assert request2.meta["proxy"] == "https://a.example" + + proxy_mw.process_request(request2) + + assert request2.headers["Proxy-Authorization"] == b"Basic YTo=" + assert request2.meta["_auth_proxy"] == "https://a.example" + assert request2.meta["proxy"] == "https://a.example" + + response2 = self.get_response(request2, "https://example.com") + request3 = redirect_mw.process_response(request2, response2) + + assert isinstance(request3, Request) + assert request3.headers["Proxy-Authorization"] == b"Basic YTo=" + assert request3.meta["_auth_proxy"] == "https://a.example" + assert request3.meta["proxy"] == "https://a.example" + + proxy_mw.process_request(request3) + + assert request3.headers["Proxy-Authorization"] == b"Basic YTo=" + assert request3.meta["_auth_proxy"] == "https://a.example" + assert request3.meta["proxy"] == "https://a.example" + + def test_system_proxy_http_absolute(self): + crawler = get_crawler() + redirect_mw = self.mwcls.from_crawler(crawler) + env = { + "http_proxy": "https://a:@a.example", + } + with set_environ(**env): + proxy_mw = HttpProxyMiddleware.from_crawler(crawler) + + request1 = Request("http://example.com") + proxy_mw.process_request(request1) + + assert request1.headers["Proxy-Authorization"] == b"Basic YTo=" + assert request1.meta["_auth_proxy"] == "https://a.example" + assert request1.meta["proxy"] == "https://a.example" + + response1 = self.get_response(request1, "http://example.com") + request2 = redirect_mw.process_response(request1, response1) + + assert isinstance(request2, Request) + assert request2.headers["Proxy-Authorization"] == b"Basic YTo=" + assert request2.meta["_auth_proxy"] == "https://a.example" + assert request2.meta["proxy"] == "https://a.example" + + proxy_mw.process_request(request2) + + assert request2.headers["Proxy-Authorization"] == b"Basic YTo=" + assert request2.meta["_auth_proxy"] == "https://a.example" + assert request2.meta["proxy"] == "https://a.example" + + response2 = self.get_response(request2, "http://example.com") + request3 = redirect_mw.process_response(request2, response2) + + assert isinstance(request3, Request) + assert request3.headers["Proxy-Authorization"] == b"Basic YTo=" + assert request3.meta["_auth_proxy"] == "https://a.example" + assert request3.meta["proxy"] == "https://a.example" + + proxy_mw.process_request(request3) + + assert request3.headers["Proxy-Authorization"] == b"Basic YTo=" + assert request3.meta["_auth_proxy"] == "https://a.example" + assert request3.meta["proxy"] == "https://a.example" + + def test_system_proxy_http_relative(self): + crawler = get_crawler() + redirect_mw = self.mwcls.from_crawler(crawler) + env = { + "http_proxy": "https://a:@a.example", + } + with set_environ(**env): + proxy_mw = HttpProxyMiddleware.from_crawler(crawler) + + request1 = Request("http://example.com") + proxy_mw.process_request(request1) + + assert request1.headers["Proxy-Authorization"] == b"Basic YTo=" + assert request1.meta["_auth_proxy"] == "https://a.example" + assert request1.meta["proxy"] == "https://a.example" + + response1 = self.get_response(request1, "/a") + request2 = redirect_mw.process_response(request1, response1) + + assert isinstance(request2, Request) + assert request2.headers["Proxy-Authorization"] == b"Basic YTo=" + assert request2.meta["_auth_proxy"] == "https://a.example" + assert request2.meta["proxy"] == "https://a.example" + + proxy_mw.process_request(request2) + + assert request2.headers["Proxy-Authorization"] == b"Basic YTo=" + assert request2.meta["_auth_proxy"] == "https://a.example" + assert request2.meta["proxy"] == "https://a.example" + + response2 = self.get_response(request2, "/a") + request3 = redirect_mw.process_response(request2, response2) + + assert isinstance(request3, Request) + assert request3.headers["Proxy-Authorization"] == b"Basic YTo=" + assert request3.meta["_auth_proxy"] == "https://a.example" + assert request3.meta["proxy"] == "https://a.example" + + proxy_mw.process_request(request3) + + assert request3.headers["Proxy-Authorization"] == b"Basic YTo=" + assert request3.meta["_auth_proxy"] == "https://a.example" + assert request3.meta["proxy"] == "https://a.example" + + def test_system_proxy_https_absolute(self): + crawler = get_crawler() + redirect_mw = self.mwcls.from_crawler(crawler) + env = { + "https_proxy": "https://a:@a.example", + } + with set_environ(**env): + proxy_mw = HttpProxyMiddleware.from_crawler(crawler) + + request1 = Request("https://example.com") + proxy_mw.process_request(request1) + + assert request1.headers["Proxy-Authorization"] == b"Basic YTo=" + assert request1.meta["_auth_proxy"] == "https://a.example" + assert request1.meta["proxy"] == "https://a.example" + + response1 = self.get_response(request1, "https://example.com") + request2 = redirect_mw.process_response(request1, response1) + + assert isinstance(request2, Request) + assert request2.headers["Proxy-Authorization"] == b"Basic YTo=" + assert request2.meta["_auth_proxy"] == "https://a.example" + assert request2.meta["proxy"] == "https://a.example" + + proxy_mw.process_request(request2) + + assert request2.headers["Proxy-Authorization"] == b"Basic YTo=" + assert request2.meta["_auth_proxy"] == "https://a.example" + assert request2.meta["proxy"] == "https://a.example" + + response2 = self.get_response(request2, "https://example.com") + request3 = redirect_mw.process_response(request2, response2) + + assert isinstance(request3, Request) + assert request3.headers["Proxy-Authorization"] == b"Basic YTo=" + assert request3.meta["_auth_proxy"] == "https://a.example" + assert request3.meta["proxy"] == "https://a.example" + + proxy_mw.process_request(request3) + + assert request3.headers["Proxy-Authorization"] == b"Basic YTo=" + assert request3.meta["_auth_proxy"] == "https://a.example" + assert request3.meta["proxy"] == "https://a.example" + + def test_system_proxy_https_relative(self): + crawler = get_crawler() + redirect_mw = self.mwcls.from_crawler(crawler) + env = { + "https_proxy": "https://a:@a.example", + } + with set_environ(**env): + proxy_mw = HttpProxyMiddleware.from_crawler(crawler) + + request1 = Request("https://example.com") + proxy_mw.process_request(request1) + + assert request1.headers["Proxy-Authorization"] == b"Basic YTo=" + assert request1.meta["_auth_proxy"] == "https://a.example" + assert request1.meta["proxy"] == "https://a.example" + + response1 = self.get_response(request1, "/a") + request2 = redirect_mw.process_response(request1, response1) + + assert isinstance(request2, Request) + assert request2.headers["Proxy-Authorization"] == b"Basic YTo=" + assert request2.meta["_auth_proxy"] == "https://a.example" + assert request2.meta["proxy"] == "https://a.example" + + proxy_mw.process_request(request2) + + assert request2.headers["Proxy-Authorization"] == b"Basic YTo=" + assert request2.meta["_auth_proxy"] == "https://a.example" + assert request2.meta["proxy"] == "https://a.example" + + response2 = self.get_response(request2, "/a") + request3 = redirect_mw.process_response(request2, response2) + + assert isinstance(request3, Request) + assert request3.headers["Proxy-Authorization"] == b"Basic YTo=" + assert request3.meta["_auth_proxy"] == "https://a.example" + assert request3.meta["proxy"] == "https://a.example" + + proxy_mw.process_request(request3) + + assert request3.headers["Proxy-Authorization"] == b"Basic YTo=" + assert request3.meta["_auth_proxy"] == "https://a.example" + assert request3.meta["proxy"] == "https://a.example" + + def test_system_proxy_proxied_http_to_proxied_https(self): + crawler = get_crawler() + redirect_mw = self.mwcls.from_crawler(crawler) + env = { + "http_proxy": "https://a:@a.example", + "https_proxy": "https://b:@b.example", + } + with set_environ(**env): + proxy_mw = HttpProxyMiddleware.from_crawler(crawler) + + request1 = Request("http://example.com") + proxy_mw.process_request(request1) + + assert request1.headers["Proxy-Authorization"] == b"Basic YTo=" + assert request1.meta["_auth_proxy"] == "https://a.example" + assert request1.meta["proxy"] == "https://a.example" + + response1 = self.get_response(request1, "https://example.com") + request2 = redirect_mw.process_response(request1, response1) + + assert isinstance(request2, Request) + assert "Proxy-Authorization" not in request2.headers + assert "_auth_proxy" not in request2.meta + assert "proxy" not in request2.meta + + proxy_mw.process_request(request2) + + assert request2.headers["Proxy-Authorization"] == b"Basic Yjo=" + assert request2.meta["_auth_proxy"] == "https://b.example" + assert request2.meta["proxy"] == "https://b.example" + + response2 = self.get_response(request2, "http://example.com") + request3 = redirect_mw.process_response(request2, response2) + + assert isinstance(request3, Request) + assert "Proxy-Authorization" not in request3.headers + assert "_auth_proxy" not in request3.meta + assert "proxy" not in request3.meta + + proxy_mw.process_request(request3) + + assert request3.headers["Proxy-Authorization"] == b"Basic YTo=" + assert request3.meta["_auth_proxy"] == "https://a.example" + assert request3.meta["proxy"] == "https://a.example" + + def test_system_proxy_proxied_http_to_unproxied_https(self): + crawler = get_crawler() + redirect_mw = self.mwcls.from_crawler(crawler) + env = { + "http_proxy": "https://a:@a.example", + } + with set_environ(**env): + proxy_mw = HttpProxyMiddleware.from_crawler(crawler) + + request1 = Request("http://example.com") + proxy_mw.process_request(request1) + + assert request1.headers["Proxy-Authorization"] == b"Basic YTo=" + assert request1.meta["_auth_proxy"] == "https://a.example" + assert request1.meta["proxy"] == "https://a.example" + + response1 = self.get_response(request1, "https://example.com") + request2 = redirect_mw.process_response(request1, response1) + + assert isinstance(request2, Request) + assert "Proxy-Authorization" not in request2.headers + assert "_auth_proxy" not in request2.meta + assert "proxy" not in request2.meta + + proxy_mw.process_request(request2) + + assert "Proxy-Authorization" not in request2.headers + assert "_auth_proxy" not in request2.meta + assert "proxy" not in request2.meta + + response2 = self.get_response(request2, "http://example.com") + request3 = redirect_mw.process_response(request2, response2) + + assert isinstance(request3, Request) + assert "Proxy-Authorization" not in request3.headers + assert "_auth_proxy" not in request3.meta + assert "proxy" not in request3.meta + + proxy_mw.process_request(request3) + + assert request3.headers["Proxy-Authorization"] == b"Basic YTo=" + assert request3.meta["_auth_proxy"] == "https://a.example" + assert request3.meta["proxy"] == "https://a.example" + + def test_system_proxy_unproxied_http_to_proxied_https(self): + crawler = get_crawler() + redirect_mw = self.mwcls.from_crawler(crawler) + env = { + "https_proxy": "https://b:@b.example", + } + with set_environ(**env): + proxy_mw = HttpProxyMiddleware.from_crawler(crawler) + + request1 = Request("http://example.com") + proxy_mw.process_request(request1) + + assert "Proxy-Authorization" not in request1.headers + assert "_auth_proxy" not in request1.meta + assert "proxy" not in request1.meta + + response1 = self.get_response(request1, "https://example.com") + request2 = redirect_mw.process_response(request1, response1) + + assert isinstance(request2, Request) + assert "Proxy-Authorization" not in request2.headers + assert "_auth_proxy" not in request2.meta + assert "proxy" not in request2.meta + + proxy_mw.process_request(request2) + + assert request2.headers["Proxy-Authorization"] == b"Basic Yjo=" + assert request2.meta["_auth_proxy"] == "https://b.example" + assert request2.meta["proxy"] == "https://b.example" + + response2 = self.get_response(request2, "http://example.com") + request3 = redirect_mw.process_response(request2, response2) + + assert isinstance(request3, Request) + assert "Proxy-Authorization" not in request3.headers + assert "_auth_proxy" not in request3.meta + assert "proxy" not in request3.meta + + proxy_mw.process_request(request3) + + assert "Proxy-Authorization" not in request3.headers + assert "_auth_proxy" not in request3.meta + assert "proxy" not in request3.meta + + def test_system_proxy_unproxied_http_to_unproxied_https(self): + crawler = get_crawler() + redirect_mw = self.mwcls.from_crawler(crawler) + proxy_mw = HttpProxyMiddleware.from_crawler(crawler) + + request1 = Request("http://example.com") + proxy_mw.process_request(request1) + + assert "Proxy-Authorization" not in request1.headers + assert "_auth_proxy" not in request1.meta + assert "proxy" not in request1.meta + + response1 = self.get_response(request1, "https://example.com") + request2 = redirect_mw.process_response(request1, response1) + + assert isinstance(request2, Request) + assert "Proxy-Authorization" not in request2.headers + assert "_auth_proxy" not in request2.meta + assert "proxy" not in request2.meta + + proxy_mw.process_request(request2) + + assert "Proxy-Authorization" not in request2.headers + assert "_auth_proxy" not in request2.meta + assert "proxy" not in request2.meta + + response2 = self.get_response(request2, "http://example.com") + request3 = redirect_mw.process_response(request2, response2) + + assert isinstance(request3, Request) + assert "Proxy-Authorization" not in request3.headers + assert "_auth_proxy" not in request3.meta + assert "proxy" not in request3.meta + + proxy_mw.process_request(request3) + + assert "Proxy-Authorization" not in request3.headers + assert "_auth_proxy" not in request3.meta + assert "proxy" not in request3.meta + + def test_system_proxy_proxied_https_to_proxied_http(self): + crawler = get_crawler() + redirect_mw = self.mwcls.from_crawler(crawler) + env = { + "http_proxy": "https://a:@a.example", + "https_proxy": "https://b:@b.example", + } + with set_environ(**env): + proxy_mw = HttpProxyMiddleware.from_crawler(crawler) + + request1 = Request("https://example.com") + proxy_mw.process_request(request1) + + assert request1.headers["Proxy-Authorization"] == b"Basic Yjo=" + assert request1.meta["_auth_proxy"] == "https://b.example" + assert request1.meta["proxy"] == "https://b.example" + + response1 = self.get_response(request1, "http://example.com") + request2 = redirect_mw.process_response(request1, response1) + + assert isinstance(request2, Request) + assert "Proxy-Authorization" not in request2.headers + assert "_auth_proxy" not in request2.meta + assert "proxy" not in request2.meta + + proxy_mw.process_request(request2) + + assert request2.headers["Proxy-Authorization"] == b"Basic YTo=" + assert request2.meta["_auth_proxy"] == "https://a.example" + assert request2.meta["proxy"] == "https://a.example" + + response2 = self.get_response(request2, "https://example.com") + request3 = redirect_mw.process_response(request2, response2) + + assert isinstance(request3, Request) + assert "Proxy-Authorization" not in request3.headers + assert "_auth_proxy" not in request3.meta + assert "proxy" not in request3.meta + + proxy_mw.process_request(request3) + + assert request3.headers["Proxy-Authorization"] == b"Basic Yjo=" + assert request3.meta["_auth_proxy"] == "https://b.example" + assert request3.meta["proxy"] == "https://b.example" + + def test_system_proxy_proxied_https_to_unproxied_http(self): + crawler = get_crawler() + redirect_mw = self.mwcls.from_crawler(crawler) + env = { + "https_proxy": "https://b:@b.example", + } + with set_environ(**env): + proxy_mw = HttpProxyMiddleware.from_crawler(crawler) + + request1 = Request("https://example.com") + proxy_mw.process_request(request1) + + assert request1.headers["Proxy-Authorization"] == b"Basic Yjo=" + assert request1.meta["_auth_proxy"] == "https://b.example" + assert request1.meta["proxy"] == "https://b.example" + + response1 = self.get_response(request1, "http://example.com") + request2 = redirect_mw.process_response(request1, response1) + + assert isinstance(request2, Request) + assert "Proxy-Authorization" not in request2.headers + assert "_auth_proxy" not in request2.meta + assert "proxy" not in request2.meta + + proxy_mw.process_request(request2) + + assert "Proxy-Authorization" not in request2.headers + assert "_auth_proxy" not in request2.meta + assert "proxy" not in request2.meta + + response2 = self.get_response(request2, "https://example.com") + request3 = redirect_mw.process_response(request2, response2) + + assert isinstance(request3, Request) + assert "Proxy-Authorization" not in request3.headers + assert "_auth_proxy" not in request3.meta + assert "proxy" not in request3.meta + + proxy_mw.process_request(request3) + + assert request3.headers["Proxy-Authorization"] == b"Basic Yjo=" + assert request3.meta["_auth_proxy"] == "https://b.example" + assert request3.meta["proxy"] == "https://b.example" + + def test_system_proxy_unproxied_https_to_proxied_http(self): + crawler = get_crawler() + redirect_mw = self.mwcls.from_crawler(crawler) + env = { + "http_proxy": "https://a:@a.example", + } + with set_environ(**env): + proxy_mw = HttpProxyMiddleware.from_crawler(crawler) + + request1 = Request("https://example.com") + proxy_mw.process_request(request1) + + assert "Proxy-Authorization" not in request1.headers + assert "_auth_proxy" not in request1.meta + assert "proxy" not in request1.meta + + response1 = self.get_response(request1, "http://example.com") + request2 = redirect_mw.process_response(request1, response1) + + assert isinstance(request2, Request) + assert "Proxy-Authorization" not in request2.headers + assert "_auth_proxy" not in request2.meta + assert "proxy" not in request2.meta + + proxy_mw.process_request(request2) + + assert request2.headers["Proxy-Authorization"] == b"Basic YTo=" + assert request2.meta["_auth_proxy"] == "https://a.example" + assert request2.meta["proxy"] == "https://a.example" + + response2 = self.get_response(request2, "https://example.com") + request3 = redirect_mw.process_response(request2, response2) + + assert isinstance(request3, Request) + assert "Proxy-Authorization" not in request3.headers + assert "_auth_proxy" not in request3.meta + assert "proxy" not in request3.meta + + proxy_mw.process_request(request3) + + assert "Proxy-Authorization" not in request3.headers + assert "_auth_proxy" not in request3.meta + assert "proxy" not in request3.meta + + def test_system_proxy_unproxied_https_to_unproxied_http(self): + crawler = get_crawler() + redirect_mw = self.mwcls.from_crawler(crawler) + proxy_mw = HttpProxyMiddleware.from_crawler(crawler) + + request1 = Request("https://example.com") + proxy_mw.process_request(request1) + + assert "Proxy-Authorization" not in request1.headers + assert "_auth_proxy" not in request1.meta + assert "proxy" not in request1.meta + + response1 = self.get_response(request1, "http://example.com") + request2 = redirect_mw.process_response(request1, response1) + + assert isinstance(request2, Request) + assert "Proxy-Authorization" not in request2.headers + assert "_auth_proxy" not in request2.meta + assert "proxy" not in request2.meta + + proxy_mw.process_request(request2) + + assert "Proxy-Authorization" not in request2.headers + assert "_auth_proxy" not in request2.meta + assert "proxy" not in request2.meta + + response2 = self.get_response(request2, "https://example.com") + request3 = redirect_mw.process_response(request2, response2) + + assert isinstance(request3, Request) + assert "Proxy-Authorization" not in request3.headers + assert "_auth_proxy" not in request3.meta + assert "proxy" not in request3.meta + + proxy_mw.process_request(request3) + + assert "Proxy-Authorization" not in request3.headers + assert "_auth_proxy" not in request3.meta + assert "proxy" not in request3.meta diff --git a/tests/test_downloadermiddleware_redirect_metarefresh.py b/tests/test_downloadermiddleware_redirect_metarefresh.py new file mode 100644 index 000000000..416fbc2aa --- /dev/null +++ b/tests/test_downloadermiddleware_redirect_metarefresh.py @@ -0,0 +1,159 @@ +from __future__ import annotations + +import logging +from itertools import chain +from unittest.mock import MagicMock + +import pytest + +from scrapy.downloadermiddlewares.redirect import MetaRefreshMiddleware +from scrapy.http import HtmlResponse, Request, Response +from scrapy.spiders import Spider +from scrapy.utils.misc import build_from_crawler +from scrapy.utils.test import get_crawler +from tests.test_downloadermiddleware_redirect_base import ( + HTTP_SCHEMES, + NON_HTTP_SCHEMES, + REDIRECT_SCHEME_CASES, + SCHEME_PARAMS, + Base, +) + + +def meta_refresh_body(url, interval=5): + html = f"""""" + return html.encode("utf-8") + + +class TestMetaRefreshMiddleware(Base.Test): + mwcls = MetaRefreshMiddleware + reason = "meta refresh" + + def setup_method(self): + crawler = get_crawler(Spider) + self.mw = self.mwcls.from_crawler(crawler) + + def _body(self, interval=5, url="http://example.org/newpage"): + return meta_refresh_body(url, interval) + + def get_response(self, request, location): + return HtmlResponse(request.url, body=self._body(url=location)) + + def test_meta_refresh(self): + req = Request(url="http://example.org") + rsp = HtmlResponse(req.url, body=self._body()) + req2 = self.mw.process_response(req, rsp) + assert isinstance(req2, Request) + assert req2.url == "http://example.org/newpage" + + def test_meta_refresh_with_high_interval(self): + # meta-refresh with high intervals don't trigger redirects + req = Request(url="http://example.org") + rsp = HtmlResponse( + url="http://example.org", body=self._body(interval=1000), encoding="utf-8" + ) + rsp2 = self.mw.process_response(req, rsp) + assert rsp is rsp2 + + def test_meta_refresh_trough_posted_request(self): + req = Request( + url="http://example.org", + method="POST", + body="test", + headers={"Content-Type": "text/plain", "Content-length": "4"}, + ) + rsp = HtmlResponse(req.url, body=self._body()) + req2 = self.mw.process_response(req, rsp) + + assert isinstance(req2, Request) + assert req2.url == "http://example.org/newpage" + assert req2.method == "GET" + assert "Content-Type" not in req2.headers, ( + "Content-Type header must not be present in redirected request" + ) + assert "Content-Length" not in req2.headers, ( + "Content-Length header must not be present in redirected request" + ) + assert not req2.body, f"Redirected body must be empty, not '{req2.body}'" + + def test_ignore_tags_default(self): + req = Request(url="http://example.org") + body = ( + """""" + ) + rsp = HtmlResponse(req.url, body=body.encode()) + response = self.mw.process_response(req, rsp) + assert isinstance(response, Response) + + def test_ignore_tags_1_x_list(self): + """Test that Scrapy 1.x behavior remains possible""" + settings = {"METAREFRESH_IGNORE_TAGS": ["script", "noscript"]} + crawler = get_crawler(Spider, settings) + mw = MetaRefreshMiddleware.from_crawler(crawler) + req = Request(url="http://example.org") + body = ( + """""" + ) + rsp = HtmlResponse(req.url, body=body.encode()) + response = mw.process_response(req, rsp) + assert isinstance(response, Response) + + +@pytest.mark.parametrize( + SCHEME_PARAMS, + [ + *REDIRECT_SCHEME_CASES, + # data/file/ftp/s3/foo → * does not redirect + *( + ( + f"{input_scheme}://example.com/a", + f"{output_scheme}://example.com/b", + None, + ) + for input_scheme in NON_HTTP_SCHEMES + for output_scheme in chain(HTTP_SCHEMES, NON_HTTP_SCHEMES) + ), + # data/file/ftp/s3/foo → relative does not redirect + *( + ( + f"{scheme}://example.com/a", + location, + None, + ) + for scheme in NON_HTTP_SCHEMES + for location in ("//example.com/b", "/b") + ), + ], +) +def test_meta_refresh_schemes(url, location, target): + crawler = get_crawler(Spider) + mw = MetaRefreshMiddleware.from_crawler(crawler) + request = Request(url) + response = HtmlResponse(url, body=meta_refresh_body(location)) + redirect = mw.process_response(request, response) + if target is None: + assert redirect == response + else: + assert isinstance(redirect, Request) + + +def test_warning_meta_refresh_middleware(caplog): + crawler = get_crawler() + crawler.get_spider_middleware = MagicMock(return_value=None) + mw = build_from_crawler(MetaRefreshMiddleware, crawler) + with caplog.at_level(logging.WARNING): + mw._engine_started() + assert ( + "scrapy.downloadermiddlewares.redirect.MetaRefreshMiddleware found no " + "scrapy.spidermiddlewares.referer.RefererMiddleware" + ) in caplog.text + assert ( + "enable scrapy.spidermiddlewares.referer.RefererMiddleware (or a subclass)" + in caplog.text + ) + assert ( + "replace scrapy.downloadermiddlewares.redirect.MetaRefreshMiddleware " + "with a subclass that overrides the handle_referer() method" + ) in caplog.text diff --git a/tests/test_feedexport.py b/tests/test_feedexport.py index 1ca5f21f0..bd6ac5967 100644 --- a/tests/test_feedexport.py +++ b/tests/test_feedexport.py @@ -1,65 +1,47 @@ from __future__ import annotations -import bz2 import csv -import gzip import json -import lzma import marshal -import os import pickle import random import shutil -import string -import sys import tempfile -import warnings from abc import ABC, abstractmethod -from collections import defaultdict -from io import BytesIO from logging import getLogger from pathlib import Path from string import ascii_letters, digits -from typing import IO, TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any from unittest import mock -from urllib.parse import quote, urljoin +from urllib.parse import urljoin from urllib.request import pathname2url import lxml.etree import pytest -from packaging.version import Version from testfixtures import LogCapture -from w3lib.url import file_uri_to_path, path_to_file_uri +from w3lib.url import file_uri_to_path from zope.interface import implementer -from zope.interface.verify import verifyObject import scrapy from scrapy import Spider, signals -from scrapy.exceptions import NotConfigured, ScrapyDeprecationWarning +from scrapy.exceptions import NotConfigured from scrapy.exporters import CsvItemExporter, JsonItemExporter from scrapy.extensions.feedexport import ( BlockingFeedStorage, FeedExporter, FeedSlot, FileFeedStorage, - FTPFeedStorage, - GCSFeedStorage, IFeedStorage, S3FeedStorage, - StdoutFeedStorage, ) -from scrapy.settings import Settings -from scrapy.utils.defer import maybe_deferred_to_future from scrapy.utils.python import to_unicode from scrapy.utils.test import get_crawler -from tests.mockserver.ftp import MockFTPServer from tests.mockserver.http import MockServer from tests.spiders import ItemSpider from tests.utils.decorators import coroutine_test, inline_callbacks_test if TYPE_CHECKING: from collections.abc import Callable, Iterable - from os import PathLike def path_to_url(path): @@ -70,541 +52,6 @@ def printf_escape(string): return string.replace("%", "%%") -def build_url(path: str | PathLike) -> str: - path_str = str(path) - if path_str[0] != "/": - path_str = "/" + path_str - return urljoin("file:", path_str) - - -def mock_google_cloud_storage() -> tuple[Any, Any, Any]: - """Creates autospec mocks for google-cloud-storage Client, Bucket and Blob - classes and set their proper return values. - """ - from google.cloud.storage import Blob, Bucket, Client # noqa: PLC0415 - - client_mock = mock.create_autospec(Client) - - bucket_mock = mock.create_autospec(Bucket) - client_mock.get_bucket.return_value = bucket_mock - - blob_mock = mock.create_autospec(Blob) - bucket_mock.blob.return_value = blob_mock - - return (client_mock, bucket_mock, blob_mock) - - -class TestFileFeedStorage: - def test_store_file_uri(self, tmp_path): - path = tmp_path / "file.txt" - uri = path_to_file_uri(str(path)) - self._assert_stores(FileFeedStorage(uri), path) - - def test_store_file_uri_makedirs(self, tmp_path): - path = tmp_path / "more" / "paths" / "file.txt" - uri = path_to_file_uri(str(path)) - self._assert_stores(FileFeedStorage(uri), path) - - def test_store_direct_path(self, tmp_path): - path = tmp_path / "file.txt" - self._assert_stores(FileFeedStorage(str(path)), path) - - def test_store_direct_path_relative(self, tmp_path): - old_cwd = Path.cwd() - try: - os.chdir(tmp_path) - path = Path("foo", "bar") - self._assert_stores(FileFeedStorage(str(path)), path) - finally: - os.chdir(old_cwd) - - def test_interface(self, tmp_path): - path = tmp_path / "file.txt" - st = FileFeedStorage(str(path)) - verifyObject(IFeedStorage, st) - - @staticmethod - def _store(path: Path, feed_options: dict[str, Any] | None = None) -> None: - storage = FileFeedStorage(str(path), feed_options=feed_options) - spider = scrapy.Spider("default") - file = storage.open(spider) - file.write(b"content") - storage.store(file) - - def test_append(self, tmp_path): - path = tmp_path / "file.txt" - self._store(path) - self._assert_stores(FileFeedStorage(str(path)), path, b"contentcontent") - - def test_overwrite(self, tmp_path): - path = tmp_path / "file.txt" - self._store(path, {"overwrite": True}) - self._assert_stores( - FileFeedStorage(str(path), feed_options={"overwrite": True}), path - ) - - @staticmethod - def _assert_stores( - storage: FileFeedStorage, path: Path, expected_content: bytes = b"content" - ) -> None: - spider = scrapy.Spider("default") - file = storage.open(spider) - file.write(b"content") - storage.store(file) - assert path.exists() - try: - assert path.read_bytes() == expected_content - finally: - path.unlink() - - def test_preserves_windows_path_without_file_scheme(self): - path = r"C:\Users\user\Desktop\test.txt" - storage = FileFeedStorage(path) - assert storage.path == path - - -@pytest.mark.requires_reactor # needs a reactor for BlockingFeedStorage -class TestFTPFeedStorage: - def get_test_spider(self, settings=None): - class TestSpider(scrapy.Spider): - name = "test_spider" - - crawler = get_crawler(settings_dict=settings) - return TestSpider.from_crawler(crawler) - - async def _store(self, uri, content, feed_options=None, settings=None): - crawler = get_crawler(settings_dict=settings or {}) - storage = FTPFeedStorage.from_crawler( - crawler, - uri, - feed_options=feed_options, - ) - verifyObject(IFeedStorage, storage) - spider = self.get_test_spider() - file = storage.open(spider) - file.write(content) - await maybe_deferred_to_future(storage.store(file)) - - def _assert_stored(self, path: Path, content): - assert path.exists() - try: - assert path.read_bytes() == content - finally: - path.unlink() - - @coroutine_test - async def test_append(self): - with MockFTPServer() as ftp_server: - filename = "file" - url = ftp_server.url(filename) - feed_options = {"overwrite": False} - await self._store(url, b"foo", feed_options=feed_options) - await self._store(url, b"bar", feed_options=feed_options) - self._assert_stored(ftp_server.path / filename, b"foobar") - - @coroutine_test - async def test_overwrite(self): - with MockFTPServer() as ftp_server: - filename = "file" - url = ftp_server.url(filename) - await self._store(url, b"foo") - await self._store(url, b"bar") - self._assert_stored(ftp_server.path / filename, b"bar") - - @coroutine_test - async def test_append_active_mode(self): - with MockFTPServer() as ftp_server: - settings = {"FEED_STORAGE_FTP_ACTIVE": True} - filename = "file" - url = ftp_server.url(filename) - feed_options = {"overwrite": False} - await self._store(url, b"foo", feed_options=feed_options, settings=settings) - await self._store(url, b"bar", feed_options=feed_options, settings=settings) - self._assert_stored(ftp_server.path / filename, b"foobar") - - @coroutine_test - async def test_overwrite_active_mode(self): - with MockFTPServer() as ftp_server: - settings = {"FEED_STORAGE_FTP_ACTIVE": True} - filename = "file" - url = ftp_server.url(filename) - await self._store(url, b"foo", settings=settings) - await self._store(url, b"bar", settings=settings) - self._assert_stored(ftp_server.path / filename, b"bar") - - def test_uri_auth_quote(self): - # RFC3986: 3.2.1. User Information - pw_quoted = quote(string.punctuation, safe="") - st = FTPFeedStorage(f"ftp://foo:{pw_quoted}@example.com/some_path", {}) - assert st.password == string.punctuation - - -class MyBlockingFeedStorage(BlockingFeedStorage): - def _store_in_thread(self, file: IO[bytes]) -> None: - return - - -class TestBlockingFeedStorage: - def get_test_spider(self, settings=None): - class TestSpider(scrapy.Spider): - name = "test_spider" - - crawler = get_crawler(settings_dict=settings) - return TestSpider.from_crawler(crawler) - - def test_default_temp_dir(self): - b = MyBlockingFeedStorage() - - storage_file = b.open(self.get_test_spider()) - storage_dir = Path(storage_file.name).parent - assert str(storage_dir) == tempfile.gettempdir() - - def test_temp_file(self, tmp_path): - b = MyBlockingFeedStorage() - - spider = self.get_test_spider({"FEED_TEMPDIR": str(tmp_path)}) - storage_file = b.open(spider) - storage_dir = Path(storage_file.name).parent - assert storage_dir == tmp_path - - def test_invalid_folder(self, tmp_path): - b = MyBlockingFeedStorage() - - invalid_path = tmp_path / "invalid_path" - spider = self.get_test_spider({"FEED_TEMPDIR": str(invalid_path)}) - - with pytest.raises(OSError, match="Not a Directory:"): - b.open(spider=spider) - - -@pytest.mark.requires_boto3 -@pytest.mark.requires_reactor # needs a reactor for BlockingFeedStorage -class TestS3FeedStorage: - def test_parse_credentials(self): - aws_credentials = { - "AWS_ACCESS_KEY_ID": "settings_key", - "AWS_SECRET_ACCESS_KEY": "settings_secret", - "AWS_SESSION_TOKEN": "settings_token", - } - crawler = get_crawler(settings_dict=aws_credentials) - # Instantiate with crawler - storage = S3FeedStorage.from_crawler( - crawler, - "s3://mybucket/export.csv", - ) - assert storage.access_key == "settings_key" - assert storage.secret_key == "settings_secret" - assert storage.session_token == "settings_token" - # Instantiate directly - storage = S3FeedStorage( - "s3://mybucket/export.csv", - aws_credentials["AWS_ACCESS_KEY_ID"], - aws_credentials["AWS_SECRET_ACCESS_KEY"], - session_token=aws_credentials["AWS_SESSION_TOKEN"], - ) - assert storage.access_key == "settings_key" - assert storage.secret_key == "settings_secret" - assert storage.session_token == "settings_token" - # URI priority > settings priority - storage = S3FeedStorage( - "s3://uri_key:uri_secret@mybucket/export.csv", - aws_credentials["AWS_ACCESS_KEY_ID"], - aws_credentials["AWS_SECRET_ACCESS_KEY"], - ) - assert storage.access_key == "uri_key" - assert storage.secret_key == "uri_secret" - - @coroutine_test - async def test_store(self): - settings = { - "AWS_ACCESS_KEY_ID": "access_key", - "AWS_SECRET_ACCESS_KEY": "secret_key", - } - crawler = get_crawler(settings_dict=settings) - bucket = "mybucket" - key = "export.csv" - storage = S3FeedStorage.from_crawler(crawler, f"s3://{bucket}/{key}") - verifyObject(IFeedStorage, storage) - - file = mock.MagicMock() - - storage.s3_client = mock.MagicMock() - await maybe_deferred_to_future(storage.store(file)) - assert storage.s3_client.upload_fileobj.call_args == mock.call( - Bucket=bucket, Key=key, Fileobj=file - ) - - def test_init_without_acl(self): - storage = S3FeedStorage("s3://mybucket/export.csv", "access_key", "secret_key") - assert storage.access_key == "access_key" - assert storage.secret_key == "secret_key" - assert storage.acl is None - - def test_init_with_acl(self): - storage = S3FeedStorage( - "s3://mybucket/export.csv", "access_key", "secret_key", "custom-acl" - ) - assert storage.access_key == "access_key" - assert storage.secret_key == "secret_key" - assert storage.acl == "custom-acl" - - def test_init_with_endpoint_url(self): - storage = S3FeedStorage( - "s3://mybucket/export.csv", - "access_key", - "secret_key", - endpoint_url="https://example.com", - ) - assert storage.access_key == "access_key" - assert storage.secret_key == "secret_key" - assert storage.endpoint_url == "https://example.com" - - def test_init_with_region_name(self): - region_name = "ap-east-1" - storage = S3FeedStorage( - "s3://mybucket/export.csv", - "access_key", - "secret_key", - region_name=region_name, - ) - assert storage.access_key == "access_key" - assert storage.secret_key == "secret_key" - assert storage.region_name == region_name - assert storage.s3_client._client_config.region_name == region_name - - def test_from_crawler_without_acl(self): - settings = { - "AWS_ACCESS_KEY_ID": "access_key", - "AWS_SECRET_ACCESS_KEY": "secret_key", - } - crawler = get_crawler(settings_dict=settings) - storage = S3FeedStorage.from_crawler( - crawler, - "s3://mybucket/export.csv", - ) - assert storage.access_key == "access_key" - assert storage.secret_key == "secret_key" - assert storage.acl is None - - def test_without_endpoint_url(self): - settings = { - "AWS_ACCESS_KEY_ID": "access_key", - "AWS_SECRET_ACCESS_KEY": "secret_key", - } - crawler = get_crawler(settings_dict=settings) - storage = S3FeedStorage.from_crawler( - crawler, - "s3://mybucket/export.csv", - ) - assert storage.access_key == "access_key" - assert storage.secret_key == "secret_key" - assert storage.endpoint_url is None - - def test_without_region_name(self): - settings = { - "AWS_ACCESS_KEY_ID": "access_key", - "AWS_SECRET_ACCESS_KEY": "secret_key", - } - crawler = get_crawler(settings_dict=settings) - storage = S3FeedStorage.from_crawler( - crawler, - "s3://mybucket/export.csv", - ) - assert storage.access_key == "access_key" - assert storage.secret_key == "secret_key" - assert storage.s3_client._client_config.region_name == "us-east-1" - - def test_from_crawler_with_acl(self): - settings = { - "AWS_ACCESS_KEY_ID": "access_key", - "AWS_SECRET_ACCESS_KEY": "secret_key", - "FEED_STORAGE_S3_ACL": "custom-acl", - } - crawler = get_crawler(settings_dict=settings) - storage = S3FeedStorage.from_crawler( - crawler, - "s3://mybucket/export.csv", - ) - assert storage.access_key == "access_key" - assert storage.secret_key == "secret_key" - assert storage.acl == "custom-acl" - - def test_from_crawler_with_endpoint_url(self): - settings = { - "AWS_ACCESS_KEY_ID": "access_key", - "AWS_SECRET_ACCESS_KEY": "secret_key", - "AWS_ENDPOINT_URL": "https://example.com", - } - crawler = get_crawler(settings_dict=settings) - storage = S3FeedStorage.from_crawler(crawler, "s3://mybucket/export.csv") - assert storage.access_key == "access_key" - assert storage.secret_key == "secret_key" - assert storage.endpoint_url == "https://example.com" - - def test_from_crawler_with_region_name(self): - region_name = "ap-east-1" - settings = { - "AWS_ACCESS_KEY_ID": "access_key", - "AWS_SECRET_ACCESS_KEY": "secret_key", - "AWS_REGION_NAME": region_name, - } - crawler = get_crawler(settings_dict=settings) - storage = S3FeedStorage.from_crawler(crawler, "s3://mybucket/export.csv") - assert storage.access_key == "access_key" - assert storage.secret_key == "secret_key" - assert storage.region_name == region_name - assert storage.s3_client._client_config.region_name == region_name - - @coroutine_test - async def test_store_without_acl(self): - storage = S3FeedStorage( - "s3://mybucket/export.csv", - "access_key", - "secret_key", - ) - assert storage.access_key == "access_key" - assert storage.secret_key == "secret_key" - assert storage.acl is None - - storage.s3_client = mock.MagicMock() - await maybe_deferred_to_future(storage.store(BytesIO(b"test file"))) - acl = ( - storage.s3_client.upload_fileobj.call_args[1] - .get("ExtraArgs", {}) - .get("ACL") - ) - assert acl is None - - @coroutine_test - async def test_store_with_acl(self): - storage = S3FeedStorage( - "s3://mybucket/export.csv", "access_key", "secret_key", "custom-acl" - ) - assert storage.access_key == "access_key" - assert storage.secret_key == "secret_key" - assert storage.acl == "custom-acl" - - storage.s3_client = mock.MagicMock() - await maybe_deferred_to_future(storage.store(BytesIO(b"test file"))) - acl = storage.s3_client.upload_fileobj.call_args[1]["ExtraArgs"]["ACL"] - assert acl == "custom-acl" - - def test_overwrite_default(self): - with LogCapture() as log: - S3FeedStorage( - "s3://mybucket/export.csv", "access_key", "secret_key", "custom-acl" - ) - assert "S3 does not support appending to files" not in str(log) - - def test_overwrite_false(self): - with LogCapture() as log: - S3FeedStorage( - "s3://mybucket/export.csv", - "access_key", - "secret_key", - "custom-acl", - feed_options={"overwrite": False}, - ) - assert "S3 does not support appending to files" in str(log) - - -@pytest.mark.requires_reactor # needs a reactor for BlockingFeedStorage -class TestGCSFeedStorage: - def test_parse_settings(self): - try: - from google.cloud.storage import Client # noqa: F401,PLC0415 - except ImportError: - pytest.skip("GCSFeedStorage requires google-cloud-storage") - - settings = {"GCS_PROJECT_ID": "123", "FEED_STORAGE_GCS_ACL": "publicRead"} - crawler = get_crawler(settings_dict=settings) - storage = GCSFeedStorage.from_crawler(crawler, "gs://mybucket/export.csv") - assert storage.project_id == "123" - assert storage.acl == "publicRead" - assert storage.bucket_name == "mybucket" - assert storage.blob_name == "export.csv" - - def test_parse_empty_acl(self): - try: - from google.cloud.storage import Client # noqa: F401,PLC0415 - except ImportError: - pytest.skip("GCSFeedStorage requires google-cloud-storage") - - settings = {"GCS_PROJECT_ID": "123", "FEED_STORAGE_GCS_ACL": ""} - crawler = get_crawler(settings_dict=settings) - storage = GCSFeedStorage.from_crawler(crawler, "gs://mybucket/export.csv") - assert storage.acl is None - - settings = {"GCS_PROJECT_ID": "123", "FEED_STORAGE_GCS_ACL": None} - crawler = get_crawler(settings_dict=settings) - storage = GCSFeedStorage.from_crawler(crawler, "gs://mybucket/export.csv") - assert storage.acl is None - - @coroutine_test - async def test_store(self): - try: - from google.cloud.storage import Client # noqa: F401,PLC0415 - except ImportError: - pytest.skip("GCSFeedStorage requires google-cloud-storage") - - uri = "gs://mybucket/export.csv" - project_id = "myproject-123" - acl = "publicRead" - (client_mock, bucket_mock, blob_mock) = mock_google_cloud_storage() - with mock.patch("google.cloud.storage.Client") as m: - m.return_value = client_mock - - f = mock.Mock() - storage = GCSFeedStorage(uri, project_id, acl) - await maybe_deferred_to_future(storage.store(f)) - - f.seek.assert_called_once_with(0) - m.assert_called_once_with(project=project_id) - client_mock.get_bucket.assert_called_once_with("mybucket") - bucket_mock.blob.assert_called_once_with("export.csv") - blob_mock.upload_from_file.assert_called_once_with(f, predefined_acl=acl) - - def test_overwrite_default(self): - with LogCapture() as log: - GCSFeedStorage("gs://mybucket/export.csv", "myproject-123", "custom-acl") - assert "GCS does not support appending to files" not in str(log) - - def test_overwrite_false(self): - with LogCapture() as log: - GCSFeedStorage( - "gs://mybucket/export.csv", - "myproject-123", - "custom-acl", - feed_options={"overwrite": False}, - ) - assert "GCS does not support appending to files" in str(log) - - -class TestStdoutFeedStorage: - def test_store(self): - out = BytesIO() - storage = StdoutFeedStorage("stdout:", _stdout=out) - file = storage.open(scrapy.Spider("default")) - file.write(b"content") - storage.store(file) - assert out.getvalue() == b"content" - - def test_overwrite_default(self): - with LogCapture() as log: - StdoutFeedStorage("stdout:") - assert ( - "Standard output (stdout) storage does not support overwriting" - not in str(log) - ) - - def test_overwrite_true(self): - with LogCapture() as log: - StdoutFeedStorage("stdout:", feed_options={"overwrite": True}) - assert "Standard output (stdout) storage does not support overwriting" in str( - log - ) - - class FromCrawlerMixin: init_with_crawler = False @@ -1834,945 +1281,6 @@ class TestFeedExport(TestFeedExportBase): assert not Storage.file_was_closed -class TestFeedPostProcessedExports(TestFeedExportBase): - items = [{"foo": "bar"}] - expected = b"foo\r\nbar\r\n" - - class MyPlugin1: - def __init__(self, file, feed_options): - self.file = file - self.feed_options = feed_options - self.char = self.feed_options.get("plugin1_char", b"") - - def write(self, data): - written_count = self.file.write(data) - written_count += self.file.write(self.char) - return written_count - - def close(self): - self.file.close() - - def _named_tempfile(self, name) -> str: - return str(Path(self.temp_dir, name)) - - async def run_and_export( - self, spider_cls: type[Spider], settings: dict[str, Any] - ) -> dict[str, bytes | None]: - """Run spider with specified settings; return exported data with filename.""" - - FEEDS = settings.get("FEEDS") or {} - settings["FEEDS"] = { - printf_escape(path_to_url(file_path)): feed_options - for file_path, feed_options in FEEDS.items() - } - - content: dict[str, bytes | None] = {} - try: - spider_cls.start_urls = [self.mockserver.url("/")] - crawler = get_crawler(spider_cls, settings) - await crawler.crawl_async() - - for file_path in FEEDS: - content[str(file_path)] = ( - Path(file_path).read_bytes() if Path(file_path).exists() else None - ) - - finally: - for file_path in FEEDS: - if not Path(file_path).exists(): - continue - - Path(file_path).unlink() - - return content - - def get_gzip_compressed(self, data, compresslevel=9, mtime=0, filename=""): - data_stream = BytesIO() - gzipf = gzip.GzipFile( - fileobj=data_stream, - filename=filename, - mtime=mtime, - compresslevel=compresslevel, - mode="wb", - ) - gzipf.write(data) - gzipf.close() - data_stream.seek(0) - return data_stream.read() - - @coroutine_test - async def test_gzip_plugin(self): - filename = self._named_tempfile("gzip_file") - - settings = { - "FEEDS": { - filename: { - "format": "csv", - "postprocessing": ["scrapy.extensions.postprocessing.GzipPlugin"], - }, - }, - } - - data = await self.exported_data(self.items, settings) - try: - gzip.decompress(data[filename]) - except OSError: - pytest.fail("Received invalid gzip data.") - - @coroutine_test - async def test_gzip_plugin_compresslevel(self): - filename_to_compressed = { - self._named_tempfile("compresslevel_0"): self.get_gzip_compressed( - self.expected, compresslevel=0 - ), - self._named_tempfile("compresslevel_9"): self.get_gzip_compressed( - self.expected, compresslevel=9 - ), - } - - settings = { - "FEEDS": { - self._named_tempfile("compresslevel_0"): { - "format": "csv", - "postprocessing": ["scrapy.extensions.postprocessing.GzipPlugin"], - "gzip_compresslevel": 0, - "gzip_mtime": 0, - "gzip_filename": "", - }, - self._named_tempfile("compresslevel_9"): { - "format": "csv", - "postprocessing": ["scrapy.extensions.postprocessing.GzipPlugin"], - "gzip_compresslevel": 9, - "gzip_mtime": 0, - "gzip_filename": "", - }, - }, - } - - data = await self.exported_data(self.items, settings) - - for filename, compressed in filename_to_compressed.items(): - result = gzip.decompress(data[filename]) - assert compressed == data[filename] - assert result == self.expected - - @coroutine_test - async def test_gzip_plugin_mtime(self): - filename_to_compressed = { - self._named_tempfile("mtime_123"): self.get_gzip_compressed( - self.expected, mtime=123 - ), - self._named_tempfile("mtime_123456789"): self.get_gzip_compressed( - self.expected, mtime=123456789 - ), - } - - settings = { - "FEEDS": { - self._named_tempfile("mtime_123"): { - "format": "csv", - "postprocessing": ["scrapy.extensions.postprocessing.GzipPlugin"], - "gzip_mtime": 123, - "gzip_filename": "", - }, - self._named_tempfile("mtime_123456789"): { - "format": "csv", - "postprocessing": ["scrapy.extensions.postprocessing.GzipPlugin"], - "gzip_mtime": 123456789, - "gzip_filename": "", - }, - }, - } - - data = await self.exported_data(self.items, settings) - - for filename, compressed in filename_to_compressed.items(): - result = gzip.decompress(data[filename]) - assert compressed == data[filename] - assert result == self.expected - - @coroutine_test - async def test_gzip_plugin_filename(self): - filename_to_compressed = { - self._named_tempfile("filename_FILE1"): self.get_gzip_compressed( - self.expected, filename="FILE1" - ), - self._named_tempfile("filename_FILE2"): self.get_gzip_compressed( - self.expected, filename="FILE2" - ), - } - - settings = { - "FEEDS": { - self._named_tempfile("filename_FILE1"): { - "format": "csv", - "postprocessing": ["scrapy.extensions.postprocessing.GzipPlugin"], - "gzip_mtime": 0, - "gzip_filename": "FILE1", - }, - self._named_tempfile("filename_FILE2"): { - "format": "csv", - "postprocessing": ["scrapy.extensions.postprocessing.GzipPlugin"], - "gzip_mtime": 0, - "gzip_filename": "FILE2", - }, - }, - } - - data = await self.exported_data(self.items, settings) - - for filename, compressed in filename_to_compressed.items(): - result = gzip.decompress(data[filename]) - assert compressed == data[filename] - assert result == self.expected - - @coroutine_test - async def test_lzma_plugin(self): - filename = self._named_tempfile("lzma_file") - - settings = { - "FEEDS": { - filename: { - "format": "csv", - "postprocessing": ["scrapy.extensions.postprocessing.LZMAPlugin"], - }, - }, - } - - data = await self.exported_data(self.items, settings) - try: - lzma.decompress(data[filename]) - except lzma.LZMAError: - pytest.fail("Received invalid lzma data.") - - @coroutine_test - async def test_lzma_plugin_format(self): - filename_to_compressed = { - self._named_tempfile("format_FORMAT_XZ"): lzma.compress( - self.expected, format=lzma.FORMAT_XZ - ), - self._named_tempfile("format_FORMAT_ALONE"): lzma.compress( - self.expected, format=lzma.FORMAT_ALONE - ), - } - - settings = { - "FEEDS": { - self._named_tempfile("format_FORMAT_XZ"): { - "format": "csv", - "postprocessing": ["scrapy.extensions.postprocessing.LZMAPlugin"], - "lzma_format": lzma.FORMAT_XZ, - }, - self._named_tempfile("format_FORMAT_ALONE"): { - "format": "csv", - "postprocessing": ["scrapy.extensions.postprocessing.LZMAPlugin"], - "lzma_format": lzma.FORMAT_ALONE, - }, - }, - } - - data = await self.exported_data(self.items, settings) - - for filename, compressed in filename_to_compressed.items(): - result = lzma.decompress(data[filename]) - assert compressed == data[filename] - assert result == self.expected - - @coroutine_test - async def test_lzma_plugin_check(self): - filename_to_compressed = { - self._named_tempfile("check_CHECK_NONE"): lzma.compress( - self.expected, check=lzma.CHECK_NONE - ), - self._named_tempfile("check_CHECK_CRC256"): lzma.compress( - self.expected, check=lzma.CHECK_SHA256 - ), - } - - settings = { - "FEEDS": { - self._named_tempfile("check_CHECK_NONE"): { - "format": "csv", - "postprocessing": ["scrapy.extensions.postprocessing.LZMAPlugin"], - "lzma_check": lzma.CHECK_NONE, - }, - self._named_tempfile("check_CHECK_CRC256"): { - "format": "csv", - "postprocessing": ["scrapy.extensions.postprocessing.LZMAPlugin"], - "lzma_check": lzma.CHECK_SHA256, - }, - }, - } - - data = await self.exported_data(self.items, settings) - - for filename, compressed in filename_to_compressed.items(): - result = lzma.decompress(data[filename]) - assert compressed == data[filename] - assert result == self.expected - - @coroutine_test - async def test_lzma_plugin_preset(self): - filename_to_compressed = { - self._named_tempfile("preset_PRESET_0"): lzma.compress( - self.expected, preset=0 - ), - self._named_tempfile("preset_PRESET_9"): lzma.compress( - self.expected, preset=9 - ), - } - - settings = { - "FEEDS": { - self._named_tempfile("preset_PRESET_0"): { - "format": "csv", - "postprocessing": ["scrapy.extensions.postprocessing.LZMAPlugin"], - "lzma_preset": 0, - }, - self._named_tempfile("preset_PRESET_9"): { - "format": "csv", - "postprocessing": ["scrapy.extensions.postprocessing.LZMAPlugin"], - "lzma_preset": 9, - }, - }, - } - - data = await self.exported_data(self.items, settings) - - for filename, compressed in filename_to_compressed.items(): - result = lzma.decompress(data[filename]) - assert compressed == data[filename] - assert result == self.expected - - @coroutine_test - async def test_lzma_plugin_filters(self): - if "PyPy" in sys.version: - # https://foss.heptapod.net/pypy/pypy/-/issues/3527 - pytest.skip("lzma filters doesn't work in PyPy") - - filters = [{"id": lzma.FILTER_LZMA2}] - compressed = lzma.compress(self.expected, filters=filters) - filename = self._named_tempfile("filters") - - settings = { - "FEEDS": { - filename: { - "format": "csv", - "postprocessing": ["scrapy.extensions.postprocessing.LZMAPlugin"], - "lzma_filters": filters, - }, - }, - } - - data = await self.exported_data(self.items, settings) - assert compressed == data[filename] - result = lzma.decompress(data[filename]) - assert result == self.expected - - @coroutine_test - async def test_bz2_plugin(self): - filename = self._named_tempfile("bz2_file") - - settings = { - "FEEDS": { - filename: { - "format": "csv", - "postprocessing": ["scrapy.extensions.postprocessing.Bz2Plugin"], - }, - }, - } - - data = await self.exported_data(self.items, settings) - try: - bz2.decompress(data[filename]) - except OSError: - pytest.fail("Received invalid bz2 data.") - - @coroutine_test - async def test_bz2_plugin_compresslevel(self): - filename_to_compressed = { - self._named_tempfile("compresslevel_1"): bz2.compress( - self.expected, compresslevel=1 - ), - self._named_tempfile("compresslevel_9"): bz2.compress( - self.expected, compresslevel=9 - ), - } - - settings = { - "FEEDS": { - self._named_tempfile("compresslevel_1"): { - "format": "csv", - "postprocessing": ["scrapy.extensions.postprocessing.Bz2Plugin"], - "bz2_compresslevel": 1, - }, - self._named_tempfile("compresslevel_9"): { - "format": "csv", - "postprocessing": ["scrapy.extensions.postprocessing.Bz2Plugin"], - "bz2_compresslevel": 9, - }, - }, - } - - data = await self.exported_data(self.items, settings) - - for filename, compressed in filename_to_compressed.items(): - result = bz2.decompress(data[filename]) - assert compressed == data[filename] - assert result == self.expected - - @coroutine_test - async def test_custom_plugin(self): - filename = self._named_tempfile("csv_file") - - settings = { - "FEEDS": { - filename: { - "format": "csv", - "postprocessing": [self.MyPlugin1], - }, - }, - } - - data = await self.exported_data(self.items, settings) - assert data[filename] == self.expected - - @coroutine_test - async def test_custom_plugin_with_parameter(self): - expected = b"foo\r\n\nbar\r\n\n" - filename = self._named_tempfile("newline") - - settings = { - "FEEDS": { - filename: { - "format": "csv", - "postprocessing": [self.MyPlugin1], - "plugin1_char": b"\n", - }, - }, - } - - data = await self.exported_data(self.items, settings) - assert data[filename] == expected - - @coroutine_test - async def test_custom_plugin_with_compression(self): - expected = b"foo\r\n\nbar\r\n\n" - - filename_to_decompressor = { - self._named_tempfile("bz2"): bz2.decompress, - self._named_tempfile("lzma"): lzma.decompress, - self._named_tempfile("gzip"): gzip.decompress, - } - - settings = { - "FEEDS": { - self._named_tempfile("bz2"): { - "format": "csv", - "postprocessing": [ - self.MyPlugin1, - "scrapy.extensions.postprocessing.Bz2Plugin", - ], - "plugin1_char": b"\n", - }, - self._named_tempfile("lzma"): { - "format": "csv", - "postprocessing": [ - self.MyPlugin1, - "scrapy.extensions.postprocessing.LZMAPlugin", - ], - "plugin1_char": b"\n", - }, - self._named_tempfile("gzip"): { - "format": "csv", - "postprocessing": [ - self.MyPlugin1, - "scrapy.extensions.postprocessing.GzipPlugin", - ], - "plugin1_char": b"\n", - }, - }, - } - - data = await self.exported_data(self.items, settings) - - for filename, decompressor in filename_to_decompressor.items(): - result = decompressor(data[filename]) - assert result == expected - - @coroutine_test - async def test_exports_compatibility_with_postproc(self): - filename_to_expected = { - self._named_tempfile("csv"): b"foo\r\nbar\r\n", - self._named_tempfile("json"): b'[\n{"foo": "bar"}\n]', - self._named_tempfile("jsonlines"): b'{"foo": "bar"}\n', - self._named_tempfile("xml"): b'\n' - b"\nbar\n", - } - - settings = { - "FEEDS": { - self._named_tempfile("csv"): { - "format": "csv", - "postprocessing": [self.MyPlugin1], - # empty plugin to activate postprocessing.PostProcessingManager - }, - self._named_tempfile("json"): { - "format": "json", - "postprocessing": [self.MyPlugin1], - }, - self._named_tempfile("jsonlines"): { - "format": "jsonlines", - "postprocessing": [self.MyPlugin1], - }, - self._named_tempfile("xml"): { - "format": "xml", - "postprocessing": [self.MyPlugin1], - }, - self._named_tempfile("marshal"): { - "format": "marshal", - "postprocessing": [self.MyPlugin1], - }, - self._named_tempfile("pickle"): { - "format": "pickle", - "postprocessing": [self.MyPlugin1], - }, - }, - } - - data = await self.exported_data(self.items, settings) - - for filename, result in data.items(): - if "pickle" in filename: - expected, result = self.items[0], pickle.loads(result) - elif "marshal" in filename: - expected, result = self.items[0], marshal.loads(result) - else: - expected = filename_to_expected[filename] - assert result == expected - - -class TestBatchDeliveries(TestFeedExportBase): - _file_mark = "_%(batch_time)s_#%(batch_id)02d_" - - async def run_and_export( - self, spider_cls: type[Spider], settings: dict[str, Any] - ) -> dict[str, list[bytes]]: - """Run spider with specified settings; return exported data.""" - - FEEDS = settings.get("FEEDS") or {} - settings["FEEDS"] = { - build_url(file_path): feed for file_path, feed in FEEDS.items() - } - content: defaultdict[str, list[bytes]] = defaultdict(list) - spider_cls.start_urls = [self.mockserver.url("/")] - crawler = get_crawler(spider_cls, settings) - await crawler.crawl_async() - - for path, feed in FEEDS.items(): - dir_name = Path(path).parent - if not dir_name.exists(): - content[feed["format"]] = [] - continue - for file in sorted(dir_name.iterdir()): - content[feed["format"]].append(file.read_bytes()) - return content - - async def assertExportedJsonLines(self, items, rows, settings=None): - settings = settings or {} - settings.update( - { - "FEEDS": { - self._random_temp_filename() / "jl" / self._file_mark: { - "format": "jl" - }, - }, - } - ) - batch_size = Settings(settings).getint("FEED_EXPORT_BATCH_ITEM_COUNT") - rows = [{k: v for k, v in row.items() if v} for row in rows] - data = await self.exported_data(items, settings) - for batch in data["jl"]: - got_batch = [ - json.loads(to_unicode(batch_item)) for batch_item in batch.splitlines() - ] - expected_batch, rows = rows[:batch_size], rows[batch_size:] - assert got_batch == expected_batch - - async def assertExportedCsv(self, items, header, rows, settings=None): - settings = settings or {} - settings.update( - { - "FEEDS": { - self._random_temp_filename() / "csv" / self._file_mark: { - "format": "csv" - }, - }, - } - ) - batch_size = Settings(settings).getint("FEED_EXPORT_BATCH_ITEM_COUNT") - data = await self.exported_data(items, settings) - for batch in data["csv"]: - got_batch = csv.DictReader(to_unicode(batch).splitlines()) - assert list(header) == got_batch.fieldnames - expected_batch, rows = rows[:batch_size], rows[batch_size:] - assert list(got_batch) == expected_batch - - async def assertExportedXml(self, items, rows, settings=None): - settings = settings or {} - settings.update( - { - "FEEDS": { - self._random_temp_filename() / "xml" / self._file_mark: { - "format": "xml" - }, - }, - } - ) - batch_size = Settings(settings).getint("FEED_EXPORT_BATCH_ITEM_COUNT") - rows = [{k: v for k, v in row.items() if v} for row in rows] - data = await self.exported_data(items, settings) - for batch in data["xml"]: - root = lxml.etree.fromstring(batch) - got_batch = [{e.tag: e.text for e in it} for it in root.findall("item")] - expected_batch, rows = rows[:batch_size], rows[batch_size:] - assert got_batch == expected_batch - - async def assertExportedMultiple(self, items, rows, settings=None): - settings = settings or {} - settings.update( - { - "FEEDS": { - self._random_temp_filename() / "xml" / self._file_mark: { - "format": "xml" - }, - self._random_temp_filename() / "json" / self._file_mark: { - "format": "json" - }, - }, - } - ) - batch_size = Settings(settings).getint("FEED_EXPORT_BATCH_ITEM_COUNT") - rows = [{k: v for k, v in row.items() if v} for row in rows] - data = await self.exported_data(items, settings) - # XML - xml_rows = rows.copy() - for batch in data["xml"]: - root = lxml.etree.fromstring(batch) - got_batch = [{e.tag: e.text for e in it} for it in root.findall("item")] - expected_batch, xml_rows = xml_rows[:batch_size], xml_rows[batch_size:] - assert got_batch == expected_batch - # JSON - json_rows = rows.copy() - for batch in data["json"]: - got_batch = json.loads(batch.decode("utf-8")) - expected_batch, json_rows = json_rows[:batch_size], json_rows[batch_size:] - assert got_batch == expected_batch - - async def assertExportedPickle(self, items, rows, settings=None): - settings = settings or {} - settings.update( - { - "FEEDS": { - self._random_temp_filename() / "pickle" / self._file_mark: { - "format": "pickle" - }, - }, - } - ) - batch_size = Settings(settings).getint("FEED_EXPORT_BATCH_ITEM_COUNT") - rows = [{k: v for k, v in row.items() if v} for row in rows] - data = await self.exported_data(items, settings) - - for batch in data["pickle"]: - got_batch = self._load_until_eof(batch, load_func=pickle.load) - expected_batch, rows = rows[:batch_size], rows[batch_size:] - assert got_batch == expected_batch - - async def assertExportedMarshal(self, items, rows, settings=None): - settings = settings or {} - settings.update( - { - "FEEDS": { - self._random_temp_filename() / "marshal" / self._file_mark: { - "format": "marshal" - }, - }, - } - ) - batch_size = Settings(settings).getint("FEED_EXPORT_BATCH_ITEM_COUNT") - rows = [{k: v for k, v in row.items() if v} for row in rows] - data = await self.exported_data(items, settings) - - for batch in data["marshal"]: - got_batch = self._load_until_eof(batch, load_func=marshal.load) - expected_batch, rows = rows[:batch_size], rows[batch_size:] - assert got_batch == expected_batch - - @coroutine_test - async def test_export_items(self): - """Test partial deliveries in all supported formats""" - items = [ - self.MyItem({"foo": "bar1", "egg": "spam1"}), - self.MyItem({"foo": "bar2", "egg": "spam2", "baz": "quux2"}), - self.MyItem({"foo": "bar3", "baz": "quux3"}), - ] - rows = [ - {"egg": "spam1", "foo": "bar1", "baz": ""}, - {"egg": "spam2", "foo": "bar2", "baz": "quux2"}, - {"foo": "bar3", "baz": "quux3", "egg": ""}, - ] - settings = {"FEED_EXPORT_BATCH_ITEM_COUNT": 2} - header = self.MyItem.fields.keys() - await self.assertExported(items, header, rows, settings=settings) - - def test_wrong_path(self): - """If path is without %(batch_time)s and %(batch_id) an exception must be raised""" - settings = { - "FEEDS": { - self._random_temp_filename(): {"format": "xml"}, - }, - "FEED_EXPORT_BATCH_ITEM_COUNT": 1, - } - crawler = get_crawler(settings_dict=settings) - with pytest.raises(NotConfigured): - FeedExporter(crawler) - - @coroutine_test - async def test_export_no_items_not_store_empty(self): - for fmt in ("json", "jsonlines", "xml", "csv"): - settings = { - "FEEDS": { - self._random_temp_filename() / fmt / self._file_mark: { - "format": fmt - }, - }, - "FEED_EXPORT_BATCH_ITEM_COUNT": 1, - "FEED_STORE_EMPTY": False, - } - data = await self.exported_no_data(settings) - data = dict(data) - assert len(data[fmt]) == 0 - - @coroutine_test - async def test_export_no_items_store_empty(self): - formats = ( - ("json", b"[]"), - ("jsonlines", b""), - ("xml", b'\n'), - ("csv", b""), - ) - - for fmt, expctd in formats: - settings = { - "FEEDS": { - self._random_temp_filename() / fmt / self._file_mark: { - "format": fmt - }, - }, - "FEED_STORE_EMPTY": True, - "FEED_EXPORT_INDENT": None, - "FEED_EXPORT_BATCH_ITEM_COUNT": 1, - } - data = await self.exported_no_data(settings) - data = dict(data) - assert data[fmt][0] == expctd - - @coroutine_test - async def test_export_multiple_configs(self): - items = [ - {"foo": "FOO", "bar": "BAR"}, - {"foo": "FOO1", "bar": "BAR1"}, - ] - - formats = { - "json": [ - b'[\n{"bar": "BAR"}\n]', - b'[\n{"bar": "BAR1"}\n]', - ], - "xml": [ - ( - b'\n' - b"\n \n FOO\n \n" - ), - ( - b'\n' - b"\n \n FOO1\n \n" - ), - ], - "csv": [ - b"foo,bar\r\nFOO,BAR\r\n", - b"foo,bar\r\nFOO1,BAR1\r\n", - ], - } - - settings = { - "FEEDS": { - self._random_temp_filename() / "json" / self._file_mark: { - "format": "json", - "indent": 0, - "fields": ["bar"], - "encoding": "utf-8", - }, - self._random_temp_filename() / "xml" / self._file_mark: { - "format": "xml", - "indent": 2, - "fields": ["foo"], - "encoding": "latin-1", - }, - self._random_temp_filename() / "csv" / self._file_mark: { - "format": "csv", - "indent": None, - "fields": ["foo", "bar"], - "encoding": "utf-8", - }, - }, - "FEED_EXPORT_BATCH_ITEM_COUNT": 1, - } - data = await self.exported_data(items, settings) - for fmt, expected in formats.items(): - for expected_batch, got_batch in zip(expected, data[fmt], strict=False): - assert got_batch == expected_batch - - @coroutine_test - async def test_batch_item_count_feeds_setting(self): - items = [{"foo": "FOO"}, {"foo": "FOO1"}] - formats = { - "json": [ - b'[{"foo": "FOO"}]', - b'[{"foo": "FOO1"}]', - ], - } - settings = { - "FEEDS": { - self._random_temp_filename() / "json" / self._file_mark: { - "format": "json", - "indent": None, - "encoding": "utf-8", - "batch_item_count": 1, - }, - }, - } - data = await self.exported_data(items, settings) - for fmt, expected in formats.items(): - for expected_batch, got_batch in zip(expected, data[fmt], strict=False): - assert got_batch == expected_batch - - @coroutine_test - async def test_batch_path_differ(self): - """ - Test that the name of all batch files differ from each other. - So %(batch_id)d replaced with the current id. - """ - items = [ - self.MyItem({"foo": "bar1", "egg": "spam1"}), - self.MyItem({"foo": "bar2", "egg": "spam2", "baz": "quux2"}), - self.MyItem({"foo": "bar3", "baz": "quux3"}), - ] - settings = { - "FEEDS": { - self._random_temp_filename() / "%(batch_id)d": { - "format": "json", - }, - }, - "FEED_EXPORT_BATCH_ITEM_COUNT": 1, - } - data = await self.exported_data(items, settings) - assert len(items) == len(data["json"]) - - @inline_callbacks_test - def test_stats_batch_file_success(self): - settings = { - "FEEDS": { - build_url( - str(self._random_temp_filename() / "json" / self._file_mark) - ): { - "format": "json", - } - }, - "FEED_EXPORT_BATCH_ITEM_COUNT": 1, - } - crawler = get_crawler(ItemSpider, settings) - yield crawler.crawl(total=2, mockserver=self.mockserver) - assert "feedexport/success_count/FileFeedStorage" in crawler.stats.get_stats() - assert crawler.stats.get_value("feedexport/success_count/FileFeedStorage") == 12 - - @pytest.mark.requires_boto3 - @inline_callbacks_test - def test_s3_export(self): - bucket = "mybucket" - items = [ - self.MyItem({"foo": "bar1", "egg": "spam1"}), - self.MyItem({"foo": "bar2", "egg": "spam2", "baz": "quux2"}), - self.MyItem({"foo": "bar3", "baz": "quux3"}), - ] - - class CustomS3FeedStorage(S3FeedStorage): - stubs = [] - - def open(self, *args, **kwargs): - from botocore import __version__ as botocore_version # noqa: PLC0415 - from botocore.stub import ANY, Stubber # noqa: PLC0415 - - expected_params = { - "Body": ANY, - "Bucket": bucket, - "Key": ANY, - } - if Version(botocore_version) >= Version("1.36.0"): - expected_params["ChecksumAlgorithm"] = ANY - - stub = Stubber(self.s3_client) - stub.activate() - CustomS3FeedStorage.stubs.append(stub) - stub.add_response( - "put_object", - expected_params=expected_params, - service_response={}, - ) - return super().open(*args, **kwargs) - - key = "export.csv" - uri = f"s3://{bucket}/{key}/%(batch_id)d.json" - batch_item_count = 1 - settings = { - "AWS_ACCESS_KEY_ID": "access_key", - "AWS_SECRET_ACCESS_KEY": "secret_key", - "FEED_EXPORT_BATCH_ITEM_COUNT": batch_item_count, - "FEED_STORAGES": { - "s3": CustomS3FeedStorage, - }, - "FEEDS": { - uri: { - "format": "json", - }, - }, - } - crawler = get_crawler(settings_dict=settings) - storage = S3FeedStorage.from_crawler(crawler, uri) - verifyObject(IFeedStorage, storage) - - class TestSpider(scrapy.Spider): - name = "testspider" - - def parse(self, response): - yield from items - - TestSpider.start_urls = [self.mockserver.url("/")] - crawler = get_crawler(TestSpider, settings) - yield crawler.crawl() - - assert len(CustomS3FeedStorage.stubs) == len(items) - for stub in CustomS3FeedStorage.stubs[:-1]: - stub.assert_no_pending_responses() - assert ( - "feedexport/success_count/CustomS3FeedStorage" in crawler.stats.get_stats() - ) - assert ( - crawler.stats.get_value("feedexport/success_count/CustomS3FeedStorage") == 3 - ) - - # Test that the FeedExporer sends the feed_exporter_closed and feed_slot_closed signals class TestFeedExporterSignals: items = [ @@ -2893,134 +1401,3 @@ class TestFeedExportInit: crawler = get_crawler(settings_dict=settings) exporter = FeedExporter.from_crawler(crawler) assert isinstance(exporter, FeedExporter) - - -class TestURIParams(ABC): - spider_name = "uri_params_spider" - deprecated_options = False - - @abstractmethod - def build_settings(self, uri="file:///tmp/foobar", uri_params=None): - raise NotImplementedError - - def _crawler_feed_exporter(self, settings): - if self.deprecated_options: - with pytest.warns( - ScrapyDeprecationWarning, - match="The `FEED_URI` and `FEED_FORMAT` settings have been deprecated", - ): - crawler = get_crawler(settings_dict=settings) - else: - crawler = get_crawler(settings_dict=settings) - feed_exporter = crawler.get_extension(FeedExporter) - return crawler, feed_exporter - - def test_default(self): - settings = self.build_settings( - uri="file:///tmp/%(name)s", - ) - crawler, feed_exporter = self._crawler_feed_exporter(settings) - spider = scrapy.Spider(self.spider_name) - spider.crawler = crawler - - with warnings.catch_warnings(): - warnings.simplefilter("error", ScrapyDeprecationWarning) - feed_exporter.open_spider(spider) - - assert feed_exporter.slots[0].uri == f"file:///tmp/{self.spider_name}" - - def test_none(self): - def uri_params(params, spider): - pass - - settings = self.build_settings( - uri="file:///tmp/%(name)s", - uri_params=uri_params, - ) - crawler, feed_exporter = self._crawler_feed_exporter(settings) - spider = scrapy.Spider(self.spider_name) - spider.crawler = crawler - - feed_exporter.open_spider(spider) - - assert feed_exporter.slots[0].uri == f"file:///tmp/{self.spider_name}" - - def test_empty_dict(self): - def uri_params(params, spider): - return {} - - settings = self.build_settings( - uri="file:///tmp/%(name)s", - uri_params=uri_params, - ) - crawler, feed_exporter = self._crawler_feed_exporter(settings) - spider = scrapy.Spider(self.spider_name) - spider.crawler = crawler - - with warnings.catch_warnings(): - warnings.simplefilter("error", ScrapyDeprecationWarning) - with pytest.raises(KeyError): - feed_exporter.open_spider(spider) - - def test_params_as_is(self): - def uri_params(params, spider): - return params - - settings = self.build_settings( - uri="file:///tmp/%(name)s", - uri_params=uri_params, - ) - crawler, feed_exporter = self._crawler_feed_exporter(settings) - spider = scrapy.Spider(self.spider_name) - spider.crawler = crawler - with warnings.catch_warnings(): - warnings.simplefilter("error", ScrapyDeprecationWarning) - feed_exporter.open_spider(spider) - - assert feed_exporter.slots[0].uri == f"file:///tmp/{self.spider_name}" - - def test_custom_param(self): - def uri_params(params, spider): - return {**params, "foo": self.spider_name} - - settings = self.build_settings( - uri="file:///tmp/%(foo)s", - uri_params=uri_params, - ) - crawler, feed_exporter = self._crawler_feed_exporter(settings) - spider = scrapy.Spider(self.spider_name) - spider.crawler = crawler - with warnings.catch_warnings(): - warnings.simplefilter("error", ScrapyDeprecationWarning) - feed_exporter.open_spider(spider) - - assert feed_exporter.slots[0].uri == f"file:///tmp/{self.spider_name}" - - -class TestURIParamsSetting(TestURIParams): - deprecated_options = True - - def build_settings(self, uri="file:///tmp/foobar", uri_params=None): - extra_settings = {} - if uri_params: - extra_settings["FEED_URI_PARAMS"] = uri_params - return { - "FEED_URI": uri, - **extra_settings, - } - - -class TestURIParamsFeedOption(TestURIParams): - deprecated_options = False - - def build_settings(self, uri="file:///tmp/foobar", uri_params=None): - options = { - "format": "jl", - } - if uri_params: - options["uri_params"] = uri_params - return { - "FEEDS": { - uri: options, - }, - } diff --git a/tests/test_feedexport_batch.py b/tests/test_feedexport_batch.py new file mode 100644 index 000000000..3b70e896c --- /dev/null +++ b/tests/test_feedexport_batch.py @@ -0,0 +1,457 @@ +from __future__ import annotations + +import csv +import json +import marshal +import pickle +from collections import defaultdict +from pathlib import Path +from typing import TYPE_CHECKING, Any +from urllib.parse import urljoin + +import lxml.etree +import pytest +from packaging.version import Version +from zope.interface.verify import verifyObject + +import scrapy +from scrapy import Spider +from scrapy.exceptions import NotConfigured +from scrapy.extensions.feedexport import FeedExporter, IFeedStorage, S3FeedStorage +from scrapy.settings import Settings +from scrapy.utils.python import to_unicode +from scrapy.utils.test import get_crawler +from tests.spiders import ItemSpider +from tests.test_feedexport import TestFeedExportBase +from tests.utils.decorators import coroutine_test, inline_callbacks_test + +if TYPE_CHECKING: + from os import PathLike + + +def build_url(path: str | PathLike) -> str: + path_str = str(path) + if path_str[0] != "/": + path_str = "/" + path_str + return urljoin("file:", path_str) + + +class TestBatchDeliveries(TestFeedExportBase): + _file_mark = "_%(batch_time)s_#%(batch_id)02d_" + + async def run_and_export( + self, spider_cls: type[Spider], settings: dict[str, Any] + ) -> dict[str, list[bytes]]: + """Run spider with specified settings; return exported data.""" + + FEEDS = settings.get("FEEDS") or {} + settings["FEEDS"] = { + build_url(file_path): feed for file_path, feed in FEEDS.items() + } + content: defaultdict[str, list[bytes]] = defaultdict(list) + spider_cls.start_urls = [self.mockserver.url("/")] + crawler = get_crawler(spider_cls, settings) + await crawler.crawl_async() + + for path, feed in FEEDS.items(): + dir_name = Path(path).parent + if not dir_name.exists(): + content[feed["format"]] = [] + continue + for file in sorted(dir_name.iterdir()): + content[feed["format"]].append(file.read_bytes()) + return content + + async def assertExportedJsonLines(self, items, rows, settings=None): + settings = settings or {} + settings.update( + { + "FEEDS": { + self._random_temp_filename() / "jl" / self._file_mark: { + "format": "jl" + }, + }, + } + ) + batch_size = Settings(settings).getint("FEED_EXPORT_BATCH_ITEM_COUNT") + rows = [{k: v for k, v in row.items() if v} for row in rows] + data = await self.exported_data(items, settings) + for batch in data["jl"]: + got_batch = [ + json.loads(to_unicode(batch_item)) for batch_item in batch.splitlines() + ] + expected_batch, rows = rows[:batch_size], rows[batch_size:] + assert got_batch == expected_batch + + async def assertExportedCsv(self, items, header, rows, settings=None): + settings = settings or {} + settings.update( + { + "FEEDS": { + self._random_temp_filename() / "csv" / self._file_mark: { + "format": "csv" + }, + }, + } + ) + batch_size = Settings(settings).getint("FEED_EXPORT_BATCH_ITEM_COUNT") + data = await self.exported_data(items, settings) + for batch in data["csv"]: + got_batch = csv.DictReader(to_unicode(batch).splitlines()) + assert list(header) == got_batch.fieldnames + expected_batch, rows = rows[:batch_size], rows[batch_size:] + assert list(got_batch) == expected_batch + + async def assertExportedXml(self, items, rows, settings=None): + settings = settings or {} + settings.update( + { + "FEEDS": { + self._random_temp_filename() / "xml" / self._file_mark: { + "format": "xml" + }, + }, + } + ) + batch_size = Settings(settings).getint("FEED_EXPORT_BATCH_ITEM_COUNT") + rows = [{k: v for k, v in row.items() if v} for row in rows] + data = await self.exported_data(items, settings) + for batch in data["xml"]: + root = lxml.etree.fromstring(batch) + got_batch = [{e.tag: e.text for e in it} for it in root.findall("item")] + expected_batch, rows = rows[:batch_size], rows[batch_size:] + assert got_batch == expected_batch + + async def assertExportedMultiple(self, items, rows, settings=None): + settings = settings or {} + settings.update( + { + "FEEDS": { + self._random_temp_filename() / "xml" / self._file_mark: { + "format": "xml" + }, + self._random_temp_filename() / "json" / self._file_mark: { + "format": "json" + }, + }, + } + ) + batch_size = Settings(settings).getint("FEED_EXPORT_BATCH_ITEM_COUNT") + rows = [{k: v for k, v in row.items() if v} for row in rows] + data = await self.exported_data(items, settings) + # XML + xml_rows = rows.copy() + for batch in data["xml"]: + root = lxml.etree.fromstring(batch) + got_batch = [{e.tag: e.text for e in it} for it in root.findall("item")] + expected_batch, xml_rows = xml_rows[:batch_size], xml_rows[batch_size:] + assert got_batch == expected_batch + # JSON + json_rows = rows.copy() + for batch in data["json"]: + got_batch = json.loads(batch.decode("utf-8")) + expected_batch, json_rows = json_rows[:batch_size], json_rows[batch_size:] + assert got_batch == expected_batch + + async def assertExportedPickle(self, items, rows, settings=None): + settings = settings or {} + settings.update( + { + "FEEDS": { + self._random_temp_filename() / "pickle" / self._file_mark: { + "format": "pickle" + }, + }, + } + ) + batch_size = Settings(settings).getint("FEED_EXPORT_BATCH_ITEM_COUNT") + rows = [{k: v for k, v in row.items() if v} for row in rows] + data = await self.exported_data(items, settings) + + for batch in data["pickle"]: + got_batch = self._load_until_eof(batch, load_func=pickle.load) + expected_batch, rows = rows[:batch_size], rows[batch_size:] + assert got_batch == expected_batch + + async def assertExportedMarshal(self, items, rows, settings=None): + settings = settings or {} + settings.update( + { + "FEEDS": { + self._random_temp_filename() / "marshal" / self._file_mark: { + "format": "marshal" + }, + }, + } + ) + batch_size = Settings(settings).getint("FEED_EXPORT_BATCH_ITEM_COUNT") + rows = [{k: v for k, v in row.items() if v} for row in rows] + data = await self.exported_data(items, settings) + + for batch in data["marshal"]: + got_batch = self._load_until_eof(batch, load_func=marshal.load) + expected_batch, rows = rows[:batch_size], rows[batch_size:] + assert got_batch == expected_batch + + @coroutine_test + async def test_export_items(self): + """Test partial deliveries in all supported formats""" + items = [ + self.MyItem({"foo": "bar1", "egg": "spam1"}), + self.MyItem({"foo": "bar2", "egg": "spam2", "baz": "quux2"}), + self.MyItem({"foo": "bar3", "baz": "quux3"}), + ] + rows = [ + {"egg": "spam1", "foo": "bar1", "baz": ""}, + {"egg": "spam2", "foo": "bar2", "baz": "quux2"}, + {"foo": "bar3", "baz": "quux3", "egg": ""}, + ] + settings = {"FEED_EXPORT_BATCH_ITEM_COUNT": 2} + header = self.MyItem.fields.keys() + await self.assertExported(items, header, rows, settings=settings) + + def test_wrong_path(self): + """If path is without %(batch_time)s and %(batch_id) an exception must be raised""" + settings = { + "FEEDS": { + self._random_temp_filename(): {"format": "xml"}, + }, + "FEED_EXPORT_BATCH_ITEM_COUNT": 1, + } + crawler = get_crawler(settings_dict=settings) + with pytest.raises(NotConfigured): + FeedExporter(crawler) + + @coroutine_test + async def test_export_no_items_not_store_empty(self): + for fmt in ("json", "jsonlines", "xml", "csv"): + settings = { + "FEEDS": { + self._random_temp_filename() / fmt / self._file_mark: { + "format": fmt + }, + }, + "FEED_EXPORT_BATCH_ITEM_COUNT": 1, + "FEED_STORE_EMPTY": False, + } + data = await self.exported_no_data(settings) + data = dict(data) + assert len(data[fmt]) == 0 + + @coroutine_test + async def test_export_no_items_store_empty(self): + formats = ( + ("json", b"[]"), + ("jsonlines", b""), + ("xml", b'\n'), + ("csv", b""), + ) + + for fmt, expctd in formats: + settings = { + "FEEDS": { + self._random_temp_filename() / fmt / self._file_mark: { + "format": fmt + }, + }, + "FEED_STORE_EMPTY": True, + "FEED_EXPORT_INDENT": None, + "FEED_EXPORT_BATCH_ITEM_COUNT": 1, + } + data = await self.exported_no_data(settings) + data = dict(data) + assert data[fmt][0] == expctd + + @coroutine_test + async def test_export_multiple_configs(self): + items = [ + {"foo": "FOO", "bar": "BAR"}, + {"foo": "FOO1", "bar": "BAR1"}, + ] + + formats = { + "json": [ + b'[\n{"bar": "BAR"}\n]', + b'[\n{"bar": "BAR1"}\n]', + ], + "xml": [ + ( + b'\n' + b"\n \n FOO\n \n" + ), + ( + b'\n' + b"\n \n FOO1\n \n" + ), + ], + "csv": [ + b"foo,bar\r\nFOO,BAR\r\n", + b"foo,bar\r\nFOO1,BAR1\r\n", + ], + } + + settings = { + "FEEDS": { + self._random_temp_filename() / "json" / self._file_mark: { + "format": "json", + "indent": 0, + "fields": ["bar"], + "encoding": "utf-8", + }, + self._random_temp_filename() / "xml" / self._file_mark: { + "format": "xml", + "indent": 2, + "fields": ["foo"], + "encoding": "latin-1", + }, + self._random_temp_filename() / "csv" / self._file_mark: { + "format": "csv", + "indent": None, + "fields": ["foo", "bar"], + "encoding": "utf-8", + }, + }, + "FEED_EXPORT_BATCH_ITEM_COUNT": 1, + } + data = await self.exported_data(items, settings) + for fmt, expected in formats.items(): + for expected_batch, got_batch in zip(expected, data[fmt], strict=False): + assert got_batch == expected_batch + + @coroutine_test + async def test_batch_item_count_feeds_setting(self): + items = [{"foo": "FOO"}, {"foo": "FOO1"}] + formats = { + "json": [ + b'[{"foo": "FOO"}]', + b'[{"foo": "FOO1"}]', + ], + } + settings = { + "FEEDS": { + self._random_temp_filename() / "json" / self._file_mark: { + "format": "json", + "indent": None, + "encoding": "utf-8", + "batch_item_count": 1, + }, + }, + } + data = await self.exported_data(items, settings) + for fmt, expected in formats.items(): + for expected_batch, got_batch in zip(expected, data[fmt], strict=False): + assert got_batch == expected_batch + + @coroutine_test + async def test_batch_path_differ(self): + """ + Test that the name of all batch files differ from each other. + So %(batch_id)d replaced with the current id. + """ + items = [ + self.MyItem({"foo": "bar1", "egg": "spam1"}), + self.MyItem({"foo": "bar2", "egg": "spam2", "baz": "quux2"}), + self.MyItem({"foo": "bar3", "baz": "quux3"}), + ] + settings = { + "FEEDS": { + self._random_temp_filename() / "%(batch_id)d": { + "format": "json", + }, + }, + "FEED_EXPORT_BATCH_ITEM_COUNT": 1, + } + data = await self.exported_data(items, settings) + assert len(items) == len(data["json"]) + + @inline_callbacks_test + def test_stats_batch_file_success(self): + settings = { + "FEEDS": { + build_url( + str(self._random_temp_filename() / "json" / self._file_mark) + ): { + "format": "json", + } + }, + "FEED_EXPORT_BATCH_ITEM_COUNT": 1, + } + crawler = get_crawler(ItemSpider, settings) + yield crawler.crawl(total=2, mockserver=self.mockserver) + assert "feedexport/success_count/FileFeedStorage" in crawler.stats.get_stats() + assert crawler.stats.get_value("feedexport/success_count/FileFeedStorage") == 12 + + @pytest.mark.requires_boto3 + @inline_callbacks_test + def test_s3_export(self): + bucket = "mybucket" + items = [ + self.MyItem({"foo": "bar1", "egg": "spam1"}), + self.MyItem({"foo": "bar2", "egg": "spam2", "baz": "quux2"}), + self.MyItem({"foo": "bar3", "baz": "quux3"}), + ] + + class CustomS3FeedStorage(S3FeedStorage): + stubs = [] + + def open(self, *args, **kwargs): + from botocore import __version__ as botocore_version # noqa: PLC0415 + from botocore.stub import ANY, Stubber # noqa: PLC0415 + + expected_params = { + "Body": ANY, + "Bucket": bucket, + "Key": ANY, + } + if Version(botocore_version) >= Version("1.36.0"): + expected_params["ChecksumAlgorithm"] = ANY + + stub = Stubber(self.s3_client) + stub.activate() + CustomS3FeedStorage.stubs.append(stub) + stub.add_response( + "put_object", + expected_params=expected_params, + service_response={}, + ) + return super().open(*args, **kwargs) + + key = "export.csv" + uri = f"s3://{bucket}/{key}/%(batch_id)d.json" + batch_item_count = 1 + settings = { + "AWS_ACCESS_KEY_ID": "access_key", + "AWS_SECRET_ACCESS_KEY": "secret_key", + "FEED_EXPORT_BATCH_ITEM_COUNT": batch_item_count, + "FEED_STORAGES": { + "s3": CustomS3FeedStorage, + }, + "FEEDS": { + uri: { + "format": "json", + }, + }, + } + crawler = get_crawler(settings_dict=settings) + storage = S3FeedStorage.from_crawler(crawler, uri) + verifyObject(IFeedStorage, storage) + + class TestSpider(scrapy.Spider): + name = "testspider" + + def parse(self, response): + yield from items + + TestSpider.start_urls = [self.mockserver.url("/")] + crawler = get_crawler(TestSpider, settings) + yield crawler.crawl() + + assert len(CustomS3FeedStorage.stubs) == len(items) + for stub in CustomS3FeedStorage.stubs[:-1]: + stub.assert_no_pending_responses() + assert ( + "feedexport/success_count/CustomS3FeedStorage" in crawler.stats.get_stats() + ) + assert ( + crawler.stats.get_value("feedexport/success_count/CustomS3FeedStorage") == 3 + ) diff --git a/tests/test_feedexport_postprocess.py b/tests/test_feedexport_postprocess.py new file mode 100644 index 000000000..fa1c0586a --- /dev/null +++ b/tests/test_feedexport_postprocess.py @@ -0,0 +1,538 @@ +from __future__ import annotations + +import bz2 +import gzip +import lzma +import marshal +import pickle +import sys +from io import BytesIO +from pathlib import Path +from typing import TYPE_CHECKING, Any + +import pytest + +from scrapy.utils.test import get_crawler +from tests.test_feedexport import TestFeedExportBase, path_to_url, printf_escape +from tests.utils.decorators import coroutine_test + +if TYPE_CHECKING: + from scrapy import Spider + + +class TestFeedPostProcessedExports(TestFeedExportBase): + items = [{"foo": "bar"}] + expected = b"foo\r\nbar\r\n" + + class MyPlugin1: + def __init__(self, file, feed_options): + self.file = file + self.feed_options = feed_options + self.char = self.feed_options.get("plugin1_char", b"") + + def write(self, data): + written_count = self.file.write(data) + written_count += self.file.write(self.char) + return written_count + + def close(self): + self.file.close() + + def _named_tempfile(self, name) -> str: + return str(Path(self.temp_dir, name)) + + async def run_and_export( + self, spider_cls: type[Spider], settings: dict[str, Any] + ) -> dict[str, bytes | None]: + """Run spider with specified settings; return exported data with filename.""" + + FEEDS = settings.get("FEEDS") or {} + settings["FEEDS"] = { + printf_escape(path_to_url(file_path)): feed_options + for file_path, feed_options in FEEDS.items() + } + + content: dict[str, bytes | None] = {} + try: + spider_cls.start_urls = [self.mockserver.url("/")] + crawler = get_crawler(spider_cls, settings) + await crawler.crawl_async() + + for file_path in FEEDS: + content[str(file_path)] = ( + Path(file_path).read_bytes() if Path(file_path).exists() else None + ) + + finally: + for file_path in FEEDS: + if not Path(file_path).exists(): + continue + + Path(file_path).unlink() + + return content + + def get_gzip_compressed(self, data, compresslevel=9, mtime=0, filename=""): + data_stream = BytesIO() + gzipf = gzip.GzipFile( + fileobj=data_stream, + filename=filename, + mtime=mtime, + compresslevel=compresslevel, + mode="wb", + ) + gzipf.write(data) + gzipf.close() + data_stream.seek(0) + return data_stream.read() + + @coroutine_test + async def test_gzip_plugin(self): + filename = self._named_tempfile("gzip_file") + + settings = { + "FEEDS": { + filename: { + "format": "csv", + "postprocessing": ["scrapy.extensions.postprocessing.GzipPlugin"], + }, + }, + } + + data = await self.exported_data(self.items, settings) + try: + gzip.decompress(data[filename]) + except OSError: + pytest.fail("Received invalid gzip data.") + + @coroutine_test + async def test_gzip_plugin_compresslevel(self): + filename_to_compressed = { + self._named_tempfile("compresslevel_0"): self.get_gzip_compressed( + self.expected, compresslevel=0 + ), + self._named_tempfile("compresslevel_9"): self.get_gzip_compressed( + self.expected, compresslevel=9 + ), + } + + settings = { + "FEEDS": { + self._named_tempfile("compresslevel_0"): { + "format": "csv", + "postprocessing": ["scrapy.extensions.postprocessing.GzipPlugin"], + "gzip_compresslevel": 0, + "gzip_mtime": 0, + "gzip_filename": "", + }, + self._named_tempfile("compresslevel_9"): { + "format": "csv", + "postprocessing": ["scrapy.extensions.postprocessing.GzipPlugin"], + "gzip_compresslevel": 9, + "gzip_mtime": 0, + "gzip_filename": "", + }, + }, + } + + data = await self.exported_data(self.items, settings) + + for filename, compressed in filename_to_compressed.items(): + result = gzip.decompress(data[filename]) + assert compressed == data[filename] + assert result == self.expected + + @coroutine_test + async def test_gzip_plugin_mtime(self): + filename_to_compressed = { + self._named_tempfile("mtime_123"): self.get_gzip_compressed( + self.expected, mtime=123 + ), + self._named_tempfile("mtime_123456789"): self.get_gzip_compressed( + self.expected, mtime=123456789 + ), + } + + settings = { + "FEEDS": { + self._named_tempfile("mtime_123"): { + "format": "csv", + "postprocessing": ["scrapy.extensions.postprocessing.GzipPlugin"], + "gzip_mtime": 123, + "gzip_filename": "", + }, + self._named_tempfile("mtime_123456789"): { + "format": "csv", + "postprocessing": ["scrapy.extensions.postprocessing.GzipPlugin"], + "gzip_mtime": 123456789, + "gzip_filename": "", + }, + }, + } + + data = await self.exported_data(self.items, settings) + + for filename, compressed in filename_to_compressed.items(): + result = gzip.decompress(data[filename]) + assert compressed == data[filename] + assert result == self.expected + + @coroutine_test + async def test_gzip_plugin_filename(self): + filename_to_compressed = { + self._named_tempfile("filename_FILE1"): self.get_gzip_compressed( + self.expected, filename="FILE1" + ), + self._named_tempfile("filename_FILE2"): self.get_gzip_compressed( + self.expected, filename="FILE2" + ), + } + + settings = { + "FEEDS": { + self._named_tempfile("filename_FILE1"): { + "format": "csv", + "postprocessing": ["scrapy.extensions.postprocessing.GzipPlugin"], + "gzip_mtime": 0, + "gzip_filename": "FILE1", + }, + self._named_tempfile("filename_FILE2"): { + "format": "csv", + "postprocessing": ["scrapy.extensions.postprocessing.GzipPlugin"], + "gzip_mtime": 0, + "gzip_filename": "FILE2", + }, + }, + } + + data = await self.exported_data(self.items, settings) + + for filename, compressed in filename_to_compressed.items(): + result = gzip.decompress(data[filename]) + assert compressed == data[filename] + assert result == self.expected + + @coroutine_test + async def test_lzma_plugin(self): + filename = self._named_tempfile("lzma_file") + + settings = { + "FEEDS": { + filename: { + "format": "csv", + "postprocessing": ["scrapy.extensions.postprocessing.LZMAPlugin"], + }, + }, + } + + data = await self.exported_data(self.items, settings) + try: + lzma.decompress(data[filename]) + except lzma.LZMAError: + pytest.fail("Received invalid lzma data.") + + @coroutine_test + async def test_lzma_plugin_format(self): + filename_to_compressed = { + self._named_tempfile("format_FORMAT_XZ"): lzma.compress( + self.expected, format=lzma.FORMAT_XZ + ), + self._named_tempfile("format_FORMAT_ALONE"): lzma.compress( + self.expected, format=lzma.FORMAT_ALONE + ), + } + + settings = { + "FEEDS": { + self._named_tempfile("format_FORMAT_XZ"): { + "format": "csv", + "postprocessing": ["scrapy.extensions.postprocessing.LZMAPlugin"], + "lzma_format": lzma.FORMAT_XZ, + }, + self._named_tempfile("format_FORMAT_ALONE"): { + "format": "csv", + "postprocessing": ["scrapy.extensions.postprocessing.LZMAPlugin"], + "lzma_format": lzma.FORMAT_ALONE, + }, + }, + } + + data = await self.exported_data(self.items, settings) + + for filename, compressed in filename_to_compressed.items(): + result = lzma.decompress(data[filename]) + assert compressed == data[filename] + assert result == self.expected + + @coroutine_test + async def test_lzma_plugin_check(self): + filename_to_compressed = { + self._named_tempfile("check_CHECK_NONE"): lzma.compress( + self.expected, check=lzma.CHECK_NONE + ), + self._named_tempfile("check_CHECK_CRC256"): lzma.compress( + self.expected, check=lzma.CHECK_SHA256 + ), + } + + settings = { + "FEEDS": { + self._named_tempfile("check_CHECK_NONE"): { + "format": "csv", + "postprocessing": ["scrapy.extensions.postprocessing.LZMAPlugin"], + "lzma_check": lzma.CHECK_NONE, + }, + self._named_tempfile("check_CHECK_CRC256"): { + "format": "csv", + "postprocessing": ["scrapy.extensions.postprocessing.LZMAPlugin"], + "lzma_check": lzma.CHECK_SHA256, + }, + }, + } + + data = await self.exported_data(self.items, settings) + + for filename, compressed in filename_to_compressed.items(): + result = lzma.decompress(data[filename]) + assert compressed == data[filename] + assert result == self.expected + + @coroutine_test + async def test_lzma_plugin_preset(self): + filename_to_compressed = { + self._named_tempfile("preset_PRESET_0"): lzma.compress( + self.expected, preset=0 + ), + self._named_tempfile("preset_PRESET_9"): lzma.compress( + self.expected, preset=9 + ), + } + + settings = { + "FEEDS": { + self._named_tempfile("preset_PRESET_0"): { + "format": "csv", + "postprocessing": ["scrapy.extensions.postprocessing.LZMAPlugin"], + "lzma_preset": 0, + }, + self._named_tempfile("preset_PRESET_9"): { + "format": "csv", + "postprocessing": ["scrapy.extensions.postprocessing.LZMAPlugin"], + "lzma_preset": 9, + }, + }, + } + + data = await self.exported_data(self.items, settings) + + for filename, compressed in filename_to_compressed.items(): + result = lzma.decompress(data[filename]) + assert compressed == data[filename] + assert result == self.expected + + @coroutine_test + async def test_lzma_plugin_filters(self): + if "PyPy" in sys.version: + # https://foss.heptapod.net/pypy/pypy/-/issues/3527 + pytest.skip("lzma filters doesn't work in PyPy") + + filters = [{"id": lzma.FILTER_LZMA2}] + compressed = lzma.compress(self.expected, filters=filters) + filename = self._named_tempfile("filters") + + settings = { + "FEEDS": { + filename: { + "format": "csv", + "postprocessing": ["scrapy.extensions.postprocessing.LZMAPlugin"], + "lzma_filters": filters, + }, + }, + } + + data = await self.exported_data(self.items, settings) + assert compressed == data[filename] + result = lzma.decompress(data[filename]) + assert result == self.expected + + @coroutine_test + async def test_bz2_plugin(self): + filename = self._named_tempfile("bz2_file") + + settings = { + "FEEDS": { + filename: { + "format": "csv", + "postprocessing": ["scrapy.extensions.postprocessing.Bz2Plugin"], + }, + }, + } + + data = await self.exported_data(self.items, settings) + try: + bz2.decompress(data[filename]) + except OSError: + pytest.fail("Received invalid bz2 data.") + + @coroutine_test + async def test_bz2_plugin_compresslevel(self): + filename_to_compressed = { + self._named_tempfile("compresslevel_1"): bz2.compress( + self.expected, compresslevel=1 + ), + self._named_tempfile("compresslevel_9"): bz2.compress( + self.expected, compresslevel=9 + ), + } + + settings = { + "FEEDS": { + self._named_tempfile("compresslevel_1"): { + "format": "csv", + "postprocessing": ["scrapy.extensions.postprocessing.Bz2Plugin"], + "bz2_compresslevel": 1, + }, + self._named_tempfile("compresslevel_9"): { + "format": "csv", + "postprocessing": ["scrapy.extensions.postprocessing.Bz2Plugin"], + "bz2_compresslevel": 9, + }, + }, + } + + data = await self.exported_data(self.items, settings) + + for filename, compressed in filename_to_compressed.items(): + result = bz2.decompress(data[filename]) + assert compressed == data[filename] + assert result == self.expected + + @coroutine_test + async def test_custom_plugin(self): + filename = self._named_tempfile("csv_file") + + settings = { + "FEEDS": { + filename: { + "format": "csv", + "postprocessing": [self.MyPlugin1], + }, + }, + } + + data = await self.exported_data(self.items, settings) + assert data[filename] == self.expected + + @coroutine_test + async def test_custom_plugin_with_parameter(self): + expected = b"foo\r\n\nbar\r\n\n" + filename = self._named_tempfile("newline") + + settings = { + "FEEDS": { + filename: { + "format": "csv", + "postprocessing": [self.MyPlugin1], + "plugin1_char": b"\n", + }, + }, + } + + data = await self.exported_data(self.items, settings) + assert data[filename] == expected + + @coroutine_test + async def test_custom_plugin_with_compression(self): + expected = b"foo\r\n\nbar\r\n\n" + + filename_to_decompressor = { + self._named_tempfile("bz2"): bz2.decompress, + self._named_tempfile("lzma"): lzma.decompress, + self._named_tempfile("gzip"): gzip.decompress, + } + + settings = { + "FEEDS": { + self._named_tempfile("bz2"): { + "format": "csv", + "postprocessing": [ + self.MyPlugin1, + "scrapy.extensions.postprocessing.Bz2Plugin", + ], + "plugin1_char": b"\n", + }, + self._named_tempfile("lzma"): { + "format": "csv", + "postprocessing": [ + self.MyPlugin1, + "scrapy.extensions.postprocessing.LZMAPlugin", + ], + "plugin1_char": b"\n", + }, + self._named_tempfile("gzip"): { + "format": "csv", + "postprocessing": [ + self.MyPlugin1, + "scrapy.extensions.postprocessing.GzipPlugin", + ], + "plugin1_char": b"\n", + }, + }, + } + + data = await self.exported_data(self.items, settings) + + for filename, decompressor in filename_to_decompressor.items(): + result = decompressor(data[filename]) + assert result == expected + + @coroutine_test + async def test_exports_compatibility_with_postproc(self): + filename_to_expected = { + self._named_tempfile("csv"): b"foo\r\nbar\r\n", + self._named_tempfile("json"): b'[\n{"foo": "bar"}\n]', + self._named_tempfile("jsonlines"): b'{"foo": "bar"}\n', + self._named_tempfile("xml"): b'\n' + b"\nbar\n", + } + + settings = { + "FEEDS": { + self._named_tempfile("csv"): { + "format": "csv", + "postprocessing": [self.MyPlugin1], + # empty plugin to activate postprocessing.PostProcessingManager + }, + self._named_tempfile("json"): { + "format": "json", + "postprocessing": [self.MyPlugin1], + }, + self._named_tempfile("jsonlines"): { + "format": "jsonlines", + "postprocessing": [self.MyPlugin1], + }, + self._named_tempfile("xml"): { + "format": "xml", + "postprocessing": [self.MyPlugin1], + }, + self._named_tempfile("marshal"): { + "format": "marshal", + "postprocessing": [self.MyPlugin1], + }, + self._named_tempfile("pickle"): { + "format": "pickle", + "postprocessing": [self.MyPlugin1], + }, + }, + } + + data = await self.exported_data(self.items, settings) + + for filename, result in data.items(): + if "pickle" in filename: + expected, result = self.items[0], pickle.loads(result) + elif "marshal" in filename: + expected, result = self.items[0], marshal.loads(result) + else: + expected = filename_to_expected[filename] + assert result == expected diff --git a/tests/test_feedexport_storages.py b/tests/test_feedexport_storages.py new file mode 100644 index 000000000..b4da9bc68 --- /dev/null +++ b/tests/test_feedexport_storages.py @@ -0,0 +1,558 @@ +from __future__ import annotations + +import os +import string +import tempfile +from io import BytesIO +from pathlib import Path +from typing import IO, Any +from unittest import mock +from urllib.parse import quote + +import pytest +from testfixtures import LogCapture +from w3lib.url import path_to_file_uri +from zope.interface.verify import verifyObject + +import scrapy +from scrapy.extensions.feedexport import ( + BlockingFeedStorage, + FileFeedStorage, + FTPFeedStorage, + GCSFeedStorage, + IFeedStorage, + S3FeedStorage, + StdoutFeedStorage, +) +from scrapy.utils.defer import maybe_deferred_to_future +from scrapy.utils.test import get_crawler +from tests.mockserver.ftp import MockFTPServer +from tests.utils.decorators import coroutine_test + + +def mock_google_cloud_storage() -> tuple[Any, Any, Any]: + """Creates autospec mocks for google-cloud-storage Client, Bucket and Blob + classes and set their proper return values. + """ + from google.cloud.storage import Blob, Bucket, Client # noqa: PLC0415 + + client_mock = mock.create_autospec(Client) + + bucket_mock = mock.create_autospec(Bucket) + client_mock.get_bucket.return_value = bucket_mock + + blob_mock = mock.create_autospec(Blob) + bucket_mock.blob.return_value = blob_mock + + return (client_mock, bucket_mock, blob_mock) + + +class TestFileFeedStorage: + def test_store_file_uri(self, tmp_path): + path = tmp_path / "file.txt" + uri = path_to_file_uri(str(path)) + self._assert_stores(FileFeedStorage(uri), path) + + def test_store_file_uri_makedirs(self, tmp_path): + path = tmp_path / "more" / "paths" / "file.txt" + uri = path_to_file_uri(str(path)) + self._assert_stores(FileFeedStorage(uri), path) + + def test_store_direct_path(self, tmp_path): + path = tmp_path / "file.txt" + self._assert_stores(FileFeedStorage(str(path)), path) + + def test_store_direct_path_relative(self, tmp_path): + old_cwd = Path.cwd() + try: + os.chdir(tmp_path) + path = Path("foo", "bar") + self._assert_stores(FileFeedStorage(str(path)), path) + finally: + os.chdir(old_cwd) + + def test_interface(self, tmp_path): + path = tmp_path / "file.txt" + st = FileFeedStorage(str(path)) + verifyObject(IFeedStorage, st) + + @staticmethod + def _store(path: Path, feed_options: dict[str, Any] | None = None) -> None: + storage = FileFeedStorage(str(path), feed_options=feed_options) + spider = scrapy.Spider("default") + file = storage.open(spider) + file.write(b"content") + storage.store(file) + + def test_append(self, tmp_path): + path = tmp_path / "file.txt" + self._store(path) + self._assert_stores(FileFeedStorage(str(path)), path, b"contentcontent") + + def test_overwrite(self, tmp_path): + path = tmp_path / "file.txt" + self._store(path, {"overwrite": True}) + self._assert_stores( + FileFeedStorage(str(path), feed_options={"overwrite": True}), path + ) + + @staticmethod + def _assert_stores( + storage: FileFeedStorage, path: Path, expected_content: bytes = b"content" + ) -> None: + spider = scrapy.Spider("default") + file = storage.open(spider) + file.write(b"content") + storage.store(file) + assert path.exists() + try: + assert path.read_bytes() == expected_content + finally: + path.unlink() + + def test_preserves_windows_path_without_file_scheme(self): + path = r"C:\Users\user\Desktop\test.txt" + storage = FileFeedStorage(path) + assert storage.path == path + + +@pytest.mark.requires_reactor # needs a reactor for BlockingFeedStorage +class TestFTPFeedStorage: + def get_test_spider(self, settings=None): + class TestSpider(scrapy.Spider): + name = "test_spider" + + crawler = get_crawler(settings_dict=settings) + return TestSpider.from_crawler(crawler) + + async def _store(self, uri, content, feed_options=None, settings=None): + crawler = get_crawler(settings_dict=settings or {}) + storage = FTPFeedStorage.from_crawler( + crawler, + uri, + feed_options=feed_options, + ) + verifyObject(IFeedStorage, storage) + spider = self.get_test_spider() + file = storage.open(spider) + file.write(content) + await maybe_deferred_to_future(storage.store(file)) + + def _assert_stored(self, path: Path, content): + assert path.exists() + try: + assert path.read_bytes() == content + finally: + path.unlink() + + @coroutine_test + async def test_append(self): + with MockFTPServer() as ftp_server: + filename = "file" + url = ftp_server.url(filename) + feed_options = {"overwrite": False} + await self._store(url, b"foo", feed_options=feed_options) + await self._store(url, b"bar", feed_options=feed_options) + self._assert_stored(ftp_server.path / filename, b"foobar") + + @coroutine_test + async def test_overwrite(self): + with MockFTPServer() as ftp_server: + filename = "file" + url = ftp_server.url(filename) + await self._store(url, b"foo") + await self._store(url, b"bar") + self._assert_stored(ftp_server.path / filename, b"bar") + + @coroutine_test + async def test_append_active_mode(self): + with MockFTPServer() as ftp_server: + settings = {"FEED_STORAGE_FTP_ACTIVE": True} + filename = "file" + url = ftp_server.url(filename) + feed_options = {"overwrite": False} + await self._store(url, b"foo", feed_options=feed_options, settings=settings) + await self._store(url, b"bar", feed_options=feed_options, settings=settings) + self._assert_stored(ftp_server.path / filename, b"foobar") + + @coroutine_test + async def test_overwrite_active_mode(self): + with MockFTPServer() as ftp_server: + settings = {"FEED_STORAGE_FTP_ACTIVE": True} + filename = "file" + url = ftp_server.url(filename) + await self._store(url, b"foo", settings=settings) + await self._store(url, b"bar", settings=settings) + self._assert_stored(ftp_server.path / filename, b"bar") + + def test_uri_auth_quote(self): + # RFC3986: 3.2.1. User Information + pw_quoted = quote(string.punctuation, safe="") + st = FTPFeedStorage(f"ftp://foo:{pw_quoted}@example.com/some_path", {}) + assert st.password == string.punctuation + + +class MyBlockingFeedStorage(BlockingFeedStorage): + def _store_in_thread(self, file: IO[bytes]) -> None: + return + + +class TestBlockingFeedStorage: + def get_test_spider(self, settings=None): + class TestSpider(scrapy.Spider): + name = "test_spider" + + crawler = get_crawler(settings_dict=settings) + return TestSpider.from_crawler(crawler) + + def test_default_temp_dir(self): + b = MyBlockingFeedStorage() + + storage_file = b.open(self.get_test_spider()) + storage_dir = Path(storage_file.name).parent + assert str(storage_dir) == tempfile.gettempdir() + + def test_temp_file(self, tmp_path): + b = MyBlockingFeedStorage() + + spider = self.get_test_spider({"FEED_TEMPDIR": str(tmp_path)}) + storage_file = b.open(spider) + storage_dir = Path(storage_file.name).parent + assert storage_dir == tmp_path + + def test_invalid_folder(self, tmp_path): + b = MyBlockingFeedStorage() + + invalid_path = tmp_path / "invalid_path" + spider = self.get_test_spider({"FEED_TEMPDIR": str(invalid_path)}) + + with pytest.raises(OSError, match="Not a Directory:"): + b.open(spider=spider) + + +@pytest.mark.requires_boto3 +@pytest.mark.requires_reactor # needs a reactor for BlockingFeedStorage +class TestS3FeedStorage: + def test_parse_credentials(self): + aws_credentials = { + "AWS_ACCESS_KEY_ID": "settings_key", + "AWS_SECRET_ACCESS_KEY": "settings_secret", + "AWS_SESSION_TOKEN": "settings_token", + } + crawler = get_crawler(settings_dict=aws_credentials) + # Instantiate with crawler + storage = S3FeedStorage.from_crawler( + crawler, + "s3://mybucket/export.csv", + ) + assert storage.access_key == "settings_key" + assert storage.secret_key == "settings_secret" + assert storage.session_token == "settings_token" + # Instantiate directly + storage = S3FeedStorage( + "s3://mybucket/export.csv", + aws_credentials["AWS_ACCESS_KEY_ID"], + aws_credentials["AWS_SECRET_ACCESS_KEY"], + session_token=aws_credentials["AWS_SESSION_TOKEN"], + ) + assert storage.access_key == "settings_key" + assert storage.secret_key == "settings_secret" + assert storage.session_token == "settings_token" + # URI priority > settings priority + storage = S3FeedStorage( + "s3://uri_key:uri_secret@mybucket/export.csv", + aws_credentials["AWS_ACCESS_KEY_ID"], + aws_credentials["AWS_SECRET_ACCESS_KEY"], + ) + assert storage.access_key == "uri_key" + assert storage.secret_key == "uri_secret" + + @coroutine_test + async def test_store(self): + settings = { + "AWS_ACCESS_KEY_ID": "access_key", + "AWS_SECRET_ACCESS_KEY": "secret_key", + } + crawler = get_crawler(settings_dict=settings) + bucket = "mybucket" + key = "export.csv" + storage = S3FeedStorage.from_crawler(crawler, f"s3://{bucket}/{key}") + verifyObject(IFeedStorage, storage) + + file = mock.MagicMock() + + storage.s3_client = mock.MagicMock() + await maybe_deferred_to_future(storage.store(file)) + assert storage.s3_client.upload_fileobj.call_args == mock.call( + Bucket=bucket, Key=key, Fileobj=file + ) + + def test_init_without_acl(self): + storage = S3FeedStorage("s3://mybucket/export.csv", "access_key", "secret_key") + assert storage.access_key == "access_key" + assert storage.secret_key == "secret_key" + assert storage.acl is None + + def test_init_with_acl(self): + storage = S3FeedStorage( + "s3://mybucket/export.csv", "access_key", "secret_key", "custom-acl" + ) + assert storage.access_key == "access_key" + assert storage.secret_key == "secret_key" + assert storage.acl == "custom-acl" + + def test_init_with_endpoint_url(self): + storage = S3FeedStorage( + "s3://mybucket/export.csv", + "access_key", + "secret_key", + endpoint_url="https://example.com", + ) + assert storage.access_key == "access_key" + assert storage.secret_key == "secret_key" + assert storage.endpoint_url == "https://example.com" + + def test_init_with_region_name(self): + region_name = "ap-east-1" + storage = S3FeedStorage( + "s3://mybucket/export.csv", + "access_key", + "secret_key", + region_name=region_name, + ) + assert storage.access_key == "access_key" + assert storage.secret_key == "secret_key" + assert storage.region_name == region_name + assert storage.s3_client._client_config.region_name == region_name + + def test_from_crawler_without_acl(self): + settings = { + "AWS_ACCESS_KEY_ID": "access_key", + "AWS_SECRET_ACCESS_KEY": "secret_key", + } + crawler = get_crawler(settings_dict=settings) + storage = S3FeedStorage.from_crawler( + crawler, + "s3://mybucket/export.csv", + ) + assert storage.access_key == "access_key" + assert storage.secret_key == "secret_key" + assert storage.acl is None + + def test_without_endpoint_url(self): + settings = { + "AWS_ACCESS_KEY_ID": "access_key", + "AWS_SECRET_ACCESS_KEY": "secret_key", + } + crawler = get_crawler(settings_dict=settings) + storage = S3FeedStorage.from_crawler( + crawler, + "s3://mybucket/export.csv", + ) + assert storage.access_key == "access_key" + assert storage.secret_key == "secret_key" + assert storage.endpoint_url is None + + def test_without_region_name(self): + settings = { + "AWS_ACCESS_KEY_ID": "access_key", + "AWS_SECRET_ACCESS_KEY": "secret_key", + } + crawler = get_crawler(settings_dict=settings) + storage = S3FeedStorage.from_crawler( + crawler, + "s3://mybucket/export.csv", + ) + assert storage.access_key == "access_key" + assert storage.secret_key == "secret_key" + assert storage.s3_client._client_config.region_name == "us-east-1" + + def test_from_crawler_with_acl(self): + settings = { + "AWS_ACCESS_KEY_ID": "access_key", + "AWS_SECRET_ACCESS_KEY": "secret_key", + "FEED_STORAGE_S3_ACL": "custom-acl", + } + crawler = get_crawler(settings_dict=settings) + storage = S3FeedStorage.from_crawler( + crawler, + "s3://mybucket/export.csv", + ) + assert storage.access_key == "access_key" + assert storage.secret_key == "secret_key" + assert storage.acl == "custom-acl" + + def test_from_crawler_with_endpoint_url(self): + settings = { + "AWS_ACCESS_KEY_ID": "access_key", + "AWS_SECRET_ACCESS_KEY": "secret_key", + "AWS_ENDPOINT_URL": "https://example.com", + } + crawler = get_crawler(settings_dict=settings) + storage = S3FeedStorage.from_crawler(crawler, "s3://mybucket/export.csv") + assert storage.access_key == "access_key" + assert storage.secret_key == "secret_key" + assert storage.endpoint_url == "https://example.com" + + def test_from_crawler_with_region_name(self): + region_name = "ap-east-1" + settings = { + "AWS_ACCESS_KEY_ID": "access_key", + "AWS_SECRET_ACCESS_KEY": "secret_key", + "AWS_REGION_NAME": region_name, + } + crawler = get_crawler(settings_dict=settings) + storage = S3FeedStorage.from_crawler(crawler, "s3://mybucket/export.csv") + assert storage.access_key == "access_key" + assert storage.secret_key == "secret_key" + assert storage.region_name == region_name + assert storage.s3_client._client_config.region_name == region_name + + @coroutine_test + async def test_store_without_acl(self): + storage = S3FeedStorage( + "s3://mybucket/export.csv", + "access_key", + "secret_key", + ) + assert storage.access_key == "access_key" + assert storage.secret_key == "secret_key" + assert storage.acl is None + + storage.s3_client = mock.MagicMock() + await maybe_deferred_to_future(storage.store(BytesIO(b"test file"))) + acl = ( + storage.s3_client.upload_fileobj.call_args[1] + .get("ExtraArgs", {}) + .get("ACL") + ) + assert acl is None + + @coroutine_test + async def test_store_with_acl(self): + storage = S3FeedStorage( + "s3://mybucket/export.csv", "access_key", "secret_key", "custom-acl" + ) + assert storage.access_key == "access_key" + assert storage.secret_key == "secret_key" + assert storage.acl == "custom-acl" + + storage.s3_client = mock.MagicMock() + await maybe_deferred_to_future(storage.store(BytesIO(b"test file"))) + acl = storage.s3_client.upload_fileobj.call_args[1]["ExtraArgs"]["ACL"] + assert acl == "custom-acl" + + def test_overwrite_default(self): + with LogCapture() as log: + S3FeedStorage( + "s3://mybucket/export.csv", "access_key", "secret_key", "custom-acl" + ) + assert "S3 does not support appending to files" not in str(log) + + def test_overwrite_false(self): + with LogCapture() as log: + S3FeedStorage( + "s3://mybucket/export.csv", + "access_key", + "secret_key", + "custom-acl", + feed_options={"overwrite": False}, + ) + assert "S3 does not support appending to files" in str(log) + + +@pytest.mark.requires_reactor # needs a reactor for BlockingFeedStorage +class TestGCSFeedStorage: + def test_parse_settings(self): + try: + from google.cloud.storage import Client # noqa: F401,PLC0415 + except ImportError: + pytest.skip("GCSFeedStorage requires google-cloud-storage") + + settings = {"GCS_PROJECT_ID": "123", "FEED_STORAGE_GCS_ACL": "publicRead"} + crawler = get_crawler(settings_dict=settings) + storage = GCSFeedStorage.from_crawler(crawler, "gs://mybucket/export.csv") + assert storage.project_id == "123" + assert storage.acl == "publicRead" + assert storage.bucket_name == "mybucket" + assert storage.blob_name == "export.csv" + + def test_parse_empty_acl(self): + try: + from google.cloud.storage import Client # noqa: F401,PLC0415 + except ImportError: + pytest.skip("GCSFeedStorage requires google-cloud-storage") + + settings = {"GCS_PROJECT_ID": "123", "FEED_STORAGE_GCS_ACL": ""} + crawler = get_crawler(settings_dict=settings) + storage = GCSFeedStorage.from_crawler(crawler, "gs://mybucket/export.csv") + assert storage.acl is None + + settings = {"GCS_PROJECT_ID": "123", "FEED_STORAGE_GCS_ACL": None} + crawler = get_crawler(settings_dict=settings) + storage = GCSFeedStorage.from_crawler(crawler, "gs://mybucket/export.csv") + assert storage.acl is None + + @coroutine_test + async def test_store(self): + try: + from google.cloud.storage import Client # noqa: F401,PLC0415 + except ImportError: + pytest.skip("GCSFeedStorage requires google-cloud-storage") + + uri = "gs://mybucket/export.csv" + project_id = "myproject-123" + acl = "publicRead" + (client_mock, bucket_mock, blob_mock) = mock_google_cloud_storage() + with mock.patch("google.cloud.storage.Client") as m: + m.return_value = client_mock + + f = mock.Mock() + storage = GCSFeedStorage(uri, project_id, acl) + await maybe_deferred_to_future(storage.store(f)) + + f.seek.assert_called_once_with(0) + m.assert_called_once_with(project=project_id) + client_mock.get_bucket.assert_called_once_with("mybucket") + bucket_mock.blob.assert_called_once_with("export.csv") + blob_mock.upload_from_file.assert_called_once_with(f, predefined_acl=acl) + + def test_overwrite_default(self): + with LogCapture() as log: + GCSFeedStorage("gs://mybucket/export.csv", "myproject-123", "custom-acl") + assert "GCS does not support appending to files" not in str(log) + + def test_overwrite_false(self): + with LogCapture() as log: + GCSFeedStorage( + "gs://mybucket/export.csv", + "myproject-123", + "custom-acl", + feed_options={"overwrite": False}, + ) + assert "GCS does not support appending to files" in str(log) + + +class TestStdoutFeedStorage: + def test_store(self): + out = BytesIO() + storage = StdoutFeedStorage("stdout:", _stdout=out) + file = storage.open(scrapy.Spider("default")) + file.write(b"content") + storage.store(file) + assert out.getvalue() == b"content" + + def test_overwrite_default(self): + with LogCapture() as log: + StdoutFeedStorage("stdout:") + assert ( + "Standard output (stdout) storage does not support overwriting" + not in str(log) + ) + + def test_overwrite_true(self): + with LogCapture() as log: + StdoutFeedStorage("stdout:", feed_options={"overwrite": True}) + assert "Standard output (stdout) storage does not support overwriting" in str( + log + ) diff --git a/tests/test_feedexport_uri_params.py b/tests/test_feedexport_uri_params.py new file mode 100644 index 000000000..150d8449f --- /dev/null +++ b/tests/test_feedexport_uri_params.py @@ -0,0 +1,142 @@ +from __future__ import annotations + +import warnings +from abc import ABC, abstractmethod + +import pytest + +import scrapy +from scrapy.exceptions import ScrapyDeprecationWarning +from scrapy.extensions.feedexport import FeedExporter +from scrapy.utils.test import get_crawler + + +class TestURIParams(ABC): + spider_name = "uri_params_spider" + deprecated_options = False + + @abstractmethod + def build_settings(self, uri="file:///tmp/foobar", uri_params=None): + raise NotImplementedError + + def _crawler_feed_exporter(self, settings): + if self.deprecated_options: + with pytest.warns( + ScrapyDeprecationWarning, + match="The `FEED_URI` and `FEED_FORMAT` settings have been deprecated", + ): + crawler = get_crawler(settings_dict=settings) + else: + crawler = get_crawler(settings_dict=settings) + feed_exporter = crawler.get_extension(FeedExporter) + return crawler, feed_exporter + + def test_default(self): + settings = self.build_settings( + uri="file:///tmp/%(name)s", + ) + crawler, feed_exporter = self._crawler_feed_exporter(settings) + spider = scrapy.Spider(self.spider_name) + spider.crawler = crawler + + with warnings.catch_warnings(): + warnings.simplefilter("error", ScrapyDeprecationWarning) + feed_exporter.open_spider(spider) + + assert feed_exporter.slots[0].uri == f"file:///tmp/{self.spider_name}" + + def test_none(self): + def uri_params(params, spider): + pass + + settings = self.build_settings( + uri="file:///tmp/%(name)s", + uri_params=uri_params, + ) + crawler, feed_exporter = self._crawler_feed_exporter(settings) + spider = scrapy.Spider(self.spider_name) + spider.crawler = crawler + + feed_exporter.open_spider(spider) + + assert feed_exporter.slots[0].uri == f"file:///tmp/{self.spider_name}" + + def test_empty_dict(self): + def uri_params(params, spider): + return {} + + settings = self.build_settings( + uri="file:///tmp/%(name)s", + uri_params=uri_params, + ) + crawler, feed_exporter = self._crawler_feed_exporter(settings) + spider = scrapy.Spider(self.spider_name) + spider.crawler = crawler + + with warnings.catch_warnings(): + warnings.simplefilter("error", ScrapyDeprecationWarning) + with pytest.raises(KeyError): + feed_exporter.open_spider(spider) + + def test_params_as_is(self): + def uri_params(params, spider): + return params + + settings = self.build_settings( + uri="file:///tmp/%(name)s", + uri_params=uri_params, + ) + crawler, feed_exporter = self._crawler_feed_exporter(settings) + spider = scrapy.Spider(self.spider_name) + spider.crawler = crawler + with warnings.catch_warnings(): + warnings.simplefilter("error", ScrapyDeprecationWarning) + feed_exporter.open_spider(spider) + + assert feed_exporter.slots[0].uri == f"file:///tmp/{self.spider_name}" + + def test_custom_param(self): + def uri_params(params, spider): + return {**params, "foo": self.spider_name} + + settings = self.build_settings( + uri="file:///tmp/%(foo)s", + uri_params=uri_params, + ) + crawler, feed_exporter = self._crawler_feed_exporter(settings) + spider = scrapy.Spider(self.spider_name) + spider.crawler = crawler + with warnings.catch_warnings(): + warnings.simplefilter("error", ScrapyDeprecationWarning) + feed_exporter.open_spider(spider) + + assert feed_exporter.slots[0].uri == f"file:///tmp/{self.spider_name}" + + +class TestURIParamsSetting(TestURIParams): + deprecated_options = True + + def build_settings(self, uri="file:///tmp/foobar", uri_params=None): + extra_settings = {} + if uri_params: + extra_settings["FEED_URI_PARAMS"] = uri_params + return { + "FEED_URI": uri, + **extra_settings, + } + + +class TestURIParamsFeedOption(TestURIParams): + deprecated_options = False + + def build_settings(self, uri="file:///tmp/foobar", uri_params=None): + options = { + "format": "jl", + } + if uri_params: + options["uri_params"] = uri_params + return { + "FEEDS": { + uri: options, + }, + } diff --git a/tests/test_http_request.py b/tests/test_http_request.py index 93cbcd1eb..06b280235 100644 --- a/tests/test_http_request.py +++ b/tests/test_http_request.py @@ -1,24 +1,12 @@ -import json -import re import warnings import xmlrpc.client from typing import Any -from unittest import mock -from urllib.parse import parse_qs, unquote_to_bytes import pytest -from scrapy.http import ( - FormRequest, - Headers, - HtmlResponse, - JsonRequest, - Request, - XmlRpcRequest, -) +from scrapy.http import Headers, Request, XmlRpcRequest from scrapy.http.request import NO_CALLBACK -from scrapy.utils.httpobj import urlparse_cached -from scrapy.utils.python import to_bytes, to_unicode +from scrapy.utils.python import to_bytes class TestRequest: @@ -495,1022 +483,6 @@ class TestRequest: ) -class TestFormRequest(TestRequest): - request_class = FormRequest - - def assertQueryEqual(self, first, second, msg=None): - first = to_unicode(first).split("&") - second = to_unicode(second).split("&") - assert sorted(first) == sorted(second), msg - - def test_empty_formdata(self): - r1 = self.request_class("http://www.example.com", formdata={}) - assert r1.body == b"" - - def test_formdata_overrides_querystring(self): - data = (("a", "one"), ("a", "two"), ("b", "2")) - url = self.request_class( - "http://www.example.com/?a=0&b=1&c=3#fragment", method="GET", formdata=data - ).url.split("#", maxsplit=1)[0] - fs = _qs(self.request_class(url, method="GET", formdata=data)) - assert set(fs[b"a"]) == {b"one", b"two"} - assert fs[b"b"] == [b"2"] - assert fs.get(b"c") is None - - data = {"a": "1", "b": "2"} - fs = _qs( - self.request_class("http://www.example.com/", method="GET", formdata=data) - ) - assert fs[b"a"] == [b"1"] - assert fs[b"b"] == [b"2"] - - def test_default_encoding_bytes(self): - # using default encoding (utf-8) - data = {b"one": b"two", b"price": b"\xc2\xa3 100"} - r2 = self.request_class("http://www.example.com", formdata=data) - assert r2.method == "POST" - assert r2.encoding == "utf-8" - self.assertQueryEqual(r2.body, b"price=%C2%A3+100&one=two") - assert r2.headers[b"Content-Type"] == b"application/x-www-form-urlencoded" - - def test_default_encoding_textual_data(self): - # using default encoding (utf-8) - data = {"µ one": "two", "price": "£ 100"} - r2 = self.request_class("http://www.example.com", formdata=data) - assert r2.method == "POST" - assert r2.encoding == "utf-8" - self.assertQueryEqual(r2.body, b"price=%C2%A3+100&%C2%B5+one=two") - assert r2.headers[b"Content-Type"] == b"application/x-www-form-urlencoded" - - def test_default_encoding_mixed_data(self): - # using default encoding (utf-8) - data = {"\u00b5one": b"two", b"price\xc2\xa3": "\u00a3 100"} - r2 = self.request_class("http://www.example.com", formdata=data) - assert r2.method == "POST" - assert r2.encoding == "utf-8" - self.assertQueryEqual(r2.body, b"%C2%B5one=two&price%C2%A3=%C2%A3+100") - assert r2.headers[b"Content-Type"] == b"application/x-www-form-urlencoded" - - def test_custom_encoding_bytes(self): - data = {b"\xb5 one": b"two", b"price": b"\xa3 100"} - r2 = self.request_class( - "http://www.example.com", formdata=data, encoding="latin1" - ) - assert r2.method == "POST" - assert r2.encoding == "latin1" - self.assertQueryEqual(r2.body, b"price=%A3+100&%B5+one=two") - assert r2.headers[b"Content-Type"] == b"application/x-www-form-urlencoded" - - def test_custom_encoding_textual_data(self): - data = {"price": "£ 100"} - r3 = self.request_class( - "http://www.example.com", formdata=data, encoding="latin1" - ) - assert r3.encoding == "latin1" - assert r3.body == b"price=%A3+100" - - def test_multi_key_values(self): - # using multiples values for a single key - data = {"price": "\xa3 100", "colours": ["red", "blue", "green"]} - r3 = self.request_class("http://www.example.com", formdata=data) - self.assertQueryEqual( - r3.body, b"colours=red&colours=blue&colours=green&price=%C2%A3+100" - ) - - def test_from_response_post(self): - response = _buildresponse( - b"""
- - - -
""", - url="http://www.example.com/this/list.html", - ) - req = self.request_class.from_response( - response, formdata={"one": ["two", "three"], "six": "seven"} - ) - - assert req.method == "POST" - assert req.headers[b"Content-type"] == b"application/x-www-form-urlencoded" - assert req.url == "http://www.example.com/this/post.php" - fs = _qs(req) - assert set(fs[b"test"]) == {b"val1", b"val2"} - assert set(fs[b"one"]) == {b"two", b"three"} - assert fs[b"test2"] == [b"xxx"] - assert fs[b"six"] == [b"seven"] - - def test_from_response_post_nonascii_bytes_utf8(self): - response = _buildresponse( - b"""
- - - -
""", - url="http://www.example.com/this/list.html", - ) - req = self.request_class.from_response( - response, formdata={"one": ["two", "three"], "six": "seven"} - ) - - assert req.method == "POST" - assert req.headers[b"Content-type"] == b"application/x-www-form-urlencoded" - assert req.url == "http://www.example.com/this/post.php" - fs = _qs(req, to_unicode=True) - assert set(fs["test £"]) == {"val1", "val2"} - assert set(fs["one"]) == {"two", "three"} - assert fs["test2"] == ["xxx µ"] - assert fs["six"] == ["seven"] - - def test_from_response_post_nonascii_bytes_latin1(self): - response = _buildresponse( - b"""
- - - -
""", - url="http://www.example.com/this/list.html", - encoding="latin1", - ) - req = self.request_class.from_response( - response, formdata={"one": ["two", "three"], "six": "seven"} - ) - - assert req.method == "POST" - assert req.headers[b"Content-type"] == b"application/x-www-form-urlencoded" - assert req.url == "http://www.example.com/this/post.php" - fs = _qs(req, to_unicode=True, encoding="latin1") - assert set(fs["test £"]) == {"val1", "val2"} - assert set(fs["one"]) == {"two", "three"} - assert fs["test2"] == ["xxx µ"] - assert fs["six"] == ["seven"] - - def test_from_response_post_nonascii_unicode(self): - response = _buildresponse( - """
- - - -
""", - url="http://www.example.com/this/list.html", - ) - req = self.request_class.from_response( - response, formdata={"one": ["two", "three"], "six": "seven"} - ) - - assert req.method == "POST" - assert req.headers[b"Content-type"] == b"application/x-www-form-urlencoded" - assert req.url == "http://www.example.com/this/post.php" - fs = _qs(req, to_unicode=True) - assert set(fs["test £"]) == {"val1", "val2"} - assert set(fs["one"]) == {"two", "three"} - assert fs["test2"] == ["xxx µ"] - assert fs["six"] == ["seven"] - - def test_from_response_duplicate_form_key(self): - response = _buildresponse("
", url="http://www.example.com") - req = self.request_class.from_response( - response=response, - method="GET", - formdata=(("foo", "bar"), ("foo", "baz")), - ) - assert urlparse_cached(req).hostname == "www.example.com" - assert urlparse_cached(req).query == "foo=bar&foo=baz" - - def test_from_response_override_duplicate_form_key(self): - response = _buildresponse( - """
- - -
""" - ) - req = self.request_class.from_response( - response, formdata=(("two", "2"), ("two", "4")) - ) - fs = _qs(req) - assert fs[b"one"] == [b"1"] - assert fs[b"two"] == [b"2", b"4"] - - def test_from_response_extra_headers(self): - response = _buildresponse( - """
- - - -
""" - ) - req = self.request_class.from_response( - response=response, - formdata={"one": ["two", "three"], "six": "seven"}, - headers={"Accept-Encoding": "gzip,deflate"}, - ) - assert req.method == "POST" - assert req.headers["Content-type"] == b"application/x-www-form-urlencoded" - assert req.headers["Accept-Encoding"] == b"gzip,deflate" - - def test_from_response_get(self): - response = _buildresponse( - """
- - - -
""", - url="http://www.example.com/this/list.html", - ) - r1 = self.request_class.from_response( - response, formdata={"one": ["two", "three"], "six": "seven"} - ) - assert r1.method == "GET" - assert urlparse_cached(r1).hostname == "www.example.com" - assert urlparse_cached(r1).path == "/this/get.php" - fs = _qs(r1) - assert set(fs[b"test"]) == {b"val1", b"val2"} - assert set(fs[b"one"]) == {b"two", b"three"} - assert fs[b"test2"] == [b"xxx"] - assert fs[b"six"] == [b"seven"] - - def test_from_response_override_params(self): - response = _buildresponse( - """
- - -
""" - ) - req = self.request_class.from_response(response, formdata={"two": "2"}) - fs = _qs(req) - assert fs[b"one"] == [b"1"] - assert fs[b"two"] == [b"2"] - - def test_from_response_drop_params(self): - response = _buildresponse( - """
- - -
""" - ) - req = self.request_class.from_response(response, formdata={"two": None}) - fs = _qs(req) - assert fs[b"one"] == [b"1"] - assert b"two" not in fs - - def test_from_response_override_method(self): - response = _buildresponse( - """ -
- """ - ) - request = FormRequest.from_response(response) - assert request.method == "GET" - request = FormRequest.from_response(response, method="POST") - assert request.method == "POST" - - def test_from_response_override_url(self): - response = _buildresponse( - """ -
- """ - ) - request = FormRequest.from_response(response) - assert request.url == "http://example.com/app" - request = FormRequest.from_response(response, url="http://foo.bar/absolute") - assert request.url == "http://foo.bar/absolute" - request = FormRequest.from_response(response, url="/relative") - assert request.url == "http://example.com/relative" - - def test_from_response_case_insensitive(self): - response = _buildresponse( - """
- - - -
""" - ) - req = self.request_class.from_response(response) - fs = _qs(req) - assert fs[b"clickable1"] == [b"clicked1"] - assert b"i1" not in fs, fs # xpath in _get_inputs() - assert b"clickable2" not in fs, fs # xpath in _get_clickable() - - def test_from_response_submit_first_clickable(self): - response = _buildresponse( - """
- - - - -
""" - ) - req = self.request_class.from_response(response, formdata={"two": "2"}) - fs = _qs(req) - assert fs[b"clickable1"] == [b"clicked1"] - assert b"clickable2" not in fs, fs - assert fs[b"one"] == [b"1"] - assert fs[b"two"] == [b"2"] - - def test_from_response_submit_not_first_clickable(self): - response = _buildresponse( - """
- - - - -
""" - ) - req = self.request_class.from_response( - response, formdata={"two": "2"}, clickdata={"name": "clickable2"} - ) - fs = _qs(req) - assert fs[b"clickable2"] == [b"clicked2"] - assert b"clickable1" not in fs, fs - assert fs[b"one"] == [b"1"] - assert fs[b"two"] == [b"2"] - - def test_from_response_dont_submit_image_as_input(self): - response = _buildresponse( - """
- - - -
""" - ) - req = self.request_class.from_response(response, dont_click=True) - fs = _qs(req) - assert fs == {b"i1": [b"i1v"]} - - def test_from_response_dont_submit_reset_as_input(self): - response = _buildresponse( - """
- - - - -
""" - ) - req = self.request_class.from_response(response, dont_click=True) - fs = _qs(req) - assert fs == {b"i1": [b"i1v"], b"i2": [b"i2v"]} - - def test_from_response_clickdata_does_not_ignore_image(self): - response = _buildresponse( - """
- - -
""" - ) - req = self.request_class.from_response(response) - fs = _qs(req) - assert fs == {b"i1": [b"i1v"], b"i2": [b"i2v"]} - - def test_from_response_multiple_clickdata(self): - response = _buildresponse( - """
- - - - -
""" - ) - req = self.request_class.from_response( - response, clickdata={"name": "clickable", "value": "clicked2"} - ) - fs = _qs(req) - assert fs[b"clickable"] == [b"clicked2"] - assert fs[b"one"] == [b"clicked1"] - assert fs[b"two"] == [b"clicked2"] - - def test_from_response_unicode_clickdata(self): - response = _buildresponse( - """
- - - - -
""" - ) - req = self.request_class.from_response( - response, clickdata={"name": "price in \u00a3"} - ) - fs = _qs(req, to_unicode=True) - assert fs["price in \u00a3"] - - def test_from_response_unicode_clickdata_latin1(self): - response = _buildresponse( - """
- - - - -
""", - encoding="latin1", - ) - req = self.request_class.from_response( - response, clickdata={"name": "price in \u00a5"} - ) - fs = _qs(req, to_unicode=True, encoding="latin1") - assert fs["price in \u00a5"] - - def test_from_response_multiple_forms_clickdata(self): - response = _buildresponse( - """
- - -
-
- - -
- """ - ) - req = self.request_class.from_response( - response, formname="form2", clickdata={"name": "clickable"} - ) - fs = _qs(req) - assert fs[b"clickable"] == [b"clicked2"] - assert fs[b"field2"] == [b"value2"] - assert b"field1" not in fs, fs - - def test_from_response_override_clickable(self): - response = _buildresponse( - """
""" - ) - req = self.request_class.from_response( - response, formdata={"clickme": "two"}, clickdata={"name": "clickme"} - ) - fs = _qs(req) - assert fs[b"clickme"] == [b"two"] - - def test_from_response_dont_click(self): - response = _buildresponse( - """
- - - - -
""" - ) - r1 = self.request_class.from_response(response, dont_click=True) - fs = _qs(r1) - assert b"clickable1" not in fs, fs - assert b"clickable2" not in fs, fs - - def test_from_response_ambiguous_clickdata(self): - response = _buildresponse( - """ -
- - - - -
""" - ) - with pytest.raises( - ValueError, - match=r"Multiple elements found .* matching the criteria in clickdata", - ): - self.request_class.from_response(response, clickdata={"type": "submit"}) - - def test_from_response_non_matching_clickdata(self): - response = _buildresponse( - """
- -
""" - ) - with pytest.raises( - ValueError, match="No clickable element matching clickdata:" - ): - self.request_class.from_response( - response, clickdata={"nonexistent": "notme"} - ) - - def test_from_response_nr_index_clickdata(self): - response = _buildresponse( - """
- - -
- """ - ) - req = self.request_class.from_response(response, clickdata={"nr": 1}) - fs = _qs(req) - assert b"clickable2" in fs - assert b"clickable1" not in fs - - def test_from_response_invalid_nr_index_clickdata(self): - response = _buildresponse( - """
- -
- """ - ) - with pytest.raises( - ValueError, match="No clickable element matching clickdata:" - ): - self.request_class.from_response(response, clickdata={"nr": 1}) - - def test_from_response_errors_noform(self): - response = _buildresponse("""""") - with pytest.raises(ValueError, match="No
element found in"): - self.request_class.from_response(response) - - def test_from_response_invalid_html5(self): - response = _buildresponse( - """""" - """""" - """
""" - ) - req = self.request_class.from_response(response, formdata={"bar": "buz"}) - fs = _qs(req) - assert fs == {b"foo": [b"xxx"], b"bar": [b"buz"]} - - def test_from_response_errors_formnumber(self): - response = _buildresponse( - """
- - - -
""" - ) - with pytest.raises(IndexError): - self.request_class.from_response(response, formnumber=1) - - def test_from_response_noformname(self): - response = _buildresponse( - """
- - -
""" - ) - r1 = self.request_class.from_response(response, formdata={"two": "3"}) - assert r1.method == "POST" - assert r1.headers["Content-type"] == b"application/x-www-form-urlencoded" - fs = _qs(r1) - assert fs == {b"one": [b"1"], b"two": [b"3"]} - - def test_from_response_formname_exists(self): - response = _buildresponse( - """
- - -
-
- - -
""" - ) - r1 = self.request_class.from_response(response, formname="form2") - assert r1.method == "POST" - fs = _qs(r1) - assert fs == {b"four": [b"4"], b"three": [b"3"]} - - def test_from_response_formname_nonexistent(self): - response = _buildresponse( - """
- -
-
- -
""" - ) - r1 = self.request_class.from_response(response, formname="form3") - assert r1.method == "POST" - fs = _qs(r1) - assert fs == {b"one": [b"1"]} - - def test_from_response_formname_errors_formnumber(self): - response = _buildresponse( - """
- -
-
- -
""" - ) - with pytest.raises(IndexError): - self.request_class.from_response(response, formname="form3", formnumber=2) - - def test_from_response_formid_exists(self): - response = _buildresponse( - """
- - -
-
- - -
""" - ) - r1 = self.request_class.from_response(response, formid="form2") - assert r1.method == "POST" - fs = _qs(r1) - assert fs == {b"four": [b"4"], b"three": [b"3"]} - - def test_from_response_formname_nonexistent_fallback_formid(self): - response = _buildresponse( - """
- - -
-
- - -
""" - ) - r1 = self.request_class.from_response( - response, formname="form3", formid="form2" - ) - assert r1.method == "POST" - fs = _qs(r1) - assert fs == {b"four": [b"4"], b"three": [b"3"]} - - def test_from_response_formid_nonexistent(self): - response = _buildresponse( - """
- -
-
- -
""" - ) - r1 = self.request_class.from_response(response, formid="form3") - assert r1.method == "POST" - fs = _qs(r1) - assert fs == {b"one": [b"1"]} - - def test_from_response_formid_errors_formnumber(self): - response = _buildresponse( - """
- -
-
- -
""" - ) - with pytest.raises(IndexError): - self.request_class.from_response(response, formid="form3", formnumber=2) - - def test_from_response_select(self): - res = _buildresponse( - """
- - - - - - - - - - - - - - -
""" - ) - req = self.request_class.from_response(res) - fs = _qs(req) - assert fs == {b"i1": [b"iv2"], b"i2": [b"on"]} - - def test_from_response_checkbox(self): - res = _buildresponse( - """
- - - - - - - - -
""" - ) - req = self.request_class.from_response(res) - fs = _qs(req) - assert fs == {b"i1": [b"iv2"], b"i2": [b"on"]} - - def test_from_response_input_text(self): - res = _buildresponse( - """
- - - - - -
""" - ) - req = self.request_class.from_response(res) - fs = _qs(req) - assert fs == {b"i1": [b"i1v1"], b"i2": [b""], b"i4": [b"i4v1"]} - - def test_from_response_input_hidden(self): - res = _buildresponse( - """
- - - - -
""" - ) - req = self.request_class.from_response(res) - fs = _qs(req) - assert fs == {b"i1": [b"i1v1"], b"i2": [b""]} - - def test_from_response_input_textarea(self): - res = _buildresponse( - """
- - - -
""" - ) - req = self.request_class.from_response(res) - fs = _qs(req) - assert fs == {b"i1": [b"i1v"], b"i2": [b""], b"i3": [b""]} - - def test_from_response_descendants(self): - res = _buildresponse( - """
-
-
- - -
- - - - -
- -
""" - ) - req = self.request_class.from_response(res) - fs = _qs(req) - assert set(fs) == {b"h2", b"i2", b"i1", b"i3", b"h1", b"i5", b"i4"} - - def test_from_response_xpath(self): - response = _buildresponse( - """
- - -
-
- - -
""" - ) - r1 = self.request_class.from_response( - response, formxpath="//form[@action='post.php']" - ) - fs = _qs(r1) - assert fs[b"one"] == [b"1"] - - r1 = self.request_class.from_response( - response, formxpath="//form/input[@name='four']" - ) - fs = _qs(r1) - assert fs[b"three"] == [b"3"] - - with pytest.raises(ValueError, match="No
element found with"): - self.request_class.from_response( - response, formxpath="//form/input[@name='abc']" - ) - - def test_from_response_unicode_xpath(self): - response = _buildresponse(b'
') - r = self.request_class.from_response( - response, formxpath="//form[@name='\u044a']" - ) - fs = _qs(r) - assert not fs - - xpath = "//form[@name='\u03b1']" - with pytest.raises(ValueError, match=re.escape(xpath)): - self.request_class.from_response(response, formxpath=xpath) - - def test_from_response_button_submit(self): - response = _buildresponse( - """
- - - -
""", - url="http://www.example.com/this/list.html", - ) - req = self.request_class.from_response(response) - assert req.method == "POST" - assert req.headers["Content-type"] == b"application/x-www-form-urlencoded" - assert req.url == "http://www.example.com/this/post.php" - fs = _qs(req) - assert fs[b"test1"] == [b"val1"] - assert fs[b"test2"] == [b"val2"] - assert fs[b"button1"] == [b"submit1"] - - def test_from_response_button_notype(self): - response = _buildresponse( - """
- - - -
""", - url="http://www.example.com/this/list.html", - ) - req = self.request_class.from_response(response) - assert req.method == "POST" - assert req.headers["Content-type"] == b"application/x-www-form-urlencoded" - assert req.url == "http://www.example.com/this/post.php" - fs = _qs(req) - assert fs[b"test1"] == [b"val1"] - assert fs[b"test2"] == [b"val2"] - assert fs[b"button1"] == [b"submit1"] - - def test_from_response_submit_novalue(self): - response = _buildresponse( - """
- - - Submit -
""", - url="http://www.example.com/this/list.html", - ) - req = self.request_class.from_response(response) - assert req.method == "POST" - assert req.headers["Content-type"] == b"application/x-www-form-urlencoded" - assert req.url == "http://www.example.com/this/post.php" - fs = _qs(req) - assert fs[b"test1"] == [b"val1"] - assert fs[b"test2"] == [b"val2"] - assert fs[b"button1"] == [b""] - - def test_from_response_button_novalue(self): - response = _buildresponse( - """
- - - -
""", - url="http://www.example.com/this/list.html", - ) - req = self.request_class.from_response(response) - assert req.method == "POST" - assert req.headers["Content-type"] == b"application/x-www-form-urlencoded" - assert req.url == "http://www.example.com/this/post.php" - fs = _qs(req) - assert fs[b"test1"] == [b"val1"] - assert fs[b"test2"] == [b"val2"] - assert fs[b"button1"] == [b""] - - def test_html_base_form_action(self): - response = _buildresponse( - """ - - - - - -
-
- - - """, - url="http://a.com/", - ) - req = self.request_class.from_response(response) - assert req.url == "http://b.com/test_form" - - def test_spaces_in_action(self): - resp = _buildresponse('
') - req = self.request_class.from_response(resp) - assert req.url == "http://example.com/path" - - def test_from_response_css(self): - response = _buildresponse( - """
- - -
-
- - -
""" - ) - r1 = self.request_class.from_response( - response, formcss="form[action='post.php']" - ) - fs = _qs(r1) - assert fs[b"one"] == [b"1"] - - r1 = self.request_class.from_response(response, formcss="input[name='four']") - fs = _qs(r1) - assert fs[b"three"] == [b"3"] - - with pytest.raises(ValueError, match="No
element found with"): - self.request_class.from_response(response, formcss="input[name='abc']") - - def test_from_response_valid_form_methods(self): - form_methods = [ - [method, method] for method in self.request_class.valid_form_methods - ] - form_methods.append(["UNKNOWN", "GET"]) - - for method, expected in form_methods: - response = _buildresponse( - f'' - '' - "
" - ) - r = self.request_class.from_response(response) - assert r.method == expected - - def test_form_response_with_invalid_formdata_type_error(self): - """Test that a ValueError is raised for non-iterable and non-dict formdata input""" - response = _buildresponse( - """ -
- -
- """ - ) - with pytest.raises( - ValueError, match="formdata should be a dict or iterable of tuples" - ): - FormRequest.from_response(response, formdata=123) - - def test_form_response_with_custom_invalid_formdata_value_error(self): - """Test that a ValueError is raised for fault-inducing iterable formdata input""" - response = _buildresponse( - """ -
- -
- """ - ) - - with pytest.raises( - ValueError, match="formdata should be a dict or iterable of tuples" - ): - FormRequest.from_response(response, formdata=("a",)) - - def test_get_form_with_xpath_no_form_parent(self): - """Test that _get_from raised a ValueError when an XPath selects an element - not nested within a
and no parent is found""" - response = _buildresponse( - """ -
-

This paragraph is not inside a form.

-
- - -
- """ - ) - - with pytest.raises(ValueError, match="No
element found with"): - FormRequest.from_response(response, formxpath='//div[@id="outside-form"]/p') - - -def _buildresponse(body, **kwargs): - kwargs.setdefault("body", body) - kwargs.setdefault("url", "http://example.com") - kwargs.setdefault("encoding", "utf-8") - return HtmlResponse(**kwargs) - - -def _qs(req, encoding="utf-8", to_unicode=False): - qs = req.body if req.method == "POST" else req.url.partition("?")[2] - uqs = unquote_to_bytes(qs) - if to_unicode: - uqs = uqs.decode(encoding) - return parse_qs(uqs, True) - - class TestXmlRpcRequest(TestRequest): request_class = XmlRpcRequest default_method = "POST" @@ -1539,184 +511,3 @@ class TestXmlRpcRequest(TestRequest): def test_latin1(self): self._test_request(params=("pas£",), encoding="latin1") - - -class TestJsonRequest(TestRequest): - request_class = JsonRequest - default_method = "GET" - default_headers = { - b"Content-Type": [b"application/json"], - b"Accept": [b"application/json, text/javascript, */*; q=0.01"], - } - - def test_data(self): - r1 = self.request_class(url="http://www.example.com/") - assert r1.body == b"" - - body = b"body" - r2 = self.request_class(url="http://www.example.com/", body=body) - assert r2.body == body - - data = { - "name": "value", - } - r3 = self.request_class(url="http://www.example.com/", data=data) - assert r3.body == to_bytes(json.dumps(data)) - - # empty data - r4 = self.request_class(url="http://www.example.com/", data=[]) - assert r4.body == to_bytes(json.dumps([])) - - def test_data_method(self): - # data is not passed - r1 = self.request_class(url="http://www.example.com/") - assert r1.method == "GET" - - body = b"body" - r2 = self.request_class(url="http://www.example.com/", body=body) - assert r2.method == "GET" - - data = { - "name": "value", - } - r3 = self.request_class(url="http://www.example.com/", data=data) - assert r3.method == "POST" - - # method passed explicitly - r4 = self.request_class(url="http://www.example.com/", data=data, method="GET") - assert r4.method == "GET" - - r5 = self.request_class(url="http://www.example.com/", data=[]) - assert r5.method == "POST" - - def test_body_data(self): - """passing both body and data should result a warning""" - body = b"body" - data = { - "name": "value", - } - with warnings.catch_warnings(record=True) as _warnings: - r5 = self.request_class(url="http://www.example.com/", body=body, data=data) - assert r5.body == body - assert r5.method == "GET" - assert len(_warnings) == 1 - assert "data will be ignored" in str(_warnings[0].message) - - def test_empty_body_data(self): - """passing any body value and data should result a warning""" - data = { - "name": "value", - } - with warnings.catch_warnings(record=True) as _warnings: - r6 = self.request_class(url="http://www.example.com/", body=b"", data=data) - assert r6.body == b"" - assert r6.method == "GET" - assert len(_warnings) == 1 - assert "data will be ignored" in str(_warnings[0].message) - - def test_body_none_data(self): - data = { - "name": "value", - } - with warnings.catch_warnings(record=True) as _warnings: - r7 = self.request_class(url="http://www.example.com/", body=None, data=data) - assert r7.body == to_bytes(json.dumps(data)) - assert r7.method == "POST" - assert len(_warnings) == 0 - - def test_body_data_none(self): - with warnings.catch_warnings(record=True) as _warnings: - r8 = self.request_class(url="http://www.example.com/", body=None, data=None) - assert r8.method == "GET" - assert len(_warnings) == 0 - - def test_dumps_sort_keys(self): - """Test that sort_keys=True is passed to json.dumps by default""" - data = { - "name": "value", - } - with mock.patch("json.dumps", return_value=b"") as mock_dumps: - self.request_class(url="http://www.example.com/", data=data) - kwargs = mock_dumps.call_args[1] - assert kwargs["sort_keys"] is True - - def test_dumps_kwargs(self): - """Test that dumps_kwargs are passed to json.dumps""" - data = { - "name": "value", - } - dumps_kwargs = { - "ensure_ascii": True, - "allow_nan": True, - } - with mock.patch("json.dumps", return_value=b"") as mock_dumps: - self.request_class( - url="http://www.example.com/", data=data, dumps_kwargs=dumps_kwargs - ) - kwargs = mock_dumps.call_args[1] - assert kwargs["ensure_ascii"] is True - assert kwargs["allow_nan"] is True - - def test_replace_data(self): - data1 = { - "name1": "value1", - } - data2 = { - "name2": "value2", - } - r1 = self.request_class(url="http://www.example.com/", data=data1) - r2 = r1.replace(data=data2) - assert r2.body == to_bytes(json.dumps(data2)) - - def test_replace_sort_keys(self): - """Test that replace provides sort_keys=True to json.dumps""" - data1 = { - "name1": "value1", - } - data2 = { - "name2": "value2", - } - r1 = self.request_class(url="http://www.example.com/", data=data1) - with mock.patch("json.dumps", return_value=b"") as mock_dumps: - r1.replace(data=data2) - kwargs = mock_dumps.call_args[1] - assert kwargs["sort_keys"] is True - - def test_replace_dumps_kwargs(self): - """Test that dumps_kwargs are provided to json.dumps when replace is called""" - data1 = { - "name1": "value1", - } - data2 = { - "name2": "value2", - } - dumps_kwargs = { - "ensure_ascii": True, - "allow_nan": True, - } - r1 = self.request_class( - url="http://www.example.com/", data=data1, dumps_kwargs=dumps_kwargs - ) - with mock.patch("json.dumps", return_value=b"") as mock_dumps: - r1.replace(data=data2) - kwargs = mock_dumps.call_args[1] - assert kwargs["ensure_ascii"] is True - assert kwargs["allow_nan"] is True - - def test_replacement_both_body_and_data_warns(self): - """Test that we get a warning if both body and data are passed""" - body1 = None - body2 = b"body" - data1 = { - "name1": "value1", - } - data2 = { - "name2": "value2", - } - r1 = self.request_class(url="http://www.example.com/", data=data1, body=body1) - - with warnings.catch_warnings(record=True) as _warnings: - r1.replace(data=data2, body=body2) - assert "Both body and data passed. data will be ignored" in str( - _warnings[0].message - ) diff --git a/tests/test_http_request_form.py b/tests/test_http_request_form.py new file mode 100644 index 000000000..92f2f3fac --- /dev/null +++ b/tests/test_http_request_form.py @@ -0,0 +1,1027 @@ +from __future__ import annotations + +import re +from urllib.parse import parse_qs, unquote_to_bytes + +import pytest + +from scrapy.http import FormRequest, HtmlResponse +from scrapy.utils.httpobj import urlparse_cached +from scrapy.utils.python import to_unicode +from tests.test_http_request import TestRequest + + +def _buildresponse(body, **kwargs): + kwargs.setdefault("body", body) + kwargs.setdefault("url", "http://example.com") + kwargs.setdefault("encoding", "utf-8") + return HtmlResponse(**kwargs) + + +def _qs(req, encoding="utf-8", to_unicode=False): + qs = req.body if req.method == "POST" else req.url.partition("?")[2] + uqs = unquote_to_bytes(qs) + if to_unicode: + uqs = uqs.decode(encoding) + return parse_qs(uqs, True) + + +class TestFormRequest(TestRequest): + request_class = FormRequest + + def assertQueryEqual(self, first, second, msg=None): + first = to_unicode(first).split("&") + second = to_unicode(second).split("&") + assert sorted(first) == sorted(second), msg + + def test_empty_formdata(self): + r1 = self.request_class("http://www.example.com", formdata={}) + assert r1.body == b"" + + def test_formdata_overrides_querystring(self): + data = (("a", "one"), ("a", "two"), ("b", "2")) + url = self.request_class( + "http://www.example.com/?a=0&b=1&c=3#fragment", method="GET", formdata=data + ).url.split("#", maxsplit=1)[0] + fs = _qs(self.request_class(url, method="GET", formdata=data)) + assert set(fs[b"a"]) == {b"one", b"two"} + assert fs[b"b"] == [b"2"] + assert fs.get(b"c") is None + + data = {"a": "1", "b": "2"} + fs = _qs( + self.request_class("http://www.example.com/", method="GET", formdata=data) + ) + assert fs[b"a"] == [b"1"] + assert fs[b"b"] == [b"2"] + + def test_default_encoding_bytes(self): + # using default encoding (utf-8) + data = {b"one": b"two", b"price": b"\xc2\xa3 100"} + r2 = self.request_class("http://www.example.com", formdata=data) + assert r2.method == "POST" + assert r2.encoding == "utf-8" + self.assertQueryEqual(r2.body, b"price=%C2%A3+100&one=two") + assert r2.headers[b"Content-Type"] == b"application/x-www-form-urlencoded" + + def test_default_encoding_textual_data(self): + # using default encoding (utf-8) + data = {"µ one": "two", "price": "£ 100"} + r2 = self.request_class("http://www.example.com", formdata=data) + assert r2.method == "POST" + assert r2.encoding == "utf-8" + self.assertQueryEqual(r2.body, b"price=%C2%A3+100&%C2%B5+one=two") + assert r2.headers[b"Content-Type"] == b"application/x-www-form-urlencoded" + + def test_default_encoding_mixed_data(self): + # using default encoding (utf-8) + data = {"\u00b5one": b"two", b"price\xc2\xa3": "\u00a3 100"} + r2 = self.request_class("http://www.example.com", formdata=data) + assert r2.method == "POST" + assert r2.encoding == "utf-8" + self.assertQueryEqual(r2.body, b"%C2%B5one=two&price%C2%A3=%C2%A3+100") + assert r2.headers[b"Content-Type"] == b"application/x-www-form-urlencoded" + + def test_custom_encoding_bytes(self): + data = {b"\xb5 one": b"two", b"price": b"\xa3 100"} + r2 = self.request_class( + "http://www.example.com", formdata=data, encoding="latin1" + ) + assert r2.method == "POST" + assert r2.encoding == "latin1" + self.assertQueryEqual(r2.body, b"price=%A3+100&%B5+one=two") + assert r2.headers[b"Content-Type"] == b"application/x-www-form-urlencoded" + + def test_custom_encoding_textual_data(self): + data = {"price": "£ 100"} + r3 = self.request_class( + "http://www.example.com", formdata=data, encoding="latin1" + ) + assert r3.encoding == "latin1" + assert r3.body == b"price=%A3+100" + + def test_multi_key_values(self): + # using multiples values for a single key + data = {"price": "\xa3 100", "colours": ["red", "blue", "green"]} + r3 = self.request_class("http://www.example.com", formdata=data) + self.assertQueryEqual( + r3.body, b"colours=red&colours=blue&colours=green&price=%C2%A3+100" + ) + + def test_from_response_post(self): + response = _buildresponse( + b""" + + + +
""", + url="http://www.example.com/this/list.html", + ) + req = self.request_class.from_response( + response, formdata={"one": ["two", "three"], "six": "seven"} + ) + + assert req.method == "POST" + assert req.headers[b"Content-type"] == b"application/x-www-form-urlencoded" + assert req.url == "http://www.example.com/this/post.php" + fs = _qs(req) + assert set(fs[b"test"]) == {b"val1", b"val2"} + assert set(fs[b"one"]) == {b"two", b"three"} + assert fs[b"test2"] == [b"xxx"] + assert fs[b"six"] == [b"seven"] + + def test_from_response_post_nonascii_bytes_utf8(self): + response = _buildresponse( + b"""
+ + + +
""", + url="http://www.example.com/this/list.html", + ) + req = self.request_class.from_response( + response, formdata={"one": ["two", "three"], "six": "seven"} + ) + + assert req.method == "POST" + assert req.headers[b"Content-type"] == b"application/x-www-form-urlencoded" + assert req.url == "http://www.example.com/this/post.php" + fs = _qs(req, to_unicode=True) + assert set(fs["test £"]) == {"val1", "val2"} + assert set(fs["one"]) == {"two", "three"} + assert fs["test2"] == ["xxx µ"] + assert fs["six"] == ["seven"] + + def test_from_response_post_nonascii_bytes_latin1(self): + response = _buildresponse( + b"""
+ + + +
""", + url="http://www.example.com/this/list.html", + encoding="latin1", + ) + req = self.request_class.from_response( + response, formdata={"one": ["two", "three"], "six": "seven"} + ) + + assert req.method == "POST" + assert req.headers[b"Content-type"] == b"application/x-www-form-urlencoded" + assert req.url == "http://www.example.com/this/post.php" + fs = _qs(req, to_unicode=True, encoding="latin1") + assert set(fs["test £"]) == {"val1", "val2"} + assert set(fs["one"]) == {"two", "three"} + assert fs["test2"] == ["xxx µ"] + assert fs["six"] == ["seven"] + + def test_from_response_post_nonascii_unicode(self): + response = _buildresponse( + """
+ + + +
""", + url="http://www.example.com/this/list.html", + ) + req = self.request_class.from_response( + response, formdata={"one": ["two", "three"], "six": "seven"} + ) + + assert req.method == "POST" + assert req.headers[b"Content-type"] == b"application/x-www-form-urlencoded" + assert req.url == "http://www.example.com/this/post.php" + fs = _qs(req, to_unicode=True) + assert set(fs["test £"]) == {"val1", "val2"} + assert set(fs["one"]) == {"two", "three"} + assert fs["test2"] == ["xxx µ"] + assert fs["six"] == ["seven"] + + def test_from_response_duplicate_form_key(self): + response = _buildresponse("
", url="http://www.example.com") + req = self.request_class.from_response( + response=response, + method="GET", + formdata=(("foo", "bar"), ("foo", "baz")), + ) + assert urlparse_cached(req).hostname == "www.example.com" + assert urlparse_cached(req).query == "foo=bar&foo=baz" + + def test_from_response_override_duplicate_form_key(self): + response = _buildresponse( + """
+ + +
""" + ) + req = self.request_class.from_response( + response, formdata=(("two", "2"), ("two", "4")) + ) + fs = _qs(req) + assert fs[b"one"] == [b"1"] + assert fs[b"two"] == [b"2", b"4"] + + def test_from_response_extra_headers(self): + response = _buildresponse( + """
+ + + +
""" + ) + req = self.request_class.from_response( + response=response, + formdata={"one": ["two", "three"], "six": "seven"}, + headers={"Accept-Encoding": "gzip,deflate"}, + ) + assert req.method == "POST" + assert req.headers["Content-type"] == b"application/x-www-form-urlencoded" + assert req.headers["Accept-Encoding"] == b"gzip,deflate" + + def test_from_response_get(self): + response = _buildresponse( + """
+ + + +
""", + url="http://www.example.com/this/list.html", + ) + r1 = self.request_class.from_response( + response, formdata={"one": ["two", "three"], "six": "seven"} + ) + assert r1.method == "GET" + assert urlparse_cached(r1).hostname == "www.example.com" + assert urlparse_cached(r1).path == "/this/get.php" + fs = _qs(r1) + assert set(fs[b"test"]) == {b"val1", b"val2"} + assert set(fs[b"one"]) == {b"two", b"three"} + assert fs[b"test2"] == [b"xxx"] + assert fs[b"six"] == [b"seven"] + + def test_from_response_override_params(self): + response = _buildresponse( + """
+ + +
""" + ) + req = self.request_class.from_response(response, formdata={"two": "2"}) + fs = _qs(req) + assert fs[b"one"] == [b"1"] + assert fs[b"two"] == [b"2"] + + def test_from_response_drop_params(self): + response = _buildresponse( + """
+ + +
""" + ) + req = self.request_class.from_response(response, formdata={"two": None}) + fs = _qs(req) + assert fs[b"one"] == [b"1"] + assert b"two" not in fs + + def test_from_response_override_method(self): + response = _buildresponse( + """ +
+ """ + ) + request = FormRequest.from_response(response) + assert request.method == "GET" + request = FormRequest.from_response(response, method="POST") + assert request.method == "POST" + + def test_from_response_override_url(self): + response = _buildresponse( + """ +
+ """ + ) + request = FormRequest.from_response(response) + assert request.url == "http://example.com/app" + request = FormRequest.from_response(response, url="http://foo.bar/absolute") + assert request.url == "http://foo.bar/absolute" + request = FormRequest.from_response(response, url="/relative") + assert request.url == "http://example.com/relative" + + def test_from_response_case_insensitive(self): + response = _buildresponse( + """
+ + + +
""" + ) + req = self.request_class.from_response(response) + fs = _qs(req) + assert fs[b"clickable1"] == [b"clicked1"] + assert b"i1" not in fs, fs # xpath in _get_inputs() + assert b"clickable2" not in fs, fs # xpath in _get_clickable() + + def test_from_response_submit_first_clickable(self): + response = _buildresponse( + """
+ + + + +
""" + ) + req = self.request_class.from_response(response, formdata={"two": "2"}) + fs = _qs(req) + assert fs[b"clickable1"] == [b"clicked1"] + assert b"clickable2" not in fs, fs + assert fs[b"one"] == [b"1"] + assert fs[b"two"] == [b"2"] + + def test_from_response_submit_not_first_clickable(self): + response = _buildresponse( + """
+ + + + +
""" + ) + req = self.request_class.from_response( + response, formdata={"two": "2"}, clickdata={"name": "clickable2"} + ) + fs = _qs(req) + assert fs[b"clickable2"] == [b"clicked2"] + assert b"clickable1" not in fs, fs + assert fs[b"one"] == [b"1"] + assert fs[b"two"] == [b"2"] + + def test_from_response_dont_submit_image_as_input(self): + response = _buildresponse( + """
+ + + +
""" + ) + req = self.request_class.from_response(response, dont_click=True) + fs = _qs(req) + assert fs == {b"i1": [b"i1v"]} + + def test_from_response_dont_submit_reset_as_input(self): + response = _buildresponse( + """
+ + + + +
""" + ) + req = self.request_class.from_response(response, dont_click=True) + fs = _qs(req) + assert fs == {b"i1": [b"i1v"], b"i2": [b"i2v"]} + + def test_from_response_clickdata_does_not_ignore_image(self): + response = _buildresponse( + """
+ + +
""" + ) + req = self.request_class.from_response(response) + fs = _qs(req) + assert fs == {b"i1": [b"i1v"], b"i2": [b"i2v"]} + + def test_from_response_multiple_clickdata(self): + response = _buildresponse( + """
+ + + + +
""" + ) + req = self.request_class.from_response( + response, clickdata={"name": "clickable", "value": "clicked2"} + ) + fs = _qs(req) + assert fs[b"clickable"] == [b"clicked2"] + assert fs[b"one"] == [b"clicked1"] + assert fs[b"two"] == [b"clicked2"] + + def test_from_response_unicode_clickdata(self): + response = _buildresponse( + """
+ + + + +
""" + ) + req = self.request_class.from_response( + response, clickdata={"name": "price in \u00a3"} + ) + fs = _qs(req, to_unicode=True) + assert fs["price in \u00a3"] + + def test_from_response_unicode_clickdata_latin1(self): + response = _buildresponse( + """
+ + + + +
""", + encoding="latin1", + ) + req = self.request_class.from_response( + response, clickdata={"name": "price in \u00a5"} + ) + fs = _qs(req, to_unicode=True, encoding="latin1") + assert fs["price in \u00a5"] + + def test_from_response_multiple_forms_clickdata(self): + response = _buildresponse( + """
+ + +
+
+ + +
+ """ + ) + req = self.request_class.from_response( + response, formname="form2", clickdata={"name": "clickable"} + ) + fs = _qs(req) + assert fs[b"clickable"] == [b"clicked2"] + assert fs[b"field2"] == [b"value2"] + assert b"field1" not in fs, fs + + def test_from_response_override_clickable(self): + response = _buildresponse( + """
""" + ) + req = self.request_class.from_response( + response, formdata={"clickme": "two"}, clickdata={"name": "clickme"} + ) + fs = _qs(req) + assert fs[b"clickme"] == [b"two"] + + def test_from_response_dont_click(self): + response = _buildresponse( + """
+ + + + +
""" + ) + r1 = self.request_class.from_response(response, dont_click=True) + fs = _qs(r1) + assert b"clickable1" not in fs, fs + assert b"clickable2" not in fs, fs + + def test_from_response_ambiguous_clickdata(self): + response = _buildresponse( + """ +
+ + + + +
""" + ) + with pytest.raises( + ValueError, + match=r"Multiple elements found .* matching the criteria in clickdata", + ): + self.request_class.from_response(response, clickdata={"type": "submit"}) + + def test_from_response_non_matching_clickdata(self): + response = _buildresponse( + """
+ +
""" + ) + with pytest.raises( + ValueError, match="No clickable element matching clickdata:" + ): + self.request_class.from_response( + response, clickdata={"nonexistent": "notme"} + ) + + def test_from_response_nr_index_clickdata(self): + response = _buildresponse( + """
+ + +
+ """ + ) + req = self.request_class.from_response(response, clickdata={"nr": 1}) + fs = _qs(req) + assert b"clickable2" in fs + assert b"clickable1" not in fs + + def test_from_response_invalid_nr_index_clickdata(self): + response = _buildresponse( + """
+ +
+ """ + ) + with pytest.raises( + ValueError, match="No clickable element matching clickdata:" + ): + self.request_class.from_response(response, clickdata={"nr": 1}) + + def test_from_response_errors_noform(self): + response = _buildresponse("""""") + with pytest.raises(ValueError, match="No
element found in"): + self.request_class.from_response(response) + + def test_from_response_invalid_html5(self): + response = _buildresponse( + """""" + """""" + """
""" + ) + req = self.request_class.from_response(response, formdata={"bar": "buz"}) + fs = _qs(req) + assert fs == {b"foo": [b"xxx"], b"bar": [b"buz"]} + + def test_from_response_errors_formnumber(self): + response = _buildresponse( + """
+ + + +
""" + ) + with pytest.raises(IndexError): + self.request_class.from_response(response, formnumber=1) + + def test_from_response_noformname(self): + response = _buildresponse( + """
+ + +
""" + ) + r1 = self.request_class.from_response(response, formdata={"two": "3"}) + assert r1.method == "POST" + assert r1.headers["Content-type"] == b"application/x-www-form-urlencoded" + fs = _qs(r1) + assert fs == {b"one": [b"1"], b"two": [b"3"]} + + def test_from_response_formname_exists(self): + response = _buildresponse( + """
+ + +
+
+ + +
""" + ) + r1 = self.request_class.from_response(response, formname="form2") + assert r1.method == "POST" + fs = _qs(r1) + assert fs == {b"four": [b"4"], b"three": [b"3"]} + + def test_from_response_formname_nonexistent(self): + response = _buildresponse( + """
+ +
+
+ +
""" + ) + r1 = self.request_class.from_response(response, formname="form3") + assert r1.method == "POST" + fs = _qs(r1) + assert fs == {b"one": [b"1"]} + + def test_from_response_formname_errors_formnumber(self): + response = _buildresponse( + """
+ +
+
+ +
""" + ) + with pytest.raises(IndexError): + self.request_class.from_response(response, formname="form3", formnumber=2) + + def test_from_response_formid_exists(self): + response = _buildresponse( + """
+ + +
+
+ + +
""" + ) + r1 = self.request_class.from_response(response, formid="form2") + assert r1.method == "POST" + fs = _qs(r1) + assert fs == {b"four": [b"4"], b"three": [b"3"]} + + def test_from_response_formname_nonexistent_fallback_formid(self): + response = _buildresponse( + """
+ + +
+
+ + +
""" + ) + r1 = self.request_class.from_response( + response, formname="form3", formid="form2" + ) + assert r1.method == "POST" + fs = _qs(r1) + assert fs == {b"four": [b"4"], b"three": [b"3"]} + + def test_from_response_formid_nonexistent(self): + response = _buildresponse( + """
+ +
+
+ +
""" + ) + r1 = self.request_class.from_response(response, formid="form3") + assert r1.method == "POST" + fs = _qs(r1) + assert fs == {b"one": [b"1"]} + + def test_from_response_formid_errors_formnumber(self): + response = _buildresponse( + """
+ +
+
+ +
""" + ) + with pytest.raises(IndexError): + self.request_class.from_response(response, formid="form3", formnumber=2) + + def test_from_response_select(self): + res = _buildresponse( + """
+ + + + + + + + + + + + + + +
""" + ) + req = self.request_class.from_response(res) + fs = _qs(req) + assert fs == {b"i1": [b"iv2"], b"i2": [b"on"]} + + def test_from_response_checkbox(self): + res = _buildresponse( + """
+ + + + + + + + +
""" + ) + req = self.request_class.from_response(res) + fs = _qs(req) + assert fs == {b"i1": [b"iv2"], b"i2": [b"on"]} + + def test_from_response_input_text(self): + res = _buildresponse( + """
+ + + + + +
""" + ) + req = self.request_class.from_response(res) + fs = _qs(req) + assert fs == {b"i1": [b"i1v1"], b"i2": [b""], b"i4": [b"i4v1"]} + + def test_from_response_input_hidden(self): + res = _buildresponse( + """
+ + + + +
""" + ) + req = self.request_class.from_response(res) + fs = _qs(req) + assert fs == {b"i1": [b"i1v1"], b"i2": [b""]} + + def test_from_response_input_textarea(self): + res = _buildresponse( + """
+ + + +
""" + ) + req = self.request_class.from_response(res) + fs = _qs(req) + assert fs == {b"i1": [b"i1v"], b"i2": [b""], b"i3": [b""]} + + def test_from_response_descendants(self): + res = _buildresponse( + """
+
+
+ + +
+ + + + +
+ +
""" + ) + req = self.request_class.from_response(res) + fs = _qs(req) + assert set(fs) == {b"h2", b"i2", b"i1", b"i3", b"h1", b"i5", b"i4"} + + def test_from_response_xpath(self): + response = _buildresponse( + """
+ + +
+
+ + +
""" + ) + r1 = self.request_class.from_response( + response, formxpath="//form[@action='post.php']" + ) + fs = _qs(r1) + assert fs[b"one"] == [b"1"] + + r1 = self.request_class.from_response( + response, formxpath="//form/input[@name='four']" + ) + fs = _qs(r1) + assert fs[b"three"] == [b"3"] + + with pytest.raises(ValueError, match="No
element found with"): + self.request_class.from_response( + response, formxpath="//form/input[@name='abc']" + ) + + def test_from_response_unicode_xpath(self): + response = _buildresponse(b'
') + r = self.request_class.from_response( + response, formxpath="//form[@name='\u044a']" + ) + fs = _qs(r) + assert not fs + + xpath = "//form[@name='\u03b1']" + with pytest.raises(ValueError, match=re.escape(xpath)): + self.request_class.from_response(response, formxpath=xpath) + + def test_from_response_button_submit(self): + response = _buildresponse( + """
+ + + +
""", + url="http://www.example.com/this/list.html", + ) + req = self.request_class.from_response(response) + assert req.method == "POST" + assert req.headers["Content-type"] == b"application/x-www-form-urlencoded" + assert req.url == "http://www.example.com/this/post.php" + fs = _qs(req) + assert fs[b"test1"] == [b"val1"] + assert fs[b"test2"] == [b"val2"] + assert fs[b"button1"] == [b"submit1"] + + def test_from_response_button_notype(self): + response = _buildresponse( + """
+ + + +
""", + url="http://www.example.com/this/list.html", + ) + req = self.request_class.from_response(response) + assert req.method == "POST" + assert req.headers["Content-type"] == b"application/x-www-form-urlencoded" + assert req.url == "http://www.example.com/this/post.php" + fs = _qs(req) + assert fs[b"test1"] == [b"val1"] + assert fs[b"test2"] == [b"val2"] + assert fs[b"button1"] == [b"submit1"] + + def test_from_response_submit_novalue(self): + response = _buildresponse( + """
+ + + Submit +
""", + url="http://www.example.com/this/list.html", + ) + req = self.request_class.from_response(response) + assert req.method == "POST" + assert req.headers["Content-type"] == b"application/x-www-form-urlencoded" + assert req.url == "http://www.example.com/this/post.php" + fs = _qs(req) + assert fs[b"test1"] == [b"val1"] + assert fs[b"test2"] == [b"val2"] + assert fs[b"button1"] == [b""] + + def test_from_response_button_novalue(self): + response = _buildresponse( + """
+ + + +
""", + url="http://www.example.com/this/list.html", + ) + req = self.request_class.from_response(response) + assert req.method == "POST" + assert req.headers["Content-type"] == b"application/x-www-form-urlencoded" + assert req.url == "http://www.example.com/this/post.php" + fs = _qs(req) + assert fs[b"test1"] == [b"val1"] + assert fs[b"test2"] == [b"val2"] + assert fs[b"button1"] == [b""] + + def test_html_base_form_action(self): + response = _buildresponse( + """ + + + + + +
+
+ + + """, + url="http://a.com/", + ) + req = self.request_class.from_response(response) + assert req.url == "http://b.com/test_form" + + def test_spaces_in_action(self): + resp = _buildresponse('
') + req = self.request_class.from_response(resp) + assert req.url == "http://example.com/path" + + def test_from_response_css(self): + response = _buildresponse( + """
+ + +
+
+ + +
""" + ) + r1 = self.request_class.from_response( + response, formcss="form[action='post.php']" + ) + fs = _qs(r1) + assert fs[b"one"] == [b"1"] + + r1 = self.request_class.from_response(response, formcss="input[name='four']") + fs = _qs(r1) + assert fs[b"three"] == [b"3"] + + with pytest.raises(ValueError, match="No
element found with"): + self.request_class.from_response(response, formcss="input[name='abc']") + + def test_from_response_valid_form_methods(self): + form_methods = [ + [method, method] for method in self.request_class.valid_form_methods + ] + form_methods.append(["UNKNOWN", "GET"]) + + for method, expected in form_methods: + response = _buildresponse( + f'' + '' + "
" + ) + r = self.request_class.from_response(response) + assert r.method == expected + + def test_form_response_with_invalid_formdata_type_error(self): + """Test that a ValueError is raised for non-iterable and non-dict formdata input""" + response = _buildresponse( + """ +
+ +
+ """ + ) + with pytest.raises( + ValueError, match="formdata should be a dict or iterable of tuples" + ): + FormRequest.from_response(response, formdata=123) + + def test_form_response_with_custom_invalid_formdata_value_error(self): + """Test that a ValueError is raised for fault-inducing iterable formdata input""" + response = _buildresponse( + """ +
+ +
+ """ + ) + + with pytest.raises( + ValueError, match="formdata should be a dict or iterable of tuples" + ): + FormRequest.from_response(response, formdata=("a",)) + + def test_get_form_with_xpath_no_form_parent(self): + """Test that _get_from raised a ValueError when an XPath selects an element + not nested within a
and no parent is found""" + response = _buildresponse( + """ +
+

This paragraph is not inside a form.

+
+ + +
+ """ + ) + + with pytest.raises(ValueError, match="No
element found with"): + FormRequest.from_response(response, formxpath='//div[@id="outside-form"]/p') diff --git a/tests/test_http_request_json.py b/tests/test_http_request_json.py new file mode 100644 index 000000000..fcfe78365 --- /dev/null +++ b/tests/test_http_request_json.py @@ -0,0 +1,190 @@ +from __future__ import annotations + +import json +import warnings +from unittest import mock + +from scrapy.http import JsonRequest +from scrapy.utils.python import to_bytes +from tests.test_http_request import TestRequest + + +class TestJsonRequest(TestRequest): + request_class = JsonRequest + default_method = "GET" + default_headers = { + b"Content-Type": [b"application/json"], + b"Accept": [b"application/json, text/javascript, */*; q=0.01"], + } + + def test_data(self): + r1 = self.request_class(url="http://www.example.com/") + assert r1.body == b"" + + body = b"body" + r2 = self.request_class(url="http://www.example.com/", body=body) + assert r2.body == body + + data = { + "name": "value", + } + r3 = self.request_class(url="http://www.example.com/", data=data) + assert r3.body == to_bytes(json.dumps(data)) + + # empty data + r4 = self.request_class(url="http://www.example.com/", data=[]) + assert r4.body == to_bytes(json.dumps([])) + + def test_data_method(self): + # data is not passed + r1 = self.request_class(url="http://www.example.com/") + assert r1.method == "GET" + + body = b"body" + r2 = self.request_class(url="http://www.example.com/", body=body) + assert r2.method == "GET" + + data = { + "name": "value", + } + r3 = self.request_class(url="http://www.example.com/", data=data) + assert r3.method == "POST" + + # method passed explicitly + r4 = self.request_class(url="http://www.example.com/", data=data, method="GET") + assert r4.method == "GET" + + r5 = self.request_class(url="http://www.example.com/", data=[]) + assert r5.method == "POST" + + def test_body_data(self): + """passing both body and data should result a warning""" + body = b"body" + data = { + "name": "value", + } + with warnings.catch_warnings(record=True) as _warnings: + r5 = self.request_class(url="http://www.example.com/", body=body, data=data) + assert r5.body == body + assert r5.method == "GET" + assert len(_warnings) == 1 + assert "data will be ignored" in str(_warnings[0].message) + + def test_empty_body_data(self): + """passing any body value and data should result a warning""" + data = { + "name": "value", + } + with warnings.catch_warnings(record=True) as _warnings: + r6 = self.request_class(url="http://www.example.com/", body=b"", data=data) + assert r6.body == b"" + assert r6.method == "GET" + assert len(_warnings) == 1 + assert "data will be ignored" in str(_warnings[0].message) + + def test_body_none_data(self): + data = { + "name": "value", + } + with warnings.catch_warnings(record=True) as _warnings: + r7 = self.request_class(url="http://www.example.com/", body=None, data=data) + assert r7.body == to_bytes(json.dumps(data)) + assert r7.method == "POST" + assert len(_warnings) == 0 + + def test_body_data_none(self): + with warnings.catch_warnings(record=True) as _warnings: + r8 = self.request_class(url="http://www.example.com/", body=None, data=None) + assert r8.method == "GET" + assert len(_warnings) == 0 + + def test_dumps_sort_keys(self): + """Test that sort_keys=True is passed to json.dumps by default""" + data = { + "name": "value", + } + with mock.patch("json.dumps", return_value=b"") as mock_dumps: + self.request_class(url="http://www.example.com/", data=data) + kwargs = mock_dumps.call_args[1] + assert kwargs["sort_keys"] is True + + def test_dumps_kwargs(self): + """Test that dumps_kwargs are passed to json.dumps""" + data = { + "name": "value", + } + dumps_kwargs = { + "ensure_ascii": True, + "allow_nan": True, + } + with mock.patch("json.dumps", return_value=b"") as mock_dumps: + self.request_class( + url="http://www.example.com/", data=data, dumps_kwargs=dumps_kwargs + ) + kwargs = mock_dumps.call_args[1] + assert kwargs["ensure_ascii"] is True + assert kwargs["allow_nan"] is True + + def test_replace_data(self): + data1 = { + "name1": "value1", + } + data2 = { + "name2": "value2", + } + r1 = self.request_class(url="http://www.example.com/", data=data1) + r2 = r1.replace(data=data2) + assert r2.body == to_bytes(json.dumps(data2)) + + def test_replace_sort_keys(self): + """Test that replace provides sort_keys=True to json.dumps""" + data1 = { + "name1": "value1", + } + data2 = { + "name2": "value2", + } + r1 = self.request_class(url="http://www.example.com/", data=data1) + with mock.patch("json.dumps", return_value=b"") as mock_dumps: + r1.replace(data=data2) + kwargs = mock_dumps.call_args[1] + assert kwargs["sort_keys"] is True + + def test_replace_dumps_kwargs(self): + """Test that dumps_kwargs are provided to json.dumps when replace is called""" + data1 = { + "name1": "value1", + } + data2 = { + "name2": "value2", + } + dumps_kwargs = { + "ensure_ascii": True, + "allow_nan": True, + } + r1 = self.request_class( + url="http://www.example.com/", data=data1, dumps_kwargs=dumps_kwargs + ) + with mock.patch("json.dumps", return_value=b"") as mock_dumps: + r1.replace(data=data2) + kwargs = mock_dumps.call_args[1] + assert kwargs["ensure_ascii"] is True + assert kwargs["allow_nan"] is True + + def test_replacement_both_body_and_data_warns(self): + """Test that we get a warning if both body and data are passed""" + body1 = None + body2 = b"body" + data1 = { + "name1": "value1", + } + data2 = { + "name2": "value2", + } + r1 = self.request_class(url="http://www.example.com/", data=data1, body=body1) + + with warnings.catch_warnings(record=True) as _warnings: + r1.replace(data=data2, body=body2) + assert "Both body and data passed. data will be ignored" in str( + _warnings[0].message + ) diff --git a/tests/test_http_response.py b/tests/test_http_response.py index 8acdf25ec..1becf1542 100644 --- a/tests/test_http_response.py +++ b/tests/test_http_response.py @@ -1,27 +1,15 @@ -import codecs -from unittest import mock - import pytest from packaging.version import Version as parse_version from w3lib import __version__ as w3lib_version from w3lib.encoding import resolve_encoding from scrapy.exceptions import NotSupported -from scrapy.http import ( - Headers, - HtmlResponse, - Request, - Response, - TextResponse, - XmlResponse, -) +from scrapy.http import Headers, Request, Response from scrapy.link import Link -from scrapy.selector import Selector -from scrapy.utils.python import to_unicode from tests import get_testdata -class TestResponseBase: +class TestResponse: response_class = Response def test_init(self): @@ -344,676 +332,3 @@ class TestResponseBase: def _links_response_no_href(self): body = get_testdata("link_extractor", "linkextractor_no_href.html") return self.response_class("http://example.com/index", body=body) - - -class TestTextResponse(TestResponseBase): - response_class = TextResponse - - def test_replace(self): - super().test_replace() - r1 = self.response_class( - "http://www.example.com", body="hello", encoding="cp852" - ) - r2 = r1.replace(url="http://www.example.com/other") - r3 = r1.replace(url="http://www.example.com/other", encoding="latin1") - - assert isinstance(r2, self.response_class) - assert r2.url == "http://www.example.com/other" - self._assert_response_encoding(r2, "cp852") - assert r3.url == "http://www.example.com/other" - assert r3._declared_encoding() == "latin1" - - def test_unicode_url(self): - # instantiate with unicode url without encoding (should set default encoding) - resp = self.response_class("http://www.example.com/") - self._assert_response_encoding(resp, self.response_class._DEFAULT_ENCODING) - - # make sure urls are converted to str - resp = self.response_class(url="http://www.example.com/", encoding="utf-8") - assert isinstance(resp.url, str) - - resp = self.response_class( - url="http://www.example.com/price/\xa3", encoding="utf-8" - ) - assert resp.url == to_unicode(b"http://www.example.com/price/\xc2\xa3") - resp = self.response_class( - url="http://www.example.com/price/\xa3", encoding="latin-1" - ) - assert resp.url == "http://www.example.com/price/\xa3" - resp = self.response_class( - "http://www.example.com/price/\xa3", - headers={"Content-type": ["text/html; charset=utf-8"]}, - ) - assert resp.url == to_unicode(b"http://www.example.com/price/\xc2\xa3") - resp = self.response_class( - "http://www.example.com/price/\xa3", - headers={"Content-type": ["text/html; charset=iso-8859-1"]}, - ) - assert resp.url == "http://www.example.com/price/\xa3" - - def test_unicode_body(self): - unicode_string = ( - "\u043a\u0438\u0440\u0438\u043b\u043b\u0438\u0447\u0435\u0441\u043a\u0438\u0439 " - "\u0442\u0435\u043a\u0441\u0442" - ) - with pytest.raises(TypeError): - self.response_class("http://www.example.com", body="unicode body") - - original_string = unicode_string.encode("cp1251") - r1 = self.response_class( - "http://www.example.com", body=original_string, encoding="cp1251" - ) - - # check response.text - assert isinstance(r1.text, str) - assert r1.text == unicode_string - - def test_encoding(self): - r1 = self.response_class( - "http://www.example.com", - body=b"\xc2\xa3", - headers={"Content-type": ["text/html; charset=utf-8"]}, - ) - r2 = self.response_class( - "http://www.example.com", encoding="utf-8", body="\xa3" - ) - r3 = self.response_class( - "http://www.example.com", - body=b"\xa3", - headers={"Content-type": ["text/html; charset=iso-8859-1"]}, - ) - r4 = self.response_class("http://www.example.com", body=b"\xa2\xa3") - r5 = self.response_class( - "http://www.example.com", - body=b"\xc2\xa3", - headers={"Content-type": ["text/html; charset=None"]}, - ) - r6 = self.response_class( - "http://www.example.com", - body=b"\xa8D", - headers={"Content-type": ["text/html; charset=gb2312"]}, - ) - r7 = self.response_class( - "http://www.example.com", - body=b"\xa8D", - headers={"Content-type": ["text/html; charset=gbk"]}, - ) - r8 = self.response_class( - "http://www.example.com", - body=codecs.BOM_UTF8 + b"\xc2\xa3", - headers={"Content-type": ["text/html; charset=cp1251"]}, - ) - r9 = self.response_class( - "http://www.example.com", - body=b"\x80", - headers={ - "Content-type": [b"application/x-download; filename=\x80dummy.txt"] - }, - ) - - assert r1._headers_encoding() == "utf-8" - assert r2._headers_encoding() is None - assert r2._declared_encoding() == "utf-8" - self._assert_response_encoding(r2, "utf-8") - assert r3._headers_encoding() == "cp1252" - assert r3._declared_encoding() == "cp1252" - assert r4._headers_encoding() is None - assert r5._headers_encoding() is None - assert r8._headers_encoding() == "cp1251" - assert r9._headers_encoding() is None - assert r8._declared_encoding() == "utf-8" - assert r9._declared_encoding() is None - self._assert_response_encoding(r5, "utf-8") - self._assert_response_encoding(r8, "utf-8") - self._assert_response_encoding(r9, "cp1252") - assert r4._body_inferred_encoding() is not None - assert r4._body_inferred_encoding() != "ascii" - self._assert_response_values(r1, "utf-8", "\xa3") - self._assert_response_values(r2, "utf-8", "\xa3") - self._assert_response_values(r3, "iso-8859-1", "\xa3") - self._assert_response_values(r6, "gb18030", "\u2015") - self._assert_response_values(r7, "gb18030", "\u2015") - self._assert_response_values(r9, "cp1252", "€") - - # TextResponse (and subclasses) must be passed a encoding when instantiating with unicode bodies - with pytest.raises(TypeError): - self.response_class("http://www.example.com", body="\xa3") - - def test_declared_encoding_invalid(self): - """Check that unknown declared encodings are ignored""" - r = self.response_class( - "http://www.example.com", - headers={"Content-type": ["text/html; charset=UNKNOWN"]}, - body=b"\xc2\xa3", - ) - assert r._declared_encoding() is None - self._assert_response_values(r, "utf-8", "\xa3") - - def test_utf16(self): - """Test utf-16 because UnicodeDammit is known to have problems with""" - r = self.response_class( - "http://www.example.com", - body=b"\xff\xfeh\x00i\x00", - encoding="utf-16", - ) - self._assert_response_values(r, "utf-16", "hi") - - def test_invalid_utf8_encoded_body_with_valid_utf8_BOM(self): - r6 = self.response_class( - "http://www.example.com", - headers={"Content-type": ["text/html; charset=utf-8"]}, - body=b"\xef\xbb\xbfWORD\xe3\xab", - ) - assert r6.encoding == "utf-8" - assert r6.text in { - "WORD\ufffd\ufffd", # w3lib < 1.19.0 - "WORD\ufffd", # w3lib >= 1.19.0 - } - - def test_bom_is_removed_from_body(self): - # Inferring encoding from body also cache decoded body as sideeffect, - # this test tries to ensure that calling response.encoding and - # response.text in indistinct order doesn't affect final - # response.text in indistinct order doesn't affect final - # values for encoding and decoded body. - url = "http://example.com" - body = b"\xef\xbb\xbfWORD" - headers = {"Content-type": ["text/html; charset=utf-8"]} - - # Test response without content-type and BOM encoding - response = self.response_class(url, body=body) - assert response.encoding == "utf-8" - assert response.text == "WORD" - response = self.response_class(url, body=body) - assert response.text == "WORD" - assert response.encoding == "utf-8" - - # Body caching sideeffect isn't triggered when encoding is declared in - # content-type header but BOM still need to be removed from decoded - # body - response = self.response_class(url, headers=headers, body=body) - assert response.encoding == "utf-8" - assert response.text == "WORD" - response = self.response_class(url, headers=headers, body=body) - assert response.text == "WORD" - assert response.encoding == "utf-8" - - def test_replace_wrong_encoding(self): - """Test invalid chars are replaced properly""" - r = self.response_class( - "http://www.example.com", - encoding="utf-8", - body=b"PREFIX\xe3\xabSUFFIX", - ) - # XXX: Policy for replacing invalid chars may suffer minor variations - # but it should always contain the unicode replacement char ('\ufffd') - assert "\ufffd" in r.text, repr(r.text) - assert "PREFIX" in r.text, repr(r.text) - assert "SUFFIX" in r.text, repr(r.text) - - # Do not destroy html tags due to encoding bugs - r = self.response_class( - "http://example.com", - encoding="utf-8", - body=b"\xf0value", - ) - assert "value" in r.text, repr(r.text) - - # FIXME: This test should pass once we stop using BeautifulSoup's UnicodeDammit in TextResponse - # r = self.response_class("http://www.example.com", body=b'PREFIX\xe3\xabSUFFIX') - # assert '\ufffd' in r.text, repr(r.text) - - def test_selector(self): - body = b"Some page" - response = self.response_class("http://www.example.com", body=body) - - assert isinstance(response.selector, Selector) - assert response.selector.type == "html" - assert response.selector is response.selector # property is cached - assert response.selector.response is response - - assert response.selector.xpath("//title/text()").getall() == ["Some page"] - assert response.selector.css("title::text").getall() == ["Some page"] - assert response.selector.re("Some (.*)") == ["page"] - - def test_selector_shortcuts(self): - body = b"Some page" - response = self.response_class("http://www.example.com", body=body) - - assert ( - response.xpath("//title/text()").getall() - == response.selector.xpath("//title/text()").getall() - ) - assert ( - response.css("title::text").getall() - == response.selector.css("title::text").getall() - ) - - def test_selector_shortcuts_kwargs(self): - body = b'Some page

A nice paragraph.

' - response = self.response_class("http://www.example.com", body=body) - - assert ( - response.xpath( - "normalize-space(//p[@class=$pclass])", pclass="content" - ).getall() - == response.xpath('normalize-space(//p[@class="content"])').getall() - ) - assert ( - response.xpath( - "//title[count(following::p[@class=$pclass])=$pcount]/text()", - pclass="content", - pcount=1, - ).getall() - == response.xpath( - '//title[count(following::p[@class="content"])=1]/text()' - ).getall() - ) - - def test_urljoin_with_base_url(self): - """Test urljoin shortcut which also evaluates base-url through get_base_url().""" - body = b'' - joined = self.response_class("http://www.example.com", body=body).urljoin( - "/test" - ) - absolute = "https://example.net/test" - assert joined == absolute - - body = b'' - joined = self.response_class("http://www.example.com", body=body).urljoin( - "test" - ) - absolute = "http://www.example.com/test" - assert joined == absolute - - body = b'' - joined = self.response_class("http://www.example.com", body=body).urljoin( - "test" - ) - absolute = "http://www.example.com/elsewhere/test" - assert joined == absolute - - def test_follow_selector(self): - resp = self._links_response() - urls = [ - "http://example.com/sample2.html", - "http://example.com/sample3.html", - "http://example.com/sample3.html", - "http://example.com/sample3.html", - "http://example.com/sample3.html#foo", - "http://www.google.com/something", - "http://example.com/innertag.html", - ] - - # select elements - for sellist in [resp.css("a"), resp.xpath("//a")]: - for sel, url in zip(sellist, urls, strict=False): - self._assert_followed_url(sel, url, response=resp) - - # select elements - self._assert_followed_url( - Selector(text='').css("link")[0], - "http://example.com/foo", - response=resp, - ) - - # href attributes should work - for sellist in [resp.css("a::attr(href)"), resp.xpath("//a/@href")]: - for sel, url in zip(sellist, urls, strict=False): - self._assert_followed_url(sel, url, response=resp) - - # non-a elements are not supported - with pytest.raises( - ValueError, match="Only and elements are supported" - ): - resp.follow(resp.css("div")[0]) - - def test_follow_selector_list(self): - resp = self._links_response() - with pytest.raises(ValueError, match="SelectorList"): - resp.follow(resp.css("a")) - - def test_follow_selector_invalid(self): - resp = self._links_response() - with pytest.raises(ValueError, match="Unsupported"): - resp.follow(resp.xpath("count(//div)")[0]) - - def test_follow_selector_attribute(self): - resp = self._links_response() - for src in resp.css("img::attr(src)"): - self._assert_followed_url(src, "http://example.com/sample2.jpg") - - def test_follow_selector_no_href(self): - resp = self.response_class( - url="http://example.com", - body=b"click me", - ) - with pytest.raises(ValueError, match="no href"): - resp.follow(resp.css("a")[0]) - - def test_follow_whitespace_selector(self): - resp = self.response_class( - "http://example.com", - body=b"""click me""", - ) - self._assert_followed_url( - resp.css("a")[0], "http://example.com/foo", response=resp - ) - self._assert_followed_url( - resp.css("a::attr(href)")[0], - "http://example.com/foo", - response=resp, - ) - - def test_follow_encoding(self): - resp1 = self.response_class( - "http://example.com", - encoding="utf8", - body='click me'.encode(), - ) - req = self._assert_followed_url( - resp1.css("a")[0], - "http://example.com/foo?%D0%BF%D1%80%D0%B8%D0%B2%D0%B5%D1%82", - response=resp1, - ) - assert req.encoding == "utf8" - - resp2 = self.response_class( - "http://example.com", - encoding="cp1251", - body='click me'.encode( - "cp1251" - ), - ) - req = self._assert_followed_url( - resp2.css("a")[0], - "http://example.com/foo?%EF%F0%E8%E2%E5%F2", - response=resp2, - ) - assert req.encoding == "cp1251" - - def test_follow_flags(self): - res = self.response_class("http://example.com/") - fol = res.follow("http://example.com/", flags=["cached", "allowed"]) - assert fol.flags == ["cached", "allowed"] - - def test_follow_all_flags(self): - re = self.response_class("http://www.example.com/") - urls = [ - "http://www.example.com/", - "http://www.example.com/2", - "http://www.example.com/foo", - ] - fol = re.follow_all(urls, flags=["cached", "allowed"]) - for req in fol: - assert req.flags == ["cached", "allowed"] - - def test_follow_all_css(self): - expected = [ - "http://example.com/sample3.html", - "http://example.com/innertag.html", - ] - response = self._links_response() - extracted = [r.url for r in response.follow_all(css='a[href*="example.com"]')] - assert expected == extracted - - def test_follow_all_css_skip_invalid(self): - expected = [ - "http://example.com/page/1/", - "http://example.com/page/3/", - "http://example.com/page/4/", - ] - response = self._links_response_no_href() - extracted1 = [r.url for r in response.follow_all(css=".pagination a")] - assert expected == extracted1 - extracted2 = [r.url for r in response.follow_all(response.css(".pagination a"))] - assert expected == extracted2 - - def test_follow_all_xpath(self): - expected = [ - "http://example.com/sample3.html", - "http://example.com/innertag.html", - ] - response = self._links_response() - extracted = response.follow_all(xpath='//a[contains(@href, "example.com")]') - assert expected == [r.url for r in extracted] - - def test_follow_all_xpath_skip_invalid(self): - expected = [ - "http://example.com/page/1/", - "http://example.com/page/3/", - "http://example.com/page/4/", - ] - response = self._links_response_no_href() - extracted1 = [ - r.url for r in response.follow_all(xpath='//div[@id="pagination"]/a') - ] - assert expected == extracted1 - extracted2 = [ - r.url - for r in response.follow_all(response.xpath('//div[@id="pagination"]/a')) - ] - assert expected == extracted2 - - def test_follow_all_too_many_arguments(self): - response = self._links_response() - with pytest.raises( - ValueError, match="Please supply exactly one of the following arguments" - ): - response.follow_all( - css='a[href*="example.com"]', - xpath='//a[contains(@href, "example.com")]', - ) - - def test_json_response(self): - json_body = b"""{"ip": "109.187.217.200"}""" - json_response = self.response_class("http://www.example.com", body=json_body) - assert json_response.json() == {"ip": "109.187.217.200"} - - text_body = b"""text""" - text_response = self.response_class("http://www.example.com", body=text_body) - with pytest.raises( - ValueError, match=r"(Expecting value|Unexpected '<'): line 1" - ): - text_response.json() - - def test_cache_json_response(self): - json_valid_bodies = [b"""{"ip": "109.187.217.200"}""", b"""null"""] - for json_body in json_valid_bodies: - json_response = self.response_class( - "http://www.example.com", body=json_body - ) - - with mock.patch("json.loads") as mock_json: - for _ in range(2): - json_response.json() - mock_json.assert_called_once_with(json_body) - - -class TestHtmlResponse(TestTextResponse): - response_class = HtmlResponse - - def test_html_encoding(self): - body = b"""Some page - - Price: \xa3100' - """ - r1 = self.response_class("http://www.example.com", body=body) - self._assert_response_values(r1, "iso-8859-1", body) - - body = b""" - - Price: \xa3100 - """ - r2 = self.response_class("http://www.example.com", body=body) - self._assert_response_values(r2, "iso-8859-1", body) - - # for conflicting declarations headers must take precedence - body = b"""Some page - - Price: \xa3100' - """ - r3 = self.response_class( - "http://www.example.com", - body=body, - headers={"Content-type": ["text/html; charset=iso-8859-1"]}, - ) - self._assert_response_values(r3, "iso-8859-1", body) - - # make sure replace() preserves the encoding of the original response - body = b"New body \xa3" - r4 = r3.replace(body=body) - self._assert_response_values(r4, "iso-8859-1", body) - - def test_html5_meta_charset(self): - body = b"""Some pagebla bla""" - r1 = self.response_class("http://www.example.com", body=body) - self._assert_response_values(r1, "gb2312", body) - - -class TestXmlResponse(TestTextResponse): - response_class = XmlResponse - - def test_xml_encoding(self): - body = b"" - r1 = self.response_class("http://www.example.com", body=body) - self._assert_response_values(r1, self.response_class._DEFAULT_ENCODING, body) - - body = b"""""" - r2 = self.response_class("http://www.example.com", body=body) - self._assert_response_values(r2, "iso-8859-1", body) - - # make sure replace() preserves the explicit encoding passed in the __init__ method - body = b"""""" - r3 = self.response_class("http://www.example.com", body=body, encoding="utf-8") - body2 = b"New body" - r4 = r3.replace(body=body2) - self._assert_response_values(r4, "utf-8", body2) - - def test_replace_encoding(self): - # make sure replace() keeps the previous encoding unless overridden explicitly - body = b"""""" - body2 = b"""""" - r5 = self.response_class("http://www.example.com", body=body) - r6 = r5.replace(body=body2) - r7 = r5.replace(body=body2, encoding="utf-8") - self._assert_response_values(r5, "iso-8859-1", body) - self._assert_response_values(r6, "iso-8859-1", body2) - self._assert_response_values(r7, "utf-8", body2) - - def test_selector(self): - body = b'value' - response = self.response_class("http://www.example.com", body=body) - - assert isinstance(response.selector, Selector) - assert response.selector.type == "xml" - assert response.selector is response.selector # property is cached - assert response.selector.response is response - - assert response.selector.xpath("//elem/text()").getall() == ["value"] - - def test_selector_shortcuts(self): - body = b'value' - response = self.response_class("http://www.example.com", body=body) - - assert ( - response.xpath("//elem/text()").getall() - == response.selector.xpath("//elem/text()").getall() - ) - - def test_selector_shortcuts_kwargs(self): - body = b""" - - value - """ - response = self.response_class("http://www.example.com", body=body) - - assert ( - response.xpath( - "//s:elem/text()", namespaces={"s": "http://scrapy.org"} - ).getall() - == response.selector.xpath( - "//s:elem/text()", namespaces={"s": "http://scrapy.org"} - ).getall() - ) - - response.selector.register_namespace("s2", "http://scrapy.org") - assert ( - response.xpath( - "//s1:elem/text()", namespaces={"s1": "http://scrapy.org"} - ).getall() - == response.selector.xpath("//s2:elem/text()").getall() - ) - - -class CustomResponse(TextResponse): - attributes = (*TextResponse.attributes, "foo", "bar") - - def __init__(self, *args, **kwargs) -> None: - self.foo = kwargs.pop("foo", None) - self.bar = kwargs.pop("bar", None) - self.lost = kwargs.pop("lost", None) - super().__init__(*args, **kwargs) - - -class TestCustomResponse(TestTextResponse): - response_class = CustomResponse - - def test_copy(self): - super().test_copy() - r1 = self.response_class( - url="https://example.org", - status=200, - foo="foo", - bar="bar", - lost="lost", - ) - r2 = r1.copy() - assert isinstance(r2, self.response_class) - assert r1.foo == r2.foo - assert r1.bar == r2.bar - assert r1.lost == "lost" - assert r2.lost is None - - def test_replace(self): - super().test_replace() - r1 = self.response_class( - url="https://example.org", - status=200, - foo="foo", - bar="bar", - lost="lost", - ) - - r2 = r1.replace(foo="new-foo", bar="new-bar", lost="new-lost") - assert isinstance(r2, self.response_class) - assert r1.foo == "foo" - assert r1.bar == "bar" - assert r1.lost == "lost" - assert r2.foo == "new-foo" - assert r2.bar == "new-bar" - assert r2.lost == "new-lost" - - r3 = r1.replace(foo="new-foo", bar="new-bar") - assert isinstance(r3, self.response_class) - assert r1.foo == "foo" - assert r1.bar == "bar" - assert r1.lost == "lost" - assert r3.foo == "new-foo" - assert r3.bar == "new-bar" - assert r3.lost is None - - r4 = r1.replace(foo="new-foo") - assert isinstance(r4, self.response_class) - assert r1.foo == "foo" - assert r1.bar == "bar" - assert r1.lost == "lost" - assert r4.foo == "new-foo" - assert r4.bar == "bar" - assert r4.lost is None - - with pytest.raises( - TypeError, - match=r"__init__\(\) got an unexpected keyword argument 'unknown'", - ): - r1.replace(unknown="unknown") diff --git a/tests/test_http_response_text.py b/tests/test_http_response_text.py new file mode 100644 index 000000000..c16af52b9 --- /dev/null +++ b/tests/test_http_response_text.py @@ -0,0 +1,684 @@ +from __future__ import annotations + +import codecs +from unittest import mock + +import pytest + +from scrapy.http import HtmlResponse, TextResponse, XmlResponse +from scrapy.selector import Selector +from scrapy.utils.python import to_unicode +from tests.test_http_response import TestResponse + + +class TestTextResponse(TestResponse): + response_class = TextResponse + + def test_replace(self): + super().test_replace() + r1 = self.response_class( + "http://www.example.com", body="hello", encoding="cp852" + ) + r2 = r1.replace(url="http://www.example.com/other") + r3 = r1.replace(url="http://www.example.com/other", encoding="latin1") + + assert isinstance(r2, self.response_class) + assert r2.url == "http://www.example.com/other" + self._assert_response_encoding(r2, "cp852") + assert r3.url == "http://www.example.com/other" + assert r3._declared_encoding() == "latin1" + + def test_unicode_url(self): + # instantiate with unicode url without encoding (should set default encoding) + resp = self.response_class("http://www.example.com/") + self._assert_response_encoding(resp, self.response_class._DEFAULT_ENCODING) + + # make sure urls are converted to str + resp = self.response_class(url="http://www.example.com/", encoding="utf-8") + assert isinstance(resp.url, str) + + resp = self.response_class( + url="http://www.example.com/price/\xa3", encoding="utf-8" + ) + assert resp.url == to_unicode(b"http://www.example.com/price/\xc2\xa3") + resp = self.response_class( + url="http://www.example.com/price/\xa3", encoding="latin-1" + ) + assert resp.url == "http://www.example.com/price/\xa3" + resp = self.response_class( + "http://www.example.com/price/\xa3", + headers={"Content-type": ["text/html; charset=utf-8"]}, + ) + assert resp.url == to_unicode(b"http://www.example.com/price/\xc2\xa3") + resp = self.response_class( + "http://www.example.com/price/\xa3", + headers={"Content-type": ["text/html; charset=iso-8859-1"]}, + ) + assert resp.url == "http://www.example.com/price/\xa3" + + def test_unicode_body(self): + unicode_string = ( + "\u043a\u0438\u0440\u0438\u043b\u043b\u0438\u0447\u0435\u0441\u043a\u0438\u0439 " + "\u0442\u0435\u043a\u0441\u0442" + ) + with pytest.raises(TypeError): + self.response_class("http://www.example.com", body="unicode body") + + original_string = unicode_string.encode("cp1251") + r1 = self.response_class( + "http://www.example.com", body=original_string, encoding="cp1251" + ) + + # check response.text + assert isinstance(r1.text, str) + assert r1.text == unicode_string + + def test_encoding(self): + r1 = self.response_class( + "http://www.example.com", + body=b"\xc2\xa3", + headers={"Content-type": ["text/html; charset=utf-8"]}, + ) + r2 = self.response_class( + "http://www.example.com", encoding="utf-8", body="\xa3" + ) + r3 = self.response_class( + "http://www.example.com", + body=b"\xa3", + headers={"Content-type": ["text/html; charset=iso-8859-1"]}, + ) + r4 = self.response_class("http://www.example.com", body=b"\xa2\xa3") + r5 = self.response_class( + "http://www.example.com", + body=b"\xc2\xa3", + headers={"Content-type": ["text/html; charset=None"]}, + ) + r6 = self.response_class( + "http://www.example.com", + body=b"\xa8D", + headers={"Content-type": ["text/html; charset=gb2312"]}, + ) + r7 = self.response_class( + "http://www.example.com", + body=b"\xa8D", + headers={"Content-type": ["text/html; charset=gbk"]}, + ) + r8 = self.response_class( + "http://www.example.com", + body=codecs.BOM_UTF8 + b"\xc2\xa3", + headers={"Content-type": ["text/html; charset=cp1251"]}, + ) + r9 = self.response_class( + "http://www.example.com", + body=b"\x80", + headers={ + "Content-type": [b"application/x-download; filename=\x80dummy.txt"] + }, + ) + + assert r1._headers_encoding() == "utf-8" + assert r2._headers_encoding() is None + assert r2._declared_encoding() == "utf-8" + self._assert_response_encoding(r2, "utf-8") + assert r3._headers_encoding() == "cp1252" + assert r3._declared_encoding() == "cp1252" + assert r4._headers_encoding() is None + assert r5._headers_encoding() is None + assert r8._headers_encoding() == "cp1251" + assert r9._headers_encoding() is None + assert r8._declared_encoding() == "utf-8" + assert r9._declared_encoding() is None + self._assert_response_encoding(r5, "utf-8") + self._assert_response_encoding(r8, "utf-8") + self._assert_response_encoding(r9, "cp1252") + assert r4._body_inferred_encoding() is not None + assert r4._body_inferred_encoding() != "ascii" + self._assert_response_values(r1, "utf-8", "\xa3") + self._assert_response_values(r2, "utf-8", "\xa3") + self._assert_response_values(r3, "iso-8859-1", "\xa3") + self._assert_response_values(r6, "gb18030", "\u2015") + self._assert_response_values(r7, "gb18030", "\u2015") + self._assert_response_values(r9, "cp1252", "€") + + # TextResponse (and subclasses) must be passed a encoding when instantiating with unicode bodies + with pytest.raises(TypeError): + self.response_class("http://www.example.com", body="\xa3") + + def test_declared_encoding_invalid(self): + """Check that unknown declared encodings are ignored""" + r = self.response_class( + "http://www.example.com", + headers={"Content-type": ["text/html; charset=UNKNOWN"]}, + body=b"\xc2\xa3", + ) + assert r._declared_encoding() is None + self._assert_response_values(r, "utf-8", "\xa3") + + def test_utf16(self): + """Test utf-16 because UnicodeDammit is known to have problems with""" + r = self.response_class( + "http://www.example.com", + body=b"\xff\xfeh\x00i\x00", + encoding="utf-16", + ) + self._assert_response_values(r, "utf-16", "hi") + + def test_invalid_utf8_encoded_body_with_valid_utf8_BOM(self): + r6 = self.response_class( + "http://www.example.com", + headers={"Content-type": ["text/html; charset=utf-8"]}, + body=b"\xef\xbb\xbfWORD\xe3\xab", + ) + assert r6.encoding == "utf-8" + assert r6.text in { + "WORD\ufffd\ufffd", # w3lib < 1.19.0 + "WORD\ufffd", # w3lib >= 1.19.0 + } + + def test_bom_is_removed_from_body(self): + # Inferring encoding from body also cache decoded body as sideeffect, + # this test tries to ensure that calling response.encoding and + # response.text in indistinct order doesn't affect final + # response.text in indistinct order doesn't affect final + # values for encoding and decoded body. + url = "http://example.com" + body = b"\xef\xbb\xbfWORD" + headers = {"Content-type": ["text/html; charset=utf-8"]} + + # Test response without content-type and BOM encoding + response = self.response_class(url, body=body) + assert response.encoding == "utf-8" + assert response.text == "WORD" + response = self.response_class(url, body=body) + assert response.text == "WORD" + assert response.encoding == "utf-8" + + # Body caching sideeffect isn't triggered when encoding is declared in + # content-type header but BOM still need to be removed from decoded + # body + response = self.response_class(url, headers=headers, body=body) + assert response.encoding == "utf-8" + assert response.text == "WORD" + response = self.response_class(url, headers=headers, body=body) + assert response.text == "WORD" + assert response.encoding == "utf-8" + + def test_replace_wrong_encoding(self): + """Test invalid chars are replaced properly""" + r = self.response_class( + "http://www.example.com", + encoding="utf-8", + body=b"PREFIX\xe3\xabSUFFIX", + ) + # XXX: Policy for replacing invalid chars may suffer minor variations + # but it should always contain the unicode replacement char ('\ufffd') + assert "\ufffd" in r.text, repr(r.text) + assert "PREFIX" in r.text, repr(r.text) + assert "SUFFIX" in r.text, repr(r.text) + + # Do not destroy html tags due to encoding bugs + r = self.response_class( + "http://example.com", + encoding="utf-8", + body=b"\xf0value", + ) + assert "value" in r.text, repr(r.text) + + # FIXME: This test should pass once we stop using BeautifulSoup's UnicodeDammit in TextResponse + # r = self.response_class("http://www.example.com", body=b'PREFIX\xe3\xabSUFFIX') + # assert '\ufffd' in r.text, repr(r.text) + + def test_selector(self): + body = b"Some page" + response = self.response_class("http://www.example.com", body=body) + + assert isinstance(response.selector, Selector) + assert response.selector.type == "html" + assert response.selector is response.selector # property is cached + assert response.selector.response is response + + assert response.selector.xpath("//title/text()").getall() == ["Some page"] + assert response.selector.css("title::text").getall() == ["Some page"] + assert response.selector.re("Some (.*)") == ["page"] + + def test_selector_shortcuts(self): + body = b"Some page" + response = self.response_class("http://www.example.com", body=body) + + assert ( + response.xpath("//title/text()").getall() + == response.selector.xpath("//title/text()").getall() + ) + assert ( + response.css("title::text").getall() + == response.selector.css("title::text").getall() + ) + + def test_selector_shortcuts_kwargs(self): + body = b'Some page

A nice paragraph.

' + response = self.response_class("http://www.example.com", body=body) + + assert ( + response.xpath( + "normalize-space(//p[@class=$pclass])", pclass="content" + ).getall() + == response.xpath('normalize-space(//p[@class="content"])').getall() + ) + assert ( + response.xpath( + "//title[count(following::p[@class=$pclass])=$pcount]/text()", + pclass="content", + pcount=1, + ).getall() + == response.xpath( + '//title[count(following::p[@class="content"])=1]/text()' + ).getall() + ) + + def test_urljoin_with_base_url(self): + """Test urljoin shortcut which also evaluates base-url through get_base_url().""" + body = b'' + joined = self.response_class("http://www.example.com", body=body).urljoin( + "/test" + ) + absolute = "https://example.net/test" + assert joined == absolute + + body = b'' + joined = self.response_class("http://www.example.com", body=body).urljoin( + "test" + ) + absolute = "http://www.example.com/test" + assert joined == absolute + + body = b'' + joined = self.response_class("http://www.example.com", body=body).urljoin( + "test" + ) + absolute = "http://www.example.com/elsewhere/test" + assert joined == absolute + + def test_follow_selector(self): + resp = self._links_response() + urls = [ + "http://example.com/sample2.html", + "http://example.com/sample3.html", + "http://example.com/sample3.html", + "http://example.com/sample3.html", + "http://example.com/sample3.html#foo", + "http://www.google.com/something", + "http://example.com/innertag.html", + ] + + # select elements + for sellist in [resp.css("a"), resp.xpath("//a")]: + for sel, url in zip(sellist, urls, strict=False): + self._assert_followed_url(sel, url, response=resp) + + # select elements + self._assert_followed_url( + Selector(text='').css("link")[0], + "http://example.com/foo", + response=resp, + ) + + # href attributes should work + for sellist in [resp.css("a::attr(href)"), resp.xpath("//a/@href")]: + for sel, url in zip(sellist, urls, strict=False): + self._assert_followed_url(sel, url, response=resp) + + # non-a elements are not supported + with pytest.raises( + ValueError, match="Only and elements are supported" + ): + resp.follow(resp.css("div")[0]) + + def test_follow_selector_list(self): + resp = self._links_response() + with pytest.raises(ValueError, match="SelectorList"): + resp.follow(resp.css("a")) + + def test_follow_selector_invalid(self): + resp = self._links_response() + with pytest.raises(ValueError, match="Unsupported"): + resp.follow(resp.xpath("count(//div)")[0]) + + def test_follow_selector_attribute(self): + resp = self._links_response() + for src in resp.css("img::attr(src)"): + self._assert_followed_url(src, "http://example.com/sample2.jpg") + + def test_follow_selector_no_href(self): + resp = self.response_class( + url="http://example.com", + body=b"click me", + ) + with pytest.raises(ValueError, match="no href"): + resp.follow(resp.css("a")[0]) + + def test_follow_whitespace_selector(self): + resp = self.response_class( + "http://example.com", + body=b"""click me""", + ) + self._assert_followed_url( + resp.css("a")[0], "http://example.com/foo", response=resp + ) + self._assert_followed_url( + resp.css("a::attr(href)")[0], + "http://example.com/foo", + response=resp, + ) + + def test_follow_encoding(self): + resp1 = self.response_class( + "http://example.com", + encoding="utf8", + body='click me'.encode(), + ) + req = self._assert_followed_url( + resp1.css("a")[0], + "http://example.com/foo?%D0%BF%D1%80%D0%B8%D0%B2%D0%B5%D1%82", + response=resp1, + ) + assert req.encoding == "utf8" + + resp2 = self.response_class( + "http://example.com", + encoding="cp1251", + body='click me'.encode( + "cp1251" + ), + ) + req = self._assert_followed_url( + resp2.css("a")[0], + "http://example.com/foo?%EF%F0%E8%E2%E5%F2", + response=resp2, + ) + assert req.encoding == "cp1251" + + def test_follow_flags(self): + res = self.response_class("http://example.com/") + fol = res.follow("http://example.com/", flags=["cached", "allowed"]) + assert fol.flags == ["cached", "allowed"] + + def test_follow_all_flags(self): + re = self.response_class("http://www.example.com/") + urls = [ + "http://www.example.com/", + "http://www.example.com/2", + "http://www.example.com/foo", + ] + fol = re.follow_all(urls, flags=["cached", "allowed"]) + for req in fol: + assert req.flags == ["cached", "allowed"] + + def test_follow_all_css(self): + expected = [ + "http://example.com/sample3.html", + "http://example.com/innertag.html", + ] + response = self._links_response() + extracted = [r.url for r in response.follow_all(css='a[href*="example.com"]')] + assert expected == extracted + + def test_follow_all_css_skip_invalid(self): + expected = [ + "http://example.com/page/1/", + "http://example.com/page/3/", + "http://example.com/page/4/", + ] + response = self._links_response_no_href() + extracted1 = [r.url for r in response.follow_all(css=".pagination a")] + assert expected == extracted1 + extracted2 = [r.url for r in response.follow_all(response.css(".pagination a"))] + assert expected == extracted2 + + def test_follow_all_xpath(self): + expected = [ + "http://example.com/sample3.html", + "http://example.com/innertag.html", + ] + response = self._links_response() + extracted = response.follow_all(xpath='//a[contains(@href, "example.com")]') + assert expected == [r.url for r in extracted] + + def test_follow_all_xpath_skip_invalid(self): + expected = [ + "http://example.com/page/1/", + "http://example.com/page/3/", + "http://example.com/page/4/", + ] + response = self._links_response_no_href() + extracted1 = [ + r.url for r in response.follow_all(xpath='//div[@id="pagination"]/a') + ] + assert expected == extracted1 + extracted2 = [ + r.url + for r in response.follow_all(response.xpath('//div[@id="pagination"]/a')) + ] + assert expected == extracted2 + + def test_follow_all_too_many_arguments(self): + response = self._links_response() + with pytest.raises( + ValueError, match="Please supply exactly one of the following arguments" + ): + response.follow_all( + css='a[href*="example.com"]', + xpath='//a[contains(@href, "example.com")]', + ) + + def test_json_response(self): + json_body = b"""{"ip": "109.187.217.200"}""" + json_response = self.response_class("http://www.example.com", body=json_body) + assert json_response.json() == {"ip": "109.187.217.200"} + + text_body = b"""text""" + text_response = self.response_class("http://www.example.com", body=text_body) + with pytest.raises( + ValueError, match=r"(Expecting value|Unexpected '<'): line 1" + ): + text_response.json() + + def test_cache_json_response(self): + json_valid_bodies = [b"""{"ip": "109.187.217.200"}""", b"""null"""] + for json_body in json_valid_bodies: + json_response = self.response_class( + "http://www.example.com", body=json_body + ) + + with mock.patch("json.loads") as mock_json: + for _ in range(2): + json_response.json() + mock_json.assert_called_once_with(json_body) + + +class TestHtmlResponse(TestTextResponse): + response_class = HtmlResponse + + def test_html_encoding(self): + body = b"""Some page + + Price: \xa3100' + """ + r1 = self.response_class("http://www.example.com", body=body) + self._assert_response_values(r1, "iso-8859-1", body) + + body = b""" + + Price: \xa3100 + """ + r2 = self.response_class("http://www.example.com", body=body) + self._assert_response_values(r2, "iso-8859-1", body) + + # for conflicting declarations headers must take precedence + body = b"""Some page + + Price: \xa3100' + """ + r3 = self.response_class( + "http://www.example.com", + body=body, + headers={"Content-type": ["text/html; charset=iso-8859-1"]}, + ) + self._assert_response_values(r3, "iso-8859-1", body) + + # make sure replace() preserves the encoding of the original response + body = b"New body \xa3" + r4 = r3.replace(body=body) + self._assert_response_values(r4, "iso-8859-1", body) + + def test_html5_meta_charset(self): + body = b"""Some pagebla bla""" + r1 = self.response_class("http://www.example.com", body=body) + self._assert_response_values(r1, "gb2312", body) + + +class TestXmlResponse(TestTextResponse): + response_class = XmlResponse + + def test_xml_encoding(self): + body = b"" + r1 = self.response_class("http://www.example.com", body=body) + self._assert_response_values(r1, self.response_class._DEFAULT_ENCODING, body) + + body = b"""""" + r2 = self.response_class("http://www.example.com", body=body) + self._assert_response_values(r2, "iso-8859-1", body) + + # make sure replace() preserves the explicit encoding passed in the __init__ method + body = b"""""" + r3 = self.response_class("http://www.example.com", body=body, encoding="utf-8") + body2 = b"New body" + r4 = r3.replace(body=body2) + self._assert_response_values(r4, "utf-8", body2) + + def test_replace_encoding(self): + # make sure replace() keeps the previous encoding unless overridden explicitly + body = b"""""" + body2 = b"""""" + r5 = self.response_class("http://www.example.com", body=body) + r6 = r5.replace(body=body2) + r7 = r5.replace(body=body2, encoding="utf-8") + self._assert_response_values(r5, "iso-8859-1", body) + self._assert_response_values(r6, "iso-8859-1", body2) + self._assert_response_values(r7, "utf-8", body2) + + def test_selector(self): + body = b'value' + response = self.response_class("http://www.example.com", body=body) + + assert isinstance(response.selector, Selector) + assert response.selector.type == "xml" + assert response.selector is response.selector # property is cached + assert response.selector.response is response + + assert response.selector.xpath("//elem/text()").getall() == ["value"] + + def test_selector_shortcuts(self): + body = b'value' + response = self.response_class("http://www.example.com", body=body) + + assert ( + response.xpath("//elem/text()").getall() + == response.selector.xpath("//elem/text()").getall() + ) + + def test_selector_shortcuts_kwargs(self): + body = b""" + + value + """ + response = self.response_class("http://www.example.com", body=body) + + assert ( + response.xpath( + "//s:elem/text()", namespaces={"s": "http://scrapy.org"} + ).getall() + == response.selector.xpath( + "//s:elem/text()", namespaces={"s": "http://scrapy.org"} + ).getall() + ) + + response.selector.register_namespace("s2", "http://scrapy.org") + assert ( + response.xpath( + "//s1:elem/text()", namespaces={"s1": "http://scrapy.org"} + ).getall() + == response.selector.xpath("//s2:elem/text()").getall() + ) + + +class CustomResponse(TextResponse): + attributes = (*TextResponse.attributes, "foo", "bar") + + def __init__(self, *args, **kwargs) -> None: + self.foo = kwargs.pop("foo", None) + self.bar = kwargs.pop("bar", None) + self.lost = kwargs.pop("lost", None) + super().__init__(*args, **kwargs) + + +class TestCustomResponse(TestTextResponse): + response_class = CustomResponse + + def test_copy(self): + super().test_copy() + r1 = self.response_class( + url="https://example.org", + status=200, + foo="foo", + bar="bar", + lost="lost", + ) + r2 = r1.copy() + assert isinstance(r2, self.response_class) + assert r1.foo == r2.foo + assert r1.bar == r2.bar + assert r1.lost == "lost" + assert r2.lost is None + + def test_replace(self): + super().test_replace() + r1 = self.response_class( + url="https://example.org", + status=200, + foo="foo", + bar="bar", + lost="lost", + ) + + r2 = r1.replace(foo="new-foo", bar="new-bar", lost="new-lost") + assert isinstance(r2, self.response_class) + assert r1.foo == "foo" + assert r1.bar == "bar" + assert r1.lost == "lost" + assert r2.foo == "new-foo" + assert r2.bar == "new-bar" + assert r2.lost == "new-lost" + + r3 = r1.replace(foo="new-foo", bar="new-bar") + assert isinstance(r3, self.response_class) + assert r1.foo == "foo" + assert r1.bar == "bar" + assert r1.lost == "lost" + assert r3.foo == "new-foo" + assert r3.bar == "new-bar" + assert r3.lost is None + + r4 = r1.replace(foo="new-foo") + assert isinstance(r4, self.response_class) + assert r1.foo == "foo" + assert r1.bar == "bar" + assert r1.lost == "lost" + assert r4.foo == "new-foo" + assert r4.bar == "bar" + assert r4.lost is None + + with pytest.raises( + TypeError, + match=r"__init__\(\) got an unexpected keyword argument 'unknown'", + ): + r1.replace(unknown="unknown") diff --git a/tests/test_spider.py b/tests/test_spider.py index 68d4f85db..ff9aed74f 100644 --- a/tests/test_spider.py +++ b/tests/test_spider.py @@ -1,35 +1,19 @@ from __future__ import annotations -import gzip -import re -import warnings -from datetime import datetime -from io import BytesIO -from logging import ERROR, WARNING -from pathlib import Path from typing import Any from unittest import mock import pytest from testfixtures import LogCapture -from w3lib.url import safe_url_string from scrapy import signals from scrapy.crawler import Crawler -from scrapy.http import HtmlResponse, Request, Response, TextResponse, XmlResponse -from scrapy.linkextractors import LinkExtractor +from scrapy.http import Response, TextResponse, XmlResponse from scrapy.settings import Settings -from scrapy.spiders import ( - CrawlSpider, - CSVFeedSpider, - Rule, - SitemapSpider, - Spider, - XMLFeedSpider, -) +from scrapy.spiders import CSVFeedSpider, Spider, XMLFeedSpider from scrapy.spiders.init import InitSpider from scrapy.utils.test import get_crawler, get_reactor_settings -from tests import get_testdata, tests_datadir +from tests import get_testdata from tests.utils.decorators import coroutine_test, inline_callbacks_test @@ -229,629 +213,6 @@ class TestCSVFeedSpider(TestSpider): assert len(rows) == 4 -class TestCrawlSpider(TestSpider): - test_body = b"""Page title - -

Item 12

- - - """ - spider_class = CrawlSpider - - def test_rule_without_link_extractor(self): - response = HtmlResponse( - "http://example.org/somepage/index.html", body=self.test_body - ) - - class _CrawlSpider(self.spider_class): - name = "test" - allowed_domains = ["example.org"] - rules = (Rule(),) - - spider = _CrawlSpider() - output = list(spider._requests_to_follow(response)) - assert len(output) == 3 - assert all(isinstance(r, Request) for r in output) - assert [r.url for r in output] == [ - "http://example.org/somepage/item/12.html", - "http://example.org/about.html", - "http://example.org/nofollow.html", - ] - - def test_process_links(self): - response = HtmlResponse( - "http://example.org/somepage/index.html", body=self.test_body - ) - - class _CrawlSpider(self.spider_class): - name = "test" - allowed_domains = ["example.org"] - rules = (Rule(LinkExtractor(), process_links="dummy_process_links"),) - - def dummy_process_links(self, links): - return links - - spider = _CrawlSpider() - output = list(spider._requests_to_follow(response)) - assert len(output) == 3 - assert all(isinstance(r, Request) for r in output) - assert [r.url for r in output] == [ - "http://example.org/somepage/item/12.html", - "http://example.org/about.html", - "http://example.org/nofollow.html", - ] - - def test_process_links_filter(self): - response = HtmlResponse( - "http://example.org/somepage/index.html", body=self.test_body - ) - - class _CrawlSpider(self.spider_class): - name = "test" - allowed_domains = ["example.org"] - rules = (Rule(LinkExtractor(), process_links="filter_process_links"),) - _test_regex = re.compile("nofollow") - - def filter_process_links(self, links): - return [link for link in links if not self._test_regex.search(link.url)] - - spider = _CrawlSpider() - output = list(spider._requests_to_follow(response)) - assert len(output) == 2 - assert all(isinstance(r, Request) for r in output) - assert [r.url for r in output] == [ - "http://example.org/somepage/item/12.html", - "http://example.org/about.html", - ] - - def test_process_links_generator(self): - response = HtmlResponse( - "http://example.org/somepage/index.html", body=self.test_body - ) - - class _CrawlSpider(self.spider_class): - name = "test" - allowed_domains = ["example.org"] - rules = (Rule(LinkExtractor(), process_links="dummy_process_links"),) - - def dummy_process_links(self, links): - yield from links - - spider = _CrawlSpider() - output = list(spider._requests_to_follow(response)) - assert len(output) == 3 - assert all(isinstance(r, Request) for r in output) - assert [r.url for r in output] == [ - "http://example.org/somepage/item/12.html", - "http://example.org/about.html", - "http://example.org/nofollow.html", - ] - - def test_process_request(self): - response = HtmlResponse( - "http://example.org/somepage/index.html", body=self.test_body - ) - - def process_request_change_domain(request, response): - return request.replace(url=request.url.replace(".org", ".com")) - - class _CrawlSpider(self.spider_class): - name = "test" - allowed_domains = ["example.org"] - rules = ( - Rule(LinkExtractor(), process_request=process_request_change_domain), - ) - - spider = _CrawlSpider() - output = list(spider._requests_to_follow(response)) - assert len(output) == 3 - assert all(isinstance(r, Request) for r in output) - assert [r.url for r in output] == [ - "http://example.com/somepage/item/12.html", - "http://example.com/about.html", - "http://example.com/nofollow.html", - ] - - def test_process_request_with_response(self): - response = HtmlResponse( - "http://example.org/somepage/index.html", body=self.test_body - ) - - def process_request_meta_response_class(request, response): - request.meta["response_class"] = response.__class__.__name__ - return request - - class _CrawlSpider(self.spider_class): - name = "test" - allowed_domains = ["example.org"] - rules = ( - Rule( - LinkExtractor(), process_request=process_request_meta_response_class - ), - ) - - spider = _CrawlSpider() - output = list(spider._requests_to_follow(response)) - assert len(output) == 3 - assert all(isinstance(r, Request) for r in output) - assert [r.url for r in output] == [ - "http://example.org/somepage/item/12.html", - "http://example.org/about.html", - "http://example.org/nofollow.html", - ] - assert [r.meta["response_class"] for r in output] == [ - "HtmlResponse", - "HtmlResponse", - "HtmlResponse", - ] - - def test_process_request_instance_method(self): - response = HtmlResponse( - "http://example.org/somepage/index.html", body=self.test_body - ) - - class _CrawlSpider(self.spider_class): - name = "test" - allowed_domains = ["example.org"] - rules = (Rule(LinkExtractor(), process_request="process_request_upper"),) - - def process_request_upper(self, request, response): - return request.replace(url=request.url.upper()) - - spider = _CrawlSpider() - output = list(spider._requests_to_follow(response)) - assert len(output) == 3 - assert all(isinstance(r, Request) for r in output) - assert [r.url for r in output] == [ - safe_url_string("http://EXAMPLE.ORG/SOMEPAGE/ITEM/12.HTML"), - safe_url_string("http://EXAMPLE.ORG/ABOUT.HTML"), - safe_url_string("http://EXAMPLE.ORG/NOFOLLOW.HTML"), - ] - - def test_process_request_instance_method_with_response(self): - response = HtmlResponse( - "http://example.org/somepage/index.html", body=self.test_body - ) - - class _CrawlSpider(self.spider_class): - name = "test" - allowed_domains = ["example.org"] - rules = ( - Rule( - LinkExtractor(), - process_request="process_request_meta_response_class", - ), - ) - - def process_request_meta_response_class(self, request, response): - request.meta["response_class"] = response.__class__.__name__ - return request - - spider = _CrawlSpider() - output = list(spider._requests_to_follow(response)) - assert len(output) == 3 - assert all(isinstance(r, Request) for r in output) - assert [r.url for r in output] == [ - "http://example.org/somepage/item/12.html", - "http://example.org/about.html", - "http://example.org/nofollow.html", - ] - assert [r.meta["response_class"] for r in output] == [ - "HtmlResponse", - "HtmlResponse", - "HtmlResponse", - ] - - def test_follow_links_attribute_population(self): - crawler = get_crawler() - spider = self.spider_class.from_crawler(crawler, "example.com") - assert hasattr(spider, "_follow_links") - assert spider._follow_links - - settings_dict = {"CRAWLSPIDER_FOLLOW_LINKS": False} - crawler = get_crawler(settings_dict=settings_dict) - spider = self.spider_class.from_crawler(crawler, "example.com") - assert hasattr(spider, "_follow_links") - assert not spider._follow_links - - @inline_callbacks_test - def test_start_url(self): - class TestSpider(self.spider_class): - name = "test" - start_url = "https://www.example.com" - - crawler = get_crawler(TestSpider) - with LogCapture("scrapy.core.engine", propagate=False, level=ERROR) as log: - yield crawler.crawl() - assert "Error while reading start items and requests" in str(log) - assert "did you miss an 's'?" in str(log) - - def test_parse_response_use(self): - class _CrawlSpider(CrawlSpider): - name = "test" - start_urls = "https://www.example.com" - _follow_links = False - - with warnings.catch_warnings(record=True) as w: - spider = _CrawlSpider() - assert len(w) == 0 - spider._parse_response( - TextResponse(spider.start_urls, body=b""), None, None - ) - assert len(w) == 1 - - def test_parse_response_override(self): - class _CrawlSpider(CrawlSpider): - def _parse_response(self, response, callback, cb_kwargs, follow=True): - pass - - name = "test" - start_urls = "https://www.example.com" - _follow_links = False - - with warnings.catch_warnings(record=True) as w: - assert len(w) == 0 - spider = _CrawlSpider() - assert len(w) == 1 - spider._parse_response( - TextResponse(spider.start_urls, body=b""), None, None - ) - assert len(w) == 1 - - def test_parse_with_rules(self): - class _CrawlSpider(CrawlSpider): - name = "test" - start_urls = "https://www.example.com" - - with warnings.catch_warnings(record=True) as w: - spider = _CrawlSpider() - spider.parse_with_rules( - TextResponse(spider.start_urls, body=b""), None, None - ) - assert len(w) == 0 - - -class TestSitemapSpider(TestSpider): - spider_class = SitemapSpider - - BODY = b"SITEMAP" - f = BytesIO() - g = gzip.GzipFile(fileobj=f, mode="w+b") - g.write(BODY) - g.close() - GZBODY = f.getvalue() - - def assertSitemapBody(self, response: Response, body: bytes | None) -> None: - crawler = get_crawler() - spider = self.spider_class.from_crawler(crawler, "example.com") - assert spider._get_sitemap_body(response) == body - - def test_get_sitemap_body(self): - r = XmlResponse(url="http://www.example.com/", body=self.BODY) - self.assertSitemapBody(r, self.BODY) - - r = HtmlResponse(url="http://www.example.com/", body=self.BODY) - self.assertSitemapBody(r, None) - - r = Response(url="http://www.example.com/favicon.ico", body=self.BODY) - self.assertSitemapBody(r, None) - - def test_get_sitemap_body_gzip_headers(self): - r = Response( - url="http://www.example.com/sitemap", - body=self.GZBODY, - headers={"content-type": "application/gzip"}, - request=Request("http://www.example.com/sitemap"), - ) - self.assertSitemapBody(r, self.BODY) - - def test_get_sitemap_body_xml_url(self): - r = TextResponse(url="http://www.example.com/sitemap.xml", body=self.BODY) - self.assertSitemapBody(r, self.BODY) - - def test_get_sitemap_body_xml_url_compressed(self): - r = Response( - url="http://www.example.com/sitemap.xml.gz", - body=self.GZBODY, - request=Request("http://www.example.com/sitemap"), - ) - self.assertSitemapBody(r, self.BODY) - - # .xml.gz but body decoded by HttpCompression middleware already - r = Response(url="http://www.example.com/sitemap.xml.gz", body=self.BODY) - self.assertSitemapBody(r, self.BODY) - - def test_get_sitemap_urls_from_robotstxt(self): - robots = b"""# Sitemap files -Sitemap: http://example.com/sitemap.xml -Sitemap: http://example.com/sitemap-product-index.xml -Sitemap: HTTP://example.com/sitemap-uppercase.xml -Sitemap: /sitemap-relative-url.xml -""" - - r = TextResponse(url="http://www.example.com/robots.txt", body=robots) - spider = self.spider_class("example.com") - assert [req.url for req in spider._parse_sitemap(r)] == [ - "http://example.com/sitemap.xml", - "http://example.com/sitemap-product-index.xml", - "http://example.com/sitemap-uppercase.xml", - "http://www.example.com/sitemap-relative-url.xml", - ] - - def test_alternate_url_locs(self): - sitemap = b""" - - - http://www.example.com/english/ - - - - - - """ - r = TextResponse(url="http://www.example.com/sitemap.xml", body=sitemap) - spider = self.spider_class("example.com") - assert [req.url for req in spider._parse_sitemap(r)] == [ - "http://www.example.com/english/" - ] - - spider.sitemap_alternate_links = True - assert [req.url for req in spider._parse_sitemap(r)] == [ - "http://www.example.com/english/", - "http://www.example.com/deutsch/", - "http://www.example.com/schweiz-deutsch/", - "http://www.example.com/italiano/", - ] - - def test_sitemap_filter(self): - sitemap = b""" - - - http://www.example.com/english/ - 2010-01-01 - - - http://www.example.com/portuguese/ - 2005-01-01 - - """ - - class FilteredSitemapSpider(self.spider_class): - def sitemap_filter(self, entries): - for entry in entries: - date_time = datetime.strptime(entry["lastmod"], "%Y-%m-%d") - if date_time.year > 2008: - yield entry - - r = TextResponse(url="http://www.example.com/sitemap.xml", body=sitemap) - spider = self.spider_class("example.com") - assert [req.url for req in spider._parse_sitemap(r)] == [ - "http://www.example.com/english/", - "http://www.example.com/portuguese/", - ] - - spider = FilteredSitemapSpider("example.com") - assert [req.url for req in spider._parse_sitemap(r)] == [ - "http://www.example.com/english/" - ] - - def test_sitemap_filter_with_alternate_links(self): - sitemap = b""" - - - http://www.example.com/english/article_1/ - 2010-01-01 - - - - http://www.example.com/english/article_2/ - 2015-01-01 - - """ - - class FilteredSitemapSpider(self.spider_class): - def sitemap_filter(self, entries): - for entry in entries: - alternate_links = entry.get("alternate", ()) - for link in alternate_links: - if "/deutsch/" in link: - entry["loc"] = link - yield entry - - r = TextResponse(url="http://www.example.com/sitemap.xml", body=sitemap) - spider = self.spider_class("example.com") - assert [req.url for req in spider._parse_sitemap(r)] == [ - "http://www.example.com/english/article_1/", - "http://www.example.com/english/article_2/", - ] - - spider = FilteredSitemapSpider("example.com") - assert [req.url for req in spider._parse_sitemap(r)] == [ - "http://www.example.com/deutsch/article_1/" - ] - - def test_sitemapindex_filter(self): - sitemap = b""" - - - http://www.example.com/sitemap1.xml - 2004-01-01T20:00:00+00:00 - - - http://www.example.com/sitemap2.xml - 2005-01-01 - - """ - - class FilteredSitemapSpider(self.spider_class): - def sitemap_filter(self, entries): - for entry in entries: - date_time = datetime.strptime( - entry["lastmod"].split("T")[0], "%Y-%m-%d" - ) - if date_time.year > 2004: - yield entry - - r = TextResponse(url="http://www.example.com/sitemap.xml", body=sitemap) - spider = self.spider_class("example.com") - assert [req.url for req in spider._parse_sitemap(r)] == [ - "http://www.example.com/sitemap1.xml", - "http://www.example.com/sitemap2.xml", - ] - - spider = FilteredSitemapSpider("example.com") - assert [req.url for req in spider._parse_sitemap(r)] == [ - "http://www.example.com/sitemap2.xml" - ] - - def test_compression_bomb_setting(self): - settings = {"DOWNLOAD_MAXSIZE": 10_000_000} - crawler = get_crawler(settings_dict=settings) - spider = self.spider_class.from_crawler(crawler, "example.com") - body_path = Path(tests_datadir, "compressed", "bomb-gzip.bin") - body = body_path.read_bytes() - request = Request(url="https://example.com") - response = Response(url="https://example.com", body=body, request=request) - assert spider._get_sitemap_body(response) is None - - @pytest.mark.filterwarnings("ignore::scrapy.exceptions.ScrapyDeprecationWarning") - def test_compression_bomb_spider_attr(self): - class DownloadMaxSizeSpider(self.spider_class): - download_maxsize = 10_000_000 - - crawler = get_crawler() - spider = DownloadMaxSizeSpider.from_crawler(crawler, "example.com") - body_path = Path(tests_datadir, "compressed", "bomb-gzip.bin") - body = body_path.read_bytes() - request = Request(url="https://example.com") - response = Response(url="https://example.com", body=body, request=request) - assert spider._get_sitemap_body(response) is None - - def test_compression_bomb_request_meta(self): - crawler = get_crawler() - spider = self.spider_class.from_crawler(crawler, "example.com") - body_path = Path(tests_datadir, "compressed", "bomb-gzip.bin") - body = body_path.read_bytes() - request = Request( - url="https://example.com", meta={"download_maxsize": 10_000_000} - ) - response = Response(url="https://example.com", body=body, request=request) - assert spider._get_sitemap_body(response) is None - - def test_download_warnsize_setting(self): - settings = {"DOWNLOAD_WARNSIZE": 10_000_000} - crawler = get_crawler(settings_dict=settings) - spider = self.spider_class.from_crawler(crawler, "example.com") - body_path = Path(tests_datadir, "compressed", "bomb-gzip.bin") - body = body_path.read_bytes() - request = Request(url="https://example.com") - response = Response(url="https://example.com", body=body, request=request) - with LogCapture( - "scrapy.spiders.sitemap", propagate=False, level=WARNING - ) as log: - spider._get_sitemap_body(response) - log.check( - ( - "scrapy.spiders.sitemap", - "WARNING", - ( - "<200 https://example.com> body size after decompression " - "(11511612 B) is larger than the download warning size " - "(10000000 B)." - ), - ), - ) - - @pytest.mark.filterwarnings("ignore::scrapy.exceptions.ScrapyDeprecationWarning") - def test_download_warnsize_spider_attr(self): - class DownloadWarnSizeSpider(self.spider_class): - download_warnsize = 10_000_000 - - crawler = get_crawler() - spider = DownloadWarnSizeSpider.from_crawler(crawler, "example.com") - body_path = Path(tests_datadir, "compressed", "bomb-gzip.bin") - body = body_path.read_bytes() - request = Request( - url="https://example.com", meta={"download_warnsize": 10_000_000} - ) - response = Response(url="https://example.com", body=body, request=request) - with LogCapture( - "scrapy.spiders.sitemap", propagate=False, level=WARNING - ) as log: - spider._get_sitemap_body(response) - log.check( - ( - "scrapy.spiders.sitemap", - "WARNING", - ( - "<200 https://example.com> body size after decompression " - "(11511612 B) is larger than the download warning size " - "(10000000 B)." - ), - ), - ) - - def test_download_warnsize_request_meta(self): - crawler = get_crawler() - spider = self.spider_class.from_crawler(crawler, "example.com") - body_path = Path(tests_datadir, "compressed", "bomb-gzip.bin") - body = body_path.read_bytes() - request = Request( - url="https://example.com", meta={"download_warnsize": 10_000_000} - ) - response = Response(url="https://example.com", body=body, request=request) - with LogCapture( - "scrapy.spiders.sitemap", propagate=False, level=WARNING - ) as log: - spider._get_sitemap_body(response) - log.check( - ( - "scrapy.spiders.sitemap", - "WARNING", - ( - "<200 https://example.com> body size after decompression " - "(11511612 B) is larger than the download warning size " - "(10000000 B)." - ), - ), - ) - - @coroutine_test - async def test_sitemap_urls(self): - class TestSpider(self.spider_class): - name = "test" - sitemap_urls = ["https://toscrape.com/sitemap.xml"] - - crawler = get_crawler(TestSpider) - spider = TestSpider.from_crawler(crawler) - with warnings.catch_warnings(): - warnings.simplefilter("error") - requests = [request async for request in spider.start()] - - assert len(requests) == 1 - request = requests[0] - assert request.url == "https://toscrape.com/sitemap.xml" - assert request.dont_filter is False - assert request.callback == spider._parse_sitemap - - -class TestDeprecation: - def test_crawl_spider(self): - assert issubclass(CrawlSpider, Spider) - assert isinstance(CrawlSpider(name="foo"), Spider) - - class TestNoParseMethodSpider: spider_class = Spider diff --git a/tests/test_spider_crawl.py b/tests/test_spider_crawl.py new file mode 100644 index 000000000..05d77912f --- /dev/null +++ b/tests/test_spider_crawl.py @@ -0,0 +1,307 @@ +from __future__ import annotations + +import re +import warnings +from logging import ERROR + +from testfixtures import LogCapture +from w3lib.url import safe_url_string + +from scrapy.http import HtmlResponse, Request, TextResponse +from scrapy.linkextractors import LinkExtractor +from scrapy.spiders import CrawlSpider, Rule, Spider +from scrapy.utils.test import get_crawler +from tests.test_spider import TestSpider +from tests.utils.decorators import inline_callbacks_test + + +class TestCrawlSpider(TestSpider): + test_body = b"""Page title + +

Item 12

+ + + """ + spider_class = CrawlSpider + + def test_rule_without_link_extractor(self): + response = HtmlResponse( + "http://example.org/somepage/index.html", body=self.test_body + ) + + class _CrawlSpider(self.spider_class): + name = "test" + allowed_domains = ["example.org"] + rules = (Rule(),) + + spider = _CrawlSpider() + output = list(spider._requests_to_follow(response)) + assert len(output) == 3 + assert all(isinstance(r, Request) for r in output) + assert [r.url for r in output] == [ + "http://example.org/somepage/item/12.html", + "http://example.org/about.html", + "http://example.org/nofollow.html", + ] + + def test_process_links(self): + response = HtmlResponse( + "http://example.org/somepage/index.html", body=self.test_body + ) + + class _CrawlSpider(self.spider_class): + name = "test" + allowed_domains = ["example.org"] + rules = (Rule(LinkExtractor(), process_links="dummy_process_links"),) + + def dummy_process_links(self, links): + return links + + spider = _CrawlSpider() + output = list(spider._requests_to_follow(response)) + assert len(output) == 3 + assert all(isinstance(r, Request) for r in output) + assert [r.url for r in output] == [ + "http://example.org/somepage/item/12.html", + "http://example.org/about.html", + "http://example.org/nofollow.html", + ] + + def test_process_links_filter(self): + response = HtmlResponse( + "http://example.org/somepage/index.html", body=self.test_body + ) + + class _CrawlSpider(self.spider_class): + name = "test" + allowed_domains = ["example.org"] + rules = (Rule(LinkExtractor(), process_links="filter_process_links"),) + _test_regex = re.compile("nofollow") + + def filter_process_links(self, links): + return [link for link in links if not self._test_regex.search(link.url)] + + spider = _CrawlSpider() + output = list(spider._requests_to_follow(response)) + assert len(output) == 2 + assert all(isinstance(r, Request) for r in output) + assert [r.url for r in output] == [ + "http://example.org/somepage/item/12.html", + "http://example.org/about.html", + ] + + def test_process_links_generator(self): + response = HtmlResponse( + "http://example.org/somepage/index.html", body=self.test_body + ) + + class _CrawlSpider(self.spider_class): + name = "test" + allowed_domains = ["example.org"] + rules = (Rule(LinkExtractor(), process_links="dummy_process_links"),) + + def dummy_process_links(self, links): + yield from links + + spider = _CrawlSpider() + output = list(spider._requests_to_follow(response)) + assert len(output) == 3 + assert all(isinstance(r, Request) for r in output) + assert [r.url for r in output] == [ + "http://example.org/somepage/item/12.html", + "http://example.org/about.html", + "http://example.org/nofollow.html", + ] + + def test_process_request(self): + response = HtmlResponse( + "http://example.org/somepage/index.html", body=self.test_body + ) + + def process_request_change_domain(request, response): + return request.replace(url=request.url.replace(".org", ".com")) + + class _CrawlSpider(self.spider_class): + name = "test" + allowed_domains = ["example.org"] + rules = ( + Rule(LinkExtractor(), process_request=process_request_change_domain), + ) + + spider = _CrawlSpider() + output = list(spider._requests_to_follow(response)) + assert len(output) == 3 + assert all(isinstance(r, Request) for r in output) + assert [r.url for r in output] == [ + "http://example.com/somepage/item/12.html", + "http://example.com/about.html", + "http://example.com/nofollow.html", + ] + + def test_process_request_with_response(self): + response = HtmlResponse( + "http://example.org/somepage/index.html", body=self.test_body + ) + + def process_request_meta_response_class(request, response): + request.meta["response_class"] = response.__class__.__name__ + return request + + class _CrawlSpider(self.spider_class): + name = "test" + allowed_domains = ["example.org"] + rules = ( + Rule( + LinkExtractor(), process_request=process_request_meta_response_class + ), + ) + + spider = _CrawlSpider() + output = list(spider._requests_to_follow(response)) + assert len(output) == 3 + assert all(isinstance(r, Request) for r in output) + assert [r.url for r in output] == [ + "http://example.org/somepage/item/12.html", + "http://example.org/about.html", + "http://example.org/nofollow.html", + ] + assert [r.meta["response_class"] for r in output] == [ + "HtmlResponse", + "HtmlResponse", + "HtmlResponse", + ] + + def test_process_request_instance_method(self): + response = HtmlResponse( + "http://example.org/somepage/index.html", body=self.test_body + ) + + class _CrawlSpider(self.spider_class): + name = "test" + allowed_domains = ["example.org"] + rules = (Rule(LinkExtractor(), process_request="process_request_upper"),) + + def process_request_upper(self, request, response): + return request.replace(url=request.url.upper()) + + spider = _CrawlSpider() + output = list(spider._requests_to_follow(response)) + assert len(output) == 3 + assert all(isinstance(r, Request) for r in output) + assert [r.url for r in output] == [ + safe_url_string("http://EXAMPLE.ORG/SOMEPAGE/ITEM/12.HTML"), + safe_url_string("http://EXAMPLE.ORG/ABOUT.HTML"), + safe_url_string("http://EXAMPLE.ORG/NOFOLLOW.HTML"), + ] + + def test_process_request_instance_method_with_response(self): + response = HtmlResponse( + "http://example.org/somepage/index.html", body=self.test_body + ) + + class _CrawlSpider(self.spider_class): + name = "test" + allowed_domains = ["example.org"] + rules = ( + Rule( + LinkExtractor(), + process_request="process_request_meta_response_class", + ), + ) + + def process_request_meta_response_class(self, request, response): + request.meta["response_class"] = response.__class__.__name__ + return request + + spider = _CrawlSpider() + output = list(spider._requests_to_follow(response)) + assert len(output) == 3 + assert all(isinstance(r, Request) for r in output) + assert [r.url for r in output] == [ + "http://example.org/somepage/item/12.html", + "http://example.org/about.html", + "http://example.org/nofollow.html", + ] + assert [r.meta["response_class"] for r in output] == [ + "HtmlResponse", + "HtmlResponse", + "HtmlResponse", + ] + + def test_follow_links_attribute_population(self): + crawler = get_crawler() + spider = self.spider_class.from_crawler(crawler, "example.com") + assert hasattr(spider, "_follow_links") + assert spider._follow_links + + settings_dict = {"CRAWLSPIDER_FOLLOW_LINKS": False} + crawler = get_crawler(settings_dict=settings_dict) + spider = self.spider_class.from_crawler(crawler, "example.com") + assert hasattr(spider, "_follow_links") + assert not spider._follow_links + + @inline_callbacks_test + def test_start_url(self): + class TestSpider(self.spider_class): + name = "test" + start_url = "https://www.example.com" + + crawler = get_crawler(TestSpider) + with LogCapture("scrapy.core.engine", propagate=False, level=ERROR) as log: + yield crawler.crawl() + assert "Error while reading start items and requests" in str(log) + assert "did you miss an 's'?" in str(log) + + def test_parse_response_use(self): + class _CrawlSpider(CrawlSpider): + name = "test" + start_urls = "https://www.example.com" + _follow_links = False + + with warnings.catch_warnings(record=True) as w: + spider = _CrawlSpider() + assert len(w) == 0 + spider._parse_response( + TextResponse(spider.start_urls, body=b""), None, None + ) + assert len(w) == 1 + + def test_parse_response_override(self): + class _CrawlSpider(CrawlSpider): + def _parse_response(self, response, callback, cb_kwargs, follow=True): + pass + + name = "test" + start_urls = "https://www.example.com" + _follow_links = False + + with warnings.catch_warnings(record=True) as w: + assert len(w) == 0 + spider = _CrawlSpider() + assert len(w) == 1 + spider._parse_response( + TextResponse(spider.start_urls, body=b""), None, None + ) + assert len(w) == 1 + + def test_parse_with_rules(self): + class _CrawlSpider(CrawlSpider): + name = "test" + start_urls = "https://www.example.com" + + with warnings.catch_warnings(record=True) as w: + spider = _CrawlSpider() + spider.parse_with_rules( + TextResponse(spider.start_urls, body=b""), None, None + ) + assert len(w) == 0 + + +class TestDeprecation: + def test_crawl_spider(self): + assert issubclass(CrawlSpider, Spider) + assert isinstance(CrawlSpider(name="foo"), Spider) diff --git a/tests/test_spider_sitemap.py b/tests/test_spider_sitemap.py new file mode 100644 index 000000000..d2664b19e --- /dev/null +++ b/tests/test_spider_sitemap.py @@ -0,0 +1,349 @@ +from __future__ import annotations + +import gzip +import warnings +from datetime import datetime +from io import BytesIO +from logging import WARNING +from pathlib import Path + +import pytest +from testfixtures import LogCapture + +from scrapy.http import HtmlResponse, Request, Response, TextResponse, XmlResponse +from scrapy.spiders import SitemapSpider +from scrapy.utils.test import get_crawler +from tests import tests_datadir +from tests.test_spider import TestSpider +from tests.utils.decorators import coroutine_test + + +class TestSitemapSpider(TestSpider): + spider_class = SitemapSpider + + BODY = b"SITEMAP" + f = BytesIO() + g = gzip.GzipFile(fileobj=f, mode="w+b") + g.write(BODY) + g.close() + GZBODY = f.getvalue() + + def assertSitemapBody(self, response: Response, body: bytes | None) -> None: + crawler = get_crawler() + spider = self.spider_class.from_crawler(crawler, "example.com") + assert spider._get_sitemap_body(response) == body + + def test_get_sitemap_body(self): + r = XmlResponse(url="http://www.example.com/", body=self.BODY) + self.assertSitemapBody(r, self.BODY) + + r = HtmlResponse(url="http://www.example.com/", body=self.BODY) + self.assertSitemapBody(r, None) + + r = Response(url="http://www.example.com/favicon.ico", body=self.BODY) + self.assertSitemapBody(r, None) + + def test_get_sitemap_body_gzip_headers(self): + r = Response( + url="http://www.example.com/sitemap", + body=self.GZBODY, + headers={"content-type": "application/gzip"}, + request=Request("http://www.example.com/sitemap"), + ) + self.assertSitemapBody(r, self.BODY) + + def test_get_sitemap_body_xml_url(self): + r = TextResponse(url="http://www.example.com/sitemap.xml", body=self.BODY) + self.assertSitemapBody(r, self.BODY) + + def test_get_sitemap_body_xml_url_compressed(self): + r = Response( + url="http://www.example.com/sitemap.xml.gz", + body=self.GZBODY, + request=Request("http://www.example.com/sitemap"), + ) + self.assertSitemapBody(r, self.BODY) + + # .xml.gz but body decoded by HttpCompression middleware already + r = Response(url="http://www.example.com/sitemap.xml.gz", body=self.BODY) + self.assertSitemapBody(r, self.BODY) + + def test_get_sitemap_urls_from_robotstxt(self): + robots = b"""# Sitemap files +Sitemap: http://example.com/sitemap.xml +Sitemap: http://example.com/sitemap-product-index.xml +Sitemap: HTTP://example.com/sitemap-uppercase.xml +Sitemap: /sitemap-relative-url.xml +""" + + r = TextResponse(url="http://www.example.com/robots.txt", body=robots) + spider = self.spider_class("example.com") + assert [req.url for req in spider._parse_sitemap(r)] == [ + "http://example.com/sitemap.xml", + "http://example.com/sitemap-product-index.xml", + "http://example.com/sitemap-uppercase.xml", + "http://www.example.com/sitemap-relative-url.xml", + ] + + def test_alternate_url_locs(self): + sitemap = b""" + + + http://www.example.com/english/ + + + + + + """ + r = TextResponse(url="http://www.example.com/sitemap.xml", body=sitemap) + spider = self.spider_class("example.com") + assert [req.url for req in spider._parse_sitemap(r)] == [ + "http://www.example.com/english/" + ] + + spider.sitemap_alternate_links = True + assert [req.url for req in spider._parse_sitemap(r)] == [ + "http://www.example.com/english/", + "http://www.example.com/deutsch/", + "http://www.example.com/schweiz-deutsch/", + "http://www.example.com/italiano/", + ] + + def test_sitemap_filter(self): + sitemap = b""" + + + http://www.example.com/english/ + 2010-01-01 + + + http://www.example.com/portuguese/ + 2005-01-01 + + """ + + class FilteredSitemapSpider(self.spider_class): + def sitemap_filter(self, entries): + for entry in entries: + date_time = datetime.strptime(entry["lastmod"], "%Y-%m-%d") + if date_time.year > 2008: + yield entry + + r = TextResponse(url="http://www.example.com/sitemap.xml", body=sitemap) + spider = self.spider_class("example.com") + assert [req.url for req in spider._parse_sitemap(r)] == [ + "http://www.example.com/english/", + "http://www.example.com/portuguese/", + ] + + spider = FilteredSitemapSpider("example.com") + assert [req.url for req in spider._parse_sitemap(r)] == [ + "http://www.example.com/english/" + ] + + def test_sitemap_filter_with_alternate_links(self): + sitemap = b""" + + + http://www.example.com/english/article_1/ + 2010-01-01 + + + + http://www.example.com/english/article_2/ + 2015-01-01 + + """ + + class FilteredSitemapSpider(self.spider_class): + def sitemap_filter(self, entries): + for entry in entries: + alternate_links = entry.get("alternate", ()) + for link in alternate_links: + if "/deutsch/" in link: + entry["loc"] = link + yield entry + + r = TextResponse(url="http://www.example.com/sitemap.xml", body=sitemap) + spider = self.spider_class("example.com") + assert [req.url for req in spider._parse_sitemap(r)] == [ + "http://www.example.com/english/article_1/", + "http://www.example.com/english/article_2/", + ] + + spider = FilteredSitemapSpider("example.com") + assert [req.url for req in spider._parse_sitemap(r)] == [ + "http://www.example.com/deutsch/article_1/" + ] + + def test_sitemapindex_filter(self): + sitemap = b""" + + + http://www.example.com/sitemap1.xml + 2004-01-01T20:00:00+00:00 + + + http://www.example.com/sitemap2.xml + 2005-01-01 + + """ + + class FilteredSitemapSpider(self.spider_class): + def sitemap_filter(self, entries): + for entry in entries: + date_time = datetime.strptime( + entry["lastmod"].split("T")[0], "%Y-%m-%d" + ) + if date_time.year > 2004: + yield entry + + r = TextResponse(url="http://www.example.com/sitemap.xml", body=sitemap) + spider = self.spider_class("example.com") + assert [req.url for req in spider._parse_sitemap(r)] == [ + "http://www.example.com/sitemap1.xml", + "http://www.example.com/sitemap2.xml", + ] + + spider = FilteredSitemapSpider("example.com") + assert [req.url for req in spider._parse_sitemap(r)] == [ + "http://www.example.com/sitemap2.xml" + ] + + def test_compression_bomb_setting(self): + settings = {"DOWNLOAD_MAXSIZE": 10_000_000} + crawler = get_crawler(settings_dict=settings) + spider = self.spider_class.from_crawler(crawler, "example.com") + body_path = Path(tests_datadir, "compressed", "bomb-gzip.bin") + body = body_path.read_bytes() + request = Request(url="https://example.com") + response = Response(url="https://example.com", body=body, request=request) + assert spider._get_sitemap_body(response) is None + + @pytest.mark.filterwarnings("ignore::scrapy.exceptions.ScrapyDeprecationWarning") + def test_compression_bomb_spider_attr(self): + class DownloadMaxSizeSpider(self.spider_class): + download_maxsize = 10_000_000 + + crawler = get_crawler() + spider = DownloadMaxSizeSpider.from_crawler(crawler, "example.com") + body_path = Path(tests_datadir, "compressed", "bomb-gzip.bin") + body = body_path.read_bytes() + request = Request(url="https://example.com") + response = Response(url="https://example.com", body=body, request=request) + assert spider._get_sitemap_body(response) is None + + def test_compression_bomb_request_meta(self): + crawler = get_crawler() + spider = self.spider_class.from_crawler(crawler, "example.com") + body_path = Path(tests_datadir, "compressed", "bomb-gzip.bin") + body = body_path.read_bytes() + request = Request( + url="https://example.com", meta={"download_maxsize": 10_000_000} + ) + response = Response(url="https://example.com", body=body, request=request) + assert spider._get_sitemap_body(response) is None + + def test_download_warnsize_setting(self): + settings = {"DOWNLOAD_WARNSIZE": 10_000_000} + crawler = get_crawler(settings_dict=settings) + spider = self.spider_class.from_crawler(crawler, "example.com") + body_path = Path(tests_datadir, "compressed", "bomb-gzip.bin") + body = body_path.read_bytes() + request = Request(url="https://example.com") + response = Response(url="https://example.com", body=body, request=request) + with LogCapture( + "scrapy.spiders.sitemap", propagate=False, level=WARNING + ) as log: + spider._get_sitemap_body(response) + log.check( + ( + "scrapy.spiders.sitemap", + "WARNING", + ( + "<200 https://example.com> body size after decompression " + "(11511612 B) is larger than the download warning size " + "(10000000 B)." + ), + ), + ) + + @pytest.mark.filterwarnings("ignore::scrapy.exceptions.ScrapyDeprecationWarning") + def test_download_warnsize_spider_attr(self): + class DownloadWarnSizeSpider(self.spider_class): + download_warnsize = 10_000_000 + + crawler = get_crawler() + spider = DownloadWarnSizeSpider.from_crawler(crawler, "example.com") + body_path = Path(tests_datadir, "compressed", "bomb-gzip.bin") + body = body_path.read_bytes() + request = Request( + url="https://example.com", meta={"download_warnsize": 10_000_000} + ) + response = Response(url="https://example.com", body=body, request=request) + with LogCapture( + "scrapy.spiders.sitemap", propagate=False, level=WARNING + ) as log: + spider._get_sitemap_body(response) + log.check( + ( + "scrapy.spiders.sitemap", + "WARNING", + ( + "<200 https://example.com> body size after decompression " + "(11511612 B) is larger than the download warning size " + "(10000000 B)." + ), + ), + ) + + def test_download_warnsize_request_meta(self): + crawler = get_crawler() + spider = self.spider_class.from_crawler(crawler, "example.com") + body_path = Path(tests_datadir, "compressed", "bomb-gzip.bin") + body = body_path.read_bytes() + request = Request( + url="https://example.com", meta={"download_warnsize": 10_000_000} + ) + response = Response(url="https://example.com", body=body, request=request) + with LogCapture( + "scrapy.spiders.sitemap", propagate=False, level=WARNING + ) as log: + spider._get_sitemap_body(response) + log.check( + ( + "scrapy.spiders.sitemap", + "WARNING", + ( + "<200 https://example.com> body size after decompression " + "(11511612 B) is larger than the download warning size " + "(10000000 B)." + ), + ), + ) + + @coroutine_test + async def test_sitemap_urls(self): + class TestSpider(self.spider_class): + name = "test" + sitemap_urls = ["https://toscrape.com/sitemap.xml"] + + crawler = get_crawler(TestSpider) + spider = TestSpider.from_crawler(crawler) + with warnings.catch_warnings(): + warnings.simplefilter("error") + requests = [request async for request in spider.start()] + + assert len(requests) == 1 + request = requests[0] + assert request.url == "https://toscrape.com/sitemap.xml" + assert request.dont_filter is False + assert request.callback == spider._parse_sitemap From c148ec44332797b0b7b1ec9b4ce9b9e63c6e8e2f Mon Sep 17 00:00:00 2001 From: Julian Hamze Date: Thu, 19 Mar 2026 04:16:40 -0400 Subject: [PATCH 070/248] Add a DOWNLOAD_BIND_ADDRESS setting for download handlers (#7283) --- docs/topics/request-response.rst | 31 ++++++++++++++++++- docs/topics/settings.rst | 33 +++++++++++++++++++++ scrapy/core/downloader/handlers/_httpx.py | 31 +++++++++++++++++-- scrapy/core/downloader/handlers/http11.py | 12 +++++--- scrapy/core/downloader/handlers/http2.py | 10 +++++-- scrapy/core/http2/agent.py | 4 +-- scrapy/settings/default_settings.py | 3 ++ scrapy/utils/_download_handlers.py | 8 +++++ tests/mockserver/http.py | 2 ++ tests/mockserver/http_resources.py | 12 ++++++++ tests/test_downloader_handler_httpx.py | 21 ++++++++++++- tests/test_downloader_handlers_http_base.py | 30 +++++++++++++++++++ 12 files changed, 185 insertions(+), 12 deletions(-) diff --git a/docs/topics/request-response.rst b/docs/topics/request-response.rst index cb2410207..5acabb0de 100644 --- a/docs/topics/request-response.rst +++ b/docs/topics/request-response.rst @@ -644,7 +644,36 @@ Those are: bindaddress ----------- -The IP of the outgoing IP address to use for the performing the request. +The default local outgoing address for download-handler connections. + +This setting can be either: + +- a host address as a string (e.g. ``"127.0.0.2"``), in which case the local + port is chosen automatically, or + +- a ``(host, port)`` tuple (e.g. ``("127.0.0.2", 50000)``) to bind to both a + specific local interface and a specific local port. + +For example: + +.. code-block:: python + + Request( + "https://example.org", + meta={"bindaddress": "127.0.0.2"}, + ) + +.. code-block:: python + + Request( + "https://example.org", + meta={"bindaddress": ("127.0.0.2", 50000)}, + ) + +If not set, built-in HTTP download handlers use the value of +:setting:`DOWNLOAD_BIND_ADDRESS` as the default bind address. +Set the :reqmeta:`bindaddress` request meta key to override it for a +specific request. .. reqmeta:: download_timeout diff --git a/docs/topics/settings.rst b/docs/topics/settings.rst index 5b4a9b5f9..2be319ed2 100644 --- a/docs/topics/settings.rst +++ b/docs/topics/settings.rst @@ -897,6 +897,39 @@ It is also possible to change this setting per domain, although it requires non-trivial code. See the implementation of the :ref:`AutoThrottle ` extension for an example. +.. setting:: DOWNLOAD_BIND_ADDRESS + +DOWNLOAD_BIND_ADDRESS +--------------------- + +Default: ``None`` + +The default local outgoing address for download-handler connections. + +This setting can be either: + +- a host address as a string (e.g. ``"127.0.0.2"``), in which case the local + port is chosen automatically, or + +- a ``(host, port)`` tuple (e.g. ``("127.0.0.2", 50000)``) to bind to both a + specific local interface and a specific local port. + +For example: + +.. code-block:: python + + # Bind to this local address + DOWNLOAD_BIND_ADDRESS = "127.0.0.2" + +.. code-block:: python + + # Bind to this local address and local port + DOWNLOAD_BIND_ADDRESS = ("127.0.0.2", 5000) + +If set, built-in HTTP download handlers use this value by default. +Set the :reqmeta:`bindaddress` request meta key to override it for a specific +request. + .. setting:: DOWNLOAD_HANDLERS DOWNLOAD_HANDLERS diff --git a/scrapy/core/downloader/handlers/_httpx.py b/scrapy/core/downloader/handlers/_httpx.py index 4a9617506..596e44f4c 100644 --- a/scrapy/core/downloader/handlers/_httpx.py +++ b/scrapy/core/downloader/handlers/_httpx.py @@ -30,6 +30,7 @@ from scrapy.utils._download_handlers import ( get_maxsize_msg, get_warnsize_msg, make_response, + normalize_bind_address, ) from scrapy.utils.asyncio import is_asyncio_available from scrapy.utils.ssl import _log_sslobj_debug_info, _make_ssl_context @@ -87,8 +88,30 @@ class HttpxDownloadHandler(BaseHttpDownloadHandler): self._tls_verbose_logging: bool = self.crawler.settings.getbool( "DOWNLOADER_CLIENT_TLS_VERBOSE_LOGGING" ) + bind_address = crawler.settings.get("DOWNLOAD_BIND_ADDRESS") + bind_address = normalize_bind_address(bind_address) + + self._bind_address: str | None = None + + if bind_address is not None: + host, port = bind_address + if port != 0: + logger.warning( + "DOWNLOAD_BIND_ADDRESS specifies a port (%s), but %s does not " + "support binding to a specific local port. Ignoring the port " + "and binding only to %r.", + port, + type(self).__name__, + host, + ) + self._bind_address = host + self._client = httpx.AsyncClient( - verify=_make_ssl_context(crawler.settings), cookies=_NullCookieJar() + cookies=_NullCookieJar(), + transport=httpx.AsyncHTTPTransport( + verify=_make_ssl_context(crawler.settings), + local_address=self._bind_address, + ), ) async def download_request(self, request: Request) -> Response: @@ -108,7 +131,11 @@ class HttpxDownloadHandler(BaseHttpDownloadHandler): except httpx.UnsupportedProtocol as e: raise UnsupportedURLSchemeError(str(e)) from e except httpx.ConnectError as e: - if "Name or service not known" in str(e) or "getaddrinfo failed" in str(e): + if ( + "Name or service not known" in str(e) + or "getaddrinfo failed" in str(e) + or "nodename nor servname" in str(e) + ): raise CannotResolveHostError(str(e)) from e raise DownloadConnectionRefusedError(str(e)) from e except httpx.NetworkError as e: diff --git a/scrapy/core/downloader/handlers/http11.py b/scrapy/core/downloader/handlers/http11.py index 2fd3bd988..089400ebd 100644 --- a/scrapy/core/downloader/handlers/http11.py +++ b/scrapy/core/downloader/handlers/http11.py @@ -45,6 +45,7 @@ from scrapy.utils._download_handlers import ( get_maxsize_msg, get_warnsize_msg, make_response, + normalize_bind_address, wrap_twisted_exceptions, ) from scrapy.utils.defer import maybe_deferred_to_future @@ -93,6 +94,7 @@ class HTTP11DownloadHandler(BaseHttpDownloadHandler): self._contextFactory: IPolicyForHTTPS = load_context_factory_from_settings( crawler.settings, crawler ) + self._bind_address = crawler.settings.get("DOWNLOAD_BIND_ADDRESS") self._disconnect_timeout: int = 1 async def download_request(self, request: Request) -> Response: @@ -106,6 +108,7 @@ class HTTP11DownloadHandler(BaseHttpDownloadHandler): agent = ScrapyAgent( contextFactory=self._contextFactory, + bindAddress=self._bind_address, pool=self._pool, maxsize=getattr( self._crawler.spider, "download_maxsize", self._default_maxsize @@ -286,7 +289,7 @@ class TunnelingAgent(Agent): proxyConf: tuple[str, int, bytes | None], contextFactory: IPolicyForHTTPS, connectTimeout: float | None = None, - bindAddress: bytes | None = None, + bindAddress: tuple[str, int] | None = None, pool: HTTPConnectionPool | None = None, ): super().__init__(reactor, contextFactory, connectTimeout, bindAddress, pool) @@ -335,7 +338,7 @@ class ScrapyProxyAgent(Agent): reactor: ReactorBase, proxyURI: bytes, connectTimeout: float | None = None, - bindAddress: bytes | None = None, + bindAddress: tuple[str, int] | None = None, pool: HTTPConnectionPool | None = None, ): super().__init__( @@ -379,7 +382,7 @@ class ScrapyAgent: *, contextFactory: IPolicyForHTTPS, connectTimeout: float = 10, - bindAddress: bytes | None = None, + bindAddress: str | tuple[str, int] | None = None, pool: HTTPConnectionPool | None = None, maxsize: int = 0, warnsize: int = 0, @@ -388,7 +391,7 @@ class ScrapyAgent: ): self._contextFactory: IPolicyForHTTPS = contextFactory self._connectTimeout: float = connectTimeout - self._bindAddress: bytes | None = bindAddress + self._bindAddress: str | tuple[str, int] | None = bindAddress self._pool: HTTPConnectionPool | None = pool self._maxsize: int = maxsize self._warnsize: int = warnsize @@ -400,6 +403,7 @@ class ScrapyAgent: from twisted.internet import reactor bindaddress = request.meta.get("bindaddress") or self._bindAddress + bindaddress = normalize_bind_address(bindaddress) proxy = request.meta.get("proxy") if proxy: proxy = add_http_if_no_scheme(proxy) diff --git a/scrapy/core/downloader/handlers/http2.py b/scrapy/core/downloader/handlers/http2.py index 75fa0772d..c749a4a73 100644 --- a/scrapy/core/downloader/handlers/http2.py +++ b/scrapy/core/downloader/handlers/http2.py @@ -10,7 +10,10 @@ from scrapy.core.downloader.contextfactory import load_context_factory_from_sett from scrapy.core.downloader.handlers.base import BaseDownloadHandler from scrapy.core.http2.agent import H2Agent, H2ConnectionPool, ScrapyProxyH2Agent from scrapy.exceptions import DownloadTimeoutError -from scrapy.utils._download_handlers import wrap_twisted_exceptions +from scrapy.utils._download_handlers import ( + normalize_bind_address, + wrap_twisted_exceptions, +) from scrapy.utils.defer import maybe_deferred_to_future from scrapy.utils.httpobj import urlparse_cached from scrapy.utils.python import to_bytes @@ -38,11 +41,13 @@ class H2DownloadHandler(BaseDownloadHandler): self._context_factory = load_context_factory_from_settings( crawler.settings, crawler ) + self._bind_address = crawler.settings.get("DOWNLOAD_BIND_ADDRESS") async def download_request(self, request: Request) -> Response: agent = ScrapyH2Agent( context_factory=self._context_factory, pool=self._pool, + bind_address=self._bind_address, crawler=self._crawler, ) assert self._crawler.spider @@ -64,7 +69,7 @@ class ScrapyH2Agent: context_factory: IPolicyForHTTPS, pool: H2ConnectionPool, connect_timeout: int = 10, - bind_address: bytes | None = None, + bind_address: str | tuple[str, int] | None = None, crawler: Crawler | None = None, ) -> None: self._context_factory = context_factory @@ -77,6 +82,7 @@ class ScrapyH2Agent: from twisted.internet import reactor bind_address = request.meta.get("bindaddress") or self._bind_address + bind_address = normalize_bind_address(bind_address) proxy = request.meta.get("proxy") if proxy: if urlparse_cached(request).scheme == "https": diff --git a/scrapy/core/http2/agent.py b/scrapy/core/http2/agent.py index 8760b13ec..22019d661 100644 --- a/scrapy/core/http2/agent.py +++ b/scrapy/core/http2/agent.py @@ -122,7 +122,7 @@ class H2Agent: pool: H2ConnectionPool, context_factory: BrowserLikePolicyForHTTPS = BrowserLikePolicyForHTTPS(), # noqa: B008 connect_timeout: float | None = None, - bind_address: bytes | None = None, + bind_address: tuple[str, int] | None = None, ) -> None: self._reactor = reactor self._pool = pool @@ -166,7 +166,7 @@ class ScrapyProxyH2Agent(H2Agent): pool: H2ConnectionPool, context_factory: BrowserLikePolicyForHTTPS = BrowserLikePolicyForHTTPS(), # noqa: B008 connect_timeout: float | None = None, - bind_address: bytes | None = None, + bind_address: tuple[str, int] | None = None, ) -> None: super().__init__( reactor=reactor, diff --git a/scrapy/settings/default_settings.py b/scrapy/settings/default_settings.py index 385d5f933..bc04ebcfb 100644 --- a/scrapy/settings/default_settings.py +++ b/scrapy/settings/default_settings.py @@ -59,6 +59,7 @@ __all__ = [ "DOWNLOADER_MIDDLEWARES", "DOWNLOADER_MIDDLEWARES_BASE", "DOWNLOADER_STATS", + "DOWNLOAD_BIND_ADDRESS", "DOWNLOAD_DELAY", "DOWNLOAD_FAIL_ON_DATALOSS", "DOWNLOAD_HANDLERS", @@ -245,6 +246,8 @@ DNS_TIMEOUT = 60 DOWNLOAD_DELAY = 0 +DOWNLOAD_BIND_ADDRESS = None + DOWNLOAD_FAIL_ON_DATALOSS = True DOWNLOAD_HANDLERS = {} diff --git a/scrapy/utils/_download_handlers.py b/scrapy/utils/_download_handlers.py index 8b0f0fa53..94063a831 100644 --- a/scrapy/utils/_download_handlers.py +++ b/scrapy/utils/_download_handlers.py @@ -146,3 +146,11 @@ def get_dataloss_msg(url: str) -> str: f"responses set the setting DOWNLOAD_FAIL_ON_DATALOSS = False" f" -- This message won't be shown in further requests" ) + + +def normalize_bind_address( + value: str | tuple[str, int] | None, +) -> tuple[str, int] | None: + if isinstance(value, str): + return (value, 0) + return value diff --git a/tests/mockserver/http.py b/tests/mockserver/http.py index d7c892828..6cf0046c0 100644 --- a/tests/mockserver/http.py +++ b/tests/mockserver/http.py @@ -14,6 +14,7 @@ from .http_resources import ( BrokenChunkedResource, BrokenDownloadResource, ChunkedResource, + ClientIPResource, Compress, ContentLengthHeaderResource, Delay, @@ -72,6 +73,7 @@ class Root(resource.Resource): self.putChild(b"wait", ForeverTakingResource()) self.putChild(b"hang-after-headers", ForeverTakingResource(write=True)) self.putChild(b"host", HostHeaderResource()) + self.putChild(b"client-ip", ClientIPResource()) self.putChild(b"broken", BrokenDownloadResource()) self.putChild(b"chunked", ChunkedResource()) self.putChild(b"broken-chunked", BrokenChunkedResource()) diff --git a/tests/mockserver/http_resources.py b/tests/mockserver/http_resources.py index 91577aa6b..30f7861e7 100644 --- a/tests/mockserver/http_resources.py +++ b/tests/mockserver/http_resources.py @@ -56,6 +56,18 @@ class HostHeaderResource(resource.Resource): return request.requestHeaders.getRawHeaders(b"host")[0] +class ClientIPResource(resource.Resource): + """ + A testing resource which renders itself as the request client IP address. + """ + + def render(self, request): + client_address = request.getClientAddress() + if client_address is None or client_address.host is None: + return b"" + return to_bytes(client_address.host) + + class PayloadResource(resource.Resource): """ A testing resource which renders itself as the contents of the request body diff --git a/tests/test_downloader_handler_httpx.py b/tests/test_downloader_handler_httpx.py index ad6c6d3c7..233bc62fe 100644 --- a/tests/test_downloader_handler_httpx.py +++ b/tests/test_downloader_handler_httpx.py @@ -2,6 +2,7 @@ from __future__ import annotations +import sys from typing import TYPE_CHECKING, Any import pytest @@ -44,7 +45,7 @@ class TestHttp11(HttpxDownloadHandlerMixin, TestHttp11Base): async def test_unsupported_bindaddress( self, caplog: pytest.LogCaptureFixture, mockserver: MockServer ) -> None: - meta = {"bindaddress": "127.0.0.2"} + meta = {"bindaddress": ("127.0.0.2", 0)} request = Request(mockserver.url("/text"), meta=meta) async with self.get_dh() as download_handler: response = await download_handler.download_request(request) @@ -54,6 +55,24 @@ class TestHttp11(HttpxDownloadHandlerMixin, TestHttp11Base): in caplog.text ) + # skip macOS tests + @pytest.mark.skipif( + sys.platform == "darwin", + reason="127.0.0.2 is not available on macOS by default", + ) + @coroutine_test + async def test_bind_address_port_warning( + self, caplog: pytest.LogCaptureFixture, mockserver: MockServer + ) -> None: + request = Request(mockserver.url("/client-ip")) + async with self.get_dh( + {"DOWNLOAD_BIND_ADDRESS": ("127.0.0.2", 12345)} + ) as download_handler: + response = await download_handler.download_request(request) + assert response.body == b"127.0.0.2" + assert "DOWNLOAD_BIND_ADDRESS specifies a port (12345)" in caplog.text + assert "Ignoring the port" in caplog.text + @coroutine_test async def test_unsupported_proxy( self, caplog: pytest.LogCaptureFixture, mockserver: MockServer diff --git a/tests/test_downloader_handlers_http_base.py b/tests/test_downloader_handlers_http_base.py index 8e9fa0ae1..88813f0b6 100644 --- a/tests/test_downloader_handlers_http_base.py +++ b/tests/test_downloader_handlers_http_base.py @@ -671,6 +671,36 @@ class TestHttp11Base(TestHttpBase): response = await download_handler.download_request(request) assert response.protocol == "HTTP/1.1" + # skip macOS tests + @pytest.mark.skipif( + sys.platform == "darwin", + reason="127.0.0.2 is not available on macOS by default", + ) + @coroutine_test + async def test_download_bind_address_setting(self, mockserver: MockServer) -> None: + request = Request(mockserver.url("/client-ip", is_secure=self.is_secure)) + async with self.get_dh( + {"DOWNLOAD_BIND_ADDRESS": ("127.0.0.2", 0)} + ) as download_handler: + response = await download_handler.download_request(request) + assert response.body == b"127.0.0.2" + + # skip macOS tests + @pytest.mark.skipif( + sys.platform == "darwin", + reason="127.0.0.2 is not available on macOS by default", + ) + @coroutine_test + async def test_download_bind_address_setting_string( + self, mockserver: MockServer + ) -> None: + request = Request(mockserver.url("/client-ip", is_secure=self.is_secure)) + async with self.get_dh( + {"DOWNLOAD_BIND_ADDRESS": "127.0.0.2"} + ) as download_handler: + response = await download_handler.download_request(request) + assert response.body == b"127.0.0.2" + class TestHttps11Base(TestHttp11Base): is_secure = True From 939db88b044d3132f0c5f0af12531d9df80066f3 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Tue, 24 Mar 2026 14:31:17 +0500 Subject: [PATCH 071/248] Disable mypy allow_untyped_calls. (#7331) --- pyproject.toml | 1 - scrapy/core/downloader/contextfactory.py | 2 +- scrapy/core/downloader/handlers/http11.py | 8 +-- scrapy/core/downloader/tls.py | 2 +- scrapy/core/http2/protocol.py | 8 +-- scrapy/utils/benchserver.py | 2 +- scrapy/utils/console.py | 2 +- .../AsyncCrawlerRunner/reactorless_datauri.py | 2 +- .../AsyncCrawlerRunner/reactorless_reactor.py | 2 +- .../AsyncCrawlerRunner/reactorless_simple.py | 2 +- tests/CrawlerRunner/ip_address.py | 14 +++-- tests/mockserver/http_resources.py | 20 ++++++- tests/mockserver/utils.py | 10 +++- tests/spiders.py | 16 +++++- tests/test_command_shell.py | 2 +- tests/test_core_downloader.py | 3 +- tests/test_crawl.py | 3 +- tests/test_crawler.py | 15 ++--- tests/test_downloaderslotssettings.py | 1 + tests/test_engine.py | 55 +++++++++++-------- tests/test_extension_periodic_log.py | 12 ++-- tests/test_feedexport.py | 14 +++-- tests/test_http2_client_protocol.py | 19 ++++++- tests/test_pipelines.py | 2 +- tests/test_robotstxt_interface.py | 2 +- 25 files changed, 144 insertions(+), 75 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 28e297401..b343cce60 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -87,7 +87,6 @@ pattern = "^(?P.+)$" [tool.mypy] strict = true allow_any_generics = true # 67 errors -allow_untyped_calls = true # 58 errors extra_checks = false # weird addErrback() errors untyped_calls_exclude = [ "twisted", diff --git a/scrapy/core/downloader/contextfactory.py b/scrapy/core/downloader/contextfactory.py index 252f509bf..917250291 100644 --- a/scrapy/core/downloader/contextfactory.py +++ b/scrapy/core/downloader/contextfactory.py @@ -55,7 +55,7 @@ class ScrapyClientContextFactory(BrowserLikePolicyForHTTPS): *args: Any, **kwargs: Any, ): - super().__init__(*args, **kwargs) + super().__init__(*args, **kwargs) # type: ignore[no-untyped-call] self._ssl_method: int = method self.tls_verbose_logging: bool = tls_verbose_logging self.tls_ciphers: AcceptableCiphers diff --git a/scrapy/core/downloader/handlers/http11.py b/scrapy/core/downloader/handlers/http11.py index 089400ebd..16879cf76 100644 --- a/scrapy/core/downloader/handlers/http11.py +++ b/scrapy/core/downloader/handlers/http11.py @@ -292,7 +292,7 @@ class TunnelingAgent(Agent): bindAddress: tuple[str, int] | None = None, pool: HTTPConnectionPool | None = None, ): - super().__init__(reactor, contextFactory, connectTimeout, bindAddress, pool) + super().__init__(reactor, contextFactory, connectTimeout, bindAddress, pool) # type: ignore[no-untyped-call] self._proxyConf: tuple[str, int, bytes | None] = proxyConf self._contextFactory: IPolicyForHTTPS = contextFactory @@ -341,7 +341,7 @@ class ScrapyProxyAgent(Agent): bindAddress: tuple[str, int] | None = None, pool: HTTPConnectionPool | None = None, ): - super().__init__( + super().__init__( # type: ignore[no-untyped-call] reactor=reactor, connectTimeout=connectTimeout, bindAddress=bindAddress, @@ -363,7 +363,7 @@ class ScrapyProxyAgent(Agent): # connecting to a single destination, the proxy: return self._requestWithEndpoint( key=(b"http-proxy", self._proxyURI.host, self._proxyURI.port), - endpoint=self._getEndpoint(self._proxyURI), + endpoint=self._getEndpoint(self._proxyURI), # type: ignore[no-untyped-call] method=method, parsedURI=URI.fromBytes(uri), headers=headers, @@ -432,7 +432,7 @@ class ScrapyAgent: pool=self._pool, ) - return self._Agent( + return self._Agent( # type: ignore[no-untyped-call] reactor=reactor, contextFactory=self._contextFactory, connectTimeout=timeout, diff --git a/scrapy/core/downloader/tls.py b/scrapy/core/downloader/tls.py index 1ae66f614..d794ea846 100644 --- a/scrapy/core/downloader/tls.py +++ b/scrapy/core/downloader/tls.py @@ -41,7 +41,7 @@ class ScrapyClientTLSOptions(ClientTLSOptions): """ def __init__(self, hostname: str, ctx: SSL.Context, verbose_logging: bool = False): - super().__init__(hostname, ctx) + super().__init__(hostname, ctx) # type: ignore[no-untyped-call] self.verbose_logging: bool = verbose_logging def _identityVerifyingInfoCallback( diff --git a/scrapy/core/http2/protocol.py b/scrapy/core/http2/protocol.py index fe4128a68..4094e6f18 100644 --- a/scrapy/core/http2/protocol.py +++ b/scrapy/core/http2/protocol.py @@ -220,7 +220,7 @@ class H2ClientProtocol(Protocol, TimeoutMixin): """ assert self.transport is not None # typing # Reset the idle timeout as connection is still actively sending data - self.resetTimeout() + self.resetTimeout() # type: ignore[no-untyped-call] data = self.conn.data_to_send() self.transport.write(data) @@ -247,7 +247,7 @@ class H2ClientProtocol(Protocol, TimeoutMixin): sending some data now: we should open with the connection preamble. """ # Initialize the timeout - self.setTimeout(self.IDLE_TIMEOUT) + self.setTimeout(self.IDLE_TIMEOUT) # type: ignore[no-untyped-call] assert self.transport is not None # typing destination = self.transport.getPeer() @@ -290,7 +290,7 @@ class H2ClientProtocol(Protocol, TimeoutMixin): def dataReceived(self, data: bytes) -> None: # Reset the idle timeout as connection is still actively receiving data - self.resetTimeout() + self.resetTimeout() # type: ignore[no-untyped-call] try: self._check_received_data(data) @@ -343,7 +343,7 @@ class H2ClientProtocol(Protocol, TimeoutMixin): No need to write anything to transport here. """ # Cancel the timeout if not done yet - self.setTimeout(None) + self.setTimeout(None) # type: ignore[no-untyped-call] # Notify the connection pool instance such that no new requests are # sent over current connection diff --git a/scrapy/utils/benchserver.py b/scrapy/utils/benchserver.py index 6ac5e66b2..59f04b8ad 100644 --- a/scrapy/utils/benchserver.py +++ b/scrapy/utils/benchserver.py @@ -36,7 +36,7 @@ def _getarg( if __name__ == "__main__": from twisted.internet import reactor - root = Root() + root = Root() # type: ignore[no-untyped-call] factory = Site(root) httpPort = reactor.listenTCP(8998, Site(root)) diff --git a/scrapy/utils/console.py b/scrapy/utils/console.py index 87cfd3c28..a3df08cd9 100644 --- a/scrapy/utils/console.py +++ b/scrapy/utils/console.py @@ -29,7 +29,7 @@ def _embed_ipython_shell( @wraps(_embed_ipython_shell) def wrapper(namespace: dict[str, Any] = namespace, banner: str = "") -> None: - config = load_default_config() + config = load_default_config() # type: ignore[no-untyped-call] # Always use .instance() to ensure _instance propagation to all parents # this is needed for completion works well for new imports # and clear the instance to always have the fresh env diff --git a/tests/AsyncCrawlerRunner/reactorless_datauri.py b/tests/AsyncCrawlerRunner/reactorless_datauri.py index cd764b8db..22095b1b0 100644 --- a/tests/AsyncCrawlerRunner/reactorless_datauri.py +++ b/tests/AsyncCrawlerRunner/reactorless_datauri.py @@ -15,7 +15,7 @@ class DataSpider(Spider): return {"data": response.text} -async def main(): +async def main() -> None: configure_logging() runner = AsyncCrawlerRunner( settings={ diff --git a/tests/AsyncCrawlerRunner/reactorless_reactor.py b/tests/AsyncCrawlerRunner/reactorless_reactor.py index 8266ffbc0..60830c60d 100644 --- a/tests/AsyncCrawlerRunner/reactorless_reactor.py +++ b/tests/AsyncCrawlerRunner/reactorless_reactor.py @@ -14,7 +14,7 @@ class NoRequestsSpider(Spider): yield -async def main(): +async def main() -> None: configure_logging() runner = AsyncCrawlerRunner( settings={ diff --git a/tests/AsyncCrawlerRunner/reactorless_simple.py b/tests/AsyncCrawlerRunner/reactorless_simple.py index 7a9e061e9..698637dd4 100644 --- a/tests/AsyncCrawlerRunner/reactorless_simple.py +++ b/tests/AsyncCrawlerRunner/reactorless_simple.py @@ -15,7 +15,7 @@ class NoRequestsSpider(Spider): yield -async def main(): +async def main() -> None: configure_logging() runner = AsyncCrawlerRunner( settings={ diff --git a/tests/CrawlerRunner/ip_address.py b/tests/CrawlerRunner/ip_address.py index 9811bf90c..fae206610 100644 --- a/tests/CrawlerRunner/ip_address.py +++ b/tests/CrawlerRunner/ip_address.py @@ -1,5 +1,9 @@ # ruff: noqa: E402 +from __future__ import annotations + +from typing import TYPE_CHECKING + from scrapy.utils.reactor import install_reactor from tests.mockserver.dns import MockDNSServer from tests.mockserver.http import MockServer @@ -17,12 +21,14 @@ from scrapy.crawler import CrawlerRunner from scrapy.utils.httpobj import urlparse_cached from scrapy.utils.log import configure_logging +if TYPE_CHECKING: + from twisted.names.common import ResolverBase + # https://stackoverflow.com/a/32784190 -def createResolver(servers=None, resolvconf=None, hosts=None): - if hosts is None: - hosts = b"/etc/hosts" if platform.getType() == "posix" else r"c:\windows\hosts" - theResolver = Resolver(resolvconf, servers) +def createResolver(servers: list[tuple[str, int]]) -> ResolverBase: + hosts = b"/etc/hosts" if platform.getType() == "posix" else r"c:\windows\hosts" + theResolver = Resolver(None, servers) hostResolver = hostsModule.Resolver(hosts) chain = [hostResolver, cache.CacheResolver(), theResolver] return resolve.ResolverChain(chain) diff --git a/tests/mockserver/http_resources.py b/tests/mockserver/http_resources.py index 30f7861e7..629932f32 100644 --- a/tests/mockserver/http_resources.py +++ b/tests/mockserver/http_resources.py @@ -3,6 +3,7 @@ from __future__ import annotations import gzip import json import random +from typing import TYPE_CHECKING, ParamSpec, TypeVar from urllib.parse import urlencode from twisted.internet.task import deferLater @@ -12,6 +13,16 @@ from twisted.web.util import Redirect, redirectTo from scrapy.utils.python import to_bytes, to_unicode +if TYPE_CHECKING: + from collections.abc import Callable + + from twisted.internet.defer import Deferred + from twisted.web.http import Request + + +_T = TypeVar("_T") +_P = ParamSpec("_P") + def getarg(request, name, default=None, type_=None): if name in request.args: @@ -86,7 +97,14 @@ class PayloadResource(resource.Resource): class LeafResource(resource.Resource): isLeaf = True - def deferRequest(self, request, delay, f, *a, **kw): + def deferRequest( + self, + request: Request, + delay: float, + f: Callable[_P, _T], + *a: _P.args, + **kw: _P.kwargs, + ) -> Deferred[_T]: from twisted.internet import reactor def _cancelrequest(_): diff --git a/tests/mockserver/utils.py b/tests/mockserver/utils.py index f43152f92..e5228cabd 100644 --- a/tests/mockserver/utils.py +++ b/tests/mockserver/utils.py @@ -1,16 +1,22 @@ from __future__ import annotations from pathlib import Path +from typing import TYPE_CHECKING from OpenSSL import SSL from twisted.internet import ssl from scrapy.utils.python import to_bytes +if TYPE_CHECKING: + from twisted.internet.ssl import ContextFactory + def ssl_context_factory( - keyfile="keys/localhost.key", certfile="keys/localhost.crt", cipher_string=None -): + keyfile: str = "keys/localhost.key", + certfile: str = "keys/localhost.crt", + cipher_string: str | None = None, +) -> ContextFactory: factory = ssl.DefaultOpenSSLContextFactory( str(Path(__file__).parent.parent / keyfile), str(Path(__file__).parent.parent / certfile), diff --git a/tests/spiders.py b/tests/spiders.py index 363a09fad..373dc00bd 100644 --- a/tests/spiders.py +++ b/tests/spiders.py @@ -6,6 +6,7 @@ from __future__ import annotations import asyncio import time +from typing import TYPE_CHECKING, Any from urllib.parse import urlencode from twisted.internet import defer @@ -20,9 +21,18 @@ from scrapy.spiders.crawl import CrawlSpider, Rule from scrapy.utils.defer import deferred_to_future, maybe_deferred_to_future from scrapy.utils.test import get_from_asyncio_queue +if TYPE_CHECKING: + from tests.mockserver.http import MockServer + class MockServerSpider(Spider): - def __init__(self, *args, mockserver=None, is_secure=False, **kwargs): + def __init__( + self, + *args, + mockserver: MockServer | None = None, + is_secure: bool = False, + **kwargs: Any, + ): super().__init__(*args, **kwargs) self.mockserver = mockserver self.is_secure = is_secure @@ -31,9 +41,9 @@ class MockServerSpider(Spider): class MetaSpider(MockServerSpider): name = "meta" - def __init__(self, *args, **kwargs): + def __init__(self, *args: Any, **kwargs: Any): super().__init__(*args, **kwargs) - self.meta = {} + self.meta: dict[str, Any] = {} def closed(self, reason): self.meta["close_reason"] = reason diff --git a/tests/test_command_shell.py b/tests/test_command_shell.py index 1417bc623..25262d809 100644 --- a/tests/test_command_shell.py +++ b/tests/test_command_shell.py @@ -145,6 +145,6 @@ class TestInteractiveShell: p.sendline("type(response)") p.expect_exact("HtmlResponse") p.sendeof() - p.wait() + p.wait() # type: ignore[no-untyped-call] logfile.seek(0) assert "Traceback" not in logfile.read().decode() diff --git a/tests/test_core_downloader.py b/tests/test_core_downloader.py index 2a098f9f3..35de52a4b 100644 --- a/tests/test_core_downloader.py +++ b/tests/test_core_downloader.py @@ -29,6 +29,7 @@ from tests.utils.decorators import coroutine_test if TYPE_CHECKING: from twisted.internet.defer import Deferred + from twisted.internet.ssl import ContextFactory from twisted.web.iweb import IBodyProducer @@ -40,7 +41,7 @@ class TestSlot: @pytest.mark.requires_reactor class TestContextFactoryBase: - context_factory = None + context_factory: ContextFactory | None = None @async_yield_fixture async def server_url(self, tmp_path): diff --git a/tests/test_crawl.py b/tests/test_crawl.py index 7e9e029a1..2bc50aad4 100644 --- a/tests/test_crawl.py +++ b/tests/test_crawl.py @@ -304,7 +304,8 @@ with multiples lines ) self._assert_retried(log) - def _assert_retried(self, log): + @staticmethod + def _assert_retried(log: LogCapture | str) -> None: assert str(log).count("Retrying") == 2 assert str(log).count("Gave up retrying") == 1 diff --git a/tests/test_crawler.py b/tests/test_crawler.py index c14b3f13f..5c053089d 100644 --- a/tests/test_crawler.py +++ b/tests/test_crawler.py @@ -4,7 +4,7 @@ import re import warnings from collections.abc import Generator from pathlib import Path -from typing import Any +from typing import Any, cast import pytest from twisted.internet.defer import Deferred @@ -18,6 +18,7 @@ from scrapy.crawler import ( Crawler, CrawlerProcess, CrawlerRunner, + CrawlerRunnerBase, ) from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.extensions.throttle import AutoThrottle @@ -45,7 +46,7 @@ def get_raw_crawler(spidercls=None, settings_dict=None): class TestBaseCrawler: - def assertOptionIsDefault(self, settings, key): + def assertOptionIsDefault(self, settings: Settings, key: str) -> None: assert isinstance(settings, Settings) assert settings[key] == getattr(default_settings, key) @@ -668,12 +669,12 @@ class NoRequestsSpider(scrapy.Spider): @pytest.mark.requires_reactor class TestCrawlerRunnerHasSpider: @staticmethod - def _runner(): + def _runner() -> CrawlerRunnerBase: return CrawlerRunner(get_reactor_settings()) @staticmethod - def _crawl(runner, spider): - return runner.crawl(spider) + def _crawl(runner: CrawlerRunnerBase, spider: type[Spider]) -> Deferred[None]: + return cast("Deferred[None]", runner.crawl(spider)) @inline_callbacks_test def test_crawler_runner_bootstrap_successful(self): @@ -742,11 +743,11 @@ class TestCrawlerRunnerHasSpider: @pytest.mark.only_asyncio class TestAsyncCrawlerRunnerHasSpider(TestCrawlerRunnerHasSpider): @staticmethod - def _runner(): + def _runner() -> CrawlerRunnerBase: return AsyncCrawlerRunner(get_reactor_settings()) @staticmethod - def _crawl(runner, spider): + def _crawl(runner: CrawlerRunnerBase, spider: type[Spider]) -> Deferred[None]: return deferred_from_coro(runner.crawl(spider)) def test_crawler_runner_asyncio_enabled_true(self): diff --git a/tests/test_downloaderslotssettings.py b/tests/test_downloaderslotssettings.py index 5c31f68bc..1a3221dc9 100644 --- a/tests/test_downloaderslotssettings.py +++ b/tests/test_downloaderslotssettings.py @@ -33,6 +33,7 @@ class DownloaderSlotsSettingsTestSpider(MetaSpider): def __init__(self, *args: Any, **kwargs: Any): super().__init__(*args, **kwargs) + assert self.mockserver self.default_slot = self.mockserver.host self.times: dict[str, list[float]] = {} diff --git a/tests/test_engine.py b/tests/test_engine.py index 4573c5001..0c0432b3c 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -7,7 +7,7 @@ import sys from collections import defaultdict from dataclasses import dataclass from logging import DEBUG -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING, Any, cast from unittest.mock import Mock, call from urllib.parse import urlparse @@ -22,7 +22,7 @@ from scrapy import signals from scrapy.core.engine import ExecutionEngine, _Slot from scrapy.core.scheduler import BaseScheduler from scrapy.exceptions import CloseSpider, IgnoreRequest -from scrapy.http import Request, Response +from scrapy.http import Headers, Request, Response from scrapy.item import Field, Item from scrapy.linkextractors import LinkExtractor from scrapy.spiders import Spider @@ -38,6 +38,8 @@ from tests import get_testdata from tests.utils.decorators import coroutine_test, inline_callbacks_test if TYPE_CHECKING: + from twisted.python.failure import Failure + from scrapy.core.scheduler import Scheduler from scrapy.crawler import Crawler from scrapy.statscollectors import MemoryStatsCollector @@ -133,16 +135,16 @@ class ChangeCloseReasonSpider(MySpider): class CrawlerRun: """A class to run the crawler and keep track of events occurred""" - def __init__(self, spider_class): - self.respplug = [] - self.reqplug = [] - self.reqdropped = [] - self.reqreached = [] - self.itemerror = [] - self.itemresp = [] - self.headers = {} - self.bytes = defaultdict(list) - self.signals_caught = {} + def __init__(self, spider_class: type[Spider]): + self.respplug: list[tuple[Response, Spider]] = [] + self.reqplug: list[tuple[Request, Spider]] = [] + self.reqdropped: list[tuple[Request, Spider]] = [] + self.reqreached: list[tuple[Request, Spider]] = [] + self.itemerror: list[tuple[Any, Response, Spider, Failure]] = [] + self.itemresp: list[tuple[Any, Response]] = [] + self.headers: dict[Request, Headers] = {} + self.bytes: defaultdict[Request, list[bytes]] = defaultdict(list) + self.signals_caught: dict[Any, dict[str, Any]] = {} self.spider_class = spider_class async def run(self, mockserver: MockServer) -> None: @@ -188,35 +190,39 @@ class CrawlerRun: def geturl(self, path: str) -> str: return self.mockserver.url(path) - def getpath(self, url): + def getpath(self, url: str) -> str: u = urlparse(url) return u.path - def item_error(self, item, response, spider, failure): + def item_error( + self, item: Any, response: Response, spider: Spider, failure: Failure + ) -> None: self.itemerror.append((item, response, spider, failure)) - def item_scraped(self, item, spider, response): + def item_scraped(self, item: Any, spider: Spider, response: Response) -> None: self.itemresp.append((item, response)) - def headers_received(self, headers, body_length, request, spider): + def headers_received( + self, headers: Headers, body_length: int, request: Request, spider: Spider + ) -> None: self.headers[request] = headers - def bytes_received(self, data, request, spider): + def bytes_received(self, data: bytes, request: Request, spider: Spider) -> None: self.bytes[request].append(data) - def request_scheduled(self, request, spider): + def request_scheduled(self, request: Request, spider: Spider) -> None: self.reqplug.append((request, spider)) - def request_reached(self, request, spider): + def request_reached(self, request: Request, spider: Spider) -> None: self.reqreached.append((request, spider)) - def request_dropped(self, request, spider): + def request_dropped(self, request: Request, spider: Spider) -> None: self.reqdropped.append((request, spider)) - def response_downloaded(self, response, spider): + def response_downloaded(self, response: Response, spider: Spider) -> None: self.respplug.append((response, spider)) - def record_signal(self, *args, **kwargs): + def record_signal(self, *args: Any, **kwargs: Any) -> None: """Record a signal and its parameters""" signalargs = kwargs.copy() sig = signalargs.pop("signal") @@ -294,8 +300,8 @@ class TestEngineBase: @staticmethod def _assert_scraped_items(run: CrawlerRun) -> None: assert len(run.itemresp) == 2 - for item, response in run.itemresp: - item = ItemAdapter(item) + for item_, response in run.itemresp: + item = ItemAdapter(item_) assert item["url"] == response.url if "item1.html" in item["url"]: assert item["name"] == "Item 1 name" @@ -308,6 +314,7 @@ class TestEngineBase: def _assert_headers_received(run: CrawlerRun) -> None: for headers in run.headers.values(): assert b"Server" in headers + assert headers[b"Server"] assert b"TwistedWeb" in headers[b"Server"] assert b"Date" in headers assert b"Content-Type" in headers diff --git a/tests/test_extension_periodic_log.py b/tests/test_extension_periodic_log.py index 0e2d4be28..2cc23c491 100644 --- a/tests/test_extension_periodic_log.py +++ b/tests/test_extension_periodic_log.py @@ -59,10 +59,10 @@ stats_dump_2 = { class CustomPeriodicLog(PeriodicLog): - def set_a(self): + def set_a(self) -> None: self.stats._stats = stats_dump_1 - def set_b(self): + def set_b(self) -> None: self.stats._stats = stats_dump_2 @@ -90,7 +90,9 @@ class TestPeriodicLog: @pytest.mark.requires_reactor # needs a reactor or an event loop for PeriodicLog.task def test_log_delta(self): - def emulate(settings=None): + def emulate( + settings: dict[str, Any] | None = None, + ) -> tuple[PeriodicLog, dict[str, Any], dict[str, Any]]: spider = MetaSpider() ext = extension(settings) ext.spider_opened(spider) @@ -154,7 +156,9 @@ class TestPeriodicLog: @pytest.mark.requires_reactor # needs a reactor or an event loop for PeriodicLog.task def test_log_stats(self): - def emulate(settings=None): + def emulate( + settings: dict[str, Any] | None = None, + ) -> tuple[PeriodicLog, dict[str, Any], dict[str, Any]]: spider = MetaSpider() ext = extension(settings) ext.spider_opened(spider) diff --git a/tests/test_feedexport.py b/tests/test_feedexport.py index bd6ac5967..ab7341d29 100644 --- a/tests/test_feedexport.py +++ b/tests/test_feedexport.py @@ -11,7 +11,7 @@ from abc import ABC, abstractmethod from logging import getLogger from pathlib import Path from string import ascii_letters, digits -from typing import TYPE_CHECKING, Any +from typing import IO, TYPE_CHECKING, Any from unittest import mock from urllib.parse import urljoin from urllib.request import pathname2url @@ -44,12 +44,12 @@ if TYPE_CHECKING: from collections.abc import Callable, Iterable -def path_to_url(path): +def path_to_url(path: Path) -> str: return urljoin("file:", pathname2url(str(path))) -def printf_escape(string): - return string.replace("%", "%%") +def printf_escape(s: str) -> str: + return s.replace("%", "%%") class FromCrawlerMixin: @@ -239,8 +239,10 @@ class TestFeedExportBase(ABC): ) -> dict[str, Any]: pass - def _load_until_eof(self, data, load_func): - result = [] + def _load_until_eof( + self, data: bytes, load_func: Callable[[IO[bytes]], Any] + ) -> list[Any]: + result: list[Any] = [] with tempfile.TemporaryFile() as temp: temp.write(data) temp.seek(0) diff --git a/tests/test_http2_client_protocol.py b/tests/test_http2_client_protocol.py index af9941f3b..49b9180b9 100644 --- a/tests/test_http2_client_protocol.py +++ b/tests/test_http2_client_protocol.py @@ -411,9 +411,22 @@ class TestHttps2ClientProtocol: client, request, Data.JSON_LARGE, Data.EXTRA_LARGE, 200 ) - async def _check_POST_json_x10(self, *args, **kwargs): + async def _check_POST_json_x10( + self, + client: H2ClientProtocol, + request: Request, + expected_request_body: dict[str, str], + expected_extra_data: str, + expected_status: int, + ) -> None: async def get_coro() -> None: - await self._check_POST_json(*args, **kwargs) + await self._check_POST_json( + client, + request, + expected_request_body, + expected_extra_data, + expected_status, + ) await self._check_repeat(get_coro, 10) @@ -717,7 +730,7 @@ class TestHttps2ClientProtocol: request = Request(self.get_url(server_port, "/timeout")) # Update the timer to 1s to test connection timeout - client.setTimeout(1) + client.setTimeout(1) # type: ignore[no-untyped-call] with pytest.raises(ResponseFailed) as exc_info: yield make_request_dfd(client, request) diff --git a/tests/test_pipelines.py b/tests/test_pipelines.py index 753fcd42e..2d5836ad6 100644 --- a/tests/test_pipelines.py +++ b/tests/test_pipelines.py @@ -253,7 +253,7 @@ class TestCustomPipelineManager: @pytest.mark.requires_reactor def test_deprecated_process_item_spider_arg(self) -> None: class CustomPipelineManager(ItemPipelineManager): - def process_item(self, item, spider): # pylint: disable=useless-parent-delegation + def process_item(self, item: Any, spider: Spider) -> Deferred[Any]: # pylint: disable=useless-parent-delegation return super().process_item(item, spider) crawler = get_crawler(DefaultSpider) diff --git a/tests/test_robotstxt_interface.py b/tests/test_robotstxt_interface.py index bc79e3a17..6c0dd9d2c 100644 --- a/tests/test_robotstxt_interface.py +++ b/tests/test_robotstxt_interface.py @@ -8,7 +8,7 @@ from scrapy.robotstxt import ( ) -def rerp_available(): +def rerp_available() -> bool: # check if robotexclusionrulesparser is installed try: from robotexclusionrulesparser import ( # noqa: PLC0415 From 9f4651151d70bda66ccdbd2d0890e2c7e94b8d7c Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Tue, 24 Mar 2026 14:36:48 +0500 Subject: [PATCH 072/248] Deprecate ScrapyCommand.set_crawler(), remove/fix some dead code. (#7276) --- scrapy/commands/__init__.py | 10 +++++++-- scrapy/core/scraper.py | 2 +- tests/test_downloader_handler_httpx.py | 10 ++++----- tests/test_downloadermiddleware_httpcache.py | 14 ------------- tests/test_engine.py | 5 +++-- tests/test_extension_periodic_log.py | 4 ++-- tests/test_feedexport.py | 2 +- tests/test_loader.py | 11 ---------- tests/test_spidermiddleware_process_start.py | 5 +++++ tests/test_utils_defer.py | 22 +------------------- 10 files changed, 25 insertions(+), 60 deletions(-) diff --git a/scrapy/commands/__init__.py b/scrapy/commands/__init__.py index d5945f6f5..d666ca796 100644 --- a/scrapy/commands/__init__.py +++ b/scrapy/commands/__init__.py @@ -7,13 +7,14 @@ from __future__ import annotations import argparse import builtins import os +import warnings from abc import ABC, abstractmethod from pathlib import Path from typing import TYPE_CHECKING, Any from twisted.python import failure -from scrapy.exceptions import UsageError +from scrapy.exceptions import ScrapyDeprecationWarning, UsageError from scrapy.utils.conf import arglist_to_dict, feed_process_params_from_cli if TYPE_CHECKING: @@ -36,7 +37,12 @@ class ScrapyCommand(ABC): def __init__(self) -> None: self.settings: Settings | None = None # set in scrapy.cmdline - def set_crawler(self, crawler: Crawler) -> None: + def set_crawler(self, crawler: Crawler) -> None: # pragma: no cover + warnings.warn( + "ScrapyCommand.set_crawler() is deprecated", + ScrapyDeprecationWarning, + stacklevel=2, + ) if hasattr(self, "_crawler"): raise RuntimeError("crawler already set") self._crawler: Crawler = crawler diff --git a/scrapy/core/scraper.py b/scrapy/core/scraper.py index 695f6ef3e..701f7cb46 100644 --- a/scrapy/core/scraper.py +++ b/scrapy/core/scraper.py @@ -66,7 +66,7 @@ class Slot: self.queue: deque[QueueTuple] = deque() self.active: set[Request] = set() self.active_size: int = 0 - self.itemproc_size: int = 0 + self.itemproc_size: int = 0 # just for scrapy.utils.engine.get_engine_status() self.closing: Deferred[Spider] | None = None def add_response_request( diff --git a/tests/test_downloader_handler_httpx.py b/tests/test_downloader_handler_httpx.py index 233bc62fe..8d146b496 100644 --- a/tests/test_downloader_handler_httpx.py +++ b/tests/test_downloader_handler_httpx.py @@ -96,23 +96,21 @@ class TestSimpleHttps(HttpxDownloadHandlerMixin, TestSimpleHttpsBase): pass -class Https11WrongHostnameTestCase( - HttpxDownloadHandlerMixin, TestHttpsWrongHostnameBase -): +class TestHttps11WrongHostname(HttpxDownloadHandlerMixin, TestHttpsWrongHostnameBase): pass -class Https11InvalidDNSId(HttpxDownloadHandlerMixin, TestHttpsInvalidDNSIdBase): +class TestHttps11InvalidDNSId(HttpxDownloadHandlerMixin, TestHttpsInvalidDNSIdBase): pass -class Https11InvalidDNSPattern( +class TestHttps11InvalidDNSPattern( HttpxDownloadHandlerMixin, TestHttpsInvalidDNSPatternBase ): pass -class Https11CustomCiphers(HttpxDownloadHandlerMixin, TestHttpsCustomCiphersBase): +class TestHttps11CustomCiphers(HttpxDownloadHandlerMixin, TestHttpsCustomCiphersBase): pass diff --git a/tests/test_downloadermiddleware_httpcache.py b/tests/test_downloadermiddleware_httpcache.py index e94591038..548c0d8ee 100644 --- a/tests/test_downloadermiddleware_httpcache.py +++ b/tests/test_downloadermiddleware_httpcache.py @@ -89,20 +89,6 @@ class TestBase: assert response1.headers == response2.headers assert response1.body == response2.body - def assertEqualRequest(self, request1, request2): - assert request1.url == request2.url - assert request1.headers == request2.headers - assert request1.body == request2.body - - def assertEqualRequestButWithCacheValidators(self, request1, request2): - assert request1.url == request2.url - assert b"If-None-Match" not in request1.headers - assert b"If-Modified-Since" not in request1.headers - assert any( - h in request2.headers for h in (b"If-None-Match", b"If-Modified-Since") - ) - assert request1.body == request2.body - class StorageTestMixin: """Mixin containing storage-specific test methods.""" diff --git a/tests/test_engine.py b/tests/test_engine.py index 0c0432b3c..131beeead 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -26,6 +26,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.statscollectors import MemoryStatsCollector from scrapy.utils.defer import ( _schedule_coro, deferred_from_coro, @@ -42,7 +43,6 @@ if TYPE_CHECKING: from scrapy.core.scheduler import Scheduler from scrapy.crawler import Crawler - from scrapy.statscollectors import MemoryStatsCollector from tests.mockserver.http import MockServer @@ -741,7 +741,8 @@ class TestEngineCloseSpider: engine = ExecutionEngine(crawler, lambda _: None) crawler.engine = engine await engine.open_spider_async() - del cast("MemoryStatsCollector", crawler.stats).spider_stats + assert isinstance(crawler.stats, MemoryStatsCollector) + del crawler.stats.spider_stats await engine.close_spider_async() assert "Stats close failure" in caplog.text diff --git a/tests/test_extension_periodic_log.py b/tests/test_extension_periodic_log.py index 2cc23c491..86517e17b 100644 --- a/tests/test_extension_periodic_log.py +++ b/tests/test_extension_periodic_log.py @@ -98,7 +98,7 @@ class TestPeriodicLog: ext.spider_opened(spider) ext.set_a() a = ext.log_delta() - ext.set_a() + ext.set_b() b = ext.log_delta() ext.spider_closed(spider, reason="finished") return ext, a, b @@ -164,7 +164,7 @@ class TestPeriodicLog: ext.spider_opened(spider) ext.set_a() a = ext.log_crawler_stats() - ext.set_a() + ext.set_b() b = ext.log_crawler_stats() ext.spider_closed(spider, reason="finished") return ext, a, b diff --git a/tests/test_feedexport.py b/tests/test_feedexport.py index ab7341d29..ff9b0a3f9 100644 --- a/tests/test_feedexport.py +++ b/tests/test_feedexport.py @@ -288,7 +288,7 @@ class IsExportingListener: if self.start_without_finish: self.start_without_finish = False else: - self.finish_before_start = True + self.finish_without_start = True class ExceptionJsonItemExporter(JsonItemExporter): diff --git a/tests/test_loader.py b/tests/test_loader.py index 224158e7f..7cfc7ed26 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -55,17 +55,6 @@ class ProcessorItemLoader(NameItemLoader): name_in = MapCompose(lambda v: v.title()) -class DefaultedItemLoader(NameItemLoader): - default_input_processor = MapCompose(lambda v: v[:-1]) - - -# test processors -def processor_with_args(value, other=None, loader_context=None): - if "key" in loader_context: - return loader_context["key"] - return value - - class TestBasicItemLoader: def test_add_value_on_unknown_field(self): il = ProcessorItemLoader() diff --git a/tests/test_spidermiddleware_process_start.py b/tests/test_spidermiddleware_process_start.py index bccfc4432..3fbe9c94e 100644 --- a/tests/test_spidermiddleware_process_start.py +++ b/tests/test_spidermiddleware_process_start.py @@ -320,6 +320,11 @@ class TestMain: DeprecatedWrapSpider, ) + @coroutine_test + async def test_universal_mw_uses_process_start(self): + """Test that process_start_requests() isn't used when process_start() exists.""" + await self._test([UniversalSpiderMiddleware], ModernWrapSpider, [ITEM_B]) + async def _test_sleep(self, spider_middlewares): class TestSpider(Spider): name = "test" diff --git a/tests/test_utils_defer.py b/tests/test_utils_defer.py index 296bd6224..1b062d28d 100644 --- a/tests/test_utils_defer.py +++ b/tests/test_utils_defer.py @@ -6,7 +6,7 @@ from asyncio import Future from typing import TYPE_CHECKING, Any import pytest -from twisted.internet.defer import Deferred, inlineCallbacks, succeed +from twisted.internet.defer import Deferred, inlineCallbacks from scrapy.utils.asyncgen import as_async_generator, collect_asyncgen from scrapy.utils.defer import ( @@ -65,26 +65,6 @@ class TestMustbeDeferred: yield dfd -def cb1(value, arg1, arg2): - return f"(cb1 {value} {arg1} {arg2})" - - -def cb2(value, arg1, arg2): - return succeed(f"(cb2 {value} {arg1} {arg2})") - - -def cb3(value, arg1, arg2): - return f"(cb3 {value} {arg1} {arg2})" - - -def cb_fail(value, arg1, arg2): - raise TypeError - - -def eb1(failure, arg1, arg2): - return f"(eb1 {failure.value.__class__.__name__} {arg1} {arg2})" - - class TestIterErrback: def test_iter_errback_good(self): def itergood() -> Generator[int, None, None]: From d42b23d78a4f14e53837b67cdec527c4c9c48dee Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Tue, 24 Mar 2026 14:52:56 +0500 Subject: [PATCH 073/248] Await persist_file() in media pipelines. (#7182) --- docs/news.rst | 18 +++++++++ scrapy/pipelines/files.py | 32 ++++++++++----- scrapy/pipelines/images.py | 21 +++++----- scrapy/pipelines/media.py | 8 +++- tests/test_pipeline_files.py | 75 +++++++++++++++++++++++------------- tests/test_pipeline_media.py | 15 ++++++++ 6 files changed, 123 insertions(+), 46 deletions(-) diff --git a/docs/news.rst b/docs/news.rst index cbdef67fc..6bff95518 100644 --- a/docs/news.rst +++ b/docs/news.rst @@ -6,6 +6,24 @@ Release notes Scrapy VERSION (unreleased) --------------------------- +Backward-incompatible changes +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +- In order to fix a long-standing bug with handling of asynchronous storages + the following changes were made to media pipeline classes, which can impact + some of the user code that subclasses them or calls their methods directly: + + - overrides of :meth:`scrapy.pipelines.media.MediaPipeline.media_downloaded` + and :meth:`~scrapy.pipelines.files.FilesPipeline.file_downloaded` can now + return coroutines + + - :meth:`~scrapy.pipelines.files.FilesPipeline.media_downloaded`, + :meth:`~scrapy.pipelines.files.FilesPipeline.file_downloaded` and + :meth:`~scrapy.pipelines.images.ImagesPipeline.image_downloaded` now + return coroutines + + (:issue:`2183`, :issue:`6369`, :issue:`7182`) + New features ~~~~~~~~~~~~ diff --git a/scrapy/pipelines/files.py b/scrapy/pipelines/files.py index 55e01c4ab..a0bb674d1 100644 --- a/scrapy/pipelines/files.py +++ b/scrapy/pipelines/files.py @@ -31,12 +31,14 @@ from scrapy.http.request import NO_CALLBACK from scrapy.pipelines.media import FileInfo, FileInfoOrError, MediaPipeline from scrapy.utils.boto import is_botocore_available from scrapy.utils.datatypes import CaseInsensitiveDict +from scrapy.utils.defer import ensure_awaitable from scrapy.utils.ftp import ftp_store_file from scrapy.utils.log import failure_to_exc_info from scrapy.utils.python import to_bytes from scrapy.utils.request import referer_str if TYPE_CHECKING: + from collections.abc import Awaitable from os import PathLike from twisted.python.failure import Failure @@ -591,7 +593,7 @@ class FilesPipeline(MediaPipeline): raise FileException - def media_downloaded( + async def media_downloaded( self, response: Response, request: Request, @@ -630,7 +632,9 @@ class FilesPipeline(MediaPipeline): try: path = self.file_path(request, response=response, info=info, item=item) - checksum = self.file_downloaded(response, request, info, item=item) + checksum: str = await ensure_awaitable( + self.file_downloaded(response, request, info, item=item) + ) except FileException as exc: logger.warning( "File (error): Error processing file from %(request)s " @@ -662,6 +666,21 @@ class FilesPipeline(MediaPipeline): self.crawler.stats.inc_value("file_count") self.crawler.stats.inc_value(f"file_status_count/{status}") + async def _file_downloaded( + self, + response: Response, + request: Request, + info: MediaPipeline.SpiderInfo, + *, + item: Any = None, + ) -> str: + path = self.file_path(request, response=response, info=info, item=item) + buf = BytesIO(response.body) + checksum = _md5sum(buf) + buf.seek(0) + await ensure_awaitable(self.store.persist_file(path, buf, info)) + return checksum + # Overridable Interface def get_media_requests( self, item: Any, info: MediaPipeline.SpiderInfo @@ -680,13 +699,8 @@ class FilesPipeline(MediaPipeline): info: MediaPipeline.SpiderInfo, *, item: Any = None, - ) -> str: - path = self.file_path(request, response=response, info=info, item=item) - buf = BytesIO(response.body) - checksum = _md5sum(buf) - buf.seek(0) - self.store.persist_file(path, buf, info) - return checksum + ) -> str | Awaitable[str]: + return self._file_downloaded(response, request, info, item=item) def item_completed( self, results: list[FileInfoOrError], item: Any, info: MediaPipeline.SpiderInfo diff --git a/scrapy/pipelines/images.py b/scrapy/pipelines/images.py index f5d1ad1b9..e92f33cca 100644 --- a/scrapy/pipelines/images.py +++ b/scrapy/pipelines/images.py @@ -19,6 +19,7 @@ from scrapy.exceptions import NotConfigured, ScrapyDeprecationWarning from scrapy.http import Request, Response from scrapy.http.request import NO_CALLBACK from scrapy.pipelines.files import FileException, FilesPipeline, _md5sum +from scrapy.utils.defer import ensure_awaitable from scrapy.utils.python import to_bytes if TYPE_CHECKING: @@ -115,7 +116,7 @@ class ImagesPipeline(FilesPipeline): store_uri = settings["IMAGES_STORE"] return cls(store_uri, crawler=crawler) - def file_downloaded( + async def file_downloaded( self, response: Response, request: Request, @@ -123,9 +124,9 @@ class ImagesPipeline(FilesPipeline): *, item: Any = None, ) -> str: - return self.image_downloaded(response, request, info, item=item) + return await self.image_downloaded(response, request, info, item=item) - def image_downloaded( + async def image_downloaded( self, response: Response, request: Request, @@ -139,12 +140,14 @@ class ImagesPipeline(FilesPipeline): buf.seek(0) checksum = _md5sum(buf) width, height = image.size - self.store.persist_file( - path, - buf, - info, - meta={"width": width, "height": height}, - headers={"Content-Type": "image/jpeg"}, + await ensure_awaitable( + self.store.persist_file( + path, + buf, + info, + meta={"width": width, "height": height}, + headers={"Content-Type": "image/jpeg"}, + ) ) assert checksum is not None return checksum diff --git a/scrapy/pipelines/media.py b/scrapy/pipelines/media.py index 7bc29f28f..b0ee10c12 100644 --- a/scrapy/pipelines/media.py +++ b/scrapy/pipelines/media.py @@ -31,6 +31,8 @@ from scrapy.utils.python import global_object_name if TYPE_CHECKING: # typing.Self requires Python 3.11 + from collections.abc import Awaitable + from typing_extensions import Self from scrapy import Spider @@ -209,7 +211,9 @@ class MediaPipeline(ABC): self._modify_media_request(request) assert self.crawler.engine response = await self.crawler.engine.download_async(request) - return self.media_downloaded(response, request, info, item=item) + return await ensure_awaitable( + self.media_downloaded(response, request, info, item=item) + ) except Exception: failure = self.media_failed(Failure(), request, info) if isinstance(failure, Failure): @@ -282,7 +286,7 @@ class MediaPipeline(ABC): info: SpiderInfo, *, item: Any = None, - ) -> FileInfo: + ) -> FileInfo | Awaitable[FileInfo]: """Handler for success downloads""" raise NotImplementedError diff --git a/tests/test_pipeline_files.py b/tests/test_pipeline_files.py index 57736e0ea..7f59af7de 100644 --- a/tests/test_pipeline_files.py +++ b/tests/test_pipeline_files.py @@ -19,6 +19,7 @@ from urllib.parse import urlparse import attr import pytest from itemadapter import ItemAdapter +from twisted.internet.defer import Deferred from scrapy.exceptions import NotConfigured from scrapy.http import Request, Response @@ -31,6 +32,7 @@ from scrapy.pipelines.files import ( S3FilesStore, ) from scrapy.settings import Settings +from scrapy.utils.asyncio import call_later from scrapy.utils.spider import DefaultSpider from scrapy.utils.test import get_crawler from tests.mockserver.ftp import MockFTPServer @@ -78,6 +80,22 @@ def get_ftp_content_and_delete( return b"".join(ftp_data) +class DeferredFSFilesStore(FSFilesStore): + """A simple store with persist_file() returning a deferred.""" + + def persist_file(self, path, buf, info, meta=None, headers=None): + deferred = Deferred() + # short-hand super() doesn't work in nested functions + parent_persist_file = super().persist_file + + def cb(): + parent_persist_file(path, buf, info, meta=meta, headers=headers) + deferred.callback(None) + + call_later(0.5, cb) + return deferred + + class TestFilesPipeline: def setup_method(self): self.tempdir = mkdtemp() @@ -165,7 +183,7 @@ class TestFilesPipeline: async def test_file_not_expired(self): item_url = "http://example.com/file.pdf" item = _create_item_with_files(item_url) - patchers = [ + with ( mock.patch.object(FilesPipeline, "inc_stats", return_value=True), mock.patch.object( FSFilesStore, @@ -177,22 +195,16 @@ class TestFilesPipeline: "get_media_requests", return_value=[_prepare_request_object(item_url)], ), - ] - for p in patchers: - p.start() - - result = await self.pipeline.process_item(item) + ): + result = await self.pipeline.process_item(item) assert result["files"][0]["checksum"] == "abc" assert result["files"][0]["status"] == "uptodate" - for p in patchers: - p.stop() - @coroutine_test async def test_file_expired(self): item_url = "http://example.com/file2.pdf" item = _create_item_with_files(item_url) - patchers = [ + with ( mock.patch.object( FSFilesStore, "stat_file", @@ -208,22 +220,16 @@ class TestFilesPipeline: return_value=[_prepare_request_object(item_url)], ), mock.patch.object(FilesPipeline, "inc_stats", return_value=True), - ] - for p in patchers: - p.start() - - result = await self.pipeline.process_item(item) + ): + result = await self.pipeline.process_item(item) assert result["files"][0]["checksum"] != "abc" assert result["files"][0]["status"] == "downloaded" - for p in patchers: - p.stop() - @coroutine_test async def test_file_cached(self): item_url = "http://example.com/file3.pdf" item = _create_item_with_files(item_url) - patchers = [ + with ( mock.patch.object(FilesPipeline, "inc_stats", return_value=True), mock.patch.object( FSFilesStore, @@ -239,16 +245,33 @@ class TestFilesPipeline: "get_media_requests", return_value=[_prepare_request_object(item_url, flags=["cached"])], ), - ] - for p in patchers: - p.start() - - result = await self.pipeline.process_item(item) + ): + result = await self.pipeline.process_item(item) assert result["files"][0]["checksum"] != "abc" assert result["files"][0]["status"] == "cached" - for p in patchers: - p.stop() + @coroutine_test + async def test_async_store(self) -> None: + """Test that async persist_file() works and is awaited.""" + + self.pipeline.store = DeferredFSFilesStore(self.tempdir) + item_url = "http://example.com/file.pdf" + item = _create_item_with_files(item_url) + with ( + mock.patch.object(FilesPipeline, "inc_stats", return_value=True), + mock.patch.object( + FilesPipeline, + "get_media_requests", + return_value=[_prepare_request_object(item_url)], + ), + ): + result = await self.pipeline.process_item(item) + assert result["files"][0]["status"] == "downloaded" + assert result["files"][0]["checksum"] + # check that the file was written by persist_file() + path = Path(self.tempdir) / result["files"][0]["path"] + assert path.exists() + assert path.read_bytes() == b"data" def test_file_path_from_item(self): """ diff --git a/tests/test_pipeline_media.py b/tests/test_pipeline_media.py index 8229d1da1..44df0bdd4 100644 --- a/tests/test_pipeline_media.py +++ b/tests/test_pipeline_media.py @@ -204,6 +204,11 @@ class MockedMediaPipeline(UserDefinedPipeline): return item +class AsyncMediaDownloadedPipeline(MockedMediaPipeline): + async def media_downloaded(self, response, request, info, *, item=None): + return super().media_downloaded(response, request, info) + + class TestMediaPipeline(TestBaseMediaPipeline): pipeline_class = MockedMediaPipeline @@ -371,6 +376,16 @@ class TestMediaPipeline(TestBaseMediaPipeline): ) +class TestAsyncMediaDownloaded(TestMediaPipeline): + pipeline_class = AsyncMediaDownloadedPipeline + + def test_key_for_pipe(self): + assert ( + self.pipe._key_for_pipe("IMAGES", base_class_name="MediaPipeline") + == "ASYNCMEDIADOWNLOADEDPIPELINE_IMAGES" + ) + + class TestMediaPipelineAllowRedirectSettings: def _assert_request_no3xx(self, pipeline_class, settings): pipe = pipeline_class(crawler=get_crawler(None, settings)) From 6fe27ba33e9117a2d632396fe5e61d0fb32fa6cc Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Tue, 24 Mar 2026 15:29:22 +0500 Subject: [PATCH 074/248] Add more no-reactor tests (#7259) * Generic changes and scrapy bench. * scrapy check. * scrapy crawl. * scrapy fetch. * scrapy parse. * scrapy runspider. * scrapy shell. * Skip httpx tests on default-reactor. * Review requires_reactor marks. * Make test functions that require an event loop async def. * Improve test_pending_asyncio_tasks(). * Add Mac OS DNS error. * Refactor most of test_scheduler.py. * Finish refactoring DownloaderAwareSchedulerTestMixin. * Refactor test_engine_loop.py. * Add the no-reactor-extra-deps tox env, run no-reactor on macos. * Skip unhandled CancelledError when shutting down the engine. * Fix typing and pre-commit checks. * Fix typing problems in master. --------- Co-authored-by: Adrian --- .github/workflows/tests-macos.yml | 9 +- .github/workflows/tests-ubuntu.yml | 3 + scrapy/core/downloader/handlers/_httpx.py | 2 +- scrapy/core/engine.py | 4 +- scrapy/crawler.py | 4 + tests/test_command_check.py | 20 +- tests/test_command_crawl.py | 17 + tests/test_command_fetch.py | 6 + tests/test_command_parse.py | 16 + tests/test_command_runspider.py | 11 + tests/test_command_shell.py | 7 + tests/test_commands.py | 10 +- tests/test_core_downloader.py | 2 +- tests/test_crawler.py | 2 +- tests/test_downloader_handler_httpx.py | 2 + tests/test_downloader_handler_twisted_ftp.py | 2 +- .../test_downloader_handler_twisted_http10.py | 2 +- .../test_downloader_handler_twisted_http11.py | 2 +- .../test_downloader_handler_twisted_http2.py | 2 +- tests/test_downloaderslotssettings.py | 8 +- tests/test_engine.py | 4 +- tests/test_engine_loop.py | 29 +- tests/test_extension_periodic_log.py | 11 +- tests/test_extension_telnet.py | 2 +- tests/test_feedexport.py | 2 +- tests/test_feedexport_batch.py | 1 + tests/test_feedexport_storages.py | 6 +- tests/test_http2_client_protocol.py | 2 +- tests/test_logstats.py | 5 +- tests/test_mail.py | 2 +- tests/test_pipeline_files.py | 7 +- tests/test_pipelines.py | 6 +- tests/test_pqueues.py | 5 +- tests/test_scheduler.py | 333 +++++++++--------- tests/test_spidermiddleware_process_start.py | 4 +- tests/test_utils_asyncio.py | 22 +- tests/test_utils_defer.py | 46 +-- tests/test_utils_reactor.py | 6 +- tests/test_webclient.py | 1 + tests/test_zz_resources.py | 16 +- tests/utils/__init__.py | 15 +- tests/utils/decorators.py | 5 + tox.ini | 25 +- 43 files changed, 400 insertions(+), 286 deletions(-) diff --git a/.github/workflows/tests-macos.yml b/.github/workflows/tests-macos.yml index 3adf3de38..2a1c62833 100644 --- a/.github/workflows/tests-macos.yml +++ b/.github/workflows/tests-macos.yml @@ -19,6 +19,12 @@ jobs: fail-fast: false matrix: python-version: ["3.10", "3.11", "3.12", "3.13"] + env: + - TOXENV: py + include: + - python-version: '3.13' + env: + TOXENV: no-reactor steps: - uses: actions/checkout@v6 @@ -29,9 +35,10 @@ jobs: python-version: ${{ matrix.python-version }} - name: Run tests + env: ${{ matrix.env }} run: | pip install -U tox - tox -e py + tox - name: Upload coverage report uses: codecov/codecov-action@v5 diff --git a/.github/workflows/tests-ubuntu.yml b/.github/workflows/tests-ubuntu.yml index c07cd49f0..a34112698 100644 --- a/.github/workflows/tests-ubuntu.yml +++ b/.github/workflows/tests-ubuntu.yml @@ -64,6 +64,9 @@ jobs: - python-version: "3.13" env: TOXENV: extra-deps + - python-version: "3.13" + env: + TOXENV: no-reactor-extra-deps - python-version: pypy3.11 env: TOXENV: pypy3-extra-deps diff --git a/scrapy/core/downloader/handlers/_httpx.py b/scrapy/core/downloader/handlers/_httpx.py index 596e44f4c..916937152 100644 --- a/scrapy/core/downloader/handlers/_httpx.py +++ b/scrapy/core/downloader/handlers/_httpx.py @@ -72,7 +72,7 @@ class HttpxDownloadHandler(BaseHttpDownloadHandler): _DEFAULT_CONNECT_TIMEOUT = 10 def __init__(self, crawler: Crawler): - # we don't run extra-deps tests with the non-asyncio reactor + # we skip HttpxDownloadHandler tests with the non-asyncio reactor if not is_asyncio_available(): # pragma: no cover raise NotConfigured( f"{type(self).__name__} requires the asyncio support. Make" diff --git a/scrapy/core/engine.py b/scrapy/core/engine.py index dc4106963..94920e840 100644 --- a/scrapy/core/engine.py +++ b/scrapy/core/engine.py @@ -8,6 +8,7 @@ For more information see docs/topics/architecture.rst from __future__ import annotations import asyncio +import contextlib import logging import warnings from time import time @@ -197,7 +198,8 @@ class ExecutionEngine: self._start_request_processing_awaitable = asyncio.ensure_future(coro) else: self._start_request_processing_awaitable = Deferred.fromCoroutine(coro) - await maybe_deferred_to_future(self._closewait) + with contextlib.suppress(asyncio.exceptions.CancelledError): + await maybe_deferred_to_future(self._closewait) def stop(self) -> Deferred[None]: # pragma: no cover warnings.warn( diff --git a/scrapy/crawler.py b/scrapy/crawler.py index b2f08450b..2e07a3b87 100644 --- a/scrapy/crawler.py +++ b/scrapy/crawler.py @@ -143,6 +143,10 @@ class Crawler: change them here when the reactor is not used. """ self.settings.set("TELNETCONSOLE_ENABLED", False, priority="default") + for scheme in ("http", "https"): + self.settings["DOWNLOAD_HANDLERS_BASE"][scheme] = ( + "scrapy.core.downloader.handlers._httpx.HttpxDownloadHandler" + ) # Cannot use @deferred_f_from_coro_f because that relies on the reactor # being installed already, which is done within _apply_settings(), inside diff --git a/tests/test_command_check.py b/tests/test_command_check.py index dc07ed695..c34e75624 100644 --- a/tests/test_command_check.py +++ b/tests/test_command_check.py @@ -46,10 +46,17 @@ class CheckSpider(scrapy.Spider): ) def _test_contract( - self, proj_path: Path, contracts: str = "", parse_def: str = "pass" + self, + proj_path: Path, + contracts: str = "", + parse_def: str = "pass", + use_reactor: bool = True, ) -> None: self._write_contract(proj_path, contracts, parse_def) - ret, out, err = proc("check", cwd=proj_path) + args = ["check"] + if not use_reactor: + args += ["-s", "TWISTED_ENABLED=False"] + ret, out, err = proc(*args, cwd=proj_path) assert "F" not in out assert "OK" in err assert ret == 0 @@ -63,6 +70,15 @@ class CheckSpider(scrapy.Spider): """ self._test_contract(proj_path, contracts, parse_def) + def test_check_no_reactor(self, proj_path: Path) -> None: + contracts = """ + @returns requests 1 + """ + parse_def = """ + yield scrapy.Request(url='http://next-url.com') + """ + self._test_contract(proj_path, contracts, parse_def, use_reactor=False) + def test_check_returns_items_contract(self, proj_path: Path) -> None: contracts = """ @returns items 1 diff --git a/tests/test_command_crawl.py b/tests/test_command_crawl.py index 5a223d122..1b5dee961 100644 --- a/tests/test_command_crawl.py +++ b/tests/test_command_crawl.py @@ -124,3 +124,20 @@ class MySpider(scrapy.Spider): not in log ) assert "Spider closed (finished)" in log + + def test_no_reactor(self, proj_path: Path) -> None: + spider_code = """ +import scrapy + +class MySpider(scrapy.Spider): + name = 'myspider' + + async def start(self): + self.logger.debug('It works!') + return + yield +""" + log = self.get_log(spider_code, proj_path, args=("-s", "TWISTED_ENABLED=False")) + assert "[myspider] DEBUG: It works!" in log + assert "Not using a Twisted reactor" in log + assert "Spider closed (finished)" in log diff --git a/tests/test_command_fetch.py b/tests/test_command_fetch.py index 4f4f19c3c..57db7dc50 100644 --- a/tests/test_command_fetch.py +++ b/tests/test_command_fetch.py @@ -30,3 +30,9 @@ class TestFetchCommand: out = out.replace("\r", "") # required on win32 assert "Server: TwistedWeb" in out assert "Content-Type: text/plain" in out + + def test_no_reactor(self, mockserver: MockServer) -> None: + _, out, _ = proc( + "fetch", "-s", "TWISTED_ENABLED=False", mockserver.url("/text") + ) + assert out.strip() == "Works" diff --git a/tests/test_command_parse.py b/tests/test_command_parse.py index 85529e607..d612b705b 100644 --- a/tests/test_command_parse.py +++ b/tests/test_command_parse.py @@ -513,3 +513,19 @@ ITEM_PIPELINES = {{'{self.project_name}.pipelines.MyPipeline': 1}} assert namespace.depth == 2 assert namespace.spider == self.spider_name assert namespace.verbose + + def test_no_reactor(self, proj_path: Path, mockserver: MockServer) -> None: + _, out, stderr = proc( + "parse", + "--spider", + "asyncdef_asyncio_return", + "-c", + "parse", + mockserver.url("/html"), + "-s", + "TWISTED_ENABLED=False", + cwd=proj_path, + ) + assert "INFO: Got response 200" in stderr + assert "{'id': 1}" in out + assert "{'id': 2}" in out diff --git a/tests/test_command_runspider.py b/tests/test_command_runspider.py index b5408ccfe..9bc65f152 100644 --- a/tests/test_command_runspider.py +++ b/tests/test_command_runspider.py @@ -213,6 +213,17 @@ class MySpider(scrapy.Spider): in log ) + def test_no_reactor(self, tmp_path: Path) -> None: + log = self.get_log( + tmp_path, + self.debug_log_spider, + args=[ + "-s", + "TWISTED_ENABLED=False", + ], + ) + assert "Not using a Twisted reactor" in log + def test_output(self, tmp_path: Path) -> None: spider_code = """ import scrapy diff --git a/tests/test_command_shell.py b/tests/test_command_shell.py index 25262d809..ee515ede4 100644 --- a/tests/test_command_shell.py +++ b/tests/test_command_shell.py @@ -125,6 +125,13 @@ class TestShellCommand: assert ret == 0, err assert "RuntimeError: There is no current event loop in thread" not in err + @pytest.mark.xfail(reason="Not implemented yet", strict=True) + def test_shell_fetch_no_reactor(self, mockserver: MockServer) -> None: + url = mockserver.url("/html") + code = f"fetch('{url}')" + ret, _, err = proc("shell", "-c", code, "--set", "TWISTED_ENABLED=False") + assert ret == 0, err + class TestInteractiveShell: def test_fetch(self, mockserver: MockServer) -> None: diff --git a/tests/test_commands.py b/tests/test_commands.py index 07bd0b45c..209ffdc08 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -345,14 +345,18 @@ Unknown command: abc class TestBenchCommand: - def test_run(self) -> None: - _, _, err = proc( + @pytest.mark.parametrize("use_reactor", [True, False]) + def test_run(self, use_reactor: bool) -> None: + args: list[str] = [ "bench", "-s", "LOGSTATS_INTERVAL=0.001", "-s", "CLOSESPIDER_TIMEOUT=0.01", - ) + ] + if not use_reactor: + args += ["-s", "TWISTED_ENABLED=False"] + _, _, err = proc(*args) assert "INFO: Crawled" in err assert "Unhandled Error" not in err assert "log_count/ERROR" not in err diff --git a/tests/test_core_downloader.py b/tests/test_core_downloader.py index 35de52a4b..92991fb8f 100644 --- a/tests/test_core_downloader.py +++ b/tests/test_core_downloader.py @@ -39,7 +39,7 @@ class TestSlot: assert repr(slot) == "Slot(concurrency=8, delay=0.10, randomize_delay=True)" -@pytest.mark.requires_reactor +@pytest.mark.requires_reactor # this test is related to the Twisted HTTP code class TestContextFactoryBase: context_factory: ContextFactory | None = None diff --git a/tests/test_crawler.py b/tests/test_crawler.py index 5c053089d..f4b4fbec9 100644 --- a/tests/test_crawler.py +++ b/tests/test_crawler.py @@ -666,7 +666,7 @@ class NoRequestsSpider(scrapy.Spider): yield -@pytest.mark.requires_reactor +@pytest.mark.requires_reactor # CrawlerRunner requires a reactor class TestCrawlerRunnerHasSpider: @staticmethod def _runner() -> CrawlerRunnerBase: diff --git a/tests/test_downloader_handler_httpx.py b/tests/test_downloader_handler_httpx.py index 8d146b496..1c11a1034 100644 --- a/tests/test_downloader_handler_httpx.py +++ b/tests/test_downloader_handler_httpx.py @@ -26,6 +26,8 @@ if TYPE_CHECKING: from tests.mockserver.http import MockServer +pytestmark = pytest.mark.only_asyncio + pytest.importorskip("httpx") diff --git a/tests/test_downloader_handler_twisted_ftp.py b/tests/test_downloader_handler_twisted_ftp.py index 957ad35c9..f39e5ecaf 100644 --- a/tests/test_downloader_handler_twisted_ftp.py +++ b/tests/test_downloader_handler_twisted_ftp.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: from twisted.protocols.ftp import FTPFactory -pytestmark = pytest.mark.requires_reactor +pytestmark = pytest.mark.requires_reactor # FTPDownloadHandler requires a reactor class TestFTPBase(ABC): diff --git a/tests/test_downloader_handler_twisted_http10.py b/tests/test_downloader_handler_twisted_http10.py index 99f3697f8..a1cd0b7a6 100644 --- a/tests/test_downloader_handler_twisted_http10.py +++ b/tests/test_downloader_handler_twisted_http10.py @@ -16,7 +16,7 @@ if TYPE_CHECKING: from tests.mockserver.http import MockServer -pytestmark = pytest.mark.requires_reactor +pytestmark = pytest.mark.requires_reactor # HTTP10DownloadHandler requires a reactor class HTTP10DownloadHandlerMixin: diff --git a/tests/test_downloader_handler_twisted_http11.py b/tests/test_downloader_handler_twisted_http11.py index ffaa2c569..954dcd246 100644 --- a/tests/test_downloader_handler_twisted_http11.py +++ b/tests/test_downloader_handler_twisted_http11.py @@ -23,7 +23,7 @@ if TYPE_CHECKING: from scrapy.core.downloader.handlers import DownloadHandlerProtocol -pytestmark = pytest.mark.requires_reactor +pytestmark = pytest.mark.requires_reactor # HTTP11DownloadHandler requires a reactor class HTTP11DownloadHandlerMixin: diff --git a/tests/test_downloader_handler_twisted_http2.py b/tests/test_downloader_handler_twisted_http2.py index 1b9dc21e7..b14356453 100644 --- a/tests/test_downloader_handler_twisted_http2.py +++ b/tests/test_downloader_handler_twisted_http2.py @@ -29,7 +29,7 @@ if TYPE_CHECKING: pytestmark = [ - pytest.mark.requires_reactor, + pytest.mark.requires_reactor, # H2DownloadHandler requires a reactor pytest.mark.skipif( not H2_ENABLED, reason="HTTP/2 support in Twisted is not enabled" ), diff --git a/tests/test_downloaderslotssettings.py b/tests/test_downloaderslotssettings.py index 1a3221dc9..a717b18a6 100644 --- a/tests/test_downloaderslotssettings.py +++ b/tests/test_downloaderslotssettings.py @@ -85,8 +85,8 @@ class TestCrawl: assert max(list(error_delta.values())) < tolerance -@pytest.mark.requires_reactor # needs a reactor or an event loop for Downloader._slot_gc_loop -def test_params(): +@coroutine_test +async def test_params(): params = { "concurrency": 1, "delay": 2, @@ -110,8 +110,8 @@ def test_params(): ) -@pytest.mark.requires_reactor # needs a reactor or an event loop for Downloader._slot_gc_loop -def test_get_slot_deprecated_spider_arg(): +@coroutine_test +async def test_get_slot_deprecated_spider_arg(): crawler = get_crawler(DefaultSpider) crawler.spider = crawler._create_spider() downloader = Downloader(crawler) diff --git a/tests/test_engine.py b/tests/test_engine.py index 131beeead..2cd583721 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -605,8 +605,8 @@ class TestEngineDownload(TestEngineDownloadAsync): return await maybe_deferred_to_future(engine.download(request)) -@pytest.mark.requires_reactor # needs a reactor or an event loop for _Slot.heartbeat -def test_request_scheduled_signal(caplog): +@coroutine_test +async def test_request_scheduled_signal(caplog): class TestScheduler(BaseScheduler): def __init__(self): self.enqueued = [] diff --git a/tests/test_engine_loop.py b/tests/test_engine_loop.py index 115deb821..8b5705487 100644 --- a/tests/test_engine_loop.py +++ b/tests/test_engine_loop.py @@ -4,30 +4,21 @@ from collections import deque from logging import ERROR from typing import TYPE_CHECKING -import pytest -from twisted.internet.defer import Deferred - from scrapy import Request, Spider, signals -from scrapy.utils.defer import maybe_deferred_to_future +from scrapy.utils.asyncio import call_later from scrapy.utils.test import get_crawler from tests.mockserver.http import MockServer from tests.test_scheduler import MemoryScheduler +from tests.utils import async_sleep from tests.utils.decorators import coroutine_test if TYPE_CHECKING: + import pytest + from scrapy.http import Response -async def sleep(seconds: float = 0.001) -> None: - from twisted.internet import reactor - - deferred: Deferred[None] = Deferred() - reactor.callLater(seconds, deferred.callback, None) - await maybe_deferred_to_future(deferred) - - class TestMain: - @pytest.mark.requires_reactor # TODO @coroutine_test async def test_sleep(self): """Neither asynchronous sleeps on Spider.start() nor the equivalent on @@ -40,27 +31,25 @@ class TestMain: name = "test" async def start(self): - from twisted.internet import reactor - yield Request("data:,a") - await sleep(seconds) + await async_sleep(seconds) self.crawler.engine._slot.scheduler.pause() self.crawler.engine._slot.scheduler.enqueue_request(Request("data:,b")) # During this time, the scheduler reports having requests but # returns None. - await sleep(seconds) + await async_sleep(seconds) self.crawler.engine._slot.scheduler.unpause() # The scheduler request is processed. - await sleep(seconds) + await async_sleep(seconds) yield Request("data:,c") - await sleep(seconds) + await async_sleep(seconds) self.crawler.engine._slot.scheduler.pause() self.crawler.engine._slot.scheduler.enqueue_request(Request("data:,d")) @@ -69,7 +58,7 @@ class TestMain: # delayed call below, proving that the start iteration can # finish before a scheduler “sleep” without causing the # scheduler to finish. - reactor.callLater(seconds, self.crawler.engine._slot.scheduler.unpause) + call_later(seconds, self.crawler.engine._slot.scheduler.unpause) def parse(self, response): pass diff --git a/tests/test_extension_periodic_log.py b/tests/test_extension_periodic_log.py index 86517e17b..18782fb70 100644 --- a/tests/test_extension_periodic_log.py +++ b/tests/test_extension_periodic_log.py @@ -3,12 +3,11 @@ from __future__ import annotations import datetime from typing import TYPE_CHECKING, Any -import pytest - from scrapy.extensions.periodic_log import PeriodicLog from scrapy.utils.test import get_crawler from .spiders import MetaSpider +from .utils.decorators import coroutine_test if TYPE_CHECKING: from collections.abc import Callable @@ -88,8 +87,8 @@ class TestPeriodicLog: assert extension({"PERIODIC_LOG_DELTA": True, "LOGSTATS_INTERVAL": 60}) assert extension({"PERIODIC_LOG_DELTA": "True", "LOGSTATS_INTERVAL": 60}) - @pytest.mark.requires_reactor # needs a reactor or an event loop for PeriodicLog.task - def test_log_delta(self): + @coroutine_test + async def test_log_delta(self): def emulate( settings: dict[str, Any] | None = None, ) -> tuple[PeriodicLog, dict[str, Any], dict[str, Any]]: @@ -154,8 +153,8 @@ class TestPeriodicLog: ), ) - @pytest.mark.requires_reactor # needs a reactor or an event loop for PeriodicLog.task - def test_log_stats(self): + @coroutine_test + async def test_log_stats(self): def emulate( settings: dict[str, Any] | None = None, ) -> tuple[PeriodicLog, dict[str, Any], dict[str, Any]]: diff --git a/tests/test_extension_telnet.py b/tests/test_extension_telnet.py index a956ab184..f1c86ce62 100644 --- a/tests/test_extension_telnet.py +++ b/tests/test_extension_telnet.py @@ -6,7 +6,7 @@ from scrapy.extensions.telnet import TelnetConsole from scrapy.utils.test import get_crawler from tests.utils.decorators import inline_callbacks_test -pytestmark = pytest.mark.requires_reactor +pytestmark = pytest.mark.requires_reactor # TelnetConsole requires a reactor class TestTelnetExtension: diff --git a/tests/test_feedexport.py b/tests/test_feedexport.py index ff9b0a3f9..e50b2be42 100644 --- a/tests/test_feedexport.py +++ b/tests/test_feedexport.py @@ -1151,7 +1151,7 @@ class TestFeedExport(TestFeedExportBase): data = await self.exported_no_data(settings) assert data["csv"] == b"" - @pytest.mark.requires_reactor # needs a reactor for BlockingFeedStorage + @pytest.mark.requires_reactor # TODO: needs a reactor for BlockingFeedStorage @coroutine_test async def test_multiple_feeds_success_logs_blocking_feed_storage(self): settings = { diff --git a/tests/test_feedexport_batch.py b/tests/test_feedexport_batch.py index 3b70e896c..16c4357bb 100644 --- a/tests/test_feedexport_batch.py +++ b/tests/test_feedexport_batch.py @@ -381,6 +381,7 @@ class TestBatchDeliveries(TestFeedExportBase): assert "feedexport/success_count/FileFeedStorage" in crawler.stats.get_stats() assert crawler.stats.get_value("feedexport/success_count/FileFeedStorage") == 12 + @pytest.mark.requires_reactor # TODO: needs a reactor for BlockingFeedStorage @pytest.mark.requires_boto3 @inline_callbacks_test def test_s3_export(self): diff --git a/tests/test_feedexport_storages.py b/tests/test_feedexport_storages.py index b4da9bc68..c5cd5c782 100644 --- a/tests/test_feedexport_storages.py +++ b/tests/test_feedexport_storages.py @@ -116,7 +116,7 @@ class TestFileFeedStorage: assert storage.path == path -@pytest.mark.requires_reactor # needs a reactor for BlockingFeedStorage +@pytest.mark.requires_reactor # TODO: needs a reactor for BlockingFeedStorage class TestFTPFeedStorage: def get_test_spider(self, settings=None): class TestSpider(scrapy.Spider): @@ -231,7 +231,7 @@ class TestBlockingFeedStorage: @pytest.mark.requires_boto3 -@pytest.mark.requires_reactor # needs a reactor for BlockingFeedStorage +@pytest.mark.requires_reactor # TODO: needs a reactor for BlockingFeedStorage class TestS3FeedStorage: def test_parse_credentials(self): aws_credentials = { @@ -461,7 +461,7 @@ class TestS3FeedStorage: assert "S3 does not support appending to files" in str(log) -@pytest.mark.requires_reactor # needs a reactor for BlockingFeedStorage +@pytest.mark.requires_reactor # TODO: needs a reactor for BlockingFeedStorage class TestGCSFeedStorage: def test_parse_settings(self): try: diff --git a/tests/test_http2_client_protocol.py b/tests/test_http2_client_protocol.py index 49b9180b9..65da89472 100644 --- a/tests/test_http2_client_protocol.py +++ b/tests/test_http2_client_protocol.py @@ -40,7 +40,7 @@ if TYPE_CHECKING: pytestmark = [ - pytest.mark.requires_reactor, + pytest.mark.requires_reactor, # H2ClientProtocol requires a reactor pytest.mark.skipif( not H2_ENABLED, reason="HTTP/2 support in Twisted is not enabled" ), diff --git a/tests/test_logstats.py b/tests/test_logstats.py index c2e6d3456..370728e6a 100644 --- a/tests/test_logstats.py +++ b/tests/test_logstats.py @@ -5,6 +5,7 @@ import pytest from scrapy.extensions.logstats import LogStats from scrapy.utils.test import get_crawler from tests.spiders import SimpleSpider +from tests.utils.decorators import coroutine_test class TestLogStats: @@ -16,8 +17,8 @@ class TestLogStats: self.stats.set_value("response_received_count", 4802) self.stats.set_value("item_scraped_count", 3201) - @pytest.mark.requires_reactor # needs a reactor or an event loop for LogStats.task - def test_stats_calculations(self): + @coroutine_test + async def test_stats_calculations(self): logstats = LogStats.from_crawler(self.crawler) with pytest.raises(AttributeError): diff --git a/tests/test_mail.py b/tests/test_mail.py index c845ba320..61136dfbe 100644 --- a/tests/test_mail.py +++ b/tests/test_mail.py @@ -8,7 +8,7 @@ from twisted.internet._sslverify import ClientTLSOptions from scrapy.mail import MailSender -@pytest.mark.requires_reactor +@pytest.mark.requires_reactor # MailSender requires a reactor class TestMailSender: def test_send(self): mailsender = MailSender(debug=True) diff --git a/tests/test_pipeline_files.py b/tests/test_pipeline_files.py index 7f59af7de..201b29f83 100644 --- a/tests/test_pipeline_files.py +++ b/tests/test_pipeline_files.py @@ -584,6 +584,7 @@ class TestFilesPipelineCustomSettings: assert fs_store.basedir == str(tmp_path) +@pytest.mark.requires_reactor # TODO: needs a reactor for S3FilesStore @pytest.mark.requires_botocore class TestS3FilesStore: @inline_callbacks_test @@ -714,7 +715,7 @@ class TestGCSFilesStore: store.bucket.get_blob.assert_called_with(expected_blob_path) -@pytest.mark.requires_reactor # needs a reactor for FTPFilesStore +@pytest.mark.requires_reactor # TODO: needs a reactor for FTPFilesStore class TestFTPFileStore: @inline_callbacks_test def test_persist(self): @@ -752,13 +753,13 @@ class ItemWithFiles(Item): files = Field() -def _create_item_with_files(*files): +def _create_item_with_files(*files: str) -> ItemWithFiles: item = ItemWithFiles() item["file_urls"] = files return item -def _prepare_request_object(item_url, flags=None): +def _prepare_request_object(item_url: str, flags: list[str] | None = None) -> Request: return Request( item_url, meta={"response": Response(item_url, status=200, body=b"data", flags=flags)}, diff --git a/tests/test_pipelines.py b/tests/test_pipelines.py index 2d5836ad6..e65b5bc32 100644 --- a/tests/test_pipelines.py +++ b/tests/test_pipelines.py @@ -248,10 +248,8 @@ class TestPipeline: class TestCustomPipelineManager: - # needs a reactor or an event loop for is_asyncio_available() - # (for ItemPipelineManager.process_item()) - @pytest.mark.requires_reactor - def test_deprecated_process_item_spider_arg(self) -> None: + @coroutine_test + async def test_deprecated_process_item_spider_arg(self) -> None: class CustomPipelineManager(ItemPipelineManager): def process_item(self, item: Any, spider: Spider) -> Deferred[Any]: # pylint: disable=useless-parent-delegation return super().process_item(item, spider) diff --git a/tests/test_pqueues.py b/tests/test_pqueues.py index 2d42ba791..350b3e10d 100644 --- a/tests/test_pqueues.py +++ b/tests/test_pqueues.py @@ -1,4 +1,5 @@ import tempfile +from unittest.mock import Mock import pytest import queuelib @@ -9,7 +10,7 @@ from scrapy.spiders import Spider from scrapy.squeues import FifoMemoryQueue from scrapy.utils.misc import build_from_crawler, load_object from scrapy.utils.test import get_crawler -from tests.test_scheduler import MockDownloader, MockEngine +from tests.test_scheduler import MockDownloader class TestPriorityQueue: @@ -98,7 +99,7 @@ class TestPriorityQueue: class TestDownloaderAwarePriorityQueue: def setup_method(self): crawler = get_crawler(Spider) - crawler.engine = MockEngine(downloader=MockDownloader()) + crawler.engine = Mock(downloader=MockDownloader()) self.queue = DownloaderAwarePriorityQueue.from_crawler( crawler=crawler, downstream_queue_cls=FifoMemoryQueue, diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index 0d2d8be93..7fec479f3 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -1,11 +1,11 @@ from __future__ import annotations -import shutil -import tempfile import warnings from abc import ABC, abstractmethod from collections import deque -from typing import Any, NamedTuple +from contextlib import AbstractAsyncContextManager, asynccontextmanager +from typing import TYPE_CHECKING, Any, NamedTuple +from unittest.mock import Mock import pytest @@ -14,12 +14,16 @@ from scrapy.core.scheduler import BaseScheduler, Scheduler from scrapy.crawler import Crawler from scrapy.http import Request from scrapy.spiders import Spider -from scrapy.utils.defer import _schedule_coro +from scrapy.utils.defer import ensure_awaitable from scrapy.utils.httpobj import urlparse_cached from scrapy.utils.misc import load_object from scrapy.utils.test import get_crawler from tests.mockserver.http import MockServer -from tests.utils.decorators import inline_callbacks_test +from tests.utils.decorators import coroutine_test, inline_callbacks_test + +if TYPE_CHECKING: + from collections.abc import AsyncGenerator + from pathlib import Path class MemoryScheduler(BaseScheduler): @@ -54,78 +58,62 @@ class MemoryScheduler(BaseScheduler): self.paused = False -class MockEngine(NamedTuple): - downloader: MockDownloader - - class MockSlot(NamedTuple): active: list[Any] class MockDownloader: - def __init__(self): - self.slots = {} + def __init__(self) -> None: + self.slots: dict[str, MockSlot] = {} - def get_slot_key(self, request): + def get_slot_key(self, request: Request) -> str: if Downloader.DOWNLOAD_SLOT in request.meta: return request.meta[Downloader.DOWNLOAD_SLOT] return urlparse_cached(request).hostname or "" - def increment(self, slot_key): + def increment(self, slot_key: str) -> None: slot = self.slots.setdefault(slot_key, MockSlot(active=[])) slot.active.append(1) - def decrement(self, slot_key): - slot = self.slots.get(slot_key) + def decrement(self, slot_key: str) -> None: + slot = self.slots[slot_key] slot.active.pop() - def close(self): + def close(self) -> None: pass class MockCrawler(Crawler): - def __init__(self, priority_queue_cls, jobdir): + def __init__(self, priority_queue_cls: str, jobdir: Path | None): settings = { "SCHEDULER_DEBUG": False, "SCHEDULER_DISK_QUEUE": "scrapy.squeues.PickleLifoDiskQueue", "SCHEDULER_MEMORY_QUEUE": "scrapy.squeues.LifoMemoryQueue", "SCHEDULER_PRIORITY_QUEUE": priority_queue_cls, - "JOBDIR": jobdir, + "JOBDIR": str(jobdir) if jobdir is not None else None, "DUPEFILTER_CLASS": "scrapy.dupefilters.BaseDupeFilter", } super().__init__(Spider, settings) - self.engine = MockEngine(downloader=MockDownloader()) + self.engine = Mock(downloader=MockDownloader()) self.stats = load_object(self.settings["STATS_CLASS"])(self) -# needs a reactor or an event loop for is_asyncio_available() -# (for _schedule_coro()) -@pytest.mark.requires_reactor -class SchedulerHandler(ABC): - jobdir = None - - @property - @abstractmethod - def priority_queue_cls(self) -> str: - raise NotImplementedError - - def create_scheduler(self): - self.mock_crawler = MockCrawler(self.priority_queue_cls, self.jobdir) - self.scheduler = Scheduler.from_crawler(self.mock_crawler) - self.spider = Spider(name="spider") - self.scheduler.open(self.spider) - - def close_scheduler(self): - self.scheduler.close("finished") - _schedule_coro(self.mock_crawler.stop_async()) - self.mock_crawler.engine.downloader.close() - - def setup_method(self): - self.create_scheduler() - - def teardown_method(self): - self.close_scheduler() +@asynccontextmanager +async def create_scheduler( + priority_queue_cls: str, jobdir: Path | None +) -> AsyncGenerator[Scheduler]: + mock_crawler = MockCrawler(priority_queue_cls, jobdir) + scheduler = Scheduler.from_crawler(mock_crawler) + spider = Spider(name="spider") + await ensure_awaitable(scheduler.open(spider)) + try: + yield scheduler + finally: + await ensure_awaitable(scheduler.close("finished")) + await mock_crawler.stop_async() + assert mock_crawler.engine + mock_crawler.engine.downloader.close() _PRIORITIES = [ @@ -140,99 +128,118 @@ _PRIORITIES = [ _URLS = {"http://foo.com/a", "http://foo.com/b", "http://foo.com/c"} -class TestSchedulerInMemoryBase(SchedulerHandler): - def test_length(self): - assert not self.scheduler.has_pending_requests() - assert len(self.scheduler) == 0 +class TestSchedulerBase(ABC): + @property + @abstractmethod + def priority_queue_cls(self) -> str: + raise NotImplementedError - for url in _URLS: - self.scheduler.enqueue_request(Request(url)) + @pytest.fixture + def jobdir(self) -> Path | None: + return None - assert self.scheduler.has_pending_requests() - assert len(self.scheduler) == len(_URLS) + def create_scheduler( + self, jobdir: Path | None + ) -> AbstractAsyncContextManager[Scheduler]: + return create_scheduler(self.priority_queue_cls, jobdir) - def test_dequeue(self): - for url in _URLS: - self.scheduler.enqueue_request(Request(url)) + # TODO: unify test methods using "reopen" like in DownloaderAwareSchedulerTestMixin - urls = set() - while self.scheduler.has_pending_requests(): - urls.add(self.scheduler.next_request().url) + +class TestSchedulerInMemoryBase(TestSchedulerBase): + @coroutine_test + async def test_length(self, jobdir: Path | None) -> None: + async with self.create_scheduler(jobdir) as scheduler: + assert not scheduler.has_pending_requests() + assert len(scheduler) == 0 + + for url in _URLS: + scheduler.enqueue_request(Request(url)) + + assert scheduler.has_pending_requests() + assert len(scheduler) == len(_URLS) + + @coroutine_test + async def test_dequeue(self, jobdir: Path | None) -> None: + async with self.create_scheduler(jobdir) as scheduler: + for url in _URLS: + scheduler.enqueue_request(Request(url)) + + urls = set() + while scheduler.has_pending_requests(): + request = scheduler.next_request() + assert request is not None + urls.add(request.url) assert urls == _URLS - def test_dequeue_priorities(self): - for url, priority in _PRIORITIES: - self.scheduler.enqueue_request(Request(url, priority=priority)) + @coroutine_test + async def test_dequeue_priorities(self, jobdir: Path | None) -> None: + async with self.create_scheduler(jobdir) as scheduler: + for url, priority in _PRIORITIES: + scheduler.enqueue_request(Request(url, priority=priority)) - priorities = [] - while self.scheduler.has_pending_requests(): - priorities.append(self.scheduler.next_request().priority) + priorities = [] + while scheduler.has_pending_requests(): + request = scheduler.next_request() + assert request is not None + priorities.append(request.priority) assert priorities == sorted([x[1] for x in _PRIORITIES], key=lambda x: -x) -class TestSchedulerOnDiskBase(SchedulerHandler): - def setup_method(self): - self.jobdir = tempfile.mkdtemp() - self.create_scheduler() +class TestSchedulerOnDiskBase(TestSchedulerBase): + @pytest.fixture + def jobdir(self, tmp_path: Path) -> Path | None: + return tmp_path - def teardown_method(self): - self.close_scheduler() + @coroutine_test + async def test_length(self, jobdir: Path | None) -> None: + async with self.create_scheduler(jobdir) as scheduler: + assert not scheduler.has_pending_requests() + assert len(scheduler) == 0 + for url in _URLS: + scheduler.enqueue_request(Request(url)) - shutil.rmtree(self.jobdir) - self.jobdir = None + async with self.create_scheduler(jobdir) as scheduler: + assert scheduler.has_pending_requests() + assert len(scheduler) == len(_URLS) - def test_length(self): - assert not self.scheduler.has_pending_requests() - assert len(self.scheduler) == 0 - - for url in _URLS: - self.scheduler.enqueue_request(Request(url)) - - self.close_scheduler() - self.create_scheduler() - - assert self.scheduler.has_pending_requests() - assert len(self.scheduler) == len(_URLS) - - def test_dequeue(self): - for url in _URLS: - self.scheduler.enqueue_request(Request(url)) - - self.close_scheduler() - self.create_scheduler() + @coroutine_test + async def test_dequeue(self, jobdir: Path | None) -> None: + async with self.create_scheduler(jobdir) as scheduler: + for url in _URLS: + scheduler.enqueue_request(Request(url)) urls = set() - while self.scheduler.has_pending_requests(): - urls.add(self.scheduler.next_request().url) - + async with self.create_scheduler(jobdir) as scheduler: + while scheduler.has_pending_requests(): + request = scheduler.next_request() + assert request is not None + urls.add(request.url) assert urls == _URLS - def test_dequeue_priorities(self): - for url, priority in _PRIORITIES: - self.scheduler.enqueue_request(Request(url, priority=priority)) - - self.close_scheduler() - self.create_scheduler() + @coroutine_test + async def test_dequeue_priorities(self, jobdir: Path | None) -> None: + async with self.create_scheduler(jobdir) as scheduler: + for url, priority in _PRIORITIES: + scheduler.enqueue_request(Request(url, priority=priority)) priorities = [] - while self.scheduler.has_pending_requests(): - priorities.append(self.scheduler.next_request().priority) - + async with self.create_scheduler(jobdir) as scheduler: + while scheduler.has_pending_requests(): + request = scheduler.next_request() + assert request is not None + priorities.append(request.priority) assert priorities == sorted([x[1] for x in _PRIORITIES], key=lambda x: -x) class TestSchedulerInMemory(TestSchedulerInMemoryBase): - @property - def priority_queue_cls(self) -> str: - return "scrapy.pqueues.ScrapyPriorityQueue" + priority_queue_cls = "scrapy.pqueues.ScrapyPriorityQueue" class TestSchedulerOnDisk(TestSchedulerOnDiskBase): - @property - def priority_queue_cls(self) -> str: - return "scrapy.pqueues.ScrapyPriorityQueue" + priority_queue_cls = "scrapy.pqueues.ScrapyPriorityQueue" _URLS_WITH_SLOTS = [ @@ -246,39 +253,25 @@ _URLS_WITH_SLOTS = [ class TestMigration: - # needs a reactor or an event loop for is_asyncio_available() - # (for _schedule_coro()) - @pytest.mark.requires_reactor - def test_migration(self, tmpdir): - class PrevSchedulerHandler(SchedulerHandler): - jobdir = tmpdir + @coroutine_test + async def test_migration(self, tmp_path: Path) -> None: + async with create_scheduler( + "scrapy.pqueues.ScrapyPriorityQueue", tmp_path + ) as prev_scheduler: + for url in _URLS: + prev_scheduler.enqueue_request(Request(url)) - @property - def priority_queue_cls(self) -> str: - return "scrapy.pqueues.ScrapyPriorityQueue" - - class NextSchedulerHandler(SchedulerHandler): - jobdir = tmpdir - - @property - def priority_queue_cls(self) -> str: - return "scrapy.pqueues.DownloaderAwarePriorityQueue" - - prev_scheduler_handler = PrevSchedulerHandler() - prev_scheduler_handler.create_scheduler() - for url in _URLS: - prev_scheduler_handler.scheduler.enqueue_request(Request(url)) - prev_scheduler_handler.close_scheduler() - - next_scheduler_handler = NextSchedulerHandler() with pytest.raises( ValueError, match="DownloaderAwarePriorityQueue accepts ``slot_startprios`` as a dict", ): - next_scheduler_handler.create_scheduler() + async with create_scheduler( + "scrapy.pqueues.DownloaderAwarePriorityQueue", tmp_path + ): + pass -def _is_scheduling_fair(enqueued_slots, dequeued_slots): +def _is_scheduling_fair(enqueued_slots: list[str], dequeued_slots: list[str]) -> bool: """ We enqueued same number of requests for every slot. Assert correct order, e.g. @@ -303,39 +296,49 @@ def _is_scheduling_fair(enqueued_slots, dequeued_slots): return True -class DownloaderAwareSchedulerTestMixin: +class DownloaderAwareSchedulerTestMixin(TestSchedulerBase): reopen = False + priority_queue_cls = "scrapy.pqueues.DownloaderAwarePriorityQueue" - @property - def priority_queue_cls(self) -> str: - return "scrapy.pqueues.DownloaderAwarePriorityQueue" + @coroutine_test + async def test_logic(self, jobdir: Path | None) -> None: + def _setup(scheduler: Scheduler) -> None: + for url, slot in _URLS_WITH_SLOTS: + request = Request(url) + request.meta[Downloader.DOWNLOAD_SLOT] = slot + scheduler.enqueue_request(request) - def test_logic(self): - for url, slot in _URLS_WITH_SLOTS: - request = Request(url) - request.meta[Downloader.DOWNLOAD_SLOT] = slot - self.scheduler.enqueue_request(request) + def _assert(scheduler: Scheduler) -> None: + dequeued_slots: list[str] = [] + requests: list[Request] = [] + assert scheduler.crawler + assert scheduler.crawler.engine + downloader = scheduler.crawler.engine.downloader + assert isinstance(downloader, MockDownloader) + while scheduler.has_pending_requests(): + request = scheduler.next_request() + assert request is not None + slot = downloader.get_slot_key(request) + dequeued_slots.append(slot) + downloader.increment(slot) + requests.append(request) + + for request in requests: + slot = downloader.get_slot_key(request) + downloader.decrement(slot) + + assert _is_scheduling_fair([s for u, s in _URLS_WITH_SLOTS], dequeued_slots) + assert sum(len(s.active) for s in downloader.slots.values()) == 0 if self.reopen: - self.close_scheduler() - self.create_scheduler() - - dequeued_slots = [] - requests = [] - downloader = self.mock_crawler.engine.downloader - while self.scheduler.has_pending_requests(): - request = self.scheduler.next_request() - slot = downloader.get_slot_key(request) - dequeued_slots.append(slot) - downloader.increment(slot) - requests.append(request) - - for request in requests: - slot = downloader.get_slot_key(request) - downloader.decrement(slot) - - assert _is_scheduling_fair([s for u, s in _URLS_WITH_SLOTS], dequeued_slots) - assert sum(len(s.active) for s in downloader.slots.values()) == 0 + async with self.create_scheduler(jobdir) as scheduler: + _setup(scheduler) + async with self.create_scheduler(jobdir) as scheduler: + _assert(scheduler) + else: + async with self.create_scheduler(jobdir) as scheduler: + _setup(scheduler) + _assert(scheduler) class TestSchedulerWithDownloaderAwareInMemory( diff --git a/tests/test_spidermiddleware_process_start.py b/tests/test_spidermiddleware_process_start.py index 3fbe9c94e..ddece52c4 100644 --- a/tests/test_spidermiddleware_process_start.py +++ b/tests/test_spidermiddleware_process_start.py @@ -346,12 +346,12 @@ class TestMain: [NoOpSpiderMiddleware, AsyncioSleepSpiderMiddleware, NoOpSpiderMiddleware] ) - @pytest.mark.requires_reactor + @pytest.mark.requires_reactor # needs a reactor for twisted_sleep() @coroutine_test async def test_twisted_sleep_single(self): await self._test_sleep([TwistedSleepSpiderMiddleware]) - @pytest.mark.requires_reactor + @pytest.mark.requires_reactor # needs a reactor for twisted_sleep() @coroutine_test async def test_twisted_sleep_multiple(self): await self._test_sleep( diff --git a/tests/test_utils_asyncio.py b/tests/test_utils_asyncio.py index 5489fd948..a871a282e 100644 --- a/tests/test_utils_asyncio.py +++ b/tests/test_utils_asyncio.py @@ -21,10 +21,10 @@ if TYPE_CHECKING: class TestAsyncio: - @pytest.mark.requires_reactor # needs a reactor or an event loop for is_asyncio_available() - def test_is_asyncio_available(self, reactor_pytest: str) -> None: + @coroutine_test + async def test_is_asyncio_available(self, reactor_pytest: str) -> None: # the result should depend only on the pytest --reactor argument - assert is_asyncio_available() == (reactor_pytest == "asyncio") + assert is_asyncio_available() == (reactor_pytest != "default") @pytest.mark.only_asyncio @@ -104,10 +104,10 @@ class TestParallelAsyncio: assert max_parallel_count[0] <= self.CONCURRENT_ITEMS -@pytest.mark.requires_reactor # needs a running event loop for AsyncioLoopingCall.start() @pytest.mark.only_asyncio class TestAsyncioLoopingCall: - def test_looping_call(self): + @coroutine_test + async def test_looping_call(self): func = mock.MagicMock() looping_call = AsyncioLoopingCall(func) looping_call.start(1, now=False) @@ -116,21 +116,24 @@ class TestAsyncioLoopingCall: assert not looping_call.running assert not func.called - def test_looping_call_now(self): + @coroutine_test + async def test_looping_call_now(self): func = mock.MagicMock() looping_call = AsyncioLoopingCall(func) looping_call.start(1) looping_call.stop() assert func.called - def test_looping_call_already_running(self): + @coroutine_test + async def test_looping_call_already_running(self): looping_call = AsyncioLoopingCall(lambda: None) looping_call.start(1) with pytest.raises(RuntimeError): looping_call.start(1) looping_call.stop() - def test_looping_call_interval(self): + @coroutine_test + async def test_looping_call_interval(self): looping_call = AsyncioLoopingCall(lambda: None) with pytest.raises(ValueError, match="Interval must be greater than 0"): looping_call.start(0) @@ -138,7 +141,8 @@ class TestAsyncioLoopingCall: looping_call.start(-1) assert not looping_call.running - def test_looping_call_bad_function(self): + @coroutine_test + async def test_looping_call_bad_function(self): looping_call = AsyncioLoopingCall(Deferred) with pytest.raises(TypeError): looping_call.start(0.1) diff --git a/tests/test_utils_defer.py b/tests/test_utils_defer.py index 1b062d28d..978c24f5a 100644 --- a/tests/test_utils_defer.py +++ b/tests/test_utils_defer.py @@ -19,13 +19,13 @@ from scrapy.utils.defer import ( mustbe_deferred, parallel_async, ) -from tests.utils.decorators import inline_callbacks_test +from tests.utils.decorators import coroutine_test, inline_callbacks_test if TYPE_CHECKING: from collections.abc import AsyncGenerator, Awaitable, Callable, Generator -@pytest.mark.requires_reactor +@pytest.mark.requires_reactor # mustbe_deferred() requires a reactor @pytest.mark.filterwarnings("ignore::scrapy.exceptions.ScrapyDeprecationWarning") class TestMustbeDeferred: @inline_callbacks_test @@ -89,9 +89,8 @@ class TestIterErrback: assert isinstance(errors[0].value, ZeroDivisionError) -@pytest.mark.requires_reactor class TestAiterErrback: - @deferred_f_from_coro_f + @coroutine_test async def test_aiter_errback_good(self): async def itergood() -> AsyncGenerator[int, None]: for x in range(10): @@ -102,7 +101,7 @@ class TestAiterErrback: assert out == list(range(10)) assert not errors - @deferred_f_from_coro_f + @coroutine_test async def test_iter_errback_bad(self): async def iterbad() -> AsyncGenerator[int, None]: for x in range(10): @@ -117,23 +116,18 @@ class TestAiterErrback: assert isinstance(errors[0].value, ZeroDivisionError) -@pytest.mark.requires_reactor class TestAsyncDefTestsuite: - @deferred_f_from_coro_f - async def test_deferred_f_from_coro_f(self): + @coroutine_test + async def test_coroutine_test(self): pass - @deferred_f_from_coro_f - async def test_deferred_f_from_coro_f_generator(self): - yield - @pytest.mark.xfail(reason="Checks that the test is actually executed", strict=True) - @deferred_f_from_coro_f - async def test_deferred_f_from_coro_f_xfail(self): + @coroutine_test + async def test_coroutine_test_xfail(self): raise RuntimeError("This is expected to be raised") -@pytest.mark.requires_reactor +@pytest.mark.requires_reactor # parallel_async() requires a reactor class TestParallelAsync: """This tests _AsyncCooperatorAdapter by testing parallel_async which is its only usage. @@ -327,9 +321,8 @@ class TestDeferredFFromCoroF: @pytest.mark.only_asyncio -@pytest.mark.requires_reactor class TestDeferredToFuture: - @deferred_f_from_coro_f + @coroutine_test async def test_deferred(self): d = Deferred() result = deferred_to_future(d) @@ -338,7 +331,7 @@ class TestDeferredToFuture: future_result = await result assert future_result == 42 - @deferred_f_from_coro_f + @coroutine_test async def test_wrapped_coroutine(self): async def c_f() -> int: return 42 @@ -349,7 +342,7 @@ class TestDeferredToFuture: future_result = await result assert future_result == 42 - @deferred_f_from_coro_f + @coroutine_test async def test_wrapped_coroutine_asyncio(self): async def c_f() -> int: await asyncio.sleep(0.01) @@ -363,11 +356,8 @@ class TestDeferredToFuture: @pytest.mark.only_asyncio -# needs a reactor or an event loop for is_asyncio_available() -# (for maybe_deferred_to_future()) -@pytest.mark.requires_reactor class TestMaybeDeferredToFutureAsyncio: - @deferred_f_from_coro_f + @coroutine_test async def test_deferred(self): d = Deferred() result = maybe_deferred_to_future(d) @@ -376,7 +366,7 @@ class TestMaybeDeferredToFutureAsyncio: future_result = await result assert future_result == 42 - @deferred_f_from_coro_f + @coroutine_test async def test_wrapped_coroutine(self): async def c_f() -> int: return 42 @@ -387,7 +377,7 @@ class TestMaybeDeferredToFutureAsyncio: future_result = await result assert future_result == 42 - @deferred_f_from_coro_f + @coroutine_test async def test_wrapped_coroutine_asyncio(self): async def c_f() -> int: await asyncio.sleep(0.01) @@ -401,11 +391,9 @@ class TestMaybeDeferredToFutureAsyncio: @pytest.mark.only_not_asyncio -# needs a reactor or an event loop for is_asyncio_available() -# (for maybe_deferred_to_future()) -@pytest.mark.requires_reactor class TestMaybeDeferredToFutureNotAsyncio: - def test_deferred(self): + @coroutine_test + async def test_deferred(self): d = Deferred() result = maybe_deferred_to_future(d) assert isinstance(result, Deferred) diff --git a/tests/test_utils_reactor.py b/tests/test_utils_reactor.py index 3255d5940..d11fbc582 100644 --- a/tests/test_utils_reactor.py +++ b/tests/test_utils_reactor.py @@ -13,12 +13,12 @@ from tests.utils.decorators import coroutine_test class TestAsyncio: - @pytest.mark.requires_reactor + @pytest.mark.requires_reactor # needs a reactor def test_is_asyncio_reactor_installed(self, reactor_pytest: str) -> None: # the result should depend only on the pytest --reactor argument assert is_asyncio_reactor_installed() == (reactor_pytest == "asyncio") - @pytest.mark.requires_reactor + @pytest.mark.requires_reactor # installs a reactor def test_install_asyncio_reactor(self): from twisted.internet import reactor as original_reactor @@ -29,7 +29,7 @@ class TestAsyncio: assert original_reactor == reactor - @pytest.mark.requires_reactor + @pytest.mark.requires_reactor # installs a reactor @pytest.mark.only_asyncio @coroutine_test async def test_set_asyncio_event_loop(self): diff --git a/tests/test_webclient.py b/tests/test_webclient.py index 7834e3b31..b14d15be4 100644 --- a/tests/test_webclient.py +++ b/tests/test_webclient.py @@ -30,6 +30,7 @@ from tests.mockserver.http_resources import ( from tests.mockserver.utils import ssl_context_factory from tests.test_core_downloader import TestContextFactoryBase +# these tests are related to the Twisted HTTP code pytestmark = pytest.mark.requires_reactor diff --git a/tests/test_zz_resources.py b/tests/test_zz_resources.py index e6ff46f5c..1faa2487a 100644 --- a/tests/test_zz_resources.py +++ b/tests/test_zz_resources.py @@ -9,6 +9,7 @@ import pytest from scrapy.utils.log import LogCounterHandler from scrapy.utils.reactor import is_asyncio_reactor_installed, is_reactor_installed +from tests.utils.decorators import coroutine_test def test_counter_handler() -> None: @@ -31,11 +32,20 @@ def test_stderr_log_handler() -> None: assert c == 0 -@pytest.mark.requires_reactor # needs a running event loop for asyncio.all_tasks() @pytest.mark.only_asyncio -def test_pending_asyncio_tasks() -> None: +@coroutine_test +async def test_pending_asyncio_tasks() -> None: """Test that there are no pending asyncio tasks.""" - assert not asyncio.all_tasks() + # note that pytest-asyncio uses separate loops per function so this isn't as useful there + tasks = [] + for t in asyncio.all_tasks(): + coro = t.get_coro() + if ( + coro is not None + and getattr(coro, "__name__", None) != "test_pending_asyncio_tasks" + ): + tasks.append(t) + assert not tasks def test_installed_reactor(reactor_pytest: str) -> None: diff --git a/tests/utils/__init__.py b/tests/utils/__init__.py index a81f2d1a2..5b7848d54 100644 --- a/tests/utils/__init__.py +++ b/tests/utils/__init__.py @@ -1,17 +1,28 @@ +import asyncio import os from pathlib import Path from twisted.internet.defer import Deferred +from scrapy.utils.asyncio import is_asyncio_available +from scrapy.utils.defer import maybe_deferred_to_future -def twisted_sleep(seconds): + +def twisted_sleep(seconds: float): from twisted.internet import reactor - d = Deferred() + d: Deferred[None] = Deferred() reactor.callLater(seconds, d.callback, None) return d +async def async_sleep(seconds: float) -> None: + if is_asyncio_available(): + await asyncio.sleep(seconds) + else: + await maybe_deferred_to_future(twisted_sleep(seconds)) + + def get_script_run_env() -> dict[str, str]: """Return a OS environment dict suitable to run scripts shipped with tests.""" diff --git a/tests/utils/decorators.py b/tests/utils/decorators.py index dd9043ab1..4750158e5 100644 --- a/tests/utils/decorators.py +++ b/tests/utils/decorators.py @@ -55,6 +55,11 @@ def coroutine_test( * with ``pytest-twisted`` this converts a coroutine into a :class:`twisted.internet.defer.Deferred` * with ``pytest-asyncio`` this is a no-op + + In addition to handling asynchronous test functions this can also be used + to mark "synchronous" test functions (they still need to be made + ``async def``) that call code that needs a reactor or a running event loop, + so that ``pytest-asyncio`` starts a loop for them too. """ if not is_reactor_installed(): diff --git a/tox.ini b/tox.ini index 1bebaf2aa..ca304330c 100644 --- a/tox.ini +++ b/tox.ini @@ -11,6 +11,7 @@ minversion = 1.7.0 deps = attrs coverage >= 7.10.6 + httpx pexpect >= 4.8.0 pyftpdlib >= 2.0.1 pygments @@ -107,6 +108,7 @@ deps = Twisted==21.7.0 cryptography==37.0.0 cssselect==0.9.1 + httpx==0.26.0 itemadapter==0.1.0 lxml==4.6.4 parsel==1.5.0 @@ -170,14 +172,6 @@ commands = {[pinned]commands} commands = {[testenv]commands} --reactor=default -[testenv:no-reactor] -deps = - {[testenv]deps} - httpx - pytest-asyncio -commands = - {[testenv]commands} -p no:twisted --reactor=none - [testenv:default-reactor-pinned] basepython = {[pinned]basepython} deps = {[testenv:pinned]deps} @@ -185,11 +179,24 @@ commands = {[pinned]commands} --reactor=default setenv = {[pinned]setenv} +[testenv:no-reactor] +deps = + {[testenv]deps} + pytest-asyncio +commands = + {[testenv]commands} -p no:twisted --reactor=none + +[testenv:no-reactor-extra-deps] +deps = + {[testenv:extra-deps]deps} + pytest-asyncio +commands = + {[testenv]commands} -p no:twisted --reactor=none + [testenv:no-reactor-pinned] basepython = {[pinned]basepython} deps = {[testenv:pinned]deps} - httpx==0.26.0 pytest-asyncio commands = {[pinned]commands} -p no:twisted --reactor=none setenv = From bfe34492fa41d7d8284d671f15125a38dc4f8d92 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Tue, 24 Mar 2026 19:41:15 +0500 Subject: [PATCH 075/248] asyncio alternative to deferToThread() (#7349) --- scrapy/extensions/feedexport.py | 5 +-- scrapy/pipelines/files.py | 70 ++++++++++++++++--------------- scrapy/utils/asyncio.py | 21 ++++++++++ scrapy/utils/decorators.py | 10 +++-- tests/test_feedexport.py | 1 - tests/test_feedexport_batch.py | 1 - tests/test_feedexport_storages.py | 3 -- tests/test_pipeline_files.py | 2 - 8 files changed, 67 insertions(+), 46 deletions(-) diff --git a/scrapy/extensions/feedexport.py b/scrapy/extensions/feedexport.py index 86593aba8..fa928678e 100644 --- a/scrapy/extensions/feedexport.py +++ b/scrapy/extensions/feedexport.py @@ -21,14 +21,13 @@ from typing import IO, TYPE_CHECKING, Any, Protocol, TypeAlias, cast from urllib.parse import unquote, urlparse from twisted.internet.defer import Deferred, DeferredList -from twisted.internet.threads import deferToThread from w3lib.url import file_uri_to_path from zope.interface import Interface, implementer from scrapy import Spider, signals from scrapy.exceptions import NotConfigured, ScrapyDeprecationWarning from scrapy.extensions.postprocessing import PostProcessingManager -from scrapy.utils.asyncio import is_asyncio_available +from scrapy.utils.asyncio import is_asyncio_available, run_in_thread from scrapy.utils.conf import feed_complete_default_values_from_settings from scrapy.utils.defer import deferred_from_coro, ensure_awaitable from scrapy.utils.ftp import ftp_store_file @@ -131,7 +130,7 @@ class BlockingFeedStorage(ABC): return NamedTemporaryFile(prefix="feed-", dir=path) def store(self, file: IO[bytes]) -> Deferred[None] | None: - return deferToThread(self._store_in_thread, file) + return deferred_from_coro(run_in_thread(self._store_in_thread, file)) @abstractmethod def _store_in_thread(self, file: IO[bytes]) -> None: diff --git a/scrapy/pipelines/files.py b/scrapy/pipelines/files.py index a0bb674d1..707207337 100644 --- a/scrapy/pipelines/files.py +++ b/scrapy/pipelines/files.py @@ -23,15 +23,15 @@ from urllib.parse import urlparse from itemadapter import ItemAdapter from twisted.internet.defer import Deferred, maybeDeferred -from twisted.internet.threads import deferToThread from scrapy.exceptions import IgnoreRequest, NotConfigured, ScrapyDeprecationWarning from scrapy.http import Request, Response from scrapy.http.request import NO_CALLBACK from scrapy.pipelines.media import FileInfo, FileInfoOrError, MediaPipeline +from scrapy.utils.asyncio import run_in_thread from scrapy.utils.boto import is_botocore_available from scrapy.utils.datatypes import CaseInsensitiveDict -from scrapy.utils.defer import ensure_awaitable +from scrapy.utils.defer import deferred_from_coro, ensure_awaitable from scrapy.utils.ftp import ftp_store_file from scrapy.utils.log import failure_to_exc_info from scrapy.utils.python import to_bytes @@ -198,13 +198,12 @@ class S3FilesStore: def _get_boto_key(self, path: str) -> Deferred[dict[str, Any]]: key_name = f"{self.prefix}{path}" - return cast( - "Deferred[dict[str, Any]]", - deferToThread( + return deferred_from_coro( + run_in_thread( self.s3_client.head_object, # type: ignore[attr-defined] Bucket=self.bucket, Key=key_name, - ), + ) ) def persist_file( @@ -221,14 +220,16 @@ class S3FilesStore: extra = self._headers_to_botocore_kwargs(self.HEADERS) if headers: extra.update(self._headers_to_botocore_kwargs(headers)) - return deferToThread( - self.s3_client.put_object, # type: ignore[attr-defined] - Bucket=self.bucket, - Key=key_name, - Body=buf, - Metadata={k: str(v) for k, v in (meta or {}).items()}, - ACL=self.POLICY, - **extra, + return deferred_from_coro( + run_in_thread( + self.s3_client.put_object, # type: ignore[attr-defined] + Bucket=self.bucket, + Key=key_name, + Body=buf, + Metadata={k: str(v) for k, v in (meta or {}).items()}, + ACL=self.POLICY, + **extra, + ) ) def _headers_to_botocore_kwargs(self, headers: dict[str, Any]) -> dict[str, Any]: @@ -315,10 +316,9 @@ class GCSFilesStore: return {} blob_path = self._get_blob_path(path) - return cast( - "Deferred[StatInfo]", - deferToThread(self.bucket.get_blob, blob_path).addCallback(_onsuccess), - ) + return deferred_from_coro( + run_in_thread(self.bucket.get_blob, blob_path) + ).addCallback(_onsuccess) def _get_content_type(self, headers: dict[str, str] | None) -> str: if headers and "Content-Type" in headers: @@ -340,11 +340,13 @@ class GCSFilesStore: blob = self.bucket.blob(blob_path) blob.cache_control = self.CACHE_CONTROL blob.metadata = {k: str(v) for k, v in (meta or {}).items()} - return deferToThread( - blob.upload_from_string, - data=buf.getvalue(), - content_type=self._get_content_type(headers), - predefined_acl=self.POLICY, + return deferred_from_coro( + run_in_thread( + blob.upload_from_string, + data=buf.getvalue(), + content_type=self._get_content_type(headers), + predefined_acl=self.POLICY, + ) ) @@ -377,15 +379,17 @@ class FTPFilesStore: headers: dict[str, str] | None = None, ) -> Deferred[Any]: path = f"{self.basedir}/{path}" - return deferToThread( - ftp_store_file, - path=path, - file=buf, - host=self.host, - port=self.port, - username=self.username, - password=self.password, - use_active_mode=self.USE_ACTIVE_MODE, + return deferred_from_coro( + run_in_thread( + ftp_store_file, + path=path, + file=buf, + host=self.host, + port=self.port, + username=self.username, + password=self.password, + use_active_mode=bool(self.USE_ACTIVE_MODE), + ) ) def stat_file( @@ -407,7 +411,7 @@ class FTPFilesStore: except Exception: return {} - return cast("Deferred[StatInfo]", deferToThread(_stat_file, path)) + return deferred_from_coro(run_in_thread(_stat_file, path)) class FilesPipeline(MediaPipeline): diff --git a/scrapy/utils/asyncio.py b/scrapy/utils/asyncio.py index 9ec090995..7852a10fd 100644 --- a/scrapy/utils/asyncio.py +++ b/scrapy/utils/asyncio.py @@ -10,6 +10,7 @@ from typing import TYPE_CHECKING, Any, Concatenate, ParamSpec, TypeVar from twisted.internet.defer import Deferred from twisted.internet.task import LoopingCall +from twisted.internet.threads import deferToThread from scrapy.utils.asyncgen import as_async_generator from scrapy.utils.reactor import is_asyncio_reactor_installed, is_reactor_installed @@ -278,3 +279,23 @@ class CallLaterResult: elif self._delayed_call and self._delayed_call.active(): self._delayed_call.cancel() self._delayed_call = None + + +async def run_in_thread( + func: Callable[_P, _T], *args: _P.args, **kwargs: _P.kwargs +) -> _T: + """Call a function in a thread and return its result as a coroutine. + + This uses either :func:`asyncio.to_thread` or + :func:`twisted.internet.threads.deferToThread`, depending on whether + asyncio support is available. + + .. versionadded:: VERSION + """ + if is_asyncio_available(): + return await asyncio.to_thread(func, *args, **kwargs) + + # circular import + from scrapy.utils.defer import maybe_deferred_to_future # noqa: PLC0415 + + return await maybe_deferred_to_future(deferToThread(func, *args, **kwargs)) diff --git a/scrapy/utils/decorators.py b/scrapy/utils/decorators.py index 044d1d4c7..f1e274190 100644 --- a/scrapy/utils/decorators.py +++ b/scrapy/utils/decorators.py @@ -6,9 +6,10 @@ from functools import wraps from typing import TYPE_CHECKING, Any, ParamSpec, TypeVar, overload from twisted.internet.defer import Deferred, maybeDeferred -from twisted.internet.threads import deferToThread from scrapy.exceptions import ScrapyDeprecationWarning +from scrapy.utils.asyncio import run_in_thread +from scrapy.utils.defer import deferred_from_coro if TYPE_CHECKING: from collections.abc import AsyncGenerator, Callable, Coroutine @@ -59,12 +60,15 @@ def defers(func: Callable[_P, _T]) -> Callable[_P, Deferred[_T]]: # pragma: no def inthread(func: Callable[_P, _T]) -> Callable[_P, Deferred[_T]]: """Decorator to call a function in a thread and return a deferred with the - result + result. + + .. versionchanged:: VERSION + Now uses :func:`asyncio.to_thread` if the asyncio support is available. """ @wraps(func) def wrapped(*a: _P.args, **kw: _P.kwargs) -> Deferred[_T]: - return deferToThread(func, *a, **kw) + return deferred_from_coro(run_in_thread(func, *a, **kw)) return wrapped diff --git a/tests/test_feedexport.py b/tests/test_feedexport.py index e50b2be42..dcf412c0f 100644 --- a/tests/test_feedexport.py +++ b/tests/test_feedexport.py @@ -1151,7 +1151,6 @@ class TestFeedExport(TestFeedExportBase): data = await self.exported_no_data(settings) assert data["csv"] == b"" - @pytest.mark.requires_reactor # TODO: needs a reactor for BlockingFeedStorage @coroutine_test async def test_multiple_feeds_success_logs_blocking_feed_storage(self): settings = { diff --git a/tests/test_feedexport_batch.py b/tests/test_feedexport_batch.py index 16c4357bb..3b70e896c 100644 --- a/tests/test_feedexport_batch.py +++ b/tests/test_feedexport_batch.py @@ -381,7 +381,6 @@ class TestBatchDeliveries(TestFeedExportBase): assert "feedexport/success_count/FileFeedStorage" in crawler.stats.get_stats() assert crawler.stats.get_value("feedexport/success_count/FileFeedStorage") == 12 - @pytest.mark.requires_reactor # TODO: needs a reactor for BlockingFeedStorage @pytest.mark.requires_boto3 @inline_callbacks_test def test_s3_export(self): diff --git a/tests/test_feedexport_storages.py b/tests/test_feedexport_storages.py index c5cd5c782..5102f8e37 100644 --- a/tests/test_feedexport_storages.py +++ b/tests/test_feedexport_storages.py @@ -116,7 +116,6 @@ class TestFileFeedStorage: assert storage.path == path -@pytest.mark.requires_reactor # TODO: needs a reactor for BlockingFeedStorage class TestFTPFeedStorage: def get_test_spider(self, settings=None): class TestSpider(scrapy.Spider): @@ -231,7 +230,6 @@ class TestBlockingFeedStorage: @pytest.mark.requires_boto3 -@pytest.mark.requires_reactor # TODO: needs a reactor for BlockingFeedStorage class TestS3FeedStorage: def test_parse_credentials(self): aws_credentials = { @@ -461,7 +459,6 @@ class TestS3FeedStorage: assert "S3 does not support appending to files" in str(log) -@pytest.mark.requires_reactor # TODO: needs a reactor for BlockingFeedStorage class TestGCSFeedStorage: def test_parse_settings(self): try: diff --git a/tests/test_pipeline_files.py b/tests/test_pipeline_files.py index 201b29f83..c412645f4 100644 --- a/tests/test_pipeline_files.py +++ b/tests/test_pipeline_files.py @@ -584,7 +584,6 @@ class TestFilesPipelineCustomSettings: assert fs_store.basedir == str(tmp_path) -@pytest.mark.requires_reactor # TODO: needs a reactor for S3FilesStore @pytest.mark.requires_botocore class TestS3FilesStore: @inline_callbacks_test @@ -715,7 +714,6 @@ class TestGCSFilesStore: store.bucket.get_blob.assert_called_with(expected_blob_path) -@pytest.mark.requires_reactor # TODO: needs a reactor for FTPFilesStore class TestFTPFileStore: @inline_callbacks_test def test_persist(self): From 54a4c3af899b8c05c6d2545fa27af664bc231e0c Mon Sep 17 00:00:00 2001 From: Kaileshwar R K <159027881+Kaileshwar16@users.noreply.github.com> Date: Wed, 25 Mar 2026 02:01:12 +0530 Subject: [PATCH 076/248] Deprecate the mail API (#7263) --- docs/index.rst | 4 - docs/topics/coroutines.rst | 23 +--- docs/topics/email.rst | 185 ---------------------------- docs/topics/extensions.rst | 30 +---- docs/topics/settings.rst | 48 ++------ docs/topics/signals.rst | 12 ++ scrapy/extensions/memusage.py | 18 ++- scrapy/extensions/statsmailer.py | 9 +- scrapy/mail.py | 11 +- scrapy/signals.py | 1 + tests/test_extension_statsmailer.py | 10 +- tests/test_mail.py | 12 +- 12 files changed, 83 insertions(+), 280 deletions(-) delete mode 100644 docs/topics/email.rst diff --git a/docs/index.rst b/docs/index.rst index fe61f3513..a46a2ad9f 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -128,7 +128,6 @@ Built-in services topics/logging topics/stats - topics/email topics/telnetconsole :doc:`topics/logging` @@ -137,9 +136,6 @@ Built-in services :doc:`topics/stats` Collect statistics about your scraping crawler. -:doc:`topics/email` - Send email notifications when certain events occur. - :doc:`topics/telnetconsole` Inspect a running crawler using a built-in Python console. diff --git a/docs/topics/coroutines.rst b/docs/topics/coroutines.rst index a2dd67f68..ea3a60b4c 100644 --- a/docs/topics/coroutines.rst +++ b/docs/topics/coroutines.rst @@ -73,12 +73,6 @@ In the future we plan to add support for the ``async def`` syntax to these APIs or replace them with other APIs where changing the existing ones isn't possible. -These APIs don't have a coroutine-based counterpart: - -- :class:`~scrapy.mail.MailSender` - - - :meth:`~scrapy.mail.MailSender.send` - These APIs have a coroutine-based implementation and a Deferred-based one: - :class:`scrapy.crawler.Crawler`: @@ -137,18 +131,11 @@ wrapping a :class:`~twisted.internet.defer.Deferred` object into a :class:`~asyncio.Future` object or vice versa. See :ref:`asyncio-await-dfd` for more information about this. -For example: - -- The :meth:`MailSender.send() ` method returns - a :class:`~twisted.internet.defer.Deferred` object that fires when the - email is sent. You can use this object directly in Deferred-based code or - convert it into a :class:`~asyncio.Future` object with - :func:`~scrapy.utils.defer.maybe_deferred_to_future`. -- A custom scheduler needs to define an ``open()`` method that can return a - :class:`~twisted.internet.defer.Deferred` object. You can write a method - that works with Deferreds and returns one directly, or you can write a - coroutine and convert it into a function that returns a Deferred with - :func:`~scrapy.utils.defer.deferred_f_from_coro_f`. +For example: a custom scheduler needs to define an ``open()`` method that can +return a :class:`~twisted.internet.defer.Deferred` object. You can write a +method that works with Deferreds and returns one directly, or you can write a +coroutine and convert it into a function that returns a Deferred with +:func:`~scrapy.utils.defer.deferred_f_from_coro_f`. General usage diff --git a/docs/topics/email.rst b/docs/topics/email.rst deleted file mode 100644 index 1d7bad787..000000000 --- a/docs/topics/email.rst +++ /dev/null @@ -1,185 +0,0 @@ -.. _topics-email: - -============== -Sending e-mail -============== - -.. module:: scrapy.mail - :synopsis: Email sending facility - -Although Python makes sending e-mails relatively easy via the :mod:`smtplib` -library, Scrapy provides its own facility for sending e-mails which is very -easy to use and it's implemented using :doc:`Twisted non-blocking IO -`, to avoid interfering with the non-blocking -IO of the crawler. It also provides a simple API for sending attachments and -it's very easy to configure, with a few :ref:`settings -`. - -Quick example -============= - -There are two ways to instantiate the mail sender. You can instantiate it using -the standard ``__init__`` method: - -.. code-block:: python - - from scrapy.mail import MailSender - - mailer = MailSender() - -Or you can instantiate it passing a :class:`scrapy.Crawler` instance, which -will respect the :ref:`settings `: - -.. skip: start -.. code-block:: python - - mailer = MailSender.from_crawler(crawler) - -And here is how to use it to send an e-mail (without attachments): - -.. code-block:: python - - mailer.send( - to=["someone@example.com"], - subject="Some subject", - body="Some body", - cc=["another@example.com"], - ) -.. skip: end - -MailSender class reference -========================== - -The MailSender :ref:`components ` is the preferred class to -use for sending emails from Scrapy, as it uses :doc:`Twisted non-blocking IO -`, like the rest of the framework. - -.. class:: MailSender(smtphost=None, mailfrom=None, smtpuser=None, smtppass=None, smtpport=None) - - :param smtphost: the SMTP host to use for sending the emails. If omitted, the - :setting:`MAIL_HOST` setting will be used. - :type smtphost: str - - :param mailfrom: the address used to send emails (in the ``From:`` header). - If omitted, the :setting:`MAIL_FROM` setting will be used. - :type mailfrom: str - - :param smtpuser: the SMTP user. If omitted, the :setting:`MAIL_USER` - setting will be used. If not given, no SMTP authentication will be - performed. - :type smtphost: str or bytes - - :param smtppass: the SMTP pass for authentication. - :type smtppass: str or bytes - - :param smtpport: the SMTP port to connect to - :type smtpport: int - - :param smtptls: enforce using SMTP STARTTLS - :type smtptls: bool - - :param smtpssl: enforce using a secure SSL connection - :type smtpssl: bool - - .. method:: send(to, subject, body, cc=None, attachs=(), mimetype='text/plain', charset=None) - - Send email to the given recipients. - - :param to: the e-mail recipients as a string or as a list of strings - :type to: str or list - - :param subject: the subject of the e-mail - :type subject: str - - :param cc: the e-mails to CC as a string or as a list of strings - :type cc: str or list - - :param body: the e-mail body - :type body: str - - :param attachs: an iterable of tuples ``(attach_name, mimetype, - file_object)`` where ``attach_name`` is a string with the name that will - appear on the e-mail's attachment, ``mimetype`` is the mimetype of the - attachment and ``file_object`` is a readable file object with the - contents of the attachment - :type attachs: collections.abc.Iterable - - :param mimetype: the MIME type of the e-mail - :type mimetype: str - - :param charset: the character encoding to use for the e-mail contents - :type charset: str - - -.. _topics-email-settings: - -Mail settings -============= - -These settings define the default ``__init__`` method values of the :class:`MailSender` -class, and can be used to configure e-mail notifications in your project without -writing any code (for those extensions and code that uses :class:`MailSender`). - -.. setting:: MAIL_FROM - -MAIL_FROM ---------- - -Default: ``'scrapy@localhost'`` - -Sender email to use (``From:`` header) for sending emails. - -.. setting:: MAIL_HOST - -MAIL_HOST ---------- - -Default: ``'localhost'`` - -SMTP host to use for sending emails. - -.. setting:: MAIL_PORT - -MAIL_PORT ---------- - -Default: ``25`` - -SMTP port to use for sending emails. - -.. setting:: MAIL_USER - -MAIL_USER ---------- - -Default: ``None`` - -User to use for SMTP authentication. If disabled no SMTP authentication will be -performed. - -.. setting:: MAIL_PASS - -MAIL_PASS ---------- - -Default: ``None`` - -Password to use for SMTP authentication, along with :setting:`MAIL_USER`. - -.. setting:: MAIL_TLS - -MAIL_TLS --------- - -Default: ``False`` - -Enforce using STARTTLS. STARTTLS is a way to take an existing insecure connection, and upgrade it to a secure connection using SSL/TLS. - -.. setting:: MAIL_SSL - -MAIL_SSL --------- - -Default: ``False`` - -Enforce connecting using an SSL encrypted connection diff --git a/docs/topics/extensions.rst b/docs/topics/extensions.rst index 0fbd22a27..f0a8bb5ed 100644 --- a/docs/topics/extensions.rst +++ b/docs/topics/extensions.rst @@ -175,20 +175,16 @@ Memory usage extension Monitors the memory used by the Scrapy process that runs the spider and: -1. sends a notification e-mail when it exceeds a certain value -2. closes the spider when it exceeds a certain value - -The notification e-mails can be triggered when a certain warning value is -reached (:setting:`MEMUSAGE_WARNING_MB`) and when the maximum value is reached -(:setting:`MEMUSAGE_LIMIT_MB`) which will also cause the spider to be closed -and the Scrapy process to be terminated. +1. sends a :signal:`memusage_warning_reached` signal when it exceeds + :setting:`MEMUSAGE_WARNING_MB` +2. closes the spider with the `"memusage_exceeded"` reason when it exceeds + :setting:`MEMUSAGE_LIMIT_MB` This extension is enabled by the :setting:`MEMUSAGE_ENABLED` setting and can be configured with the following settings: * :setting:`MEMUSAGE_LIMIT_MB` * :setting:`MEMUSAGE_WARNING_MB` -* :setting:`MEMUSAGE_NOTIFY_MAIL` * :setting:`MEMUSAGE_CHECK_INTERVAL_SECONDS` Memory debugger extension @@ -332,24 +328,6 @@ closing the spider. If the spider generates more than that number of errors, it will be closed with the reason ``closespider_errorcount``. If zero (or non set), spiders won't be closed by number of errors. -StatsMailer extension -~~~~~~~~~~~~~~~~~~~~~ - -.. module:: scrapy.extensions.statsmailer - :synopsis: StatsMailer extension - -.. class:: StatsMailer - -This simple extension can be used to send a notification e-mail every time a -domain has finished scraping, including the Scrapy stats collected. The email -will be sent to all recipients specified in the :setting:`STATSMAILER_RCPTS` -setting. - -Emails can be sent using the :class:`~scrapy.mail.MailSender` class. To see a -full list of parameters, including examples on how to instantiate -:class:`~scrapy.mail.MailSender` and use mail settings, see -:ref:`topics-email`. - .. module:: scrapy.extensions.debug :synopsis: Extensions for debugging Scrapy diff --git a/docs/topics/settings.rst b/docs/topics/settings.rst index 2be319ed2..6e8d0b54f 100644 --- a/docs/topics/settings.rst +++ b/docs/topics/settings.rst @@ -1562,13 +1562,12 @@ MEMUSAGE_ENABLED Default: ``True`` -Scope: ``scrapy.extensions.memusage`` +Scope: ``scrapy.extensions.memusage.MemoryUsage`` Whether to enable the memory usage extension. This extension keeps track of a peak memory used by the process (it writes it to stats). It can also optionally shutdown the Scrapy process when it exceeds a memory limit -(see :setting:`MEMUSAGE_LIMIT_MB`), and notify by email when that happened -(see :setting:`MEMUSAGE_NOTIFY_MAIL`). +(see :setting:`MEMUSAGE_LIMIT_MB`). See :ref:`topics-extensions-ref-memusage`. @@ -1579,10 +1578,11 @@ MEMUSAGE_LIMIT_MB Default: ``0`` -Scope: ``scrapy.extensions.memusage`` +Scope: ``scrapy.extensions.memusage.MemoryUsage`` The maximum amount of memory to allow (in megabytes) before shutting down -Scrapy (if MEMUSAGE_ENABLED is True). If zero, no check will be performed. +Scrapy (if :setting:`MEMUSAGE_ENABLED` is ``True``). If zero, no check will be +performed. See :ref:`topics-extensions-ref-memusage`. @@ -1593,7 +1593,7 @@ MEMUSAGE_CHECK_INTERVAL_SECONDS Default: ``60.0`` -Scope: ``scrapy.extensions.memusage`` +Scope: ``scrapy.extensions.memusage.MemoryUsage`` The :ref:`Memory usage extension ` checks the current memory usage, versus the limits set by @@ -1604,23 +1604,6 @@ This sets the length of these intervals, in seconds. See :ref:`topics-extensions-ref-memusage`. -.. setting:: MEMUSAGE_NOTIFY_MAIL - -MEMUSAGE_NOTIFY_MAIL --------------------- - -Default: ``False`` - -Scope: ``scrapy.extensions.memusage`` - -A list of emails to notify if the memory limit has been reached. - -Example:: - - MEMUSAGE_NOTIFY_MAIL = ['user@example.com'] - -See :ref:`topics-extensions-ref-memusage`. - .. setting:: MEMUSAGE_WARNING_MB MEMUSAGE_WARNING_MB @@ -1628,10 +1611,13 @@ MEMUSAGE_WARNING_MB Default: ``0`` -Scope: ``scrapy.extensions.memusage`` +Scope: ``scrapy.extensions.memusage.MemoryUsage`` -The maximum amount of memory to allow (in megabytes) before sending a warning -email notifying about it. If zero, no warning will be produced. +The maximum amount of memory to allow (in megabytes) before sending a +:signal:`memusage_warning_reached` signal (if :setting:`MEMUSAGE_ENABLED` is +``True``). If zero, no signal will be sent. + +See :ref:`topics-extensions-ref-memusage`. .. setting:: NEWSPIDER_MODULE @@ -1983,16 +1969,6 @@ finishes. For more info see: :ref:`topics-stats`. -.. setting:: STATSMAILER_RCPTS - -STATSMAILER_RCPTS ------------------ - -Default: ``[]`` (empty list) - -Send Scrapy stats after spiders finish scraping. See -:class:`~scrapy.extensions.statsmailer.StatsMailer` for more info. - .. setting:: TELNETCONSOLE_ENABLED TELNETCONSOLE_ENABLED diff --git a/docs/topics/signals.rst b/docs/topics/signals.rst index efe3bb8fc..d13733623 100644 --- a/docs/topics/signals.rst +++ b/docs/topics/signals.rst @@ -356,6 +356,18 @@ feed_exporter_closed This signal supports :ref:`asynchronous handlers `. +memusage_warning_reached +~~~~~~~~~~~~~~~~~~~~~~~~ + +.. signal:: memusage_warning_reached + +.. function:: memusage_warning_reached() + + Sent by the :class:`~scrapy.extensions.memusage.MemoryUsage` extension when the + memory usage reaches the warning threshold (:setting:`MEMUSAGE_WARNING_MB`). + + This signal does not support :ref:`asynchronous handlers `. + Request signals --------------- diff --git a/scrapy/extensions/memusage.py b/scrapy/extensions/memusage.py index 990c144b5..f045e9b14 100644 --- a/scrapy/extensions/memusage.py +++ b/scrapy/extensions/memusage.py @@ -9,13 +9,13 @@ from __future__ import annotations import logging import socket import sys +import warnings from importlib import import_module from pprint import pformat from typing import TYPE_CHECKING from scrapy import signals -from scrapy.exceptions import NotConfigured -from scrapy.mail import MailSender +from scrapy.exceptions import NotConfigured, ScrapyDeprecationWarning from scrapy.utils.asyncio import AsyncioLoopingCall, create_looping_call from scrapy.utils.defer import _schedule_coro from scrapy.utils.engine import get_engine_status @@ -45,12 +45,23 @@ class MemoryUsage: self.crawler: Crawler = crawler self.warned: bool = False self.notify_mails: list[str] = crawler.settings.getlist("MEMUSAGE_NOTIFY_MAIL") + if self.notify_mails: # pragma: no cover + from scrapy.mail import MailSender # noqa: PLC0415 + + warnings.warn( + "The 'MEMUSAGE_NOTIFY_MAIL' setting is deprecated and will be removed " + "in a future release. Please use the 'memusage_warning_reached' and 'spider_closed' " + "signals to implement custom notifications.", + category=ScrapyDeprecationWarning, + stacklevel=2, + ) + self.mail = MailSender.from_crawler(crawler) + self.limit: int = crawler.settings.getint("MEMUSAGE_LIMIT_MB") * 1024 * 1024 self.warning: int = crawler.settings.getint("MEMUSAGE_WARNING_MB") * 1024 * 1024 self.check_interval: float = crawler.settings.getfloat( "MEMUSAGE_CHECK_INTERVAL_SECONDS" ) - self.mail: MailSender = MailSender.from_crawler(crawler) crawler.signals.connect(self.engine_started, signal=signals.engine_started) crawler.signals.connect(self.engine_stopped, signal=signals.engine_stopped) @@ -128,6 +139,7 @@ class MemoryUsage: assert self.crawler.stats if self.get_virtual_size() > self.warning: self.crawler.stats.set_value("memusage/warning_reached", 1) + self.crawler.signals.send_catch_log(signal=signals.memusage_warning_reached) mem = self.warning / 1024 / 1024 logger.warning( "Memory usage reached %(memusage)dMiB", diff --git a/scrapy/extensions/statsmailer.py b/scrapy/extensions/statsmailer.py index 9e900308d..47eb4bf93 100644 --- a/scrapy/extensions/statsmailer.py +++ b/scrapy/extensions/statsmailer.py @@ -6,10 +6,11 @@ Use STATSMAILER_RCPTS setting to enable and give the recipient mail address from __future__ import annotations +import warnings from typing import TYPE_CHECKING from scrapy import Spider, signals -from scrapy.exceptions import NotConfigured +from scrapy.exceptions import NotConfigured, ScrapyDeprecationWarning from scrapy.mail import MailSender if TYPE_CHECKING: @@ -21,6 +22,12 @@ if TYPE_CHECKING: from scrapy.crawler import Crawler from scrapy.statscollectors import StatsCollector +warnings.warn( + "The scrapy.extensions.statsmailer module is deprecated and will be " + "removed in a future release.", + category=ScrapyDeprecationWarning, +) + class StatsMailer: def __init__(self, stats: StatsCollector, recipients: list[str], mail: MailSender): diff --git a/scrapy/mail.py b/scrapy/mail.py index 84209effa..6ebbe195f 100644 --- a/scrapy/mail.py +++ b/scrapy/mail.py @@ -7,6 +7,7 @@ See documentation in docs/topics/email.rst from __future__ import annotations import logging +import warnings from email import encoders as Encoders from email.mime.base import MIMEBase from email.mime.multipart import MIMEMultipart @@ -19,6 +20,7 @@ from typing import IO, TYPE_CHECKING, Any from twisted.internet import ssl from twisted.internet.defer import Deferred +from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.utils.misc import arg_to_iter from scrapy.utils.python import to_bytes @@ -49,6 +51,13 @@ def _to_bytes_or_none(text: str | bytes | None) -> bytes | None: return to_bytes(text) +warnings.warn( + "The scrapy.mail module is deprecated and will be removed in a future release. " + "Please use a dedicated Python mail library instead.", + category=ScrapyDeprecationWarning, +) + + class MailSender: def __init__( self, @@ -71,7 +80,7 @@ class MailSender: self.debug: bool = debug @classmethod - def from_crawler(cls, crawler: Crawler) -> Self: + def from_crawler(cls, crawler: Crawler) -> Self: # pragma: no cover settings = crawler.settings return cls( smtphost=settings["MAIL_HOST"], diff --git a/scrapy/signals.py b/scrapy/signals.py index bdeec1ba0..972f4fd60 100644 --- a/scrapy/signals.py +++ b/scrapy/signals.py @@ -12,6 +12,7 @@ spider_opened = object() spider_idle = object() spider_closed = object() spider_error = object() +memusage_warning_reached = object() request_scheduled = object() request_dropped = object() request_reached_downloader = object() diff --git a/tests/test_extension_statsmailer.py b/tests/test_extension_statsmailer.py index d2e4d5c00..28db389c3 100644 --- a/tests/test_extension_statsmailer.py +++ b/tests/test_extension_statsmailer.py @@ -4,12 +4,18 @@ import pytest from scrapy import signals from scrapy.exceptions import NotConfigured -from scrapy.extensions import statsmailer -from scrapy.mail import MailSender from scrapy.signalmanager import SignalManager from scrapy.statscollectors import StatsCollector from scrapy.utils.spider import DefaultSpider +pytestmark = pytest.mark.filterwarnings( + "ignore:The scrapy.extensions.statsmailer module is deprecated:scrapy.exceptions.ScrapyDeprecationWarning", + "ignore:The scrapy.mail module is deprecated:scrapy.exceptions.ScrapyDeprecationWarning", +) + +from scrapy.extensions import statsmailer # noqa: E402 +from scrapy.mail import MailSender # noqa: E402 + @pytest.fixture def dummy_stats(): diff --git a/tests/test_mail.py b/tests/test_mail.py index 61136dfbe..e6d65cd10 100644 --- a/tests/test_mail.py +++ b/tests/test_mail.py @@ -5,11 +5,18 @@ import pytest from twisted.internet import defer from twisted.internet._sslverify import ClientTLSOptions -from scrapy.mail import MailSender +pytestmark = pytest.mark.filterwarnings( + "ignore:The scrapy.mail module is deprecated:scrapy.exceptions.ScrapyDeprecationWarning" +) + +from scrapy.mail import MailSender # noqa: E402 @pytest.mark.requires_reactor # MailSender requires a reactor class TestMailSender: + def _catch_mail_sent(self, **kwargs): + self.catched_msg = {**kwargs} + def test_send(self): mailsender = MailSender(debug=True) mailsender.send( @@ -88,9 +95,6 @@ class TestMailSender: assert text.get_charset() == Charset("us-ascii") assert attach.get_payload(decode=True) == b"content" - def _catch_mail_sent(self, **kwargs): - self.catched_msg = {**kwargs} - def test_send_utf8(self): subject = "sübjèçt" body = "bödÿ-àéïöñß" From 03d105ac924e0f21ab1fa5daa46dda9544ac111d Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Wed, 25 Mar 2026 13:21:08 +0500 Subject: [PATCH 077/248] Add warnings to components not working without a reactor (#7355) --- scrapy/commands/shell.py | 4 ++++ scrapy/core/downloader/handlers/ftp.py | 3 +++ scrapy/core/downloader/handlers/http10.py | 4 +++- scrapy/core/downloader/handlers/http11.py | 3 +++ scrapy/core/downloader/handlers/http2.py | 4 +++- scrapy/mail.py | 3 +++ scrapy/shell.py | 5 +++++ tests/test_command_shell.py | 9 +++++++++ tests/test_downloader_handler_twisted_ftp.py | 9 +++++++++ tests/test_downloader_handler_twisted_http11.py | 9 +++++++++ tests/test_downloader_handler_twisted_http2.py | 12 +++++++++++- 11 files changed, 62 insertions(+), 3 deletions(-) diff --git a/scrapy/commands/shell.py b/scrapy/commands/shell.py index 080f62382..9f4faba3a 100644 --- a/scrapy/commands/shell.py +++ b/scrapy/commands/shell.py @@ -83,6 +83,10 @@ class Command(ScrapyCommand): # crawling engine, so the set up in the crawl method won't work crawler = self.crawler_process._create_crawler(spidercls) crawler._apply_settings() + if not crawler.settings.getbool("TWISTED_ENABLED"): + raise RuntimeError( + "scrapy shell currently doesn't support TWISTED_ENABLED=False" + ) # The Shell class needs a persistent engine in the crawler crawler.engine = crawler._create_engine() _schedule_coro(crawler.engine.start_async(_start_request_processing=False)) diff --git a/scrapy/core/downloader/handlers/ftp.py b/scrapy/core/downloader/handlers/ftp.py index f261e8e7f..65660d389 100644 --- a/scrapy/core/downloader/handlers/ftp.py +++ b/scrapy/core/downloader/handlers/ftp.py @@ -39,6 +39,7 @@ from urllib.parse import unquote from twisted.internet.protocol import ClientCreator, Protocol from scrapy.core.downloader.handlers.base import BaseDownloadHandler +from scrapy.exceptions import NotConfigured from scrapy.http import Response from scrapy.responsetypes import responsetypes from scrapy.utils.defer import maybe_deferred_to_future @@ -84,6 +85,8 @@ class FTPDownloadHandler(BaseDownloadHandler): } def __init__(self, crawler: Crawler): + if not crawler.settings.getbool("TWISTED_ENABLED"): + raise NotConfigured(f"{type(self).__name__} requires a Twisted reactor.") super().__init__(crawler) self.default_user = crawler.settings["FTP_USER"] self.default_password = crawler.settings["FTP_PASSWORD"] diff --git a/scrapy/core/downloader/handlers/http10.py b/scrapy/core/downloader/handlers/http10.py index af3588019..3820e79bd 100644 --- a/scrapy/core/downloader/handlers/http10.py +++ b/scrapy/core/downloader/handlers/http10.py @@ -5,7 +5,7 @@ from __future__ import annotations import warnings from typing import TYPE_CHECKING -from scrapy.exceptions import ScrapyDeprecationWarning +from scrapy.exceptions import NotConfigured, ScrapyDeprecationWarning from scrapy.utils.defer import maybe_deferred_to_future from scrapy.utils.misc import build_from_crawler, load_object from scrapy.utils.python import to_unicode @@ -33,6 +33,8 @@ class HTTP10DownloadHandler: category=ScrapyDeprecationWarning, stacklevel=2, ) + if not crawler.settings.getbool("TWISTED_ENABLED"): # pragma: no cover + raise NotConfigured(f"{type(self).__name__} requires a Twisted reactor.") self.HTTPClientFactory: type[ScrapyHTTPClientFactory] = load_object( settings["DOWNLOADER_HTTPCLIENTFACTORY"] ) diff --git a/scrapy/core/downloader/handlers/http11.py b/scrapy/core/downloader/handlers/http11.py index 16879cf76..acf2be55e 100644 --- a/scrapy/core/downloader/handlers/http11.py +++ b/scrapy/core/downloader/handlers/http11.py @@ -34,6 +34,7 @@ from scrapy.core.downloader.contextfactory import load_context_factory_from_sett from scrapy.exceptions import ( DownloadCancelledError, DownloadTimeoutError, + NotConfigured, ResponseDataLossError, StopDownload, ) @@ -80,6 +81,8 @@ class _ResultT(TypedDict): class HTTP11DownloadHandler(BaseHttpDownloadHandler): def __init__(self, crawler: Crawler): + if not crawler.settings.getbool("TWISTED_ENABLED"): + raise NotConfigured(f"{type(self).__name__} requires a Twisted reactor.") super().__init__(crawler) self._crawler = crawler diff --git a/scrapy/core/downloader/handlers/http2.py b/scrapy/core/downloader/handlers/http2.py index c749a4a73..9b241d61d 100644 --- a/scrapy/core/downloader/handlers/http2.py +++ b/scrapy/core/downloader/handlers/http2.py @@ -9,7 +9,7 @@ from twisted.web.client import URI from scrapy.core.downloader.contextfactory import load_context_factory_from_settings from scrapy.core.downloader.handlers.base import BaseDownloadHandler from scrapy.core.http2.agent import H2Agent, H2ConnectionPool, ScrapyProxyH2Agent -from scrapy.exceptions import DownloadTimeoutError +from scrapy.exceptions import DownloadTimeoutError, NotConfigured from scrapy.utils._download_handlers import ( normalize_bind_address, wrap_twisted_exceptions, @@ -32,6 +32,8 @@ class H2DownloadHandler(BaseDownloadHandler): lazy = True def __init__(self, crawler: Crawler): + if not crawler.settings.getbool("TWISTED_ENABLED"): + raise NotConfigured(f"{type(self).__name__} requires a Twisted reactor.") super().__init__(crawler) self._crawler = crawler diff --git a/scrapy/mail.py b/scrapy/mail.py index 6ebbe195f..718548b03 100644 --- a/scrapy/mail.py +++ b/scrapy/mail.py @@ -23,6 +23,7 @@ from twisted.internet.defer import Deferred from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.utils.misc import arg_to_iter from scrapy.utils.python import to_bytes +from scrapy.utils.reactorless import is_reactorless if TYPE_CHECKING: from collections.abc import Callable, Sequence @@ -70,6 +71,8 @@ class MailSender: smtpssl: bool = False, debug: bool = False, ): + if is_reactorless(): # pragma: no cover + raise RuntimeError(f"{type(self).__name__} requires a Twisted reactor.") self.smtphost: str = smtphost self.smtpport: int = smtpport self.smtpuser: bytes | None = _to_bytes_or_none(smtpuser) diff --git a/scrapy/shell.py b/scrapy/shell.py index 6f33f3f14..4ce6e20c4 100644 --- a/scrapy/shell.py +++ b/scrapy/shell.py @@ -27,6 +27,7 @@ from scrapy.utils.console import DEFAULT_PYTHON_SHELLS, start_python_console from scrapy.utils.datatypes import SequenceExclude from scrapy.utils.defer import _schedule_coro, deferred_f_from_coro_f from scrapy.utils.misc import load_object +from scrapy.utils.python import global_object_name from scrapy.utils.reactor import is_asyncio_reactor_installed, set_asyncio_event_loop from scrapy.utils.response import open_in_browser @@ -44,6 +45,10 @@ class Shell: code: str | None = None, ): self.crawler: Crawler = crawler + if not crawler.settings.getbool("TWISTED_ENABLED"): # pragma: no cover + raise RuntimeError( + f"{global_object_name(self.__class__)} currently doesn't support TWISTED_ENABLED=False." + ) self.update_vars: Callable[[dict[str, Any]], None] = update_vars or ( lambda x: None ) diff --git a/tests/test_command_shell.py b/tests/test_command_shell.py index ee515ede4..5699a64a1 100644 --- a/tests/test_command_shell.py +++ b/tests/test_command_shell.py @@ -132,6 +132,15 @@ class TestShellCommand: ret, _, err = proc("shell", "-c", code, "--set", "TWISTED_ENABLED=False") assert ret == 0, err + def test_no_reactor_unsupported(self) -> None: + # to be removed when it's supported + ret, out, err = proc("shell", "-c", "item", "--set", "TWISTED_ENABLED=False") + assert ret == 1, out or err + assert ( + "RuntimeError: scrapy shell currently doesn't support TWISTED_ENABLED=False" + in err + ) + class TestInteractiveShell: def test_fetch(self, mockserver: MockServer) -> None: diff --git a/tests/test_downloader_handler_twisted_ftp.py b/tests/test_downloader_handler_twisted_ftp.py index f39e5ecaf..8f43d8071 100644 --- a/tests/test_downloader_handler_twisted_ftp.py +++ b/tests/test_downloader_handler_twisted_ftp.py @@ -11,7 +11,10 @@ import pytest from pytest_twisted import async_yield_fixture from twisted.cred import checkers, credentials, portal +from scrapy import Spider from scrapy.core.downloader.handlers.ftp import FTPDownloadHandler +from scrapy.crawler import Crawler +from scrapy.exceptions import NotConfigured from scrapy.http import HtmlResponse, Request, Response from scrapy.http.response.text import TextResponse from scrapy.utils.defer import deferred_f_from_coro_f @@ -200,3 +203,9 @@ class TestAnonymousFTP(TestFTPBase): p = portal.Portal(realm) p.registerChecker(checkers.AllowAnonymousAccess(), credentials.IAnonymous) return FTPFactory(portal=p, userAnonymous=self.username) + + +def test_not_configured_without_reactor() -> None: + crawler = Crawler(Spider, {"TWISTED_ENABLED": False}) + with pytest.raises(NotConfigured): + FTPDownloadHandler.from_crawler(crawler) diff --git a/tests/test_downloader_handler_twisted_http11.py b/tests/test_downloader_handler_twisted_http11.py index 954dcd246..5d093cc17 100644 --- a/tests/test_downloader_handler_twisted_http11.py +++ b/tests/test_downloader_handler_twisted_http11.py @@ -6,7 +6,10 @@ from typing import TYPE_CHECKING, Any import pytest +from scrapy import Spider from scrapy.core.downloader.handlers.http11 import HTTP11DownloadHandler +from scrapy.crawler import Crawler +from scrapy.exceptions import NotConfigured from tests.test_downloader_handlers_http_base import ( TestHttp11Base, TestHttpProxyBase, @@ -32,6 +35,12 @@ class HTTP11DownloadHandlerMixin: return HTTP11DownloadHandler +def test_not_configured_without_reactor() -> None: + crawler = Crawler(Spider, {"TWISTED_ENABLED": False}) + with pytest.raises(NotConfigured): + HTTP11DownloadHandler.from_crawler(crawler) + + class TestHttp11(HTTP11DownloadHandlerMixin, TestHttp11Base): pass diff --git a/tests/test_downloader_handler_twisted_http2.py b/tests/test_downloader_handler_twisted_http2.py index b14356453..7e2c06d6b 100644 --- a/tests/test_downloader_handler_twisted_http2.py +++ b/tests/test_downloader_handler_twisted_http2.py @@ -9,7 +9,9 @@ import pytest from testfixtures import LogCapture from twisted.web.http import H2_ENABLED -from scrapy.exceptions import UnsupportedURLSchemeError +from scrapy import Spider +from scrapy.crawler import Crawler +from scrapy.exceptions import NotConfigured, UnsupportedURLSchemeError from scrapy.http import Request from scrapy.utils.defer import deferred_f_from_coro_f, maybe_deferred_to_future from tests.test_downloader_handlers_http_base import ( @@ -47,6 +49,14 @@ class H2DownloadHandlerMixin: return H2DownloadHandler +def test_not_configured_without_reactor() -> None: + from scrapy.core.downloader.handlers.http2 import H2DownloadHandler # noqa: PLC0415 + + crawler = Crawler(Spider, {"TWISTED_ENABLED": False}) + with pytest.raises(NotConfigured): + H2DownloadHandler.from_crawler(crawler) + + class TestHttps2(H2DownloadHandlerMixin, TestHttps11Base): HTTP2_DATALOSS_SKIP_REASON = "Content-Length mismatch raises InvalidBodyLengthError" From 2ce02d417a99343f630d97e6bb8def1760df1366 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Wed, 25 Mar 2026 15:42:43 +0500 Subject: [PATCH 078/248] TLS code modernization (#7353) * Import some stuff from service_identity directly. * Extract _log_tls(). * Sync validating IP addresses. * Introduce _deps_compat. * Deprecate getCertificateOptions(). * Deprecate ScrapyClientTLSOptions. * Add pragma: no cover. * verify=False and trustRoot=platformTrust() are the defaults. * Improve docstrings for TLS classes. * Deprecate AcceptableProtocolsContextFactory. * Switch from DefaultOpenSSLContextFactory to CertificateOptions. * Deprecate load_context_factory_from_settings(). * Remove the outdated warning about incompatible factories. * Simplify _load_context_factory_from_settings(). * Make CertificateOptions and Context once per factory. * pragma: no cover --- scrapy/core/downloader/contextfactory.py | 136 +++++++++++++--------- scrapy/core/downloader/handlers/http11.py | 6 +- scrapy/core/downloader/handlers/http2.py | 6 +- scrapy/core/downloader/tls.py | 70 ++++++----- scrapy/core/http2/agent.py | 4 +- scrapy/pipelines/media.py | 5 +- scrapy/utils/_deps_compat.py | 10 ++ tests/__init__.py | 6 - tests/mockserver/utils.py | 26 +++-- tests/test_core_downloader.py | 34 +++--- 10 files changed, 176 insertions(+), 127 deletions(-) create mode 100644 scrapy/utils/_deps_compat.py diff --git a/scrapy/core/downloader/contextfactory.py b/scrapy/core/downloader/contextfactory.py index 917250291..8b79b4273 100644 --- a/scrapy/core/downloader/contextfactory.py +++ b/scrapy/core/downloader/contextfactory.py @@ -9,7 +9,6 @@ from twisted.internet.ssl import ( AcceptableCiphers, CertificateOptions, optionsForClientTLS, - platformTrust, ) from twisted.web.client import BrowserLikePolicyForHTTPS from twisted.web.iweb import IPolicyForHTTPS @@ -18,11 +17,11 @@ from zope.interface.verify import verifyObject from scrapy.core.downloader.tls import ( DEFAULT_CIPHERS, - ScrapyClientTLSOptions, + _ScrapyClientTLSOptions, openssl_methods, ) from scrapy.exceptions import ScrapyDeprecationWarning -from scrapy.utils.deprecate import method_is_overridden +from scrapy.utils.deprecate import create_deprecated_class, method_is_overridden from scrapy.utils.misc import build_from_crawler, load_object if TYPE_CHECKING: @@ -37,14 +36,14 @@ if TYPE_CHECKING: @implementer(IPolicyForHTTPS) class ScrapyClientContextFactory(BrowserLikePolicyForHTTPS): - """ - Non-peer-certificate verifying HTTPS context factory + """Non-peer-certificate verifying HTTPS context factory. - Default OpenSSL method is TLS_METHOD (also called SSLv23_METHOD) - which allows TLS protocol negotiation + Default OpenSSL method is ``TLS_METHOD`` (also called ``SSLv23_METHOD``) + which allows TLS protocol negotiation. - 'A TLS/SSL connection established with [this method] may - understand the TLSv1, TLSv1.1 and TLSv1.2 protocols.' + The purpose of this custom class is to provide a ``creatorForNetloc()`` + method that returns a ``_ScrapyClientTLSOptions`` instance configured based + on TLS settings provided to the factory. """ def __init__( @@ -63,6 +62,12 @@ class ScrapyClientContextFactory(BrowserLikePolicyForHTTPS): self.tls_ciphers = AcceptableCiphers.fromOpenSSLCipherString(tls_ciphers) else: self.tls_ciphers = DEFAULT_CIPHERS + self._certificate_options = CertificateOptions( + method=self._ssl_method, + fixBrokenPeers=True, + acceptableCiphers=self.tls_ciphers, + ) + self._ctx = self._get_context() if method_is_overridden(type(self), ScrapyClientContextFactory, "getContext"): warnings.warn( "Overriding ScrapyClientContextFactory.getContext() is deprecated and that method" @@ -70,6 +75,15 @@ class ScrapyClientContextFactory(BrowserLikePolicyForHTTPS): category=ScrapyDeprecationWarning, stacklevel=2, ) + if method_is_overridden( + type(self), ScrapyClientContextFactory, "getCertificateOptions" + ): # pragma: no cover + warnings.warn( + "Overriding ScrapyClientContextFactory.getCertificateOptions() is deprecated and that method" + " will be removed in a future Scrapy version. Override creatorForNetloc() instead.", + category=ScrapyDeprecationWarning, + stacklevel=2, + ) @classmethod def from_crawler( @@ -91,27 +105,33 @@ class ScrapyClientContextFactory(BrowserLikePolicyForHTTPS): **kwargs, ) - def getCertificateOptions(self) -> CertificateOptions: - # setting verify=True will require you to provide CAs - # to verify against; in other words: it's not that simple - return CertificateOptions( - verify=False, - method=self._ssl_method, - fixBrokenPeers=True, - acceptableCiphers=self.tls_ciphers, + def getCertificateOptions(self) -> CertificateOptions: # pragma: no cover + warnings.warn( + "ScrapyClientContextFactory.getCertificateOptions() is deprecated.", + ScrapyDeprecationWarning, + stacklevel=2, ) + return self._certificate_options # kept for old-style HTTP/1.0 downloader context twisted calls, # e.g. connectSSL() def getContext(self, hostname: Any = None, port: Any = None) -> SSL.Context: - ctx: SSL.Context = self.getCertificateOptions().getContext() + warnings.warn( + "ScrapyClientContextFactory.getContext() is deprecated.", + ScrapyDeprecationWarning, + stacklevel=2, + ) + return self._ctx + + def _get_context(self) -> SSL.Context: + ctx = self._certificate_options.getContext() ctx.set_options(0x4) # OP_LEGACY_SERVER_CONNECT return ctx def creatorForNetloc(self, hostname: bytes, port: int) -> ClientTLSOptions: - return ScrapyClientTLSOptions( + return _ScrapyClientTLSOptions( hostname.decode("ascii"), - self.getContext(), + self._ctx, verbose_logging=self.tls_verbose_logging, ) @@ -133,25 +153,28 @@ class BrowserLikeContextFactory(ScrapyClientContextFactory): The default OpenSSL method is ``TLS_METHOD`` (also called ``SSLv23_METHOD``) which allows TLS protocol negotiation. + + As this overrides the parent ``creatorForNetloc()`` method, only + ``self._ssl_method`` is used from the parent class. """ def creatorForNetloc(self, hostname: bytes, port: int) -> ClientTLSOptions: - # trustRoot set to platformTrust() will use the platform's root CAs. - # - # This means that a website like https://www.cacert.org will be rejected - # by default, since CAcert.org CA certificate is seldom shipped. return optionsForClientTLS( hostname=hostname.decode("ascii"), - trustRoot=platformTrust(), extraCertificateOptions={"method": self._ssl_method}, ) @implementer(IPolicyForHTTPS) -class AcceptableProtocolsContextFactory: +class _AcceptableProtocolsContextFactory: """Context factory to used to override the acceptable protocols - to set up the [OpenSSL.SSL.Context] for doing NPN and/or ALPN - negotiation. + to set up the :class:`OpenSSL.SSL.Context` for doing ALPN negotiation. + It's a private class for :class:`~.H2DownloadHandler`. + + This class wraps ``creatorForNetloc()`` of another factory class, setting + the acceptable protocols on the :class:`.ClientTLSOptions` instance + returned by it. It's only needed because we support custom factories via + :setting:`DOWNLOADER_CLIENTCONTEXTFACTORY`. """ def __init__(self, context_factory: Any, acceptable_protocols: list[bytes]): @@ -167,31 +190,36 @@ class AcceptableProtocolsContextFactory: return options +AcceptableProtocolsContextFactory = create_deprecated_class( + "AcceptableProtocolsContextFactory", + _AcceptableProtocolsContextFactory, + subclass_warn_message="{old} is deprecated.", + instance_warn_message="{cls} is deprecated.", +) + + +def _load_context_factory_from_settings(crawler: Crawler) -> IPolicyForHTTPS: + """Create an instance of :setting:`DOWNLOADER_CLIENTCONTEXTFACTORY`. + + Also passes values of other relevant settings to the factory class. + """ + ssl_method = openssl_methods[crawler.settings.get("DOWNLOADER_CLIENT_TLS_METHOD")] + context_factory_cls = load_object( + crawler.settings["DOWNLOADER_CLIENTCONTEXTFACTORY"] + ) + return build_from_crawler( + context_factory_cls, + crawler, + method=ssl_method, + ) + + def load_context_factory_from_settings( settings: BaseSettings, crawler: Crawler -) -> IPolicyForHTTPS: - ssl_method = openssl_methods[settings.get("DOWNLOADER_CLIENT_TLS_METHOD")] - context_factory_cls = load_object(settings["DOWNLOADER_CLIENTCONTEXTFACTORY"]) - # try method-aware context factory - try: - context_factory = build_from_crawler( - context_factory_cls, - crawler, - method=ssl_method, - ) - except TypeError: - # use context factory defaults - context_factory = build_from_crawler( - context_factory_cls, - crawler, - ) - msg = ( - f"{settings['DOWNLOADER_CLIENTCONTEXTFACTORY']} does not accept " - "a `method` argument (type OpenSSL.SSL method, e.g. " - "OpenSSL.SSL.SSLv23_METHOD) and/or a `tls_verbose_logging` " - "argument and/or a `tls_ciphers` argument. Please, upgrade your " - "context factory class to handle them or ignore them." - ) - warnings.warn(msg) - - return context_factory +) -> IPolicyForHTTPS: # pragma: no cover + warnings.warn( + "load_context_factory_from_settings() is deprecated.", + ScrapyDeprecationWarning, + stacklevel=2, + ) + return _load_context_factory_from_settings(crawler) diff --git a/scrapy/core/downloader/handlers/http11.py b/scrapy/core/downloader/handlers/http11.py index acf2be55e..8423093e6 100644 --- a/scrapy/core/downloader/handlers/http11.py +++ b/scrapy/core/downloader/handlers/http11.py @@ -30,7 +30,7 @@ from twisted.web.iweb import UNKNOWN_LENGTH, IBodyProducer, IPolicyForHTTPS, IRe from zope.interface import implementer from scrapy import Request, signals -from scrapy.core.downloader.contextfactory import load_context_factory_from_settings +from scrapy.core.downloader.contextfactory import _load_context_factory_from_settings from scrapy.exceptions import ( DownloadCancelledError, DownloadTimeoutError, @@ -94,8 +94,8 @@ class HTTP11DownloadHandler(BaseHttpDownloadHandler): ) self._pool._factory.noisy = False - self._contextFactory: IPolicyForHTTPS = load_context_factory_from_settings( - crawler.settings, crawler + self._contextFactory: IPolicyForHTTPS = _load_context_factory_from_settings( + crawler ) self._bind_address = crawler.settings.get("DOWNLOAD_BIND_ADDRESS") self._disconnect_timeout: int = 1 diff --git a/scrapy/core/downloader/handlers/http2.py b/scrapy/core/downloader/handlers/http2.py index 9b241d61d..52ca89b0f 100644 --- a/scrapy/core/downloader/handlers/http2.py +++ b/scrapy/core/downloader/handlers/http2.py @@ -6,7 +6,7 @@ from urllib.parse import urldefrag from twisted.web.client import URI -from scrapy.core.downloader.contextfactory import load_context_factory_from_settings +from scrapy.core.downloader.contextfactory import _load_context_factory_from_settings from scrapy.core.downloader.handlers.base import BaseDownloadHandler from scrapy.core.http2.agent import H2Agent, H2ConnectionPool, ScrapyProxyH2Agent from scrapy.exceptions import DownloadTimeoutError, NotConfigured @@ -40,9 +40,7 @@ class H2DownloadHandler(BaseDownloadHandler): from twisted.internet import reactor self._pool = H2ConnectionPool(reactor, crawler.settings) - self._context_factory = load_context_factory_from_settings( - crawler.settings, crawler - ) + self._context_factory = _load_context_factory_from_settings(crawler) self._bind_address = crawler.settings.get("DOWNLOAD_BIND_ADDRESS") async def download_request(self, request: Request) -> Response: diff --git a/scrapy/core/downloader/tls.py b/scrapy/core/downloader/tls.py index d794ea846..6ff13f5a4 100644 --- a/scrapy/core/downloader/tls.py +++ b/scrapy/core/downloader/tls.py @@ -2,14 +2,14 @@ import logging from typing import Any from OpenSSL import SSL +from OpenSSL.SSL import Connection +from service_identity import VerificationError from service_identity.exceptions import CertificateError -from twisted.internet._sslverify import ( - ClientTLSOptions, - VerificationError, - verifyHostname, -) +from service_identity.pyopenssl import verify_hostname, verify_ip_address +from twisted.internet._sslverify import ClientTLSOptions from twisted.internet.ssl import AcceptableCiphers +from scrapy.utils.deprecate import create_deprecated_class from scrapy.utils.ssl import get_temp_key_info, x509name_to_string logger = logging.getLogger(__name__) @@ -29,15 +29,38 @@ openssl_methods: dict[str, int] = { } -class ScrapyClientTLSOptions(ClientTLSOptions): +def _log_tls(hostname: str, connection: Connection) -> None: + logger.debug( + "SSL connection to %s using protocol %s, cipher %s", + hostname, + connection.get_protocol_version_name(), + connection.get_cipher_name(), + ) + server_cert = connection.get_peer_certificate() + if server_cert: + logger.debug( + 'SSL connection certificate: issuer "%s", subject "%s"', + x509name_to_string(server_cert.get_issuer()), + x509name_to_string(server_cert.get_subject()), + ) + key_info = get_temp_key_info(connection._ssl) + if key_info: + logger.debug("SSL temp key: %s", key_info) + + +class _ScrapyClientTLSOptions(ClientTLSOptions): """ SSL Client connection creator ignoring certificate verification errors - (for genuinely invalid certificates or bugs in verification code). + (for genuinely invalid certificates or bugs in verification code) and + optionally logging TLS details of the connection. Same as Twisted's private _sslverify.ClientTLSOptions, except that VerificationError, CertificateError and ValueError exceptions are caught, so that the connection is not closed, only logging warnings. Also, HTTPS connection parameters logging is added. + + Instances of this class are returned from + :class:`.ScrapyClientContextFactory`. """ def __init__(self, hostname: str, ctx: SSL.Context, verbose_logging: bool = False): @@ -47,36 +70,23 @@ class ScrapyClientTLSOptions(ClientTLSOptions): def _identityVerifyingInfoCallback( self, connection: SSL.Connection, where: int, ret: Any ) -> None: - if where & SSL.SSL_CB_HANDSHAKE_START: + if where & SSL.SSL_CB_HANDSHAKE_START and self._hostnameIsDnsName: connection.set_tlsext_host_name(self._hostnameBytes) elif where & SSL.SSL_CB_HANDSHAKE_DONE: if self.verbose_logging: - logger.debug( - "SSL connection to %s using protocol %s, cipher %s", - self._hostnameASCII, - connection.get_protocol_version_name(), - connection.get_cipher_name(), - ) - server_cert = connection.get_peer_certificate() - if server_cert: - logger.debug( - 'SSL connection certificate: issuer "%s", subject "%s"', - x509name_to_string(server_cert.get_issuer()), - x509name_to_string(server_cert.get_subject()), - ) - key_info = get_temp_key_info(connection._ssl) - if key_info: - logger.debug("SSL temp key: %s", key_info) + _log_tls(self._hostnameASCII, connection) try: - verifyHostname(connection, self._hostnameASCII) + if self._hostnameIsDnsName: + verify_hostname(connection, self._hostnameASCII) + else: + verify_ip_address(connection, self._hostnameASCII) except (CertificateError, VerificationError) as e: logger.warning( 'Remote certificate is not valid for hostname "%s"; %s', self._hostnameASCII, e, ) - except ValueError as e: logger.warning( "Ignoring error while verifying certificate " @@ -86,6 +96,14 @@ class ScrapyClientTLSOptions(ClientTLSOptions): ) +ScrapyClientTLSOptions = create_deprecated_class( + "ScrapyClientTLSOptions", + _ScrapyClientTLSOptions, + subclass_warn_message="{old} is deprecated.", + instance_warn_message="{cls} is deprecated.", +) + + DEFAULT_CIPHERS: AcceptableCiphers = AcceptableCiphers.fromOpenSSLCipherString( "DEFAULT" ) diff --git a/scrapy/core/http2/agent.py b/scrapy/core/http2/agent.py index 22019d661..7409926a0 100644 --- a/scrapy/core/http2/agent.py +++ b/scrapy/core/http2/agent.py @@ -14,7 +14,7 @@ from twisted.web.client import ( ) from twisted.web.error import SchemeNotSupported -from scrapy.core.downloader.contextfactory import AcceptableProtocolsContextFactory +from scrapy.core.downloader.contextfactory import _AcceptableProtocolsContextFactory from scrapy.core.http2.protocol import H2ClientFactory, H2ClientProtocol if TYPE_CHECKING: @@ -126,7 +126,7 @@ class H2Agent: ) -> None: self._reactor = reactor self._pool = pool - self._context_factory = AcceptableProtocolsContextFactory( + self._context_factory = _AcceptableProtocolsContextFactory( context_factory, acceptable_protocols=[b"h2"] ) self.endpoint_factory = _StandardEndpointFactory( diff --git a/scrapy/pipelines/media.py b/scrapy/pipelines/media.py index b0ee10c12..1fe14c9b0 100644 --- a/scrapy/pipelines/media.py +++ b/scrapy/pipelines/media.py @@ -8,13 +8,12 @@ from abc import ABC, abstractmethod from collections import defaultdict from typing import TYPE_CHECKING, Any, Literal, TypeAlias, TypedDict, cast -from twisted import version as twisted_version from twisted.internet.defer import Deferred, DeferredList from twisted.python.failure import Failure -from twisted.python.versions import Version from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.http.request import NO_CALLBACK, Request +from scrapy.utils._deps_compat import TWISTED_FAILURE_HAS_STACK from scrapy.utils.asyncio import call_later, is_asyncio_available from scrapy.utils.datatypes import SequenceExclude from scrapy.utils.decorators import _warn_spider_arg @@ -232,7 +231,7 @@ class MediaPipeline(ABC): # minimize cached information for failure result.cleanFailure() result.frames = [] - if twisted_version < Version("twisted", 24, 10, 0): + if TWISTED_FAILURE_HAS_STACK: result.stack = [] # type: ignore[method-assign] # This code fixes a memory leak by avoiding to keep references to # the Request and Response objects on the Media Pipeline cache. diff --git a/scrapy/utils/_deps_compat.py b/scrapy/utils/_deps_compat.py new file mode 100644 index 000000000..92399b31f --- /dev/null +++ b/scrapy/utils/_deps_compat.py @@ -0,0 +1,10 @@ +from OpenSSL import __version__ as PYOPENSSL_VERSION_STRING +from packaging.version import Version +from twisted import version as TWISTED_VERSION +from twisted.python.versions import Version as TxVersion + +TWISTED_FAILURE_HAS_STACK = TWISTED_VERSION < TxVersion("twisted", 24, 10, 0) + +PYOPENSSL_VERSION = Version(PYOPENSSL_VERSION_STRING) +# SSL.Context.use_certificate wants an X509 object, SSL.Context.use_privatekey wants a PKey object +PYOPENSSL_WANTS_X509_PKEY = PYOPENSSL_VERSION < Version("24.3.0") diff --git a/tests/__init__.py b/tests/__init__.py index ccfabb0da..cd52ade58 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -8,9 +8,6 @@ import os import socket from pathlib import Path -from twisted import version as TWISTED_VERSION -from twisted.python.versions import Version - # ignore system-wide proxies for tests # which would send requests to a totally unsuspecting server # (e.g. because urllib does not fully understand the proxy spec) @@ -33,6 +30,3 @@ except socket.gaierror: def get_testdata(*paths: str) -> bytes: """Return test data""" return Path(tests_datadir, *paths).read_bytes() - - -TWISTED_KEEPS_TRACEBACKS = TWISTED_VERSION >= Version("twisted", 24, 10, 0) diff --git a/tests/mockserver/utils.py b/tests/mockserver/utils.py index e5228cabd..f900ee693 100644 --- a/tests/mockserver/utils.py +++ b/tests/mockserver/utils.py @@ -1,25 +1,35 @@ from __future__ import annotations from pathlib import Path -from typing import TYPE_CHECKING +from cryptography.hazmat.primitives.serialization import load_pem_private_key +from cryptography.x509 import load_pem_x509_certificate from OpenSSL import SSL -from twisted.internet import ssl +from OpenSSL.crypto import FILETYPE_PEM, load_certificate, load_privatekey +from twisted.internet.ssl import CertificateOptions, ContextFactory +from scrapy.utils._deps_compat import PYOPENSSL_WANTS_X509_PKEY from scrapy.utils.python import to_bytes -if TYPE_CHECKING: - from twisted.internet.ssl import ContextFactory - def ssl_context_factory( keyfile: str = "keys/localhost.key", certfile: str = "keys/localhost.crt", cipher_string: str | None = None, ) -> ContextFactory: - factory = ssl.DefaultOpenSSLContextFactory( - str(Path(__file__).parent.parent / keyfile), - str(Path(__file__).parent.parent / certfile), + keyfile_path = Path(__file__).parent.parent / keyfile + certfile_path = Path(__file__).parent.parent / certfile + + if not PYOPENSSL_WANTS_X509_PKEY: + cert = load_pem_x509_certificate(certfile_path.read_bytes()) + key = load_pem_private_key(keyfile_path.read_bytes(), password=None) + else: + cert = load_certificate(FILETYPE_PEM, certfile_path.read_bytes()) # type: ignore[assignment] + key = load_privatekey(FILETYPE_PEM, keyfile_path.read_bytes()) # type: ignore[assignment] + + factory = CertificateOptions( + privateKey=key, + certificate=cert, ) if cipher_string: ctx = factory.getContext() diff --git a/tests/test_core_downloader.py b/tests/test_core_downloader.py index 92991fb8f..b24d6c843 100644 --- a/tests/test_core_downloader.py +++ b/tests/test_core_downloader.py @@ -13,11 +13,10 @@ from twisted.web.client import Response as TxResponse from scrapy.core.downloader import Downloader, Slot from scrapy.core.downloader.contextfactory import ( ScrapyClientContextFactory, - load_context_factory_from_settings, + _load_context_factory_from_settings, ) from scrapy.core.downloader.handlers.http11 import _RequestBodyProducer from scrapy.exceptions import ScrapyDeprecationWarning -from scrapy.settings import Settings from scrapy.utils.defer import maybe_deferred_to_future from scrapy.utils.misc import build_from_crawler from scrapy.utils.python import to_bytes @@ -102,8 +101,7 @@ class TestContextFactory(TestContextFactoryBase): async def testPayload(self, server_url: str) -> None: s = "0123456789" * 10 crawler = get_crawler() - settings = Settings() - client_context_factory = load_context_factory_from_settings(settings, crawler) + client_context_factory = _load_context_factory_from_settings(crawler) body = await self.get_page( server_url + "payload", client_context_factory, body=s ) @@ -117,13 +115,11 @@ class TestContextFactory(TestContextFactoryBase): ctx: OpenSSL.SSL.Context = super().getContext(hostname, port) return ctx - with warnings.catch_warnings(record=True) as w: + with pytest.warns( + ScrapyDeprecationWarning, + match=r"ScrapyClientContextFactory\.getContext\(\) is deprecated", + ): MyFactory() - assert len(w) == 1 - assert ( - "Overriding ScrapyClientContextFactory.getContext() is deprecated" - in str(w[0].message) - ) class TestContextFactoryTLSMethod(TestContextFactoryBase): @@ -139,28 +135,24 @@ class TestContextFactoryTLSMethod(TestContextFactoryBase): @coroutine_test async def test_setting_default(self, server_url: str) -> None: crawler = get_crawler() - settings = Settings() - client_context_factory = load_context_factory_from_settings(settings, crawler) + client_context_factory = _load_context_factory_from_settings(crawler) assert client_context_factory._ssl_method == OpenSSL.SSL.SSLv23_METHOD await self._assert_factory_works(server_url, client_context_factory) def test_setting_none(self): - crawler = get_crawler() - settings = Settings({"DOWNLOADER_CLIENT_TLS_METHOD": None}) + crawler = get_crawler(settings_dict={"DOWNLOADER_CLIENT_TLS_METHOD": None}) with pytest.raises(KeyError): - load_context_factory_from_settings(settings, crawler) + _load_context_factory_from_settings(crawler) def test_setting_bad(self): - crawler = get_crawler() - settings = Settings({"DOWNLOADER_CLIENT_TLS_METHOD": "bad"}) + crawler = get_crawler(settings_dict={"DOWNLOADER_CLIENT_TLS_METHOD": "bad"}) with pytest.raises(KeyError): - load_context_factory_from_settings(settings, crawler) + _load_context_factory_from_settings(crawler) @coroutine_test async def test_setting_explicit(self, server_url: str) -> None: - crawler = get_crawler() - settings = Settings({"DOWNLOADER_CLIENT_TLS_METHOD": "TLSv1.2"}) - client_context_factory = load_context_factory_from_settings(settings, crawler) + crawler = get_crawler(settings_dict={"DOWNLOADER_CLIENT_TLS_METHOD": "TLSv1.2"}) + client_context_factory = _load_context_factory_from_settings(crawler) assert client_context_factory._ssl_method == OpenSSL.SSL.TLSv1_2_METHOD await self._assert_factory_works(server_url, client_context_factory) From a8e99aeb2efa56f09ec422cd8e8aca1904810337 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Thu, 26 Mar 2026 12:21:58 +0500 Subject: [PATCH 079/248] Fix botocore-pinned and pypy3-* (#7363) --- .github/workflows/tests-ubuntu.yml | 9 ++++++--- tox.ini | 2 ++ 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/.github/workflows/tests-ubuntu.yml b/.github/workflows/tests-ubuntu.yml index a34112698..a193ca05f 100644 --- a/.github/workflows/tests-ubuntu.yml +++ b/.github/workflows/tests-ubuntu.yml @@ -37,7 +37,8 @@ jobs: - python-version: "3.13" env: TOXENV: no-reactor - - python-version: pypy3.11 + # pinned due to https://github.com/pypy/pypy/issues/5388 + - python-version: pypy3.11-7.3.20 env: TOXENV: pypy3 @@ -51,7 +52,8 @@ jobs: - python-version: "3.10.19" env: TOXENV: no-reactor-pinned - - python-version: pypy3.11 + # pinned due to https://github.com/pypy/pypy/issues/5388 + - python-version: pypy3.11-7.3.20 env: TOXENV: pypy3-pinned - python-version: "3.10.19" @@ -67,7 +69,8 @@ jobs: - python-version: "3.13" env: TOXENV: no-reactor-extra-deps - - python-version: pypy3.11 + # pinned due to https://github.com/pypy/pypy/issues/5388 + - python-version: pypy3.11-7.3.20 env: TOXENV: pypy3-extra-deps - python-version: "3.13" diff --git a/tox.ini b/tox.ini index ca304330c..d35b0406c 100644 --- a/tox.ini +++ b/tox.ini @@ -288,6 +288,8 @@ basepython = {[pinned]basepython} deps = {[pinned]deps} botocore==1.13.45 + # botocore 1.13.45 requires urllib3>=1.20,<1.26; requests 2.33.0 requires urllib3>=1.26,<3 + requests<2.33.0 setenv = {[pinned]setenv} commands = From 5561aaec1d68384cbcea2e19a564e0ba499eb855 Mon Sep 17 00:00:00 2001 From: "Albert Eduardovich N." Date: Fri, 27 Mar 2026 13:38:44 +0300 Subject: [PATCH 080/248] fixing oversight from #7036 (#7367) https://github.com/scrapy/scrapy/pull/7269#issuecomment-3958535816 --- scrapy/http/request/__init__.py | 8 -------- tests/test_http_request.py | 24 ------------------------ 2 files changed, 32 deletions(-) diff --git a/scrapy/http/request/__init__.py b/scrapy/http/request/__init__.py index c4a0c8131..f523c2bb9 100644 --- a/scrapy/http/request/__init__.py +++ b/scrapy/http/request/__init__.py @@ -242,20 +242,12 @@ class Request(object_ref): self._cb_kwargs = {} return self._cb_kwargs - @cb_kwargs.setter - def cb_kwargs(self, value: dict[str, Any] | None) -> None: - self._cb_kwargs = value or None - @property def meta(self) -> dict[str, Any]: if self._meta is None: self._meta = {} return self._meta - @meta.setter - def meta(self, value: dict[str, Any] | None) -> None: - self._meta = value or None - @property def url(self) -> str: return self._url diff --git a/tests/test_http_request.py b/tests/test_http_request.py index 06b280235..81f8fa3c1 100644 --- a/tests/test_http_request.py +++ b/tests/test_http_request.py @@ -320,12 +320,6 @@ class TestRequest: def test_setters(self): request = self.request_class("http://example.com") - request.cb_kwargs = {"a": 1} - assert request.cb_kwargs == {"a": 1} - - request.meta = {"k": "v"} - assert request.meta == {"k": "v"} - request.flags = ["f1"] assert request.flags == ["f1"] @@ -349,24 +343,6 @@ class TestRequest: request = self.request_class("http://example.com") - assert request._cb_kwargs is None - assert request.cb_kwargs == {} - assert request.cb_kwargs is request.cb_kwargs - assert request._cb_kwargs == {} - original_cb_kwargs = request.cb_kwargs - request.cb_kwargs = None - assert request.cb_kwargs == {} - assert request.cb_kwargs is not original_cb_kwargs - - assert request._meta is None - assert request.meta == {} - assert request.meta is request.meta - assert request._meta == {} - original_meta = request.meta - request.meta = None - assert request.meta == {} - assert request.meta is not original_meta - assert request._flags is None assert request.flags == [] assert request.flags is request.flags From fee20b785866dc64bd5f1cff5fa2c50416332e50 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Fri, 27 Mar 2026 16:21:25 +0500 Subject: [PATCH 081/248] AsyncCrawlerProcess._start_asyncio() improvements. (#7366) --- scrapy/crawler.py | 169 ++++++++++++++++-- .../reactorless_sleeping.py | 20 +++ tests/test_crawler_subprocess.py | 41 +++-- 3 files changed, 199 insertions(+), 31 deletions(-) create mode 100644 tests/AsyncCrawlerProcess/reactorless_sleeping.py diff --git a/scrapy/crawler.py b/scrapy/crawler.py index 2e07a3b87..2ae45fe40 100644 --- a/scrapy/crawler.py +++ b/scrapy/crawler.py @@ -605,22 +605,30 @@ class CrawlerProcessBase(CrawlerRunnerBase): from twisted.internet import reactor install_shutdown_handlers(self._signal_kill) - signame = signal_names[signum] - logger.info( - "Received %(signame)s, shutting down gracefully. Send again to force ", - {"signame": signame}, - ) + self._log_shutdown(signum) reactor.callFromThread(self._graceful_stop_reactor) def _signal_kill(self, signum: int, _: Any) -> None: from twisted.internet import reactor install_shutdown_handlers(signal.SIG_IGN) + self._log_kill(signum) + reactor.callFromThread(self._stop_reactor) + + @staticmethod + def _log_shutdown(signum: int) -> None: + signame = signal_names[signum] + logger.info( + "Received %(signame)s, shutting down gracefully. Send again to force ", + {"signame": signame}, + ) + + @staticmethod + def _log_kill(signum: int) -> None: signame = signal_names[signum] logger.info( "Received %(signame)s twice, forcing unclean shutdown", {"signame": signame} ) - reactor.callFromThread(self._stop_reactor) def _setup_reactor(self, install_signal_handlers: bool) -> None: from twisted.internet import reactor @@ -767,6 +775,7 @@ class AsyncCrawlerProcess(CrawlerProcessBase, AsyncCrawlerRunner): ): super().__init__(settings, install_root_handler) logger.debug("Using AsyncCrawlerProcess") + self._reactorless_loop: asyncio.AbstractEventLoop | None = None # We want the asyncio event loop to be installed early, so that it's # always the correct one. And as we do that, we can also install the # reactor here. @@ -778,7 +787,7 @@ class AsyncCrawlerProcess(CrawlerProcessBase, AsyncCrawlerRunner): raise RuntimeError( "TWISTED_ENABLED is False but a Twisted reactor is installed." ) - set_asyncio_event_loop(loop_path) + self._reactorless_loop = set_asyncio_event_loop(loop_path) install_reactor_import_hook() elif is_reactor_installed(): # The user could install a reactor before this class is instantiated. @@ -790,6 +799,7 @@ class AsyncCrawlerProcess(CrawlerProcessBase, AsyncCrawlerRunner): else: install_reactor(_asyncio_reactor_path, loop_path) self._initialized_reactor = True + self._reactorless_main_task: asyncio.Future[None] | None = None def _stop_dfd(self) -> Deferred[Any]: return deferred_from_coro(self.stop()) @@ -820,20 +830,149 @@ class AsyncCrawlerProcess(CrawlerProcessBase, AsyncCrawlerRunner): def _start_asyncio( self, stop_after_crawl: bool, install_signal_handlers: bool ) -> None: - # Very basic and will need multiple improvements. - # TODO https://docs.python.org/3/library/asyncio-runner.html#handling-keyboard-interruption - # TODO various exception handling - # TODO consider asyncio.run() + # We cannot use asyncio.run() here, because we can't let it handle the + # loop lifetime: _crawl() needs a loop (which we create in __init__()), + # because crawl() returns a Task. + # So we reproduce a part of asyncio.runners.Runner that is useful to us. + + # Normal workflow: + # 1. _start_asyncio() creates a task for self.join() and calls _run_loop() + # 2. _run_loop() calls loop.run_until_complete(main_task) + # 3. Crawling tasks start and finish + # 4. join() completes, loop.run_until_complete() and thus _run_loop() return + # 5. _start_asyncio() calls _close_loop() + # 6. _close_loop() does finalization and calls loop.close() + + # Normal workflow with stop_after_crawl=False: + # 1. _start_asyncio() creates a simple future and calls _run_loop() + # 2. _run_loop() calls loop.run_until_complete(main_task) + # 3. Crawling tasks start and finish + # 4. _run_loop() blocks until the loop is stopped externally or the + # future is cancelled via Ctrl-C + # 5. (after _run_loop() returns) _start_asyncio() calls _close_loop() + # 6. _close_loop() does finalization and calls loop.close() + + # Workflow with Ctrl-C pressed once: + # 1. While loop.run_until_complete() blocks, _signal_shutdown_reactorless() + # is called + # 2. _signal_shutdown_reactorless() calls _shutdown_graceful_reactorless() + # (via call_soon_threadsafe()) + # 3. _shutdown_graceful_reactorless() calls stop() + # 4. For stop_after_crawl=True: crawl tasks finish, join() completes, + # loop.run_until_complete() and thus _run_loop() return + # For stop_after_crawl=False: _shutdown_graceful_reactorless() waits + # for crawl tasks via join(), then cancels the main task, + # loop.run_until_complete() raises CancelledError, _run_loop() returns + # 5. _start_asyncio() calls _close_loop() + # 6. _close_loop() does finalization and calls loop.close() + + # Workflow with Ctrl-C pressed twice: + # 1. While loop.run_until_complete() blocks, _signal_shutdown_reactorless() + # is called + # 2. _signal_shutdown_reactorless() calls _shutdown_graceful_reactorless() + # (via call_soon_threadsafe()) and installs _signal_kill_reactorless() + # as the next handler + # 3. Before _shutdown_graceful_reactorless() completes, + # _signal_kill_reactorless() is called + # 4. _signal_kill_reactorless() cancels the main task + # (via call_soon_threadsafe()) + # 5. loop.run_until_complete() raises CancelledError, _run_loop() returns + # 6. _start_asyncio() calls _close_loop() + # 7. _close_loop() cancels all pending tasks (including + # _shutdown_graceful_reactorless()), does finalization and calls loop.close() + + loop = self._reactorless_loop + assert loop - loop = asyncio.get_event_loop() if stop_after_crawl: - join_task = loop.create_task(self.join()) - join_task.add_done_callback(lambda _: loop.stop()) + self._reactorless_main_task = loop.create_task(self.join()) + else: + self._reactorless_main_task = loop.create_future() + self._stop_after_crawl = stop_after_crawl + try: - loop.run_forever() # blocking call + self._run_loop(install_signal_handlers) # blocking call + except asyncio.CancelledError: + pass finally: + self._close_loop() + + def _run_loop(self, install_signal_handlers: bool) -> None: + # similar to asyncio.runners.Runner.run() + if install_signal_handlers: + install_shutdown_handlers(self._signal_shutdown_reactorless) + assert self._reactorless_loop + assert self._reactorless_main_task + self._reactorless_loop.run_until_complete(self._reactorless_main_task) + + def _close_loop(self) -> None: + # Similar to asyncio.runners.Runner.close() + loop = self._reactorless_loop + assert loop + try: + self._cancel_all_tasks(loop) loop.run_until_complete(loop.shutdown_asyncgens()) + loop.run_until_complete(loop.shutdown_default_executor()) + finally: + self._reactorless_main_task = None + asyncio.set_event_loop(None) loop.close() + self._reactorless_loop = None + + @staticmethod + def _cancel_all_tasks(loop: asyncio.AbstractEventLoop) -> None: + # copy of asyncio.runners._cancel_all_tasks() + to_cancel = asyncio.all_tasks(loop) + if not to_cancel: + return + + for task in to_cancel: + task.cancel() + + loop.run_until_complete(asyncio.gather(*to_cancel, return_exceptions=True)) + + for task in to_cancel: + if task.cancelled(): + continue + if task.exception() is not None: + loop.call_exception_handler( + { + "message": "unhandled exception during AsyncCrawlerProcess shutdown", + "exception": task.exception(), + "task": task, + } + ) + + def _signal_shutdown_reactorless(self, signum: int, _: Any) -> None: + install_shutdown_handlers(self._signal_kill_reactorless) + self._log_shutdown(signum) + if (loop := self._reactorless_loop) is None: + return + + def _create_shutdown_task() -> None: + coro = self._shutdown_graceful_reactorless() + try: + loop.create_task(coro) + except RuntimeError: + coro.close() + + loop.call_soon_threadsafe(_create_shutdown_task) + + async def _shutdown_graceful_reactorless(self) -> None: + await self.stop() + if not self._stop_after_crawl: + # wait until crawl tasks finish and cancel the future + await self.join() + if self._reactorless_main_task and not self._reactorless_main_task.done(): + self._reactorless_main_task.cancel() + + def _signal_kill_reactorless(self, signum: int, _: Any) -> None: + install_shutdown_handlers(signal.SIG_IGN) + self._log_kill(signum) + if (loop := self._reactorless_loop) is None: + return + if (task := self._reactorless_main_task) is not None: + loop.call_soon_threadsafe(task.cancel) def _start_twisted( self, stop_after_crawl: bool, install_signal_handlers: bool diff --git a/tests/AsyncCrawlerProcess/reactorless_sleeping.py b/tests/AsyncCrawlerProcess/reactorless_sleeping.py new file mode 100644 index 000000000..6bfe64686 --- /dev/null +++ b/tests/AsyncCrawlerProcess/reactorless_sleeping.py @@ -0,0 +1,20 @@ +import asyncio +import sys + +import scrapy +from scrapy.crawler import AsyncCrawlerProcess + + +class SleepingSpider(scrapy.Spider): + name = "sleeping" + + start_urls = ["data:,;"] + + async def parse(self, response): + await asyncio.sleep(int(sys.argv[1])) + + +process = AsyncCrawlerProcess(settings={"TWISTED_ENABLED": False}) + +process.crawl(SleepingSpider) +process.start() diff --git a/tests/test_crawler_subprocess.py b/tests/test_crawler_subprocess.py index 8d7b8f871..b07ddbe09 100644 --- a/tests/test_crawler_subprocess.py +++ b/tests/test_crawler_subprocess.py @@ -12,12 +12,10 @@ from typing import TYPE_CHECKING import pytest from packaging.version import parse as parse_version from pexpect.popen_spawn import PopenSpawn -from twisted.internet.defer import Deferred from w3lib import __version__ as w3lib_version -from scrapy.utils.asyncio import call_later -from tests.utils import get_script_run_env -from tests.utils.decorators import inline_callbacks_test +from tests.utils import async_sleep, get_script_run_env +from tests.utils.decorators import coroutine_test if TYPE_CHECKING: from tests.mockserver.http import MockServer @@ -207,33 +205,37 @@ class TestCrawlerProcessSubprocessBase(ScriptRunnerMixin): assert "Spider closed (finished)" in log assert "The value of FOO is 42" in log - def test_shutdown_graceful(self): - sig = signal.SIGINT if sys.platform != "win32" else signal.SIGBREAK - args = self.get_script_args("sleeping.py", "3") + def _test_shutdown_graceful(self, script: str = "sleeping.py") -> None: + sig = signal.SIGINT if sys.platform != "win32" else signal.SIGBREAK # type: ignore[attr-defined] + args = self.get_script_args(script, "3") p = PopenSpawn(args, timeout=5, env=get_script_run_env()) p.expect_exact("Spider opened") p.expect_exact("Crawled (200)") p.kill(sig) p.expect_exact("shutting down gracefully") p.expect_exact("Spider closed (shutdown)") - p.wait() + p.wait() # type: ignore[no-untyped-call] - @inline_callbacks_test - def test_shutdown_forced(self): - sig = signal.SIGINT if sys.platform != "win32" else signal.SIGBREAK - args = self.get_script_args("sleeping.py", "10") + def test_shutdown_graceful(self) -> None: + self._test_shutdown_graceful() + + async def _test_shutdown_forced(self, script: str = "sleeping.py") -> None: + sig = signal.SIGINT if sys.platform != "win32" else signal.SIGBREAK # type: ignore[attr-defined] + args = self.get_script_args(script, "10") p = PopenSpawn(args, timeout=5, env=get_script_run_env()) p.expect_exact("Spider opened") p.expect_exact("Crawled (200)") p.kill(sig) p.expect_exact("shutting down gracefully") # sending the second signal too fast often causes problems - d = Deferred() - call_later(0.01, d.callback, None) - yield d + await async_sleep(0.01) p.kill(sig) p.expect_exact("forcing unclean shutdown") - p.wait() + p.wait() # type: ignore[no-untyped-call] + + @coroutine_test + async def test_shutdown_forced(self) -> None: + await self._test_shutdown_forced() class TestCrawlerProcessSubprocess(TestCrawlerProcessSubprocessBase): @@ -397,6 +399,13 @@ class TestAsyncCrawlerProcessSubprocess(TestCrawlerProcessSubprocessBase): in log ) + def test_shutdown_graceful(self) -> None: + self._test_shutdown_graceful("reactorless_sleeping.py") + + @coroutine_test + async def test_shutdown_forced(self) -> None: + await self._test_shutdown_forced("reactorless_sleeping.py") + class TestCrawlerRunnerSubprocessBase(ScriptRunnerMixin): """Common tests between CrawlerRunner and AsyncCrawlerRunner, From 31bf7c3892df3eb0dfdf5223a0aff5e261f0a7c3 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Fri, 27 Mar 2026 21:06:55 +0500 Subject: [PATCH 082/248] Docs for HttpxDownloadHandler, handle httpx not being installed (#7368) * Add docs for HttpxDownloadHandler. * Handle httpx not being installed. * Revert test_not_configured_without_asyncio(). * Restore the comment. * Cleanup. --- docs/topics/download-handlers.rst | 51 +++++++++++++++++++++++ docs/topics/downloader-middleware.rst | 11 ++++- docs/topics/request-response.rst | 6 ++- docs/topics/settings.rst | 17 +++++--- scrapy/core/downloader/handlers/_httpx.py | 11 ++++- 5 files changed, 85 insertions(+), 11 deletions(-) diff --git a/docs/topics/download-handlers.rst b/docs/topics/download-handlers.rst index 7e672f0b4..54a7f0e7c 100644 --- a/docs/topics/download-handlers.rst +++ b/docs/topics/download-handlers.rst @@ -206,6 +206,57 @@ uses the HTTP/1.1 protocol for them. It's implemented using :mod:`twisted.web.client`. +HttpxDownloadHandler +-------------------- + +.. autoclass:: scrapy.core.downloader.handlers._httpx.HttpxDownloadHandler + +| Supported schemes: ``http``, ``https``. +| Lazy: no. + +This handler supports ``http://host/path`` and ``https://host/path`` URLs and +uses the HTTP/1.1 protocol for them. + +It's implemented using the ``httpx`` library and needs it to be installed. + +If you want to use this handler you need to replace the default ones for the +``http`` and ``https`` schemes: + +.. code-block:: python + + DOWNLOAD_HANDLERS = { + "http": "scrapy.core.downloader.handlers._httpx.HttpxDownloadHandler", + "https": "scrapy.core.downloader.handlers._httpx.HttpxDownloadHandler", + } + +.. warning:: + + This handler is experimental, and not yet recommended for production + environments. Future Scrapy versions may introduce related changes without + a deprecation period or warning or even remove it altogether. + +.. note:: + + As this handler is based on a different HTTP client implementation compared + to :class:`~.HTTP11DownloadHandler`, it's expected that its behavior on + some websites may be different. Additionally, these are the Scrapy features + that are explicitly not supported when using it: + + - Proxy support (the :reqmeta:`proxy` meta key). + + - Per-request bind address support (the :reqmeta:`bindaddress` meta key). + The global :setting:`DOWNLOAD_BIND_ADDRESS` setting is supported but the + port number, if specified, will be ignored. + + - The :setting:`DOWNLOADER_CLIENT_TLS_CIPHERS` and + :setting:`DOWNLOADER_CLIENT_TLS_METHOD` settings. + + - Settings specific to the Twisted networking or HTTP implementation, like + :setting:`DNS_RESOLVER`. + + - Using :ref:`non-asyncio reactors ` (``httpx`` requires + ``asyncio``). + S3DownloadHandler ----------------- diff --git a/docs/topics/downloader-middleware.rst b/docs/topics/downloader-middleware.rst index 1a4f91d93..64722f0e3 100644 --- a/docs/topics/downloader-middleware.rst +++ b/docs/topics/downloader-middleware.rst @@ -726,7 +726,7 @@ HttpProxyMiddleware .. class:: HttpProxyMiddleware This middleware sets the HTTP proxy to use for requests, by setting the - ``proxy`` meta value for :class:`~scrapy.Request` objects. + :reqmeta:`proxy` meta value for :class:`~scrapy.Request` objects. Like the Python standard library module :mod:`urllib.request`, it obeys the following environment variables: @@ -735,11 +735,18 @@ HttpProxyMiddleware * ``https_proxy`` * ``no_proxy`` - You can also set the meta key ``proxy`` per-request, to a value like + You can also set the meta key :reqmeta:`proxy` per-request, to a value like ``http://some_proxy_server:port`` or ``http://username:password@some_proxy_server:port``. Keep in mind this value will take precedence over ``http_proxy``/``https_proxy`` environment variables, and it will also ignore ``no_proxy`` environment variable. +.. note:: + + Handling of this meta key needs to be implemented inside the :ref:`download + handler `, so it's not guaranteed to be supported + by all 3rd-party handlers. It's currently unsupported by + :class:`~scrapy.core.downloader.handlers._httpx.HttpxDownloadHandler`. + HttpProxyMiddleware settings ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/docs/topics/request-response.rst b/docs/topics/request-response.rst index 5acabb0de..f23c7611c 100644 --- a/docs/topics/request-response.rst +++ b/docs/topics/request-response.rst @@ -646,7 +646,7 @@ bindaddress The default local outgoing address for download-handler connections. -This setting can be either: +This meta value can be either: - a host address as a string (e.g. ``"127.0.0.2"``), in which case the local port is chosen automatically, or @@ -675,6 +675,10 @@ If not set, built-in HTTP download handlers use the value of Set the :reqmeta:`bindaddress` request meta key to override it for a specific request. +This meta key is not supported by +:class:`~scrapy.core.downloader.handlers._httpx.HttpxDownloadHandler`, but the +:setting:`DOWNLOAD_BIND_ADDRESS` is supported by it. + .. reqmeta:: download_timeout download_timeout diff --git a/docs/topics/settings.rst b/docs/topics/settings.rst index 6e8d0b54f..c004c253e 100644 --- a/docs/topics/settings.rst +++ b/docs/topics/settings.rst @@ -714,12 +714,6 @@ or even enable client-side authentication (and various other things). ``'scrapy.core.downloader.contextfactory.BrowserLikeContextFactory'``, which uses the platform's certificates to validate remote endpoints. -If you do use a custom ContextFactory, make sure its ``__init__`` method -accepts a ``method`` parameter (this is the ``OpenSSL.SSL`` method mapping -:setting:`DOWNLOADER_CLIENT_TLS_METHOD`), a ``tls_verbose_logging`` -parameter (``bool``) and a ``tls_ciphers`` parameter (see -:setting:`DOWNLOADER_CLIENT_TLS_CIPHERS`). - .. note:: This setting is specific to the built-in Twisted-based download handlers: @@ -753,6 +747,8 @@ specific cipher that is not included in ``DEFAULT`` if a website requires it. (:class:`scrapy.core.downloader.handlers.http11.HTTP11DownloadHandler` and :class:`scrapy.core.downloader.handlers.http2.H2DownloadHandler`) it needs to be implemented in the :setting:`DOWNLOADER_CLIENTCONTEXTFACTORY` class. + It's currently unsupported by + :class:`~scrapy.core.downloader.handlers._httpx.HttpxDownloadHandler`. .. setting:: DOWNLOADER_CLIENT_TLS_METHOD @@ -783,6 +779,8 @@ This setting must be one of these string values: (:class:`scrapy.core.downloader.handlers.http11.HTTP11DownloadHandler` and :class:`scrapy.core.downloader.handlers.http2.H2DownloadHandler`) it needs to be implemented in the :setting:`DOWNLOADER_CLIENTCONTEXTFACTORY` class. + It's currently unsupported by + :class:`~scrapy.core.downloader.handlers._httpx.HttpxDownloadHandler`. .. setting:: DOWNLOADER_CLIENT_TLS_VERBOSE_LOGGING @@ -930,6 +928,13 @@ If set, built-in HTTP download handlers use this value by default. Set the :reqmeta:`bindaddress` request meta key to override it for a specific request. +.. note:: + + Handling of this setting needs to be implemented inside the :ref:`download + handler `, so it's not guaranteed to be supported + by all 3rd-party handlers. Specifying the port is unsupported by + :class:`~scrapy.core.downloader.handlers._httpx.HttpxDownloadHandler`. + .. setting:: DOWNLOAD_HANDLERS DOWNLOAD_HANDLERS diff --git a/scrapy/core/downloader/handlers/_httpx.py b/scrapy/core/downloader/handlers/_httpx.py index 916937152..b19a66696 100644 --- a/scrapy/core/downloader/handlers/_httpx.py +++ b/scrapy/core/downloader/handlers/_httpx.py @@ -9,8 +9,6 @@ from http.cookiejar import Cookie, CookieJar from io import BytesIO from typing import TYPE_CHECKING, Any, NoReturn, TypedDict -import httpx - from scrapy import Request, signals from scrapy.exceptions import ( CannotResolveHostError, @@ -46,6 +44,11 @@ if TYPE_CHECKING: from scrapy.crawler import Crawler +try: + import httpx +except ImportError: + httpx = None # type: ignore[assignment] + logger = logging.getLogger(__name__) @@ -81,6 +84,10 @@ class HttpxDownloadHandler(BaseHttpDownloadHandler): f" TWISTED_ENABLED setting. See the asyncio documentation" f" of Scrapy for more information." ) + if httpx is None: # pragma: no cover + raise NotConfigured( + f"{type(self).__name__} requires the httpx library to be installed." + ) super().__init__(crawler) logger.warning( "HttpxDownloadHandler is experimental and is not recommented for production use." From 86a7ceaa9ff590da04e35992497878f15a7edd8b Mon Sep 17 00:00:00 2001 From: Kailesh <159027881+Kaileshwar16@users.noreply.github.com> Date: Mon, 30 Mar 2026 13:42:43 +0530 Subject: [PATCH 083/248] Dynamic loading for S3 HTTPS handler (#7370) --- scrapy/core/downloader/handlers/s3.py | 8 +++++--- tests/test_downloader_handlers.py | 19 ++++++++----------- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/scrapy/core/downloader/handlers/s3.py b/scrapy/core/downloader/handlers/s3.py index a601bc2aa..a609cedb3 100644 --- a/scrapy/core/downloader/handlers/s3.py +++ b/scrapy/core/downloader/handlers/s3.py @@ -3,11 +3,10 @@ from __future__ import annotations from typing import TYPE_CHECKING from scrapy.core.downloader.handlers.base import BaseDownloadHandler -from scrapy.core.downloader.handlers.http11 import HTTP11DownloadHandler from scrapy.exceptions import NotConfigured from scrapy.utils.boto import is_botocore_available from scrapy.utils.httpobj import urlparse_cached -from scrapy.utils.misc import build_from_crawler +from scrapy.utils.misc import build_from_crawler, load_object if TYPE_CHECKING: from scrapy import Request @@ -40,7 +39,10 @@ class S3DownloadHandler(BaseDownloadHandler): ) ) - _http_handler = build_from_crawler(HTTP11DownloadHandler, crawler) + _http_handler = build_from_crawler( + load_object(crawler.settings.getwithbase("DOWNLOAD_HANDLERS")["https"]), + crawler, + ) self._download_http = _http_handler.download_request async def download_request(self, request: Request) -> Response: diff --git a/tests/test_downloader_handlers.py b/tests/test_downloader_handlers.py index a8e635707..eadb7740e 100644 --- a/tests/test_downloader_handlers.py +++ b/tests/test_downloader_handlers.py @@ -156,12 +156,12 @@ class HttpDownloadHandlerMock: @pytest.mark.requires_botocore class TestS3Anon: def setup_method(self): - crawler = get_crawler() - with mock.patch( - "scrapy.core.downloader.handlers.s3.HTTP11DownloadHandler", - HttpDownloadHandlerMock, - ): - self.s3reqh = build_from_crawler(S3DownloadHandler, crawler) + crawler = get_crawler( + settings_dict={ + "DOWNLOAD_HANDLERS": {"https": HttpDownloadHandlerMock}, + } + ) + self.s3reqh = build_from_crawler(S3DownloadHandler, crawler) self.download_request = self.s3reqh.download_request @coroutine_test @@ -183,13 +183,10 @@ class TestS3: settings_dict={ "AWS_ACCESS_KEY_ID": "0PN5J17HBGZHT7JJ3X82", "AWS_SECRET_ACCESS_KEY": "uV3F3YluFJax1cknvbcGwgjvx4QpvB+leU8dUj2o", + "DOWNLOAD_HANDLERS": {"https": HttpDownloadHandlerMock}, } ) - with mock.patch( - "scrapy.core.downloader.handlers.s3.HTTP11DownloadHandler", - HttpDownloadHandlerMock, - ): - s3reqh = build_from_crawler(S3DownloadHandler, crawler) + s3reqh = build_from_crawler(S3DownloadHandler, crawler) self.download_request = s3reqh.download_request @contextlib.contextmanager From 6cef717dad7d456590dd15da7513910aa35d0419 Mon Sep 17 00:00:00 2001 From: Adrian Date: Mon, 30 Mar 2026 17:33:21 +0200 Subject: [PATCH 084/248] Add a template for PRs (#7381) --- .github/pull_request_template.md | 31 ++++++++++++++++++ .github/workflows/auto-close-llm-pr.yml | 42 +++++++++++++++++++++++++ 2 files changed, 73 insertions(+) create mode 100644 .github/pull_request_template.md create mode 100644 .github/workflows/auto-close-llm-pr.yml diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 000000000..dd2edebdd --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,31 @@ + diff --git a/.github/workflows/auto-close-llm-pr.yml b/.github/workflows/auto-close-llm-pr.yml new file mode 100644 index 000000000..257ca5c66 --- /dev/null +++ b/.github/workflows/auto-close-llm-pr.yml @@ -0,0 +1,42 @@ +name: Auto-close LLM PRs +on: + pull_request_target: + types: [opened] +permissions: + contents: read + pull-requests: write +jobs: + close-llm-pr: + name: Close PR if marked as LLM-written + runs-on: ubuntu-latest + steps: + - name: Check PR body and close if LLM-written + uses: actions/github-script@v6 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const marker = "This PR was written entirely using an LLM"; + const { owner, repo } = context.repo; + const prNumber = context.payload.pull_request && context.payload.pull_request.number; + if (!prNumber) { + console.log('No pull request number found in context; exiting.'); + return; + } + const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: prNumber }); + const body = pr.body || ""; + if (body.includes(marker)) { + if (pr.state === 'closed') { + console.log(`PR #${prNumber} already closed.`); + return; + } + await github.rest.issues.createComment({ + owner, + repo, + issue_number: prNumber, + body: "Closing this PR because it contains the disclosure: \"This PR was written entirely using an LLM\"." + }); + await github.rest.pulls.update({ owner, repo, pull_number: prNumber, state: 'closed' }); + console.log(`Closed PR #${prNumber} because marker was found.`); + } else { + console.log(`Marker not found in PR #${prNumber}; nothing to do.`); + } From 74c33e5172403d9763107489a0f9042ae38c65fc Mon Sep 17 00:00:00 2001 From: Adrian Chaves Date: Mon, 30 Mar 2026 17:43:59 +0200 Subject: [PATCH 085/248] Use the spam label when auto-closing PRs --- .github/workflows/auto-close-llm-pr.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/auto-close-llm-pr.yml b/.github/workflows/auto-close-llm-pr.yml index 257ca5c66..160b39488 100644 --- a/.github/workflows/auto-close-llm-pr.yml +++ b/.github/workflows/auto-close-llm-pr.yml @@ -29,6 +29,12 @@ jobs: console.log(`PR #${prNumber} already closed.`); return; } + await github.rest.issues.addLabels({ + owner, + repo, + issue_number: prNumber, + labels: ['spam'] + }); await github.rest.issues.createComment({ owner, repo, From 299993b62aaf04614f184d4283963e2e06a73428 Mon Sep 17 00:00:00 2001 From: Adrian Chaves Date: Mon, 30 Mar 2026 17:46:55 +0200 Subject: [PATCH 086/248] Fix the docs build --- docs/_ext/scrapydocs.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/_ext/scrapydocs.py b/docs/_ext/scrapydocs.py index 776afaa6c..10d9bdf35 100644 --- a/docs/_ext/scrapydocs.py +++ b/docs/_ext/scrapydocs.py @@ -3,6 +3,7 @@ from collections.abc import Sequence from operator import itemgetter from typing import Any, TypedDict +import sphinx from docutils import nodes from docutils.nodes import Element, General, Node, document from docutils.parsers.rst import Directive @@ -126,7 +127,7 @@ def rev_role( return [node], [] -def setup(app: Sphinx) -> None: +def setup(app: Sphinx) -> dict[str, Any]: app.add_crossref_type( directivename="setting", rolename="setting", @@ -157,3 +158,5 @@ def setup(app: Sphinx) -> None: app.connect("doctree-read", collect_scrapy_settings_refs) app.connect("doctree-resolved", replace_settingslist_nodes) + + return {"version": sphinx.__display_version__, "parallel_read_safe": True} From c4c0555ccf0807600f95b0d31f3cb68c55001e40 Mon Sep 17 00:00:00 2001 From: Adrian Chaves Date: Mon, 30 Mar 2026 18:01:29 +0200 Subject: [PATCH 087/248] Enable parallel execution in the other internal Sphinx plugin --- docs/_ext/scrapydocs.py | 4 +--- docs/_ext/scrapyfixautodoc.py | 5 ++++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/_ext/scrapydocs.py b/docs/_ext/scrapydocs.py index 10d9bdf35..4e232967d 100644 --- a/docs/_ext/scrapydocs.py +++ b/docs/_ext/scrapydocs.py @@ -3,7 +3,6 @@ from collections.abc import Sequence from operator import itemgetter from typing import Any, TypedDict -import sphinx from docutils import nodes from docutils.nodes import Element, General, Node, document from docutils.parsers.rst import Directive @@ -158,5 +157,4 @@ def setup(app: Sphinx) -> dict[str, Any]: app.connect("doctree-read", collect_scrapy_settings_refs) app.connect("doctree-resolved", replace_settingslist_nodes) - - return {"version": sphinx.__display_version__, "parallel_read_safe": True} + return {"parallel_read_safe": True} diff --git a/docs/_ext/scrapyfixautodoc.py b/docs/_ext/scrapyfixautodoc.py index d7a3fb514..a8f2c6b68 100644 --- a/docs/_ext/scrapyfixautodoc.py +++ b/docs/_ext/scrapyfixautodoc.py @@ -3,6 +3,8 @@ Must be included after 'sphinx.ext.autodoc'. Fixes unwanted 'alias of' behavior. https://github.com/sphinx-doc/sphinx/issues/4422 """ +from typing import Any + # pylint: disable=import-error from sphinx.application import Sphinx @@ -14,5 +16,6 @@ def maybe_skip_member(app: Sphinx, what, name: str, obj, skip: bool, options) -> return skip -def setup(app: Sphinx) -> None: +def setup(app: Sphinx) -> dict[str, Any]: app.connect("autodoc-skip-member", maybe_skip_member) + return {"parallel_read_safe": True} From 72bcf8cb466fb5a55c903037644f45f0a69cdf50 Mon Sep 17 00:00:00 2001 From: "Albert Eduardovich N." Date: Tue, 31 Mar 2026 20:02:38 +0300 Subject: [PATCH 088/248] adjust httpx error mapping (#7384) --- scrapy/core/downloader/handlers/_httpx.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/scrapy/core/downloader/handlers/_httpx.py b/scrapy/core/downloader/handlers/_httpx.py index b19a66696..467174fee 100644 --- a/scrapy/core/downloader/handlers/_httpx.py +++ b/scrapy/core/downloader/handlers/_httpx.py @@ -138,12 +138,14 @@ class HttpxDownloadHandler(BaseHttpDownloadHandler): except httpx.UnsupportedProtocol as e: raise UnsupportedURLSchemeError(str(e)) from e except httpx.ConnectError as e: + error_message = str(e) if ( - "Name or service not known" in str(e) - or "getaddrinfo failed" in str(e) - or "nodename nor servname" in str(e) + "Name or service not known" in error_message + or "getaddrinfo failed" in error_message + or "nodename nor servname" in error_message + or "Temporary failure in name resolution" in error_message ): - raise CannotResolveHostError(str(e)) from e + raise CannotResolveHostError(error_message) from e raise DownloadConnectionRefusedError(str(e)) from e except httpx.NetworkError as e: raise DownloadFailedError(str(e)) from e From eabb149f4bf7737eb469319c7ce2c98ed0a6c32d Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Wed, 1 Apr 2026 15:18:43 +0500 Subject: [PATCH 089/248] Deprecate DOWNLOADER_CLIENTCONTEXTFACTORY, add DOWNLOAD_VERIFY_CERTIFICATES (#7379) * Deprecate DOWNLOADER_CLIENTCONTEXTFACTORY. * Deprecate BrowserLikeContextFactory. * Add DOWNLOAD_VERIFY_CERTIFICATES. * Cleanup, add acceptableCiphers to the verifying path. --- docs/news.rst | 2 +- docs/topics/components.rst | 2 - docs/topics/settings.rst | 73 ++++++++------------- scrapy/core/downloader/contextfactory.py | 61 +++++++++++++---- scrapy/core/downloader/handlers/http10.py | 18 +++-- scrapy/settings/default_settings.py | 10 +-- scrapy/utils/ssl.py | 10 ++- tests/test_downloader_handler_httpx.py | 4 ++ tests/test_downloader_handlers_http_base.py | 33 ++++++++++ 9 files changed, 141 insertions(+), 72 deletions(-) diff --git a/docs/news.rst b/docs/news.rst index 6bff95518..fd96b0b9b 100644 --- a/docs/news.rst +++ b/docs/news.rst @@ -5369,7 +5369,7 @@ Backward-incompatible changes consistency with similar classes (:issue:`3929`, :issue:`3982`) * If you are using a custom context factory - (:setting:`DOWNLOADER_CLIENTCONTEXTFACTORY`), its ``__init__`` method must + (``DOWNLOADER_CLIENTCONTEXTFACTORY``), its ``__init__`` method must accept two new parameters: ``tls_verbose_logging`` and ``tls_ciphers`` (:issue:`2111`, :issue:`3392`, :issue:`3442`, :issue:`3450`) diff --git a/docs/topics/components.rst b/docs/topics/components.rst index 56f8c6498..d8a604ea7 100644 --- a/docs/topics/components.rst +++ b/docs/topics/components.rst @@ -15,8 +15,6 @@ That includes the classes that you may assign to the following settings: - :setting:`DOWNLOAD_HANDLERS` -- :setting:`DOWNLOADER_CLIENTCONTEXTFACTORY` - - :setting:`DOWNLOADER_MIDDLEWARES` - :setting:`DUPEFILTER_CLASS` diff --git a/docs/topics/settings.rst b/docs/topics/settings.rst index c004c253e..13735b670 100644 --- a/docs/topics/settings.rst +++ b/docs/topics/settings.rst @@ -691,35 +691,6 @@ Default: ``'scrapy.core.downloader.Downloader'`` The downloader to use for crawling. -.. setting:: DOWNLOADER_CLIENTCONTEXTFACTORY - -DOWNLOADER_CLIENTCONTEXTFACTORY -------------------------------- - -Default: ``'scrapy.core.downloader.contextfactory.ScrapyClientContextFactory'`` - -Represents the classpath to the ContextFactory to use. - -Here, "ContextFactory" is a Twisted term for SSL/TLS contexts, defining -the TLS/SSL protocol version to use, whether to do certificate verification, -or even enable client-side authentication (and various other things). - -.. note:: - - Scrapy default context factory **does NOT perform remote server - certificate verification**. This is usually fine for web scraping. - - If you do need remote server certificate verification enabled, - Scrapy also has another context factory class that you can set, - ``'scrapy.core.downloader.contextfactory.BrowserLikeContextFactory'``, - which uses the platform's certificates to validate remote endpoints. - -.. note:: - - This setting is specific to the built-in Twisted-based download handlers: - :class:`scrapy.core.downloader.handlers.http11.HTTP11DownloadHandler` and - :class:`scrapy.core.downloader.handlers.http2.H2DownloadHandler`. - .. setting:: DOWNLOADER_CLIENT_TLS_CIPHERS DOWNLOADER_CLIENT_TLS_CIPHERS @@ -742,12 +713,7 @@ specific cipher that is not included in ``DEFAULT`` if a website requires it. Handling of this setting needs to be implemented inside the :ref:`download handler `, so it's not guaranteed to be supported - by all 3rd-party handlers. Moreover, for the built-in Twisted-based - download handlers - (:class:`scrapy.core.downloader.handlers.http11.HTTP11DownloadHandler` and - :class:`scrapy.core.downloader.handlers.http2.H2DownloadHandler`) it needs - to be implemented in the :setting:`DOWNLOADER_CLIENTCONTEXTFACTORY` class. - It's currently unsupported by + by all 3rd-party handlers. It's currently unsupported by :class:`~scrapy.core.downloader.handlers._httpx.HttpxDownloadHandler`. .. setting:: DOWNLOADER_CLIENT_TLS_METHOD @@ -774,12 +740,7 @@ This setting must be one of these string values: Handling of this setting needs to be implemented inside the :ref:`download handler `, so it's not guaranteed to be supported - by all 3rd-party handlers. Moreover, for the built-in Twisted-based - download handlers - (:class:`scrapy.core.downloader.handlers.http11.HTTP11DownloadHandler` and - :class:`scrapy.core.downloader.handlers.http2.H2DownloadHandler`) it needs - to be implemented in the :setting:`DOWNLOADER_CLIENTCONTEXTFACTORY` class. - It's currently unsupported by + by all 3rd-party handlers. It's currently unsupported by :class:`~scrapy.core.downloader.handlers._httpx.HttpxDownloadHandler`. .. setting:: DOWNLOADER_CLIENT_TLS_VERBOSE_LOGGING @@ -798,11 +759,7 @@ the TLS-related libraries. Handling of this setting needs to be implemented inside the :ref:`download handler `, so it's not guaranteed to be supported - by all 3rd-party handlers. Moreover, for the built-in Twisted-based - download handlers - (:class:`scrapy.core.downloader.handlers.http11.HTTP11DownloadHandler` and - :class:`scrapy.core.downloader.handlers.http2.H2DownloadHandler`) it needs - to be implemented in the :setting:`DOWNLOADER_CLIENTCONTEXTFACTORY` class. + by all 3rd-party handlers. .. setting:: DOWNLOADER_MIDDLEWARES @@ -1120,6 +1077,30 @@ Optionally, this can be set per-request basis by using the requests that use the same connection; hence, a ``ResponseFailed([InvalidBodyLengthError])`` failure is always raised for every request that was using that connection. +.. setting:: DOWNLOAD_VERIFY_CERTIFICATES + +DOWNLOAD_VERIFY_CERTIFICATES +---------------------------- + +Default: ``False`` + +Whether the HTTPS download handlers should verify the server TLS certificate +when making a request and abort the request if the verification fails. + +.. note:: + + Handling of this setting needs to be implemented inside the :ref:`download + handler `, so it's not guaranteed to be supported + by all 3rd-party handlers. The exact behavior of a handler (e.g. whether + certificate problems are logged when this setting is set to ``False``) + depends on its implementation. + +.. warning:: + Enabling this setting disables handling of + :setting:`DOWNLOADER_CLIENT_TLS_VERBOSE_LOGGING` in + :class:`~scrapy.core.downloader.handlers.http11.HTTP11DownloadHandler` and + :class:`~scrapy.core.downloader.handlers.http2.H2DownloadHandler`. + .. setting:: DUPEFILTER_CLASS DUPEFILTER_CLASS diff --git a/scrapy/core/downloader/contextfactory.py b/scrapy/core/downloader/contextfactory.py index 8b79b4273..9b1f778e2 100644 --- a/scrapy/core/downloader/contextfactory.py +++ b/scrapy/core/downloader/contextfactory.py @@ -1,7 +1,7 @@ from __future__ import annotations import warnings -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast from OpenSSL import SSL from twisted.internet._sslverify import _setAcceptableProtocols @@ -52,6 +52,7 @@ class ScrapyClientContextFactory(BrowserLikePolicyForHTTPS): tls_verbose_logging: bool = False, tls_ciphers: str | None = None, *args: Any, + verify_certificates: bool = False, **kwargs: Any, ): super().__init__(*args, **kwargs) # type: ignore[no-untyped-call] @@ -68,6 +69,7 @@ class ScrapyClientContextFactory(BrowserLikePolicyForHTTPS): acceptableCiphers=self.tls_ciphers, ) self._ctx = self._get_context() + self._verify_certificates = verify_certificates if method_is_overridden(type(self), ScrapyClientContextFactory, "getContext"): warnings.warn( "Overriding ScrapyClientContextFactory.getContext() is deprecated and that method" @@ -97,11 +99,13 @@ class ScrapyClientContextFactory(BrowserLikePolicyForHTTPS): "DOWNLOADER_CLIENT_TLS_VERBOSE_LOGGING" ) tls_ciphers: str | None = crawler.settings["DOWNLOADER_CLIENT_TLS_CIPHERS"] + verify_certificates = crawler.settings.getbool("DOWNLOAD_VERIFY_CERTIFICATES") return cls( # type: ignore[misc] *args, method=method, tls_verbose_logging=tls_verbose_logging, tls_ciphers=tls_ciphers, + verify_certificates=verify_certificates, **kwargs, ) @@ -129,10 +133,22 @@ class ScrapyClientContextFactory(BrowserLikePolicyForHTTPS): return ctx def creatorForNetloc(self, hostname: bytes, port: int) -> ClientTLSOptions: - return _ScrapyClientTLSOptions( - hostname.decode("ascii"), - self._ctx, - verbose_logging=self.tls_verbose_logging, + if not self._verify_certificates: + return _ScrapyClientTLSOptions( + hostname.decode("ascii"), + self._ctx, + verbose_logging=self.tls_verbose_logging, + ) + # this matches the behavior of BrowserLikeContextFactory in that it + # only uses self._ssl_method and doesn't support TLS logging or other + # features of ScrapyClientContextFactory, however it additionally + # supports self.tls_ciphers + return optionsForClientTLS( + hostname=hostname.decode("ascii"), + extraCertificateOptions={ + "method": self._ssl_method, + "acceptableCiphers": self.tls_ciphers, + }, ) @@ -158,6 +174,16 @@ class BrowserLikeContextFactory(ScrapyClientContextFactory): ``self._ssl_method`` is used from the parent class. """ + def __init__(self, *args: Any, **kwargs: Any): + warnings.warn( + "BrowserLikeContextFactory is deprecated." + " You can set DOWNLOAD_VERIFY_CERTIFICATES=True to enable" + " certificate verification instead of using it.", + category=ScrapyDeprecationWarning, + stacklevel=2, + ) + super().__init__(*args, **kwargs) + def creatorForNetloc(self, hostname: bytes, port: int) -> ClientTLSOptions: return optionsForClientTLS( hostname=hostname.decode("ascii"), @@ -203,14 +229,25 @@ def _load_context_factory_from_settings(crawler: Crawler) -> IPolicyForHTTPS: Also passes values of other relevant settings to the factory class. """ + if crawler.settings["DOWNLOADER_CLIENTCONTEXTFACTORY"] == "SENTINEL": + context_factory_cls = ScrapyClientContextFactory + else: # pragma: no cover + warnings.warn( + "The 'DOWNLOADER_CLIENTCONTEXTFACTORY' setting is deprecated.", + category=ScrapyDeprecationWarning, + stacklevel=2, + ) + context_factory_cls = load_object( + crawler.settings["DOWNLOADER_CLIENTCONTEXTFACTORY"] + ) ssl_method = openssl_methods[crawler.settings.get("DOWNLOADER_CLIENT_TLS_METHOD")] - context_factory_cls = load_object( - crawler.settings["DOWNLOADER_CLIENTCONTEXTFACTORY"] - ) - return build_from_crawler( - context_factory_cls, - crawler, - method=ssl_method, + return cast( + "IPolicyForHTTPS", + build_from_crawler( + context_factory_cls, + crawler, + method=ssl_method, + ), ) diff --git a/scrapy/core/downloader/handlers/http10.py b/scrapy/core/downloader/handlers/http10.py index 3820e79bd..0ce7eff3b 100644 --- a/scrapy/core/downloader/handlers/http10.py +++ b/scrapy/core/downloader/handlers/http10.py @@ -5,6 +5,7 @@ from __future__ import annotations import warnings from typing import TYPE_CHECKING +from scrapy.core.downloader.contextfactory import ScrapyClientContextFactory from scrapy.exceptions import NotConfigured, ScrapyDeprecationWarning from scrapy.utils.defer import maybe_deferred_to_future from scrapy.utils.misc import build_from_crawler, load_object @@ -17,7 +18,6 @@ if TYPE_CHECKING: from typing_extensions import Self from scrapy import Request - from scrapy.core.downloader.contextfactory import ScrapyClientContextFactory from scrapy.core.downloader.webclient import ScrapyHTTPClientFactory from scrapy.crawler import Crawler from scrapy.http import Response @@ -38,9 +38,19 @@ class HTTP10DownloadHandler: self.HTTPClientFactory: type[ScrapyHTTPClientFactory] = load_object( settings["DOWNLOADER_HTTPCLIENTFACTORY"] ) - self.ClientContextFactory: type[ScrapyClientContextFactory] = load_object( - settings["DOWNLOADER_CLIENTCONTEXTFACTORY"] - ) + if settings["DOWNLOADER_CLIENTCONTEXTFACTORY"] == "SENTINEL": + self.ClientContextFactory: type[ScrapyClientContextFactory] = ( + ScrapyClientContextFactory + ) + else: # pragma: no cover + warnings.warn( + "The 'DOWNLOADER_CLIENTCONTEXTFACTORY' setting is deprecated.", + category=ScrapyDeprecationWarning, + stacklevel=2, + ) + self.ClientContextFactory = load_object( + settings["DOWNLOADER_CLIENTCONTEXTFACTORY"] + ) self._settings: BaseSettings = settings self._crawler: Crawler = crawler diff --git a/scrapy/settings/default_settings.py b/scrapy/settings/default_settings.py index bc04ebcfb..a4768a4bf 100644 --- a/scrapy/settings/default_settings.py +++ b/scrapy/settings/default_settings.py @@ -244,10 +244,10 @@ DNSCACHE_SIZE = 10000 DNS_RESOLVER = "scrapy.resolver.CachingThreadedResolver" DNS_TIMEOUT = 60 -DOWNLOAD_DELAY = 0 - DOWNLOAD_BIND_ADDRESS = None +DOWNLOAD_DELAY = 0 + DOWNLOAD_FAIL_ON_DATALOSS = True DOWNLOAD_HANDLERS = {} @@ -265,11 +265,11 @@ DOWNLOAD_WARNSIZE = 32 * 1024 * 1024 # 32m DOWNLOAD_TIMEOUT = 180 # 3mins +DOWNLOAD_VERIFY_CERTIFICATES = False + DOWNLOADER = "scrapy.core.downloader.Downloader" -DOWNLOADER_CLIENTCONTEXTFACTORY = ( - "scrapy.core.downloader.contextfactory.ScrapyClientContextFactory" -) +DOWNLOADER_CLIENTCONTEXTFACTORY = "SENTINEL" DOWNLOADER_CLIENT_TLS_CIPHERS = "DEFAULT" # Use highest TLS/SSL protocol version supported by the platform, also allowing negotiation: DOWNLOADER_CLIENT_TLS_METHOD = "TLS" diff --git a/scrapy/utils/ssl.py b/scrapy/utils/ssl.py index 0062da52b..07c1b2ecc 100644 --- a/scrapy/utils/ssl.py +++ b/scrapy/utils/ssl.py @@ -39,10 +39,16 @@ def _make_ssl_context(settings: BaseSettings) -> ssl.SSLContext: if method_setting not in _STDLIB_PROTOCOL_MAP: raise ValueError(f"Unsupported TLS method: {method_setting}") ciphers_setting: str | None = settings["DOWNLOADER_CLIENT_TLS_CIPHERS"] + verify_setting = settings.getbool("DOWNLOAD_VERIFY_CERTIFICATES") ctx = ssl.SSLContext(_STDLIB_PROTOCOL_MAP[method_setting]) - ctx.check_hostname = False - ctx.verify_mode = ssl.CERT_NONE + if verify_setting: + ctx.check_hostname = True + ctx.verify_mode = ssl.CERT_REQUIRED + ctx.load_default_certs() + else: + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE if ciphers_setting: ctx.set_ciphers(ciphers_setting) return ctx diff --git a/tests/test_downloader_handler_httpx.py b/tests/test_downloader_handler_httpx.py index 1c11a1034..8e8e9a037 100644 --- a/tests/test_downloader_handler_httpx.py +++ b/tests/test_downloader_handler_httpx.py @@ -93,6 +93,10 @@ class TestHttp11(HttpxDownloadHandlerMixin, TestHttp11Base): class TestHttps11(HttpxDownloadHandlerMixin, TestHttps11Base): tls_log_message = "SSL connection to 127.0.0.1 using protocol TLSv1.3, cipher" + @pytest.mark.skip(reason="The check is Twisted-specific") + def test_verify_certs_deprecated(self): + pass + class TestSimpleHttps(HttpxDownloadHandlerMixin, TestSimpleHttpsBase): pass diff --git a/tests/test_downloader_handlers_http_base.py b/tests/test_downloader_handlers_http_base.py index 88813f0b6..9a6a77560 100644 --- a/tests/test_downloader_handlers_http_base.py +++ b/tests/test_downloader_handlers_http_base.py @@ -25,6 +25,7 @@ from scrapy.exceptions import ( DownloadFailedError, DownloadTimeoutError, ResponseDataLossError, + ScrapyDeprecationWarning, StopDownload, UnsupportedURLSchemeError, ) @@ -729,6 +730,38 @@ class TestHttps11Base(TestHttp11Base): assert response.body == b"Works" assert self.tls_log_message in caplog.text + @coroutine_test + async def test_verify_certs_deprecated(self, mockserver: MockServer) -> None: + request = Request(mockserver.url("/text", is_secure=self.is_secure)) + with ( # noqa: PT031 + pytest.warns( + ScrapyDeprecationWarning, + match="'DOWNLOADER_CLIENTCONTEXTFACTORY' setting is deprecated", + ), + pytest.warns( + ScrapyDeprecationWarning, + match="BrowserLikeContextFactory is deprecated", + ), + ): + async with self.get_dh( + { + "DOWNLOADER_CLIENTCONTEXTFACTORY": "scrapy.core.downloader.contextfactory.BrowserLikeContextFactory" + } + ) as download_handler: + with pytest.raises( + (DownloadConnectionRefusedError, DownloadFailedError) + ): + await download_handler.download_request(request) + + @coroutine_test + async def test_verify_certs(self, mockserver: MockServer) -> None: + request = Request(mockserver.url("/text", is_secure=self.is_secure)) + async with self.get_dh( + {"DOWNLOAD_VERIFY_CERTIFICATES": True} + ) as download_handler: + with pytest.raises((DownloadConnectionRefusedError, DownloadFailedError)): + await download_handler.download_request(request) + class TestSimpleHttpsBase(ABC): """Base class for special cases tested with just one simple request""" From fa76ca52e94c44aaa6409a0577cc4e0f5e247662 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Wed, 1 Apr 2026 16:02:55 +0500 Subject: [PATCH 090/248] Move TLS logging from _ScrapyClientTLSOptions to handlers. (#7387) * Move TLS logging from _ScrapyClientTLSOptions to handlers. * Update the docs. * Fix typing. * Update new docs. * Add the comment back. --- docs/topics/settings.rst | 6 ---- scrapy/core/downloader/contextfactory.py | 13 ++------ scrapy/core/downloader/handlers/_httpx.py | 3 -- scrapy/core/downloader/handlers/http11.py | 14 ++++++++ scrapy/core/downloader/tls.py | 39 +++-------------------- scrapy/core/http2/agent.py | 11 ++++++- scrapy/core/http2/protocol.py | 20 +++++++++++- scrapy/utils/_download_handlers.py | 3 ++ scrapy/utils/ssl.py | 19 +++++++++++ 9 files changed, 73 insertions(+), 55 deletions(-) diff --git a/docs/topics/settings.rst b/docs/topics/settings.rst index 13735b670..0ec4a53e9 100644 --- a/docs/topics/settings.rst +++ b/docs/topics/settings.rst @@ -1095,12 +1095,6 @@ when making a request and abort the request if the verification fails. certificate problems are logged when this setting is set to ``False``) depends on its implementation. -.. warning:: - Enabling this setting disables handling of - :setting:`DOWNLOADER_CLIENT_TLS_VERBOSE_LOGGING` in - :class:`~scrapy.core.downloader.handlers.http11.HTTP11DownloadHandler` and - :class:`~scrapy.core.downloader.handlers.http2.H2DownloadHandler`. - .. setting:: DUPEFILTER_CLASS DUPEFILTER_CLASS diff --git a/scrapy/core/downloader/contextfactory.py b/scrapy/core/downloader/contextfactory.py index 9b1f778e2..a01f021a6 100644 --- a/scrapy/core/downloader/contextfactory.py +++ b/scrapy/core/downloader/contextfactory.py @@ -57,7 +57,7 @@ class ScrapyClientContextFactory(BrowserLikePolicyForHTTPS): ): super().__init__(*args, **kwargs) # type: ignore[no-untyped-call] self._ssl_method: int = method - self.tls_verbose_logging: bool = tls_verbose_logging + self.tls_verbose_logging: bool = tls_verbose_logging # unused self.tls_ciphers: AcceptableCiphers if tls_ciphers: self.tls_ciphers = AcceptableCiphers.fromOpenSSLCipherString(tls_ciphers) @@ -134,15 +134,8 @@ class ScrapyClientContextFactory(BrowserLikePolicyForHTTPS): def creatorForNetloc(self, hostname: bytes, port: int) -> ClientTLSOptions: if not self._verify_certificates: - return _ScrapyClientTLSOptions( - hostname.decode("ascii"), - self._ctx, - verbose_logging=self.tls_verbose_logging, - ) - # this matches the behavior of BrowserLikeContextFactory in that it - # only uses self._ssl_method and doesn't support TLS logging or other - # features of ScrapyClientContextFactory, however it additionally - # supports self.tls_ciphers + return _ScrapyClientTLSOptions(hostname.decode("ascii"), self._ctx) # type: ignore[no-untyped-call] + # Note that this doesn't use self._ctx return optionsForClientTLS( hostname=hostname.decode("ascii"), extraCertificateOptions={ diff --git a/scrapy/core/downloader/handlers/_httpx.py b/scrapy/core/downloader/handlers/_httpx.py index 467174fee..1787ec918 100644 --- a/scrapy/core/downloader/handlers/_httpx.py +++ b/scrapy/core/downloader/handlers/_httpx.py @@ -92,9 +92,6 @@ class HttpxDownloadHandler(BaseHttpDownloadHandler): logger.warning( "HttpxDownloadHandler is experimental and is not recommented for production use." ) - self._tls_verbose_logging: bool = self.crawler.settings.getbool( - "DOWNLOADER_CLIENT_TLS_VERBOSE_LOGGING" - ) bind_address = crawler.settings.get("DOWNLOAD_BIND_ADDRESS") bind_address = normalize_bind_address(bind_address) diff --git a/scrapy/core/downloader/handlers/http11.py b/scrapy/core/downloader/handlers/http11.py index 8423093e6..6c9b699f6 100644 --- a/scrapy/core/downloader/handlers/http11.py +++ b/scrapy/core/downloader/handlers/http11.py @@ -53,6 +53,7 @@ from scrapy.utils.defer import maybe_deferred_to_future from scrapy.utils.deprecate import warn_on_deprecated_spider_attribute from scrapy.utils.httpobj import urlparse_cached from scrapy.utils.python import to_bytes, to_unicode +from scrapy.utils.ssl import _log_ssl_conn_debug_info from scrapy.utils.url import add_http_if_no_scheme if TYPE_CHECKING: @@ -121,6 +122,7 @@ class HTTP11DownloadHandler(BaseHttpDownloadHandler): ), fail_on_dataloss=self._fail_on_dataloss, crawler=self._crawler, + tls_verbose_logging=self._tls_verbose_logging, ) try: with wrap_twisted_exceptions(): @@ -391,6 +393,7 @@ class ScrapyAgent: warnsize: int = 0, fail_on_dataloss: bool = True, crawler: Crawler, + tls_verbose_logging: bool = False, ): self._contextFactory: IPolicyForHTTPS = contextFactory self._connectTimeout: float = connectTimeout @@ -401,6 +404,7 @@ class ScrapyAgent: self._fail_on_dataloss: bool = fail_on_dataloss self._txresponse: TxResponse | None = None self._crawler: Crawler = crawler + self._tls_verbose_logging: bool = tls_verbose_logging def _get_agent(self, request: Request, timeout: float) -> Agent: from twisted.internet import reactor @@ -557,6 +561,7 @@ class ScrapyAgent: warnsize=warnsize, fail_on_dataloss=fail_on_dataloss, crawler=self._crawler, + tls_verbose_logging=self._tls_verbose_logging, ) ) @@ -612,6 +617,8 @@ class _ResponseReader(Protocol): warnsize: int, fail_on_dataloss: bool, crawler: Crawler, + *, + tls_verbose_logging: bool = False, ): self._finished: Deferred[_ResultT] = finished self._txresponse: TxResponse = txresponse @@ -625,6 +632,7 @@ class _ResponseReader(Protocol): self._certificate: ssl.Certificate | None = None self._ip_address: ipaddress.IPv4Address | ipaddress.IPv6Address | None = None self._crawler: Crawler = crawler + self._tls_verbose_logging: bool = tls_verbose_logging def _finish_response( self, flags: list[str] | None = None, stop_download: StopDownload | None = None @@ -653,6 +661,12 @@ class _ResponseReader(Protocol): self.transport._producer.getPeer().host ) + if self._tls_verbose_logging: + connection = self.transport._producer.getHandle() + hostname = urlparse_cached(self._request).hostname + assert hostname is not None + _log_ssl_conn_debug_info(hostname, connection) + def dataReceived(self, bodyBytes: bytes) -> None: # This maybe called several times after cancel was called with buffered data. if self._finished.called: diff --git a/scrapy/core/downloader/tls.py b/scrapy/core/downloader/tls.py index 6ff13f5a4..390309900 100644 --- a/scrapy/core/downloader/tls.py +++ b/scrapy/core/downloader/tls.py @@ -2,7 +2,6 @@ import logging from typing import Any from OpenSSL import SSL -from OpenSSL.SSL import Connection from service_identity import VerificationError from service_identity.exceptions import CertificateError from service_identity.pyopenssl import verify_hostname, verify_ip_address @@ -10,7 +9,6 @@ from twisted.internet._sslverify import ClientTLSOptions from twisted.internet.ssl import AcceptableCiphers from scrapy.utils.deprecate import create_deprecated_class -from scrapy.utils.ssl import get_temp_key_info, x509name_to_string logger = logging.getLogger(__name__) @@ -29,53 +27,24 @@ openssl_methods: dict[str, int] = { } -def _log_tls(hostname: str, connection: Connection) -> None: - logger.debug( - "SSL connection to %s using protocol %s, cipher %s", - hostname, - connection.get_protocol_version_name(), - connection.get_cipher_name(), - ) - server_cert = connection.get_peer_certificate() - if server_cert: - logger.debug( - 'SSL connection certificate: issuer "%s", subject "%s"', - x509name_to_string(server_cert.get_issuer()), - x509name_to_string(server_cert.get_subject()), - ) - key_info = get_temp_key_info(connection._ssl) - if key_info: - logger.debug("SSL temp key: %s", key_info) - - class _ScrapyClientTLSOptions(ClientTLSOptions): """ SSL Client connection creator ignoring certificate verification errors - (for genuinely invalid certificates or bugs in verification code) and - optionally logging TLS details of the connection. + (for genuinely invalid certificates or bugs in verification code). Same as Twisted's private _sslverify.ClientTLSOptions, except that VerificationError, CertificateError and ValueError exceptions are caught, so that the connection is not closed, only - logging warnings. Also, HTTPS connection parameters logging is added. + logging warnings. Instances of this class are returned from :class:`.ScrapyClientContextFactory`. """ - def __init__(self, hostname: str, ctx: SSL.Context, verbose_logging: bool = False): - super().__init__(hostname, ctx) # type: ignore[no-untyped-call] - self.verbose_logging: bool = verbose_logging - def _identityVerifyingInfoCallback( self, connection: SSL.Connection, where: int, ret: Any ) -> None: - if where & SSL.SSL_CB_HANDSHAKE_START and self._hostnameIsDnsName: - connection.set_tlsext_host_name(self._hostnameBytes) - elif where & SSL.SSL_CB_HANDSHAKE_DONE: - if self.verbose_logging: - _log_tls(self._hostnameASCII, connection) - + if where & SSL.SSL_CB_HANDSHAKE_DONE: try: if self._hostnameIsDnsName: verify_hostname(connection, self._hostnameASCII) @@ -94,6 +63,8 @@ class _ScrapyClientTLSOptions(ClientTLSOptions): self._hostnameASCII, e, ) + else: + super()._identityVerifyingInfoCallback(connection, where, ret) # type: ignore[no-untyped-call] ScrapyClientTLSOptions = create_deprecated_class( diff --git a/scrapy/core/http2/agent.py b/scrapy/core/http2/agent.py index 7409926a0..540b9aa74 100644 --- a/scrapy/core/http2/agent.py +++ b/scrapy/core/http2/agent.py @@ -43,6 +43,10 @@ class H2ConnectionPool: ConnectionKeyT, deque[Deferred[H2ClientProtocol]] ] = {} + self._tls_verbose_logging: bool = settings.getbool( + "DOWNLOADER_CLIENT_TLS_VERBOSE_LOGGING" + ) + def get_connection( self, key: ConnectionKeyT, uri: URI, endpoint: HostnameEndpoint ) -> Deferred[H2ClientProtocol]: @@ -71,7 +75,12 @@ class H2ConnectionPool: conn_lost_deferred: Deferred[list[BaseException]] = Deferred() conn_lost_deferred.addCallback(self._remove_connection, key) - factory = H2ClientFactory(uri, self.settings, conn_lost_deferred) + factory = H2ClientFactory( + uri, + self.settings, + conn_lost_deferred, + tls_verbose_logging=self._tls_verbose_logging, + ) conn_d = endpoint.connect(factory) conn_d.addCallback(self.put_connection, key) diff --git a/scrapy/core/http2/protocol.py b/scrapy/core/http2/protocol.py index 4094e6f18..19f0c16e9 100644 --- a/scrapy/core/http2/protocol.py +++ b/scrapy/core/http2/protocol.py @@ -35,6 +35,7 @@ from scrapy.core.http2.stream import Stream, StreamCloseReason from scrapy.exceptions import DownloadTimeoutError from scrapy.http import Request, Response from scrapy.utils.deprecate import warn_on_deprecated_spider_attribute +from scrapy.utils.ssl import _log_ssl_conn_debug_info if TYPE_CHECKING: from ipaddress import IPv4Address, IPv6Address @@ -92,6 +93,8 @@ class H2ClientProtocol(Protocol, TimeoutMixin): uri: URI, settings: Settings, conn_lost_deferred: Deferred[list[BaseException]], + *, + tls_verbose_logging: bool = False, ) -> None: """ Arguments: @@ -101,8 +104,10 @@ class H2ClientProtocol(Protocol, TimeoutMixin): settings -- Scrapy project settings conn_lost_deferred -- Deferred fires with the reason: Failure to notify that connection was lost + tls_verbose_logging -- Whether to log TLS details """ self._conn_lost_deferred: Deferred[list[BaseException]] = conn_lost_deferred + self._tls_verbose_logging: bool = tls_verbose_logging config = H2Configuration(client_side=True, header_encoding="utf-8") self.conn = H2Connection(config=config) @@ -278,6 +283,11 @@ class H2ClientProtocol(Protocol, TimeoutMixin): [InvalidNegotiatedProtocol(self.transport.negotiatedProtocol)] ) + if self._tls_verbose_logging: + connection = self.transport.getHandle() + hostname = self.metadata["uri"].host.decode("ascii") + _log_ssl_conn_debug_info(hostname, connection) + def _check_received_data(self, data: bytes) -> None: """Checks for edge cases where the connection to remote fails without raising an appropriate H2Error @@ -454,13 +464,21 @@ class H2ClientFactory(Factory): uri: URI, settings: Settings, conn_lost_deferred: Deferred[list[BaseException]], + *, + tls_verbose_logging: bool = False, ) -> None: self.uri = uri self.settings = settings self.conn_lost_deferred = conn_lost_deferred + self.tls_verbose_logging = tls_verbose_logging def buildProtocol(self, addr: IAddress) -> H2ClientProtocol: - return H2ClientProtocol(self.uri, self.settings, self.conn_lost_deferred) + return H2ClientProtocol( + self.uri, + self.settings, + self.conn_lost_deferred, + tls_verbose_logging=self.tls_verbose_logging, + ) def acceptableProtocols(self) -> list[bytes]: return [PROTOCOL_NAME] diff --git a/scrapy/utils/_download_handlers.py b/scrapy/utils/_download_handlers.py index 94063a831..9538dd81e 100644 --- a/scrapy/utils/_download_handlers.py +++ b/scrapy/utils/_download_handlers.py @@ -48,6 +48,9 @@ class BaseHttpDownloadHandler(BaseDownloadHandler, ABC): self._fail_on_dataloss: bool = crawler.settings.getbool( "DOWNLOAD_FAIL_ON_DATALOSS" ) + self._tls_verbose_logging: bool = crawler.settings.getbool( + "DOWNLOADER_CLIENT_TLS_VERBOSE_LOGGING" + ) self._fail_on_dataloss_warned: bool = False diff --git a/scrapy/utils/ssl.py b/scrapy/utils/ssl.py index 07c1b2ecc..a8b9cd225 100644 --- a/scrapy/utils/ssl.py +++ b/scrapy/utils/ssl.py @@ -120,3 +120,22 @@ def get_openssl_version() -> str: system_openssl_bytes = OpenSSL.SSL.SSLeay_version(OpenSSL.SSL.SSLEAY_VERSION) system_openssl = system_openssl_bytes.decode("ascii", errors="replace") return f"{OpenSSL.version.__version__} ({system_openssl})" + + +def _log_ssl_conn_debug_info(hostname: str, connection: OpenSSL.SSL.Connection) -> None: + logger.debug( + "SSL connection to %s using protocol %s, cipher %s", + hostname, + connection.get_protocol_version_name(), + connection.get_cipher_name(), + ) + server_cert = connection.get_peer_certificate() + if server_cert: + logger.debug( + 'SSL connection certificate: issuer "%s", subject "%s"', + x509name_to_string(server_cert.get_issuer()), + x509name_to_string(server_cert.get_subject()), + ) + key_info = get_temp_key_info(connection._ssl) + if key_info: + logger.debug("SSL temp key: %s", key_info) From 0c6ccf50b39330bf88d71b48f388f7c0efdb8496 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Wed, 1 Apr 2026 20:40:34 +0500 Subject: [PATCH 091/248] Docs for the reactorless mode (#7385) * Reactorless mode docs. * Unset the ftp handler in the reactorless mode. * Cleanup reactorless subprocess tests. * Better handling of incompatible TWISTED_ENABLED cases. * Improvements. * Improve early error logging in test scripts. * Address feedback. * Address feedback. --- docs/news.rst | 2 +- docs/topics/asyncio.rst | 170 ++++++++++++++++++ docs/topics/download-handlers.rst | 9 + docs/topics/practices.rst | 80 +++++++++ docs/topics/settings.rst | 82 ++++++++- docs/topics/shell.rst | 3 + docs/topics/telnetconsole.rst | 3 + scrapy/crawler.py | 55 ++++-- scrapy/utils/asyncio.py | 22 ++- .../reactorless_custom_settings.py | 36 ++++ .../reactorless_import_hook.py | 11 +- .../reactorless_reactor.py | 11 +- .../AsyncCrawlerProcess/reactorless_simple.py | 11 +- .../reactorless_telnetconsole_default.py | 25 --- .../reactorless_telnetconsole_disabled.py | 5 - .../reactorless_telnetconsole_enabled.py | 5 - tests/AsyncCrawlerRunner/no_reactor.py | 22 +++ .../reactorless_custom_settings.py | 27 +++ .../AsyncCrawlerRunner/reactorless_datauri.py | 11 +- .../AsyncCrawlerRunner/reactorless_reactor.py | 11 +- .../AsyncCrawlerRunner/reactorless_simple.py | 11 +- tests/CrawlerRunner/no_reactor.py | 19 ++ tests/test_crawler_subprocess.py | 35 +++- 23 files changed, 542 insertions(+), 124 deletions(-) create mode 100644 tests/AsyncCrawlerProcess/reactorless_custom_settings.py delete mode 100644 tests/AsyncCrawlerProcess/reactorless_telnetconsole_default.py create mode 100644 tests/AsyncCrawlerRunner/no_reactor.py create mode 100644 tests/AsyncCrawlerRunner/reactorless_custom_settings.py create mode 100644 tests/CrawlerRunner/no_reactor.py diff --git a/docs/news.rst b/docs/news.rst index fd96b0b9b..356220c3f 100644 --- a/docs/news.rst +++ b/docs/news.rst @@ -663,7 +663,7 @@ New features (:issue:`4463`, :issue:`6804`) - Added :func:`scrapy.utils.asyncio.is_asyncio_available` as an alternative - to :func:`scrapy.utils.defer.is_asyncio_reactor_installed` with a + to :func:`scrapy.utils.reactor.is_asyncio_reactor_installed` with a future-proof name and semantics. (:issue:`6827`) diff --git a/docs/topics/asyncio.rst b/docs/topics/asyncio.rst index b2b17a408..c6d26ea94 100644 --- a/docs/topics/asyncio.rst +++ b/docs/topics/asyncio.rst @@ -129,6 +129,173 @@ example: .. autofunction:: scrapy.utils.reactor.is_asyncio_reactor_installed +.. _asyncio-without-reactor: + +Using Scrapy without a Twisted reactor +====================================== + +.. versionadded:: 2.15.0 + +.. warning:: + This is currently experimental and may not be suitable for production use. + +It's possible to use Scrapy without installing a Twisted reactor at all, by +setting the :setting:`TWISTED_ENABLED` setting to ``False``. In this mode +Scrapy will use the asyncio event loop directly, and most of the Scrapy +functionality will work in the same way. + +Doing this provides several benefits in certain use cases: + +* A Twisted reactor, once stopped, cannot be started again. This prevents, for + example, using several instances of + :class:`~scrapy.crawler.AsyncCrawlerProcess` in the same process when they + use a reactor, but with ``TWISTED_ENABLED=False`` it becomes possible. +* There may be limitations imposed by + :class:`~twisted.internet.asyncioreactor.AsyncioSelectorReactor` and related + Twisted code, such as the requirement of using + :class:`~asyncio.SelectorEventLoop` on Windows (see :ref:`asyncio-windows`), + that do not apply if the reactor is not used. +* :class:`~twisted.internet.asyncioreactor.AsyncioSelectorReactor` manages the + underlying event loop, and while :class:`~scrapy.crawler.AsyncCrawlerRunner` + can use a pre-existing reactor which, in turn, can use a pre-existing event + loop, it's easier to use :class:`~scrapy.crawler.AsyncCrawlerRunner` with a + pre-existing loop directly. +* Omitting the reactor machinery may improve performance and reliability. + +Limitations +----------- + +As some Scrapy features and components require a reactor, they don't work and +are disabled without it. Replacements that don't require a reactor may be added +in future Scrapy versions. The following features are not available: + +* The default HTTP(S) download handler, + :class:`~scrapy.core.downloader.handlers.http11.HTTP11DownloadHandler` (this + is likely the biggest difference; Scrapy provides an HTTP(S) download handler + that doesn't require a reactor and will be used instead of it: + :class:`~scrapy.core.downloader.handlers._httpx.HttpxDownloadHandler`) +* :class:`~scrapy.core.downloader.handlers.ftp.FTPDownloadHandler` +* :class:`~scrapy.core.downloader.handlers.http2.H2DownloadHandler` +* :ref:`topics-shell` +* :ref:`topics-telnetconsole` +* :class:`~scrapy.crawler.CrawlerRunner` and + :class:`~scrapy.crawler.CrawlerProcess` + (:class:`~scrapy.crawler.AsyncCrawlerProcess` and + :class:`~scrapy.crawler.AsyncCrawlerRunner` are available) +* Twisted-specific DNS resolvers (the :setting:`DNS_RESOLVER` setting) +* User and 3rd-party code that requires a reactor (see :ref:`below + ` for examples) + +Note that importing Twisted modules and, among other things, creating and using +:class:`~twisted.internet.defer.Deferred` objects doesn't require a reactor, so +code that uses :class:`~twisted.internet.defer.Deferred`, +:class:`~twisted.python.failure.Failure` and some other Twisted APIs will not +necessarily stop working. + +Other differences +----------------- + +When :setting:`TWISTED_ENABLED` is set to ``False``, Scrapy will change the +defaults of some other settings: + +* :setting:`TELNETCONSOLE_ENABLED` is set to ``False``. +* The ``"http"`` and ``"https"`` keys in :setting:`DOWNLOAD_HANDLERS_BASE` are + set to ``"scrapy.core.downloader.handlers._httpx.HttpxDownloadHandler"``. +* The ``"ftp"`` key in :setting:`DOWNLOAD_HANDLERS_BASE` is set to ``None``. + +Thus, :class:`~scrapy.core.downloader.handlers._httpx.HttpxDownloadHandler` is +used by default for making HTTP(S) requests. Please refer to its documentation +for its differences and limitations compared to +:class:`~scrapy.core.downloader.handlers.http11.HTTP11DownloadHandler`. + +Additionally, :class:`~scrapy.crawler.AsyncCrawlerProcess` will install a +:term:`meta path finder` that prevents :mod:`twisted.internet.reactor` from +being imported. + +.. _asyncio-without-reactor-migrate: + +Adding support to existing code +------------------------------- + +Code that doesn't directly use Twisted APIs or APIs that depend on Twisted ones +doesn't need special support for running without a reactor. + +Here are some examples of APIs and patterns that need a replacement: + +* Using :meth:`reactor.callLater() + ` for sleeping or delayed calls. + You can use :meth:`asyncio.loop.call_later` instead. +* Using :func:`twisted.internet.threads.deferToThread`, + :meth:`reactor.callFromThread() + ` and related APIs to + execute code in other threads. You can use :func:`asyncio.to_thread`, + :meth:`asyncio.loop.call_soon_threadsafe` and related APIs instead. +* Using :class:`twisted.internet.task.LoopingCall` for scheduling repeated + tasks. As there is no direct replacement in the standard library, you may + need to write your own one using :func:`asyncio.sleep` in a task. +* Using Twisted network client and server APIs (:meth:`reactor.connectTCP() + `, + :meth:`reactor.listenTCP() + `, + :mod:`twisted.web.client`, :mod:`twisted.mail.smtp` etc.). You can use other + built-in or 3rd-party libraries for this. +* Using :class:`~scrapy.crawler.CrawlerProcess` or + :class:`~scrapy.crawler.CrawlerRunner`. You should use + :class:`~scrapy.crawler.AsyncCrawlerProcess` or + :class:`~scrapy.crawler.AsyncCrawlerRunner` respectively instead. +* Checking whether ``asyncio`` support is available with + :func:`scrapy.utils.reactor.is_asyncio_reactor_installed`. You should use + :func:`scrapy.utils.asyncio.is_asyncio_available` instead. + +Scrapy provides unified helpers for some of these examples: + +.. autofunction:: scrapy.utils.asyncio.call_later +.. autofunction:: scrapy.utils.asyncio.create_looping_call +.. autoclass:: scrapy.utils.asyncio.AsyncioLoopingCall +.. autofunction:: scrapy.utils.asyncio.run_in_thread + +If your code needs to know whether the reactor is available, you can either +check for the value of the :setting:`TWISTED_ENABLED` setting (you need access +to the :class:`~scrapy.crawler.Crawler` instance to do this) or use the +following function: + +.. autofunction:: scrapy.utils.reactorless.is_reactorless + +In general, code that doesn't use the reactor (directly or indirectly) can be +used unmodified both with the asyncio reactor and without a reactor. This +includes code that converts Deferreds to futures and vice versa as described in +:ref:`asyncio-await-dfd`. + +Troubleshooting +--------------- + +**ImportError: Import of twisted.internet.reactor is forbidden when running +without a Twisted reactor [...]:** Scrapy is configured to run without a +reactor, but some code imported :mod:`twisted.internet.reactor`, most likely +because that code needs a reactor to be used. You need to stop using this code +or set :setting:`TWISTED_ENABLED` back to ``True``. It's also possible that the +reactor isn't really needed but was installed due to the problem described in +:ref:`asyncio-preinstalled-reactor`, in which case it should be enough to fix +the problematic imports. + +**RuntimeError: TWISTED_ENABLED is False but a Twisted reactor is installed:** +Scrapy is configured to run without a reactor, but a reactor is already +installed before the Scrapy code is executed. If you are trying to set +:setting:`TWISTED_ENABLED` via :ref:`per-spider settings `, +it's currently unsupported. + +**RuntimeError: We expected a Twisted reactor to be installed but it isn't:** +Scrapy is configured to run with a reactor and not to install one, but a +reactor wasn't installed before the Scrapy code is executed. If you are trying +to set :setting:`TWISTED_ENABLED` via :ref:`per-spider settings +`, it's currently unsupported. + +**RuntimeError: doesn't support TWISTED_ENABLED=False:** The listed +class cannot be used with :setting:`TWISTED_ENABLED` set to ``False``. There +may be a replacement in the :ref:`documentation above +` or the documentation of the affected class. + + .. _asyncio-windows: Windows-specific notes @@ -149,6 +316,9 @@ automatically when you change the :setting:`TWISTED_REACTOR` setting or call them together with Scrapy on Windows (but you should be able to use them on WSL or native Linux). +.. note:: This problem doesn't apply when not using the reactor, see + :ref:`asyncio-without-reactor`. + .. _playwright: https://github.com/microsoft/playwright-python diff --git a/docs/topics/download-handlers.rst b/docs/topics/download-handlers.rst index 54a7f0e7c..29868bbaa 100644 --- a/docs/topics/download-handlers.rst +++ b/docs/topics/download-handlers.rst @@ -140,6 +140,9 @@ This handler supports ``ftp://host/path`` FTP URIs. It's implemented using :mod:`twisted.protocols.ftp`. +.. note:: + This handler is not supported when :setting:`TWISTED_ENABLED` is ``False``. + .. _twisted-http2-handler: H2DownloadHandler @@ -193,6 +196,9 @@ If you want to use this handler you need to replace the default one for the .. _http2 faq: https://http2.github.io/faq/#does-http2-require-encryption .. _server pushes: https://datatracker.ietf.org/doc/html/rfc7540#section-8.2 +.. note:: + This handler is not supported when :setting:`TWISTED_ENABLED` is ``False``. + HTTP11DownloadHandler --------------------- @@ -206,6 +212,9 @@ uses the HTTP/1.1 protocol for them. It's implemented using :mod:`twisted.web.client`. +.. note:: + This handler is not supported when :setting:`TWISTED_ENABLED` is ``False``. + HttpxDownloadHandler -------------------- diff --git a/docs/topics/practices.rst b/docs/topics/practices.rst index 79d5bcce9..8176063d8 100644 --- a/docs/topics/practices.rst +++ b/docs/topics/practices.rst @@ -166,6 +166,86 @@ with :class:`~twisted.internet.asyncioreactor.AsyncioSelectorReactor`): .. seealso:: :doc:`twisted:core/howto/reactor-basics` +And here are examples of using these classes with :setting:`TWISTED_ENABLED` +set to ``False``. + +Simple usage of :class:`~scrapy.crawler.AsyncCrawlerProcess`: + +.. code-block:: python + + import scrapy + from scrapy.crawler import AsyncCrawlerProcess + + + class MySpider(scrapy.Spider): + # Your spider definition + ... + + + process = AsyncCrawlerProcess( + settings={ + "TWISTED_ENABLED": False, + } + ) + + process.crawl(MySpider) + process.start() # the script will block here until the crawling is finished + +With ``TWISTED_ENABLED=False`` you can use several instances of +:class:`~scrapy.crawler.AsyncCrawlerProcess` in the same process: + +.. code-block:: python + + import scrapy + from scrapy.crawler import AsyncCrawlerProcess + + + class MySpider(scrapy.Spider): + # Your spider definition + ... + + + process1 = AsyncCrawlerProcess( + settings={ + "TWISTED_ENABLED": False, + } + ) + process1.crawl(MySpider) + process1.start() + + process2 = AsyncCrawlerProcess( + settings={ + "TWISTED_ENABLED": False, + } + ) + process2.crawl(MySpider) + process2.start() + +Using :func:`asyncio.run` with :class:`~scrapy.crawler.AsyncCrawlerRunner`: + +.. code-block:: python + + import asyncio + + import scrapy + from scrapy.crawler import AsyncCrawlerRunner + from scrapy.utils.log import configure_logging + + + class MySpider(scrapy.Spider): + # Your spider definition + ... + + + async def main(): + configure_logging({"LOG_FORMAT": "%(levelname)s: %(message)s"}) + runner = AsyncCrawlerRunner(settings={"TWISTED_ENABLED": False}) + await runner.crawl(MySpider) # completes when the spider finishes + + + asyncio.run(main()) + + .. _run-multiple-spiders: Running multiple spiders in the same process diff --git a/docs/topics/settings.rst b/docs/topics/settings.rst index 0ec4a53e9..964ea99c0 100644 --- a/docs/topics/settings.rst +++ b/docs/topics/settings.rst @@ -303,11 +303,12 @@ Pre-crawler settings These settings cannot be :ref:`set from a spider `. -These settings are :setting:`SPIDER_LOADER_CLASS` and settings used by the -corresponding :ref:`component `, e.g. -:setting:`SPIDER_MODULES` and :setting:`SPIDER_LOADER_WARN_ONLY` for the -default component. +These settings are: +- :setting:`TWISTED_ENABLED` +- :setting:`SPIDER_LOADER_CLASS` and settings used by the corresponding + spider loader class, e.g. :setting:`SPIDER_MODULES` and + :setting:`SPIDER_LOADER_WARN_ONLY` for the default spider loader class. .. _reactor-settings: @@ -356,6 +357,9 @@ ignoring the value of :setting:`TWISTED_REACTOR` and using the value of e.g. in :ref:`per-spider settings `, an exception will be raised. +All of these settings, except for :setting:`ASYNCIO_EVENT_LOOP`, are only used +when the Twisted reactor is used, i.e. when :setting:`TWISTED_ENABLED` is +``True``. .. _topics-settings-ref: @@ -651,6 +655,13 @@ Default: ``True`` Whether to enable DNS in-memory cache. +.. note:: + This setting is only used by + :class:`~scrapy.resolver.CachingThreadedResolver` and + :class:`~scrapy.resolver.CachingHostnameResolver`. It has no effect when + :setting:`TWISTED_ENABLED` is ``False``, and may have no effect either when + :setting:`DNS_RESOLVER` is set to a different resolver. + .. setting:: DNSCACHE_SIZE DNSCACHE_SIZE @@ -658,7 +669,7 @@ DNSCACHE_SIZE Default: ``10000`` -DNS in-memory cache size. +DNS in-memory cache size, see :setting:`DNSCACHE_ENABLED`. .. setting:: DNS_RESOLVER @@ -667,12 +678,16 @@ DNS_RESOLVER Default: ``'scrapy.resolver.CachingThreadedResolver'`` -The class to be used to resolve DNS names. The default ``scrapy.resolver.CachingThreadedResolver`` -supports specifying a timeout for DNS requests via the :setting:`DNS_TIMEOUT` setting, -but works only with IPv4 addresses. Scrapy provides an alternative resolver, +The class to be used by Twisted to resolve DNS names. The default +``scrapy.resolver.CachingThreadedResolver`` supports specifying a timeout for +DNS requests via the :setting:`DNS_TIMEOUT` setting, but works only with IPv4 +addresses. Scrapy provides an alternative resolver, ``scrapy.resolver.CachingHostnameResolver``, which supports IPv4/IPv6 addresses but does not take the :setting:`DNS_TIMEOUT` setting into account. +.. note:: + This setting has no effect when :setting:`TWISTED_ENABLED` is ``False``. + .. setting:: DNS_TIMEOUT DNS_TIMEOUT @@ -682,6 +697,12 @@ Default: ``60`` Timeout for processing of DNS queries in seconds. Float is supported. +.. note:: + This setting is only used by + :class:`~scrapy.resolver.CachingThreadedResolver`. It has no effect when + :setting:`TWISTED_ENABLED` is ``False``, and may have no effect either when + :setting:`DNS_RESOLVER` is set to a different resolver. + .. setting:: DOWNLOADER DOWNLOADER @@ -922,6 +943,20 @@ Default: "ftp": "scrapy.core.downloader.handlers.ftp.FTPDownloadHandler", } +(when :setting:`TWISTED_ENABLED` is ``True``) + +.. code-block:: python + + { + "data": "scrapy.core.downloader.handlers.datauri.DataURIDownloadHandler", + "file": "scrapy.core.downloader.handlers.file.FileDownloadHandler", + "http": "scrapy.core.downloader.handlers._httpx.HttpxDownloadHandler", + "https": "scrapy.core.downloader.handlers._httpx.HttpxDownloadHandler", + "s3": "scrapy.core.downloader.handlers.s3.S3DownloadHandler", + "ftp": None, + } + +(when :setting:`TWISTED_ENABLED` is ``False``) A dict containing the :ref:`download handlers ` enabled by default in Scrapy. You should never modify this setting in your @@ -1954,7 +1989,7 @@ For more info see: :ref:`topics-stats`. TELNETCONSOLE_ENABLED --------------------- -Default: ``True`` +Default: ``True`` (``False`` when :setting:`TWISTED_ENABLED` is ``False``) A boolean which specifies if the :ref:`telnet console ` will be enabled (provided its extension is also enabled). @@ -1973,6 +2008,35 @@ command. The project name must not conflict with the name of custom files or directories in the ``project`` subdirectory. +.. setting:: TWISTED_ENABLED + +TWISTED_ENABLED +--------------- + +Default: ``True`` + +Whether to install and use the Twisted reactor. + +If this is set to ``True``, Scrapy will use the Twisted reactor and will +install one according to the :setting:`TWISTED_REACTOR` setting value when +appropriate (e.g. when running via :ref:`the command-line tool +`). This is the traditional mode of using Scrapy. + +If this is set to ``False``, Scrapy will use the asyncio event loop directly +and will not attempt to install or use a reactor. Features that require a +reactor won't be available, but Twisted APIs that don't require a reactor, +including :class:`~twisted.internet.defer.Deferred` and +:class:`~twisted.python.failure.Failure`, will still be available. On the other +hand, limitations related to Twisted reactors (such as not being able to start +a reactor in the same process where a reactor was previously started and +stopped) will not apply. This mode is currently experimental and may not be +suitable for production use. It may also not be supported by 3rd-party code. +See :ref:`asyncio-without-reactor` for more information about this mode. + +.. note:: This setting can't be set :ref:`per-spider `. + +.. versionadded:: 2.15.0 + .. setting:: TWISTED_REACTOR TWISTED_REACTOR diff --git a/docs/topics/shell.rst b/docs/topics/shell.rst index 8ae8ff512..b59d45d1b 100644 --- a/docs/topics/shell.rst +++ b/docs/topics/shell.rst @@ -17,6 +17,9 @@ spider, without having to run the spider to test every change. Once you get familiarized with the Scrapy shell, you'll see that it's an invaluable tool for developing and debugging your spiders. +.. note:: + This feature is not supported when :setting:`TWISTED_ENABLED` is ``False``. + Configuring the shell ===================== diff --git a/docs/topics/telnetconsole.rst b/docs/topics/telnetconsole.rst index ae9cb634c..4f05057bf 100644 --- a/docs/topics/telnetconsole.rst +++ b/docs/topics/telnetconsole.rst @@ -26,6 +26,9 @@ disable it if you want. For more information about the extension itself see Please avoid using telnet console over insecure connections, or disable it completely using :setting:`TELNETCONSOLE_ENABLED` option. +.. note:: + This feature is not supported when :setting:`TWISTED_ENABLED` is ``False``. + .. highlight:: none How to access the telnet console diff --git a/scrapy/crawler.py b/scrapy/crawler.py index 2ae45fe40..31cb892a7 100644 --- a/scrapy/crawler.py +++ b/scrapy/crawler.py @@ -107,16 +107,26 @@ class Crawler: use_reactor = self.settings.getbool("TWISTED_ENABLED") if use_reactor: + # We either install a reactor or expect one to be installed. reactor_class: str = self.settings["TWISTED_REACTOR"] event_loop: str = self.settings["ASYNCIO_EVENT_LOOP"] if self._init_reactor: - # this needs to be done after the spider settings are merged, - # but before something imports twisted.internet.reactor + # We need to install a reactor. + # This needs to be done after the spider settings are merged, + # but before something imports twisted.internet.reactor. if reactor_class: + # Install a specific reactor. install_reactor(reactor_class, event_loop) else: + # Install the default one. from twisted.internet import reactor # noqa: F401 + elif not is_reactor_installed(): + # We need a reactor to be already installed. + raise RuntimeError( + "We expected a Twisted reactor to be installed but it isn't." + ) if reactor_class: + # We need to check that the correct reactor is installed. verify_installed_reactor(reactor_class) if is_asyncio_reactor_installed() and event_loop: verify_installed_asyncio_event_loop(event_loop) @@ -124,6 +134,11 @@ class Crawler: if self._init_reactor or reactor_class: log_reactor_info() else: + # We expect a reactor to not be installed. + if is_reactor_installed(): + raise RuntimeError( + "TWISTED_ENABLED is False but a Twisted reactor is installed." + ) logger.debug("Not using a Twisted reactor") self._apply_reactorless_default_settings() @@ -147,6 +162,7 @@ class Crawler: self.settings["DOWNLOAD_HANDLERS_BASE"][scheme] = ( "scrapy.core.downloader.handlers._httpx.HttpxDownloadHandler" ) + self.settings["DOWNLOAD_HANDLERS_BASE"]["ftp"] = None # Cannot use @deferred_f_from_coro_f because that relies on the reactor # being installed already, which is done within _apply_settings(), inside @@ -477,17 +493,24 @@ class CrawlerRunner(CrawlerRunnerBase): class AsyncCrawlerRunner(CrawlerRunnerBase): """ This is a convenient helper class that keeps track of, manages and runs - crawlers inside an already setup :mod:`~twisted.internet.reactor`. + crawlers inside an already setup :mod:`~twisted.internet.reactor` or + asyncio event loop. The AsyncCrawlerRunner object must be instantiated with a :class:`~scrapy.settings.Settings` object. + When the :setting:`TWISTED_ENABLED` setting is set to ``True``, this class + requires a reactor to be installed and uses it, otherwise it requires a + reactor to not be installed but requires an asyncio event loop to be + installed and uses it. + This class shouldn't be needed (since Scrapy is responsible of using it accordingly) unless writing scripts that manually handle the crawling process. See :ref:`run-from-script` for an example. This class provides coroutine APIs. It requires - :class:`~twisted.internet.asyncioreactor.AsyncioSelectorReactor`. + :class:`~twisted.internet.asyncioreactor.AsyncioSelectorReactor` when used + with a reactor. """ def __init__(self, settings: dict[str, Any] | Settings | None = None): @@ -528,6 +551,10 @@ class AsyncCrawlerRunner(CrawlerRunnerBase): "it must be a spider class (or a Crawler object)" ) if self.settings.getbool("TWISTED_ENABLED"): + if not is_reactor_installed(): + raise RuntimeError( + "We expected a Twisted reactor to be installed but it isn't." + ) if not is_asyncio_reactor_installed(): raise RuntimeError( f"When TWISTED_ENABLED is True, {type(self).__name__} " @@ -716,8 +743,8 @@ class CrawlerProcess(CrawlerProcessBase, CrawlerRunner): ) -> None: """ This method starts a :mod:`~twisted.internet.reactor`, adjusts its pool - size to :setting:`REACTOR_THREADPOOL_MAXSIZE`, and installs a DNS cache - based on :setting:`DNSCACHE_ENABLED` and :setting:`DNSCACHE_SIZE`. + size to :setting:`REACTOR_THREADPOOL_MAXSIZE`, and installs a DNS + resolver based on :setting:`DNSCACHE_ENABLED`. If ``stop_after_crawl`` is True, the reactor will be stopped after all crawlers have finished, using :meth:`join`. @@ -757,6 +784,10 @@ class AsyncCrawlerProcess(CrawlerProcessBase, AsyncCrawlerRunner): The AsyncCrawlerProcess object must be instantiated with a :class:`~scrapy.settings.Settings` object. + When the :setting:`TWISTED_ENABLED` setting is set to ``True``, this class + installs a reactor and uses it, otherwise it requires a reactor to not be + installed but installs an asyncio event loop and uses it. + :param install_root_handler: whether to install root logging handler (default: True) @@ -765,7 +796,8 @@ class AsyncCrawlerProcess(CrawlerProcessBase, AsyncCrawlerRunner): process. See :ref:`run-from-script` for an example. This class provides coroutine APIs. It requires - :class:`~twisted.internet.asyncioreactor.AsyncioSelectorReactor`. + :class:`~twisted.internet.asyncioreactor.AsyncioSelectorReactor` when used + with a reactor. """ def __init__( @@ -808,9 +840,12 @@ class AsyncCrawlerProcess(CrawlerProcessBase, AsyncCrawlerRunner): self, stop_after_crawl: bool = True, install_signal_handlers: bool = True ) -> None: """ - This method starts a :mod:`~twisted.internet.reactor`, adjusts its pool - size to :setting:`REACTOR_THREADPOOL_MAXSIZE`, and installs a DNS cache - based on :setting:`DNSCACHE_ENABLED` and :setting:`DNSCACHE_SIZE`. + This method starts a :mod:`~twisted.internet.reactor`/asyncio event + loop, depending on the value of the :setting:`TWISTED_ENABLED` setting. + + When using a reactor it adjusts its pool size to + :setting:`REACTOR_THREADPOOL_MAXSIZE` and installs a DNS resolver based + on :setting:`DNSCACHE_ENABLED`. If ``stop_after_crawl`` is True, the reactor will be stopped after all crawlers have finished, using :meth:`join`. diff --git a/scrapy/utils/asyncio.py b/scrapy/utils/asyncio.py index 7852a10fd..e03b7b38a 100644 --- a/scrapy/utils/asyncio.py +++ b/scrapy/utils/asyncio.py @@ -132,13 +132,16 @@ async def _parallel_asyncio( class AsyncioLoopingCall: """A simple implementation of a periodic call using asyncio, keeping - some API and behavior compatibility with the Twisted ``LoopingCall``. + some API and behavior compatibility with + :class:`~twisted.internet.task.LoopingCall`. The function is called every *interval* seconds, independent of the finish time of the previous call. If the function is still running when it's time to call it again, calls are skipped until the function finishes. The function must not return a coroutine or a ``Deferred``. + + .. versionadded:: 2.14.0 """ def __init__(self, func: Callable[_P, _T], *args: _P.args, **kwargs: _P.kwargs): @@ -216,8 +219,12 @@ def create_looping_call( ) -> AsyncioLoopingCall | LoopingCall: """Create an instance of a looping call class. - This creates an instance of :class:`AsyncioLoopingCall` or - :class:`LoopingCall`, depending on whether asyncio support is available. + This creates an instance of + :class:`~scrapy.utils.asyncio.AsyncioLoopingCall` or + :class:`~twisted.internet.task.LoopingCall`, depending on whether asyncio + support is available. + + .. versionadded:: 2.14.0 """ if is_asyncio_available(): return AsyncioLoopingCall(func, *args, **kwargs) @@ -229,8 +236,11 @@ def call_later( ) -> CallLaterResult: """Schedule a function to be called after a delay. - This uses either ``loop.call_later()`` or ``reactor.callLater()``, depending - on whether asyncio support is available. + This uses either :meth:`asyncio.loop.call_later` or + :meth:`reactor.callLater() `, + depending on whether asyncio support is available. + + .. versionadded:: 2.14.0 """ if is_asyncio_available(): loop = asyncio.get_event_loop() @@ -249,6 +259,8 @@ class CallLaterResult: no ``active()`` (as there is no such public API in :class:`asyncio.TimerHandle`) but ``cancel()`` can be called on already called or cancelled instances. + + .. versionadded:: 2.14.0 """ _timer_handle: asyncio.TimerHandle | None = None diff --git a/tests/AsyncCrawlerProcess/reactorless_custom_settings.py b/tests/AsyncCrawlerProcess/reactorless_custom_settings.py new file mode 100644 index 000000000..b0d4d47e5 --- /dev/null +++ b/tests/AsyncCrawlerProcess/reactorless_custom_settings.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +import scrapy +from scrapy.crawler import AsyncCrawlerProcess +from scrapy.utils.reactorless import is_reactorless + +if TYPE_CHECKING: + from asyncio import Task + + +class NoRequestsSpider(scrapy.Spider): + name = "no_request" + custom_settings = { + "TWISTED_ENABLED": False, + } + + async def start(self): + self.logger.info(f"is_reactorless(): {is_reactorless()}") + return + yield + + +def log_task_exception(task: Task) -> None: + try: + task.result() + except Exception: + logging.exception("Crawl task failed") # noqa: LOG015 + + +process = AsyncCrawlerProcess() +task = process.crawl(NoRequestsSpider) +task.add_done_callback(log_task_exception) +process.start() diff --git a/tests/AsyncCrawlerProcess/reactorless_import_hook.py b/tests/AsyncCrawlerProcess/reactorless_import_hook.py index 2f949cfc1..c3ef6389a 100644 --- a/tests/AsyncCrawlerProcess/reactorless_import_hook.py +++ b/tests/AsyncCrawlerProcess/reactorless_import_hook.py @@ -12,16 +12,7 @@ class NoRequestsSpider(scrapy.Spider): yield -process = AsyncCrawlerProcess( - settings={ - "TWISTED_ENABLED": False, - "DOWNLOAD_HANDLERS": { - "http": None, - "https": None, - "ftp": None, - }, - } -) +process = AsyncCrawlerProcess(settings={"TWISTED_ENABLED": False}) process.crawl(NoRequestsSpider) process.start() diff --git a/tests/AsyncCrawlerProcess/reactorless_reactor.py b/tests/AsyncCrawlerProcess/reactorless_reactor.py index a32beee22..59bbc9f0a 100644 --- a/tests/AsyncCrawlerProcess/reactorless_reactor.py +++ b/tests/AsyncCrawlerProcess/reactorless_reactor.py @@ -3,13 +3,4 @@ from scrapy.utils.reactor import install_reactor install_reactor("twisted.internet.asyncioreactor.AsyncioSelectorReactor") -AsyncCrawlerProcess( - settings={ - "TWISTED_ENABLED": False, - "DOWNLOAD_HANDLERS": { - "http": None, - "https": None, - "ftp": None, - }, - } -) +AsyncCrawlerProcess(settings={"TWISTED_ENABLED": False}) diff --git a/tests/AsyncCrawlerProcess/reactorless_simple.py b/tests/AsyncCrawlerProcess/reactorless_simple.py index dbe9c73b4..fa8cb64b0 100644 --- a/tests/AsyncCrawlerProcess/reactorless_simple.py +++ b/tests/AsyncCrawlerProcess/reactorless_simple.py @@ -12,16 +12,7 @@ class NoRequestsSpider(scrapy.Spider): yield -process = AsyncCrawlerProcess( - settings={ - "TWISTED_ENABLED": False, - "DOWNLOAD_HANDLERS": { - "http": None, - "https": None, - "ftp": None, - }, - } -) +process = AsyncCrawlerProcess(settings={"TWISTED_ENABLED": False}) process.crawl(NoRequestsSpider) process.start() diff --git a/tests/AsyncCrawlerProcess/reactorless_telnetconsole_default.py b/tests/AsyncCrawlerProcess/reactorless_telnetconsole_default.py deleted file mode 100644 index 1a4bc5148..000000000 --- a/tests/AsyncCrawlerProcess/reactorless_telnetconsole_default.py +++ /dev/null @@ -1,25 +0,0 @@ -import scrapy -from scrapy.crawler import AsyncCrawlerProcess - - -class NoRequestsSpider(scrapy.Spider): - name = "no_request" - - async def start(self): - return - yield - - -process = AsyncCrawlerProcess( - settings={ - "TWISTED_ENABLED": False, - "DOWNLOAD_HANDLERS": { - "http": None, - "https": None, - "ftp": None, - }, - } -) - -process.crawl(NoRequestsSpider) -process.start() diff --git a/tests/AsyncCrawlerProcess/reactorless_telnetconsole_disabled.py b/tests/AsyncCrawlerProcess/reactorless_telnetconsole_disabled.py index 1814071ee..8403986fb 100644 --- a/tests/AsyncCrawlerProcess/reactorless_telnetconsole_disabled.py +++ b/tests/AsyncCrawlerProcess/reactorless_telnetconsole_disabled.py @@ -13,11 +13,6 @@ class NoRequestsSpider(scrapy.Spider): process = AsyncCrawlerProcess( settings={ "TWISTED_ENABLED": False, - "DOWNLOAD_HANDLERS": { - "http": None, - "https": None, - "ftp": None, - }, "TELNETCONSOLE_ENABLED": False, } ) diff --git a/tests/AsyncCrawlerProcess/reactorless_telnetconsole_enabled.py b/tests/AsyncCrawlerProcess/reactorless_telnetconsole_enabled.py index 0026a3f45..34f0c69ba 100644 --- a/tests/AsyncCrawlerProcess/reactorless_telnetconsole_enabled.py +++ b/tests/AsyncCrawlerProcess/reactorless_telnetconsole_enabled.py @@ -13,11 +13,6 @@ class NoRequestsSpider(scrapy.Spider): process = AsyncCrawlerProcess( settings={ "TWISTED_ENABLED": False, - "DOWNLOAD_HANDLERS": { - "http": None, - "https": None, - "ftp": None, - }, "TELNETCONSOLE_ENABLED": True, } ) diff --git a/tests/AsyncCrawlerRunner/no_reactor.py b/tests/AsyncCrawlerRunner/no_reactor.py new file mode 100644 index 000000000..6a473e5e8 --- /dev/null +++ b/tests/AsyncCrawlerRunner/no_reactor.py @@ -0,0 +1,22 @@ +import asyncio + +from scrapy import Spider +from scrapy.crawler import AsyncCrawlerRunner +from scrapy.utils.log import configure_logging + + +class NoRequestsSpider(Spider): + name = "no_request" + + async def start(self): + return + yield + + +async def main() -> None: + configure_logging() + runner = AsyncCrawlerRunner() + await runner.crawl(NoRequestsSpider) + + +asyncio.run(main()) diff --git a/tests/AsyncCrawlerRunner/reactorless_custom_settings.py b/tests/AsyncCrawlerRunner/reactorless_custom_settings.py new file mode 100644 index 000000000..0545ca3dc --- /dev/null +++ b/tests/AsyncCrawlerRunner/reactorless_custom_settings.py @@ -0,0 +1,27 @@ +import asyncio + +from scrapy import Spider +from scrapy.crawler import AsyncCrawlerRunner +from scrapy.utils.log import configure_logging +from scrapy.utils.reactorless import is_reactorless + + +class NoRequestsSpider(Spider): + name = "no_request" + custom_settings = { + "TWISTED_ENABLED": False, + } + + async def start(self): + self.logger.info(f"is_reactorless(): {is_reactorless()}") + return + yield + + +async def main() -> None: + configure_logging() + runner = AsyncCrawlerRunner() + await runner.crawl(NoRequestsSpider) + + +asyncio.run(main()) diff --git a/tests/AsyncCrawlerRunner/reactorless_datauri.py b/tests/AsyncCrawlerRunner/reactorless_datauri.py index 22095b1b0..7915fbdcb 100644 --- a/tests/AsyncCrawlerRunner/reactorless_datauri.py +++ b/tests/AsyncCrawlerRunner/reactorless_datauri.py @@ -17,16 +17,7 @@ class DataSpider(Spider): async def main() -> None: configure_logging() - runner = AsyncCrawlerRunner( - settings={ - "TWISTED_ENABLED": False, - "DOWNLOAD_HANDLERS": { - "http": None, - "https": None, - "ftp": None, - }, - } - ) + runner = AsyncCrawlerRunner(settings={"TWISTED_ENABLED": False}) await runner.crawl(DataSpider) diff --git a/tests/AsyncCrawlerRunner/reactorless_reactor.py b/tests/AsyncCrawlerRunner/reactorless_reactor.py index 60830c60d..9a77b9c44 100644 --- a/tests/AsyncCrawlerRunner/reactorless_reactor.py +++ b/tests/AsyncCrawlerRunner/reactorless_reactor.py @@ -16,16 +16,7 @@ class NoRequestsSpider(Spider): async def main() -> None: configure_logging() - runner = AsyncCrawlerRunner( - settings={ - "TWISTED_ENABLED": False, - "DOWNLOAD_HANDLERS": { - "http": None, - "https": None, - "ftp": None, - }, - } - ) + runner = AsyncCrawlerRunner(settings={"TWISTED_ENABLED": False}) await runner.crawl(NoRequestsSpider) diff --git a/tests/AsyncCrawlerRunner/reactorless_simple.py b/tests/AsyncCrawlerRunner/reactorless_simple.py index 698637dd4..6f42600ad 100644 --- a/tests/AsyncCrawlerRunner/reactorless_simple.py +++ b/tests/AsyncCrawlerRunner/reactorless_simple.py @@ -17,16 +17,7 @@ class NoRequestsSpider(Spider): async def main() -> None: configure_logging() - runner = AsyncCrawlerRunner( - settings={ - "TWISTED_ENABLED": False, - "DOWNLOAD_HANDLERS": { - "http": None, - "https": None, - "ftp": None, - }, - } - ) + runner = AsyncCrawlerRunner(settings={"TWISTED_ENABLED": False}) await runner.crawl(NoRequestsSpider) diff --git a/tests/CrawlerRunner/no_reactor.py b/tests/CrawlerRunner/no_reactor.py new file mode 100644 index 000000000..1405de00f --- /dev/null +++ b/tests/CrawlerRunner/no_reactor.py @@ -0,0 +1,19 @@ +from twisted.python import log + +from scrapy import Spider +from scrapy.crawler import CrawlerRunner +from scrapy.utils.log import configure_logging + + +class NoRequestsSpider(Spider): + name = "no_request" + + async def start(self): + return + yield + + +configure_logging() +runner = CrawlerRunner() +d = runner.crawl(NoRequestsSpider) +d.addErrback(log.err) diff --git a/tests/test_crawler_subprocess.py b/tests/test_crawler_subprocess.py index b07ddbe09..c13bc0c9d 100644 --- a/tests/test_crawler_subprocess.py +++ b/tests/test_crawler_subprocess.py @@ -352,7 +352,16 @@ class TestAsyncCrawlerProcessSubprocess(TestCrawlerProcessSubprocessBase): assert "Spider closed (finished)" in log assert "is_reactorless(): True" in log assert "ERROR: " not in log - assert "WARNING: " not in log + assert log.count("WARNING: HttpxDownloadHandler is experimental") == 2 + assert log.count("WARNING: ") == 2 + + def test_reactorless_custom_settings(self): + """Setting TWISTED_ENABLED=False in spider settings is not currently supported, + AsyncCrawlerProcess will install a reactor in this case. + """ + log = self.run_script("reactorless_custom_settings.py") + assert "Spider closed (finished)" not in log + assert "TWISTED_ENABLED is False but a Twisted reactor is installed." in log def test_reactorless_datauri(self): log = self.run_script("reactorless_datauri.py") @@ -371,7 +380,7 @@ class TestAsyncCrawlerProcessSubprocess(TestCrawlerProcessSubprocessBase): def test_reactorless_telnetconsole_default(self): """By default TWISTED_ENABLED=False silently sets TELNETCONSOLE_ENABLED=False.""" - log = self.run_script("reactorless_telnetconsole_default.py") + log = self.run_script("reactorless_simple.py") # no need for a separate script assert "Not using a Twisted reactor" in log assert "Spider closed (finished)" in log assert "The TelnetConsole extension requires a Twisted reactor" not in log @@ -466,6 +475,14 @@ class TestCrawlerRunnerSubprocessBase(ScriptRunnerMixin): "setting (uvloop.Loop)" ) in log + def test_no_reactor(self): + log = self.run_script("no_reactor.py") + assert "Spider closed (finished)" not in log + assert ( + "RuntimeError: We expected a Twisted reactor to be installed but it isn't." + in log + ) + class TestCrawlerRunnerSubprocess(TestCrawlerRunnerSubprocessBase): @property @@ -521,7 +538,16 @@ class TestAsyncCrawlerRunnerSubprocess(TestCrawlerRunnerSubprocessBase): assert "Spider closed (finished)" in log assert "is_reactorless(): True" in log assert "ERROR: " not in log - assert "WARNING: " not in log + assert log.count("WARNING: HttpxDownloadHandler is experimental") == 2 + assert log.count("WARNING: ") == 2 + + def test_reactorless_custom_settings(self): + """Setting TWISTED_ENABLED=False in spider settings is not currently supported, + AsyncCrawlerRunner will expect a reactor installed by the user. + """ + log = self.run_script("reactorless_custom_settings.py") + assert "Spider closed (finished)" not in log + assert "We expected a Twisted reactor to be installed but it isn't." in log def test_reactorless_datauri(self): log = self.run_script("reactorless_datauri.py") @@ -530,7 +556,8 @@ class TestAsyncCrawlerRunnerSubprocess(TestCrawlerRunnerSubprocessBase): assert "{'data': 'foo'}" in log assert "'item_scraped_count': 1" in log assert "ERROR: " not in log - assert "WARNING: " not in log + assert log.count("WARNING: HttpxDownloadHandler is experimental") == 2 + assert log.count("WARNING: ") == 2 def test_reactorless_reactor(self): log = self.run_script("reactorless_reactor.py") From ed31dcbb100ef4d2b79f29a9a677266a79481a4e Mon Sep 17 00:00:00 2001 From: "Albert Eduardovich N." Date: Wed, 1 Apr 2026 20:11:35 +0300 Subject: [PATCH 092/248] deprecate `walk_modules` in favour of `walk_modules_iter` (#7388) * deprecate `walk_modules` in favour of `walk_modules_iter` * remove deprecation line from docstring * addressing review --- scrapy/cmdline.py | 4 ++-- scrapy/spiderloader.py | 4 ++-- scrapy/utils/misc.py | 39 ++++++++++++++++++++++++------- tests/test_utils_misc/__init__.py | 21 +++++++++++++---- 4 files changed, 52 insertions(+), 16 deletions(-) diff --git a/scrapy/cmdline.py b/scrapy/cmdline.py index 487980abd..057ddba4d 100644 --- a/scrapy/cmdline.py +++ b/scrapy/cmdline.py @@ -12,7 +12,7 @@ import scrapy from scrapy.commands import BaseRunSpiderCommand, ScrapyCommand, ScrapyHelpFormatter from scrapy.crawler import AsyncCrawlerProcess, CrawlerProcess from scrapy.exceptions import UsageError -from scrapy.utils.misc import walk_modules +from scrapy.utils.misc import walk_modules_iter from scrapy.utils.project import get_project_settings, inside_project from scrapy.utils.python import garbage_collect from scrapy.utils.reactor import _asyncio_reactor_path @@ -40,7 +40,7 @@ class ScrapyArgumentParser(argparse.ArgumentParser): def _iter_command_classes(module_name: str) -> Iterable[type[ScrapyCommand]]: # TODO: add `name` attribute to commands and merge this function with # scrapy.utils.spider.iter_spider_classes - for module in walk_modules(module_name): + for module in walk_modules_iter(module_name): for obj in vars(module).values(): if ( inspect.isclass(obj) diff --git a/scrapy/spiderloader.py b/scrapy/spiderloader.py index 8eac188c8..c37b2e759 100644 --- a/scrapy/spiderloader.py +++ b/scrapy/spiderloader.py @@ -9,7 +9,7 @@ from zope.interface import implementer from zope.interface.verify import verifyClass from scrapy.interfaces import ISpiderLoader -from scrapy.utils.misc import load_object, walk_modules +from scrapy.utils.misc import load_object, walk_modules_iter from scrapy.utils.spider import iter_spider_classes if TYPE_CHECKING: @@ -88,7 +88,7 @@ class SpiderLoader: def _load_all_spiders(self) -> None: for name in self.spider_modules: try: - for module in walk_modules(name): + for module in walk_modules_iter(name): self._load_spiders(module) except (ImportError, SyntaxError): if self.warn_only: diff --git a/scrapy/utils/misc.py b/scrapy/utils/misc.py index 7994a2471..ee988a665 100644 --- a/scrapy/utils/misc.py +++ b/scrapy/utils/misc.py @@ -77,26 +77,49 @@ def load_object(path: str | Callable[..., Any]) -> Any: return obj -def walk_modules(path: str) -> list[ModuleType]: +def walk_modules_iter(path: str) -> Iterable[ModuleType]: """Loads a module and all its submodules from the given module path and returns them. If *any* module throws an exception while importing, that exception is thrown back. - For example: walk_modules('scrapy.utils') + For example: + >>> list(walk_modules_iter('scrapy.utils')) + [, ...] + >>> gen = walk_modules_iter('scrapy.utils.nonexistent') # error not raised until the generator is consumed + >>> list(gen) + Traceback (most recent call last): + ... + ModuleNotFoundError: No module named 'scrapy.utils.nonexistent' """ - mods: list[ModuleType] = [] mod = import_module(path) - mods.append(mod) + yield mod if hasattr(mod, "__path__"): for _, subpath, ispkg in iter_modules(mod.__path__): fullpath = path + "." + subpath if ispkg: - mods += walk_modules(fullpath) + yield from walk_modules_iter(fullpath) else: - submod = import_module(fullpath) - mods.append(submod) - return mods + yield import_module(fullpath) + + +def walk_modules(path: str) -> list[ModuleType]: # pragma: no cover + """ + Loads a module and all its submodules from the given module path and + returns them. If *any* module throws an exception while importing, that + exception is thrown back. + """ + warnings.warn( + ( + "The scrapy.utils.misc.walk_modules function is deprecated and will be " + "removed in a future version of Scrapy. " + "Use scrapy.utils.misc.walk_modules_iter instead." + ), + ScrapyDeprecationWarning, + stacklevel=2, + ) + + return list(walk_modules_iter(path)) def md5sum(file: IO[bytes]) -> str: diff --git a/tests/test_utils_misc/__init__.py b/tests/test_utils_misc/__init__.py index 610fc0ffe..a995e38e6 100644 --- a/tests/test_utils_misc/__init__.py +++ b/tests/test_utils_misc/__init__.py @@ -5,6 +5,7 @@ from unittest import mock import pytest +from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.item import Field, Item from scrapy.utils.misc import ( arg_to_iter, @@ -13,6 +14,7 @@ from scrapy.utils.misc import ( rel_has_nofollow, set_environ, walk_modules, + walk_modules_iter, ) @@ -38,7 +40,7 @@ class TestUtilsMisc: load_object({}) def test_walk_modules(self): - mods = walk_modules("tests.test_utils_misc.test_walk_modules") + mods = walk_modules_iter("tests.test_utils_misc.test_walk_modules") expected = [ "tests.test_utils_misc.test_walk_modules", "tests.test_utils_misc.test_walk_modules.mod", @@ -47,27 +49,38 @@ class TestUtilsMisc: ] assert {m.__name__ for m in mods} == set(expected) - mods = walk_modules("tests.test_utils_misc.test_walk_modules.mod") + mods = walk_modules_iter("tests.test_utils_misc.test_walk_modules.mod") expected = [ "tests.test_utils_misc.test_walk_modules.mod", "tests.test_utils_misc.test_walk_modules.mod.mod0", ] assert {m.__name__ for m in mods} == set(expected) - mods = walk_modules("tests.test_utils_misc.test_walk_modules.mod1") + mods = walk_modules_iter("tests.test_utils_misc.test_walk_modules.mod1") expected = [ "tests.test_utils_misc.test_walk_modules.mod1", ] assert {m.__name__ for m in mods} == set(expected) with pytest.raises(ImportError): + for _ in walk_modules_iter("nomodule999"): + pass + with ( + pytest.raises(ImportError), + pytest.warns( + ScrapyDeprecationWarning, + match="The scrapy.utils.misc.walk_modules function is deprecated and will be " + "removed in a future version of Scrapy. " + "Use scrapy.utils.misc.walk_modules_iter instead.", + ), + ): walk_modules("nomodule999") def test_walk_modules_egg(self): egg = str(Path(__file__).parent / "test.egg") sys.path.append(egg) try: - mods = walk_modules("testegg") + mods = walk_modules_iter("testegg") expected = [ "testegg.spiders", "testegg.spiders.a", From 510f09a961473da15b3db4f67bf9200fd1a0927c Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Thu, 2 Apr 2026 12:29:47 +0500 Subject: [PATCH 093/248] Deprecate ScrapyClientContextFactory. (#7391) --- scrapy/core/downloader/contextfactory.py | 42 +++++++---------------- scrapy/core/downloader/handlers/http10.py | 6 ++-- scrapy/core/downloader/tls.py | 2 +- tests/test_core_downloader.py | 26 ++++---------- tests/test_webclient.py | 10 ++++-- 5 files changed, 30 insertions(+), 56 deletions(-) diff --git a/scrapy/core/downloader/contextfactory.py b/scrapy/core/downloader/contextfactory.py index a01f021a6..a0e0dd933 100644 --- a/scrapy/core/downloader/contextfactory.py +++ b/scrapy/core/downloader/contextfactory.py @@ -21,7 +21,7 @@ from scrapy.core.downloader.tls import ( openssl_methods, ) from scrapy.exceptions import ScrapyDeprecationWarning -from scrapy.utils.deprecate import create_deprecated_class, method_is_overridden +from scrapy.utils.deprecate import create_deprecated_class from scrapy.utils.misc import build_from_crawler, load_object if TYPE_CHECKING: @@ -35,7 +35,7 @@ if TYPE_CHECKING: @implementer(IPolicyForHTTPS) -class ScrapyClientContextFactory(BrowserLikePolicyForHTTPS): +class _ScrapyClientContextFactory(BrowserLikePolicyForHTTPS): """Non-peer-certificate verifying HTTPS context factory. Default OpenSSL method is ``TLS_METHOD`` (also called ``SSLv23_METHOD``) @@ -70,22 +70,6 @@ class ScrapyClientContextFactory(BrowserLikePolicyForHTTPS): ) self._ctx = self._get_context() self._verify_certificates = verify_certificates - if method_is_overridden(type(self), ScrapyClientContextFactory, "getContext"): - warnings.warn( - "Overriding ScrapyClientContextFactory.getContext() is deprecated and that method" - " will be removed in a future Scrapy version. Override creatorForNetloc() instead.", - category=ScrapyDeprecationWarning, - stacklevel=2, - ) - if method_is_overridden( - type(self), ScrapyClientContextFactory, "getCertificateOptions" - ): # pragma: no cover - warnings.warn( - "Overriding ScrapyClientContextFactory.getCertificateOptions() is deprecated and that method" - " will be removed in a future Scrapy version. Override creatorForNetloc() instead.", - category=ScrapyDeprecationWarning, - stacklevel=2, - ) @classmethod def from_crawler( @@ -110,21 +94,11 @@ class ScrapyClientContextFactory(BrowserLikePolicyForHTTPS): ) def getCertificateOptions(self) -> CertificateOptions: # pragma: no cover - warnings.warn( - "ScrapyClientContextFactory.getCertificateOptions() is deprecated.", - ScrapyDeprecationWarning, - stacklevel=2, - ) return self._certificate_options # kept for old-style HTTP/1.0 downloader context twisted calls, # e.g. connectSSL() def getContext(self, hostname: Any = None, port: Any = None) -> SSL.Context: - warnings.warn( - "ScrapyClientContextFactory.getContext() is deprecated.", - ScrapyDeprecationWarning, - stacklevel=2, - ) return self._ctx def _get_context(self) -> SSL.Context: @@ -145,8 +119,16 @@ class ScrapyClientContextFactory(BrowserLikePolicyForHTTPS): ) +ScrapyClientContextFactory = create_deprecated_class( + "ScrapyClientContextFactory", + _ScrapyClientContextFactory, + subclass_warn_message="{old} is deprecated.", + instance_warn_message="{cls} is deprecated.", +) + + @implementer(IPolicyForHTTPS) -class BrowserLikeContextFactory(ScrapyClientContextFactory): +class BrowserLikeContextFactory(_ScrapyClientContextFactory): """ Twisted-recommended context factory for web clients. @@ -223,7 +205,7 @@ def _load_context_factory_from_settings(crawler: Crawler) -> IPolicyForHTTPS: Also passes values of other relevant settings to the factory class. """ if crawler.settings["DOWNLOADER_CLIENTCONTEXTFACTORY"] == "SENTINEL": - context_factory_cls = ScrapyClientContextFactory + context_factory_cls = _ScrapyClientContextFactory else: # pragma: no cover warnings.warn( "The 'DOWNLOADER_CLIENTCONTEXTFACTORY' setting is deprecated.", diff --git a/scrapy/core/downloader/handlers/http10.py b/scrapy/core/downloader/handlers/http10.py index 0ce7eff3b..e720132f0 100644 --- a/scrapy/core/downloader/handlers/http10.py +++ b/scrapy/core/downloader/handlers/http10.py @@ -5,7 +5,7 @@ from __future__ import annotations import warnings from typing import TYPE_CHECKING -from scrapy.core.downloader.contextfactory import ScrapyClientContextFactory +from scrapy.core.downloader.contextfactory import _ScrapyClientContextFactory from scrapy.exceptions import NotConfigured, ScrapyDeprecationWarning from scrapy.utils.defer import maybe_deferred_to_future from scrapy.utils.misc import build_from_crawler, load_object @@ -39,8 +39,8 @@ class HTTP10DownloadHandler: settings["DOWNLOADER_HTTPCLIENTFACTORY"] ) if settings["DOWNLOADER_CLIENTCONTEXTFACTORY"] == "SENTINEL": - self.ClientContextFactory: type[ScrapyClientContextFactory] = ( - ScrapyClientContextFactory + self.ClientContextFactory: type[_ScrapyClientContextFactory] = ( + _ScrapyClientContextFactory ) else: # pragma: no cover warnings.warn( diff --git a/scrapy/core/downloader/tls.py b/scrapy/core/downloader/tls.py index 390309900..9ec5b8c48 100644 --- a/scrapy/core/downloader/tls.py +++ b/scrapy/core/downloader/tls.py @@ -38,7 +38,7 @@ class _ScrapyClientTLSOptions(ClientTLSOptions): logging warnings. Instances of this class are returned from - :class:`.ScrapyClientContextFactory`. + :class:`._ScrapyClientContextFactory`. """ def _identityVerifyingInfoCallback( diff --git a/tests/test_core_downloader.py b/tests/test_core_downloader.py index b24d6c843..14b1771f5 100644 --- a/tests/test_core_downloader.py +++ b/tests/test_core_downloader.py @@ -1,7 +1,7 @@ from __future__ import annotations import warnings -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, cast import OpenSSL.SSL import pytest @@ -12,8 +12,8 @@ from twisted.web.client import Response as TxResponse from scrapy.core.downloader import Downloader, Slot from scrapy.core.downloader.contextfactory import ( - ScrapyClientContextFactory, _load_context_factory_from_settings, + _ScrapyClientContextFactory, ) from scrapy.core.downloader.handlers.http11 import _RequestBodyProducer from scrapy.exceptions import ScrapyDeprecationWarning @@ -107,24 +107,10 @@ class TestContextFactory(TestContextFactoryBase): ) assert body == to_bytes(s) - def test_override_getContext(self): - class MyFactory(ScrapyClientContextFactory): - def getContext( - self, hostname: Any = None, port: Any = None - ) -> OpenSSL.SSL.Context: - ctx: OpenSSL.SSL.Context = super().getContext(hostname, port) - return ctx - - with pytest.warns( - ScrapyDeprecationWarning, - match=r"ScrapyClientContextFactory\.getContext\(\) is deprecated", - ): - MyFactory() - class TestContextFactoryTLSMethod(TestContextFactoryBase): async def _assert_factory_works( - self, server_url: str, client_context_factory: ScrapyClientContextFactory + self, server_url: str, client_context_factory: _ScrapyClientContextFactory ) -> None: s = "0123456789" * 10 body = await self.get_page( @@ -160,13 +146,15 @@ class TestContextFactoryTLSMethod(TestContextFactoryBase): async def test_direct_from_crawler(self, server_url: str) -> None: # the setting is ignored crawler = get_crawler(settings_dict={"DOWNLOADER_CLIENT_TLS_METHOD": "bad"}) - client_context_factory = build_from_crawler(ScrapyClientContextFactory, crawler) + client_context_factory = build_from_crawler( + _ScrapyClientContextFactory, crawler + ) assert client_context_factory._ssl_method == OpenSSL.SSL.SSLv23_METHOD await self._assert_factory_works(server_url, client_context_factory) @coroutine_test async def test_direct_init(self, server_url: str) -> None: - client_context_factory = ScrapyClientContextFactory(OpenSSL.SSL.TLSv1_2_METHOD) + client_context_factory = _ScrapyClientContextFactory(OpenSSL.SSL.TLSv1_2_METHOD) assert client_context_factory._ssl_method == OpenSSL.SSL.TLSv1_2_METHOD await self._assert_factory_works(server_url, client_context_factory) diff --git a/tests/test_webclient.py b/tests/test_webclient.py index b14d15be4..ac05d457d 100644 --- a/tests/test_webclient.py +++ b/tests/test_webclient.py @@ -16,7 +16,7 @@ from twisted.web import resource, server, static, util from twisted.web.client import _makeGetterFactory from scrapy.core.downloader import webclient as client -from scrapy.core.downloader.contextfactory import ScrapyClientContextFactory +from scrapy.core.downloader.contextfactory import _ScrapyClientContextFactory from scrapy.exceptions import DownloadTimeoutError from scrapy.http import Headers, Request from scrapy.utils.misc import build_from_crawler @@ -379,7 +379,9 @@ class TestWebClientCustomCiphersSSL(TestWebClientSSL): crawler = get_crawler( settings_dict={"DOWNLOADER_CLIENT_TLS_CIPHERS": self.custom_ciphers} ) - client_context_factory = build_from_crawler(ScrapyClientContextFactory, crawler) + client_context_factory = build_from_crawler( + _ScrapyClientContextFactory, crawler + ) body = yield getPage( server_url + "payload", body=s, contextFactory=client_context_factory ) @@ -393,7 +395,9 @@ class TestWebClientCustomCiphersSSL(TestWebClientSSL): "DOWNLOADER_CLIENT_TLS_CIPHERS": "ECDHE-RSA-AES256-GCM-SHA384" } ) - client_context_factory = build_from_crawler(ScrapyClientContextFactory, crawler) + client_context_factory = build_from_crawler( + _ScrapyClientContextFactory, crawler + ) with pytest.raises(OpenSSL.SSL.Error): yield getPage( server_url + "payload", body=s, contextFactory=client_context_factory From b2b2d0b015948f8ca89fae9984ad67c4e7b33ea8 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Thu, 2 Apr 2026 13:20:54 +0500 Subject: [PATCH 094/248] Rename TWISTED_ENABLED to TWISTED_REACTOR_ENABLED. (#7394) --- docs/topics/asyncio.rst | 41 ++++++++++--------- docs/topics/download-handlers.rst | 6 +-- docs/topics/practices.rst | 14 +++---- docs/topics/settings.rst | 28 ++++++------- docs/topics/shell.rst | 2 +- docs/topics/telnetconsole.rst | 2 +- scrapy/commands/shell.py | 4 +- scrapy/core/downloader/handlers/_httpx.py | 4 +- scrapy/core/downloader/handlers/ftp.py | 2 +- scrapy/core/downloader/handlers/http10.py | 2 +- scrapy/core/downloader/handlers/http11.py | 2 +- scrapy/core/downloader/handlers/http2.py | 2 +- scrapy/crawler.py | 39 +++++++++--------- scrapy/extensions/telnet.py | 2 +- scrapy/settings/default_settings.py | 4 +- scrapy/shell.py | 4 +- scrapy/utils/reactorless.py | 2 +- scrapy/utils/test.py | 2 +- .../reactorless_custom_settings.py | 2 +- .../reactorless_datauri.py | 11 +---- .../reactorless_import_hook.py | 2 +- .../reactorless_reactor.py | 2 +- .../AsyncCrawlerProcess/reactorless_simple.py | 2 +- .../reactorless_sleeping.py | 2 +- .../reactorless_telnetconsole_disabled.py | 2 +- .../reactorless_telnetconsole_enabled.py | 2 +- .../reactorless_custom_settings.py | 2 +- .../AsyncCrawlerRunner/reactorless_datauri.py | 2 +- .../AsyncCrawlerRunner/reactorless_reactor.py | 2 +- .../AsyncCrawlerRunner/reactorless_simple.py | 2 +- tests/CrawlerProcess/reactorless.py | 11 +---- tests/CrawlerRunner/reactorless.py | 11 +---- tests/test_addons.py | 4 +- tests/test_command_check.py | 2 +- tests/test_command_crawl.py | 4 +- tests/test_command_fetch.py | 2 +- tests/test_command_parse.py | 2 +- tests/test_command_runspider.py | 2 +- tests/test_command_shell.py | 10 +++-- tests/test_commands.py | 2 +- tests/test_crawl.py | 2 +- tests/test_crawler.py | 4 +- tests/test_crawler_subprocess.py | 32 +++++++++------ tests/test_downloader_handler_twisted_ftp.py | 2 +- .../test_downloader_handler_twisted_http11.py | 2 +- .../test_downloader_handler_twisted_http2.py | 2 +- 46 files changed, 139 insertions(+), 150 deletions(-) diff --git a/docs/topics/asyncio.rst b/docs/topics/asyncio.rst index c6d26ea94..63bde8279 100644 --- a/docs/topics/asyncio.rst +++ b/docs/topics/asyncio.rst @@ -140,8 +140,8 @@ Using Scrapy without a Twisted reactor This is currently experimental and may not be suitable for production use. It's possible to use Scrapy without installing a Twisted reactor at all, by -setting the :setting:`TWISTED_ENABLED` setting to ``False``. In this mode -Scrapy will use the asyncio event loop directly, and most of the Scrapy +setting the :setting:`TWISTED_REACTOR_ENABLED` setting to ``False``. In this +mode Scrapy will use the asyncio event loop directly, and most of the Scrapy functionality will work in the same way. Doing this provides several benefits in certain use cases: @@ -149,7 +149,8 @@ Doing this provides several benefits in certain use cases: * A Twisted reactor, once stopped, cannot be started again. This prevents, for example, using several instances of :class:`~scrapy.crawler.AsyncCrawlerProcess` in the same process when they - use a reactor, but with ``TWISTED_ENABLED=False`` it becomes possible. + use a reactor, but with ``TWISTED_REACTOR_ENABLED=False`` it becomes + possible. * There may be limitations imposed by :class:`~twisted.internet.asyncioreactor.AsyncioSelectorReactor` and related Twisted code, such as the requirement of using @@ -195,8 +196,8 @@ necessarily stop working. Other differences ----------------- -When :setting:`TWISTED_ENABLED` is set to ``False``, Scrapy will change the -defaults of some other settings: +When :setting:`TWISTED_REACTOR_ENABLED` is set to ``False``, Scrapy will change +the defaults of some other settings: * :setting:`TELNETCONSOLE_ENABLED` is set to ``False``. * The ``"http"`` and ``"https"`` keys in :setting:`DOWNLOAD_HANDLERS_BASE` are @@ -255,8 +256,8 @@ Scrapy provides unified helpers for some of these examples: .. autofunction:: scrapy.utils.asyncio.run_in_thread If your code needs to know whether the reactor is available, you can either -check for the value of the :setting:`TWISTED_ENABLED` setting (you need access -to the :class:`~scrapy.crawler.Crawler` instance to do this) or use the +check for the value of the :setting:`TWISTED_REACTOR_ENABLED` setting (you need +access to the :class:`~scrapy.crawler.Crawler` instance to do this) or use the following function: .. autofunction:: scrapy.utils.reactorless.is_reactorless @@ -273,26 +274,26 @@ Troubleshooting without a Twisted reactor [...]:** Scrapy is configured to run without a reactor, but some code imported :mod:`twisted.internet.reactor`, most likely because that code needs a reactor to be used. You need to stop using this code -or set :setting:`TWISTED_ENABLED` back to ``True``. It's also possible that the -reactor isn't really needed but was installed due to the problem described in -:ref:`asyncio-preinstalled-reactor`, in which case it should be enough to fix -the problematic imports. +or set :setting:`TWISTED_REACTOR_ENABLED` back to ``True``. It's also possible +that the reactor isn't really needed but was installed due to the problem +described in :ref:`asyncio-preinstalled-reactor`, in which case it should be +enough to fix the problematic imports. -**RuntimeError: TWISTED_ENABLED is False but a Twisted reactor is installed:** -Scrapy is configured to run without a reactor, but a reactor is already -installed before the Scrapy code is executed. If you are trying to set -:setting:`TWISTED_ENABLED` via :ref:`per-spider settings `, -it's currently unsupported. +**RuntimeError: TWISTED_REACTOR_ENABLED is False but a Twisted reactor is +installed:** Scrapy is configured to run without a reactor, but a reactor is +already installed before the Scrapy code is executed. If you are trying to set +:setting:`TWISTED_REACTOR_ENABLED` via :ref:`per-spider settings +`, it's currently unsupported. **RuntimeError: We expected a Twisted reactor to be installed but it isn't:** Scrapy is configured to run with a reactor and not to install one, but a reactor wasn't installed before the Scrapy code is executed. If you are trying -to set :setting:`TWISTED_ENABLED` via :ref:`per-spider settings +to set :setting:`TWISTED_REACTOR_ENABLED` via :ref:`per-spider settings `, it's currently unsupported. -**RuntimeError: doesn't support TWISTED_ENABLED=False:** The listed -class cannot be used with :setting:`TWISTED_ENABLED` set to ``False``. There -may be a replacement in the :ref:`documentation above +**RuntimeError: doesn't support TWISTED_REACTOR_ENABLED=False:** The +listed class cannot be used with :setting:`TWISTED_REACTOR_ENABLED` set to +``False``. There may be a replacement in the :ref:`documentation above ` or the documentation of the affected class. diff --git a/docs/topics/download-handlers.rst b/docs/topics/download-handlers.rst index 29868bbaa..182ea613a 100644 --- a/docs/topics/download-handlers.rst +++ b/docs/topics/download-handlers.rst @@ -141,7 +141,7 @@ This handler supports ``ftp://host/path`` FTP URIs. It's implemented using :mod:`twisted.protocols.ftp`. .. note:: - This handler is not supported when :setting:`TWISTED_ENABLED` is ``False``. + This handler is not supported when :setting:`TWISTED_REACTOR_ENABLED` is ``False``. .. _twisted-http2-handler: @@ -197,7 +197,7 @@ If you want to use this handler you need to replace the default one for the .. _server pushes: https://datatracker.ietf.org/doc/html/rfc7540#section-8.2 .. note:: - This handler is not supported when :setting:`TWISTED_ENABLED` is ``False``. + This handler is not supported when :setting:`TWISTED_REACTOR_ENABLED` is ``False``. HTTP11DownloadHandler --------------------- @@ -213,7 +213,7 @@ uses the HTTP/1.1 protocol for them. It's implemented using :mod:`twisted.web.client`. .. note:: - This handler is not supported when :setting:`TWISTED_ENABLED` is ``False``. + This handler is not supported when :setting:`TWISTED_REACTOR_ENABLED` is ``False``. HttpxDownloadHandler -------------------- diff --git a/docs/topics/practices.rst b/docs/topics/practices.rst index 8176063d8..b0a2cd135 100644 --- a/docs/topics/practices.rst +++ b/docs/topics/practices.rst @@ -166,8 +166,8 @@ with :class:`~twisted.internet.asyncioreactor.AsyncioSelectorReactor`): .. seealso:: :doc:`twisted:core/howto/reactor-basics` -And here are examples of using these classes with :setting:`TWISTED_ENABLED` -set to ``False``. +And here are examples of using these classes with +:setting:`TWISTED_REACTOR_ENABLED` set to ``False``. Simple usage of :class:`~scrapy.crawler.AsyncCrawlerProcess`: @@ -184,14 +184,14 @@ Simple usage of :class:`~scrapy.crawler.AsyncCrawlerProcess`: process = AsyncCrawlerProcess( settings={ - "TWISTED_ENABLED": False, + "TWISTED_REACTOR_ENABLED": False, } ) process.crawl(MySpider) process.start() # the script will block here until the crawling is finished -With ``TWISTED_ENABLED=False`` you can use several instances of +With ``TWISTED_REACTOR_ENABLED=False`` you can use several instances of :class:`~scrapy.crawler.AsyncCrawlerProcess` in the same process: .. code-block:: python @@ -207,7 +207,7 @@ With ``TWISTED_ENABLED=False`` you can use several instances of process1 = AsyncCrawlerProcess( settings={ - "TWISTED_ENABLED": False, + "TWISTED_REACTOR_ENABLED": False, } ) process1.crawl(MySpider) @@ -215,7 +215,7 @@ With ``TWISTED_ENABLED=False`` you can use several instances of process2 = AsyncCrawlerProcess( settings={ - "TWISTED_ENABLED": False, + "TWISTED_REACTOR_ENABLED": False, } ) process2.crawl(MySpider) @@ -239,7 +239,7 @@ Using :func:`asyncio.run` with :class:`~scrapy.crawler.AsyncCrawlerRunner`: async def main(): configure_logging({"LOG_FORMAT": "%(levelname)s: %(message)s"}) - runner = AsyncCrawlerRunner(settings={"TWISTED_ENABLED": False}) + runner = AsyncCrawlerRunner(settings={"TWISTED_REACTOR_ENABLED": False}) await runner.crawl(MySpider) # completes when the spider finishes diff --git a/docs/topics/settings.rst b/docs/topics/settings.rst index 964ea99c0..f0456b4d5 100644 --- a/docs/topics/settings.rst +++ b/docs/topics/settings.rst @@ -305,7 +305,7 @@ These settings cannot be :ref:`set from a spider `. These settings are: -- :setting:`TWISTED_ENABLED` +- :setting:`TWISTED_REACTOR_ENABLED` - :setting:`SPIDER_LOADER_CLASS` and settings used by the corresponding spider loader class, e.g. :setting:`SPIDER_MODULES` and :setting:`SPIDER_LOADER_WARN_ONLY` for the default spider loader class. @@ -358,8 +358,8 @@ e.g. in :ref:`per-spider settings `, an exception will be raised. All of these settings, except for :setting:`ASYNCIO_EVENT_LOOP`, are only used -when the Twisted reactor is used, i.e. when :setting:`TWISTED_ENABLED` is -``True``. +when the Twisted reactor is used, i.e. when :setting:`TWISTED_REACTOR_ENABLED` +is ``True``. .. _topics-settings-ref: @@ -659,8 +659,8 @@ Whether to enable DNS in-memory cache. This setting is only used by :class:`~scrapy.resolver.CachingThreadedResolver` and :class:`~scrapy.resolver.CachingHostnameResolver`. It has no effect when - :setting:`TWISTED_ENABLED` is ``False``, and may have no effect either when - :setting:`DNS_RESOLVER` is set to a different resolver. + :setting:`TWISTED_REACTOR_ENABLED` is ``False``, and may have no effect + either when :setting:`DNS_RESOLVER` is set to a different resolver. .. setting:: DNSCACHE_SIZE @@ -686,7 +686,7 @@ addresses. Scrapy provides an alternative resolver, take the :setting:`DNS_TIMEOUT` setting into account. .. note:: - This setting has no effect when :setting:`TWISTED_ENABLED` is ``False``. + This setting has no effect when :setting:`TWISTED_REACTOR_ENABLED` is ``False``. .. setting:: DNS_TIMEOUT @@ -700,8 +700,8 @@ Timeout for processing of DNS queries in seconds. Float is supported. .. note:: This setting is only used by :class:`~scrapy.resolver.CachingThreadedResolver`. It has no effect when - :setting:`TWISTED_ENABLED` is ``False``, and may have no effect either when - :setting:`DNS_RESOLVER` is set to a different resolver. + :setting:`TWISTED_REACTOR_ENABLED` is ``False``, and may have no effect + either when :setting:`DNS_RESOLVER` is set to a different resolver. .. setting:: DOWNLOADER @@ -943,7 +943,7 @@ Default: "ftp": "scrapy.core.downloader.handlers.ftp.FTPDownloadHandler", } -(when :setting:`TWISTED_ENABLED` is ``True``) +(when :setting:`TWISTED_REACTOR_ENABLED` is ``True``) .. code-block:: python @@ -956,7 +956,7 @@ Default: "ftp": None, } -(when :setting:`TWISTED_ENABLED` is ``False``) +(when :setting:`TWISTED_REACTOR_ENABLED` is ``False``) A dict containing the :ref:`download handlers ` enabled by default in Scrapy. You should never modify this setting in your @@ -1989,7 +1989,7 @@ For more info see: :ref:`topics-stats`. TELNETCONSOLE_ENABLED --------------------- -Default: ``True`` (``False`` when :setting:`TWISTED_ENABLED` is ``False``) +Default: ``True`` (``False`` when :setting:`TWISTED_REACTOR_ENABLED` is ``False``) A boolean which specifies if the :ref:`telnet console ` will be enabled (provided its extension is also enabled). @@ -2008,10 +2008,10 @@ command. The project name must not conflict with the name of custom files or directories in the ``project`` subdirectory. -.. setting:: TWISTED_ENABLED +.. setting:: TWISTED_REACTOR_ENABLED -TWISTED_ENABLED ---------------- +TWISTED_REACTOR_ENABLED +----------------------- Default: ``True`` diff --git a/docs/topics/shell.rst b/docs/topics/shell.rst index b59d45d1b..ca5457b2a 100644 --- a/docs/topics/shell.rst +++ b/docs/topics/shell.rst @@ -18,7 +18,7 @@ Once you get familiarized with the Scrapy shell, you'll see that it's an invaluable tool for developing and debugging your spiders. .. note:: - This feature is not supported when :setting:`TWISTED_ENABLED` is ``False``. + This feature is not supported when :setting:`TWISTED_REACTOR_ENABLED` is ``False``. Configuring the shell ===================== diff --git a/docs/topics/telnetconsole.rst b/docs/topics/telnetconsole.rst index 4f05057bf..e74afe49b 100644 --- a/docs/topics/telnetconsole.rst +++ b/docs/topics/telnetconsole.rst @@ -27,7 +27,7 @@ disable it if you want. For more information about the extension itself see or disable it completely using :setting:`TELNETCONSOLE_ENABLED` option. .. note:: - This feature is not supported when :setting:`TWISTED_ENABLED` is ``False``. + This feature is not supported when :setting:`TWISTED_REACTOR_ENABLED` is ``False``. .. highlight:: none diff --git a/scrapy/commands/shell.py b/scrapy/commands/shell.py index 9f4faba3a..772b2911c 100644 --- a/scrapy/commands/shell.py +++ b/scrapy/commands/shell.py @@ -83,9 +83,9 @@ class Command(ScrapyCommand): # crawling engine, so the set up in the crawl method won't work crawler = self.crawler_process._create_crawler(spidercls) crawler._apply_settings() - if not crawler.settings.getbool("TWISTED_ENABLED"): + if not crawler.settings.getbool("TWISTED_REACTOR_ENABLED"): raise RuntimeError( - "scrapy shell currently doesn't support TWISTED_ENABLED=False" + "scrapy shell currently doesn't support TWISTED_REACTOR_ENABLED=False" ) # The Shell class needs a persistent engine in the crawler crawler.engine = crawler._create_engine() diff --git a/scrapy/core/downloader/handlers/_httpx.py b/scrapy/core/downloader/handlers/_httpx.py index 1787ec918..9bbcea7bb 100644 --- a/scrapy/core/downloader/handlers/_httpx.py +++ b/scrapy/core/downloader/handlers/_httpx.py @@ -81,8 +81,8 @@ class HttpxDownloadHandler(BaseHttpDownloadHandler): f"{type(self).__name__} requires the asyncio support. Make" f" sure that you have either enabled the asyncio Twisted" f" reactor in the TWISTED_REACTOR setting or disabled the" - f" TWISTED_ENABLED setting. See the asyncio documentation" - f" of Scrapy for more information." + f" TWISTED_REACTOR_ENABLED setting. See the asyncio" + f" documentation of Scrapy for more information." ) if httpx is None: # pragma: no cover raise NotConfigured( diff --git a/scrapy/core/downloader/handlers/ftp.py b/scrapy/core/downloader/handlers/ftp.py index 65660d389..8327a3b0e 100644 --- a/scrapy/core/downloader/handlers/ftp.py +++ b/scrapy/core/downloader/handlers/ftp.py @@ -85,7 +85,7 @@ class FTPDownloadHandler(BaseDownloadHandler): } def __init__(self, crawler: Crawler): - if not crawler.settings.getbool("TWISTED_ENABLED"): + if not crawler.settings.getbool("TWISTED_REACTOR_ENABLED"): raise NotConfigured(f"{type(self).__name__} requires a Twisted reactor.") super().__init__(crawler) self.default_user = crawler.settings["FTP_USER"] diff --git a/scrapy/core/downloader/handlers/http10.py b/scrapy/core/downloader/handlers/http10.py index e720132f0..f5d1bbd76 100644 --- a/scrapy/core/downloader/handlers/http10.py +++ b/scrapy/core/downloader/handlers/http10.py @@ -33,7 +33,7 @@ class HTTP10DownloadHandler: category=ScrapyDeprecationWarning, stacklevel=2, ) - if not crawler.settings.getbool("TWISTED_ENABLED"): # pragma: no cover + if not crawler.settings.getbool("TWISTED_REACTOR_ENABLED"): # pragma: no cover raise NotConfigured(f"{type(self).__name__} requires a Twisted reactor.") self.HTTPClientFactory: type[ScrapyHTTPClientFactory] = load_object( settings["DOWNLOADER_HTTPCLIENTFACTORY"] diff --git a/scrapy/core/downloader/handlers/http11.py b/scrapy/core/downloader/handlers/http11.py index 6c9b699f6..9dd403aca 100644 --- a/scrapy/core/downloader/handlers/http11.py +++ b/scrapy/core/downloader/handlers/http11.py @@ -82,7 +82,7 @@ class _ResultT(TypedDict): class HTTP11DownloadHandler(BaseHttpDownloadHandler): def __init__(self, crawler: Crawler): - if not crawler.settings.getbool("TWISTED_ENABLED"): + if not crawler.settings.getbool("TWISTED_REACTOR_ENABLED"): raise NotConfigured(f"{type(self).__name__} requires a Twisted reactor.") super().__init__(crawler) self._crawler = crawler diff --git a/scrapy/core/downloader/handlers/http2.py b/scrapy/core/downloader/handlers/http2.py index 52ca89b0f..f2774cc07 100644 --- a/scrapy/core/downloader/handlers/http2.py +++ b/scrapy/core/downloader/handlers/http2.py @@ -32,7 +32,7 @@ class H2DownloadHandler(BaseDownloadHandler): lazy = True def __init__(self, crawler: Crawler): - if not crawler.settings.getbool("TWISTED_ENABLED"): + if not crawler.settings.getbool("TWISTED_REACTOR_ENABLED"): raise NotConfigured(f"{type(self).__name__} requires a Twisted reactor.") super().__init__(crawler) self._crawler = crawler diff --git a/scrapy/crawler.py b/scrapy/crawler.py index 31cb892a7..d622e407c 100644 --- a/scrapy/crawler.py +++ b/scrapy/crawler.py @@ -105,7 +105,7 @@ class Crawler: self, ) - use_reactor = self.settings.getbool("TWISTED_ENABLED") + use_reactor = self.settings.getbool("TWISTED_REACTOR_ENABLED") if use_reactor: # We either install a reactor or expect one to be installed. reactor_class: str = self.settings["TWISTED_REACTOR"] @@ -137,7 +137,7 @@ class Crawler: # We expect a reactor to not be installed. if is_reactor_installed(): raise RuntimeError( - "TWISTED_ENABLED is False but a Twisted reactor is installed." + "TWISTED_REACTOR_ENABLED is False but a Twisted reactor is installed." ) logger.debug("Not using a Twisted reactor") self._apply_reactorless_default_settings() @@ -411,9 +411,9 @@ class CrawlerRunner(CrawlerRunnerBase): def __init__(self, settings: dict[str, Any] | Settings | None = None): super().__init__(settings) - if not self.settings.getbool("TWISTED_ENABLED"): + if not self.settings.getbool("TWISTED_REACTOR_ENABLED"): raise RuntimeError( - f"{type(self).__name__} doesn't support TWISTED_ENABLED=False." + f"{type(self).__name__} doesn't support TWISTED_REACTOR_ENABLED=False." ) self._active: set[Deferred[None]] = set() @@ -499,10 +499,10 @@ class AsyncCrawlerRunner(CrawlerRunnerBase): The AsyncCrawlerRunner object must be instantiated with a :class:`~scrapy.settings.Settings` object. - When the :setting:`TWISTED_ENABLED` setting is set to ``True``, this class - requires a reactor to be installed and uses it, otherwise it requires a - reactor to not be installed but requires an asyncio event loop to be - installed and uses it. + When the :setting:`TWISTED_REACTOR_ENABLED` setting is set to ``True``, + this class requires a reactor to be installed and uses it, otherwise it + requires a reactor to not be installed but requires an asyncio event loop + to be installed and uses it. This class shouldn't be needed (since Scrapy is responsible of using it accordingly) unless writing scripts that manually handle the crawling @@ -550,20 +550,20 @@ class AsyncCrawlerRunner(CrawlerRunnerBase): "The crawler_or_spidercls argument cannot be a spider object, " "it must be a spider class (or a Crawler object)" ) - if self.settings.getbool("TWISTED_ENABLED"): + if self.settings.getbool("TWISTED_REACTOR_ENABLED"): if not is_reactor_installed(): raise RuntimeError( "We expected a Twisted reactor to be installed but it isn't." ) if not is_asyncio_reactor_installed(): raise RuntimeError( - f"When TWISTED_ENABLED is True, {type(self).__name__} " + f"When TWISTED_REACTOR_ENABLED is True, {type(self).__name__} " f"requires that the installed Twisted reactor is " f'"twisted.internet.asyncioreactor.AsyncioSelectorReactor".' ) elif is_reactor_installed(): raise RuntimeError( - "TWISTED_ENABLED is False but a Twisted reactor is installed." + "TWISTED_REACTOR_ENABLED is False but a Twisted reactor is installed." ) crawler = self.create_crawler(crawler_or_spidercls) return self._crawl(crawler, *args, **kwargs) @@ -784,9 +784,9 @@ class AsyncCrawlerProcess(CrawlerProcessBase, AsyncCrawlerRunner): The AsyncCrawlerProcess object must be instantiated with a :class:`~scrapy.settings.Settings` object. - When the :setting:`TWISTED_ENABLED` setting is set to ``True``, this class - installs a reactor and uses it, otherwise it requires a reactor to not be - installed but installs an asyncio event loop and uses it. + When the :setting:`TWISTED_REACTOR_ENABLED` setting is set to ``True``, + this class installs a reactor and uses it, otherwise it requires a reactor + to not be installed but installs an asyncio event loop and uses it. :param install_root_handler: whether to install root logging handler (default: True) @@ -814,10 +814,10 @@ class AsyncCrawlerProcess(CrawlerProcessBase, AsyncCrawlerRunner): # The ASYNCIO_EVENT_LOOP setting cannot be overridden by add-ons and # spiders when using AsyncCrawlerProcess. loop_path = self.settings["ASYNCIO_EVENT_LOOP"] - if not self.settings.getbool("TWISTED_ENABLED"): + if not self.settings.getbool("TWISTED_REACTOR_ENABLED"): if is_reactor_installed(): raise RuntimeError( - "TWISTED_ENABLED is False but a Twisted reactor is installed." + "TWISTED_REACTOR_ENABLED is False but a Twisted reactor is installed." ) self._reactorless_loop = set_asyncio_event_loop(loop_path) install_reactor_import_hook() @@ -840,8 +840,9 @@ class AsyncCrawlerProcess(CrawlerProcessBase, AsyncCrawlerRunner): self, stop_after_crawl: bool = True, install_signal_handlers: bool = True ) -> None: """ - This method starts a :mod:`~twisted.internet.reactor`/asyncio event - loop, depending on the value of the :setting:`TWISTED_ENABLED` setting. + This method starts a :mod:`~twisted.internet.reactor` or an asyncio + event loop, depending on the value of the + :setting:`TWISTED_REACTOR_ENABLED` setting. When using a reactor it adjusts its pool size to :setting:`REACTOR_THREADPOOL_MAXSIZE` and installs a DNS resolver based @@ -857,7 +858,7 @@ class AsyncCrawlerProcess(CrawlerProcessBase, AsyncCrawlerRunner): handlers from Twisted and Scrapy (default: True) """ - if not self.settings.getbool("TWISTED_ENABLED"): + if not self.settings.getbool("TWISTED_REACTOR_ENABLED"): self._start_asyncio(stop_after_crawl, install_signal_handlers) else: self._start_twisted(stop_after_crawl, install_signal_handlers) diff --git a/scrapy/extensions/telnet.py b/scrapy/extensions/telnet.py index fec6b2d56..3be24c53f 100644 --- a/scrapy/extensions/telnet.py +++ b/scrapy/extensions/telnet.py @@ -44,7 +44,7 @@ class TelnetConsole(protocol.ServerFactory): if not crawler.settings.getbool("TELNETCONSOLE_ENABLED"): raise NotConfigured - if not crawler.settings.getbool("TWISTED_ENABLED"): + if not crawler.settings.getbool("TWISTED_REACTOR_ENABLED"): raise NotConfigured( "The TelnetConsole extension requires a Twisted reactor." " You can set the TELNETCONSOLE_ENABLED setting to False to remove this warning." diff --git a/scrapy/settings/default_settings.py b/scrapy/settings/default_settings.py index a4768a4bf..462240133 100644 --- a/scrapy/settings/default_settings.py +++ b/scrapy/settings/default_settings.py @@ -185,8 +185,8 @@ __all__ = [ "TELNETCONSOLE_PORT", "TELNETCONSOLE_USERNAME", "TEMPLATES_DIR", - "TWISTED_ENABLED", "TWISTED_REACTOR", + "TWISTED_REACTOR_ENABLED", "URLLENGTH_LIMIT", "USER_AGENT", "WARN_ON_GENERATOR_RETURN_VALUE", @@ -526,7 +526,7 @@ TELNETCONSOLE_PASSWORD = None TEMPLATES_DIR = str((Path(__file__).parent / ".." / "templates").resolve()) -TWISTED_ENABLED = True +TWISTED_REACTOR_ENABLED = True TWISTED_REACTOR = "twisted.internet.asyncioreactor.AsyncioSelectorReactor" URLLENGTH_LIMIT = 2083 diff --git a/scrapy/shell.py b/scrapy/shell.py index 4ce6e20c4..6a9464d74 100644 --- a/scrapy/shell.py +++ b/scrapy/shell.py @@ -45,9 +45,9 @@ class Shell: code: str | None = None, ): self.crawler: Crawler = crawler - if not crawler.settings.getbool("TWISTED_ENABLED"): # pragma: no cover + if not crawler.settings.getbool("TWISTED_REACTOR_ENABLED"): # pragma: no cover raise RuntimeError( - f"{global_object_name(self.__class__)} currently doesn't support TWISTED_ENABLED=False." + f"{global_object_name(self.__class__)} currently doesn't support TWISTED_REACTOR_ENABLED=False." ) self.update_vars: Callable[[dict[str, Any]], None] = update_vars or ( lambda x: None diff --git a/scrapy/utils/reactorless.py b/scrapy/utils/reactorless.py index cd60e6c7d..27dc69d93 100644 --- a/scrapy/utils/reactorless.py +++ b/scrapy/utils/reactorless.py @@ -15,7 +15,7 @@ if TYPE_CHECKING: def is_reactorless() -> bool: """Check if we are running in the reactorless mode, i.e. with - :setting:`TWISTED_ENABLED` set to ``False``. + :setting:`TWISTED_REACTOR_ENABLED` set to ``False``. As this checks the runtime state and not the setting itself, it can be wrong when executed very early, before the reactor and/or the asyncio event diff --git a/scrapy/utils/test.py b/scrapy/utils/test.py index 75f357d50..5c69dc76d 100644 --- a/scrapy/utils/test.py +++ b/scrapy/utils/test.py @@ -128,7 +128,7 @@ def get_reactor_settings() -> dict[str, Any]: # running some 3rd-party library tests without initializing a reactor # properly. The first two cases are fine, but we cannot distinguish the # last one from them. - settings["TWISTED_ENABLED"] = False + settings["TWISTED_REACTOR_ENABLED"] = False settings["DOWNLOAD_HANDLERS"] = { "ftp": None, "http": "scrapy.core.downloader.handlers._httpx.HttpxDownloadHandler", diff --git a/tests/AsyncCrawlerProcess/reactorless_custom_settings.py b/tests/AsyncCrawlerProcess/reactorless_custom_settings.py index b0d4d47e5..93259f696 100644 --- a/tests/AsyncCrawlerProcess/reactorless_custom_settings.py +++ b/tests/AsyncCrawlerProcess/reactorless_custom_settings.py @@ -14,7 +14,7 @@ if TYPE_CHECKING: class NoRequestsSpider(scrapy.Spider): name = "no_request" custom_settings = { - "TWISTED_ENABLED": False, + "TWISTED_REACTOR_ENABLED": False, } async def start(self): diff --git a/tests/AsyncCrawlerProcess/reactorless_datauri.py b/tests/AsyncCrawlerProcess/reactorless_datauri.py index 7906cc9de..5270b7b83 100644 --- a/tests/AsyncCrawlerProcess/reactorless_datauri.py +++ b/tests/AsyncCrawlerProcess/reactorless_datauri.py @@ -12,16 +12,7 @@ class DataSpider(Spider): return {"data": response.text} -process = AsyncCrawlerProcess( - settings={ - "TWISTED_ENABLED": False, - "DOWNLOAD_HANDLERS": { - "http": None, - "https": None, - "ftp": None, - }, - } -) +process = AsyncCrawlerProcess(settings={"TWISTED_REACTOR_ENABLED": False}) process.crawl(DataSpider) process.start() diff --git a/tests/AsyncCrawlerProcess/reactorless_import_hook.py b/tests/AsyncCrawlerProcess/reactorless_import_hook.py index c3ef6389a..57e2a5c26 100644 --- a/tests/AsyncCrawlerProcess/reactorless_import_hook.py +++ b/tests/AsyncCrawlerProcess/reactorless_import_hook.py @@ -12,7 +12,7 @@ class NoRequestsSpider(scrapy.Spider): yield -process = AsyncCrawlerProcess(settings={"TWISTED_ENABLED": False}) +process = AsyncCrawlerProcess(settings={"TWISTED_REACTOR_ENABLED": False}) process.crawl(NoRequestsSpider) process.start() diff --git a/tests/AsyncCrawlerProcess/reactorless_reactor.py b/tests/AsyncCrawlerProcess/reactorless_reactor.py index 59bbc9f0a..481112ca7 100644 --- a/tests/AsyncCrawlerProcess/reactorless_reactor.py +++ b/tests/AsyncCrawlerProcess/reactorless_reactor.py @@ -3,4 +3,4 @@ from scrapy.utils.reactor import install_reactor install_reactor("twisted.internet.asyncioreactor.AsyncioSelectorReactor") -AsyncCrawlerProcess(settings={"TWISTED_ENABLED": False}) +AsyncCrawlerProcess(settings={"TWISTED_REACTOR_ENABLED": False}) diff --git a/tests/AsyncCrawlerProcess/reactorless_simple.py b/tests/AsyncCrawlerProcess/reactorless_simple.py index fa8cb64b0..33b4e8cb2 100644 --- a/tests/AsyncCrawlerProcess/reactorless_simple.py +++ b/tests/AsyncCrawlerProcess/reactorless_simple.py @@ -12,7 +12,7 @@ class NoRequestsSpider(scrapy.Spider): yield -process = AsyncCrawlerProcess(settings={"TWISTED_ENABLED": False}) +process = AsyncCrawlerProcess(settings={"TWISTED_REACTOR_ENABLED": False}) process.crawl(NoRequestsSpider) process.start() diff --git a/tests/AsyncCrawlerProcess/reactorless_sleeping.py b/tests/AsyncCrawlerProcess/reactorless_sleeping.py index 6bfe64686..12101d221 100644 --- a/tests/AsyncCrawlerProcess/reactorless_sleeping.py +++ b/tests/AsyncCrawlerProcess/reactorless_sleeping.py @@ -14,7 +14,7 @@ class SleepingSpider(scrapy.Spider): await asyncio.sleep(int(sys.argv[1])) -process = AsyncCrawlerProcess(settings={"TWISTED_ENABLED": False}) +process = AsyncCrawlerProcess(settings={"TWISTED_REACTOR_ENABLED": False}) process.crawl(SleepingSpider) process.start() diff --git a/tests/AsyncCrawlerProcess/reactorless_telnetconsole_disabled.py b/tests/AsyncCrawlerProcess/reactorless_telnetconsole_disabled.py index 8403986fb..e59a365bc 100644 --- a/tests/AsyncCrawlerProcess/reactorless_telnetconsole_disabled.py +++ b/tests/AsyncCrawlerProcess/reactorless_telnetconsole_disabled.py @@ -12,7 +12,7 @@ class NoRequestsSpider(scrapy.Spider): process = AsyncCrawlerProcess( settings={ - "TWISTED_ENABLED": False, + "TWISTED_REACTOR_ENABLED": False, "TELNETCONSOLE_ENABLED": False, } ) diff --git a/tests/AsyncCrawlerProcess/reactorless_telnetconsole_enabled.py b/tests/AsyncCrawlerProcess/reactorless_telnetconsole_enabled.py index 34f0c69ba..34daf8779 100644 --- a/tests/AsyncCrawlerProcess/reactorless_telnetconsole_enabled.py +++ b/tests/AsyncCrawlerProcess/reactorless_telnetconsole_enabled.py @@ -12,7 +12,7 @@ class NoRequestsSpider(scrapy.Spider): process = AsyncCrawlerProcess( settings={ - "TWISTED_ENABLED": False, + "TWISTED_REACTOR_ENABLED": False, "TELNETCONSOLE_ENABLED": True, } ) diff --git a/tests/AsyncCrawlerRunner/reactorless_custom_settings.py b/tests/AsyncCrawlerRunner/reactorless_custom_settings.py index 0545ca3dc..9b5efc3f9 100644 --- a/tests/AsyncCrawlerRunner/reactorless_custom_settings.py +++ b/tests/AsyncCrawlerRunner/reactorless_custom_settings.py @@ -9,7 +9,7 @@ from scrapy.utils.reactorless import is_reactorless class NoRequestsSpider(Spider): name = "no_request" custom_settings = { - "TWISTED_ENABLED": False, + "TWISTED_REACTOR_ENABLED": False, } async def start(self): diff --git a/tests/AsyncCrawlerRunner/reactorless_datauri.py b/tests/AsyncCrawlerRunner/reactorless_datauri.py index 7915fbdcb..d964f9c1c 100644 --- a/tests/AsyncCrawlerRunner/reactorless_datauri.py +++ b/tests/AsyncCrawlerRunner/reactorless_datauri.py @@ -17,7 +17,7 @@ class DataSpider(Spider): async def main() -> None: configure_logging() - runner = AsyncCrawlerRunner(settings={"TWISTED_ENABLED": False}) + runner = AsyncCrawlerRunner(settings={"TWISTED_REACTOR_ENABLED": False}) await runner.crawl(DataSpider) diff --git a/tests/AsyncCrawlerRunner/reactorless_reactor.py b/tests/AsyncCrawlerRunner/reactorless_reactor.py index 9a77b9c44..4889b2ddc 100644 --- a/tests/AsyncCrawlerRunner/reactorless_reactor.py +++ b/tests/AsyncCrawlerRunner/reactorless_reactor.py @@ -16,7 +16,7 @@ class NoRequestsSpider(Spider): async def main() -> None: configure_logging() - runner = AsyncCrawlerRunner(settings={"TWISTED_ENABLED": False}) + runner = AsyncCrawlerRunner(settings={"TWISTED_REACTOR_ENABLED": False}) await runner.crawl(NoRequestsSpider) diff --git a/tests/AsyncCrawlerRunner/reactorless_simple.py b/tests/AsyncCrawlerRunner/reactorless_simple.py index 6f42600ad..e91c7c89b 100644 --- a/tests/AsyncCrawlerRunner/reactorless_simple.py +++ b/tests/AsyncCrawlerRunner/reactorless_simple.py @@ -17,7 +17,7 @@ class NoRequestsSpider(Spider): async def main() -> None: configure_logging() - runner = AsyncCrawlerRunner(settings={"TWISTED_ENABLED": False}) + runner = AsyncCrawlerRunner(settings={"TWISTED_REACTOR_ENABLED": False}) await runner.crawl(NoRequestsSpider) diff --git a/tests/CrawlerProcess/reactorless.py b/tests/CrawlerProcess/reactorless.py index 6cfafe605..c9384010e 100644 --- a/tests/CrawlerProcess/reactorless.py +++ b/tests/CrawlerProcess/reactorless.py @@ -1,12 +1,3 @@ from scrapy.crawler import CrawlerProcess -CrawlerProcess( - settings={ - "TWISTED_ENABLED": False, - "DOWNLOAD_HANDLERS": { - "http": None, - "https": None, - "ftp": None, - }, - } -) +CrawlerProcess(settings={"TWISTED_REACTOR_ENABLED": False}) diff --git a/tests/CrawlerRunner/reactorless.py b/tests/CrawlerRunner/reactorless.py index be4eb10fb..bad2ae8d9 100644 --- a/tests/CrawlerRunner/reactorless.py +++ b/tests/CrawlerRunner/reactorless.py @@ -1,12 +1,3 @@ from scrapy.crawler import CrawlerRunner -CrawlerRunner( - settings={ - "TWISTED_ENABLED": False, - "DOWNLOAD_HANDLERS": { - "http": None, - "https": None, - "ftp": None, - }, - } -) +CrawlerRunner(settings={"TWISTED_REACTOR_ENABLED": False}) diff --git a/tests/test_addons.py b/tests/test_addons.py index 5c26e4651..3ad8cf7ff 100644 --- a/tests/test_addons.py +++ b/tests/test_addons.py @@ -110,7 +110,7 @@ class TestAddonManager: runner_cls = ( CrawlerRunner - if settings_dict.get("TWISTED_ENABLED", True) + if settings_dict.get("TWISTED_REACTOR_ENABLED", True) else AsyncCrawlerRunner ) @@ -201,7 +201,7 @@ class TestAddonManager: settings.set("KEY", "default", priority="default") runner_cls = ( CrawlerRunner - if settings.getbool("TWISTED_ENABLED", True) + if settings.getbool("TWISTED_REACTOR_ENABLED", True) else AsyncCrawlerRunner ) runner = runner_cls(settings) diff --git a/tests/test_command_check.py b/tests/test_command_check.py index c34e75624..84486e717 100644 --- a/tests/test_command_check.py +++ b/tests/test_command_check.py @@ -55,7 +55,7 @@ class CheckSpider(scrapy.Spider): self._write_contract(proj_path, contracts, parse_def) args = ["check"] if not use_reactor: - args += ["-s", "TWISTED_ENABLED=False"] + args += ["-s", "TWISTED_REACTOR_ENABLED=False"] ret, out, err = proc(*args, cwd=proj_path) assert "F" not in out assert "OK" in err diff --git a/tests/test_command_crawl.py b/tests/test_command_crawl.py index 1b5dee961..dd69d36ba 100644 --- a/tests/test_command_crawl.py +++ b/tests/test_command_crawl.py @@ -137,7 +137,9 @@ class MySpider(scrapy.Spider): return yield """ - log = self.get_log(spider_code, proj_path, args=("-s", "TWISTED_ENABLED=False")) + log = self.get_log( + spider_code, proj_path, args=("-s", "TWISTED_REACTOR_ENABLED=False") + ) assert "[myspider] DEBUG: It works!" in log assert "Not using a Twisted reactor" in log assert "Spider closed (finished)" in log diff --git a/tests/test_command_fetch.py b/tests/test_command_fetch.py index 57db7dc50..d98dac968 100644 --- a/tests/test_command_fetch.py +++ b/tests/test_command_fetch.py @@ -33,6 +33,6 @@ class TestFetchCommand: def test_no_reactor(self, mockserver: MockServer) -> None: _, out, _ = proc( - "fetch", "-s", "TWISTED_ENABLED=False", mockserver.url("/text") + "fetch", "-s", "TWISTED_REACTOR_ENABLED=False", mockserver.url("/text") ) assert out.strip() == "Works" diff --git a/tests/test_command_parse.py b/tests/test_command_parse.py index d612b705b..c210a06e8 100644 --- a/tests/test_command_parse.py +++ b/tests/test_command_parse.py @@ -523,7 +523,7 @@ ITEM_PIPELINES = {{'{self.project_name}.pipelines.MyPipeline': 1}} "parse", mockserver.url("/html"), "-s", - "TWISTED_ENABLED=False", + "TWISTED_REACTOR_ENABLED=False", cwd=proj_path, ) assert "INFO: Got response 200" in stderr diff --git a/tests/test_command_runspider.py b/tests/test_command_runspider.py index 9bc65f152..b1455611e 100644 --- a/tests/test_command_runspider.py +++ b/tests/test_command_runspider.py @@ -219,7 +219,7 @@ class MySpider(scrapy.Spider): self.debug_log_spider, args=[ "-s", - "TWISTED_ENABLED=False", + "TWISTED_REACTOR_ENABLED=False", ], ) assert "Not using a Twisted reactor" in log diff --git a/tests/test_command_shell.py b/tests/test_command_shell.py index 5699a64a1..6a12f28bb 100644 --- a/tests/test_command_shell.py +++ b/tests/test_command_shell.py @@ -129,15 +129,19 @@ class TestShellCommand: def test_shell_fetch_no_reactor(self, mockserver: MockServer) -> None: url = mockserver.url("/html") code = f"fetch('{url}')" - ret, _, err = proc("shell", "-c", code, "--set", "TWISTED_ENABLED=False") + ret, _, err = proc( + "shell", "-c", code, "--set", "TWISTED_REACTOR_ENABLED=False" + ) assert ret == 0, err def test_no_reactor_unsupported(self) -> None: # to be removed when it's supported - ret, out, err = proc("shell", "-c", "item", "--set", "TWISTED_ENABLED=False") + ret, out, err = proc( + "shell", "-c", "item", "--set", "TWISTED_REACTOR_ENABLED=False" + ) assert ret == 1, out or err assert ( - "RuntimeError: scrapy shell currently doesn't support TWISTED_ENABLED=False" + "RuntimeError: scrapy shell currently doesn't support TWISTED_REACTOR_ENABLED=False" in err ) diff --git a/tests/test_commands.py b/tests/test_commands.py index 209ffdc08..1e91aa0b0 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -355,7 +355,7 @@ class TestBenchCommand: "CLOSESPIDER_TIMEOUT=0.01", ] if not use_reactor: - args += ["-s", "TWISTED_ENABLED=False"] + args += ["-s", "TWISTED_REACTOR_ENABLED=False"] _, _, err = proc(*args) assert "INFO: Crawled" in err assert "Unhandled Error" not in err diff --git a/tests/test_crawl.py b/tests/test_crawl.py index 2bc50aad4..811ce1d18 100644 --- a/tests/test_crawl.py +++ b/tests/test_crawl.py @@ -407,7 +407,7 @@ with multiples lines settings_dict = get_reactor_settings() runner_cls = ( CrawlerRunner - if settings_dict.get("TWISTED_ENABLED", True) + if settings_dict.get("TWISTED_REACTOR_ENABLED", True) else AsyncCrawlerRunner ) runner = runner_cls(settings_dict) diff --git a/tests/test_crawler.py b/tests/test_crawler.py index f4b4fbec9..cbcb7e274 100644 --- a/tests/test_crawler.py +++ b/tests/test_crawler.py @@ -638,13 +638,13 @@ class TestCrawlerProcess(TestBaseCrawler): class TestAsyncCrawlerProcess(TestBaseCrawler): def test_crawler_process_accepts_dict(self, reactor_pytest: str) -> None: runner = AsyncCrawlerProcess( - {"foo": "bar", "TWISTED_ENABLED": reactor_pytest != "none"}, + {"foo": "bar", "TWISTED_REACTOR_ENABLED": reactor_pytest != "none"}, install_root_handler=False, ) assert runner.settings["foo"] == "bar" self.assertOptionIsDefault(runner.settings, "RETRY_ENABLED") - @pytest.mark.requires_reactor # can't pass TWISTED_ENABLED=False + @pytest.mark.requires_reactor # can't pass TWISTED_REACTOR_ENABLED=False def test_crawler_process_accepts_None(self) -> None: runner = AsyncCrawlerProcess(install_root_handler=False) self.assertOptionIsDefault(runner.settings, "RETRY_ENABLED") diff --git a/tests/test_crawler_subprocess.py b/tests/test_crawler_subprocess.py index c13bc0c9d..beae4f277 100644 --- a/tests/test_crawler_subprocess.py +++ b/tests/test_crawler_subprocess.py @@ -309,7 +309,8 @@ class TestCrawlerProcessSubprocess(TestCrawlerProcessSubprocessBase): def test_reactorless(self): log = self.run_script("reactorless.py") assert ( - "RuntimeError: CrawlerProcess doesn't support TWISTED_ENABLED=False" in log + "RuntimeError: CrawlerProcess doesn't support TWISTED_REACTOR_ENABLED=False" + in log ) @@ -356,12 +357,16 @@ class TestAsyncCrawlerProcessSubprocess(TestCrawlerProcessSubprocessBase): assert log.count("WARNING: ") == 2 def test_reactorless_custom_settings(self): - """Setting TWISTED_ENABLED=False in spider settings is not currently supported, - AsyncCrawlerProcess will install a reactor in this case. + """Setting TWISTED_REACTOR_ENABLED=False in spider settings is not + currently supported, AsyncCrawlerProcess will install a reactor in this + case. """ log = self.run_script("reactorless_custom_settings.py") assert "Spider closed (finished)" not in log - assert "TWISTED_ENABLED is False but a Twisted reactor is installed." in log + assert ( + "TWISTED_REACTOR_ENABLED is False but a Twisted reactor is installed." + in log + ) def test_reactorless_datauri(self): log = self.run_script("reactorless_datauri.py") @@ -370,7 +375,8 @@ class TestAsyncCrawlerProcessSubprocess(TestCrawlerProcessSubprocessBase): assert "{'data': 'foo'}" in log assert "'item_scraped_count': 1" in log assert "ERROR: " not in log - assert "WARNING: " not in log + assert log.count("WARNING: HttpxDownloadHandler is experimental") == 2 + assert log.count("WARNING: ") == 2 def test_reactorless_import_hook(self): log = self.run_script("reactorless_import_hook.py") @@ -379,7 +385,7 @@ class TestAsyncCrawlerProcessSubprocess(TestCrawlerProcessSubprocessBase): assert "ImportError: Import of twisted.internet.reactor is forbidden" in log def test_reactorless_telnetconsole_default(self): - """By default TWISTED_ENABLED=False silently sets TELNETCONSOLE_ENABLED=False.""" + """By default TWISTED_REACTOR_ENABLED=False silently sets TELNETCONSOLE_ENABLED=False.""" log = self.run_script("reactorless_simple.py") # no need for a separate script assert "Not using a Twisted reactor" in log assert "Spider closed (finished)" in log @@ -404,7 +410,7 @@ class TestAsyncCrawlerProcessSubprocess(TestCrawlerProcessSubprocessBase): def test_reactorless_reactor(self): log = self.run_script("reactorless_reactor.py") assert ( - "RuntimeError: TWISTED_ENABLED is False but a Twisted reactor is installed" + "RuntimeError: TWISTED_REACTOR_ENABLED is False but a Twisted reactor is installed" in log ) @@ -515,7 +521,8 @@ class TestCrawlerRunnerSubprocess(TestCrawlerRunnerSubprocessBase): def test_reactorless(self): log = self.run_script("reactorless.py") assert ( - "RuntimeError: CrawlerRunner doesn't support TWISTED_ENABLED=False" in log + "RuntimeError: CrawlerRunner doesn't support TWISTED_REACTOR_ENABLED=False" + in log ) @@ -528,7 +535,7 @@ class TestAsyncCrawlerRunnerSubprocess(TestCrawlerRunnerSubprocessBase): log = self.run_script("simple_default_reactor.py") assert "Spider closed (finished)" not in log assert ( - "RuntimeError: When TWISTED_ENABLED is True, " + "RuntimeError: When TWISTED_REACTOR_ENABLED is True, " "AsyncCrawlerRunner requires that the installed Twisted reactor" ) in log @@ -542,8 +549,9 @@ class TestAsyncCrawlerRunnerSubprocess(TestCrawlerRunnerSubprocessBase): assert log.count("WARNING: ") == 2 def test_reactorless_custom_settings(self): - """Setting TWISTED_ENABLED=False in spider settings is not currently supported, - AsyncCrawlerRunner will expect a reactor installed by the user. + """Setting TWISTED_REACTOR_ENABLED=False in spider settings is not + currently supported, AsyncCrawlerRunner will expect a reactor installed + by the user. """ log = self.run_script("reactorless_custom_settings.py") assert "Spider closed (finished)" not in log @@ -562,6 +570,6 @@ class TestAsyncCrawlerRunnerSubprocess(TestCrawlerRunnerSubprocessBase): def test_reactorless_reactor(self): log = self.run_script("reactorless_reactor.py") assert ( - "RuntimeError: TWISTED_ENABLED is False but a Twisted reactor is installed" + "RuntimeError: TWISTED_REACTOR_ENABLED is False but a Twisted reactor is installed" in log ) diff --git a/tests/test_downloader_handler_twisted_ftp.py b/tests/test_downloader_handler_twisted_ftp.py index 8f43d8071..361e91382 100644 --- a/tests/test_downloader_handler_twisted_ftp.py +++ b/tests/test_downloader_handler_twisted_ftp.py @@ -206,6 +206,6 @@ class TestAnonymousFTP(TestFTPBase): def test_not_configured_without_reactor() -> None: - crawler = Crawler(Spider, {"TWISTED_ENABLED": False}) + crawler = Crawler(Spider, {"TWISTED_REACTOR_ENABLED": False}) with pytest.raises(NotConfigured): FTPDownloadHandler.from_crawler(crawler) diff --git a/tests/test_downloader_handler_twisted_http11.py b/tests/test_downloader_handler_twisted_http11.py index 5d093cc17..f6e86d3dc 100644 --- a/tests/test_downloader_handler_twisted_http11.py +++ b/tests/test_downloader_handler_twisted_http11.py @@ -36,7 +36,7 @@ class HTTP11DownloadHandlerMixin: def test_not_configured_without_reactor() -> None: - crawler = Crawler(Spider, {"TWISTED_ENABLED": False}) + crawler = Crawler(Spider, {"TWISTED_REACTOR_ENABLED": False}) with pytest.raises(NotConfigured): HTTP11DownloadHandler.from_crawler(crawler) diff --git a/tests/test_downloader_handler_twisted_http2.py b/tests/test_downloader_handler_twisted_http2.py index 7e2c06d6b..53f0210ff 100644 --- a/tests/test_downloader_handler_twisted_http2.py +++ b/tests/test_downloader_handler_twisted_http2.py @@ -52,7 +52,7 @@ class H2DownloadHandlerMixin: def test_not_configured_without_reactor() -> None: from scrapy.core.downloader.handlers.http2 import H2DownloadHandler # noqa: PLC0415 - crawler = Crawler(Spider, {"TWISTED_ENABLED": False}) + crawler = Crawler(Spider, {"TWISTED_REACTOR_ENABLED": False}) with pytest.raises(NotConfigured): H2DownloadHandler.from_crawler(crawler) From e3a8ff2b59f8e6e43d7b13b37af816cb7e432a23 Mon Sep 17 00:00:00 2001 From: "Albert Eduardovich N." Date: Fri, 3 Apr 2026 13:15:30 +0300 Subject: [PATCH 095/248] improve trackref (#7375) * improve trackref - use `NoneType` from `types` since python 3.9 is no longer supported - use `monotonic` instead of `time` and fix flakiness of `get_oldest` * tracing GC is no joke * refine tests * explain the tests * add note for pypy in docs + ... return empty tuple instead of list in `iter_all` --- scrapy/utils/trackref.py | 19 +++++++---- tests/test_utils_trackref.py | 62 ++++++++++++++++++++++++++++-------- 2 files changed, 60 insertions(+), 21 deletions(-) diff --git a/scrapy/utils/trackref.py b/scrapy/utils/trackref.py index b04214c51..082aca4b1 100644 --- a/scrapy/utils/trackref.py +++ b/scrapy/utils/trackref.py @@ -7,13 +7,19 @@ subclass from object_ref (instead of object). About performance: This library has a minimal performance impact when enabled, and no performance penalty at all when disabled (as object_ref becomes just an alias to object in that case). + +.. note:: PyPy uses a tracing garbage collector, so objects may + remain in the ``live_refs`` longer than expected, even after they + go out of scope. If deterministic behavior is required, you may need + to explicitly trigger garbage collection or call ``trackref.live_refs.clear()``. """ from __future__ import annotations from collections import defaultdict from operator import itemgetter -from time import time +from time import monotonic_ns +from types import NoneType from typing import TYPE_CHECKING, Any from weakref import WeakKeyDictionary @@ -24,7 +30,6 @@ if TYPE_CHECKING: from typing_extensions import Self -NoneType = type(None) live_refs: defaultdict[type, WeakKeyDictionary] = defaultdict(WeakKeyDictionary) @@ -35,7 +40,7 @@ class object_ref: def __new__(cls, *args: Any, **kwargs: Any) -> Self: obj = object.__new__(cls) - live_refs[cls][obj] = time() + live_refs[cls][obj] = monotonic_ns() return obj @@ -43,14 +48,14 @@ class object_ref: def format_live_refs(ignore: Any = NoneType) -> str: """Return a tabular representation of tracked objects""" s = "Live References\n\n" - now = time() + now_ns = monotonic_ns() for cls, wdict in sorted(live_refs.items(), key=lambda x: x[0].__name__): if not wdict: continue if issubclass(cls, ignore): continue - oldest = min(wdict.values()) - s += f"{cls.__name__:<30} {len(wdict):6} oldest: {int(now - oldest)}s ago\n" + oldest_ns = min(wdict.values()) + s += f"{cls.__name__:<30} {len(wdict):6} oldest: {int((now_ns - oldest_ns) // 1e9)}s ago\n" return s @@ -74,4 +79,4 @@ def iter_all(class_name: str) -> Iterable[Any]: for cls, wdict in live_refs.items(): if cls.__name__ == class_name: return wdict.keys() - return [] + return () diff --git a/tests/test_utils_trackref.py b/tests/test_utils_trackref.py index 3967c3365..7a7a264d3 100644 --- a/tests/test_utils_trackref.py +++ b/tests/test_utils_trackref.py @@ -1,10 +1,11 @@ +import sys from io import StringIO -from time import sleep, time from unittest import mock import pytest from scrapy.utils import trackref +from scrapy.utils.python import garbage_collect class Foo(trackref.object_ref): @@ -63,24 +64,57 @@ Foo 1 oldest: 0s ago\n\n""" ) +_IS_PYPY = "PyPy" in sys.version + + def test_get_oldest(): - o1 = Foo() + """ + Verify that `get_oldest` returns the oldest live instance of a class. - o1_time = time() + The test runs in two passes to expose differences between: + - CPython (reference counting, immediate destruction) + - PyPy (tracing GC, delayed destruction) - o2 = Bar() + Since `trackref` relies on weak references, delayed GC on PyPy can leave + stale entries in `live_refs`, affecting results unless explicitly cleared. + """ - o3_time = time() - if o3_time <= o1_time: - sleep(0.01) - o3_time = time() - if o3_time <= o1_time: - pytest.skip("time.time is not precise enough") + def _delete_o1(): + """Delete `o1` and ensure it is actually collected on PyPy.""" + nonlocal o1 + del o1 - o3 = Foo() # noqa: F841 - assert trackref.get_oldest("Foo") is o1 - assert trackref.get_oldest("Bar") is o2 - assert trackref.get_oldest("XXX") is None + if _IS_PYPY: + # On PyPy, `del` only removes the local reference. The object may + # still exist until the GC runs, so we force a collection cycle. + garbage_collect() + + def _do_asserts(): + assert trackref.get_oldest("Foo") is o1 + assert trackref.get_oldest("Bar") is o2 + # Ensure the newer Foo is not incorrectly considered the oldest + assert trackref.get_oldest("Foo") is not o3 + assert trackref.get_oldest("XXX") is None + + o1, o2, o3 = Foo(), Bar(), Foo() + + _do_asserts() + + # Remove the oldest Foo instance; o3 should now become the oldest + _delete_o1() + assert trackref.get_oldest("Foo") is o3 + + # PyPy-specific behavior where stale references may persist + # unless the registry is explicitly cleared. + if _IS_PYPY: + trackref.live_refs.clear() + + o1, o2, o3 = Foo(), Bar(), Foo() + + _do_asserts() + + _delete_o1() + assert trackref.get_oldest("Foo") is o3 def test_iter_all(): From 58d85282cf785369bd9f2eddcce0889f60d8a065 Mon Sep 17 00:00:00 2001 From: "Albert Eduardovich N." Date: Fri, 3 Apr 2026 17:00:13 +0300 Subject: [PATCH 096/248] chore: more ruff rules and overall minor improvements (#7386) * overall prettyfication - don't create empty mutable containers (lists, dicts) where it is appropriate - removed from ignore section and applied some rules from ruff (but keep them ignored in tests) - use `deque` in `_AsyncCooperatorAdapter` instead of `list.pop(0)` - remove `f` prefix from strings without any formatting * return `SIM300` to ignore * apply ruff rules to all files * another one * remove extra space in pyproject.toml * simplify `__getattr__` in `scrapy.utils.url` * lazy `url_is_from_any_domain` and `url_is_from_spider` * improve typing * specify `arg_to_iter` with None as arg --- pyproject.toml | 43 +++++++------ scrapy/cmdline.py | 2 +- scrapy/commands/__init__.py | 12 ++-- scrapy/commands/bench.py | 4 +- scrapy/commands/check.py | 4 +- scrapy/commands/edit.py | 3 +- scrapy/commands/genspider.py | 4 +- scrapy/commands/list.py | 4 +- scrapy/commands/parse.py | 10 +-- scrapy/commands/runspider.py | 10 +-- scrapy/commands/settings.py | 3 +- scrapy/commands/shell.py | 4 +- scrapy/commands/startproject.py | 6 +- scrapy/commands/version.py | 3 +- scrapy/contracts/__init__.py | 8 +-- scrapy/contracts/default.py | 6 +- scrapy/core/downloader/handlers/datauri.py | 7 +- scrapy/core/downloader/handlers/file.py | 3 +- scrapy/core/downloader/handlers/ftp.py | 4 +- scrapy/core/downloader/handlers/http11.py | 2 +- scrapy/core/http2/protocol.py | 4 +- scrapy/core/http2/stream.py | 10 +-- scrapy/core/scheduler.py | 12 ++-- scrapy/core/spidermw.py | 2 + scrapy/crawler.py | 2 +- scrapy/downloadermiddlewares/cookies.py | 4 +- scrapy/downloadermiddlewares/httpcache.py | 2 +- .../downloadermiddlewares/httpcompression.py | 3 +- scrapy/downloadermiddlewares/httpproxy.py | 2 +- scrapy/downloadermiddlewares/offsite.py | 4 +- scrapy/downloadermiddlewares/redirect.py | 19 ++++-- scrapy/extensions/feedexport.py | 31 ++++++--- scrapy/extensions/httpcache.py | 6 +- scrapy/extensions/memusage.py | 4 +- scrapy/extensions/periodic_log.py | 23 +++++-- scrapy/extensions/statsmailer.py | 1 + scrapy/http/cookies.py | 4 +- scrapy/http/request/form.py | 8 +-- scrapy/http/request/json_request.py | 8 ++- scrapy/http/response/__init__.py | 4 +- scrapy/http/response/text.py | 2 +- scrapy/linkextractors/lxmlhtml.py | 6 +- scrapy/mail.py | 1 + scrapy/pipelines/files.py | 16 +++-- scrapy/pipelines/images.py | 8 +-- scrapy/pipelines/media.py | 4 +- scrapy/pqueues.py | 5 +- scrapy/responsetypes.py | 4 +- scrapy/settings/__init__.py | 6 +- scrapy/spiderloader.py | 4 +- scrapy/spidermiddlewares/httperror.py | 2 +- scrapy/spidermiddlewares/referer.py | 4 +- scrapy/spiders/crawl.py | 16 +++-- scrapy/spiders/sitemap.py | 2 +- scrapy/utils/console.py | 16 ++--- scrapy/utils/curl.py | 2 +- scrapy/utils/defer.py | 5 +- scrapy/utils/deprecate.py | 3 +- scrapy/utils/misc.py | 64 ++++++++++++++++--- scrapy/utils/project.py | 3 +- scrapy/utils/reactor.py | 2 +- scrapy/utils/request.py | 2 +- scrapy/utils/testproc.py | 5 +- scrapy/utils/testsite.py | 1 + scrapy/utils/url.py | 24 ++++--- tests/test_spidermiddleware_httperror.py | 2 +- 66 files changed, 308 insertions(+), 196 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b343cce60..117edce0f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -415,25 +415,6 @@ ignore = [ "SIM115", # Yoda condition detected "SIM300", - - # Ones that we may want to address (fix, ignore per-line or move to "don't want to fix") - - # Assigning to `os.environ` doesn't clear the environment. - "B003", - # Do not use mutable data structures for argument defaults. - "B006", - # Found useless expression. - "B018", - # No explicit stacklevel argument found. - "B028", - # Within an `except` clause, raise exceptions with `raise ... from` - "B904", - # `for` loop variable overwritten by assignment target - "PLW2901", - # Mutable class attributes should be annotated with `typing.ClassVar` - "RUF012", - # Use capitalized environment variable - "SIM112", ] [tool.ruff.lint.flake8-tidy-imports] @@ -453,8 +434,28 @@ split-on-trailing-comma = false "scrapy/linkextractors/__init__.py" = ["E402"] "scrapy/spiders/__init__.py" = ["E402"] -# Skip bandit and allow blocking file I/O in tests -"tests/**" = ["ASYNC240", "S"] +"tests/**" = [ + # Skip bandit and allow blocking file I/O in tests + "ASYNC240", + "S", + # Ones that we may want to address (fix, ignore per-line or move to "don't want to fix") + # Assigning to `os.environ` doesn't clear the environment. + "B003", + # Do not use mutable data structures for argument defaults. + "B006", + # Found useless expression. + "B018", + # No explicit stacklevel argument found. + "B028", + # Within an `except` clause, raise exceptions with `raise ... from` + "B904", + # `for` loop variable overwritten by assignment target + "PLW2901", + # Mutable class attributes should be annotated with `typing.ClassVar` + "RUF012", + # Use capitalized environment variable + "SIM112", +] # Issues pending a review: "docs/conf.py" = ["E402"] diff --git a/scrapy/cmdline.py b/scrapy/cmdline.py index 057ddba4d..8aadc90e7 100644 --- a/scrapy/cmdline.py +++ b/scrapy/cmdline.py @@ -46,7 +46,7 @@ def _iter_command_classes(module_name: str) -> Iterable[type[ScrapyCommand]]: inspect.isclass(obj) and issubclass(obj, ScrapyCommand) and obj.__module__ == module.__name__ - and obj not in (ScrapyCommand, BaseRunSpiderCommand) + and obj not in {ScrapyCommand, BaseRunSpiderCommand} ): yield obj diff --git a/scrapy/commands/__init__.py b/scrapy/commands/__init__.py index d666ca796..e213d0d94 100644 --- a/scrapy/commands/__init__.py +++ b/scrapy/commands/__init__.py @@ -10,7 +10,7 @@ import os import warnings from abc import ABC, abstractmethod from pathlib import Path -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, ClassVar from twisted.python import failure @@ -30,7 +30,7 @@ class ScrapyCommand(ABC): crawler_process: CrawlerProcessBase | None = None # set in scrapy.cmdline # default settings to be used for this command instead of global defaults - default_settings: dict[str, Any] = {} + default_settings: ClassVar[dict[str, Any]] = {} exitcode: int = 0 @@ -115,7 +115,9 @@ class ScrapyCommand(ABC): try: self.settings.setdict(arglist_to_dict(opts.set), priority="cmdline") except ValueError: - raise UsageError("Invalid -s value, use -s NAME=VALUE", print_help=False) + raise UsageError( + "Invalid -s value, use -s NAME=VALUE", print_help=False + ) from None if opts.logfile: self.settings.set("LOG_ENABLED", True, priority="cmdline") @@ -181,7 +183,9 @@ class BaseRunSpiderCommand(ScrapyCommand): try: opts.spargs = arglist_to_dict(opts.spargs) except ValueError: - raise UsageError("Invalid -a value, use -a NAME=VALUE", print_help=False) + raise UsageError( + "Invalid -a value, use -a NAME=VALUE", print_help=False + ) from None if opts.output or opts.overwrite_output: assert self.settings is not None feeds = feed_process_params_from_cli( diff --git a/scrapy/commands/bench.py b/scrapy/commands/bench.py index cc39d344a..0a5b431a2 100644 --- a/scrapy/commands/bench.py +++ b/scrapy/commands/bench.py @@ -3,7 +3,7 @@ from __future__ import annotations import subprocess import sys import time -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, ClassVar from urllib.parse import urlencode import scrapy @@ -18,7 +18,7 @@ if TYPE_CHECKING: class Command(ScrapyCommand): - default_settings = { + default_settings: ClassVar[dict[str, Any]] = { "LOG_LEVEL": "INFO", "LOGSTATS_INTERVAL": 1, "CLOSESPIDER_TIMEOUT": 10, diff --git a/scrapy/commands/check.py b/scrapy/commands/check.py index 17e66e20c..1e4e09135 100644 --- a/scrapy/commands/check.py +++ b/scrapy/commands/check.py @@ -2,7 +2,7 @@ import argparse import time from collections import defaultdict from collections.abc import AsyncIterator -from typing import Any +from typing import Any, ClassVar from unittest import TextTestResult as _TextTestResult from unittest import TextTestRunner @@ -44,7 +44,7 @@ class TextTestResult(_TextTestResult): class Command(ScrapyCommand): requires_project = True - default_settings = {"LOG_ENABLED": False} + default_settings: ClassVar[dict[str, Any]] = {"LOG_ENABLED": False} def syntax(self) -> str: return "[options] " diff --git a/scrapy/commands/edit.py b/scrapy/commands/edit.py index f2d52673a..cd7c57f28 100644 --- a/scrapy/commands/edit.py +++ b/scrapy/commands/edit.py @@ -1,6 +1,7 @@ import argparse import os import sys +from typing import Any, ClassVar from scrapy.commands import ScrapyCommand from scrapy.exceptions import UsageError @@ -10,7 +11,7 @@ from scrapy.spiderloader import get_spider_loader class Command(ScrapyCommand): requires_project = True requires_crawler_process = False - default_settings = {"LOG_ENABLED": False} + default_settings: ClassVar[dict[str, Any]] = {"LOG_ENABLED": False} def syntax(self) -> str: return "" diff --git a/scrapy/commands/genspider.py b/scrapy/commands/genspider.py index 9d2742afd..b030be4de 100644 --- a/scrapy/commands/genspider.py +++ b/scrapy/commands/genspider.py @@ -5,7 +5,7 @@ import shutil import string from importlib import import_module from pathlib import Path -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any, ClassVar, cast from urllib.parse import urlparse import scrapy @@ -47,7 +47,7 @@ def verify_url_scheme(url: str) -> str: class Command(ScrapyCommand): requires_crawler_process = False - default_settings = {"LOG_ENABLED": False} + default_settings: ClassVar[dict[str, Any]] = {"LOG_ENABLED": False} def syntax(self) -> str: return "[options] " diff --git a/scrapy/commands/list.py b/scrapy/commands/list.py index b4dc97f3d..ad55dba66 100644 --- a/scrapy/commands/list.py +++ b/scrapy/commands/list.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any, ClassVar from scrapy.commands import ScrapyCommand from scrapy.spiderloader import get_spider_loader @@ -12,7 +12,7 @@ if TYPE_CHECKING: class Command(ScrapyCommand): requires_project = True requires_crawler_process = False - default_settings = {"LOG_ENABLED": False} + default_settings: ClassVar[dict[str, Any]] = {"LOG_ENABLED": False} def short_desc(self) -> str: return "List available spiders" diff --git a/scrapy/commands/parse.py b/scrapy/commands/parse.py index e1e027c95..632186a7d 100644 --- a/scrapy/commands/parse.py +++ b/scrapy/commands/parse.py @@ -4,7 +4,7 @@ import functools import inspect import json import logging -from typing import TYPE_CHECKING, Any, TypeVar, overload +from typing import TYPE_CHECKING, Any, ClassVar, TypeVar, overload from itemadapter import ItemAdapter from twisted.internet.defer import Deferred, maybeDeferred @@ -39,8 +39,8 @@ class Command(BaseRunSpiderCommand): requires_project = True spider: Spider | None = None - items: dict[int, list[Any]] = {} - requests: dict[int, list[Request]] = {} + items: ClassVar[dict[int, list[Any]]] = {} + requests: ClassVar[dict[int, list[Request]]] = {} spidercls: type[Spider] | None first_response = None @@ -387,7 +387,7 @@ class Command(BaseRunSpiderCommand): "Invalid -m/--meta value, pass a valid json string to -m or --meta. " 'Example: --meta=\'{"foo" : "bar"}\'', print_help=False, - ) + ) from None def process_request_cb_kwargs(self, opts: argparse.Namespace) -> None: if opts.cbkwargs: @@ -398,7 +398,7 @@ class Command(BaseRunSpiderCommand): "Invalid --cbkwargs value, pass a valid json string to --cbkwargs. " 'Example: --cbkwargs=\'{"foo" : "bar"}\'', print_help=False, - ) + ) from None def run(self, args: list[str], opts: argparse.Namespace) -> None: # parse arguments diff --git a/scrapy/commands/runspider.py b/scrapy/commands/runspider.py index eeb1303e2..0b9036457 100644 --- a/scrapy/commands/runspider.py +++ b/scrapy/commands/runspider.py @@ -3,7 +3,7 @@ from __future__ import annotations import sys from importlib import import_module from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any, ClassVar from scrapy.commands import BaseRunSpiderCommand from scrapy.exceptions import UsageError @@ -18,7 +18,7 @@ if TYPE_CHECKING: def _import_file(filepath: str | PathLike[str]) -> ModuleType: abspath = Path(filepath).resolve() - if abspath.suffix not in (".py", ".pyw"): + if abspath.suffix not in {".py", ".pyw"}: raise ValueError(f"Not a Python source file: {abspath}") dirname = str(abspath.parent) sys.path = [dirname, *sys.path] @@ -30,7 +30,9 @@ def _import_file(filepath: str | PathLike[str]) -> ModuleType: class Command(BaseRunSpiderCommand): - default_settings = {"SPIDER_LOADER_CLASS": DummySpiderLoader} + default_settings: ClassVar[dict[str, Any]] = { + "SPIDER_LOADER_CLASS": DummySpiderLoader + } def syntax(self) -> str: return "[options] " @@ -50,7 +52,7 @@ class Command(BaseRunSpiderCommand): try: module = _import_file(filename) except (ImportError, ValueError) as e: - raise UsageError(f"Unable to load {str(filename)!r}: {e}\n") + raise UsageError(f"Unable to load {str(filename)!r}: {e}\n") from e spclasses = list(iter_spider_classes(module)) if not spclasses: raise UsageError(f"No spider found in file: {filename}\n") diff --git a/scrapy/commands/settings.py b/scrapy/commands/settings.py index 704cc500d..26a97ccbb 100644 --- a/scrapy/commands/settings.py +++ b/scrapy/commands/settings.py @@ -1,5 +1,6 @@ import argparse import json +from typing import Any, ClassVar from scrapy.commands import ScrapyCommand from scrapy.settings import BaseSettings @@ -7,7 +8,7 @@ from scrapy.settings import BaseSettings class Command(ScrapyCommand): requires_crawler_process = False - default_settings = {"LOG_ENABLED": False} + default_settings: ClassVar[dict[str, Any]] = {"LOG_ENABLED": False} def syntax(self) -> str: return "[options]" diff --git a/scrapy/commands/shell.py b/scrapy/commands/shell.py index 772b2911c..8db5f4b49 100644 --- a/scrapy/commands/shell.py +++ b/scrapy/commands/shell.py @@ -7,7 +7,7 @@ See documentation in docs/topics/shell.rst from __future__ import annotations from threading import Thread -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, ClassVar from scrapy.commands import ScrapyCommand from scrapy.http import Request @@ -23,7 +23,7 @@ if TYPE_CHECKING: class Command(ScrapyCommand): - default_settings = { + default_settings: ClassVar[dict[str, Any]] = { "DUPEFILTER_CLASS": "scrapy.dupefilters.BaseDupeFilter", "KEEP_ALIVE": True, "LOGSTATS_INTERVAL": 0, diff --git a/scrapy/commands/startproject.py b/scrapy/commands/startproject.py index 8f4427580..c56a7319a 100644 --- a/scrapy/commands/startproject.py +++ b/scrapy/commands/startproject.py @@ -6,7 +6,7 @@ from importlib.util import find_spec from pathlib import Path from shutil import copy2, copystat, ignore_patterns, move from stat import S_IWUSR as OWNER_WRITE_PERMISSION -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any, ClassVar import scrapy from scrapy.commands import ScrapyCommand @@ -34,7 +34,7 @@ def _make_writable(path: Path) -> None: class Command(ScrapyCommand): requires_crawler_process = False - default_settings = {"LOG_ENABLED": False} + default_settings: ClassVar[dict[str, Any]] = {"LOG_ENABLED": False} def syntax(self) -> str: return " [project_dir]" @@ -90,7 +90,7 @@ class Command(ScrapyCommand): _make_writable(dst) def run(self, args: list[str], opts: argparse.Namespace) -> None: - if len(args) not in (1, 2): + if len(args) not in {1, 2}: raise UsageError project_name = args[0] diff --git a/scrapy/commands/version.py b/scrapy/commands/version.py index 30b0e9fd7..1d1985d4e 100644 --- a/scrapy/commands/version.py +++ b/scrapy/commands/version.py @@ -1,4 +1,5 @@ import argparse +from typing import Any, ClassVar import scrapy from scrapy.commands import ScrapyCommand @@ -7,7 +8,7 @@ from scrapy.utils.versions import get_versions class Command(ScrapyCommand): requires_crawler_process = False - default_settings = {"LOG_ENABLED": False} + default_settings: ClassVar[dict[str, Any]] = {"LOG_ENABLED": False} def syntax(self) -> str: return "[-v]" diff --git a/scrapy/contracts/__init__.py b/scrapy/contracts/__init__.py index cbdb36d2f..f84bbe0d7 100644 --- a/scrapy/contracts/__init__.py +++ b/scrapy/contracts/__init__.py @@ -6,7 +6,7 @@ from collections.abc import AsyncGenerator, Iterable from functools import wraps from inspect import getmembers from types import CoroutineType -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any, ClassVar, cast from unittest import TestCase, TestResult from scrapy.http import Request, Response @@ -90,7 +90,7 @@ class Contract: class ContractsManager: - contracts: dict[str, type[Contract]] = {} + contracts: ClassVar[dict[str, type[Contract]]] = {} def __init__(self, contracts: Iterable[type[Contract]]): for contract in contracts: @@ -108,8 +108,8 @@ class ContractsManager: def extract_contracts(self, method: Callable) -> list[Contract]: contracts: list[Contract] = [] assert method.__doc__ is not None - for line in method.__doc__.split("\n"): - line = line.strip() + for line_ in method.__doc__.split("\n"): + line = line_.strip() if line.startswith("@"): m = re.match(r"@(\w+)\s*(.*)", line) diff --git a/scrapy/contracts/default.py b/scrapy/contracts/default.py index 90f054a87..9b42ca36f 100644 --- a/scrapy/contracts/default.py +++ b/scrapy/contracts/default.py @@ -1,7 +1,7 @@ from __future__ import annotations import json -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, ClassVar from itemadapter import ItemAdapter, is_item @@ -68,7 +68,7 @@ class ReturnsContract(Contract): """ name = "returns" - object_type_verifiers: dict[str | None, Callable[[Any], bool]] = { + object_type_verifiers: ClassVar[dict[str | None, Callable[[Any], bool]]] = { "request": lambda x: isinstance(x, Request), "requests": lambda x: isinstance(x, Request), "item": is_item, @@ -78,7 +78,7 @@ class ReturnsContract(Contract): def __init__(self, *args: Any, **kwargs: Any): super().__init__(*args, **kwargs) - if len(self.args) not in [1, 2, 3]: + if len(self.args) not in {1, 2, 3}: raise ValueError( f"Incorrect argument quantity: expected 1, 2 or 3, got {len(self.args)}" ) diff --git a/scrapy/core/downloader/handlers/datauri.py b/scrapy/core/downloader/handlers/datauri.py index 84f241d88..d0f32216c 100644 --- a/scrapy/core/downloader/handlers/datauri.py +++ b/scrapy/core/downloader/handlers/datauri.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING from w3lib.url import parse_data_uri @@ -17,9 +17,8 @@ class DataURIDownloadHandler(BaseDownloadHandler): uri = parse_data_uri(request.url) respcls = responsetypes.from_mimetype(uri.media_type) - resp_kwargs: dict[str, Any] = {} if issubclass(respcls, TextResponse) and uri.media_type.split("/")[0] == "text": charset = uri.media_type_parameters.get("charset") - resp_kwargs["encoding"] = charset + return respcls(url=request.url, body=uri.data, encoding=charset) - return respcls(url=request.url, body=uri.data, **resp_kwargs) + return respcls(url=request.url, body=uri.data) diff --git a/scrapy/core/downloader/handlers/file.py b/scrapy/core/downloader/handlers/file.py index a59aa722b..f080cce48 100644 --- a/scrapy/core/downloader/handlers/file.py +++ b/scrapy/core/downloader/handlers/file.py @@ -7,6 +7,7 @@ from w3lib.url import file_uri_to_path from scrapy.core.downloader.handlers.base import BaseDownloadHandler from scrapy.responsetypes import responsetypes +from scrapy.utils.asyncio import run_in_thread if TYPE_CHECKING: from scrapy import Request @@ -16,6 +17,6 @@ if TYPE_CHECKING: class FileDownloadHandler(BaseDownloadHandler): async def download_request(self, request: Request) -> Response: filepath = file_uri_to_path(request.url) - body = Path(filepath).read_bytes() # noqa: ASYNC240 + body = await run_in_thread(Path(filepath).read_bytes) respcls = responsetypes.from_args(filename=filepath, body=body) return respcls(url=request.url, body=body) diff --git a/scrapy/core/downloader/handlers/ftp.py b/scrapy/core/downloader/handlers/ftp.py index 8327a3b0e..6258067c1 100644 --- a/scrapy/core/downloader/handlers/ftp.py +++ b/scrapy/core/downloader/handlers/ftp.py @@ -33,7 +33,7 @@ from __future__ import annotations import re from io import BytesIO from pathlib import Path -from typing import TYPE_CHECKING, BinaryIO +from typing import TYPE_CHECKING, BinaryIO, ClassVar from urllib.parse import unquote from twisted.internet.protocol import ClientCreator, Protocol @@ -79,7 +79,7 @@ _CODE_RE = re.compile(r"\d+") class FTPDownloadHandler(BaseDownloadHandler): - CODE_MAPPING: dict[str, int] = { + CODE_MAPPING: ClassVar[dict[str, int]] = { "550": 404, "default": 503, } diff --git a/scrapy/core/downloader/handlers/http11.py b/scrapy/core/downloader/handlers/http11.py index 9dd403aca..9d102aff8 100644 --- a/scrapy/core/downloader/handlers/http11.py +++ b/scrapy/core/downloader/handlers/http11.py @@ -147,7 +147,7 @@ class HTTP11DownloadHandler(BaseHttpDownloadHandler): # issue a callback after `_disconnect_timeout` seconds. # # See also https://github.com/scrapy/scrapy/issues/2653 - delayed_call = reactor.callLater(self._disconnect_timeout, d.callback, []) + delayed_call = reactor.callLater(self._disconnect_timeout, d.callback, ()) try: await maybe_deferred_to_future(d) diff --git a/scrapy/core/http2/protocol.py b/scrapy/core/http2/protocol.py index 19f0c16e9..173545f55 100644 --- a/scrapy/core/http2/protocol.py +++ b/scrapy/core/http2/protocol.py @@ -265,7 +265,7 @@ class H2ClientProtocol(Protocol, TimeoutMixin): def _lose_connection_with_error(self, errors: list[BaseException]) -> None: """Helper function to lose the connection with the error sent as a reason""" - self._conn_lost_errors += errors + self._conn_lost_errors.extend(errors) assert self.transport is not None # typing self.transport.loseConnection() @@ -310,7 +310,7 @@ class H2ClientProtocol(Protocol, TimeoutMixin): if isinstance(e, FrameTooLargeError): # hyper-h2 does not drop the connection in this scenario, we # need to abort the connection manually. - self._conn_lost_errors += [e] + self._conn_lost_errors.append(e) assert self.transport is not None # typing self.transport.abortConnection() return diff --git a/scrapy/core/http2/stream.py b/scrapy/core/http2/stream.py index 39c2eaae8..21ce4942e 100644 --- a/scrapy/core/http2/stream.py +++ b/scrapy/core/http2/stream.py @@ -22,6 +22,8 @@ from scrapy.utils._download_handlers import ( from scrapy.utils.httpobj import urlparse_cached if TYPE_CHECKING: + from collections.abc import Sequence + from hpack import HeaderTuple from scrapy.core.http2.protocol import H2ClientProtocol @@ -150,7 +152,7 @@ class Stream: # flow control window "flow_controlled_size": 0, # Headers received after sending the request - "headers": Headers({}), + "headers": Headers(), } def _cancel(_: Any) -> None: @@ -391,7 +393,7 @@ class Stream: def close( self, reason: StreamCloseReason, - errors: list[BaseException] | None = None, + errors: Sequence[BaseException] | None = None, from_protocol: bool = False, ) -> None: """Based on the reason sent we will handle each case.""" @@ -405,7 +407,7 @@ class Stream: # Have default value of errors as an empty list as # some cases can add a list of exceptions - errors = errors or [] + errors = errors or () if not from_protocol: self._protocol.pop_stream(self.stream_id) @@ -470,7 +472,7 @@ class Stream: self._deferred_response.errback(ResponseFailed(errors)) elif reason is StreamCloseReason.INACTIVE: - errors.insert(0, InactiveStreamClosed(self._request)) + errors = (InactiveStreamClosed(self._request), *errors) self._deferred_response.errback(ResponseFailed(errors)) else: diff --git a/scrapy/core/scheduler.py b/scrapy/core/scheduler.py index 5a0aa2197..1b7fb652d 100644 --- a/scrapy/core/scheduler.py +++ b/scrapy/core/scheduler.py @@ -461,9 +461,10 @@ class Scheduler(BaseScheduler): except TypeError: # pragma: no cover warn( f"The __init__ method of {global_object_name(self.pqclass)} " - f"does not support a `start_queue_cls` keyword-only " - f"parameter.", + "does not support a `start_queue_cls` keyword-only " + "parameter.", ScrapyDeprecationWarning, + stacklevel=2, ) return build_from_crawler( self.pqclass, @@ -490,9 +491,10 @@ class Scheduler(BaseScheduler): except TypeError: # pragma: no cover warn( f"The __init__ method of {global_object_name(self.pqclass)} " - f"does not support a `start_queue_cls` keyword-only " - f"parameter.", + "does not support a `start_queue_cls` keyword-only " + "parameter.", ScrapyDeprecationWarning, + stacklevel=2, ) q = build_from_crawler( self.pqclass, @@ -521,7 +523,7 @@ class Scheduler(BaseScheduler): def _read_dqs_state(self, dqdir: str) -> Any: path = Path(dqdir, "active.json") if not path.exists(): - return [] + return () with path.open(encoding="utf-8") as f: return json.load(f) diff --git a/scrapy/core/spidermw.py b/scrapy/core/spidermw.py index 007d03a04..39342ebc0 100644 --- a/scrapy/core/spidermw.py +++ b/scrapy/core/spidermw.py @@ -114,6 +114,7 @@ class SpiderMiddlewareManager(MiddlewareManager): f"Scrapy 2.13 for details: " f"https://docs.scrapy.org/en/2.13/news.html", ScrapyDeprecationWarning, + stacklevel=2, ) def _add_middleware(self, mw: Any) -> None: @@ -502,6 +503,7 @@ class SpiderMiddlewareManager(MiddlewareManager): f"copy-pasting. See the release notes of Scrapy 2.13 for " f"details: https://docs.scrapy.org/en/2.13/news.html", ScrapyDeprecationWarning, + stacklevel=2, ) if ( diff --git a/scrapy/crawler.py b/scrapy/crawler.py index d622e407c..500139566 100644 --- a/scrapy/crawler.py +++ b/scrapy/crawler.py @@ -663,7 +663,7 @@ class CrawlerProcessBase(CrawlerRunnerBase): resolver_class = load_object(self.settings["DNS_RESOLVER"]) # We pass self, which is CrawlerProcess, instead of Crawler here, # which works because the default resolvers only use crawler.settings. - resolver = build_from_crawler(resolver_class, self, reactor=reactor) # type: ignore[arg-type] + resolver = build_from_crawler(resolver_class, self, reactor=reactor) # type: ignore[call-overload] resolver.install_on_reactor() tp = reactor.getThreadPool() tp.adjustPoolsize(maxthreads=self.settings.getint("REACTOR_THREADPOOL_MAXSIZE")) diff --git a/scrapy/downloadermiddlewares/cookies.py b/scrapy/downloadermiddlewares/cookies.py index d945546d5..cd8c2abca 100644 --- a/scrapy/downloadermiddlewares/cookies.py +++ b/scrapy/downloadermiddlewares/cookies.py @@ -139,7 +139,7 @@ class CookiesMiddleware: for key in ("name", "value", "path", "domain"): value = cookie.get(key) if value is None: - if key in ("name", "value"): + if key in {"name", "value"}: msg = f"Invalid cookie found in request {request}: {cookie} ('{key}' is missing)" logger.warning(msg) return None @@ -176,7 +176,7 @@ class CookiesMiddleware: Extract cookies from the Request.cookies attribute """ if not request.cookies: - return [] + return () cookies: Iterable[VerboseCookie] if isinstance(request.cookies, dict): cookies = tuple({"name": k, "value": v} for k, v in request.cookies.items()) diff --git a/scrapy/downloadermiddlewares/httpcache.py b/scrapy/downloadermiddlewares/httpcache.py index a176dcd9f..c6c811809 100644 --- a/scrapy/downloadermiddlewares/httpcache.py +++ b/scrapy/downloadermiddlewares/httpcache.py @@ -107,7 +107,7 @@ class HttpCacheMiddleware: return response # Skip cached responses and uncacheable requests - if "cached" in response.flags or "_dont_cache" in request.meta: + if "_dont_cache" in request.meta or "cached" in response.flags: request.meta.pop("_dont_cache", None) return response diff --git a/scrapy/downloadermiddlewares/httpcompression.py b/scrapy/downloadermiddlewares/httpcompression.py index d4fa2d4d7..414c3d8a3 100644 --- a/scrapy/downloadermiddlewares/httpcompression.py +++ b/scrapy/downloadermiddlewares/httpcompression.py @@ -40,12 +40,13 @@ except ImportError: pass else: try: - brotli.Decompressor.can_accept_more_data + brotli.Decompressor.can_accept_more_data # noqa: B018 except AttributeError: # pragma: no cover warnings.warn( "You have brotli installed. But 'br' encoding support now requires " "brotli's or brotlicffi's version >= 1.2.0. Please upgrade " "brotli/brotlicffi to make Scrapy decode 'br' encoded responses.", + stacklevel=2, ) else: ACCEPTED_ENCODINGS.append(b"br") diff --git a/scrapy/downloadermiddlewares/httpproxy.py b/scrapy/downloadermiddlewares/httpproxy.py index d3d46a947..e034dced1 100644 --- a/scrapy/downloadermiddlewares/httpproxy.py +++ b/scrapy/downloadermiddlewares/httpproxy.py @@ -69,7 +69,7 @@ class HttpProxyMiddleware: _scheme = parsed.scheme if ( # 'no_proxy' is only supported by http schemes - _scheme not in ("http", "https") + _scheme not in {"http", "https"} or (parsed.hostname and not proxy_bypass(parsed.hostname)) ) and _scheme in self.proxies: scheme = _scheme diff --git a/scrapy/downloadermiddlewares/offsite.py b/scrapy/downloadermiddlewares/offsite.py index 33d7ba609..10f19bacc 100644 --- a/scrapy/downloadermiddlewares/offsite.py +++ b/scrapy/downloadermiddlewares/offsite.py @@ -86,13 +86,13 @@ class OffsiteMiddleware: "allowed_domains accepts only domains, not URLs. " f"Ignoring URL entry {domain} in allowed_domains." ) - warnings.warn(message) + warnings.warn(message, stacklevel=2) elif port_pattern.search(domain): message = ( "allowed_domains accepts only domains without ports. " f"Ignoring entry {domain} in allowed_domains." ) - warnings.warn(message) + warnings.warn(message, stacklevel=2) else: domains.append(re.escape(domain)) regex = rf"^(.*\.)?({'|'.join(domains)})$" diff --git a/scrapy/downloadermiddlewares/redirect.py b/scrapy/downloadermiddlewares/redirect.py index 69c58ca49..821f41699 100644 --- a/scrapy/downloadermiddlewares/redirect.py +++ b/scrapy/downloadermiddlewares/redirect.py @@ -71,7 +71,7 @@ class BaseRedirectMiddleware: return redirect_cls = global_object_name(self.__class__) referer_cls = global_object_name(RefererMiddleware) - if self.__class__ in (RedirectMiddleware, MetaRefreshMiddleware): + if self.__class__ in {RedirectMiddleware, MetaRefreshMiddleware}: replacement = ( f"replace {redirect_cls} with a subclass that overrides the " f"handle_referer() method" @@ -208,14 +208,19 @@ class RedirectMiddleware(BaseRedirectMiddleware): if ( request.meta.get("dont_redirect", False) or response.status - in getattr(self.crawler.spider, "handle_httpstatus_list", []) - or response.status in request.meta.get("handle_httpstatus_list", []) + in getattr(self.crawler.spider, "handle_httpstatus_list", ()) + or response.status in request.meta.get("handle_httpstatus_list", ()) or request.meta.get("handle_httpstatus_all", False) ): return response - allowed_status = (301, 302, 303, 307, 308) - if "Location" not in response.headers or response.status not in allowed_status: + if "Location" not in response.headers or response.status not in { + 301, + 302, + 303, + 307, + 308, + }: return response assert response.headers["Location"] is not None @@ -235,8 +240,8 @@ class RedirectMiddleware(BaseRedirectMiddleware): if urlparse_cached(redirected).scheme not in {"http", "https"}: return response - if (response.status in (301, 302) and request.method == "POST") or ( - response.status == 303 and request.method not in ("GET", "HEAD") + if (response.status in {301, 302} and request.method == "POST") or ( + response.status == 303 and request.method not in {"GET", "HEAD"} ): redirected = self._redirect_request_using_get( request, response, redirected_url diff --git a/scrapy/extensions/feedexport.py b/scrapy/extensions/feedexport.py index fa928678e..ed4f60785 100644 --- a/scrapy/extensions/feedexport.py +++ b/scrapy/extensions/feedexport.py @@ -200,7 +200,7 @@ class S3FeedStorage(BlockingFeedStorage): try: import boto3.session # noqa: PLC0415 except ImportError: - raise NotConfigured("missing boto3 library") + raise NotConfigured("missing boto3 library") from None u = urlparse(uri) assert u.hostname self.bucketname: str = u.hostname @@ -250,10 +250,19 @@ class S3FeedStorage(BlockingFeedStorage): def _store_in_thread(self, file: IO[bytes]) -> None: file.seek(0) - kwargs: dict[str, Any] = {"ExtraArgs": {"ACL": self.acl}} if self.acl else {} - self.s3_client.upload_fileobj( - Bucket=self.bucketname, Key=self.keyname, Fileobj=file, **kwargs - ) + if self.acl: + self.s3_client.upload_fileobj( + Bucket=self.bucketname, + Key=self.keyname, + Fileobj=file, + ExtraArgs={"ACL": self.acl}, + ) + else: + self.s3_client.upload_fileobj( + Bucket=self.bucketname, + Key=self.keyname, + Fileobj=file, + ) file.close() @@ -468,9 +477,13 @@ class FeedExporter: # End: Backward compatibility for FEED_URI and FEED_FORMAT settings # 'FEEDS' setting takes precedence over 'FEED_URI' - for uri, feed_options in self.settings.getdict("FEEDS").items(): + for settings_uri, feed_options in self.settings.getdict("FEEDS").items(): # handle pathlib.Path objects - uri = str(uri) if not isinstance(uri, Path) else uri.absolute().as_uri() + uri = ( + str(settings_uri) + if not isinstance(settings_uri, Path) + else settings_uri.absolute().as_uri() + ) self.feeds[uri] = feed_complete_default_values_from_settings( feed_options, self.settings ) @@ -692,9 +705,7 @@ class FeedExporter: uri_params_function: str | UriParamsCallableT | None, slot: FeedSlot | None = None, ) -> dict[str, Any]: - params = {} - for k in dir(spider): - params[k] = getattr(spider, k) + params = {k: getattr(spider, k) for k in dir(spider)} utc_now = datetime.now(tz=timezone.utc) params["time"] = utc_now.replace(microsecond=0).isoformat().replace(":", "-") params["batch_time"] = utc_now.isoformat().replace(":", "-") diff --git a/scrapy/extensions/httpcache.py b/scrapy/extensions/httpcache.py index efda50a82..86b066a38 100644 --- a/scrapy/extensions/httpcache.py +++ b/scrapy/extensions/httpcache.py @@ -106,10 +106,10 @@ class RFC2616Policy: if b"max-age" in cc or b"Expires" in response.headers: return True # Firefox fallbacks this statuses to one year expiration if none is set - if response.status in (300, 301, 308): + if response.status in {300, 301, 308}: return True # Other statuses without expiration requires at least one validator - if response.status in (200, 203, 401): + if response.status in {200, 203, 401}: return b"Last-Modified" in response.headers or b"ETag" in response.headers # Any other is probably not eligible for caching # Makes no sense to cache responses that does not contain expiration @@ -216,7 +216,7 @@ class RFC2616Policy: return (date - lastmodified) / 10 # This request can be cached indefinitely - if response.status in (300, 301, 308): + if response.status in {300, 301, 308}: return self.MAXAGE # Insufficient information to compute freshness lifetime diff --git a/scrapy/extensions/memusage.py b/scrapy/extensions/memusage.py index f045e9b14..01af02ba3 100644 --- a/scrapy/extensions/memusage.py +++ b/scrapy/extensions/memusage.py @@ -39,8 +39,8 @@ class MemoryUsage: try: # stdlib's resource module is only available on unix platforms. self.resource = import_module("resource") - except ImportError: - raise NotConfigured + except ImportError as exc: + raise NotConfigured from exc self.crawler: Crawler = crawler self.warned: bool = False diff --git a/scrapy/extensions/periodic_log.py b/scrapy/extensions/periodic_log.py index 860b97a55..cd35c8165 100644 --- a/scrapy/extensions/periodic_log.py +++ b/scrapy/extensions/periodic_log.py @@ -10,6 +10,7 @@ from scrapy.utils.asyncio import AsyncioLoopingCall, create_looping_call from scrapy.utils.serialize import ScrapyJSONEncoder if TYPE_CHECKING: + from collections.abc import Sequence from json import JSONEncoder from twisted.internet.task import LoopingCall @@ -31,8 +32,8 @@ class PeriodicLog: self, stats: StatsCollector, interval: float = 60.0, - ext_stats: dict[str, Any] = {}, - ext_delta: dict[str, Any] = {}, + ext_stats: dict[str, Any] | None = None, + ext_delta: dict[str, Any] | None = None, ext_timing_enabled: bool = False, ): self.stats: StatsCollector = stats @@ -41,11 +42,19 @@ class PeriodicLog: self.task: AsyncioLoopingCall | LoopingCall | None = None self.encoder: JSONEncoder = ScrapyJSONEncoder(sort_keys=True, indent=4) self.ext_stats_enabled: bool = bool(ext_stats) - self.ext_stats_include: list[str] = ext_stats.get("include", []) - self.ext_stats_exclude: list[str] = ext_stats.get("exclude", []) + self.ext_stats_include: Sequence[str] = ( + ext_stats.get("include", ()) if ext_stats else () + ) + self.ext_stats_exclude: Sequence[str] = ( + ext_stats.get("exclude", ()) if ext_stats else () + ) self.ext_delta_enabled: bool = bool(ext_delta) - self.ext_delta_include: list[str] = ext_delta.get("include", []) - self.ext_delta_exclude: list[str] = ext_delta.get("exclude", []) + self.ext_delta_include: Sequence[str] = ( + ext_delta.get("include", ()) if ext_delta else () + ) + self.ext_delta_exclude: Sequence[str] = ( + ext_delta.get("exclude", ()) if ext_delta else () + ) self.ext_timing_enabled: bool = ext_timing_enabled @classmethod @@ -143,7 +152,7 @@ class PeriodicLog: return {"stats": stats} def param_allowed( - self, stat_name: str, include: list[str], exclude: list[str] + self, stat_name: str, include: Sequence[str], exclude: Sequence[str] ) -> bool: if not include and not exclude: return True diff --git a/scrapy/extensions/statsmailer.py b/scrapy/extensions/statsmailer.py index 47eb4bf93..f05595806 100644 --- a/scrapy/extensions/statsmailer.py +++ b/scrapy/extensions/statsmailer.py @@ -25,6 +25,7 @@ if TYPE_CHECKING: warnings.warn( "The scrapy.extensions.statsmailer module is deprecated and will be " "removed in a future release.", + stacklevel=2, category=ScrapyDeprecationWarning, ) diff --git a/scrapy/http/cookies.py b/scrapy/http/cookies.py index 13d1c85d0..09286606d 100644 --- a/scrapy/http/cookies.py +++ b/scrapy/http/cookies.py @@ -54,14 +54,14 @@ class CookieJar: if not IPV4_RE.search(req_host): hosts = potential_domain_matches(req_host) if "." not in req_host: - hosts += [req_host + ".local"] + hosts.append(req_host + ".local") else: hosts = [req_host] cookies = [] for host in hosts: if host in self.jar._cookies: # type: ignore[attr-defined] - cookies += self.jar._cookies_for_domain(host, wreq) # type: ignore[attr-defined] + cookies.extend(self.jar._cookies_for_domain(host, wreq)) # type: ignore[attr-defined] attrs = self.jar._cookie_attrs(cookies) # type: ignore[attr-defined] if attrs and not wreq.has_header("Cookie"): diff --git a/scrapy/http/request/form.py b/scrapy/http/request/form.py index d2d13b8df..affa14499 100644 --- a/scrapy/http/request/form.py +++ b/scrapy/http/request/form.py @@ -8,7 +8,7 @@ See documentation in docs/topics/request-response.rst from __future__ import annotations from collections.abc import Iterable -from typing import TYPE_CHECKING, Any, TypeAlias, cast +from typing import TYPE_CHECKING, Any, ClassVar, TypeAlias, cast from urllib.parse import urlencode, urljoin, urlsplit, urlunsplit from parsel.csstranslator import HTMLTranslator @@ -39,7 +39,7 @@ FormdataType: TypeAlias = dict[str, FormdataVType] | list[FormdataKVType] | None class FormRequest(Request): __slots__ = () - valid_form_methods = ["GET", "POST"] + valid_form_methods: ClassVar[list[str]] = ["GET", "POST"] def __init__( self, *args: Any, formdata: FormdataType = None, **kwargs: Any @@ -153,7 +153,7 @@ def _get_form( try: form = forms[formnumber] except IndexError: - raise IndexError(f"Form number {formnumber} not found in {response}") + raise IndexError(f"Form number {formnumber} not found in {response}") from None return cast("FormElement", form) @@ -167,7 +167,7 @@ def _get_inputs( try: formdata_keys = dict(formdata or ()).keys() except (ValueError, TypeError): - raise ValueError("formdata should be a dict or iterable of tuples") + raise ValueError("formdata should be a dict or iterable of tuples") from None if not formdata: formdata = [] diff --git a/scrapy/http/request/json_request.py b/scrapy/http/request/json_request.py index 1776bdca8..13fee06d8 100644 --- a/scrapy/http/request/json_request.py +++ b/scrapy/http/request/json_request.py @@ -36,7 +36,9 @@ class JsonRequest(Request): data_passed: bool = data is not None if body_passed and data_passed: - warnings.warn("Both body and data passed. data will be ignored") + warnings.warn( + "Both body and data passed. data will be ignored", stacklevel=2 + ) elif not body_passed and data_passed: kwargs["body"] = self._dumps(data) if "method" not in kwargs: @@ -68,7 +70,9 @@ class JsonRequest(Request): data_passed: bool = data is not None if body_passed and data_passed: - warnings.warn("Both body and data passed. data will be ignored") + warnings.warn( + "Both body and data passed. data will be ignored", stacklevel=2 + ) elif not body_passed and data_passed: kwargs["body"] = self._dumps(data) diff --git a/scrapy/http/response/__init__.py b/scrapy/http/response/__init__.py index 2cc35fef4..7e23df491 100644 --- a/scrapy/http/response/__init__.py +++ b/scrapy/http/response/__init__.py @@ -86,7 +86,7 @@ class Response(object_ref): raise AttributeError( "Response.cb_kwargs not available, this response " "is not tied to any request" - ) + ) from None @property def meta(self) -> dict[str, Any]: @@ -95,7 +95,7 @@ class Response(object_ref): except AttributeError: raise AttributeError( "Response.meta not available, this response is not tied to any request" - ) + ) from None @property def url(self) -> str: diff --git a/scrapy/http/response/text.py b/scrapy/http/response/text.py index 012ead519..077b86a33 100644 --- a/scrapy/http/response/text.py +++ b/scrapy/http/response/text.py @@ -297,7 +297,7 @@ def _url_from_selector(sel: parsel.Selector) -> str: return strip_html5_whitespace(sel.root) if not hasattr(sel.root, "tag"): raise _InvalidSelector(f"Unsupported selector: {sel}") - if sel.root.tag not in ("a", "link"): + if sel.root.tag not in {"a", "link"}: raise _InvalidSelector( f"Only and elements are supported; got <{sel.root.tag}>" ) diff --git a/scrapy/linkextractors/lxmlhtml.py b/scrapy/linkextractors/lxmlhtml.py index 96a0f523b..11fd62fb2 100644 --- a/scrapy/linkextractors/lxmlhtml.py +++ b/scrapy/linkextractors/lxmlhtml.py @@ -114,8 +114,8 @@ class LxmlParserLinkExtractor: # pseudo lxml.html.HtmlElement.make_links_absolute(base_url) try: if self.strip: - attr_val = strip_html5_whitespace(attr_val) - attr_val = urljoin(base_url, attr_val) + attr_val = strip_html5_whitespace(attr_val) # noqa: PLW2901 this is intended + attr_val = urljoin(base_url, attr_val) # noqa: PLW2901 except ValueError: continue # skipping bogus links else: @@ -245,7 +245,7 @@ class LxmlLinkExtractor: if self.allow_res else [True] ) - denied = (regex.search(url) for regex in self.deny_res) if self.deny_res else [] + denied = (regex.search(url) for regex in self.deny_res) if self.deny_res else () return any(allowed) and not any(denied) def _process_links(self, links: list[Link]) -> list[Link]: diff --git a/scrapy/mail.py b/scrapy/mail.py index 718548b03..fbd11ad1d 100644 --- a/scrapy/mail.py +++ b/scrapy/mail.py @@ -55,6 +55,7 @@ def _to_bytes_or_none(text: str | bytes | None) -> bytes | None: warnings.warn( "The scrapy.mail module is deprecated and will be removed in a future release. " "Please use a dedicated Python mail library instead.", + stacklevel=2, category=ScrapyDeprecationWarning, ) diff --git a/scrapy/pipelines/files.py b/scrapy/pipelines/files.py index 707207337..4d06b6dd3 100644 --- a/scrapy/pipelines/files.py +++ b/scrapy/pipelines/files.py @@ -18,7 +18,7 @@ from contextlib import suppress from ftplib import FTP from io import BytesIO from pathlib import Path -from typing import IO, TYPE_CHECKING, Any, NoReturn, Protocol, TypedDict, cast +from typing import IO, TYPE_CHECKING, Any, ClassVar, NoReturn, Protocol, TypedDict, cast from urllib.parse import urlparse from itemadapter import ItemAdapter @@ -161,7 +161,7 @@ class S3FilesStore: AWS_VERIFY = None POLICY = "private" # Overridden from settings.FILES_STORE_S3_ACL in FilesPipeline.from_crawler() - HEADERS = { + HEADERS: ClassVar[dict[str, str]] = { "Cache-Control": "max-age=172800", } @@ -226,7 +226,7 @@ class S3FilesStore: Bucket=self.bucket, Key=key_name, Body=buf, - Metadata={k: str(v) for k, v in (meta or {}).items()}, + Metadata={k: str(v) for k, v in meta.items()} if meta else {}, ACL=self.POLICY, **extra, ) @@ -269,7 +269,9 @@ class S3FilesStore: try: kwarg = mapping[key] except KeyError: - raise TypeError(f'Header "{key}" is not supported by botocore') + raise TypeError( + f'Header "{key}" is not supported by botocore' + ) from None extra[kwarg] = value return extra @@ -339,7 +341,7 @@ class GCSFilesStore: blob_path = self._get_blob_path(path) blob = self.bucket.blob(blob_path) blob.cache_control = self.CACHE_CONTROL - blob.metadata = {k: str(v) for k, v in (meta or {}).items()} + blob.metadata = {k: str(v) for k, v in meta.items()} if meta else {} return deferred_from_coro( run_in_thread( blob.upload_from_string, @@ -435,7 +437,7 @@ class FilesPipeline(MediaPipeline): MEDIA_NAME: str = "file" EXPIRES: int = 90 - STORE_SCHEMES: dict[str, type[FilesStoreProtocol]] = { + STORE_SCHEMES: ClassVar[dict[str, type[FilesStoreProtocol]]] = { "": FSFilesStore, "file": FSFilesStore, "s3": S3FilesStore, @@ -656,7 +658,7 @@ class FilesPipeline(MediaPipeline): exc_info=True, extra={"spider": info.spider}, ) - raise FileException(str(exc)) + raise FileException(str(exc)) from exc return { "url": request.url, diff --git a/scrapy/pipelines/images.py b/scrapy/pipelines/images.py index e92f33cca..83d04e6ca 100644 --- a/scrapy/pipelines/images.py +++ b/scrapy/pipelines/images.py @@ -11,7 +11,7 @@ import hashlib import warnings from contextlib import suppress from io import BytesIO -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, ClassVar from itemadapter import ItemAdapter @@ -49,7 +49,7 @@ class ImagesPipeline(FilesPipeline): MIN_WIDTH: int = 0 MIN_HEIGHT: int = 0 EXPIRES: int = 90 - THUMBS: dict[str, tuple[int, int]] = {} + THUMBS: ClassVar[dict[str, tuple[int, int]]] = {} DEFAULT_IMAGES_URLS_FIELD = "image_urls" DEFAULT_IMAGES_RESULT_FIELD = "images" @@ -76,7 +76,7 @@ class ImagesPipeline(FilesPipeline): except ImportError: raise NotConfigured( "ImagesPipeline requires installing Pillow 8.3.2 or later" - ) + ) from None super().__init__(store_uri, crawler=crawler) @@ -191,7 +191,7 @@ class ImagesPipeline(FilesPipeline): *, response_body: BytesIO, ) -> tuple[Image.Image, BytesIO]: - if image.format in ("PNG", "WEBP") and image.mode == "RGBA": + if image.format in {"PNG", "WEBP"} and image.mode == "RGBA": background = self._Image.new("RGBA", image.size, (255, 255, 255)) background.paste(image, image) image = background.convert("RGB") diff --git a/scrapy/pipelines/media.py b/scrapy/pipelines/media.py index 1fe14c9b0..1043da332 100644 --- a/scrapy/pipelines/media.py +++ b/scrapy/pipelines/media.py @@ -230,9 +230,9 @@ class MediaPipeline(ABC): if isinstance(result, Failure): # minimize cached information for failure result.cleanFailure() - result.frames = [] + result.frames.clear() if TWISTED_FAILURE_HAS_STACK: - result.stack = [] # type: ignore[method-assign] + result.stack.clear() # This code fixes a memory leak by avoiding to keep references to # the Request and Response objects on the Media Pipeline cache. # diff --git a/scrapy/pqueues.py b/scrapy/pqueues.py index ed57091ba..ad0b36f6b 100644 --- a/scrapy/pqueues.py +++ b/scrapy/pqueues.py @@ -350,8 +350,9 @@ class DownloaderAwarePriorityQueue: self.crawler: Crawler = crawler self.pqueues: dict[str, ScrapyPriorityQueue] = {} # slot -> priority queue - for slot, startprios in (slot_startprios or {}).items(): - self.pqueues[slot] = self.pqfactory(slot, startprios) + if slot_startprios: + for slot, startprios in slot_startprios.items(): + self.pqueues[slot] = self.pqfactory(slot, startprios) def pqfactory( self, slot: str, startprios: Iterable[int] = () diff --git a/scrapy/responsetypes.py b/scrapy/responsetypes.py index 3aaf17b53..cd62f02af 100644 --- a/scrapy/responsetypes.py +++ b/scrapy/responsetypes.py @@ -8,7 +8,7 @@ from __future__ import annotations from io import StringIO from mimetypes import MimeTypes from pkgutil import get_data -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, ClassVar from scrapy.http import Response from scrapy.utils.misc import load_object @@ -19,7 +19,7 @@ if TYPE_CHECKING: class ResponseTypes: - CLASSES = { + CLASSES: ClassVar[dict[str, str]] = { "text/html": "scrapy.http.HtmlResponse", "application/atom+xml": "scrapy.http.XmlResponse", "application/rdf+xml": "scrapy.http.XmlResponse", diff --git a/scrapy/settings/__init__.py b/scrapy/settings/__init__.py index 598a746a9..d5c4b2dd3 100644 --- a/scrapy/settings/__init__.py +++ b/scrapy/settings/__init__.py @@ -184,15 +184,15 @@ class BaseSettings(MutableMapping[_SettingsKey, Any]): try: return bool(int(got)) except ValueError: - if got in ("True", "true"): + if got in {"True", "true"}: return True - if got in ("False", "false"): + if got in {"False", "false"}: return False raise ValueError( "Supported values for boolean settings " "are 0/1, True/False, '0'/'1', " "'True'/'False' and 'true'/'false'" - ) + ) from None def getint(self, name: _SettingsKey, default: int = 0) -> int: """ diff --git a/scrapy/spiderloader.py b/scrapy/spiderloader.py index c37b2e759..8c980fd46 100644 --- a/scrapy/spiderloader.py +++ b/scrapy/spiderloader.py @@ -77,6 +77,7 @@ class SpiderLoader: warnings.warn( "There are several spiders with the same name:\n\n" f"{dupes_string}\n\n This can cause unexpected behavior.", + stacklevel=2, category=UserWarning, ) @@ -96,6 +97,7 @@ class SpiderLoader: f"\n{traceback.format_exc()}Could not load spiders " f"from module '{name}'. " "See above traceback for details.", + stacklevel=2, category=RuntimeWarning, ) else: @@ -114,7 +116,7 @@ class SpiderLoader: try: return self._spiders[spider_name] except KeyError: - raise KeyError(f"Spider not found: {spider_name}") + raise KeyError(f"Spider not found: {spider_name}") from None def find_by_request(self, request: Request) -> list[str]: """ diff --git a/scrapy/spidermiddlewares/httperror.py b/scrapy/spidermiddlewares/httperror.py index c160d1adb..94b6dfbb5 100644 --- a/scrapy/spidermiddlewares/httperror.py +++ b/scrapy/spidermiddlewares/httperror.py @@ -88,5 +88,5 @@ class HttpErrorMiddleware: {"response": response}, extra={"spider": self.crawler.spider}, ) - return [] + return () return None diff --git a/scrapy/spidermiddlewares/referer.py b/scrapy/spidermiddlewares/referer.py index c1c9044a5..6c5acf0de 100644 --- a/scrapy/spidermiddlewares/referer.py +++ b/scrapy/spidermiddlewares/referer.py @@ -103,7 +103,7 @@ class ReferrerPolicy(ABC): return self.tls_protected(url) def tls_protected(self, url: str) -> bool: - return urlparse(url).scheme in ("https", "ftps") + return urlparse(url).scheme in {"https", "ftps"} class NoReferrerPolicy(ReferrerPolicy): @@ -424,7 +424,7 @@ class RefererMiddleware(BaseSpiderMiddleware): msg += " (import paths from the response Referrer-Policy header are not allowed)" if not warning_only: raise RuntimeError(msg) - warnings.warn(msg, RuntimeWarning) + warnings.warn(msg, RuntimeWarning, stacklevel=2) return None def get_processed_request( diff --git a/scrapy/spiders/crawl.py b/scrapy/spiders/crawl.py index 89421cbe4..f0d093c6e 100644 --- a/scrapy/spiders/crawl.py +++ b/scrapy/spiders/crawl.py @@ -102,11 +102,12 @@ class CrawlSpider(Spider): self._compile_rules() if method_is_overridden(self.__class__, CrawlSpider, "_parse_response"): warnings.warn( - f"The CrawlSpider._parse_response method, which the " + "The CrawlSpider._parse_response method, which the " f"{global_object_name(self.__class__)} class overrides, is " - f"deprecated: it will be removed in future Scrapy releases. " - f"Please override the CrawlSpider.parse_with_rules method " - f"instead." + "deprecated: it will be removed in future Scrapy releases. " + "Please override the CrawlSpider.parse_with_rules method " + "instead.", + stacklevel=2, ) def _parse(self, response: Response, **kwargs: Any) -> Any: @@ -118,7 +119,7 @@ class CrawlSpider(Spider): ) def parse_start_url(self, response: Response, **kwargs: Any) -> Any: - return [] + return () def process_results( self, response: Response, results: Iterable[Any] @@ -209,8 +210,9 @@ class CrawlSpider(Spider): def _compile_rules(self) -> None: self._rules = [] for rule in self.rules: - self._rules.append(copy.copy(rule)) - self._rules[-1]._compile(self) + copied_rule = copy.copy(rule) + copied_rule._compile(self) + self._rules.append(copied_rule) @classmethod def from_crawler(cls, crawler: Crawler, *args: Any, **kwargs: Any) -> Self: diff --git a/scrapy/spiders/sitemap.py b/scrapy/spiders/sitemap.py index 2813a32a0..cb5779d74 100644 --- a/scrapy/spiders/sitemap.py +++ b/scrapy/spiders/sitemap.py @@ -49,7 +49,7 @@ class SitemapSpider(Spider): self._cbs: list[tuple[re.Pattern[str], CallbackT]] = [] for r, c in self.sitemap_rules: if isinstance(c, str): - c = cast("CallbackT", getattr(self, c)) + c = cast("CallbackT", getattr(self, c)) # noqa: PLW2901 self._cbs.append((regex(r), c)) self._follow: list[re.Pattern[str]] = [regex(x) for x in self.sitemap_follow] diff --git a/scrapy/utils/console.py b/scrapy/utils/console.py index a3df08cd9..93fbb9033 100644 --- a/scrapy/utils/console.py +++ b/scrapy/utils/console.py @@ -13,7 +13,7 @@ KnownShellsT = dict[str, Callable[..., EmbedFuncT]] def _embed_ipython_shell( - namespace: dict[str, Any] = {}, banner: str = "" + namespace: dict[str, Any] | None = None, banner: str = "" ) -> EmbedFuncT: """Start an IPython Shell""" try: @@ -28,7 +28,7 @@ def _embed_ipython_shell( ) @wraps(_embed_ipython_shell) - def wrapper(namespace: dict[str, Any] = namespace, banner: str = "") -> None: + def wrapper(namespace: dict[str, Any] = namespace or {}, banner: str = "") -> None: config = load_default_config() # type: ignore[no-untyped-call] # Always use .instance() to ensure _instance propagation to all parents # this is needed for completion works well for new imports @@ -44,26 +44,26 @@ def _embed_ipython_shell( def _embed_bpython_shell( - namespace: dict[str, Any] = {}, banner: str = "" + namespace: dict[str, Any] | None = None, banner: str = "" ) -> EmbedFuncT: """Start a bpython shell""" import bpython # noqa: PLC0415 @wraps(_embed_bpython_shell) - def wrapper(namespace: dict[str, Any] = namespace, banner: str = "") -> None: + def wrapper(namespace: dict[str, Any] = namespace or {}, banner: str = "") -> None: bpython.embed(locals_=namespace, banner=banner) return wrapper def _embed_ptpython_shell( - namespace: dict[str, Any] = {}, banner: str = "" + namespace: dict[str, Any] | None = None, banner: str = "" ) -> EmbedFuncT: """Start a ptpython shell""" import ptpython.repl # noqa: PLC0415 # pylint: disable=import-error @wraps(_embed_ptpython_shell) - def wrapper(namespace: dict[str, Any] = namespace, banner: str = "") -> None: + def wrapper(namespace: dict[str, Any] = namespace or {}, banner: str = "") -> None: print(banner) ptpython.repl.embed(locals=namespace) @@ -71,7 +71,7 @@ def _embed_ptpython_shell( def _embed_standard_shell( - namespace: dict[str, Any] = {}, banner: str = "" + namespace: dict[str, Any] | None = None, banner: str = "" ) -> EmbedFuncT: """Start a standard python shell""" try: # readline module is only available on unix systems @@ -84,7 +84,7 @@ def _embed_standard_shell( readline.parse_and_bind("tab:complete") # type: ignore[attr-defined,unused-ignore] @wraps(_embed_standard_shell) - def wrapper(namespace: dict[str, Any] = namespace, banner: str = "") -> None: + def wrapper(namespace: dict[str, Any] = namespace or {}, banner: str = "") -> None: code.interact(banner=banner, local=namespace) return wrapper diff --git a/scrapy/utils/curl.py b/scrapy/utils/curl.py index a40ee8997..646335fb1 100644 --- a/scrapy/utils/curl.py +++ b/scrapy/utils/curl.py @@ -106,7 +106,7 @@ def curl_to_request_kwargs( if argv: msg = f"Unrecognized options: {', '.join(argv)}" if ignore_unknown_options: - warnings.warn(msg) + warnings.warn(msg, stacklevel=2) else: raise ValueError(msg) diff --git a/scrapy/utils/defer.py b/scrapy/utils/defer.py index 1acb576b4..7d7636fe6 100644 --- a/scrapy/utils/defer.py +++ b/scrapy/utils/defer.py @@ -8,6 +8,7 @@ import asyncio import inspect import warnings from asyncio import Future +from collections import deque from collections.abc import Awaitable, Coroutine, Iterable, Iterator from functools import wraps from typing import ( @@ -230,7 +231,7 @@ class _AsyncCooperatorAdapter(Iterator, Generic[_T]): self.callable_args: tuple[Any, ...] = callable_args self.callable_kwargs: dict[str, Any] = callable_kwargs self.finished: bool = False - self.waiting_deferreds: list[Deferred[Any]] = [] + self.waiting_deferreds: deque[Deferred[Any]] = deque() self.anext_deferred: Deferred[_T] | None = None def _callback(self, result: _T) -> None: @@ -241,7 +242,7 @@ class _AsyncCooperatorAdapter(Iterator, Generic[_T]): callable_result = self.callable( result, *self.callable_args, **self.callable_kwargs ) - d = self.waiting_deferreds.pop(0) + d = self.waiting_deferreds.popleft() if isinstance(callable_result, Deferred): callable_result.chainDeferred(d) else: diff --git a/scrapy/utils/deprecate.py b/scrapy/utils/deprecate.py index 3bf6639c5..da1838030 100644 --- a/scrapy/utils/deprecate.py +++ b/scrapy/utils/deprecate.py @@ -130,7 +130,7 @@ def create_deprecated_class( # deprecated class is in jinja2 template). __module__ attribute is not # important enough to raise an exception as users may be unable # to fix inspect.stack() errors. - warnings.warn(f"Error detecting parent module: {e!r}") + warnings.warn(f"Error detecting parent module: {e!r}", stacklevel=2) return deprecated_cls @@ -160,6 +160,7 @@ def update_classpath(path: Any) -> Any: warnings.warn( f"`{path}` class is deprecated, use `{new_path}` instead", ScrapyDeprecationWarning, + stacklevel=2, ) return new_path return path diff --git a/scrapy/utils/misc.py b/scrapy/utils/misc.py index ee988a665..fba80484c 100644 --- a/scrapy/utils/misc.py +++ b/scrapy/utils/misc.py @@ -13,7 +13,7 @@ from contextlib import contextmanager from functools import partial from importlib import import_module from pkgutil import iter_modules -from typing import IO, TYPE_CHECKING, Any, TypeVar, cast +from typing import IO, TYPE_CHECKING, Any, ParamSpec, Protocol, TypeVar, overload from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.item import Item @@ -28,9 +28,20 @@ if TYPE_CHECKING: _ITERABLE_SINGLE_VALUES = dict, Item, str, bytes -T = TypeVar("T") +_ITER_T = TypeVar("_ITER_T", bound=dict | Item | str | bytes) +_T = TypeVar("_T") +_T_co = TypeVar("_T_co", covariant=True) +_P = ParamSpec("_P") +@overload +def arg_to_iter(arg: None) -> tuple[()]: ... +@overload +def arg_to_iter(arg: _ITER_T) -> Iterable[_ITER_T]: ... +@overload +def arg_to_iter(arg: Iterable[_T]) -> Iterable[_T]: ... +@overload +def arg_to_iter(arg: _T) -> Iterable[_T]: ... def arg_to_iter(arg: Any) -> Iterable[Any]: """Convert an argument to an iterable. The argument can be a None, single value, or an iterable. @@ -38,9 +49,9 @@ def arg_to_iter(arg: Any) -> Iterable[Any]: Exception: if arg is a dict, [arg] will be returned """ if arg is None: - return [] + return () if not isinstance(arg, _ITERABLE_SINGLE_VALUES) and hasattr(arg, "__iter__"): - return cast("Iterable[Any]", arg) + return arg return [arg] @@ -64,7 +75,7 @@ def load_object(path: str | Callable[..., Any]) -> Any: try: dot = path.rindex(".") except ValueError: - raise ValueError(f"Error loading object '{path}': not a full path") + raise ValueError(f"Error loading object '{path}': not a full path") from None module, name = path[:dot], path[dot + 1 :] mod = import_module(module) @@ -72,7 +83,9 @@ def load_object(path: str | Callable[..., Any]) -> Any: try: obj = getattr(mod, name) except AttributeError: - raise NameError(f"Module '{module}' doesn't define any object named '{name}'") + raise NameError( + f"Module '{module}' doesn't define any object named '{name}'" + ) from None return obj @@ -152,9 +165,40 @@ def rel_has_nofollow(rel: str | None) -> bool: return rel is not None and "nofollow" in rel.replace(",", " ").split() +class SupportsFromCrawler(Protocol[_T_co, _P]): + @classmethod + def from_crawler( + cls, crawler: Crawler, /, *args: _P.args, **kwargs: _P.kwargs + ) -> _T_co: ... + + +@overload def build_from_crawler( - objcls: type[T], crawler: Crawler, /, *args: Any, **kwargs: Any -) -> T: + objcls: SupportsFromCrawler[_T_co, _P], + crawler: Crawler, + /, + *args: _P.args, + **kwargs: _P.kwargs, +) -> _T_co: ... + + +@overload +def build_from_crawler( + objcls: Callable[_P, _T_co], + crawler: Crawler, + /, + *args: _P.args, + **kwargs: _P.kwargs, +) -> _T_co: ... + + +def build_from_crawler( + objcls: Any, + crawler: Crawler, + /, + *args: Any, + **kwargs: Any, +) -> Any: """Construct a class instance using its ``from_crawler()`` or ``__init__()`` constructor. .. versionadded:: 2.12 @@ -164,14 +208,14 @@ def build_from_crawler( Raises ``TypeError`` if the resulting instance is ``None``. """ if hasattr(objcls, "from_crawler"): - instance = objcls.from_crawler(crawler, *args, **kwargs) # type: ignore[attr-defined] + instance = objcls.from_crawler(crawler, *args, **kwargs) method_name = "from_crawler" else: instance = objcls(*args, **kwargs) method_name = "__new__" if instance is None: raise TypeError(f"{objcls.__qualname__}.{method_name} returned None") - return cast("T", instance) + return instance @contextmanager diff --git a/scrapy/utils/project.py b/scrapy/utils/project.py index 0139720b7..3e75c7729 100644 --- a/scrapy/utils/project.py +++ b/scrapy/utils/project.py @@ -20,7 +20,8 @@ def inside_project() -> bool: import_module(scrapy_module) except ImportError as exc: warnings.warn( - f"Cannot import scrapy settings module {scrapy_module}: {exc}" + f"Cannot import scrapy settings module {scrapy_module}: {exc}", + stacklevel=2, ) else: return True diff --git a/scrapy/utils/reactor.py b/scrapy/utils/reactor.py index 419c552bf..b41b8af38 100644 --- a/scrapy/utils/reactor.py +++ b/scrapy/utils/reactor.py @@ -76,7 +76,7 @@ class CallLaterOnce(Generic[_T]): for d in self._deferreds: call_later(0, d.callback, None) - self._deferreds = [] + self._deferreds.clear() return result diff --git a/scrapy/utils/request.py b/scrapy/utils/request.py index 616ab661f..27d669e71 100644 --- a/scrapy/utils/request.py +++ b/scrapy/utils/request.py @@ -169,7 +169,7 @@ def _get_method(obj: Any, name: Any) -> Any: try: return getattr(obj, name) except AttributeError: - raise ValueError(f"Method {name!r} not found in: {obj}") + raise ValueError(f"Method {name!r} not found in: {obj}") from None def request_to_curl(request: Request) -> str: diff --git a/scrapy/utils/testproc.py b/scrapy/utils/testproc.py index 2f1acffd7..7d548524a 100644 --- a/scrapy/utils/testproc.py +++ b/scrapy/utils/testproc.py @@ -4,7 +4,7 @@ from __future__ import annotations import os import sys import warnings -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING, ClassVar, cast from twisted.internet.defer import Deferred from twisted.internet.protocol import ProcessProtocol @@ -21,12 +21,13 @@ if TYPE_CHECKING: warnings.warn( "The scrapy.utils.testproc module is deprecated.", ScrapyDeprecationWarning, + stacklevel=2, ) class ProcessTest: command: str | None = None - prefix = [sys.executable, "-m", "scrapy.cmdline"] + prefix: ClassVar[list[str]] = [sys.executable, "-m", "scrapy.cmdline"] cwd = os.getcwd() # trial chdirs to temp dir # noqa: PTH109 def execute( diff --git a/scrapy/utils/testsite.py b/scrapy/utils/testsite.py index e57eb802b..4089e86ed 100644 --- a/scrapy/utils/testsite.py +++ b/scrapy/utils/testsite.py @@ -9,6 +9,7 @@ from scrapy.exceptions import ScrapyDeprecationWarning warnings.warn( "The scrapy.utils.testsite module is deprecated.", ScrapyDeprecationWarning, + stacklevel=2, ) diff --git a/scrapy/utils/url.py b/scrapy/utils/url.py index 097809cac..39b581ff0 100644 --- a/scrapy/utils/url.py +++ b/scrapy/utils/url.py @@ -19,13 +19,18 @@ from w3lib.url import parse_url as _parse_url from scrapy.exceptions import ScrapyDeprecationWarning +_DEPRECATED_NAMES: frozenset[str] = frozenset( + {"_unquotepath", "_safe_chars", "parse_url", *_public_w3lib_objects} +) + def __getattr__(name: str) -> Any: - if name in ("_unquotepath", "_safe_chars", "parse_url", *_public_w3lib_objects): + if name in _DEPRECATED_NAMES: obj_type = "attribute" if name == "_safe_chars" else "function" warnings.warn( f"The scrapy.utils.url.{name} {obj_type} is deprecated, use w3lib.url.{name} instead.", ScrapyDeprecationWarning, + stacklevel=2, ) return getattr(import_module("w3lib.url"), name) @@ -45,15 +50,18 @@ def url_is_from_any_domain(url: UrlT, domains: Iterable[str]) -> bool: host = _parse_url(url).netloc.lower() if not host: return False - domains = [d.lower() for d in domains] - return any((host == d) or (host.endswith(f".{d}")) for d in domains) + return any((host == d) or (host.endswith(f".{d}")) for d in map(str.lower, domains)) + + +def _spider_domains(spider: type[Spider]) -> Iterable[str]: + yield spider.name + if allowed_domains := getattr(spider, "allowed_domains", None): + yield from allowed_domains def url_is_from_spider(url: UrlT, spider: type[Spider]) -> bool: """Return True if the url belongs to the given spider""" - return url_is_from_any_domain( - url, [spider.name, *getattr(spider, "allowed_domains", [])] - ) + return url_is_from_any_domain(url, _spider_domains(spider)) def url_has_any_extension(url: UrlT, extensions: Iterable[str]) -> bool: @@ -184,11 +192,11 @@ def strip_url( strip_default_port and parsed_url.port and (parsed_url.scheme, parsed_url.port) - in ( + in { ("http", 80), ("https", 443), ("ftp", 21), - ) + } ): netloc = netloc.replace(f":{parsed_url.port}", "") diff --git a/tests/test_spidermiddleware_httperror.py b/tests/test_spidermiddleware_httperror.py index b15f68dd0..d5ad59346 100644 --- a/tests/test_spidermiddleware_httperror.py +++ b/tests/test_spidermiddleware_httperror.py @@ -88,7 +88,7 @@ class TestHttpErrorMiddleware: def test_process_spider_exception( self, mw: HttpErrorMiddleware, res404: Response ) -> None: - assert mw.process_spider_exception(res404, HttpError(res404)) == [] + assert mw.process_spider_exception(res404, HttpError(res404)) == () assert mw.process_spider_exception(res404, Exception()) is None def test_handle_httpstatus_list( From f8d103a65a5d5a424d3100db37ed2e5ff8738b2b Mon Sep 17 00:00:00 2001 From: Adrian Date: Mon, 6 Apr 2026 10:24:21 +0200 Subject: [PATCH 097/248] Add llms.txt and llms-full.txt generation (#7380) --- .github/workflows/checks.yml | 6 +- .gitignore | 2 +- .pre-commit-config.yaml | 4 + .readthedocs.yml | 18 +-- docs/README.rst | 2 +- docs/_ext/scrapydocs.py | 68 ++++++--- docs/conf.py | 36 ++--- docs/requirements.in | 8 ++ docs/requirements.txt | 193 +++++++++++++++++++++++++- docs/topics/spiders.rst | 2 +- pyproject.toml | 3 + scrapy/downloadermiddlewares/retry.py | 14 +- tox.ini | 33 ++--- 13 files changed, 301 insertions(+), 88 deletions(-) create mode 100644 docs/requirements.in diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index d3715caf2..cb05784fa 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -26,9 +26,13 @@ jobs: - python-version: "3.10" env: TOXENV: typing-tests - - python-version: "3.13" # Keep in sync with .readthedocs.yml + # Keep in sync with pyproject.toml tool.sphinx-scrapy.python-version. + - python-version: "3.13" env: TOXENV: docs + - python-version: "3.13" + env: + TOXENV: docs-tests - python-version: "3.13" env: TOXENV: twinecheck diff --git a/.gitignore b/.gitignore index 4100bcd97..5e52ecf1e 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,7 @@ *.pyc _trial_temp* dropin.cache -docs/build +docs/_build *egg-info .tox/ venv/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a2bf1be0c..b86681b21 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -26,3 +26,7 @@ repos: rev: v1.0.2 hooks: - id: sphinx-lint +- repo: https://github.com/scrapy/sphinx-scrapy + rev: 0.7.0 + hooks: + - id: sphinx-scrapy diff --git a/.readthedocs.yml b/.readthedocs.yml index 23e4cabea..dc3827869 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -1,17 +1,9 @@ version: 2 -formats: all -sphinx: - configuration: docs/conf.py - fail_on_warning: true - build: os: ubuntu-24.04 tools: - # For available versions, see: - # https://docs.readthedocs.io/en/stable/config-file/v2.html#build-tools-python - python: "3.13" # Keep in sync with .github/workflows/checks.yml - -python: - install: - - requirements: docs/requirements.txt - - path: . + python: "3.13" + commands: + - pip install tox + - tox -e docs + - cp -a docs/_build/all/. $READTHEDOCS_OUTPUT/html/ diff --git a/docs/README.rst b/docs/README.rst index 36dd5aea4..4d2236b53 100644 --- a/docs/README.rst +++ b/docs/README.rst @@ -65,4 +65,4 @@ To compile the documentation to HTML run the following command:: tox -e docs -Documentation will be generated (in HTML format) inside the ``.tox/docs/tmp/html`` dir. +Documentation will be generated inside the ``docs/_build/all`` dir. diff --git a/docs/_ext/scrapydocs.py b/docs/_ext/scrapydocs.py index 4e232967d..edb91bfb9 100644 --- a/docs/_ext/scrapydocs.py +++ b/docs/_ext/scrapydocs.py @@ -77,6 +77,25 @@ def make_setting_element( return item +def make_setting_markdown_item( + setting_data: SettingData, app: Sphinx, fromdocname: str +) -> str: + uri = app.builder.get_relative_uri(fromdocname, setting_data["docname"]) + if uri.startswith("#"): + target = f"#{setting_data['refid']}" + else: + target = f"{uri}#{setting_data['refid']}" + return f"* [{setting_data['setting_name']}]({target})" + + +def _iter_sorted_settings(env: Any, fromdocname: str) -> list[SettingData]: + return [ + d + for d in sorted(env.scrapy_all_settings, key=itemgetter("setting_name")) # type: ignore[attr-defined] + if fromdocname != d["docname"] + ] + + def replace_settingslist_nodes( app: Sphinx, doctree: document, fromdocname: str ) -> None: @@ -87,13 +106,29 @@ def replace_settingslist_nodes( settings_list.extend( [ make_setting_element(d, app, fromdocname) - for d in sorted(env.scrapy_all_settings, key=itemgetter("setting_name")) # type: ignore[attr-defined] - if fromdocname != d["docname"] + for d in _iter_sorted_settings(env, fromdocname) ] ) node.replace_self(settings_list) +def visit_settingslist_node_markdown(translator: Any, _node: Node) -> None: + builder = translator.builder + env = builder.env + fromdocname = getattr(builder, "current_doc_name", env.docname) + lines = [ + make_setting_markdown_item(setting_data, builder.app, fromdocname) + for setting_data in _iter_sorted_settings(env, fromdocname) + ] + if lines: + translator.add("\n".join(lines), prefix_eol=2, suffix_eol=2) + raise nodes.SkipNode + + +def depart_settingslist_node_markdown(_translator: Any, _node: Node) -> None: + return None + + def source_role( name, rawtext, text: str, lineno, inliner, options=None, content=None ) -> tuple[list[Any], list[Any]]: @@ -127,32 +162,19 @@ def rev_role( def setup(app: Sphinx) -> dict[str, Any]: - app.add_crossref_type( - directivename="setting", - rolename="setting", - indextemplate="pair: %s; setting", - ) - app.add_crossref_type( - directivename="signal", - rolename="signal", - indextemplate="pair: %s; signal", - ) - app.add_crossref_type( - directivename="command", - rolename="command", - indextemplate="pair: %s; command", - ) - app.add_crossref_type( - directivename="reqmeta", - rolename="reqmeta", - indextemplate="pair: %s; reqmeta", - ) app.add_role("source", source_role) app.add_role("commit", commit_role) app.add_role("issue", issue_role) app.add_role("rev", rev_role) - app.add_node(SettingslistNode) + app.add_node( + SettingslistNode, + markdown=(visit_settingslist_node_markdown, depart_settingslist_node_markdown), + singlemarkdown=( + visit_settingslist_node_markdown, + depart_settingslist_node_markdown, + ), + ) app.add_directive("settingslist", SettingsListDirective) app.connect("doctree-read", collect_scrapy_settings_refs) diff --git a/docs/conf.py b/docs/conf.py index 9b1db9bb7..e3e618860 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -28,11 +28,9 @@ author = "Scrapy developers" extensions = [ "notfound.extension", "scrapydocs", - "sphinx.ext.autodoc", + "sphinx_scrapy", "scrapyfixautodoc", # Must be after "sphinx.ext.autodoc" "sphinx.ext.coverage", - "sphinx.ext.intersphinx", - "sphinx.ext.viewcode", "sphinx_rtd_dark_mode", ] @@ -147,22 +145,24 @@ coverage_ignore_pyobjects = [ # -- Options for the InterSphinx extension ----------------------------------- # https://www.sphinx-doc.org/en/master/usage/extensions/intersphinx.html#configuration -intersphinx_mapping = { - "attrs": ("https://www.attrs.org/en/stable/", None), - "coverage": ("https://coverage.readthedocs.io/en/latest", None), - "cryptography": ("https://cryptography.io/en/latest/", None), - "cssselect": ("https://cssselect.readthedocs.io/en/latest", None), - "itemloaders": ("https://itemloaders.readthedocs.io/en/latest/", None), - "parsel": ("https://parsel.readthedocs.io/en/latest/", None), - "pytest": ("https://docs.pytest.org/en/latest", None), - "python": ("https://docs.python.org/3", None), - "sphinx": ("https://www.sphinx-doc.org/en/master", None), - "tox": ("https://tox.wiki/en/latest/", None), - "twisted": ("https://docs.twisted.org/en/stable/", None), - "twistedapi": ("https://docs.twisted.org/en/stable/api/", None), - "w3lib": ("https://w3lib.readthedocs.io/en/latest", None), -} intersphinx_disabled_reftypes: Sequence[str] = [] +# sphinx-scrapy --------------------------------------------------------------- + +scrapy_intersphinx_enable = [ + "attrs", + "coverage", + "cryptography", + "cssselect", + "itemloaders", + "parsel", + "pytest", + "sphinx", + "tox", + "twisted", + "twistedapi", + "w3lib", +] + # -- Other options ------------------------------------------------------------ default_dark_mode = False diff --git a/docs/requirements.in b/docs/requirements.in new file mode 100644 index 000000000..d0ef2455e --- /dev/null +++ b/docs/requirements.in @@ -0,0 +1,8 @@ +h2 +pydantic +scrapy-spider-metadata +sphinx +sphinx-notfound-page +sphinx-rtd-theme +sphinx-rtd-dark-mode +sphinx-scrapy @ git+https://github.com/scrapy/sphinx-scrapy.git@0.7.0 diff --git a/docs/requirements.txt b/docs/requirements.txt index 500db7a7e..354c670c7 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,7 +1,192 @@ +# This file was autogenerated by uv via the following command: +# uv pip compile requirements.in -o requirements.txt +alabaster==1.0.0 + # via sphinx +annotated-types==0.7.0 + # via pydantic +attrs==26.1.0 + # via + # service-identity + # twisted +automat==25.4.16 + # via twisted +babel==2.18.0 + # via sphinx +certifi==2026.2.25 + # via requests +cffi==2.0.0 + # via cryptography +charset-normalizer==3.4.6 + # via requests +constantly==23.10.4 + # via twisted +cryptography==46.0.6 + # via + # pyopenssl + # scrapy + # service-identity +cssselect==1.4.0 + # via + # parsel + # scrapy +defusedxml==0.7.1 + # via scrapy +docutils==0.22.4 + # via + # sphinx + # sphinx-markdown-builder + # sphinx-rtd-theme +filelock==3.25.2 + # via tldextract h2==4.3.0 -pydantic==2.12.3 + # via -r requirements.in +hpack==4.1.0 + # via h2 +hyperframe==6.1.0 + # via h2 +hyperlink==21.0.0 + # via twisted +idna==3.11 + # via + # hyperlink + # requests + # tldextract +imagesize==2.0.0 + # via sphinx +incremental==24.11.0 + # via twisted +itemadapter==0.13.1 + # via + # itemloaders + # scrapy +itemloaders==1.4.0 + # via scrapy +jinja2==3.1.6 + # via sphinx +jmespath==1.1.0 + # via + # itemloaders + # parsel +lxml==6.0.2 + # via + # parsel + # scrapy +markupsafe==3.0.3 + # via jinja2 +packaging==26.0 + # via + # incremental + # parsel + # scrapy + # scrapy-spider-metadata + # sphinx + # sphinx-scrapy +parsel==1.11.0 + # via + # itemloaders + # scrapy +protego==0.6.0 + # via scrapy +pyasn1==0.6.3 + # via + # pyasn1-modules + # service-identity +pyasn1-modules==0.4.2 + # via service-identity +pycparser==3.0 + # via cffi +pydantic==2.12.5 + # via + # -r requirements.in + # scrapy-spider-metadata +pydantic-core==2.41.5 + # via pydantic +pydispatcher==2.0.7 + # via scrapy +pygments==2.19.2 + # via sphinx +pyopenssl==26.0.0 + # via scrapy +queuelib==1.9.0 + # via scrapy +requests==2.33.0 + # via + # requests-file + # sphinx + # tldextract +requests-file==3.0.1 + # via tldextract +roman-numerals==4.1.0 + # via sphinx +scrapy==2.14.2 + # via scrapy-spider-metadata scrapy-spider-metadata==0.2.0 -sphinx==8.1.3 -sphinx-notfound-page==1.0.4 -sphinx-rtd-theme==3.0.2 + # via -r requirements.in +service-identity==24.2.0 + # via scrapy +snowballstemmer==3.0.1 + # via sphinx +sphinx==9.1.0 + # via + # -r requirements.in + # sphinx-copybutton + # sphinx-llms-txt + # sphinx-markdown-builder + # sphinx-notfound-page + # sphinx-rtd-theme + # sphinx-scrapy + # sphinxcontrib-jquery +sphinx-copybutton==0.5.2 + # via sphinx-scrapy +sphinx-llms-txt @ git+https://github.com/zytedata/sphinx-llms-txt.git@5e8866cb0cc249aa2017ad9050b3b83a7ca16f69 + # via sphinx-scrapy +sphinx-markdown-builder @ git+https://github.com/zytedata/sphinx-markdown-builder.git@ac9f8babfe622e4300099ab44b96d9d9228e742e + # via sphinx-scrapy +sphinx-notfound-page==1.1.0 + # via -r requirements.in sphinx-rtd-dark-mode==1.3.0 + # via -r requirements.in +sphinx-rtd-theme==3.1.0 + # via + # -r requirements.in + # sphinx-rtd-dark-mode +sphinx-scrapy @ git+https://github.com/scrapy/sphinx-scrapy.git@46c52fc3a6d5ee632a8ef6bccf4daa1ff53e96fd + # via -r requirements.in +sphinxcontrib-applehelp==2.0.0 + # via sphinx +sphinxcontrib-devhelp==2.0.0 + # via sphinx +sphinxcontrib-htmlhelp==2.1.0 + # via sphinx +sphinxcontrib-jquery==4.1 + # via sphinx-rtd-theme +sphinxcontrib-jsmath==1.0.1 + # via sphinx +sphinxcontrib-qthelp==2.0.0 + # via sphinx +sphinxcontrib-serializinghtml==2.0.0 + # via sphinx +tabulate==0.10.0 + # via sphinx-markdown-builder +tldextract==5.3.1 + # via scrapy +twisted==25.5.0 + # via scrapy +typing-extensions==4.15.0 + # via + # pydantic + # pydantic-core + # twisted + # typing-inspection +typing-inspection==0.4.2 + # via pydantic +urllib3==2.6.3 + # via requests +w3lib==2.4.1 + # via + # parsel + # scrapy +zope-interface==8.2 + # via + # scrapy + # twisted diff --git a/docs/topics/spiders.rst b/docs/topics/spiders.rst index 98374b6b4..806af509f 100644 --- a/docs/topics/spiders.rst +++ b/docs/topics/spiders.rst @@ -198,7 +198,7 @@ scrapy.Spider The ``parse`` method is in charge of processing the response and returning scraped data and/or more URLs to follow. Other Requests callbacks have - the same requirements as the :class:`Spider` class. + the same requirements as the :class:`~scrapy.Spider` class. This method, as well as any other Request callback, must return a :class:`~scrapy.Request` object, an :ref:`item object `, an diff --git a/pyproject.toml b/pyproject.toml index 117edce0f..70ef73f26 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -464,3 +464,6 @@ split-on-trailing-comma = false [tool.ruff.lint.pydocstyle] convention = "pep257" + +[tool.sphinx-scrapy] +python-version = "3.13" # Keep in sync with .github/workflows/checks.yml. diff --git a/scrapy/downloadermiddlewares/retry.py b/scrapy/downloadermiddlewares/retry.py index b6dba5773..d38b4b9db 100644 --- a/scrapy/downloadermiddlewares/retry.py +++ b/scrapy/downloadermiddlewares/retry.py @@ -25,11 +25,11 @@ if TYPE_CHECKING: # typing.Self requires Python 3.11 from typing_extensions import Self + import scrapy from scrapy.crawler import Crawler from scrapy.http import Response from scrapy.http.request import Request from scrapy.settings import BaseSettings - from scrapy.spiders import Spider retry_logger = getLogger(__name__) @@ -38,7 +38,7 @@ retry_logger = getLogger(__name__) def get_retry_request( request: Request, *, - spider: Spider, + spider: scrapy.Spider, reason: str | Exception | type[Exception] = "unspecified", max_retry_times: int | None = None, priority_adjust: int | None = None, @@ -145,7 +145,10 @@ class RetryMiddleware: @_warn_spider_arg def process_response( - self, request: Request, response: Response, spider: Spider | None = None + self, + request: Request, + response: Response, + spider: scrapy.Spider | None = None, ) -> Request | Response: if request.meta.get("dont_retry", False): return response @@ -156,7 +159,10 @@ class RetryMiddleware: @_warn_spider_arg def process_exception( - self, request: Request, exception: Exception, spider: Spider | None = None + self, + request: Request, + exception: Exception, + spider: scrapy.Spider | None = None, ) -> Request | Response | None: if isinstance(exception, self.exceptions_to_retry) and not request.meta.get( "dont_retry", False diff --git a/tox.ini b/tox.ini index d35b0406c..0e86f964a 100644 --- a/tox.ini +++ b/tox.ini @@ -4,7 +4,9 @@ # and then run "tox" from this directory. [tox] -envlist = pre-commit,pylint,typing,py +requires = + sphinx-scrapy @ git+https://github.com/scrapy/sphinx-scrapy.git@0.7.0 +envlist = pre-commit,pylint,typing,py,docs minversion = 1.7.0 [test-requirements] @@ -238,38 +240,25 @@ commands = setenv = {[pinned]setenv} -[docs] +[testenv:docs-tests] changedir = docs -deps = - -rdocs/requirements.txt -setenv = - READTHEDOCS_PROJECT=scrapy - READTHEDOCS_VERSION=master - -[testenv:docs] -basepython = python3 -changedir = {[docs]changedir} deps = {[test-requirements]deps} - {[docs]deps} -setenv = {[docs]setenv} + -rdocs/requirements.txt commands = - sphinx-build -W -b html . {envtmpdir}/html pytest [testenv:docs-coverage] -basepython = python3 -changedir = {[docs]changedir} -deps = {[docs]deps} -setenv = {[docs]setenv} +changedir = docs +deps = + -rdocs/requirements.txt commands = sphinx-build -b coverage . {envtmpdir}/coverage [testenv:docs-links] -basepython = python3 -changedir = {[docs]changedir} -deps = {[docs]deps} -setenv = {[docs]setenv} +changedir = docs +deps = + -rdocs/requirements.txt commands = sphinx-build -W -b linkcheck . {envtmpdir}/linkcheck From 8a26c3c2a0f5f38cc313f8776a6521ed0067bff1 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Mon, 6 Apr 2026 17:55:33 +0500 Subject: [PATCH 098/248] Use a random port in MockFTPServer. (#7402) --- tests/mockserver/ftp.py | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/tests/mockserver/ftp.py b/tests/mockserver/ftp.py index 5505ac258..22efc966b 100644 --- a/tests/mockserver/ftp.py +++ b/tests/mockserver/ftp.py @@ -1,5 +1,6 @@ from __future__ import annotations +import re import sys from argparse import ArgumentParser from pathlib import Path @@ -15,20 +16,36 @@ from tests.utils import get_script_run_env class MockFTPServer: - """Creates an FTP server on port 2121 with a default passwordless user + """Creates an FTP server on a random port with a default passwordless user (anonymous) and a temporary root path that you can read from the :attr:`path` attribute.""" + def __init__(self) -> None: + self.proc: Popen[str] | None = None + self.host: str = "127.0.0.1" + self.port: int | None = None + self.path: Path | None = None + def __enter__(self): self.path = Path(mkdtemp()) self.proc = Popen( [sys.executable, "-u", "-m", "tests.mockserver.ftp", "-d", str(self.path)], stderr=PIPE, env=get_script_run_env(), + text=True, ) for line in self.proc.stderr: - if b"starting FTP server" in line: + if "starting FTP server" in line and ( + m := re.search(r"starting FTP server on ([^ :]+):(\d+),", line) + ): + self.port = int(m.group(2)) break + else: + self.proc.kill() + self.proc.communicate() + raise RuntimeError( + "The FTP server failed to start or the output is unrecognized" + ) return self def __exit__(self, exc_type, exc_value, traceback): @@ -37,12 +54,12 @@ class MockFTPServer: self.proc.communicate() def url(self, path): - return "ftp://127.0.0.1:2121/" + path + return f"ftp://{self.host}:{self.port}/{path}" def main() -> None: parser = ArgumentParser() - parser.add_argument("-d", "--directory") + parser.add_argument("-d", "--directory", required=True) args = parser.parse_args() authorizer = DummyAuthorizer() @@ -50,7 +67,7 @@ def main() -> None: authorizer.add_anonymous(args.directory, perm=full_permissions) handler = FTPHandler handler.authorizer = authorizer - address = ("127.0.0.1", 2121) + address = ("127.0.0.1", 0) server = FTPServer(address, handler) server.serve_forever() From 010faf17229254a5363817343b991a9d246c9051 Mon Sep 17 00:00:00 2001 From: Adrian Date: Mon, 6 Apr 2026 15:29:59 +0200 Subject: [PATCH 099/248] Use sphinx-scrapy 0.7.1 (#7406) --- .pre-commit-config.yaml | 2 +- .readthedocs.yml | 1 + docs/requirements.in | 2 +- docs/requirements.txt | 2 +- tox.ini | 2 +- 5 files changed, 5 insertions(+), 4 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b86681b21..de5ad0a39 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -27,6 +27,6 @@ repos: hooks: - id: sphinx-lint - repo: https://github.com/scrapy/sphinx-scrapy - rev: 0.7.0 + rev: 0.7.1 hooks: - id: sphinx-scrapy diff --git a/.readthedocs.yml b/.readthedocs.yml index dc3827869..6d1aeb507 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -6,4 +6,5 @@ build: commands: - pip install tox - tox -e docs + - mkdir -p $READTHEDOCS_OUTPUT/html - cp -a docs/_build/all/. $READTHEDOCS_OUTPUT/html/ diff --git a/docs/requirements.in b/docs/requirements.in index d0ef2455e..3b1cbb226 100644 --- a/docs/requirements.in +++ b/docs/requirements.in @@ -5,4 +5,4 @@ sphinx sphinx-notfound-page sphinx-rtd-theme sphinx-rtd-dark-mode -sphinx-scrapy @ git+https://github.com/scrapy/sphinx-scrapy.git@0.7.0 +sphinx-scrapy @ git+https://github.com/scrapy/sphinx-scrapy.git@0.7.1 diff --git a/docs/requirements.txt b/docs/requirements.txt index 354c670c7..6da4a52c9 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -150,7 +150,7 @@ sphinx-rtd-theme==3.1.0 # via # -r requirements.in # sphinx-rtd-dark-mode -sphinx-scrapy @ git+https://github.com/scrapy/sphinx-scrapy.git@46c52fc3a6d5ee632a8ef6bccf4daa1ff53e96fd +sphinx-scrapy @ git+https://github.com/scrapy/sphinx-scrapy.git@2b5f6c7de64c8317cb771fdeb2e5020d1c9c9dcf # via -r requirements.in sphinxcontrib-applehelp==2.0.0 # via sphinx diff --git a/tox.ini b/tox.ini index 0e86f964a..d0bcb4f81 100644 --- a/tox.ini +++ b/tox.ini @@ -5,7 +5,7 @@ [tox] requires = - sphinx-scrapy @ git+https://github.com/scrapy/sphinx-scrapy.git@0.7.0 + sphinx-scrapy @ git+https://github.com/scrapy/sphinx-scrapy.git@0.7.1 envlist = pre-commit,pylint,typing,py,docs minversion = 1.7.0 From 830eaeab5d87f7c04eb2c1f0c8524b4d3f2ef0c1 Mon Sep 17 00:00:00 2001 From: Ahmed Arshad <151191202+ahamed-arshad@users.noreply.github.com> Date: Wed, 8 Apr 2026 13:08:09 +0530 Subject: [PATCH 100/248] docs: fix typos in addons.rst (#7408) Co-authored-by: Abdul Rahman --- docs/topics/addons.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/topics/addons.rst b/docs/topics/addons.rst index 64f00f622..370e5d385 100644 --- a/docs/topics/addons.rst +++ b/docs/topics/addons.rst @@ -98,9 +98,9 @@ recommend that such custom components should be written in the following way: (``MY_FALLBACK_DOWNLOAD_HANDLER`` mentioned earlier) and set the default setting to the component provided by the add-on (e.g. ``MyDownloadHandler``). If the fallback setting is already set by the user, - they shouldn't change it. + it should not be changed. 3. This way, if there are several add-ons that want to modify the same setting, - all of them will fallback to the component from the previous one and then to + all of them will fall back to the component from the previous one and then to the Scrapy default. The order of that depends on the priority order in the ``ADDONS`` setting. From 8835a69f12c524bc58d13ee03c8744d7d8e86800 Mon Sep 17 00:00:00 2001 From: "Albert Eduardovich N." Date: Wed, 8 Apr 2026 14:26:01 +0300 Subject: [PATCH 101/248] Use `dataclass` for the downloader `Slot` (#7405) --- scrapy/core/downloader/__init__.py | 34 +++++++++++------------------- tests/test_core_downloader.py | 2 +- 2 files changed, 13 insertions(+), 23 deletions(-) diff --git a/scrapy/core/downloader/__init__.py b/scrapy/core/downloader/__init__.py index 59871fdc3..905b32d08 100644 --- a/scrapy/core/downloader/__init__.py +++ b/scrapy/core/downloader/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations import random from collections import deque +from dataclasses import dataclass, field from datetime import datetime from time import time from typing import TYPE_CHECKING, Any @@ -40,24 +41,21 @@ if TYPE_CHECKING: from scrapy.signalmanager import SignalManager +@dataclass(slots=True, eq=False) class Slot: """Downloader slot""" - def __init__( - self, - concurrency: int, - delay: float, - randomize_delay: bool, - ): - self.concurrency: int = concurrency - self.delay: float = delay - self.randomize_delay: bool = randomize_delay + concurrency: int + delay: float + randomize_delay: bool - self.active: set[Request] = set() - self.queue: deque[tuple[Request, Deferred[Response]]] = deque() - self.transferring: set[Request] = set() - self.lastseen: float = 0 - self.latercall: CallLaterResult | None = None + active: set[Request] = field(default_factory=set, init=False, repr=False) + queue: deque[tuple[Request, Deferred[Response]]] = field( + default_factory=deque, init=False, repr=False + ) + transferring: set[Request] = field(default_factory=set, init=False, repr=False) + lastseen: float = field(default=0, init=False, repr=False) + latercall: CallLaterResult | None = field(default=None, init=False, repr=False) def free_transfer_slots(self) -> int: return self.concurrency - len(self.transferring) @@ -72,14 +70,6 @@ class Slot: self.latercall.cancel() self.latercall = None - def __repr__(self) -> str: - cls_name = self.__class__.__name__ - return ( - f"{cls_name}(concurrency={self.concurrency!r}, " - f"delay={self.delay:.2f}, " - f"randomize_delay={self.randomize_delay!r})" - ) - def __str__(self) -> str: return ( f" Date: Wed, 8 Apr 2026 17:28:35 +0530 Subject: [PATCH 102/248] Rename DNS_RESOLVER to TWISTED_DNS_RESOLVER (#7361) --- docs/faq.rst | 2 +- docs/news.rst | 4 +-- docs/topics/components.rst | 2 +- docs/topics/settings.rst | 8 +++--- scrapy/crawler.py | 26 +++++++++++++++++-- scrapy/settings/__init__.py | 8 ++++++ scrapy/settings/default_settings.py | 4 ++- .../caching_hostname_resolver.py | 2 +- .../caching_hostname_resolver_ipv6.py | 2 +- .../caching_hostname_resolver.py | 2 +- .../caching_hostname_resolver_ipv6.py | 2 +- 11 files changed, 47 insertions(+), 15 deletions(-) diff --git a/docs/faq.rst b/docs/faq.rst index fdf0582de..e117d2a5f 100644 --- a/docs/faq.rst +++ b/docs/faq.rst @@ -360,7 +360,7 @@ method for this purpose. For example: Does Scrapy support IPv6 addresses? ----------------------------------- -Yes, by setting :setting:`DNS_RESOLVER` to ``scrapy.resolver.CachingHostnameResolver``. +Yes, by setting :setting:`TWISTED_DNS_RESOLVER` to ``scrapy.resolver.CachingHostnameResolver``. Note that by doing so, you lose the ability to set a specific timeout for DNS requests (the value of the :setting:`DNS_TIMEOUT` setting is ignored). diff --git a/docs/news.rst b/docs/news.rst index 356220c3f..e445b0498 100644 --- a/docs/news.rst +++ b/docs/news.rst @@ -4731,7 +4731,7 @@ Highlights: * :ref:`FTP support ` for media pipelines * New :attr:`Response.certificate ` attribute -* IPv6 support through :setting:`DNS_RESOLVER` +* IPv6 support through ``DNS_RESOLVER`` Backward-incompatible changes ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -4839,7 +4839,7 @@ New features :class:`twisted.internet.ssl.Certificate` object for HTTPS responses (:issue:`2726`, :issue:`4054`) -* A new :setting:`DNS_RESOLVER` setting allows enabling IPv6 support +* A new ``DNS_RESOLVER`` setting allows enabling IPv6 support (:issue:`1031`, :issue:`4227`) * A new :setting:`SCRAPER_SLOT_MAX_ACTIVE_SIZE` setting allows configuring diff --git a/docs/topics/components.rst b/docs/topics/components.rst index d8a604ea7..c0df86922 100644 --- a/docs/topics/components.rst +++ b/docs/topics/components.rst @@ -11,7 +11,7 @@ That includes the classes that you may assign to the following settings: - :setting:`ADDONS` -- :setting:`DNS_RESOLVER` +- :setting:`TWISTED_DNS_RESOLVER` - :setting:`DOWNLOAD_HANDLERS` diff --git a/docs/topics/settings.rst b/docs/topics/settings.rst index f0456b4d5..97c8f41de 100644 --- a/docs/topics/settings.rst +++ b/docs/topics/settings.rst @@ -332,7 +332,7 @@ These settings are: - :setting:`ASYNCIO_EVENT_LOOP` (not possible to set per-spider when using :class:`~scrapy.crawler.AsyncCrawlerProcess`, see below) -- :setting:`DNS_RESOLVER` and settings used by the corresponding +- :setting:`TWISTED_DNS_RESOLVER` and settings used by the corresponding component, e.g. :setting:`DNSCACHE_ENABLED`, :setting:`DNSCACHE_SIZE` and :setting:`DNS_TIMEOUT` for the default one. @@ -671,10 +671,10 @@ Default: ``10000`` DNS in-memory cache size, see :setting:`DNSCACHE_ENABLED`. -.. setting:: DNS_RESOLVER +.. setting:: TWISTED_DNS_RESOLVER -DNS_RESOLVER ------------- +TWISTED_DNS_RESOLVER +-------------------- Default: ``'scrapy.resolver.CachingThreadedResolver'`` diff --git a/scrapy/crawler.py b/scrapy/crawler.py index 500139566..0a19e9985 100644 --- a/scrapy/crawler.py +++ b/scrapy/crawler.py @@ -16,7 +16,7 @@ from scrapy.addons import AddonManager from scrapy.core.engine import ExecutionEngine from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.extension import ExtensionManager -from scrapy.settings import Settings, overridden_settings +from scrapy.settings import SETTINGS_PRIORITIES, Settings, overridden_settings from scrapy.signalmanager import SignalManager from scrapy.spiderloader import SpiderLoaderProtocol, get_spider_loader from scrapy.utils.defer import deferred_from_coro @@ -660,7 +660,29 @@ class CrawlerProcessBase(CrawlerRunnerBase): def _setup_reactor(self, install_signal_handlers: bool) -> None: from twisted.internet import reactor - resolver_class = load_object(self.settings["DNS_RESOLVER"]) + dns_priority = self.settings.getpriority("DNS_RESOLVER") or 0 + default_priority = SETTINGS_PRIORITIES["default"] + + if dns_priority > default_priority: + warnings.warn( + "The DNS_RESOLVER setting is deprecated, please use " + "TWISTED_DNS_RESOLVER instead.", + category=ScrapyDeprecationWarning, + stacklevel=2, + ) + + twisted_dns_priority = ( + self.settings.getpriority("TWISTED_DNS_RESOLVER") or 0 + ) + if twisted_dns_priority > dns_priority: + resolver_cls_path = self.settings["TWISTED_DNS_RESOLVER"] + else: + resolver_cls_path = self.settings["DNS_RESOLVER"] + else: + resolver_cls_path = self.settings["TWISTED_DNS_RESOLVER"] + + resolver_class = load_object(resolver_cls_path) + # We pass self, which is CrawlerProcess, instead of Crawler here, # which works because the default resolvers only use crawler.settings. resolver = build_from_crawler(resolver_class, self, reactor=reactor) # type: ignore[call-overload] diff --git a/scrapy/settings/__init__.py b/scrapy/settings/__init__.py index d5c4b2dd3..456e90e77 100644 --- a/scrapy/settings/__init__.py +++ b/scrapy/settings/__init__.py @@ -162,6 +162,14 @@ class BaseSettings(MutableMapping[_SettingsKey, Any]): stacklevel=2, ) + if name == "DNS_RESOLVER": + warnings.warn( + "The DNS_RESOLVER setting is deprecated, please use " + "TWISTED_DNS_RESOLVER instead.", + ScrapyDeprecationWarning, + stacklevel=2, + ) + return self[name] if self[name] is not None else default def getbool(self, name: _SettingsKey, default: bool = False) -> bool: diff --git a/scrapy/settings/default_settings.py b/scrapy/settings/default_settings.py index 462240133..b80e48601 100644 --- a/scrapy/settings/default_settings.py +++ b/scrapy/settings/default_settings.py @@ -48,7 +48,6 @@ __all__ = [ "DEPTH_STATS_VERBOSE", "DNSCACHE_ENABLED", "DNSCACHE_SIZE", - "DNS_RESOLVER", "DNS_TIMEOUT", "DOWNLOADER", "DOWNLOADER_CLIENTCONTEXTFACTORY", @@ -185,6 +184,7 @@ __all__ = [ "TELNETCONSOLE_PORT", "TELNETCONSOLE_USERNAME", "TEMPLATES_DIR", + "TWISTED_DNS_RESOLVER", "TWISTED_REACTOR", "TWISTED_REACTOR_ENABLED", "URLLENGTH_LIMIT", @@ -526,6 +526,8 @@ TELNETCONSOLE_PASSWORD = None TEMPLATES_DIR = str((Path(__file__).parent / ".." / "templates").resolve()) +TWISTED_DNS_RESOLVER = "scrapy.resolver.CachingThreadedResolver" + TWISTED_REACTOR_ENABLED = True TWISTED_REACTOR = "twisted.internet.asyncioreactor.AsyncioSelectorReactor" diff --git a/tests/AsyncCrawlerProcess/caching_hostname_resolver.py b/tests/AsyncCrawlerProcess/caching_hostname_resolver.py index 5f75d5e17..a6520ca22 100644 --- a/tests/AsyncCrawlerProcess/caching_hostname_resolver.py +++ b/tests/AsyncCrawlerProcess/caching_hostname_resolver.py @@ -28,7 +28,7 @@ if __name__ == "__main__": process = AsyncCrawlerProcess( settings={ "RETRY_ENABLED": False, - "DNS_RESOLVER": "scrapy.resolver.CachingHostnameResolver", + "TWISTED_DNS_RESOLVER": "scrapy.resolver.CachingHostnameResolver", } ) process.crawl(CachingHostnameResolverSpider, url=sys.argv[1]) diff --git a/tests/AsyncCrawlerProcess/caching_hostname_resolver_ipv6.py b/tests/AsyncCrawlerProcess/caching_hostname_resolver_ipv6.py index 07e2d3684..55d2ef711 100644 --- a/tests/AsyncCrawlerProcess/caching_hostname_resolver_ipv6.py +++ b/tests/AsyncCrawlerProcess/caching_hostname_resolver_ipv6.py @@ -15,7 +15,7 @@ if __name__ == "__main__": process = AsyncCrawlerProcess( settings={ "RETRY_ENABLED": False, - "DNS_RESOLVER": "scrapy.resolver.CachingHostnameResolver", + "TWISTED_DNS_RESOLVER": "scrapy.resolver.CachingHostnameResolver", } ) process.crawl(CachingHostnameResolverSpider) diff --git a/tests/CrawlerProcess/caching_hostname_resolver.py b/tests/CrawlerProcess/caching_hostname_resolver.py index 53d427061..7f687e7d9 100644 --- a/tests/CrawlerProcess/caching_hostname_resolver.py +++ b/tests/CrawlerProcess/caching_hostname_resolver.py @@ -28,7 +28,7 @@ if __name__ == "__main__": process = CrawlerProcess( settings={ "RETRY_ENABLED": False, - "DNS_RESOLVER": "scrapy.resolver.CachingHostnameResolver", + "TWISTED_DNS_RESOLVER": "scrapy.resolver.CachingHostnameResolver", } ) process.crawl(CachingHostnameResolverSpider, url=sys.argv[1]) diff --git a/tests/CrawlerProcess/caching_hostname_resolver_ipv6.py b/tests/CrawlerProcess/caching_hostname_resolver_ipv6.py index 5cca94bed..da9c16cb8 100644 --- a/tests/CrawlerProcess/caching_hostname_resolver_ipv6.py +++ b/tests/CrawlerProcess/caching_hostname_resolver_ipv6.py @@ -15,7 +15,7 @@ if __name__ == "__main__": process = CrawlerProcess( settings={ "RETRY_ENABLED": False, - "DNS_RESOLVER": "scrapy.resolver.CachingHostnameResolver", + "TWISTED_DNS_RESOLVER": "scrapy.resolver.CachingHostnameResolver", } ) process.crawl(CachingHostnameResolverSpider) From 9fffcc1b8288eabfdb42f0f89ff7e95df85f65c6 Mon Sep 17 00:00:00 2001 From: "Albert Eduardovich N." Date: Wed, 8 Apr 2026 14:59:16 +0300 Subject: [PATCH 103/248] Optimize SitemapSpider memory usage (#7007) --- scrapy/spiders/sitemap.py | 75 +++++++++++++-------- scrapy/utils/sitemap.py | 126 ++++++++++++++++++++++++++++------- tests/test_spider_sitemap.py | 91 +++++++++++++++++++++++++ tests/test_utils_sitemap.py | 38 ++++++++++- 4 files changed, 278 insertions(+), 52 deletions(-) diff --git a/scrapy/spiders/sitemap.py b/scrapy/spiders/sitemap.py index cb5779d74..d87ffccaf 100644 --- a/scrapy/spiders/sitemap.py +++ b/scrapy/spiders/sitemap.py @@ -72,31 +72,53 @@ class SitemapSpider(Spider): def _parse_sitemap(self, response: Response) -> Iterable[Request]: if response.url.endswith("/robots.txt"): - for url in sitemap_urls_from_robots(response.text, base_url=response.url): - yield Request(url, callback=self._parse_sitemap) - else: - body = self._get_sitemap_body(response) - if body is None: - logger.warning( - "Ignoring invalid sitemap: %(response)s", - {"response": response}, - extra={"spider": self}, - ) - return + urls = list(sitemap_urls_from_robots(response.body, base_url=response.url)) + return (Request(url, callback=self._parse_sitemap) for url in urls) - s = Sitemap(body) - it = self.sitemap_filter(s) + body = self._get_sitemap_body(response) + if not body: + logger.warning( + "Ignoring invalid sitemap: %(response)s", + {"response": response}, + extra={"spider": self}, + ) + return () - if s.type == "sitemapindex": - for loc in iterloc(it, self.sitemap_alternate_links): - if any(x.search(loc) for x in self._follow): - yield Request(loc, callback=self._parse_sitemap) - elif s.type == "urlset": - for loc in iterloc(it, self.sitemap_alternate_links): - for r, c in self._cbs: - if r.search(loc): - yield Request(loc, callback=c) - break + s = Sitemap(body) + + if s.type == "sitemapindex": + urls = list(self._get_urls_from_sitemapindex(self.sitemap_filter(s))) + return (Request(loc, callback=self._parse_sitemap) for loc in urls) + + if s.type == "urlset": + url_callback_pairs = list( + self._get_urls_and_callbacks_from_urlset(self.sitemap_filter(s)) + ) + return (Request(loc, callback=c) for loc, c in url_callback_pairs) + + logger.warning( + "Ignoring invalid sitemap: %(response)s", + {"response": response}, + extra={"spider": self}, + ) + + return () + + def _get_urls_from_sitemapindex( + self, it: Iterable[dict[str, Any]] + ) -> Iterable[str]: + for loc in iterloc(it, self.sitemap_alternate_links): + if any(x.search(loc) for x in self._follow): + yield loc + + def _get_urls_and_callbacks_from_urlset( + self, it: Iterable[dict[str, Any]] + ) -> Iterable[tuple[str, CallbackT]]: + for loc in iterloc(it, self.sitemap_alternate_links): + for r, c in self._cbs: + if r.search(loc): + yield loc, c + break def _get_sitemap_body(self, response: Response) -> bytes | None: """Return the sitemap body contained in the given response, @@ -140,8 +162,9 @@ def regex(x: re.Pattern[str] | str) -> re.Pattern[str]: def iterloc(it: Iterable[dict[str, Any]], alt: bool = False) -> Iterable[str]: for d in it: - yield d["loc"] + if loc := d["loc"]: + yield loc # Also consider alternate URLs (xhtml:link rel="alternate") - if alt and "alternate" in d: - yield from d["alternate"] + if alt and (alt_list := d.get("alternate")): + yield from alt_list diff --git a/scrapy/utils/sitemap.py b/scrapy/utils/sitemap.py index c0100ea62..042ed6281 100644 --- a/scrapy/utils/sitemap.py +++ b/scrapy/utils/sitemap.py @@ -7,11 +7,15 @@ SitemapSpider, its API is subject to change without notice. from __future__ import annotations +import warnings +from io import BytesIO, StringIO from typing import TYPE_CHECKING, Any from urllib.parse import urljoin import lxml.etree +from scrapy.exceptions import ScrapyDeprecationWarning + if TYPE_CHECKING: from collections.abc import Iterable, Iterator @@ -20,40 +24,112 @@ class Sitemap: """Class to parse Sitemap (type=urlset) and Sitemap Index (type=sitemapindex) files""" + __slots__ = ("type", "xmliter") + def __init__(self, xmltext: str | bytes): - xmlp = lxml.etree.XMLParser( - recover=True, remove_comments=True, resolve_entities=False + if isinstance(xmltext, str): + warnings.warn( + "Passing `str` type as `xmltext` is deprecated, use `bytes`", + ScrapyDeprecationWarning, + stacklevel=2, + ) + xmltext = xmltext.encode() + + self.xmliter = lxml.etree.iterparse( + BytesIO(xmltext), + recover=True, + remove_comments=True, + resolve_entities=False, + remove_blank_text=True, + collect_ids=False, + remove_pis=True, + events=("start", "end"), ) - self._root = lxml.etree.fromstring(xmltext, parser=xmlp) - rt = self._root.tag - assert isinstance(rt, str) - self.type = rt.split("}", 1)[1] if "}" in rt else rt + _, root = next(self.xmliter) + self.type = self._get_tag_name(root) def __iter__(self) -> Iterator[dict[str, Any]]: - for elem in self._root.getchildren(): - d: dict[str, Any] = {} - for el in elem.getchildren(): - tag = el.tag - assert isinstance(tag, str) - name = tag.split("}", 1)[1] if "}" in tag else tag + for event, elem in self.xmliter: + if event == "start": + continue - if name == "link": - if "href" in el.attrib: - d.setdefault("alternate", []).append(el.get("href")) - else: - d[name] = el.text.strip() if el.text else "" + if self._get_tag_name(elem) not in {"url", "sitemap"}: + continue - if "loc" in d: + if d := self._process_sitemap_element(elem): yield d + def _process_sitemap_element( + self, elem: lxml.etree._Element + ) -> dict[str, Any] | None: + d: dict[str, Any] = {} + alternate: list[str] = [] + has_loc = False + + for el in elem: + try: + tag_name = self._get_tag_name(el) + if not tag_name: + continue + + if tag_name == "link": + if href := el.get("href"): + alternate.append(href) + else: + d[tag_name] = el.text.strip() if el.text else "" + if not has_loc and tag_name == "loc": + has_loc = True + finally: + el.clear() + elem.clear() + parent = elem.getparent() + if parent is not None: + while elem.getprevious() is not None: + del parent[0] + + if not has_loc: + return None + + if alternate: + d["alternate"] = alternate + + return d + + @staticmethod + def _get_tag_name(elem: lxml.etree._Element) -> str: + if TYPE_CHECKING: + assert isinstance(elem.tag, str) + _, _, localname = elem.tag.partition("}") + return localname or elem.tag + def sitemap_urls_from_robots( - robots_text: str, base_url: str | None = None + robots_text: str | bytes, + base_url: str | None = None, ) -> Iterable[str]: - """Return an iterator over all sitemap urls contained in the given - robots.txt file - """ - for line in robots_text.splitlines(): - if line.lstrip().lower().startswith("sitemap:"): - url = line.split(":", 1)[1].strip() + if isinstance(robots_text, bytes): + for line in BytesIO(robots_text): + if line.lstrip()[:8].lower() == b"sitemap:": + try: + url = line.partition(b":")[2].strip().decode() + except UnicodeDecodeError: + continue + yield urljoin(base_url or "", url) + + else: + yield from _sitemap_urls_from_robots_str(robots_text, base_url) + + +def _sitemap_urls_from_robots_str( + robots_text: str, + base_url: str | None = None, +) -> Iterable[str]: + warnings.warn( + "Passing `str` type as `robots_text` is deprecated, use `bytes`", + ScrapyDeprecationWarning, + stacklevel=2, + ) + for line in StringIO(robots_text): + if line.lstrip()[:8].lower() == "sitemap:": + url = line.partition(":")[2].strip() yield urljoin(base_url or "", url) diff --git a/tests/test_spider_sitemap.py b/tests/test_spider_sitemap.py index d2664b19e..57c209615 100644 --- a/tests/test_spider_sitemap.py +++ b/tests/test_spider_sitemap.py @@ -1,6 +1,7 @@ from __future__ import annotations import gzip +import re import warnings from datetime import datetime from io import BytesIO @@ -43,6 +44,9 @@ class TestSitemapSpider(TestSpider): r = Response(url="http://www.example.com/favicon.ico", body=self.BODY) self.assertSitemapBody(r, None) + r = XmlResponse(url="http://www.example.com/", body=b"") + self.assertSitemapBody(r, b"") + def test_get_sitemap_body_gzip_headers(self): r = Response( url="http://www.example.com/sitemap", @@ -85,6 +89,20 @@ Sitemap: /sitemap-relative-url.xml "http://www.example.com/sitemap-relative-url.xml", ] + def test_get_sitemap_urls_from_robotstxt_skips_invalid_utf8_urls(self): + robots = ( + b"User-agent: *\n" + b"Sitemap: http://example.com/\xff.xml\n" + b"Sitemap: http://example.com/ok.xml\n" + ) + + r = TextResponse(url="http://www.example.com/robots.txt", body=robots) + spider = self.spider_class("example.com") + + assert [req.url for req in spider._parse_sitemap(r)] == [ + "http://example.com/ok.xml", + ] + def test_alternate_url_locs(self): sitemap = b""" + + http://www.example.com/english/ + http://www.example.com/portuguese/ + """ + r = TextResponse(url="http://www.example.com/sitemap.xml", body=sitemap) + + class _RuleSpider(self.spider_class): # type: ignore[name-defined,misc] + sitemap_rules = [(rule, "parse")] + + spider = _RuleSpider("example.com") + urls = [req.url for req in spider._parse_sitemap(r)] + assert urls == result + + def test_parse_sitemap_empty_body(self): + r = XmlResponse(url="http://www.example.com/sitemap.xml", body=b"") + spider = self.spider_class("example.com") + + with LogCapture() as lc: + results = list(spider._parse_sitemap(r)) + + assert not results + + lc.check( + ( + "scrapy.spiders.sitemap", + "WARNING", + "Ignoring invalid sitemap: <200 http://www.example.com/sitemap.xml>", + ) + ) + + def test_parse_sitemap_not_sitemap(self): + body = b""" + + sometext + sometext2 + """ + r = XmlResponse(url="http://www.example.com/random.xml", body=body) + spider = self.spider_class("example.com") + + results = list(spider._parse_sitemap(r)) + + assert not results + + @pytest.mark.parametrize( + ("follow", "result"), + [ + (r"1.xml", ["http://www.example.com/sitemap1.xml"]), + (re.compile(r"sitemap\d"), ["http://www.example.com/sitemap1.xml"]), + (r"nonexistent", []), + ], + ) + def test_sitemap_follow(self, follow, result): + sitemap = b""" + + + http://www.example.com/sitemap1.xml + + """ + r = TextResponse(url="http://www.example.com/sitemap.xml", body=sitemap) + + class _FollowSpider(self.spider_class): + sitemap_follow = [follow] + + spider = _FollowSpider("example.com") + urls = [req.url for req in spider._parse_sitemap(r)] + assert urls == result + def test_compression_bomb_setting(self): settings = {"DOWNLOAD_MAXSIZE": 10_000_000} crawler = get_crawler(settings_dict=settings) diff --git a/tests/test_utils_sitemap.py b/tests/test_utils_sitemap.py index 464a31777..f2bcd7541 100644 --- a/tests/test_utils_sitemap.py +++ b/tests/test_utils_sitemap.py @@ -1,3 +1,5 @@ +import warnings + from scrapy.utils.sitemap import Sitemap, sitemap_urls_from_robots @@ -156,7 +158,7 @@ def test_sitemap_wrong_ns2(): def test_sitemap_urls_from_robots(): - robots = """User-agent: * + robots = b"""User-agent: * Disallow: /aff/ Disallow: /wl/ @@ -182,6 +184,40 @@ Disallow: /forum/active/ ] +def test_sitemap_urls_from_robots_str_compat(): + robots = """User-agent: * +Disallow: /aff/ +Disallow: /wl/ + +# Search and shopping refining +Disallow: /s*/*facet +Disallow: /s*/*tags + +# Sitemap files +Sitemap: http://example.com/sitemap.xml +Sitemap: http://example.com/sitemap-product-index.xml +Sitemap: HTTP://example.com/sitemap-uppercase.xml +Sitemap: /sitemap-relative-url.xml + +# Forums +Disallow: /forum/search/ +Disallow: /forum/active/ +""" + + with warnings.catch_warnings(record=True) as w: + assert list( + sitemap_urls_from_robots(robots, base_url="http://example.com") + ) == [ + "http://example.com/sitemap.xml", + "http://example.com/sitemap-product-index.xml", + "http://example.com/sitemap-uppercase.xml", + "http://example.com/sitemap-relative-url.xml", + ] + assert "Passing `str` type as `robots_text` is deprecated, use `bytes`" in str( + w[0].message + ) + + def test_sitemap_blanklines(): """Assert we can deal with starting blank lines before tag""" s = Sitemap( From 13a014d2e6b5b9a3eca67a8f3d9b8e2c7b214f1d Mon Sep 17 00:00:00 2001 From: "Albert Eduardovich N." Date: Wed, 8 Apr 2026 15:25:11 +0300 Subject: [PATCH 104/248] Response now uses less memory (#7374) --- docs/news.rst | 20 ++++++++++++ scrapy/http/request/__init__.py | 8 +++-- scrapy/http/response/__init__.py | 52 +++++++++++++++++++++++++++----- scrapy/http/response/html.py | 2 +- scrapy/http/response/json.py | 2 +- scrapy/http/response/text.py | 17 ++++++++--- scrapy/http/response/xml.py | 2 +- tests/test_http_request.py | 4 +++ tests/test_http_response.py | 47 +++++++++++++++++++++++++++++ 9 files changed, 136 insertions(+), 18 deletions(-) diff --git a/docs/news.rst b/docs/news.rst index e445b0498..4f9d8ab45 100644 --- a/docs/news.rst +++ b/docs/news.rst @@ -24,6 +24,26 @@ Backward-incompatible changes (:issue:`2183`, :issue:`6369`, :issue:`7182`) +- ``Request`` and ``Response`` objects: ``__slots__`` and setter changes: + + - :class:`scrapy.http.Request` and :class:`scrapy.http.Response` now + define ``__slots__``. Assigning arbitrary attributes to instances (for + example, ``response.foo = 1``) will raise ``AttributeError``. Store + per-request/response data in the request/response ``meta`` mapping + instead of attaching new attributes to the objects. + + - If you maintain custom ``Request`` or ``Response`` subclasses that + relied on dynamic instance attributes, either add ``'__dict__'`` to + your subclass ``__slots__`` to allow dynamic attributes, or migrate + per-instance state to ``meta`` or explicit documented attributes. + + - The setters for ``headers``, ``flags`` and ``cookies`` no longer coerce + falsy values into ``None``. For example, ``request.headers = {}`` now + stores an empty :class:`scrapy.http.headers.Headers` instance (not + ``None``), and ``request.flags = []`` remains an empty list instead of + being set to ``None``. Update code that relied on ``is None`` checks or + the previous coercion behaviour. + New features ~~~~~~~~~~~~ diff --git a/scrapy/http/request/__init__.py b/scrapy/http/request/__init__.py index f523c2bb9..00042e093 100644 --- a/scrapy/http/request/__init__.py +++ b/scrapy/http/request/__init__.py @@ -284,7 +284,7 @@ class Request(object_ref): @flags.setter def flags(self, value: list[str] | None) -> None: - self._flags = value or None + self._flags = value @property def cookies(self) -> CookiesT: @@ -294,7 +294,7 @@ class Request(object_ref): @cookies.setter def cookies(self, value: CookiesT | None) -> None: - self._cookies = value or None + self._cookies = value @property def headers(self) -> Headers: @@ -309,7 +309,9 @@ class Request(object_ref): if isinstance(value, Headers): self._headers = value else: - self._headers = Headers(value, encoding=self.encoding) if value else None + self._headers = ( + Headers(value, encoding=self.encoding) if value is not None else None + ) def __repr__(self) -> str: return f"<{self.method} {self.url}>" diff --git a/scrapy/http/response/__init__.py b/scrapy/http/response/__init__.py index 7e23df491..a80ea3da8 100644 --- a/scrapy/http/response/__init__.py +++ b/scrapy/http/response/__init__.py @@ -38,17 +38,20 @@ class Response(object_ref): downloaded (by the Downloader) and fed to the Spiders for processing. """ - attributes: tuple[str, ...] = ( - "url", + __attrs_and_slots = ( "status", - "headers", - "body", - "flags", "request", "certificate", "ip_address", "protocol", ) + attributes: tuple[str, ...] = ( + "url", + "headers", + "body", + "flags", + *__attrs_and_slots, + ) """A tuple of :class:`str` objects containing the name of all public attributes of the class that are also keyword parameters of the ``__init__()`` method. @@ -56,6 +59,16 @@ class Response(object_ref): Currently used by :meth:`Response.replace`. """ + __slots__ = ( + "__weakref__", + "_url", + "_body", + "_headers", + "_flags", + *__attrs_and_slots, + ) + del __attrs_and_slots + def __init__( self, url: str, @@ -68,12 +81,12 @@ class Response(object_ref): ip_address: IPv4Address | IPv6Address | None = None, protocol: str | None = None, ): - self.headers: Headers = Headers(headers or {}) + self._headers: Headers | None = Headers(headers) if headers else None self.status: int = int(status) self._set_body(body) self._set_url(url) self.request: Request | None = request - self.flags: list[str] = [] if flags is None else list(flags) + self._flags: list[str] | None = list(flags) if flags else None self.certificate: Certificate | None = certificate self.ip_address: IPv4Address | IPv6Address | None = ip_address self.protocol: str | None = protocol @@ -125,6 +138,31 @@ class Response(object_ref): else: self._body = body + @property + def headers(self) -> Headers: + if self._headers is None: + self._headers = Headers() + return self._headers + + @headers.setter + def headers( + self, value: Mapping[AnyStr, Any] | Iterable[tuple[AnyStr, Any]] | None + ) -> None: + if isinstance(value, Headers): + self._headers = value + else: + self._headers = Headers(value) if value is not None else None + + @property + def flags(self) -> list[str]: + if self._flags is None: + self._flags = [] + return self._flags + + @flags.setter + def flags(self, value: list[str] | None) -> None: + self._flags = value + def __repr__(self) -> str: return f"<{self.status} {self.url}>" diff --git a/scrapy/http/response/html.py b/scrapy/http/response/html.py index 7eed052c2..70c08c11d 100644 --- a/scrapy/http/response/html.py +++ b/scrapy/http/response/html.py @@ -9,4 +9,4 @@ from scrapy.http.response.text import TextResponse class HtmlResponse(TextResponse): - pass + __slots__ = () diff --git a/scrapy/http/response/json.py b/scrapy/http/response/json.py index 219691094..0428dde6e 100644 --- a/scrapy/http/response/json.py +++ b/scrapy/http/response/json.py @@ -9,4 +9,4 @@ from scrapy.http.response.text import TextResponse class JsonResponse(TextResponse): - pass + __slots__ = () diff --git a/scrapy/http/response/text.py b/scrapy/http/response/text.py index 077b86a33..13853f64d 100644 --- a/scrapy/http/response/text.py +++ b/scrapy/http/response/text.py @@ -41,15 +41,22 @@ _NONE = object() class TextResponse(Response): _DEFAULT_ENCODING = "ascii" - _cached_decoded_json = _NONE attributes: tuple[str, ...] = (*Response.attributes, "encoding") + __slots__ = ( + "_cached_benc", + "_cached_decoded_json", + "_cached_selector", + "_cached_ubody", + "_encoding", + ) def __init__(self, *args: Any, **kwargs: Any): self._encoding: str | None = kwargs.pop("encoding", None) self._cached_benc: str | None = None self._cached_ubody: str | None = None self._cached_selector: Selector | None = None + self._cached_decoded_json: object = _NONE super().__init__(*args, **kwargs) def _set_body(self, body: str | bytes | None) -> None: @@ -100,7 +107,7 @@ class TextResponse(Response): @memoizemethod_noargs def _headers_encoding(self) -> str | None: - content_type = cast("bytes", self.headers.get(b"Content-Type", b"")) + content_type = self.headers.get(b"Content-Type") or b"" return http_content_type_encoding(to_unicode(content_type, encoding="latin-1")) def _body_inferred_encoding(self) -> str: @@ -138,10 +145,10 @@ class TextResponse(Response): @property def selector(self) -> Selector: - # circular import - from scrapy.selector import Selector # noqa: PLC0415 - if self._cached_selector is None: + # circular import + from scrapy.selector import Selector # noqa: PLC0415 + self._cached_selector = Selector(self) return self._cached_selector diff --git a/scrapy/http/response/xml.py b/scrapy/http/response/xml.py index abf474a2f..6d9c4cb73 100644 --- a/scrapy/http/response/xml.py +++ b/scrapy/http/response/xml.py @@ -9,4 +9,4 @@ from scrapy.http.response.text import TextResponse class XmlResponse(TextResponse): - pass + __slots__ = () diff --git a/tests/test_http_request.py b/tests/test_http_request.py index 81f8fa3c1..4c77bb1ec 100644 --- a/tests/test_http_request.py +++ b/tests/test_http_request.py @@ -349,6 +349,7 @@ class TestRequest: assert request._flags == [] original_flags = request.flags request.flags = None + assert request._flags is None assert request.flags == [] assert request.flags is not original_flags @@ -358,6 +359,7 @@ class TestRequest: assert request._cookies == {} original_cookies = request.cookies request.cookies = None + assert request._cookies is None assert request.cookies == {} assert request.cookies is not original_cookies @@ -373,7 +375,9 @@ class TestRequest: assert isinstance(request._headers, Headers) original_headers = request.headers request.headers = None + assert request._headers is None assert request.headers == {} + assert request._headers == {} assert request.headers is not original_headers def test_no_callback(self): diff --git a/tests/test_http_response.py b/tests/test_http_response.py index 1becf1542..09c95dc29 100644 --- a/tests/test_http_response.py +++ b/tests/test_http_response.py @@ -160,6 +160,53 @@ class TestResponse: with pytest.raises(AttributeError): r.body = "xxx" + def test_setter_mutable_lazy_loading(self): + """Mutable attributes are set internally to None only until they are + read, then they always return the same falsy instance of the + corresponding mutable structure. + + Setting them to None causes the next read to return a different object. + """ + + response = self.response_class("http://example.com") + + response.request = Request("http://example.com") + + assert response._flags is None + assert response.flags == [] + assert response.flags is response.flags + assert response._flags == [] + original_flags = response.flags + response.flags = None + assert response._flags is None + assert response.flags == [] + assert response.flags is not original_flags + + assert response._headers is None + assert response.headers == {} + assert response.headers is response.headers + assert isinstance(response.headers, Headers) + assert isinstance(response._headers, Headers) + original_headers = response.headers + response.headers = None + assert response._headers is None + assert response.headers == {} + assert response._headers == {} + assert response.headers is not original_headers + + def test_setters(self): + response = self.response_class("http://example.com") + + response.flags = ["f1"] + assert response.flags == ["f1"] + + headers = Headers({b"X-Test": b"1"}) + response.headers = headers + assert response._headers is headers + response.headers = {b"A": b"b"} + assert isinstance(response.headers, Headers) + assert response._headers[b"A"] == b"b" + def test_urljoin(self): """Test urljoin shortcut (only for existence, since behavior equals urljoin)""" joined = self.response_class("http://www.example.com").urljoin("/test") From 5b3761361809eeefc86e7fb59abe66c3b8ac8aec Mon Sep 17 00:00:00 2001 From: "Albert Eduardovich N." Date: Wed, 8 Apr 2026 15:59:00 +0300 Subject: [PATCH 105/248] Improve commands (#7376) --- scrapy/cmdline.py | 25 ++++++++++++------- scrapy/commands/__init__.py | 2 +- scrapy/commands/check.py | 18 ++++++++------ scrapy/commands/genspider.py | 24 ++++++++++++------ scrapy/commands/list.py | 3 +-- scrapy/commands/startproject.py | 10 ++++---- tests/test_commands.py | 43 +++++++++++++++++---------------- 7 files changed, 71 insertions(+), 54 deletions(-) diff --git a/scrapy/cmdline.py b/scrapy/cmdline.py index 8aadc90e7..c43aa626d 100644 --- a/scrapy/cmdline.py +++ b/scrapy/cmdline.py @@ -108,17 +108,24 @@ def _print_header(settings: BaseSettings, inproject: bool) -> None: def _print_commands(settings: BaseSettings, inproject: bool) -> None: _print_header(settings, inproject) - print("Usage:") - print(" scrapy [options] [args]\n") - print("Available commands:") + print( + "Usage:\n", + " scrapy [options] [args]\n", + "Available commands:\n", + ) cmds = _get_commands_dict(settings, inproject) - for cmdname, cmdclass in sorted(cmds.items()): - print(f" {cmdname:<13} {cmdclass.short_desc()}") + print( + "\n".join( + f" {cmdname:<13} {cmdclass.short_desc()}" + for cmdname, cmdclass in sorted(cmds.items()) + ) + ) if not inproject: - print() - print(" [ more ] More commands available when run from project directory") - print() - print('Use "scrapy -h" to see more info about a command') + print( + "\n", + " [ more ] More commands available when run from project directory", + ) + print("\n", 'Use "scrapy -h" to see more info about a command') def _print_unknown_command_msg( diff --git a/scrapy/commands/__init__.py b/scrapy/commands/__init__.py index e213d0d94..19b6f6681 100644 --- a/scrapy/commands/__init__.py +++ b/scrapy/commands/__init__.py @@ -229,7 +229,7 @@ class ScrapyHelpFormatter(argparse.HelpFormatter): headings = [ i for i in range(len(part_strings)) if part_strings[i].endswith(":\n") ] - for index in headings[::-1]: + for index in reversed(headings): char = "-" if "Global Options" in part_strings[index] else "=" part_strings[index] = part_strings[index][:-2].title() underline = "".join(["\n", (char * len(part_strings[index])), "\n"]) diff --git a/scrapy/commands/check.py b/scrapy/commands/check.py index 1e4e09135..229e8a487 100644 --- a/scrapy/commands/check.py +++ b/scrapy/commands/check.py @@ -102,16 +102,18 @@ class Command(ScrapyCommand): # start checks if opts.list: - for spider, methods in sorted(contract_reqs.items()): - if not methods and not opts.verbose: - continue - print(spider) - for method in sorted(methods): - print(f" * {method}") + print( + "\n".join( + f"{spider}\n" + + "\n".join(f" * {method}" for method in sorted(methods)) + for spider, methods in sorted(contract_reqs.items()) + if methods or opts.verbose + ) + ) else: - start_time = time.time() + start_time = time.monotonic() self.crawler_process.start() - stop = time.time() + stop = time.monotonic() result.printErrors() result.printSummary(start_time, stop) diff --git a/scrapy/commands/genspider.py b/scrapy/commands/genspider.py index b030be4de..17bcf19b0 100644 --- a/scrapy/commands/genspider.py +++ b/scrapy/commands/genspider.py @@ -173,15 +173,21 @@ class Command(ScrapyCommand): template_file = Path(self.templates_dir, f"{template}.tmpl") if template_file.exists(): return template_file - print(f"Unable to find template: {template}\n") - print('Use "scrapy genspider --list" to see all available templates.') + print( + f"Unable to find template: {template}\n", + 'Use "scrapy genspider --list" to see all available templates.', + ) return None def _list_templates(self) -> None: - print("Available templates:") - for file in sorted(Path(self.templates_dir).iterdir()): - if file.suffix == ".tmpl": - print(f" {file.stem}") + print( + "Available templates:\n", + "\n".join( + f" {file.stem}" + for file in sorted(Path(self.templates_dir).iterdir()) + if file.suffix == ".tmpl" + ), + ) def _spider_exists(self, name: str) -> bool: assert self.settings is not None @@ -200,8 +206,10 @@ class Command(ScrapyCommand): pass else: # if spider with same name exists - print(f"Spider {name!r} already exists in module:") - print(f" {spidercls.__module__}") + print( + f"Spider {name!r} already exists in module:\n", + f" {spidercls.__module__}", + ) return True # a file with the same name exists in the target directory diff --git a/scrapy/commands/list.py b/scrapy/commands/list.py index ad55dba66..a037f5c9b 100644 --- a/scrapy/commands/list.py +++ b/scrapy/commands/list.py @@ -20,5 +20,4 @@ class Command(ScrapyCommand): def run(self, args: list[str], opts: argparse.Namespace) -> None: assert self.settings is not None spider_loader = get_spider_loader(self.settings) - for s in sorted(spider_loader.list()): - print(s) + print("\n".join(sorted(spider_loader.list()))) diff --git a/scrapy/commands/startproject.py b/scrapy/commands/startproject.py index c56a7319a..5661a0a48 100644 --- a/scrapy/commands/startproject.py +++ b/scrapy/commands/startproject.py @@ -123,12 +123,12 @@ class Command(ScrapyCommand): ) print( f"New Scrapy project '{project_name}', using template directory " - f"'{self.templates_dir}', created in:" + f"'{self.templates_dir}', created in:\n", + f" {project_dir.resolve()}\n\n", + "You can start your first spider with:\n", + f" cd {project_dir}\n", + " scrapy genspider example example.com", ) - print(f" {project_dir.resolve()}\n") - print("You can start your first spider with:") - print(f" cd {project_dir}") - print(" scrapy genspider example example.com") @property def templates_dir(self) -> str: diff --git a/tests/test_commands.py b/tests/test_commands.py index 1e91aa0b0..edb03da1b 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -378,27 +378,28 @@ class TestViewCommand: class TestHelpMessage(TestProjectBase): - COMMANDS = [ - "parse", - "startproject", - "view", - "crawl", - "edit", - "list", - "fetch", - "settings", - "shell", - "runspider", - "version", - "genspider", - "check", - "bench", - ] - - def test_help_messages(self, proj_path: Path) -> None: - for command in self.COMMANDS: - _, out, _ = proc(command, "-h", cwd=proj_path) - assert "Usage" in out + @pytest.mark.parametrize( + "command", + [ + "parse", + "startproject", + "view", + "crawl", + "edit", + "list", + "fetch", + "settings", + "shell", + "runspider", + "version", + "genspider", + "check", + "bench", + ], + ) + def test_help_messages(self, proj_path: Path, command: str) -> None: + _, out, _ = proc(command, "-h", cwd=proj_path) + assert "Usage" in out class TestPopCommandName: From b9be5ce053cfe2685dce045c98ffbbc8811c1eac Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Wed, 8 Apr 2026 18:24:51 +0500 Subject: [PATCH 106/248] Reactorless shell and other small shell improvements (#7395) * Use AsyncCrawlerProcess if TWISTED_REACTOR_ENABLED=False. * defer.Deferred -> Deferred. * Allow Shell with TWISTED_REACTOR_ENABLED=False. * Shell workflow notes. * Remove an unused argument. * Shell.fetch_available. * Shell._use_reactor. * Some more comments. * Simplify _schedule() and fix the spider shell var. * More async def. * Reactorless shell support. * Add pragma: no cover. * More notes. * Remove a TODO. --- docs/topics/asyncio.rst | 1 - docs/topics/shell.rst | 3 - scrapy/cmdline.py | 7 +- scrapy/commands/shell.py | 51 ++++++++--- scrapy/shell.py | 173 ++++++++++++++++++++++++++++-------- tests/test_command_shell.py | 18 ++-- 6 files changed, 184 insertions(+), 69 deletions(-) diff --git a/docs/topics/asyncio.rst b/docs/topics/asyncio.rst index 63bde8279..8efa2559e 100644 --- a/docs/topics/asyncio.rst +++ b/docs/topics/asyncio.rst @@ -177,7 +177,6 @@ in future Scrapy versions. The following features are not available: :class:`~scrapy.core.downloader.handlers._httpx.HttpxDownloadHandler`) * :class:`~scrapy.core.downloader.handlers.ftp.FTPDownloadHandler` * :class:`~scrapy.core.downloader.handlers.http2.H2DownloadHandler` -* :ref:`topics-shell` * :ref:`topics-telnetconsole` * :class:`~scrapy.crawler.CrawlerRunner` and :class:`~scrapy.crawler.CrawlerProcess` diff --git a/docs/topics/shell.rst b/docs/topics/shell.rst index ca5457b2a..8ae8ff512 100644 --- a/docs/topics/shell.rst +++ b/docs/topics/shell.rst @@ -17,9 +17,6 @@ spider, without having to run the spider to test every change. Once you get familiarized with the Scrapy shell, you'll see that it's an invaluable tool for developing and debugging your spiders. -.. note:: - This feature is not supported when :setting:`TWISTED_REACTOR_ENABLED` is ``False``. - Configuring the shell ===================== diff --git a/scrapy/cmdline.py b/scrapy/cmdline.py index c43aa626d..6c306afdb 100644 --- a/scrapy/cmdline.py +++ b/scrapy/cmdline.py @@ -204,9 +204,10 @@ def execute(argv: list[str] | None = None, settings: Settings | None = None) -> _run_print_help(parser, cmd.process_options, args, opts) if cmd.requires_crawler_process: - if settings[ - "TWISTED_REACTOR" - ] == _asyncio_reactor_path and not settings.getbool("FORCE_CRAWLER_PROCESS"): + if ( + settings["TWISTED_REACTOR"] == _asyncio_reactor_path + and not settings.getbool("FORCE_CRAWLER_PROCESS") + ) or not settings.getbool("TWISTED_REACTOR_ENABLED"): cmd.crawler_process = AsyncCrawlerProcess(settings) else: cmd.crawler_process = CrawlerProcess(settings) diff --git a/scrapy/commands/shell.py b/scrapy/commands/shell.py index 8db5f4b49..19138ffd0 100644 --- a/scrapy/commands/shell.py +++ b/scrapy/commands/shell.py @@ -6,10 +6,12 @@ See documentation in docs/topics/shell.rst from __future__ import annotations +import asyncio from threading import Thread from typing import TYPE_CHECKING, Any, ClassVar from scrapy.commands import ScrapyCommand +from scrapy.crawler import AsyncCrawlerProcess, Crawler from scrapy.http import Request from scrapy.shell import Shell from scrapy.utils.defer import _schedule_coro @@ -83,20 +85,47 @@ class Command(ScrapyCommand): # crawling engine, so the set up in the crawl method won't work crawler = self.crawler_process._create_crawler(spidercls) crawler._apply_settings() - if not crawler.settings.getbool("TWISTED_REACTOR_ENABLED"): - raise RuntimeError( - "scrapy shell currently doesn't support TWISTED_REACTOR_ENABLED=False" - ) - # The Shell class needs a persistent engine in the crawler - crawler.engine = crawler._create_engine() - _schedule_coro(crawler.engine.start_async(_start_request_processing=False)) - - self._start_crawler_thread() - - shell = Shell(crawler, update_vars=self.update_vars, code=opts.code) + loop: asyncio.AbstractEventLoop | None = None + if crawler.settings.getbool("TWISTED_REACTOR_ENABLED"): + self._init_with_reactor(crawler) + else: + self._init_without_reactor(crawler) + loop = self._get_reactorless_loop() + shell = Shell(crawler, update_vars=self.update_vars, code=opts.code, loop=loop) shell.start(url=url, redirect=not opts.no_redirect) + def _init_with_reactor(self, crawler: Crawler) -> None: + # Create the engine and run start_async() in the main thread + crawler.engine = crawler._create_engine() + _schedule_coro(crawler.engine.start_async(_start_request_processing=False)) + self._start_crawler_thread() + + def _init_without_reactor(self, crawler: Crawler) -> None: + # Create the engine and run start_async() in the event loop thread + loop = self._get_reactorless_loop() + self._start_crawler_thread() + + async def _init_engine() -> None: + # We may need to wait until some parts of start_async() have + # finished, which may need a special event in the engine and may + # wait until https://github.com/scrapy/scrapy/issues/6916 + crawler.engine = crawler._create_engine() + loop.create_task( + crawler.engine.start_async(_start_request_processing=False) + ) + + future = asyncio.run_coroutine_threadsafe(_init_engine(), loop) + future.result() + + def _get_reactorless_loop(self) -> asyncio.AbstractEventLoop: + assert self.crawler_process + assert isinstance(self.crawler_process, AsyncCrawlerProcess) + loop = self.crawler_process._reactorless_loop + assert loop + return loop + def _start_crawler_thread(self) -> None: + """Run self.crawler_process.start() in a separate thread.""" assert self.crawler_process t = Thread( target=self.crawler_process.start, diff --git a/scrapy/shell.py b/scrapy/shell.py index 6a9464d74..0966d9f55 100644 --- a/scrapy/shell.py +++ b/scrapy/shell.py @@ -6,34 +6,95 @@ See documentation in docs/topics/shell.rst from __future__ import annotations +import asyncio import contextlib import os import signal +import warnings from typing import TYPE_CHECKING, Any from itemadapter import is_item -from twisted.internet import defer, threads +from twisted.internet import threads +from twisted.internet.defer import Deferred from twisted.python import threadable from w3lib.url import any_to_uri import scrapy from scrapy.crawler import Crawler -from scrapy.exceptions import IgnoreRequest +from scrapy.exceptions import IgnoreRequest, ScrapyDeprecationWarning from scrapy.http import Request, Response from scrapy.settings import Settings from scrapy.spiders import Spider from scrapy.utils.conf import get_config from scrapy.utils.console import DEFAULT_PYTHON_SHELLS, start_python_console from scrapy.utils.datatypes import SequenceExclude -from scrapy.utils.defer import _schedule_coro, deferred_f_from_coro_f +from scrapy.utils.defer import ( + _schedule_coro, + deferred_f_from_coro_f, + maybe_deferred_to_future, +) from scrapy.utils.misc import load_object -from scrapy.utils.python import global_object_name from scrapy.utils.reactor import is_asyncio_reactor_installed, set_asyncio_event_loop from scrapy.utils.response import open_in_browser if TYPE_CHECKING: from collections.abc import Callable +# Hopefully temporary architecture notes +# +# The Shell class is always instantiated in the "main" thread. There are two +# official ways to use it: +# 1. scrapy.commands.shell, which makes a secondary thread and calls +# CrawlerProcess.start() in it, which runs a reactor there. +# 2. scrapy.shell.inspect_response(), which just creates Shell() in the current +# thread. +# +# Shell._inthread is True when this class is run in a thread separate from the +# reactor, e.g. the 1st way (in other words, the reactor is in a secondary +# thread). +# Shell._inthread is False when this class is run in the same thread as the +# reactor, e.g. the 2nd way. +# The only thing that differs is availability of fetch() (it needs the +# reactor to be in a separate thread: the shell sends the request to +# the reactor and waits for the result synchronously). +# +# Thus the only thing Shell needs an event loop for is fetch(). More machinery +# is used for it to work. In chronological order: +# 1. scrapy.commands.shell.Command.run() creates a crawler and an engine, then +# calls +# _schedule_coro(crawler.engine.start_async(_start_request_processing=False)), +# which initializes the engine but doesn't start processing of requests. +# 2. scrapy.commands.shell.Command.run() calls crawler_process.start() in a +# thread which starts a reactor in that thread. +# 3. When fetch() is called, it prepares a request and calls Shell._schedule() +# in the reactor thread (via threads.blockingCallFromThread()). +# 4. Shell._schedule() calls Shell._open_spider() (on the first call). +# 5. Shell._open_spider() calls engine.open_spider_async(close_if_idle=False) +# and engine._start_request_processing(). +# 6. Shell._schedule() calls engine.crawl(request), scheduling the request. +# 7. Shell._schedule() via _request_deferred() waits until the request callback +# is called. When it's called, the response becomes available. +# +# In the reactorless mode this is slightly different, the engine initialization +# happens in the event loop thread as many things need either a reactor or a +# running event loop. +# +# Side note: it should be possible to remove _request_deferred() by using +# engine.download() instead of engine.schedule(), losing the usual stuff like +# spider middlewares (none of which should be important). +# +# Other architecture problems: +# * scrapy.cmdline.execute() creates an AsyncCrawlerProcess instance which +# immediately installs a reactor (which is maybe not thread-specific?) or an +# event loop (which *is* thread-specific, so the main thread will always have +# a (not running) loop installed. +# * scrapy.commands.shell.Command.run() calls _schedule_coro() in the main +# thread, and various engine init code also calls similar things, +# conceptually this shouldn't work (and doesn't in the reactorless mode, so +# there the initialization is moved to the event loop thread). +# * The engine has several code paths specifically for the shell, and the shell +# uses several private members of the engine and of AsyncCrawlerProcess. + class Shell: relevant_classes: tuple[type, ...] = (Crawler, Spider, Request, Response, Settings) @@ -43,21 +104,47 @@ class Shell: crawler: Crawler, update_vars: Callable[[dict[str, Any]], None] | None = None, code: str | None = None, + *, + loop: asyncio.AbstractEventLoop | None = None, ): - self.crawler: Crawler = crawler - if not crawler.settings.getbool("TWISTED_REACTOR_ENABLED"): # pragma: no cover + self._use_reactor = crawler.settings.getbool("TWISTED_REACTOR_ENABLED") + if not self._use_reactor and not loop: # pragma: no cover raise RuntimeError( - f"{global_object_name(self.__class__)} currently doesn't support TWISTED_REACTOR_ENABLED=False." + "Shell needs the crawler loop reference when TWISTED_REACTOR_ENABLED=False." ) + self._loop = loop + self.crawler: Crawler = crawler self.update_vars: Callable[[dict[str, Any]], None] = update_vars or ( lambda x: None ) self.item_class: type = load_object(crawler.settings["DEFAULT_ITEM_CLASS"]) self.spider: Spider | None = None - self.inthread: bool = not threadable.isInIOThread() + if self._use_reactor: + self._inthread: bool = not threadable.isInIOThread() + else: + try: + # in case there is also a running loop in the main thread + current_loop = asyncio.get_running_loop() + self._inthread = current_loop is not self._loop + except RuntimeError: + self._inthread = True self.code: str | None = code self.vars: dict[str, Any] = {} + @property + def inthread(self) -> bool: # pragma: no cover + warnings.warn( + "Shell.inthread is deprecated, use Shell.fetch_available instead.", + ScrapyDeprecationWarning, + stacklevel=2, + ) + return self._inthread + + @property + def fetch_available(self) -> bool: + """Whether fetch() can be used.""" + return self._inthread + def start( self, url: str | None = None, @@ -103,28 +190,24 @@ class Shell: self.vars, shells=shells, banner=self.vars.pop("banner", "") ) - def _schedule(self, request: Request, spider: Spider | None) -> defer.Deferred[Any]: - if is_asyncio_reactor_installed(): + async def _schedule(self, request: Request, spider: Spider | None) -> Response: + """Send the request to the engine, wait for the result. + + Runs in the reactor thread. + """ + if self._use_reactor and is_asyncio_reactor_installed(): # set the asyncio event loop for the current thread event_loop_path = self.crawler.settings["ASYNCIO_EVENT_LOOP"] set_asyncio_event_loop(event_loop_path) + if not self.spider: + await self._open_spider(spider) + assert self.crawler.engine is not None + # send the request to the engine + self.crawler.engine.crawl(request) + # this will fire when the request callback runs (via the callback hijacking in _request_deferred()) + return await maybe_deferred_to_future(_request_deferred(request)) - def crawl_request(_: None) -> None: - assert self.crawler.engine is not None - self.crawler.engine.crawl(request) - - d2 = self._open_spider(request, spider) - d2.addCallback(crawl_request) - - d = _request_deferred(request) - d.addCallback(lambda x: (x, spider)) - return d - - @deferred_f_from_coro_f - async def _open_spider(self, request: Request, spider: Spider | None) -> None: - if self.spider: - return - + async def _open_spider(self, spider: Spider | None) -> None: if spider is None: spider = self.crawler.spider or self.crawler._create_spider() @@ -141,8 +224,6 @@ class Shell: redirect: bool = True, **kwargs: Any, ) -> None: - from twisted.internet import reactor - if isinstance(request_or_url, Request): request = request_or_url else: @@ -154,12 +235,22 @@ class Shell: ) else: request.meta["handle_httpstatus_all"] = True - response = None - with contextlib.suppress(IgnoreRequest): - response, spider = threads.blockingCallFromThread( - reactor, self._schedule, request, spider - ) - self.populate_vars(response, request, spider) + response: Response | None = None + if self._use_reactor: + from twisted.internet import reactor + + with contextlib.suppress(IgnoreRequest): + response = threads.blockingCallFromThread( + reactor, deferred_f_from_coro_f(self._schedule), request, spider + ) + else: + assert self._loop + with contextlib.suppress(IgnoreRequest): + future = asyncio.run_coroutine_threadsafe( + self._schedule(request, spider), self._loop + ) + response = future.result() + self.populate_vars(response, request, self.spider) def populate_vars( self, @@ -174,7 +265,7 @@ class Shell: self.vars["spider"] = spider self.vars["request"] = request self.vars["response"] = response - if self.inthread: + if self.fetch_available: self.vars["fetch"] = self.fetch self.vars["view"] = open_in_browser self.vars["shelp"] = self.print_help @@ -195,7 +286,7 @@ class Shell: if self._is_relevant(v): b.append(f" {k:<10} {v}") b.append("Useful shortcuts:") - if self.inthread: + if self.fetch_available: b.append( " fetch(url[, redirect=True]) " "Fetch URL and update local objects (by default, redirects are followed)" @@ -218,11 +309,15 @@ def inspect_response(response: Response, spider: Spider) -> None: # Shell.start removes the SIGINT handler, so save it and re-add it after # the shell has closed sigint_handler = signal.getsignal(signal.SIGINT) - Shell(spider.crawler).start(response=response, spider=spider) + if not spider.crawler.settings.getbool("TWISTED_REACTOR_ENABLED"): + loop = asyncio.get_running_loop() + else: + loop = None + Shell(spider.crawler, loop=loop).start(response=response, spider=spider) signal.signal(signal.SIGINT, sigint_handler) -def _request_deferred(request: Request) -> defer.Deferred[Any]: +def _request_deferred(request: Request) -> Deferred[Any]: """Wrap a request inside a Deferred. This function is harmful, do not use it until you know what you are doing. @@ -241,7 +336,7 @@ def _request_deferred(request: Request) -> defer.Deferred[Any]: request.errback = request_errback return result - d: defer.Deferred[Any] = defer.Deferred() + d: Deferred[Any] = Deferred() d.addBoth(_restore_callbacks) if request.callback: d.addCallback(request.callback) diff --git a/tests/test_command_shell.py b/tests/test_command_shell.py index 6a12f28bb..b407211dd 100644 --- a/tests/test_command_shell.py +++ b/tests/test_command_shell.py @@ -22,6 +22,12 @@ class TestShellCommand: _, out, _ = proc("shell", "-c", "item") assert "{}" in out + def test_empty_no_reactor(self) -> None: + _, out, _ = proc( + "shell", "-c", "item", "--set", "TWISTED_REACTOR_ENABLED=False" + ) + assert "{}" in out + def test_response_body(self, mockserver: MockServer) -> None: _, out, _ = proc("shell", mockserver.url("/text"), "-c", "response.body") assert "Works" in out @@ -125,7 +131,6 @@ class TestShellCommand: assert ret == 0, err assert "RuntimeError: There is no current event loop in thread" not in err - @pytest.mark.xfail(reason="Not implemented yet", strict=True) def test_shell_fetch_no_reactor(self, mockserver: MockServer) -> None: url = mockserver.url("/html") code = f"fetch('{url}')" @@ -134,17 +139,6 @@ class TestShellCommand: ) assert ret == 0, err - def test_no_reactor_unsupported(self) -> None: - # to be removed when it's supported - ret, out, err = proc( - "shell", "-c", "item", "--set", "TWISTED_REACTOR_ENABLED=False" - ) - assert ret == 1, out or err - assert ( - "RuntimeError: scrapy shell currently doesn't support TWISTED_REACTOR_ENABLED=False" - in err - ) - class TestInteractiveShell: def test_fetch(self, mockserver: MockServer) -> None: From 2b174e348d88d19dd32135e8e483c4eb784aeca8 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Wed, 8 Apr 2026 18:54:13 +0500 Subject: [PATCH 107/248] Silence the CertificateOptions method warning. (#7410) * Silence the CertificateOptions method warning. * Fix typing. --- scrapy/core/downloader/contextfactory.py | 50 ++++++++++++++++-------- 1 file changed, 34 insertions(+), 16 deletions(-) diff --git a/scrapy/core/downloader/contextfactory.py b/scrapy/core/downloader/contextfactory.py index a0e0dd933..ef0a46a8e 100644 --- a/scrapy/core/downloader/contextfactory.py +++ b/scrapy/core/downloader/contextfactory.py @@ -1,6 +1,7 @@ from __future__ import annotations import warnings +from contextlib import contextmanager from typing import TYPE_CHECKING, Any, cast from OpenSSL import SSL @@ -25,6 +26,8 @@ from scrapy.utils.deprecate import create_deprecated_class from scrapy.utils.misc import build_from_crawler, load_object if TYPE_CHECKING: + from collections.abc import Generator + from twisted.internet._sslverify import ClientTLSOptions # typing.Self requires Python 3.11 @@ -34,6 +37,18 @@ if TYPE_CHECKING: from scrapy.settings import BaseSettings +@contextmanager +def _filter_method_warning() -> Generator[None]: + with warnings.catch_warnings(): + # Twisted deprecation, https://github.com/scrapy/scrapy/issues/3288 + warnings.filterwarnings( + "ignore", + message=r"Passing method to twisted\.internet\.ssl\.CertificateOptions", + category=DeprecationWarning, + ) + yield + + @implementer(IPolicyForHTTPS) class _ScrapyClientContextFactory(BrowserLikePolicyForHTTPS): """Non-peer-certificate verifying HTTPS context factory. @@ -63,11 +78,12 @@ class _ScrapyClientContextFactory(BrowserLikePolicyForHTTPS): self.tls_ciphers = AcceptableCiphers.fromOpenSSLCipherString(tls_ciphers) else: self.tls_ciphers = DEFAULT_CIPHERS - self._certificate_options = CertificateOptions( - method=self._ssl_method, - fixBrokenPeers=True, - acceptableCiphers=self.tls_ciphers, - ) + with _filter_method_warning(): + self._certificate_options = CertificateOptions( + method=self._ssl_method, + fixBrokenPeers=True, + acceptableCiphers=self.tls_ciphers, + ) self._ctx = self._get_context() self._verify_certificates = verify_certificates @@ -110,13 +126,14 @@ class _ScrapyClientContextFactory(BrowserLikePolicyForHTTPS): if not self._verify_certificates: return _ScrapyClientTLSOptions(hostname.decode("ascii"), self._ctx) # type: ignore[no-untyped-call] # Note that this doesn't use self._ctx - return optionsForClientTLS( - hostname=hostname.decode("ascii"), - extraCertificateOptions={ - "method": self._ssl_method, - "acceptableCiphers": self.tls_ciphers, - }, - ) + with _filter_method_warning(): + return optionsForClientTLS( + hostname=hostname.decode("ascii"), + extraCertificateOptions={ + "method": self._ssl_method, + "acceptableCiphers": self.tls_ciphers, + }, + ) ScrapyClientContextFactory = create_deprecated_class( @@ -160,10 +177,11 @@ class BrowserLikeContextFactory(_ScrapyClientContextFactory): super().__init__(*args, **kwargs) def creatorForNetloc(self, hostname: bytes, port: int) -> ClientTLSOptions: - return optionsForClientTLS( - hostname=hostname.decode("ascii"), - extraCertificateOptions={"method": self._ssl_method}, - ) + with _filter_method_warning(): + return optionsForClientTLS( + hostname=hostname.decode("ascii"), + extraCertificateOptions={"method": self._ssl_method}, + ) @implementer(IPolicyForHTTPS) From b68f26726ac87c5950a4258a8e29bb7ec2e0ebc1 Mon Sep 17 00:00:00 2001 From: Adrian Date: Wed, 8 Apr 2026 16:10:21 +0200 Subject: [PATCH 108/248] Fix DownloaderAwarePriorityQueue tie-breaking across slots (#7351) --- scrapy/pqueues.py | 32 ++++++++++++++++++++++++++-- tests/test_pqueues.py | 49 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 2 deletions(-) diff --git a/scrapy/pqueues.py b/scrapy/pqueues.py index ad0b36f6b..0ad0b5d78 100644 --- a/scrapy/pqueues.py +++ b/scrapy/pqueues.py @@ -350,10 +350,38 @@ class DownloaderAwarePriorityQueue: self.crawler: Crawler = crawler self.pqueues: dict[str, ScrapyPriorityQueue] = {} # slot -> priority queue + self._last_selected_slot: str | None = None if slot_startprios: for slot, startprios in slot_startprios.items(): self.pqueues[slot] = self.pqfactory(slot, startprios) + def _next_slot(self, stats: list[tuple[int, str]], *, update_state: bool) -> str: + last = self._last_selected_slot + min_active: int | None = None + best_slot: str | None = None + best_slot_after_last: str | None = None + for active, slot in stats: + if min_active is None or active < min_active: + min_active = active + best_slot = slot + best_slot_after_last = None + if last is not None and slot > last: + best_slot_after_last = slot + elif active == min_active: + if best_slot is None or slot < best_slot: + best_slot = slot + if ( + last is not None + and slot > last + and (best_slot_after_last is None or slot < best_slot_after_last) + ): + best_slot_after_last = slot + assert best_slot is not None + slot = best_slot_after_last if best_slot_after_last is not None else best_slot + if update_state: + self._last_selected_slot = slot + return slot + def pqfactory( self, slot: str, startprios: Iterable[int] = () ) -> ScrapyPriorityQueue: @@ -371,7 +399,7 @@ class DownloaderAwarePriorityQueue: if not stats: return None - slot = min(stats)[1] + slot = self._next_slot(stats, update_state=True) queue = self.pqueues[slot] request = queue.pop() if len(queue) == 0: @@ -395,7 +423,7 @@ class DownloaderAwarePriorityQueue: stats = self._downloader_interface.stats(self.pqueues) if not stats: return None - slot = min(stats)[1] + slot = self._next_slot(stats, update_state=False) queue = self.pqueues[slot] return queue.peek() diff --git a/tests/test_pqueues.py b/tests/test_pqueues.py index 350b3e10d..7be9241b9 100644 --- a/tests/test_pqueues.py +++ b/tests/test_pqueues.py @@ -4,6 +4,7 @@ from unittest.mock import Mock import pytest import queuelib +from scrapy.core.downloader import Downloader from scrapy.http.request import Request from scrapy.pqueues import DownloaderAwarePriorityQueue, ScrapyPriorityQueue from scrapy.spiders import Spider @@ -158,6 +159,54 @@ class TestDownloaderAwarePriorityQueue: assert self.queue.pop().url == req3.url assert self.queue.peek() is None + def test_tie_breaking_rotates_slots(self): + # No active downloads are tracked in the downloader, so every slot has + # the same score and tie-breaking must not starve a slot. + req_a1 = Request("https://example.org/a1") + req_a1.meta[Downloader.DOWNLOAD_SLOT] = "slot-a" + req_b1 = Request("https://example.org/b1") + req_b1.meta[Downloader.DOWNLOAD_SLOT] = "slot-b" + req_a2 = Request("https://example.org/a2") + req_a2.meta[Downloader.DOWNLOAD_SLOT] = "slot-a" + req_b2 = Request("https://example.org/b2") + req_b2.meta[Downloader.DOWNLOAD_SLOT] = "slot-b" + + for request in (req_a1, req_b1, req_a2, req_b2): + self.queue.push(request) + + slots = [ + self.queue.pop().meta[Downloader.DOWNLOAD_SLOT], + self.queue.pop().meta[Downloader.DOWNLOAD_SLOT], + self.queue.pop().meta[Downloader.DOWNLOAD_SLOT], + self.queue.pop().meta[Downloader.DOWNLOAD_SLOT], + ] + + assert slots == ["slot-a", "slot-b", "slot-a", "slot-b"] + + def test_tie_breaking_keeps_rotation_after_selected_slot_is_deleted(self): + # If the selected slot becomes empty, rotation should continue from + # that slot marker to avoid restarting from the smallest slot. + req_a1 = Request("https://example.org/a1") + req_a1.meta[Downloader.DOWNLOAD_SLOT] = "slot-a" + req_a2 = Request("https://example.org/a2") + req_a2.meta[Downloader.DOWNLOAD_SLOT] = "slot-a" + req_b1 = Request("https://example.org/b1") + req_b1.meta[Downloader.DOWNLOAD_SLOT] = "slot-b" + req_c1 = Request("https://example.org/c1") + req_c1.meta[Downloader.DOWNLOAD_SLOT] = "slot-c" + + for request in (req_a1, req_a2, req_b1, req_c1): + self.queue.push(request) + + slots = [ + self.queue.pop().meta[Downloader.DOWNLOAD_SLOT], + self.queue.pop().meta[Downloader.DOWNLOAD_SLOT], + self.queue.pop().meta[Downloader.DOWNLOAD_SLOT], + self.queue.pop().meta[Downloader.DOWNLOAD_SLOT], + ] + + assert slots == ["slot-a", "slot-b", "slot-c", "slot-a"] + @pytest.mark.parametrize( ("input_", "output"), From 7e881ce2d7037dd3a073fc400aa39a999e4d8902 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Thu, 9 Apr 2026 16:56:30 +0500 Subject: [PATCH 109/248] Release notes for 2.15.0 (#7373) * Cover 2.15.0 in the release notes (up to 31bf7c3). * Replace VERSION. * Document unified download handler exceptions. * Updates. * Cover 2.15.0 in the release notes (up to 0c6ccf5). * Cover 2.15.0 in the release notes (up to b68f267). * Run linkcheck and fix issues. * Cleanup unused link targets. * Cleanup. --- docs/contributing.rst | 5 +- docs/faq.rst | 1 - docs/intro/install.rst | 4 +- docs/intro/overview.rst | 2 +- docs/news.rst | 322 +++++++++++++++++++++- docs/topics/download-handlers.rst | 26 ++ docs/topics/downloader-middleware.rst | 4 +- docs/topics/dynamic-content.rst | 3 - docs/topics/feed-exports.rst | 4 +- docs/topics/link-extractors.rst | 2 - docs/topics/loaders.rst | 1 - docs/topics/media-pipeline.rst | 6 +- docs/topics/practices.rst | 2 +- docs/topics/selectors.rst | 12 +- docs/topics/settings.rst | 14 +- docs/topics/shell.rst | 6 +- docs/versioning.rst | 5 +- scrapy/core/downloader/handlers/_httpx.py | 2 +- scrapy/utils/asyncio.py | 4 +- scrapy/utils/decorators.py | 2 +- scrapy/utils/reactorless.py | 2 +- scrapy/utils/sitemap.py | 10 +- 22 files changed, 379 insertions(+), 60 deletions(-) diff --git a/docs/contributing.rst b/docs/contributing.rst index 3976d34c2..c868a0ac4 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -258,7 +258,7 @@ Scrapy: * Don't put your name in the code you contribute; git provides enough metadata to identify author of the code. - See https://docs.github.com/en/get-started/getting-started-with-git/setting-your-username-in-git + See https://docs.github.com/en/get-started/git-basics/setting-your-username-in-git for setup instructions. .. _scrapy-pre-commit: @@ -390,8 +390,7 @@ And their unit-tests are in:: .. _issue tracker: https://github.com/scrapy/scrapy/issues .. _scrapy-users: https://groups.google.com/forum/#!forum/scrapy-users -.. _Scrapy subreddit: https://reddit.com/r/scrapy -.. _AUTHORS: https://github.com/scrapy/scrapy/blob/master/AUTHORS +.. _Scrapy subreddit: https://www.reddit.com/r/scrapy/ .. _tests/: https://github.com/scrapy/scrapy/tree/master/tests .. _open issues: https://github.com/scrapy/scrapy/issues .. _PEP 257: https://peps.python.org/pep-0257/ diff --git a/docs/faq.rst b/docs/faq.rst index e117d2a5f..87adbfa4b 100644 --- a/docs/faq.rst +++ b/docs/faq.rst @@ -418,4 +418,3 @@ See :issue:`2680`. .. _has been reported: https://github.com/scrapy/scrapy/issues/2905 .. _Python standard library modules: https://docs.python.org/3/py-modindex.html .. _Python package: https://pypi.org/ -.. _user agents: https://en.wikipedia.org/wiki/User_agent diff --git a/docs/intro/install.rst b/docs/intro/install.rst index 0810e7274..8cef04ff1 100644 --- a/docs/intro/install.rst +++ b/docs/intro/install.rst @@ -263,7 +263,6 @@ reinstall Twisted with the :code:`tls` extra option:: For details, see `Issue #2473 `_. .. _Python: https://www.python.org/ -.. _pip: https://pip.pypa.io/en/latest/installing/ .. _lxml: https://lxml.de/index.html .. _parsel: https://pypi.org/project/parsel/ .. _w3lib: https://pypi.org/project/w3lib/ @@ -273,8 +272,7 @@ For details, see `Issue #2473 `_. .. _setuptools: https://pypi.org/pypi/setuptools .. _homebrew: https://brew.sh/ .. _zsh: https://www.zsh.org/ -.. _Anaconda: https://docs.anaconda.com/anaconda/ +.. _Anaconda: https://www.anaconda.com/docs/main .. _Miniconda: https://docs.conda.io/projects/conda/en/latest/user-guide/install/index.html -.. _Visual Studio: https://docs.microsoft.com/en-us/visualstudio/install/install-visual-studio .. _Microsoft C++ Build Tools: https://visualstudio.microsoft.com/visual-cpp-build-tools/ .. _conda-forge: https://conda-forge.org/ diff --git a/docs/intro/overview.rst b/docs/intro/overview.rst index d05e46551..ee91ce7ca 100644 --- a/docs/intro/overview.rst +++ b/docs/intro/overview.rst @@ -150,7 +150,7 @@ The next steps for you are to :ref:`install Scrapy `, a full-blown Scrapy project and `join the community`_. Thanks for your interest! -.. _join the community: https://scrapy.org/community/ +.. _join the community: https://www.scrapy.org/community .. _web scraping: https://en.wikipedia.org/wiki/Web_scraping .. _Amazon Associates Web Services: https://affiliate-program.amazon.com/welcome/ecs .. _Amazon S3: https://aws.amazon.com/s3/ diff --git a/docs/news.rst b/docs/news.rst index 4f9d8ab45..24c20477c 100644 --- a/docs/news.rst +++ b/docs/news.rst @@ -3,13 +3,28 @@ Release notes ============= -Scrapy VERSION (unreleased) ---------------------------- +.. _release-2.15.0: + +Scrapy 2.15.0 (unreleased) +-------------------------- + +Highlights: + +- Experimental support for running without a Twisted reactor + +- Experimental ``httpx``-based download handler Backward-incompatible changes ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -- In order to fix a long-standing bug with handling of asynchronous storages +- The built-in HTTP :ref:`download handlers ` now + raise Scrapy-specific exceptions instead of implementation-specific ones, + see :ref:`download-handlers-exceptions`. This can affect user code that + handles downloader exceptions, such as ``process_exception()`` methods of + custom :ref:`downloader middlewares `. + (:issue:`7208`) + +- In order to fix a long-standing bug with handling of asynchronous storages, the following changes were made to media pipeline classes, which can impact some of the user code that subclasses them or calls their methods directly: @@ -44,14 +59,308 @@ Backward-incompatible changes being set to ``None``. Update code that relied on ``is None`` checks or the previous coercion behaviour. + (:issue:`7036`, :issue:`7367`, :issue:`7374`) + +Deprecation removals +~~~~~~~~~~~~~~~~~~~~ + +- The context factory class set as the value of the + ``DOWNLOADER_CLIENTCONTEXTFACTORY`` setting is now required to support the + ``method`` argument of ``__init__()``, recommended since Scrapy 1.2.0. + (:issue:`7353`) + +Deprecations +~~~~~~~~~~~~ + +- ``scrapy.mail.MailSender`` is deprecated. Please use :mod:`smtplib`, + :mod:`twisted.mail.smtp` or other 3rd party email libraries. + (:issue:`7249`, :issue:`7263`) + +- The ``scrapy.extensions.statsmailer.StatsMailer`` extension is deprecated. + You can instead implement your own notifications by handling the + :signal:`spider_closed` signal. + (:issue:`7249`, :issue:`7263`) + +- The ``MEMUSAGE_NOTIFY_MAIL`` setting is deprecated. You can instead + implement your own notifications by handling the + :signal:`memusage_warning_reached` and :signal:`spider_closed` signals. + (:issue:`7249`, :issue:`7263`) + +- The ``DNS_RESOLVER`` setting was renamed to :setting:`TWISTED_DNS_RESOLVER` + and the old name is deprecated. + (:issue:`7350`, :issue:`7361`) + +- The ``DOWNLOADER_CLIENTCONTEXTFACTORY`` setting is deprecated. If you were + using it to switch to + ``scrapy.core.downloader.contextfactory.BrowserLikeContextFactory``, please + use the new :setting:`DOWNLOAD_VERIFY_CERTIFICATES` setting instead. If you + cannot use the default context factory for some other reason, please + subclass the :ref:`download handler ` instead. + (:issue:`7352`, :issue:`7379`) + +- ``scrapy.core.downloader.contextfactory.BrowserLikeContextFactory`` is + deprecated. You can set the new :setting:`DOWNLOAD_VERIFY_CERTIFICATES` + setting to ``True`` instead. + (:issue:`7379`) + +- The following implementation details of the context factory handling code + are deprecated: + + - ``scrapy.core.downloader.contextfactory.AcceptableProtocolsContextFactory`` + + - ``scrapy.core.downloader.contextfactory.load_context_factory_from_settings()`` + + - ``scrapy.core.downloader.contextfactory.ScrapyClientContextFactory`` + + - ``scrapy.core.downloader.tls.ScrapyClientTLSOptions`` + + (:issue:`7353`, :issue:`7391`) + +- Passing :class:`str` instead of :class:`bytes` to + :class:`scrapy.utils.sitemap.Sitemap` and + :func:`scrapy.utils.sitemap.sitemap_urls_from_robots` is deprecated. + (:issue:`7007`) + +- ``scrapy.utils.misc.walk_modules()`` is deprecated. You can use + :func:`scrapy.utils.misc.walk_modules_iter` instead. + (:issue:`7388`) + +- ``scrapy.shell.Shell.inthread`` is deprecated. You can use + :attr:`scrapy.shell.Shell.fetch_available` instead to check if + :func:`~scrapy.shell.Shell.fetch` can be used. + (:issue:`7395`) + +- ``scrapy.commands.ScrapyCommand.set_crawler()`` is deprecated. + (:issue:`7276`) + New features ~~~~~~~~~~~~ +- Added an *experimental* mode for running Scrapy without installing a + Twisted reactor: set :setting:`TWISTED_REACTOR_ENABLED` to ``False`` to + enable it. This mode has limitations, refer to :ref:`its documentation + ` for details. As long as it's experimental, its + behavior and related features and APIs may change in future Scrapy releases + in a breaking way. + (:issue:`6219`, + :issue:`7185`, + :issue:`7186`, + :issue:`7187`, + :issue:`7188`, + :issue:`7190`, + :issue:`7197`, + :issue:`7199`, + :issue:`7209`, + :issue:`7228`, + :issue:`7355`, + :issue:`7366`, + :issue:`7385`, + :issue:`7395`) + +- Added the :func:`scrapy.utils.reactorless.is_reactorless` function that + checks if there is a running asyncio event loop but no Twisted reactor. + (:issue:`7185`, :issue:`7199`) + +- Changed :func:`scrapy.utils.asyncio.is_asyncio_available` to return + ``True`` if there is a running asyncio loop, even if no Twisted reactor is + installed. + (:issue:`7185`, :issue:`7199`) + +- Added an *experimental* download handler that uses the httpx_ library and + doesn't require a Twisted reactor: + :class:`~scrapy.core.downloader.handlers._httpx.HttpxDownloadHandler`. As + long as it's experimental, its behavior may change in future Scrapy + releases in a breaking way. + (:issue:`6805`, :issue:`7239`, :issue:`7368`, :issue:`7384`) + + .. _httpx: https://www.python-httpx.org/ + +- Added the :setting:`DOWNLOAD_BIND_ADDRESS` setting as a global counterpart + to the per-request :reqmeta:`bindaddress` meta key. + (:issue:`7266`, :issue:`7283`) + +- Added the :setting:`DOWNLOAD_VERIFY_CERTIFICATES` setting that can be set + to ``True`` to make Scrapy abort HTTPS requests when the server certificate + is invalid or doesn't match the domain. + (:issue:`7379`) + +- The built-in HTTP :ref:`download handlers ` now + raise Scrapy-specific exceptions instead of implementation-specific ones, + to allow unified handling of similar problems caused by different + implementations. The default value of the :setting:`RETRY_EXCEPTIONS` + setting was updated replacing Twisted-specific exceptions with these new + ones. The exceptions: + + - :exc:`~scrapy.exceptions.CannotResolveHostError` + + - :exc:`~scrapy.exceptions.DownloadCancelledError` + + - :exc:`~scrapy.exceptions.DownloadConnectionRefusedError` + + - :exc:`~scrapy.exceptions.DownloadFailedError` + + - :exc:`~scrapy.exceptions.DownloadTimeoutError` + + - :exc:`~scrapy.exceptions.ResponseDataLossError` + + - :exc:`~scrapy.exceptions.UnsupportedURLSchemeError` + + (:issue:`7208`) + +- Added the :signal:`memusage_warning_reached` signal emitted by the + :class:`~scrapy.extensions.memusage.MemoryUsage` extension when the memory + usage reaches :setting:`MEMUSAGE_WARNING_MB`. + (:issue:`7249`, :issue:`7263`) + - Added :meth:`Headers.to_tuple_list() ` that returns headers as a list of ``(key, value)`` tuples. (:issue:`7239`) +- :class:`~scrapy.core.downloader.handlers.s3.S3DownloadHandler` now uses the + download handler configured for the ``"https"`` scheme to make requests + instead of always using + :class:`~scrapy.core.downloader.handlers.http11.HTTP11DownloadHandler`. + (:issue:`7369`, :issue:`7370`) + +- Added :func:`scrapy.utils.misc.walk_modules_iter` as a replacement for + ``scrapy.utils.misc.walk_modules()`` that returns an iterable instead of a + list. + (:issue:`7388`) + +Improvements +~~~~~~~~~~~~ + +- :func:`asyncio.to_thread` is now used instead of + :func:`twisted.internet.threads.deferToThread` in the built-in feed + storages, media pipeline storages and the + :func:`scrapy.utils.decorators.inthread` decorator when available. + (:issue:`7183`, :issue:`7184`, :issue:`7349`) + +- Improved memory footprint of :class:`~scrapy.Request` and + :class:`~scrapy.http.Response` objects by adding ``__slots__`` and omitting + empty lists and dicts in some internal attributes. + (:issue:`7036`, :issue:`7367`, :issue:`7374`) + +- :class:`~scrapy.core.downloader.contextfactory._ScrapyClientContextFactory` + no longer mutates the SSL context, to avoid the behavior that was + deprecated in pyOpenSSL 25.1.0. + (:issue:`6859`, :issue:`7353`) + +- Improved memory usage of :class:`~scrapy.spiders.sitemap.SitemapSpider` and + :class:`scrapy.utils.sitemap.Sitemap`. + (:issue:`3529`, :issue:`7007`) + +- Improved the scheduling behavior of + :class:`~scrapy.pqueues.DownloaderAwarePriorityQueue` when crawling + multiple domains. + (:issue:`7293`, :issue:`7351`) + +- :class:`~scrapy.core.downloader.handlers.http11.HTTP11DownloadHandler` and + :class:`~scrapy.core.downloader.handlers.http2.H2DownloadHandler` now handle + TLS verbose logging (see :setting:`DOWNLOADER_CLIENT_TLS_VERBOSE_LOGGING`) + directly instead of relying on + :class:`~scrapy.core.downloader.contextfactory._ScrapyClientContextFactory`. + (:issue:`7387`) + +- The server certificate verification code now correctly handles certificates + with IP addresses in ``subjectAltName``. + (:issue:`7353`) + +- Improved reliability of :func:`scrapy.utils.trackref.get_oldest`. + (:issue:`1758`, :issue:`7375`) + +- Other code refactoring and improvements. + (:issue:`7210`, :issue:`7238`, :issue:`7376`, :issue:`7386`, :issue:`7395`, + :issue:`7405`, :issue:`7410`) + +Bug fixes +~~~~~~~~~ + +- :ref:`Media pipelines ` should now wait for uploads + to asynchronous storages (e.g. + :class:`~scrapy.pipelines.files.S3FilesStore`) to complete. + (:issue:`2183`, :issue:`6369`, :issue:`7182`) + +- Fixed merging ``*_BASE`` settings (e.g. merging + :setting:`DOWNLOADER_MIDDLEWARES` with + :setting:`DOWNLOADER_MIDDLEWARES_BASE`) when a component is referred to by + a class object in one setting and by a string import path in the other one. + (:issue:`6912`, :issue:`6993`) + +- ``scrapy runspider`` and ``scrapy crawl`` now set the exit code to 1 if an + exception happened early (this was broken in an earlier Scrapy version). + (:issue:`6820`, :issue:`7255`) + +- Fixed repeated warnings about data loss (see + :setting:`DOWNLOAD_FAIL_ON_DATALOSS`) not being suppressed in + :class:`~scrapy.core.downloader.handlers.http11.HTTP11DownloadHandler`. + (:issue:`7222`) + +- Improved FTP connection management in + :class:`scrapy.pipelines.files.FTPFilesStore`. + (:issue:`7256`) + +- Fixed the ``spider`` variable in the :ref:`shell `, which + wasn't available since Scrapy 2.13.0. + (:issue:`7395`) + +Documentation +~~~~~~~~~~~~~ + +- The ``llms.txt`` and ``llms-full.txt`` files and Markdown versions of pages + are now generated when the HTML documentation is built. + (:issue:`7380`) + +- Added a "Copy as Markdown" button to the HTML documentation. + (:issue:`7380`) + +- Added :ref:`docs for using Pydantic models as items `. + (:issue:`6955`, :issue:`6966`) + +- Documented :ref:`job directory contents `. + (:issue:`4842`, :issue:`5260`) + +- Improved docs for :attr:`~scrapy.Request.dont_filter`. + (:issue:`6398`, :issue:`7245`) + +- Clarified that settings related to :setting:`TWISTED_DNS_RESOLVER` are only + taken into account if the selected resolver supports them. + (:issue:`7385`) + +- Other documentation improvements and fixes. + (:issue:`7248`, :issue:`7274`, :issue:`7406`, :issue:`7408`) + +Quality assurance +~~~~~~~~~~~~~~~~~ + +- Added the ``no-reactor`` test environment that doesn't install a Twisted + reactor and uses ``pytest-asyncio`` instead of ``pytest-twisted`` to run + asynchronous test functions. + (:issue:`6952`, :issue:`7189`, :issue:`7233`, :issue:`7234`, :issue:`7254`, + :issue:`7259`) + +- Fixed running tests with ``pytest-xdist``. + (:issue:`7216`, :issue:`7257`) + +- Type hints improvements and fixes. + (:issue:`7300`, :issue:`7331`) + +- CI and test improvements and fixes. + (:issue:`7060`, + :issue:`7223`, + :issue:`7232`, + :issue:`7241`, + :issue:`7250`, + :issue:`7256`, + :issue:`7276`, + :issue:`7277`, + :issue:`7279`, + :issue:`7329`, + :issue:`7363`, + :issue:`7381`, + :issue:`7402`) + .. _release-2.14.2: Scrapy 2.14.2 (2026-03-12) @@ -4939,7 +5248,7 @@ New features components already supported (:issue:`4126`) * :class:`scrapy.utils.python.MutableChain.__iter__` now returns ``self``, - `allowing it to be used as a sequence `_ + allowing it to be used as a sequence. (:issue:`4153`) @@ -6880,8 +7189,7 @@ Keep reading for more details on other improvements and bug fixes. Beta Python 3 Support ~~~~~~~~~~~~~~~~~~~~~ -We have been `hard at work to make Scrapy run on Python 3 -`_. As a result, now +We have been hard at work to make Scrapy run on Python 3. As a result, now you can run spiders on Python 3.3, 3.4 and 3.5 (Twisted >= 15.5 required). Some features are still missing (and some may never be ported). @@ -6949,7 +7257,7 @@ Additional New Features and Enhancements - Other refactoring, optimizations and cleanup (:issue:`1476`, :issue:`1481`, :issue:`1477`, :issue:`1315`, :issue:`1290`, :issue:`1750`, :issue:`1881`). -.. _`Code of Conduct`: https://github.com/scrapy/scrapy/blob/master/CODE_OF_CONDUCT.md +.. _Code of Conduct: https://github.com/scrapy/scrapy/blob/master/CODE_OF_CONDUCT.md Deprecations and Removals diff --git a/docs/topics/download-handlers.rst b/docs/topics/download-handlers.rst index 182ea613a..96cf46c35 100644 --- a/docs/topics/download-handlers.rst +++ b/docs/topics/download-handlers.rst @@ -102,6 +102,32 @@ An optional base class for custom handlers is provided: :undoc-members: :member-order: bysource +.. _download-handlers-exceptions: + +Exceptions raised by download handlers +====================================== + +.. versionadded:: 2.15.0 + +The built-in download handlers raise Scrapy-specific exceptions instead of +implementation-specific ones, so that code that handles these exceptions can be +written in a generic way. We recommend custom download handlers to also use +these exceptions. + +.. autoexception:: scrapy.exceptions.CannotResolveHostError + +.. autoexception:: scrapy.exceptions.DownloadCancelledError + +.. autoexception:: scrapy.exceptions.DownloadConnectionRefusedError + +.. autoexception:: scrapy.exceptions.DownloadFailedError + +.. autoexception:: scrapy.exceptions.DownloadTimeoutError + +.. autoexception:: scrapy.exceptions.ResponseDataLossError + +.. autoexception:: scrapy.exceptions.UnsupportedURLSchemeError + .. _download-handlers-ref: Built-in download handlers reference diff --git a/docs/topics/downloader-middleware.rst b/docs/topics/downloader-middleware.rst index 64722f0e3..75e48be41 100644 --- a/docs/topics/downloader-middleware.rst +++ b/docs/topics/downloader-middleware.rst @@ -1103,7 +1103,7 @@ Parsers vary in several aspects: * Support for wildcard matching -* Usage of `length based rule `_: +* Usage of `length based rule `_: in particular for ``Allow`` and ``Disallow`` directives, where the most specific rule based on the length of the path trumps the less specific (shorter) rule @@ -1121,7 +1121,7 @@ Based on `Protego `_: * implemented in Python * is compliant with `Google's Robots.txt Specification - `_ + `_ * supports wildcard matching diff --git a/docs/topics/dynamic-content.rst b/docs/topics/dynamic-content.rst index 18b3ce244..090caa6e0 100644 --- a/docs/topics/dynamic-content.rst +++ b/docs/topics/dynamic-content.rst @@ -274,16 +274,13 @@ However, using `playwright-python`_ directly as in the above example circumvents most of the Scrapy components (middlewares, dupefilter, etc). We recommend using `scrapy-playwright`_ for a better integration. -.. _AJAX: https://en.wikipedia.org/wiki/Ajax_%28programming%29 .. _CSS: https://en.wikipedia.org/wiki/Cascading_Style_Sheets -.. _JavaScript: https://en.wikipedia.org/wiki/JavaScript .. _chompjs: https://github.com/Nykakin/chompjs .. _curl: https://curl.se/ .. _headless browser: https://en.wikipedia.org/wiki/Headless_browser .. _js2xml: https://github.com/scrapinghub/js2xml .. _playwright-python: https://github.com/microsoft/playwright-python .. _playwright: https://github.com/microsoft/playwright -.. _pyppeteer: https://pyppeteer.github.io/pyppeteer/ .. _pytesseract: https://github.com/madmaze/pytesseract .. _scrapy-playwright: https://github.com/scrapy-plugins/scrapy-playwright .. _tabula-py: https://github.com/chezou/tabula-py diff --git a/docs/topics/feed-exports.rst b/docs/topics/feed-exports.rst index a5537c39e..6ce5255b9 100644 --- a/docs/topics/feed-exports.rst +++ b/docs/topics/feed-exports.rst @@ -246,7 +246,7 @@ The feeds are stored on `Google Cloud Storage`_. - Required external libraries: `google-cloud-storage`_. -For more information about authentication, please refer to `Google Cloud documentation `_. +For more information about authentication, please refer to `Google Cloud documentation `_. You can set a *Project ID* and *Access Control List (ACL)* through the following settings: @@ -261,7 +261,7 @@ storage backend is: ``True``. This storage backend uses :ref:`delayed file delivery `. -.. _google-cloud-storage: https://cloud.google.com/storage/docs/reference/libraries#client-libraries-install-python +.. _google-cloud-storage: https://docs.cloud.google.com/storage/docs/reference/libraries#client-libraries-install-python .. _topics-feed-storage-stdout: diff --git a/docs/topics/link-extractors.rst b/docs/topics/link-extractors.rst index 7e3517937..613e175da 100644 --- a/docs/topics/link-extractors.rst +++ b/docs/topics/link-extractors.rst @@ -159,5 +159,3 @@ Link :synopsis: Link from link extractors .. autoclass:: Link - -.. _scrapy.linkextractors: https://github.com/scrapy/scrapy/blob/master/scrapy/linkextractors/__init__.py diff --git a/docs/topics/loaders.rst b/docs/topics/loaders.rst index a43edb28c..5ad005893 100644 --- a/docs/topics/loaders.rst +++ b/docs/topics/loaders.rst @@ -452,4 +452,3 @@ organization of your Loaders collection - that's up to you and your project's needs. .. _itemloaders: https://itemloaders.readthedocs.io/en/latest/ -.. _processors: https://itemloaders.readthedocs.io/en/latest/built-in-processors.html diff --git a/docs/topics/media-pipeline.rst b/docs/topics/media-pipeline.rst index 8636e29c9..8c04c578d 100644 --- a/docs/topics/media-pipeline.rst +++ b/docs/topics/media-pipeline.rst @@ -290,7 +290,7 @@ Google Cloud Storage :setting:`FILES_STORE` and :setting:`IMAGES_STORE` can represent a Google Cloud Storage bucket. Scrapy will automatically upload the files to the bucket. (requires `google-cloud-storage`_ ) -.. _google-cloud-storage: https://cloud.google.com/storage/docs/reference/libraries#client-libraries-install-python +.. _google-cloud-storage: https://docs.cloud.google.com/storage/docs/reference/libraries#client-libraries-install-python For example, these are valid :setting:`IMAGES_STORE` and :setting:`GCS_PROJECT_ID` settings: @@ -301,7 +301,7 @@ For example, these are valid :setting:`IMAGES_STORE` and :setting:`GCS_PROJECT_I For information about authentication, see this `documentation`_. -.. _documentation: https://cloud.google.com/docs/authentication +.. _documentation: https://docs.cloud.google.com/docs/authentication You can modify the Access Control List (ACL) policy used for the stored files, which is defined by the :setting:`FILES_STORE_GCS_ACL` and @@ -316,7 +316,7 @@ policy: For more information, see `Predefined ACLs`_ in the Google Cloud Platform Developer Guide. -.. _Predefined ACLs: https://cloud.google.com/storage/docs/access-control/lists#predefined-acl +.. _Predefined ACLs: https://docs.cloud.google.com/storage/docs/access-control/lists#predefined-acl Usage example ============= diff --git a/docs/topics/practices.rst b/docs/topics/practices.rst index b0a2cd135..4f036db29 100644 --- a/docs/topics/practices.rst +++ b/docs/topics/practices.rst @@ -417,7 +417,7 @@ If you are still unable to prevent your bot getting banned, consider contacting `commercial support`_. .. _Tor project: https://www.torproject.org/ -.. _commercial support: https://scrapy.org/support/ +.. _commercial support: https://www.scrapy.org/companies .. _ProxyMesh: https://proxymesh.com/ .. _Common Crawl: https://commoncrawl.org/ .. _testspiders: https://github.com/scrapinghub/testspiders diff --git a/docs/topics/selectors.rst b/docs/topics/selectors.rst index 40a85201a..35ea2a200 100644 --- a/docs/topics/selectors.rst +++ b/docs/topics/selectors.rst @@ -543,7 +543,7 @@ you may want to take a look first at this `XPath tutorial`_. .. note:: Some of the tips are based on `this post from Zyte's blog`_. -.. _`XPath tutorial`: http://www.zvon.org/comp/r/tut-XPath_1.html +.. _XPath tutorial: http://www.zvon.org/comp/r/tut-XPath_1.html .. _this post from Zyte's blog: https://www.zyte.com/blog/xpath-tips-from-the-web-scraping-trenches/ @@ -728,7 +728,7 @@ But using the ``.`` to mean the node, works: >>> sel.xpath("//a[contains(., 'Next Page')]").getall() ['Click here to go to the Next Page'] -.. _`XPath string function`: https://www.w3.org/TR/xpath-10/#section-String-Functions +.. _XPath string function: https://www.w3.org/TR/xpath-10/#section-String-Functions .. _topics-selectors-xpath-variables: @@ -983,9 +983,9 @@ Here we first iterate over ``itemscope`` elements, and for each one, we look for all ``itemprops`` elements and exclude those that are themselves inside another ``itemscope``. -.. _EXSLT: http://exslt.org/ -.. _regular expressions: http://exslt.org/regexp/index.html -.. _set manipulation: http://exslt.org/set/index.html +.. _EXSLT: https://exslt.github.io/ +.. _regular expressions: https://exslt.github.io/regexp/index.html +.. _set manipulation: https://exslt.github.io/set/index.html Other XPath extensions ---------------------- @@ -1190,4 +1190,4 @@ instantiated with an :class:`~scrapy.http.XmlResponse` object: .. skip: end -.. _Google Base XML feed: https://support.google.com/merchants/answer/160589?hl=en&ref_topic=2473799 +.. _Google Base XML feed: https://support.google.com/merchants/answer/14987622 diff --git a/docs/topics/settings.rst b/docs/topics/settings.rst index 97c8f41de..84219095b 100644 --- a/docs/topics/settings.rst +++ b/docs/topics/settings.rst @@ -787,7 +787,7 @@ the TLS-related libraries. DOWNLOADER_MIDDLEWARES ---------------------- -Default:: ``{}`` +Default: ``{}`` A dict containing the downloader middlewares enabled in your project, and their orders. For more info see :ref:`topics-downloader-middleware-setting`. @@ -1239,7 +1239,7 @@ command will prefer it over the default setting. EXTENSIONS ---------- -Default:: ``{}`` +Default: ``{}`` :ref:`Component priority dictionary ` of enabled extensions. See :ref:`topics-extensions`. @@ -1287,7 +1287,7 @@ FEED_STORAGE_GCS_ACL -------------------- The Access Control List (ACL) used when storing items to :ref:`Google Cloud Storage `. -For more information on how to set this value, please refer to the column *JSON API* in `Google Cloud documentation `_. +For more information on how to set this value, please refer to the column *JSON API* in `Google Cloud documentation `_. .. setting:: FORCE_CRAWLER_PROCESS @@ -1861,7 +1861,7 @@ Scrapy does not process new requests. SPIDER_CONTRACTS ---------------- -Default:: ``{}`` +Default: ``{}`` A dict containing the spider contracts enabled in your project, used for testing spiders. For more info see :ref:`topics-contracts`. @@ -1922,7 +1922,7 @@ warning by setting ``SPIDER_LOADER_WARN_ONLY = True``. SPIDER_MIDDLEWARES ------------------ -Default:: ``{}`` +Default: ``{}`` A dict containing the spider middlewares enabled in your project, and their orders. For more info see :ref:`topics-spider-middleware-setting`. @@ -2162,7 +2162,7 @@ Use ``0`` to allow URLs of any length. The default value is copied from the `Microsoft Internet Explorer maximum URL length`_, even though this setting exists for different reasons. -.. _Microsoft Internet Explorer maximum URL length: https://support.microsoft.com/en-us/topic/maximum-url-length-is-2-083-characters-in-internet-explorer-174e7c8a-6666-f4e0-6fd6-908b53c12246 +.. _Microsoft Internet Explorer maximum URL length: https://web.archive.org/web/20250206050143/https://support.microsoft.com/en-us/topic/maximum-url-length-is-2-083-characters-in-internet-explorer-174e7c8a-6666-f4e0-6fd6-908b53c12246 .. setting:: USER_AGENT @@ -2201,6 +2201,4 @@ case to see how to enable and use them. .. settingslist:: .. _Amazon web services: https://aws.amazon.com/ -.. _breadth-first order: https://en.wikipedia.org/wiki/Breadth-first_search -.. _depth-first order: https://en.wikipedia.org/wiki/Depth-first_search .. _Google Cloud Storage: https://cloud.google.com/storage/ diff --git a/docs/topics/shell.rst b/docs/topics/shell.rst index 8ae8ff512..5b826ed18 100644 --- a/docs/topics/shell.rst +++ b/docs/topics/shell.rst @@ -40,7 +40,7 @@ variable; or by defining it in your :ref:`scrapy.cfg `:: shell = bpython .. _IPython: https://ipython.org/ -.. _IPython installation guide: https://ipython.org/install.html +.. _IPython installation guide: https://ipython.org/install/ .. _bpython: https://bpython-interpreter.org/ Launch the shell @@ -111,7 +111,7 @@ Available Shortcuts Note, however, that this will create a temporary file in your computer, which won't be removed automatically. -.. _ tag: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base +.. _ tag: https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/base Available Scrapy objects ------------------------ @@ -145,7 +145,7 @@ Example of shell session .. skip: start Here's an example of a typical shell session where we start by scraping the -https://scrapy.org page, and then proceed to scrape the https://old.reddit.com/ +https://www.scrapy.org/ page, and then proceed to scrape the https://old.reddit.com/ page. Finally, we modify the (Reddit) request method to POST and re-fetch it getting an error. We end the session by typing Ctrl-D (in Unix systems) or Ctrl-Z in Windows. diff --git a/docs/versioning.rst b/docs/versioning.rst index aab6af865..0c6498244 100644 --- a/docs/versioning.rst +++ b/docs/versioning.rst @@ -23,7 +23,7 @@ Development releases do not follow 3-numbers version and are generally released as ``dev`` suffixed versions, e.g. ``1.3dev``. .. note:: - With Scrapy 0.* series, Scrapy used `odd-numbered versions for development releases`_. + With Scrapy 0.* series, Scrapy used odd-numbered versions for development releases. This is not the case anymore from Scrapy 1.0 onwards. Starting with Scrapy 1.0, all releases should be considered production-ready. @@ -63,6 +63,3 @@ feature. All deprecated features removed in a Scrapy release are explicitly mentioned in the :ref:`release notes `. - - -.. _odd-numbered versions for development releases: https://en.wikipedia.org/wiki/Software_versioning#Odd-numbered_versions_for_development_releases diff --git a/scrapy/core/downloader/handlers/_httpx.py b/scrapy/core/downloader/handlers/_httpx.py index 9bbcea7bb..83009c0ed 100644 --- a/scrapy/core/downloader/handlers/_httpx.py +++ b/scrapy/core/downloader/handlers/_httpx.py @@ -90,7 +90,7 @@ class HttpxDownloadHandler(BaseHttpDownloadHandler): ) super().__init__(crawler) logger.warning( - "HttpxDownloadHandler is experimental and is not recommented for production use." + "HttpxDownloadHandler is experimental and is not recommended for production use." ) bind_address = crawler.settings.get("DOWNLOAD_BIND_ADDRESS") bind_address = normalize_bind_address(bind_address) diff --git a/scrapy/utils/asyncio.py b/scrapy/utils/asyncio.py index e03b7b38a..5b0c66658 100644 --- a/scrapy/utils/asyncio.py +++ b/scrapy/utils/asyncio.py @@ -65,7 +65,7 @@ def is_asyncio_available() -> bool: calling it from code such as spiders and Scrapy components, if Scrapy is run using one of the supported ways). - .. versionchanged:: VERSION + .. versionchanged:: 2.15.0 This function now also returns ``True`` if there is a running asyncio loop, even if no Twisted reactor is installed. """ @@ -302,7 +302,7 @@ async def run_in_thread( :func:`twisted.internet.threads.deferToThread`, depending on whether asyncio support is available. - .. versionadded:: VERSION + .. versionadded:: 2.15.0 """ if is_asyncio_available(): return await asyncio.to_thread(func, *args, **kwargs) diff --git a/scrapy/utils/decorators.py b/scrapy/utils/decorators.py index f1e274190..aea2557d2 100644 --- a/scrapy/utils/decorators.py +++ b/scrapy/utils/decorators.py @@ -62,7 +62,7 @@ def inthread(func: Callable[_P, _T]) -> Callable[_P, Deferred[_T]]: """Decorator to call a function in a thread and return a deferred with the result. - .. versionchanged:: VERSION + .. versionchanged:: 2.15.0 Now uses :func:`asyncio.to_thread` if the asyncio support is available. """ diff --git a/scrapy/utils/reactorless.py b/scrapy/utils/reactorless.py index 27dc69d93..aca76951b 100644 --- a/scrapy/utils/reactorless.py +++ b/scrapy/utils/reactorless.py @@ -25,7 +25,7 @@ def is_reactorless() -> bool: :func:`scrapy.utils.asyncio.is_asyncio_available()`, it has the same limitations for detecting a running asyncio event loop as that one. - .. versionadded:: VERSION + .. versionadded:: 2.15.0 """ return is_asyncio_available() and not is_reactor_installed() diff --git a/scrapy/utils/sitemap.py b/scrapy/utils/sitemap.py index 042ed6281..1520a4ff0 100644 --- a/scrapy/utils/sitemap.py +++ b/scrapy/utils/sitemap.py @@ -117,6 +117,11 @@ def sitemap_urls_from_robots( yield urljoin(base_url or "", url) else: + warnings.warn( + "Passing `str` type as `robots_text` is deprecated, use `bytes`", + ScrapyDeprecationWarning, + stacklevel=2, + ) yield from _sitemap_urls_from_robots_str(robots_text, base_url) @@ -124,11 +129,6 @@ def _sitemap_urls_from_robots_str( robots_text: str, base_url: str | None = None, ) -> Iterable[str]: - warnings.warn( - "Passing `str` type as `robots_text` is deprecated, use `bytes`", - ScrapyDeprecationWarning, - stacklevel=2, - ) for line in StringIO(robots_text): if line.lstrip()[:8].lower() == "sitemap:": url = line.partition(":")[2].strip() From 1432455d35f2c2d14c25022154242fe85f45144b Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Thu, 9 Apr 2026 16:59:18 +0500 Subject: [PATCH 110/248] Add a missing release notes change. --- docs/news.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/news.rst b/docs/news.rst index 24c20477c..e1edc0f78 100644 --- a/docs/news.rst +++ b/docs/news.rst @@ -289,7 +289,7 @@ Bug fixes (:issue:`6912`, :issue:`6993`) - ``scrapy runspider`` and ``scrapy crawl`` now set the exit code to 1 if an - exception happened early (this was broken in an earlier Scrapy version). + exception happened early (this was broken since Scrapy 2.13.0). (:issue:`6820`, :issue:`7255`) - Fixed repeated warnings about data loss (see From 47e25fbbb5ac3012c3cc6e88bd9ff79f3a61c2a5 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Thu, 9 Apr 2026 17:00:40 +0500 Subject: [PATCH 111/248] =?UTF-8?q?Bump=20version:=202.14.2=20=E2=86=92=20?= =?UTF-8?q?2.15.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- SECURITY.md | 4 ++-- docs/news.rst | 2 +- pyproject.toml | 2 +- scrapy/VERSION | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index db67dea5d..752d6318a 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,8 +4,8 @@ | Version | Supported | | ------- | ------------------ | -| 2.14.x | :white_check_mark: | -| < 2.14.x | :x: | +| 2.15.x | :white_check_mark: | +| < 2.15.x | :x: | ## Reporting a Vulnerability diff --git a/docs/news.rst b/docs/news.rst index e1edc0f78..d1276b82a 100644 --- a/docs/news.rst +++ b/docs/news.rst @@ -5,7 +5,7 @@ Release notes .. _release-2.15.0: -Scrapy 2.15.0 (unreleased) +Scrapy 2.15.0 (2026-04-09) -------------------------- Highlights: diff --git a/pyproject.toml b/pyproject.toml index 70ef73f26..abafa3037 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -154,7 +154,7 @@ module = [ ignore_missing_imports = true [tool.bumpversion] -current_version = "2.14.2" +current_version = "2.15.0" commit = true tag = true tag_name = "{new_version}" diff --git a/scrapy/VERSION b/scrapy/VERSION index 7243b12cf..68e69e405 100644 --- a/scrapy/VERSION +++ b/scrapy/VERSION @@ -1 +1 @@ -2.14.2 +2.15.0 From 508367664fddf36da0f059142b94747601a3131c Mon Sep 17 00:00:00 2001 From: NI283105 Date: Thu, 9 Apr 2026 15:31:01 +0200 Subject: [PATCH 112/248] Fix missing load_object import and grammar in docs (#7417) Co-authored-by: NI --- docs/topics/addons.rst | 2 +- docs/topics/architecture.rst | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/topics/addons.rst b/docs/topics/addons.rst index 370e5d385..01e4bcac1 100644 --- a/docs/topics/addons.rst +++ b/docs/topics/addons.rst @@ -166,7 +166,7 @@ Use a fallback component: .. code-block:: python - from scrapy.utils.misc import build_from_crawler + from scrapy.utils.misc import build_from_crawler, load_object FALLBACK_SETTING = "MY_FALLBACK_DOWNLOAD_HANDLER" diff --git a/docs/topics/architecture.rst b/docs/topics/architecture.rst index e8c510ea5..c60c43f3c 100644 --- a/docs/topics/architecture.rst +++ b/docs/topics/architecture.rst @@ -63,7 +63,7 @@ this: :meth:`~scrapy.spidermiddlewares.SpiderMiddleware.process_spider_output`). 8. The :ref:`Engine ` sends processed items to - :ref:`Item Pipelines `, then send processed Requests to + :ref:`Item Pipelines `, then sends processed Requests to the :ref:`Scheduler ` and asks for possible next Requests to crawl. From 9da14cdff142ec91e727e864e7844a66590dfbf0 Mon Sep 17 00:00:00 2001 From: Hamzah Alshawwaf <145495693+otaoi@users.noreply.github.com> Date: Tue, 14 Apr 2026 08:04:38 -0700 Subject: [PATCH 113/248] Fix file extension extraction for URLs with query parameters (#4225) (#7414) * Fix file extension extraction for URLs with query parameters (#4225) * Prioritize parsed path for file extensions and add tests (#4225) --- scrapy/pipelines/files.py | 11 ++++++++++- tests/test_pipeline_files.py | 9 +++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/scrapy/pipelines/files.py b/scrapy/pipelines/files.py index 4d06b6dd3..1eb17c3d3 100644 --- a/scrapy/pipelines/files.py +++ b/scrapy/pipelines/files.py @@ -33,6 +33,7 @@ from scrapy.utils.boto import is_botocore_available from scrapy.utils.datatypes import CaseInsensitiveDict from scrapy.utils.defer import deferred_from_coro, ensure_awaitable from scrapy.utils.ftp import ftp_store_file +from scrapy.utils.httpobj import urlparse_cached from scrapy.utils.log import failure_to_exc_info from scrapy.utils.python import to_bytes from scrapy.utils.request import referer_str @@ -724,7 +725,15 @@ class FilesPipeline(MediaPipeline): item: Any = None, ) -> str: media_guid = hashlib.sha1(to_bytes(request.url)).hexdigest() # noqa: S324 - media_ext = Path(request.url).suffix + + # clean it up and look at the path first + parsed_url = urlparse_cached(request) + media_ext = Path(parsed_url.path).suffix + + # if path has no extension look at the raw URL + if media_ext not in mimetypes.types_map: + media_ext = Path(request.url).suffix + # Handles empty and wild extensions by trying to guess the # mime type then extension or default to empty string otherwise if media_ext not in mimetypes.types_map: diff --git a/tests/test_pipeline_files.py b/tests/test_pipeline_files.py index c412645f4..76f8c2512 100644 --- a/tests/test_pipeline_files.py +++ b/tests/test_pipeline_files.py @@ -109,6 +109,15 @@ class TestFilesPipeline: def teardown_method(self): rmtree(self.tempdir) + def test_file_path_query_parameters(self): + file_path = self.pipeline.file_path + + req1 = Request("http://foo.bar/baz.txt?fizz") + assert file_path(req1) == "full/a2b4913a62f65445aeae2bac08cd8c3b41d7195e.txt" + + req2 = Request("http://foo.bar/get_img.php?file=photo.jpg") + assert file_path(req2) == "full/118230fd648f1080c81c234d5e2463ea496f8c05.jpg" + def test_file_path(self): file_path = self.pipeline.file_path assert ( From 27092b2cb703f2910b0a255a1b8250623999e12c Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Tue, 21 Apr 2026 18:27:16 +0500 Subject: [PATCH 114/248] Small download handler fixes and improvements (#7441) * Popen cleanup. * Improve the test for response headers. * Fix default headers of Httpx and H2 handlers, add tests. * Check Location in redirect tests. * Simplify bindaddress tests. * Add download_latency to HttpxDownloadHandler, add tests. * Remove explicit method="GET". * Don't expect a specific value in test_download_with_maxsize_very_large_file * Use a non-existent scheme in test_unsupported_scheme. * Remporarily rollback the test_download_latency test check. * Better check in test_download_latency. * Improve logic for StreamCloseReason.CANCELLED. --- scrapy/core/downloader/handlers/_httpx.py | 7 +- scrapy/core/http2/protocol.py | 3 +- scrapy/core/http2/stream.py | 17 ++- tests/mockserver/dns.py | 5 +- tests/mockserver/http_base.py | 7 +- tests/mockserver/http_resources.py | 2 +- tests/test_downloader_handler_httpx.py | 16 +-- .../test_downloader_handler_twisted_http10.py | 4 +- .../test_downloader_handler_twisted_http2.py | 4 +- tests/test_downloader_handlers_http_base.py | 118 ++++++++++++++---- tests/test_proxy_connect.py | 36 +++--- 11 files changed, 144 insertions(+), 75 deletions(-) diff --git a/scrapy/core/downloader/handlers/_httpx.py b/scrapy/core/downloader/handlers/_httpx.py index 83009c0ed..313fb1c4f 100644 --- a/scrapy/core/downloader/handlers/_httpx.py +++ b/scrapy/core/downloader/handlers/_httpx.py @@ -5,6 +5,7 @@ from __future__ import annotations import ipaddress import logging import ssl +import time from http.cookiejar import Cookie, CookieJar from io import BytesIO from typing import TYPE_CHECKING, Any, NoReturn, TypedDict @@ -117,6 +118,9 @@ class HttpxDownloadHandler(BaseHttpDownloadHandler): local_address=self._bind_address, ), ) + # https://github.com/encode/httpx/discussions/1566 + for header_name in ("accept", "accept-encoding", "user-agent"): + self._client.headers.pop(header_name, None) async def download_request(self, request: Request) -> Response: self._warn_unsupported_meta(request.meta) @@ -124,9 +128,10 @@ class HttpxDownloadHandler(BaseHttpDownloadHandler): timeout: float = request.meta.get( "download_timeout", self._DEFAULT_CONNECT_TIMEOUT ) - + start_time = time.monotonic() try: async with self._get_httpx_response(request, timeout) as httpx_response: + request.meta["download_latency"] = time.monotonic() - start_time return await self._read_response(httpx_response, request) except httpx.TimeoutException as e: raise DownloadTimeoutError( diff --git a/scrapy/core/http2/protocol.py b/scrapy/core/http2/protocol.py index 173545f55..39703f976 100644 --- a/scrapy/core/http2/protocol.py +++ b/scrapy/core/http2/protocol.py @@ -40,7 +40,6 @@ from scrapy.utils.ssl import _log_ssl_conn_debug_info if TYPE_CHECKING: from ipaddress import IPv4Address, IPv6Address - from hpack import HeaderTuple from twisted.internet.defer import Deferred from twisted.python.failure import Failure from twisted.web.client import URI @@ -419,7 +418,7 @@ class H2ClientProtocol(Protocol, TimeoutMixin): except KeyError: pass # We ignore server-initiated events else: - stream.receive_headers(cast("list[HeaderTuple]", event.headers)) + stream.receive_headers(cast("list[tuple[str, str]]", event.headers)) def settings_acknowledged(self, event: SettingsAcknowledged) -> None: self.metadata["settings_acknowledged"] = True diff --git a/scrapy/core/http2/stream.py b/scrapy/core/http2/stream.py index 21ce4942e..71569b217 100644 --- a/scrapy/core/http2/stream.py +++ b/scrapy/core/http2/stream.py @@ -24,8 +24,6 @@ from scrapy.utils.httpobj import urlparse_cached if TYPE_CHECKING: from collections.abc import Sequence - from hpack import HeaderTuple - from scrapy.core.http2.protocol import H2ClientProtocol from scrapy.http import Request, Response @@ -153,6 +151,8 @@ class Stream: "flow_controlled_size": 0, # Headers received after sending the request "headers": Headers(), + # Response status code + "status": None, } def _cancel(_: Any) -> None: @@ -361,9 +361,13 @@ class Stream: self._response["flow_controlled_size"], self.stream_id ) - def receive_headers(self, headers: list[HeaderTuple]) -> None: + def receive_headers(self, headers: list[tuple[str, str]]) -> None: for name, value in headers: - self._response["headers"].appendlist(name, value) + if name == ":status": + # it's a pseudo-header + self._response["status"] = int(value) + else: + self._response["headers"].appendlist(name, value) # Check if we exceed the allowed max data size which can be received expected_size = int(self._response["headers"].get(b"Content-Length", -1)) @@ -453,7 +457,8 @@ class Stream: # There maybe no :status in headers, we make # HTTP Status Code: 499 - Client Closed Request - self._response["headers"][":status"] = "499" + if self._response["status"] is None: + self._response["status"] = 499 self._fire_response_deferred() elif reason is StreamCloseReason.RESET: @@ -492,7 +497,7 @@ class Stream: response = make_response( url=self._request.url, - status=int(self._response["headers"][":status"]), + status=self._response["status"], headers=self._response["headers"], body=self._response["body"].getvalue(), certificate=self._protocol.metadata["certificate"], diff --git a/tests/mockserver/dns.py b/tests/mockserver/dns.py index 7c08f237c..e19a2e61a 100644 --- a/tests/mockserver/dns.py +++ b/tests/mockserver/dns.py @@ -35,11 +35,10 @@ class MockDNSServer: [sys.executable, "-u", "-m", "tests.mockserver.dns"], stdout=PIPE, env=get_script_run_env(), + text=True, ) self.host = "127.0.0.1" - self.port = int( - self.proc.stdout.readline().strip().decode("ascii").split(":")[1] - ) + self.port = int(self.proc.stdout.readline().strip().split(":")[1]) return self def __exit__(self, exc_type, exc_value, traceback): diff --git a/tests/mockserver/http_base.py b/tests/mockserver/http_base.py index 440d38fb9..ffd11c2dd 100644 --- a/tests/mockserver/http_base.py +++ b/tests/mockserver/http_base.py @@ -34,7 +34,7 @@ class BaseMockServer(ABC): if not self.listen_http and not self.listen_https: raise ValueError("At least one of listen_http and listen_https must be set") - self.proc: Popen | None = None + self.proc: Popen[str] | None = None self.host: str = "127.0.0.1" self.http_port: int | None = None self.https_port: int | None = None @@ -44,13 +44,14 @@ class BaseMockServer(ABC): [sys.executable, "-u", "-m", self.module_name, *self.get_additional_args()], stdout=PIPE, env=get_script_run_env(), + text=True, ) if self.listen_http: - http_address = self.proc.stdout.readline().strip().decode("ascii") + http_address = self.proc.stdout.readline().strip() http_parsed = urlparse(http_address) self.http_port = http_parsed.port if self.listen_https: - https_address = self.proc.stdout.readline().strip().decode("ascii") + https_address = self.proc.stdout.readline().strip() https_parsed = urlparse(https_address) self.https_port = https_parsed.port return self diff --git a/tests/mockserver/http_resources.py b/tests/mockserver/http_resources.py index 629932f32..7f52f0092 100644 --- a/tests/mockserver/http_resources.py +++ b/tests/mockserver/http_resources.py @@ -348,7 +348,7 @@ class ResponseHeadersResource(resource.Resource): def render(self, request): body = json.loads(request.content.read().decode()) for header_name, header_value in body.items(): - request.responseHeaders.addRawHeader(header_name, header_value) + request.responseHeaders.setRawHeaders(header_name, [header_value]) return json.dumps(body).encode("utf-8") diff --git a/tests/test_downloader_handler_httpx.py b/tests/test_downloader_handler_httpx.py index 8e8e9a037..3b1796af7 100644 --- a/tests/test_downloader_handler_httpx.py +++ b/tests/test_downloader_handler_httpx.py @@ -43,21 +43,8 @@ class HttpxDownloadHandlerMixin: class TestHttp11(HttpxDownloadHandlerMixin, TestHttp11Base): - @coroutine_test - async def test_unsupported_bindaddress( - self, caplog: pytest.LogCaptureFixture, mockserver: MockServer - ) -> None: - meta = {"bindaddress": ("127.0.0.2", 0)} - request = Request(mockserver.url("/text"), meta=meta) - async with self.get_dh() as download_handler: - response = await download_handler.download_request(request) - assert response.body == b"Works" - assert ( - "The 'bindaddress' request meta key is not supported by HttpxDownloadHandler" - in caplog.text - ) + handler_supports_bindaddress_meta = False - # skip macOS tests @pytest.mark.skipif( sys.platform == "darwin", reason="127.0.0.2 is not available on macOS by default", @@ -91,6 +78,7 @@ class TestHttp11(HttpxDownloadHandlerMixin, TestHttp11Base): class TestHttps11(HttpxDownloadHandlerMixin, TestHttps11Base): + handler_supports_bindaddress_meta = False tls_log_message = "SSL connection to 127.0.0.1 using protocol TLSv1.3, cipher" @pytest.mark.skip(reason="The check is Twisted-specific") diff --git a/tests/test_downloader_handler_twisted_http10.py b/tests/test_downloader_handler_twisted_http10.py index a1cd0b7a6..8281337b0 100644 --- a/tests/test_downloader_handler_twisted_http10.py +++ b/tests/test_downloader_handler_twisted_http10.py @@ -34,9 +34,7 @@ class TestHttp10(HTTP10DownloadHandlerMixin, TestHttpBase): @deferred_f_from_coro_f async def test_protocol(self, mockserver: MockServer) -> None: - request = Request( - mockserver.url("/host", is_secure=self.is_secure), method="GET" - ) + request = Request(mockserver.url("/host", is_secure=self.is_secure)) async with self.get_dh() as download_handler: response = await download_handler.download_request(request) assert response.protocol == "HTTP/1.0" diff --git a/tests/test_downloader_handler_twisted_http2.py b/tests/test_downloader_handler_twisted_http2.py index 53f0210ff..e99257b6f 100644 --- a/tests/test_downloader_handler_twisted_http2.py +++ b/tests/test_downloader_handler_twisted_http2.py @@ -62,9 +62,7 @@ class TestHttps2(H2DownloadHandlerMixin, TestHttps11Base): @deferred_f_from_coro_f async def test_protocol(self, mockserver: MockServer) -> None: - request = Request( - mockserver.url("/host", is_secure=self.is_secure), method="GET" - ) + request = Request(mockserver.url("/host", is_secure=self.is_secure)) async with self.get_dh() as download_handler: response = await download_handler.download_request(request) assert response.protocol == "h2" diff --git a/tests/test_downloader_handlers_http_base.py b/tests/test_downloader_handlers_http_base.py index 9a6a77560..59b6d5754 100644 --- a/tests/test_downloader_handlers_http_base.py +++ b/tests/test_downloader_handlers_http_base.py @@ -4,6 +4,7 @@ from __future__ import annotations import gzip import json +import platform import re import sys from abc import ABC, abstractmethod @@ -11,7 +12,7 @@ from contextlib import asynccontextmanager from http import HTTPStatus from ipaddress import IPv4Address from socket import gethostbyname -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, ClassVar from urllib.parse import urlparse import pytest @@ -55,6 +56,10 @@ if TYPE_CHECKING: class TestHttpBase(ABC): is_secure = False + # whether the handler supports per-request bindaddress + handler_supports_bindaddress_meta = True + # default headers added by the underlying library that cannot be suppressed + always_present_req_headers: ClassVar[frozenset[str]] = frozenset() @property @abstractmethod @@ -75,7 +80,7 @@ class TestHttpBase(ABC): @coroutine_test async def test_unsupported_scheme(self) -> None: - request = Request("ftp://unsupported.scheme") + request = Request("unsupp://unsupported.scheme") async with self.get_dh() as download_handler: with pytest.raises(UnsupportedURLSchemeError): await download_handler.download_request(request) @@ -184,6 +189,26 @@ class TestHttpBase(ABC): assert "headers" in body assert body["headers"]["X-Custom-Header"] == ["foo", "bar"] + @coroutine_test + async def test_server_receives_no_extra_headers( + self, mockserver: MockServer + ) -> None: + """Test that the handler doesn't add headers to the request.""" + request = Request(mockserver.url("/echo", is_secure=self.is_secure)) + async with self.get_dh() as download_handler: + response = await download_handler.download_request(request) + assert response.status == HTTPStatus.OK + body = json.loads(response.body.decode("utf-8")) + assert "headers" in body + received_headers = set(body["headers"].keys()) + allowed_headers = { + "Connection", + "Content-Length", + "Host", + } | self.always_present_req_headers + extra_headers = received_headers - allowed_headers + assert not extra_headers, body["headers"] + @coroutine_test async def test_server_receives_correct_request_body( self, mockserver: MockServer @@ -236,9 +261,32 @@ class TestHttpBase(ABC): assert header_name in response.headers, ( f"Response was missing expected header {header_name}" ) - assert response.headers[header_name] == bytes( - header_value, encoding="utf-8" - ) + assert response.headers.getlist(header_name) == [ + header_value.encode(encoding="utf-8") + ] + + @coroutine_test + async def test_download_no_extra_response_headers( + self, mockserver: MockServer + ) -> None: + """Test that the handler doesn't add headers to the response.""" + request = Request( + mockserver.url("/response-headers", is_secure=self.is_secure), + headers={"content-type": "application/json"}, + body=json.dumps({}), + ) + async with self.get_dh() as download_handler: + response = await download_handler.download_request(request) + assert response.status == 200 + received_headers = set(response.headers.keys()) + allowed_headers = { + b"Content-Length", + b"Content-Type", + b"Date", + b"Server", + } + extra_headers = received_headers - allowed_headers + assert not extra_headers, response.headers @coroutine_test async def test_redirect_status(self, mockserver: MockServer) -> None: @@ -246,6 +294,7 @@ class TestHttpBase(ABC): async with self.get_dh() as download_handler: response = await download_handler.download_request(request) assert response.status == 302 + assert response.headers["Location"] == b"/redirected" @coroutine_test async def test_redirect_status_head(self, mockserver: MockServer) -> None: @@ -255,6 +304,7 @@ class TestHttpBase(ABC): async with self.get_dh() as download_handler: response = await download_handler.download_request(request) assert response.status == 302 + assert response.headers["Location"] == b"/redirected" @coroutine_test async def test_timeout_download_from_spider_nodata_rcvd( @@ -360,9 +410,7 @@ class TestHttpBase(ABC): @coroutine_test async def test_response_header_content_length(self, mockserver: MockServer) -> None: - request = Request( - mockserver.url("/text", is_secure=self.is_secure), method="GET" - ) + request = Request(mockserver.url("/text", is_secure=self.is_secure)) async with self.get_dh() as download_handler: response = await download_handler.download_request(request) assert response.headers[b"content-length"] == b"5" @@ -457,6 +505,20 @@ class TestHttpBase(ABC): assert "Cookie" not in headers assert "cookie" not in headers + @coroutine_test + async def test_download_latency(self, mockserver: MockServer) -> None: + request = Request(mockserver.url("/text", is_secure=self.is_secure)) + async with self.get_dh() as download_handler: + await download_handler.download_request(request) + assert "download_latency" in request.meta + latency = request.meta["download_latency"] + if sys.version_info < (3, 13) and platform.system() == "Windows": + # time.monotonic() resolution is too low here: + # https://docs.python.org/3/whatsnew/3.13.html#time + assert latency >= 0 + else: + assert latency > 0 + class TestHttp11Base(TestHttpBase): """HTTP 1.1 test case""" @@ -510,9 +572,9 @@ class TestHttp11Base(TestHttpBase): async with self.get_dh({"DOWNLOAD_MAXSIZE": 1_500}) as download_handler: with pytest.raises(DownloadCancelledError): await download_handler.download_request(request) - assert ( - "Received 2048 bytes which is larger than download max size (1500)" - in caplog.text + assert re.search( + r"Received \d+ bytes which is larger than download max size \(1500\)", + caplog.text, ) @coroutine_test @@ -665,42 +727,48 @@ class TestHttp11Base(TestHttpBase): @coroutine_test async def test_protocol(self, mockserver: MockServer) -> None: - request = Request( - mockserver.url("/host", is_secure=self.is_secure), method="GET" - ) + request = Request(mockserver.url("/host", is_secure=self.is_secure)) async with self.get_dh() as download_handler: response = await download_handler.download_request(request) assert response.protocol == "HTTP/1.1" - # skip macOS tests @pytest.mark.skipif( sys.platform == "darwin", reason="127.0.0.2 is not available on macOS by default", ) + @pytest.mark.parametrize("setting_value", [("127.0.0.2", 0), "127.0.0.2"]) @coroutine_test - async def test_download_bind_address_setting(self, mockserver: MockServer) -> None: + async def test_download_bind_address_setting( + self, mockserver: MockServer, setting_value: Any + ) -> None: request = Request(mockserver.url("/client-ip", is_secure=self.is_secure)) async with self.get_dh( - {"DOWNLOAD_BIND_ADDRESS": ("127.0.0.2", 0)} + {"DOWNLOAD_BIND_ADDRESS": setting_value} ) as download_handler: response = await download_handler.download_request(request) assert response.body == b"127.0.0.2" - # skip macOS tests @pytest.mark.skipif( sys.platform == "darwin", reason="127.0.0.2 is not available on macOS by default", ) + @pytest.mark.parametrize("meta_value", [("127.0.0.2", 0), "127.0.0.2"]) @coroutine_test - async def test_download_bind_address_setting_string( - self, mockserver: MockServer + async def test_download_bind_address_meta( + self, mockserver: MockServer, caplog: pytest.LogCaptureFixture, meta_value: Any ) -> None: - request = Request(mockserver.url("/client-ip", is_secure=self.is_secure)) - async with self.get_dh( - {"DOWNLOAD_BIND_ADDRESS": "127.0.0.2"} - ) as download_handler: + request = Request( + mockserver.url("/client-ip", is_secure=self.is_secure), + meta={"bindaddress": meta_value}, + ) + async with self.get_dh() as download_handler: response = await download_handler.download_request(request) - assert response.body == b"127.0.0.2" + if self.handler_supports_bindaddress_meta: + assert response.body == b"127.0.0.2" + else: + assert ( + "The 'bindaddress' request meta key is not supported by" in caplog.text + ) class TestHttps11Base(TestHttp11Base): diff --git a/tests/test_proxy_connect.py b/tests/test_proxy_connect.py index 912075401..c89b51ad3 100644 --- a/tests/test_proxy_connect.py +++ b/tests/test_proxy_connect.py @@ -20,7 +20,7 @@ class MitmProxy: auth_user = "scrapy" auth_pass = "scrapy" - def start(self): + def start(self) -> str: script = """ import sys from mitmproxy.tools.main import mitmdump @@ -28,34 +28,42 @@ sys.argv[0] = "mitmdump" sys.exit(mitmdump()) """ cert_path = Path(__file__).parent.resolve() / "keys" + args = [ + "--listen-host", + "127.0.0.1", + "--listen-port", + "0", + "--proxyauth", + f"{self.auth_user}:{self.auth_pass}", + "--set", + f"confdir={cert_path}", + "--ssl-insecure", + ] self.proc = Popen( [ sys.executable, "-u", "-c", script, - "--listen-host", - "127.0.0.1", - "--listen-port", - "0", - "--proxyauth", - f"{self.auth_user}:{self.auth_pass}", - "--set", - f"confdir={cert_path}", - "--ssl-insecure", + *args, ], stdout=PIPE, + text=True, ) - line = self.proc.stdout.readline().decode("utf-8") - host_port = re.search(r"listening at (?:http://)?([^:]+:\d+)", line).group(1) + assert self.proc.stdout is not None + line = self.proc.stdout.readline() + m = re.search(r"listening at (?:http://)?([^:]+:\d+)", line) + if not m: + raise RuntimeError(f"Failed to parse mitmdump output: {line}") + host_port = m.group(1) return f"http://{self.auth_user}:{self.auth_pass}@{host_port}" - def stop(self): + def stop(self) -> None: self.proc.kill() self.proc.communicate() -def _wrong_credentials(proxy_url): +def _wrong_credentials(proxy_url: str) -> str: bad_auth_proxy = list(urlsplit(proxy_url)) bad_auth_proxy[1] = bad_auth_proxy[1].replace("scrapy:scrapy@", "wrong:wronger@") return urlunsplit(bad_auth_proxy) From 294abed1383f6790a1380f5b621d1442f7002fa9 Mon Sep 17 00:00:00 2001 From: Adrian Date: Wed, 22 Apr 2026 16:00:39 +0200 Subject: [PATCH 115/248] Restore 2.14 getwithbase, add a new method for class key deduplication (#7449) * Restore 2.14 getwithbase, add a new method for class key deduplication * Use the original getwithbase code, not some equivalent * Fix mocking * Test key exceptions --- scrapy/commands/check.py | 4 +- scrapy/core/downloader/middleware.py | 4 +- scrapy/core/spidermw.py | 4 +- scrapy/extension.py | 4 +- scrapy/pipelines/__init__.py | 4 +- scrapy/settings/__init__.py | 28 ++++++++++-- tests/test_command_check.py | 8 +++- tests/test_settings/__init__.py | 68 ++++++++++++++++++++++++---- 8 files changed, 106 insertions(+), 18 deletions(-) diff --git a/scrapy/commands/check.py b/scrapy/commands/check.py index 229e8a487..2113c19d2 100644 --- a/scrapy/commands/check.py +++ b/scrapy/commands/check.py @@ -73,7 +73,9 @@ class Command(ScrapyCommand): def run(self, args: list[str], opts: argparse.Namespace) -> None: # load contracts assert self.settings is not None - contracts = build_component_list(self.settings.getwithbase("SPIDER_CONTRACTS")) + contracts = build_component_list( + self.settings.get_component_priority_dict_with_base("SPIDER_CONTRACTS") + ) conman = ContractsManager(load_object(c) for c in contracts) runner = TextTestRunner(verbosity=2 if opts.verbose else 1) result = TextTestResult(runner.stream, runner.descriptions, runner.verbosity) diff --git a/scrapy/core/downloader/middleware.py b/scrapy/core/downloader/middleware.py index 431579856..d08c6f8e3 100644 --- a/scrapy/core/downloader/middleware.py +++ b/scrapy/core/downloader/middleware.py @@ -36,7 +36,9 @@ class DownloaderMiddlewareManager(MiddlewareManager): @classmethod def _get_mwlist_from_settings(cls, settings: BaseSettings) -> list[Any]: - return build_component_list(settings.getwithbase("DOWNLOADER_MIDDLEWARES")) + return build_component_list( + settings.get_component_priority_dict_with_base("DOWNLOADER_MIDDLEWARES") + ) def _add_middleware(self, mw: Any) -> None: if hasattr(mw, "process_request"): diff --git a/scrapy/core/spidermw.py b/scrapy/core/spidermw.py index 39342ebc0..67342099d 100644 --- a/scrapy/core/spidermw.py +++ b/scrapy/core/spidermw.py @@ -56,7 +56,9 @@ class SpiderMiddlewareManager(MiddlewareManager): @classmethod def _get_mwlist_from_settings(cls, settings: BaseSettings) -> list[Any]: - return build_component_list(settings.getwithbase("SPIDER_MIDDLEWARES")) + return build_component_list( + settings.get_component_priority_dict_with_base("SPIDER_MIDDLEWARES") + ) def __init__(self, *middlewares: Any, crawler: Crawler | None = None) -> None: self._check_deprecated_process_start_requests_use(middlewares) diff --git a/scrapy/extension.py b/scrapy/extension.py index 9f978fa32..05cce326b 100644 --- a/scrapy/extension.py +++ b/scrapy/extension.py @@ -20,4 +20,6 @@ class ExtensionManager(MiddlewareManager): @classmethod def _get_mwlist_from_settings(cls, settings: Settings) -> list[Any]: - return build_component_list(settings.getwithbase("EXTENSIONS")) + return build_component_list( + settings.get_component_priority_dict_with_base("EXTENSIONS") + ) diff --git a/scrapy/pipelines/__init__.py b/scrapy/pipelines/__init__.py index f58864471..18473c534 100644 --- a/scrapy/pipelines/__init__.py +++ b/scrapy/pipelines/__init__.py @@ -33,7 +33,9 @@ class ItemPipelineManager(MiddlewareManager): @classmethod def _get_mwlist_from_settings(cls, settings: Settings) -> list[Any]: - return build_component_list(settings.getwithbase("ITEM_PIPELINES")) + return build_component_list( + settings.get_component_priority_dict_with_base("ITEM_PIPELINES") + ) def _add_middleware(self, pipe: Any) -> None: if hasattr(pipe, "open_spider"): diff --git a/scrapy/settings/__init__.py b/scrapy/settings/__init__.py index 456e90e77..2a1b1932c 100644 --- a/scrapy/settings/__init__.py +++ b/scrapy/settings/__init__.py @@ -323,12 +323,34 @@ class BaseSettings(MutableMapping[_SettingsKey, Any]): return copy.deepcopy(value) def getwithbase(self, name: _SettingsKey) -> BaseSettings: - """Get a composition of a dictionary-like setting and its `_BASE` + """Get a composition of a dictionary-like setting and its ``_BASE`` counterpart. + Use + :meth:`~scrapy.settings.BaseSettings.get_component_priority_dict_with_base` + instead if the setting is a :ref:`component priority dictionary + `. + :param name: name of the dictionary-like setting :type name: str """ + if not isinstance(name, str): + raise ValueError(f"Base setting key must be a string, got {name}") + compbs = BaseSettings() + compbs.update(self[name + "_BASE"]) + compbs.update(self[name]) + return compbs + + def get_component_priority_dict_with_base(self, name: _SettingsKey) -> BaseSettings: + """Get a composition of a component priority dictionary setting and + its ``_BASE`` counterpart. + + Keys are resolved to their import path for deduplication and then + restored to their latest input representation. + + :param name: name of the component priority dictionary setting + :type name: str + """ if not isinstance(name, str): raise ValueError(f"Base setting key must be a string, got {name}") @@ -345,10 +367,10 @@ class BaseSettings(MutableMapping[_SettingsKey, Any]): f"be kept." ) - def normalize_key(key: Any) -> str: + def normalize_key(key: Any) -> Any: try: loaded_key = load_object(key) - except (AttributeError, TypeError, ValueError): + except (NameError, TypeError, ValueError): loaded_key = key else: import_path = global_object_name(loaded_key) diff --git a/tests/test_command_check.py b/tests/test_command_check.py index 84486e717..fd1ff612c 100644 --- a/tests/test_command_check.py +++ b/tests/test_command_check.py @@ -186,7 +186,9 @@ class CheckSpider(scrapy.Spider): output = StringIO() sys.stdout = output cmd = Command() - cmd.settings = Mock(getwithbase=Mock(return_value={})) + cmd.settings = Mock( + get_component_priority_dict_with_base=Mock(return_value={}), + ) cm_cls_mock.return_value = cm_mock = Mock() spider_loader_mock = Mock() cmd.crawler_process = Mock(spider_loader=spider_loader_mock) @@ -211,7 +213,9 @@ class CheckSpider(scrapy.Spider): self, cm_cls_mock ) -> None: cmd = Command() - cmd.settings = Mock(getwithbase=Mock(return_value={})) + cmd.settings = Mock( + get_component_priority_dict_with_base=Mock(return_value={}), + ) cm_cls_mock.return_value = cm_mock = Mock() spider_loader_mock = Mock() cmd.crawler_process = Mock(spider_loader=spider_loader_mock) diff --git a/tests/test_settings/__init__.py b/tests/test_settings/__init__.py index 4436f03ba..1d4c3d487 100644 --- a/tests/test_settings/__init__.py +++ b/tests/test_settings/__init__.py @@ -410,7 +410,45 @@ class TestBaseSettings: assert frozencopy.frozen assert frozencopy is not self.settings - def test_getwithbase_override_none_by_type(self): + def test_getwithbase_for_dotted_keys(self): + settings = BaseSettings( + { + "FEED_EXPORTERS_BASE": BaseSettings({"json": "foo"}), + "FEED_EXPORTERS": BaseSettings({"csv.gz": "bar"}), + } + ) + value = settings.getwithbase("FEED_EXPORTERS") + assert isinstance(value, BaseSettings) + assert dict(value) == { + "json": "foo", + "csv.gz": "bar", + } + + @pytest.mark.parametrize( + ("key", "exception"), + [ + pytest.param(1, TypeError, id="type-error"), + pytest.param("foo", ValueError, id="value-error"), + pytest.param("csv.gz", NameError, id="name-error"), + ], + ) + def test_get_component_priority_dict_with_base_handles_load_object_exceptions( + self, key, exception + ): + with pytest.raises(exception): + load_object(key) + + settings = BaseSettings( + { + "FOO": BaseSettings({key: 1}), + } + ) + value = settings.get_component_priority_dict_with_base("FOO") + + assert isinstance(value, BaseSettings) + assert dict(value) == {key: 1} + + def test_get_component_priority_dict_with_base_override_none_by_type(self): settings = BaseSettings() setting_names = set() for k, v in scrapy_default_settings.__dict__.items(): @@ -426,10 +464,10 @@ class TestBaseSettings: load_object(import_path): None for import_path in v } for setting_name in setting_names: - value = settings.getwithbase(setting_name) + value = settings.get_component_priority_dict_with_base(setting_name) assert not dict(value) - def test_getwithbase_override_value_by_type(self): + def test_get_component_priority_dict_with_base_override_value_by_type(self): settings = BaseSettings() setting_names = set() value = 0 @@ -446,7 +484,10 @@ class TestBaseSettings: load_object(import_path): value for import_path in v } for setting_name in setting_names: - assert settings.getwithbase(setting_name) == settings[setting_name] + assert ( + settings.get_component_priority_dict_with_base(setting_name) + == settings[setting_name] + ) def test_getwithbase_for_non_component_priority_dicts(self): settings = BaseSettings() @@ -465,7 +506,9 @@ class TestBaseSettings: assert isinstance(value, BaseSettings) assert dict(value) == expected - def test_getwithbase_warns_on_duplicate_import_paths(self, caplog): + def test_get_component_priority_dict_with_base_warns_on_duplicate_import_paths( + self, caplog + ): settings = BaseSettings() settings["FOO"] = BaseSettings( { @@ -474,20 +517,22 @@ class TestBaseSettings: } ) with caplog.at_level(logging.WARNING): - value = settings.getwithbase("FOO") + value = settings.get_component_priority_dict_with_base("FOO") assert isinstance(value, BaseSettings) assert dict(value) == {"scrapy.http.Request": 2} assert caplog.records, "Expected a warning to be logged" msg = caplog.records[0].message assert "scrapy.http.request.Request" in msg - def test_getwithbase_warns_on_duplicate_mixed_type_and_path(self, caplog): + def test_get_component_priority_dict_with_base_warns_on_duplicate_mixed_type_and_path( + self, caplog + ): settings = BaseSettings() settings["FOO"] = BaseSettings( {Component1: 1, "tests.test_settings.Component1": 2} ) with caplog.at_level(logging.WARNING): - value = settings.getwithbase("FOO") + value = settings.get_component_priority_dict_with_base("FOO") assert isinstance(value, BaseSettings) assert dict(value) == {"tests.test_settings.Component1": 2} assert caplog.records, "Expected a warning to be logged" @@ -501,6 +546,13 @@ class TestBaseSettings: ): settings.getwithbase(123) + def test_get_component_priority_dict_with_base_invalid_setting_name(self): + settings = BaseSettings() + with pytest.raises( + ValueError, match="Base setting key must be a string, got 123" + ): + settings.get_component_priority_dict_with_base(123) + class TestSettings: def setup_method(self): From 320e40a044f9c443c2e05c6340e9e656b9543d8d Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Wed, 22 Apr 2026 19:01:01 +0500 Subject: [PATCH 116/248] Revert sharing of the SSL context in _ScrapyClientContextFactory. (#7450) --- scrapy/core/downloader/contextfactory.py | 32 ++++++++++++-------- scrapy/utils/_deps_compat.py | 4 ++- tests/test_core_downloader.py | 38 +++++++++++++++++++++++- 3 files changed, 60 insertions(+), 14 deletions(-) diff --git a/scrapy/core/downloader/contextfactory.py b/scrapy/core/downloader/contextfactory.py index ef0a46a8e..19fc77959 100644 --- a/scrapy/core/downloader/contextfactory.py +++ b/scrapy/core/downloader/contextfactory.py @@ -78,13 +78,6 @@ class _ScrapyClientContextFactory(BrowserLikePolicyForHTTPS): self.tls_ciphers = AcceptableCiphers.fromOpenSSLCipherString(tls_ciphers) else: self.tls_ciphers = DEFAULT_CIPHERS - with _filter_method_warning(): - self._certificate_options = CertificateOptions( - method=self._ssl_method, - fixBrokenPeers=True, - acceptableCiphers=self.tls_ciphers, - ) - self._ctx = self._get_context() self._verify_certificates = verify_certificates @classmethod @@ -109,23 +102,38 @@ class _ScrapyClientContextFactory(BrowserLikePolicyForHTTPS): **kwargs, ) + # should be removed together with ScrapyClientContextFactory def getCertificateOptions(self) -> CertificateOptions: # pragma: no cover - return self._certificate_options + return self._get_cert_options() + + def _get_cert_options(self) -> CertificateOptions: + with _filter_method_warning(): + return CertificateOptions( + method=self._ssl_method, + fixBrokenPeers=True, + acceptableCiphers=self.tls_ciphers, + ) # kept for old-style HTTP/1.0 downloader context twisted calls, # e.g. connectSSL() + # should be removed together with ScrapyClientContextFactory def getContext(self, hostname: Any = None, port: Any = None) -> SSL.Context: - return self._ctx + return self._get_context() def _get_context(self) -> SSL.Context: - ctx = self._certificate_options.getContext() + cert_options = self._get_cert_options() + ctx = cert_options.getContext() ctx.set_options(0x4) # OP_LEGACY_SERVER_CONNECT return ctx def creatorForNetloc(self, hostname: bytes, port: int) -> ClientTLSOptions: if not self._verify_certificates: - return _ScrapyClientTLSOptions(hostname.decode("ascii"), self._ctx) # type: ignore[no-untyped-call] - # Note that this doesn't use self._ctx + # _ScrapyClientTLSOptions is needed to skip verification errors + return _ScrapyClientTLSOptions( + hostname.decode("ascii"), self._get_context() + ) # type: ignore[no-untyped-call] + # Otherwise use the normal Twisted function. + # Note that this doesn't use self._get_context(). with _filter_method_warning(): return optionsForClientTLS( hostname=hostname.decode("ascii"), diff --git a/scrapy/utils/_deps_compat.py b/scrapy/utils/_deps_compat.py index 92399b31f..2ca6f657c 100644 --- a/scrapy/utils/_deps_compat.py +++ b/scrapy/utils/_deps_compat.py @@ -6,5 +6,7 @@ from twisted.python.versions import Version as TxVersion TWISTED_FAILURE_HAS_STACK = TWISTED_VERSION < TxVersion("twisted", 24, 10, 0) PYOPENSSL_VERSION = Version(PYOPENSSL_VERSION_STRING) -# SSL.Context.use_certificate wants an X509 object, SSL.Context.use_privatekey wants a PKey object +# SSL.Context.use_certificate() wants an X509 object, SSL.Context.use_privatekey() wants a PKey object PYOPENSSL_WANTS_X509_PKEY = PYOPENSSL_VERSION < Version("24.3.0") +# SSL.Context.set_cipher_list() creates a temporary connection, making the context immutable +PYOPENSSL_SET_CIPHER_LIST_TMP_CONN = PYOPENSSL_VERSION < Version("25.2.0") diff --git a/tests/test_core_downloader.py b/tests/test_core_downloader.py index cddc53b16..f9c5abf8a 100644 --- a/tests/test_core_downloader.py +++ b/tests/test_core_downloader.py @@ -17,6 +17,7 @@ from scrapy.core.downloader.contextfactory import ( ) from scrapy.core.downloader.handlers.http11 import _RequestBodyProducer from scrapy.exceptions import ScrapyDeprecationWarning +from scrapy.utils._deps_compat import PYOPENSSL_SET_CIPHER_LIST_TMP_CONN from scrapy.utils.defer import maybe_deferred_to_future from scrapy.utils.misc import build_from_crawler from scrapy.utils.python import to_bytes @@ -98,7 +99,7 @@ class TestContextFactoryBase: class TestContextFactory(TestContextFactoryBase): @coroutine_test - async def testPayload(self, server_url: str) -> None: + async def test_payload(self, server_url: str) -> None: s = "0123456789" * 10 crawler = get_crawler() client_context_factory = _load_context_factory_from_settings(crawler) @@ -107,6 +108,41 @@ class TestContextFactory(TestContextFactoryBase): ) assert body == to_bytes(s) + def test_no_context_sharing(self) -> None: + """Every call to creatorForNetloc() should give a fresh context.""" + crawler = get_crawler() + client_context_factory: _ScrapyClientContextFactory = ( + _load_context_factory_from_settings(crawler) + ) + creator1 = client_context_factory.creatorForNetloc(b"website1.tld", 443) + assert creator1._hostnameBytes == b"website1.tld" + creator2 = client_context_factory.creatorForNetloc(b"website2.tld", 443) + assert creator2._hostnameBytes == b"website2.tld" + assert creator1._ctx is not creator2._ctx + + @pytest.mark.skipif( + PYOPENSSL_SET_CIPHER_LIST_TMP_CONN, + reason="Fails or doesn't make sense on this pyOpenSSL version", + ) + def test_no_immutable_ctx_warning(self) -> None: + """There should be no pyOpenSSL context modification warning. + + pyOpenSSL < 25.1.0 doesn't produce this warning, and on 25.1.0 it's + always produced due to + https://github.com/scrapy/scrapy/issues/6859#issuecomment-4294917851. + """ + crawler = get_crawler() + client_context_factory: _ScrapyClientContextFactory = ( + _load_context_factory_from_settings(crawler) + ) + with warnings.catch_warnings(): + warnings.filterwarnings( + "error", + category=DeprecationWarning, + message="Attempting to mutate a Context after a Connection was created", + ) + client_context_factory.creatorForNetloc(b"website.tld", 443) + class TestContextFactoryTLSMethod(TestContextFactoryBase): async def _assert_factory_works( From da6dfae7506c0dde5f6744c4a88dcb47469dd7c5 Mon Sep 17 00:00:00 2001 From: Adrian Date: Wed, 22 Apr 2026 16:00:39 +0200 Subject: [PATCH 117/248] Restore 2.14 getwithbase, add a new method for class key deduplication (#7449) * Restore 2.14 getwithbase, add a new method for class key deduplication * Use the original getwithbase code, not some equivalent * Fix mocking * Test key exceptions --- scrapy/commands/check.py | 4 +- scrapy/core/downloader/middleware.py | 4 +- scrapy/core/spidermw.py | 4 +- scrapy/extension.py | 4 +- scrapy/pipelines/__init__.py | 4 +- scrapy/settings/__init__.py | 28 ++++++++++-- tests/test_command_check.py | 8 +++- tests/test_settings/__init__.py | 68 ++++++++++++++++++++++++---- 8 files changed, 106 insertions(+), 18 deletions(-) diff --git a/scrapy/commands/check.py b/scrapy/commands/check.py index 229e8a487..2113c19d2 100644 --- a/scrapy/commands/check.py +++ b/scrapy/commands/check.py @@ -73,7 +73,9 @@ class Command(ScrapyCommand): def run(self, args: list[str], opts: argparse.Namespace) -> None: # load contracts assert self.settings is not None - contracts = build_component_list(self.settings.getwithbase("SPIDER_CONTRACTS")) + contracts = build_component_list( + self.settings.get_component_priority_dict_with_base("SPIDER_CONTRACTS") + ) conman = ContractsManager(load_object(c) for c in contracts) runner = TextTestRunner(verbosity=2 if opts.verbose else 1) result = TextTestResult(runner.stream, runner.descriptions, runner.verbosity) diff --git a/scrapy/core/downloader/middleware.py b/scrapy/core/downloader/middleware.py index 431579856..d08c6f8e3 100644 --- a/scrapy/core/downloader/middleware.py +++ b/scrapy/core/downloader/middleware.py @@ -36,7 +36,9 @@ class DownloaderMiddlewareManager(MiddlewareManager): @classmethod def _get_mwlist_from_settings(cls, settings: BaseSettings) -> list[Any]: - return build_component_list(settings.getwithbase("DOWNLOADER_MIDDLEWARES")) + return build_component_list( + settings.get_component_priority_dict_with_base("DOWNLOADER_MIDDLEWARES") + ) def _add_middleware(self, mw: Any) -> None: if hasattr(mw, "process_request"): diff --git a/scrapy/core/spidermw.py b/scrapy/core/spidermw.py index 39342ebc0..67342099d 100644 --- a/scrapy/core/spidermw.py +++ b/scrapy/core/spidermw.py @@ -56,7 +56,9 @@ class SpiderMiddlewareManager(MiddlewareManager): @classmethod def _get_mwlist_from_settings(cls, settings: BaseSettings) -> list[Any]: - return build_component_list(settings.getwithbase("SPIDER_MIDDLEWARES")) + return build_component_list( + settings.get_component_priority_dict_with_base("SPIDER_MIDDLEWARES") + ) def __init__(self, *middlewares: Any, crawler: Crawler | None = None) -> None: self._check_deprecated_process_start_requests_use(middlewares) diff --git a/scrapy/extension.py b/scrapy/extension.py index 9f978fa32..05cce326b 100644 --- a/scrapy/extension.py +++ b/scrapy/extension.py @@ -20,4 +20,6 @@ class ExtensionManager(MiddlewareManager): @classmethod def _get_mwlist_from_settings(cls, settings: Settings) -> list[Any]: - return build_component_list(settings.getwithbase("EXTENSIONS")) + return build_component_list( + settings.get_component_priority_dict_with_base("EXTENSIONS") + ) diff --git a/scrapy/pipelines/__init__.py b/scrapy/pipelines/__init__.py index f58864471..18473c534 100644 --- a/scrapy/pipelines/__init__.py +++ b/scrapy/pipelines/__init__.py @@ -33,7 +33,9 @@ class ItemPipelineManager(MiddlewareManager): @classmethod def _get_mwlist_from_settings(cls, settings: Settings) -> list[Any]: - return build_component_list(settings.getwithbase("ITEM_PIPELINES")) + return build_component_list( + settings.get_component_priority_dict_with_base("ITEM_PIPELINES") + ) def _add_middleware(self, pipe: Any) -> None: if hasattr(pipe, "open_spider"): diff --git a/scrapy/settings/__init__.py b/scrapy/settings/__init__.py index 456e90e77..2a1b1932c 100644 --- a/scrapy/settings/__init__.py +++ b/scrapy/settings/__init__.py @@ -323,12 +323,34 @@ class BaseSettings(MutableMapping[_SettingsKey, Any]): return copy.deepcopy(value) def getwithbase(self, name: _SettingsKey) -> BaseSettings: - """Get a composition of a dictionary-like setting and its `_BASE` + """Get a composition of a dictionary-like setting and its ``_BASE`` counterpart. + Use + :meth:`~scrapy.settings.BaseSettings.get_component_priority_dict_with_base` + instead if the setting is a :ref:`component priority dictionary + `. + :param name: name of the dictionary-like setting :type name: str """ + if not isinstance(name, str): + raise ValueError(f"Base setting key must be a string, got {name}") + compbs = BaseSettings() + compbs.update(self[name + "_BASE"]) + compbs.update(self[name]) + return compbs + + def get_component_priority_dict_with_base(self, name: _SettingsKey) -> BaseSettings: + """Get a composition of a component priority dictionary setting and + its ``_BASE`` counterpart. + + Keys are resolved to their import path for deduplication and then + restored to their latest input representation. + + :param name: name of the component priority dictionary setting + :type name: str + """ if not isinstance(name, str): raise ValueError(f"Base setting key must be a string, got {name}") @@ -345,10 +367,10 @@ class BaseSettings(MutableMapping[_SettingsKey, Any]): f"be kept." ) - def normalize_key(key: Any) -> str: + def normalize_key(key: Any) -> Any: try: loaded_key = load_object(key) - except (AttributeError, TypeError, ValueError): + except (NameError, TypeError, ValueError): loaded_key = key else: import_path = global_object_name(loaded_key) diff --git a/tests/test_command_check.py b/tests/test_command_check.py index 84486e717..fd1ff612c 100644 --- a/tests/test_command_check.py +++ b/tests/test_command_check.py @@ -186,7 +186,9 @@ class CheckSpider(scrapy.Spider): output = StringIO() sys.stdout = output cmd = Command() - cmd.settings = Mock(getwithbase=Mock(return_value={})) + cmd.settings = Mock( + get_component_priority_dict_with_base=Mock(return_value={}), + ) cm_cls_mock.return_value = cm_mock = Mock() spider_loader_mock = Mock() cmd.crawler_process = Mock(spider_loader=spider_loader_mock) @@ -211,7 +213,9 @@ class CheckSpider(scrapy.Spider): self, cm_cls_mock ) -> None: cmd = Command() - cmd.settings = Mock(getwithbase=Mock(return_value={})) + cmd.settings = Mock( + get_component_priority_dict_with_base=Mock(return_value={}), + ) cm_cls_mock.return_value = cm_mock = Mock() spider_loader_mock = Mock() cmd.crawler_process = Mock(spider_loader=spider_loader_mock) diff --git a/tests/test_settings/__init__.py b/tests/test_settings/__init__.py index 4436f03ba..1d4c3d487 100644 --- a/tests/test_settings/__init__.py +++ b/tests/test_settings/__init__.py @@ -410,7 +410,45 @@ class TestBaseSettings: assert frozencopy.frozen assert frozencopy is not self.settings - def test_getwithbase_override_none_by_type(self): + def test_getwithbase_for_dotted_keys(self): + settings = BaseSettings( + { + "FEED_EXPORTERS_BASE": BaseSettings({"json": "foo"}), + "FEED_EXPORTERS": BaseSettings({"csv.gz": "bar"}), + } + ) + value = settings.getwithbase("FEED_EXPORTERS") + assert isinstance(value, BaseSettings) + assert dict(value) == { + "json": "foo", + "csv.gz": "bar", + } + + @pytest.mark.parametrize( + ("key", "exception"), + [ + pytest.param(1, TypeError, id="type-error"), + pytest.param("foo", ValueError, id="value-error"), + pytest.param("csv.gz", NameError, id="name-error"), + ], + ) + def test_get_component_priority_dict_with_base_handles_load_object_exceptions( + self, key, exception + ): + with pytest.raises(exception): + load_object(key) + + settings = BaseSettings( + { + "FOO": BaseSettings({key: 1}), + } + ) + value = settings.get_component_priority_dict_with_base("FOO") + + assert isinstance(value, BaseSettings) + assert dict(value) == {key: 1} + + def test_get_component_priority_dict_with_base_override_none_by_type(self): settings = BaseSettings() setting_names = set() for k, v in scrapy_default_settings.__dict__.items(): @@ -426,10 +464,10 @@ class TestBaseSettings: load_object(import_path): None for import_path in v } for setting_name in setting_names: - value = settings.getwithbase(setting_name) + value = settings.get_component_priority_dict_with_base(setting_name) assert not dict(value) - def test_getwithbase_override_value_by_type(self): + def test_get_component_priority_dict_with_base_override_value_by_type(self): settings = BaseSettings() setting_names = set() value = 0 @@ -446,7 +484,10 @@ class TestBaseSettings: load_object(import_path): value for import_path in v } for setting_name in setting_names: - assert settings.getwithbase(setting_name) == settings[setting_name] + assert ( + settings.get_component_priority_dict_with_base(setting_name) + == settings[setting_name] + ) def test_getwithbase_for_non_component_priority_dicts(self): settings = BaseSettings() @@ -465,7 +506,9 @@ class TestBaseSettings: assert isinstance(value, BaseSettings) assert dict(value) == expected - def test_getwithbase_warns_on_duplicate_import_paths(self, caplog): + def test_get_component_priority_dict_with_base_warns_on_duplicate_import_paths( + self, caplog + ): settings = BaseSettings() settings["FOO"] = BaseSettings( { @@ -474,20 +517,22 @@ class TestBaseSettings: } ) with caplog.at_level(logging.WARNING): - value = settings.getwithbase("FOO") + value = settings.get_component_priority_dict_with_base("FOO") assert isinstance(value, BaseSettings) assert dict(value) == {"scrapy.http.Request": 2} assert caplog.records, "Expected a warning to be logged" msg = caplog.records[0].message assert "scrapy.http.request.Request" in msg - def test_getwithbase_warns_on_duplicate_mixed_type_and_path(self, caplog): + def test_get_component_priority_dict_with_base_warns_on_duplicate_mixed_type_and_path( + self, caplog + ): settings = BaseSettings() settings["FOO"] = BaseSettings( {Component1: 1, "tests.test_settings.Component1": 2} ) with caplog.at_level(logging.WARNING): - value = settings.getwithbase("FOO") + value = settings.get_component_priority_dict_with_base("FOO") assert isinstance(value, BaseSettings) assert dict(value) == {"tests.test_settings.Component1": 2} assert caplog.records, "Expected a warning to be logged" @@ -501,6 +546,13 @@ class TestBaseSettings: ): settings.getwithbase(123) + def test_get_component_priority_dict_with_base_invalid_setting_name(self): + settings = BaseSettings() + with pytest.raises( + ValueError, match="Base setting key must be a string, got 123" + ): + settings.get_component_priority_dict_with_base(123) + class TestSettings: def setup_method(self): From b7cd42da39834267d5b854a796cf6023021e1d95 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Wed, 22 Apr 2026 19:01:01 +0500 Subject: [PATCH 118/248] Revert sharing of the SSL context in _ScrapyClientContextFactory. (#7450) --- scrapy/core/downloader/contextfactory.py | 32 ++++++++++++-------- scrapy/utils/_deps_compat.py | 4 ++- tests/test_core_downloader.py | 38 +++++++++++++++++++++++- 3 files changed, 60 insertions(+), 14 deletions(-) diff --git a/scrapy/core/downloader/contextfactory.py b/scrapy/core/downloader/contextfactory.py index ef0a46a8e..19fc77959 100644 --- a/scrapy/core/downloader/contextfactory.py +++ b/scrapy/core/downloader/contextfactory.py @@ -78,13 +78,6 @@ class _ScrapyClientContextFactory(BrowserLikePolicyForHTTPS): self.tls_ciphers = AcceptableCiphers.fromOpenSSLCipherString(tls_ciphers) else: self.tls_ciphers = DEFAULT_CIPHERS - with _filter_method_warning(): - self._certificate_options = CertificateOptions( - method=self._ssl_method, - fixBrokenPeers=True, - acceptableCiphers=self.tls_ciphers, - ) - self._ctx = self._get_context() self._verify_certificates = verify_certificates @classmethod @@ -109,23 +102,38 @@ class _ScrapyClientContextFactory(BrowserLikePolicyForHTTPS): **kwargs, ) + # should be removed together with ScrapyClientContextFactory def getCertificateOptions(self) -> CertificateOptions: # pragma: no cover - return self._certificate_options + return self._get_cert_options() + + def _get_cert_options(self) -> CertificateOptions: + with _filter_method_warning(): + return CertificateOptions( + method=self._ssl_method, + fixBrokenPeers=True, + acceptableCiphers=self.tls_ciphers, + ) # kept for old-style HTTP/1.0 downloader context twisted calls, # e.g. connectSSL() + # should be removed together with ScrapyClientContextFactory def getContext(self, hostname: Any = None, port: Any = None) -> SSL.Context: - return self._ctx + return self._get_context() def _get_context(self) -> SSL.Context: - ctx = self._certificate_options.getContext() + cert_options = self._get_cert_options() + ctx = cert_options.getContext() ctx.set_options(0x4) # OP_LEGACY_SERVER_CONNECT return ctx def creatorForNetloc(self, hostname: bytes, port: int) -> ClientTLSOptions: if not self._verify_certificates: - return _ScrapyClientTLSOptions(hostname.decode("ascii"), self._ctx) # type: ignore[no-untyped-call] - # Note that this doesn't use self._ctx + # _ScrapyClientTLSOptions is needed to skip verification errors + return _ScrapyClientTLSOptions( + hostname.decode("ascii"), self._get_context() + ) # type: ignore[no-untyped-call] + # Otherwise use the normal Twisted function. + # Note that this doesn't use self._get_context(). with _filter_method_warning(): return optionsForClientTLS( hostname=hostname.decode("ascii"), diff --git a/scrapy/utils/_deps_compat.py b/scrapy/utils/_deps_compat.py index 92399b31f..2ca6f657c 100644 --- a/scrapy/utils/_deps_compat.py +++ b/scrapy/utils/_deps_compat.py @@ -6,5 +6,7 @@ from twisted.python.versions import Version as TxVersion TWISTED_FAILURE_HAS_STACK = TWISTED_VERSION < TxVersion("twisted", 24, 10, 0) PYOPENSSL_VERSION = Version(PYOPENSSL_VERSION_STRING) -# SSL.Context.use_certificate wants an X509 object, SSL.Context.use_privatekey wants a PKey object +# SSL.Context.use_certificate() wants an X509 object, SSL.Context.use_privatekey() wants a PKey object PYOPENSSL_WANTS_X509_PKEY = PYOPENSSL_VERSION < Version("24.3.0") +# SSL.Context.set_cipher_list() creates a temporary connection, making the context immutable +PYOPENSSL_SET_CIPHER_LIST_TMP_CONN = PYOPENSSL_VERSION < Version("25.2.0") diff --git a/tests/test_core_downloader.py b/tests/test_core_downloader.py index cddc53b16..f9c5abf8a 100644 --- a/tests/test_core_downloader.py +++ b/tests/test_core_downloader.py @@ -17,6 +17,7 @@ from scrapy.core.downloader.contextfactory import ( ) from scrapy.core.downloader.handlers.http11 import _RequestBodyProducer from scrapy.exceptions import ScrapyDeprecationWarning +from scrapy.utils._deps_compat import PYOPENSSL_SET_CIPHER_LIST_TMP_CONN from scrapy.utils.defer import maybe_deferred_to_future from scrapy.utils.misc import build_from_crawler from scrapy.utils.python import to_bytes @@ -98,7 +99,7 @@ class TestContextFactoryBase: class TestContextFactory(TestContextFactoryBase): @coroutine_test - async def testPayload(self, server_url: str) -> None: + async def test_payload(self, server_url: str) -> None: s = "0123456789" * 10 crawler = get_crawler() client_context_factory = _load_context_factory_from_settings(crawler) @@ -107,6 +108,41 @@ class TestContextFactory(TestContextFactoryBase): ) assert body == to_bytes(s) + def test_no_context_sharing(self) -> None: + """Every call to creatorForNetloc() should give a fresh context.""" + crawler = get_crawler() + client_context_factory: _ScrapyClientContextFactory = ( + _load_context_factory_from_settings(crawler) + ) + creator1 = client_context_factory.creatorForNetloc(b"website1.tld", 443) + assert creator1._hostnameBytes == b"website1.tld" + creator2 = client_context_factory.creatorForNetloc(b"website2.tld", 443) + assert creator2._hostnameBytes == b"website2.tld" + assert creator1._ctx is not creator2._ctx + + @pytest.mark.skipif( + PYOPENSSL_SET_CIPHER_LIST_TMP_CONN, + reason="Fails or doesn't make sense on this pyOpenSSL version", + ) + def test_no_immutable_ctx_warning(self) -> None: + """There should be no pyOpenSSL context modification warning. + + pyOpenSSL < 25.1.0 doesn't produce this warning, and on 25.1.0 it's + always produced due to + https://github.com/scrapy/scrapy/issues/6859#issuecomment-4294917851. + """ + crawler = get_crawler() + client_context_factory: _ScrapyClientContextFactory = ( + _load_context_factory_from_settings(crawler) + ) + with warnings.catch_warnings(): + warnings.filterwarnings( + "error", + category=DeprecationWarning, + message="Attempting to mutate a Context after a Connection was created", + ) + client_context_factory.creatorForNetloc(b"website.tld", 443) + class TestContextFactoryTLSMethod(TestContextFactoryBase): async def _assert_factory_works( From 41f43f4649bed5daa9d1672f4fa1e1d50fac526f Mon Sep 17 00:00:00 2001 From: Adrian Date: Thu, 23 Apr 2026 10:03:09 +0200 Subject: [PATCH 119/248] =?UTF-8?q?sphinx-scrapy:=200.7.1=20=E2=86=92=200.?= =?UTF-8?q?8.3=20(#7454)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .pre-commit-config.yaml | 2 +- docs/requirements.in | 2 +- docs/requirements.txt | 11 ++++++++--- tox.ini | 2 +- 4 files changed, 11 insertions(+), 6 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index de5ad0a39..13709efde 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -27,6 +27,6 @@ repos: hooks: - id: sphinx-lint - repo: https://github.com/scrapy/sphinx-scrapy - rev: 0.7.1 + rev: 0.8.3 hooks: - id: sphinx-scrapy diff --git a/docs/requirements.in b/docs/requirements.in index 3b1cbb226..13b9dfb5d 100644 --- a/docs/requirements.in +++ b/docs/requirements.in @@ -5,4 +5,4 @@ sphinx sphinx-notfound-page sphinx-rtd-theme sphinx-rtd-dark-mode -sphinx-scrapy @ git+https://github.com/scrapy/sphinx-scrapy.git@0.7.1 +sphinx-scrapy @ git+https://github.com/scrapy/sphinx-scrapy.git@0.8.3 diff --git a/docs/requirements.txt b/docs/requirements.txt index 6da4a52c9..4d9eb5454 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,5 +1,5 @@ # This file was autogenerated by uv via the following command: -# uv pip compile requirements.in -o requirements.txt +# uv pip compile -p 3.13 requirements.in -o requirements.txt alabaster==1.0.0 # via sphinx annotated-types==0.7.0 @@ -130,6 +130,7 @@ sphinx==9.1.0 # via # -r requirements.in # sphinx-copybutton + # sphinx-last-updated-by-git # sphinx-llms-txt # sphinx-markdown-builder # sphinx-notfound-page @@ -138,9 +139,11 @@ sphinx==9.1.0 # sphinxcontrib-jquery sphinx-copybutton==0.5.2 # via sphinx-scrapy +sphinx-last-updated-by-git==0.3.8 + # via sphinx-sitemap sphinx-llms-txt @ git+https://github.com/zytedata/sphinx-llms-txt.git@5e8866cb0cc249aa2017ad9050b3b83a7ca16f69 # via sphinx-scrapy -sphinx-markdown-builder @ git+https://github.com/zytedata/sphinx-markdown-builder.git@ac9f8babfe622e4300099ab44b96d9d9228e742e +sphinx-markdown-builder @ git+https://github.com/zytedata/sphinx-markdown-builder.git@cfe4c0bfd7b4542f7e6b65a58cdf9ec765829940 # via sphinx-scrapy sphinx-notfound-page==1.1.0 # via -r requirements.in @@ -150,8 +153,10 @@ sphinx-rtd-theme==3.1.0 # via # -r requirements.in # sphinx-rtd-dark-mode -sphinx-scrapy @ git+https://github.com/scrapy/sphinx-scrapy.git@2b5f6c7de64c8317cb771fdeb2e5020d1c9c9dcf +sphinx-scrapy @ git+https://github.com/scrapy/sphinx-scrapy.git@f20366277f2598d0c8a60e55fe282aff2da40dcf # via -r requirements.in +sphinx-sitemap==2.9.0 + # via sphinx-scrapy sphinxcontrib-applehelp==2.0.0 # via sphinx sphinxcontrib-devhelp==2.0.0 diff --git a/tox.ini b/tox.ini index d0bcb4f81..5060a043f 100644 --- a/tox.ini +++ b/tox.ini @@ -5,7 +5,7 @@ [tox] requires = - sphinx-scrapy @ git+https://github.com/scrapy/sphinx-scrapy.git@0.7.1 + sphinx-scrapy[tox] @ git+https://github.com/scrapy/sphinx-scrapy.git@0.8.3 envlist = pre-commit,pylint,typing,py,docs minversion = 1.7.0 From 3561748280f3acbabca03818f131c7de71f0269a Mon Sep 17 00:00:00 2001 From: Adrian Date: Thu, 23 Apr 2026 10:03:09 +0200 Subject: [PATCH 120/248] =?UTF-8?q?sphinx-scrapy:=200.7.1=20=E2=86=92=200.?= =?UTF-8?q?8.3=20(#7454)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .pre-commit-config.yaml | 2 +- docs/requirements.in | 2 +- docs/requirements.txt | 11 ++++++++--- tox.ini | 2 +- 4 files changed, 11 insertions(+), 6 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index de5ad0a39..13709efde 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -27,6 +27,6 @@ repos: hooks: - id: sphinx-lint - repo: https://github.com/scrapy/sphinx-scrapy - rev: 0.7.1 + rev: 0.8.3 hooks: - id: sphinx-scrapy diff --git a/docs/requirements.in b/docs/requirements.in index 3b1cbb226..13b9dfb5d 100644 --- a/docs/requirements.in +++ b/docs/requirements.in @@ -5,4 +5,4 @@ sphinx sphinx-notfound-page sphinx-rtd-theme sphinx-rtd-dark-mode -sphinx-scrapy @ git+https://github.com/scrapy/sphinx-scrapy.git@0.7.1 +sphinx-scrapy @ git+https://github.com/scrapy/sphinx-scrapy.git@0.8.3 diff --git a/docs/requirements.txt b/docs/requirements.txt index 6da4a52c9..4d9eb5454 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,5 +1,5 @@ # This file was autogenerated by uv via the following command: -# uv pip compile requirements.in -o requirements.txt +# uv pip compile -p 3.13 requirements.in -o requirements.txt alabaster==1.0.0 # via sphinx annotated-types==0.7.0 @@ -130,6 +130,7 @@ sphinx==9.1.0 # via # -r requirements.in # sphinx-copybutton + # sphinx-last-updated-by-git # sphinx-llms-txt # sphinx-markdown-builder # sphinx-notfound-page @@ -138,9 +139,11 @@ sphinx==9.1.0 # sphinxcontrib-jquery sphinx-copybutton==0.5.2 # via sphinx-scrapy +sphinx-last-updated-by-git==0.3.8 + # via sphinx-sitemap sphinx-llms-txt @ git+https://github.com/zytedata/sphinx-llms-txt.git@5e8866cb0cc249aa2017ad9050b3b83a7ca16f69 # via sphinx-scrapy -sphinx-markdown-builder @ git+https://github.com/zytedata/sphinx-markdown-builder.git@ac9f8babfe622e4300099ab44b96d9d9228e742e +sphinx-markdown-builder @ git+https://github.com/zytedata/sphinx-markdown-builder.git@cfe4c0bfd7b4542f7e6b65a58cdf9ec765829940 # via sphinx-scrapy sphinx-notfound-page==1.1.0 # via -r requirements.in @@ -150,8 +153,10 @@ sphinx-rtd-theme==3.1.0 # via # -r requirements.in # sphinx-rtd-dark-mode -sphinx-scrapy @ git+https://github.com/scrapy/sphinx-scrapy.git@2b5f6c7de64c8317cb771fdeb2e5020d1c9c9dcf +sphinx-scrapy @ git+https://github.com/scrapy/sphinx-scrapy.git@f20366277f2598d0c8a60e55fe282aff2da40dcf # via -r requirements.in +sphinx-sitemap==2.9.0 + # via sphinx-scrapy sphinxcontrib-applehelp==2.0.0 # via sphinx sphinxcontrib-devhelp==2.0.0 diff --git a/tox.ini b/tox.ini index d0bcb4f81..5060a043f 100644 --- a/tox.ini +++ b/tox.ini @@ -5,7 +5,7 @@ [tox] requires = - sphinx-scrapy @ git+https://github.com/scrapy/sphinx-scrapy.git@0.7.1 + sphinx-scrapy[tox] @ git+https://github.com/scrapy/sphinx-scrapy.git@0.8.3 envlist = pre-commit,pylint,typing,py,docs minversion = 1.7.0 From 416a454dc56a33f4d87ef288a1536f1356da4202 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Thu, 23 Apr 2026 19:51:00 +0500 Subject: [PATCH 121/248] Release notes for 2.15.1. (#7456) --- docs/news.rst | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/docs/news.rst b/docs/news.rst index d1276b82a..fa2f104b7 100644 --- a/docs/news.rst +++ b/docs/news.rst @@ -3,6 +3,31 @@ Release notes ============= +.. _release-2.15.1: + +Scrapy 2.15.1 (unreleased) +-------------------------- + +Bug fixes +~~~~~~~~~ + +- Sharing of the SSL context between multiple connections, introduced in + Scrapy 2.15.0, is reverted as it caused problems and wasn't actually + needed. + (:issue:`7445`, :issue:`7450`) + +- Fixed :meth:`scrapy.settings.BaseSettings.getwithbase` failing on keys with + dots that aren't import names. It now works the way it worked before Scrapy + 2.15.0, without trying to match class objects and import path. A separate + method, + :func:`~scrapy.settings.BaseSettings.get_component_priority_dict_with_base`, + was added that does that, and it is now used for :ref:`component priority + dictionaries `. + (:issue:`7426`, :issue:`7449`) + +- Documentation rendering improvements. + (:issue:`7452`, :issue:`7454`) + .. _release-2.15.0: Scrapy 2.15.0 (2026-04-09) From f3c5a6e75f53fcc4e885359d64baf9fe84f85fac Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Thu, 23 Apr 2026 19:57:44 +0500 Subject: [PATCH 122/248] =?UTF-8?q?Bump=20version:=202.15.0=20=E2=86=92=20?= =?UTF-8?q?2.15.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/news.rst | 2 +- pyproject.toml | 2 +- scrapy/VERSION | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/news.rst b/docs/news.rst index fa2f104b7..aef567a2a 100644 --- a/docs/news.rst +++ b/docs/news.rst @@ -5,7 +5,7 @@ Release notes .. _release-2.15.1: -Scrapy 2.15.1 (unreleased) +Scrapy 2.15.1 (2026-04-23) -------------------------- Bug fixes diff --git a/pyproject.toml b/pyproject.toml index abafa3037..42d00d526 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -154,7 +154,7 @@ module = [ ignore_missing_imports = true [tool.bumpversion] -current_version = "2.15.0" +current_version = "2.15.1" commit = true tag = true tag_name = "{new_version}" diff --git a/scrapy/VERSION b/scrapy/VERSION index 68e69e405..3b1fc7950 100644 --- a/scrapy/VERSION +++ b/scrapy/VERSION @@ -1 +1 @@ -2.15.0 +2.15.1 From fc4c57e7958dbbc9532a38f3c622dd990c18591e Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Fri, 24 Apr 2026 13:54:37 +0500 Subject: [PATCH 123/248] Fix the substitution pattern in open_in_browser(). (#7459) * Fix the substitution pattern in open_in_browser(). * Formatting. --- scrapy/utils/response.py | 2 +- tests/test_utils_response.py | 82 +++++++++++++++++------------------- 2 files changed, 39 insertions(+), 45 deletions(-) diff --git a/scrapy/utils/response.py b/scrapy/utils/response.py index 8b2417bfb..abb5f6a70 100644 --- a/scrapy/utils/response.py +++ b/scrapy/utils/response.py @@ -99,7 +99,7 @@ def open_in_browser( if isinstance(response, HtmlResponse): if b"' + repl = rf'\g<0>' body = re.sub(rb"]*?>)", to_bytes(repl), body, count=1) ext = ".html" elif isinstance(response, TextResponse): diff --git a/tests/test_utils_response.py b/tests/test_utils_response.py index 0aeb5594f..381a9c3ff 100644 --- a/tests/test_utils_response.py +++ b/tests/test_utils_response.py @@ -111,36 +111,27 @@ def test_response_status_message(): assert response_status_message(573) == "573 Unknown Status" -def test_inject_base_url(): - url = "http://www.example.com" - - def check_base_url(burl): - path = urlparse(burl).path - if not path or not Path(path).exists(): - path = burl.replace("file://", "") - bbody = Path(path).read_bytes() - assert bbody.count(b'') == 1 - return True - - r1 = HtmlResponse( - url, - body=b""" +@pytest.mark.parametrize( + "body", + [ + pytest.param( + b""" Dummy

Hello world.

""", - ) - r2 = HtmlResponse( - url, - body=b""" + id="Simple", + ), + pytest.param( + b""" Dummy Hello world. """, - ) - r3 = HtmlResponse( - url, - body=b""" + id=" with attrs", + ), + pytest.param( + b""" Dummy @@ -148,19 +139,19 @@ def test_inject_base_url():

Hello world.

""", - ) - r4 = HtmlResponse( - url, - body=b""" + id="Misleading tag", + ), + pytest.param( + b""" Dummy

Hello world.

""", - ) - r5 = HtmlResponse( - url, - body=b""" + id="Misleading comment", + ), + pytest.param( + b"""

Hello world.

""", - ) + id="Conditional comment", + ), + ], +) +def test_inject_base_url(body: bytes) -> None: + url = "http://www.example.com" - assert open_in_browser(r1, _openfunc=check_base_url), "Inject base url" - assert open_in_browser(r2, _openfunc=check_base_url), ( - "Inject base url with argumented head" - ) - assert open_in_browser(r3, _openfunc=check_base_url), ( - "Inject unique base url with misleading tag" - ) - assert open_in_browser(r4, _openfunc=check_base_url), ( - "Inject unique base url with misleading comment" - ) - assert open_in_browser(r5, _openfunc=check_base_url), ( - "Inject unique base url with conditional comment" - ) + def check_base_url(burl): + path = urlparse(burl).path + if not path or not Path(path).exists(): + path = burl.replace("file://", "") + bbody = Path(path).read_bytes() + assert bbody.count(b'>') == 1 + assert b" Date: Mon, 27 Apr 2026 18:47:52 +0500 Subject: [PATCH 124/248] Fixes for Python 3.14. (#7460) --- scrapy/extensions/feedexport.py | 2 +- tests/test_command_check.py | 10 +++++----- tests/test_feedexport.py | 4 ++-- tests/test_pipeline_files.py | 12 ++++++------ tests/test_squeues.py | 2 +- tests/test_utils_reactor.py | 12 ------------ 6 files changed, 15 insertions(+), 27 deletions(-) diff --git a/scrapy/extensions/feedexport.py b/scrapy/extensions/feedexport.py index ed4f60785..8f70dbc2e 100644 --- a/scrapy/extensions/feedexport.py +++ b/scrapy/extensions/feedexport.py @@ -167,7 +167,7 @@ class StdoutFeedStorage: @implementer(IFeedStorage) class FileFeedStorage: def __init__(self, uri: str, *, feed_options: dict[str, Any] | None = None): - self.path: str = file_uri_to_path(uri) if uri.startswith("file://") else uri + self.path: str = file_uri_to_path(uri) if uri.startswith("file:") else uri feed_options = feed_options or {} self.write_mode: OpenBinaryMode = ( "wb" if feed_options.get("overwrite", False) else "ab" diff --git a/tests/test_command_check.py b/tests/test_command_check.py index fd1ff612c..815f87026 100644 --- a/tests/test_command_check.py +++ b/tests/test_command_check.py @@ -4,7 +4,7 @@ import sys from io import StringIO from typing import TYPE_CHECKING from unittest import TestCase -from unittest.mock import Mock, PropertyMock, call, patch +from unittest.mock import MagicMock, Mock, PropertyMock, call, patch from scrapy.commands.check import Command, TextTestResult from tests.test_commands import TestProjectBase @@ -133,7 +133,7 @@ class CheckSpider(scrapy.Spider): def test_printSummary_with_unsuccessful_test_result_without_errors_and_without_failures( self, ) -> None: - result = TextTestResult(Mock(), descriptions=False, verbosity=1) + result = TextTestResult(MagicMock(), descriptions=False, verbosity=1) start_time = 1.0 stop_time = 2.0 result.testsRun = 5 @@ -147,7 +147,7 @@ class CheckSpider(scrapy.Spider): def test_printSummary_with_unsuccessful_test_result_with_only_failures( self, ) -> None: - result = TextTestResult(Mock(), descriptions=False, verbosity=1) + result = TextTestResult(MagicMock(), descriptions=False, verbosity=1) start_time = 1.0 stop_time = 2.0 result.testsRun = 5 @@ -158,7 +158,7 @@ class CheckSpider(scrapy.Spider): mock_write.assert_called_with(" (failures=1)") def test_printSummary_with_unsuccessful_test_result_with_only_errors(self) -> None: - result = TextTestResult(Mock(), descriptions=False, verbosity=1) + result = TextTestResult(MagicMock(), descriptions=False, verbosity=1) start_time = 1.0 stop_time = 2.0 result.testsRun = 5 @@ -171,7 +171,7 @@ class CheckSpider(scrapy.Spider): def test_printSummary_with_unsuccessful_test_result_with_both_failures_and_errors( self, ) -> None: - result = TextTestResult(Mock(), descriptions=False, verbosity=1) + result = TextTestResult(MagicMock(), descriptions=False, verbosity=1) start_time = 1.0 stop_time = 2.0 result.testsRun = 5 diff --git a/tests/test_feedexport.py b/tests/test_feedexport.py index dcf412c0f..cbf568524 100644 --- a/tests/test_feedexport.py +++ b/tests/test_feedexport.py @@ -44,7 +44,7 @@ if TYPE_CHECKING: from collections.abc import Callable, Iterable -def path_to_url(path: Path) -> str: +def path_to_url(path: str | Path) -> str: return urljoin("file:", pathname2url(str(path))) @@ -1293,7 +1293,7 @@ class TestFeedExporterSignals: with tempfile.NamedTemporaryFile(suffix="json") as tmp: settings = { "FEEDS": { - f"file:///{tmp.name}": { + printf_escape(path_to_url(tmp.name)): { "format": "json", }, }, diff --git a/tests/test_pipeline_files.py b/tests/test_pipeline_files.py index 76f8c2512..dbe8d85a5 100644 --- a/tests/test_pipeline_files.py +++ b/tests/test_pipeline_files.py @@ -115,8 +115,8 @@ class TestFilesPipeline: req1 = Request("http://foo.bar/baz.txt?fizz") assert file_path(req1) == "full/a2b4913a62f65445aeae2bac08cd8c3b41d7195e.txt" - req2 = Request("http://foo.bar/get_img.php?file=photo.jpg") - assert file_path(req2) == "full/118230fd648f1080c81c234d5e2463ea496f8c05.jpg" + req2 = Request("http://foo.bar/get_img.foo?file=photo.jpg") + assert file_path(req2) == "full/7fc9461c9fd836515bea6983373097203a7d748e.jpg" def test_file_path(self): file_path = self.pipeline.file_path @@ -141,10 +141,10 @@ class TestFilesPipeline: assert ( file_path( Request( - "http://www.dfsonline.co.uk/get_prod_image.php?img=status_0907_mdm.jpg" + "http://www.dfsonline.co.uk/get_prod_image?img=status_0907_mdm.jpg" ) ) - == "full/4507be485f38b0da8a0be9eb2e1dfab8a19223f2.jpg" + == "full/c67f916ff9d542e822dedf38f9fcb146d1faba78.jpg" ) assert ( file_path(Request("http://www.dorma.co.uk/images/product_details/2532/")) @@ -165,10 +165,10 @@ class TestFilesPipeline: assert ( file_path( Request( - "http://www.dfsonline.co.uk/get_prod_image.php?img=status_0907_mdm.jpg.bohaha" + "http://www.dfsonline.co.uk/get_prod_image?img=status_0907_mdm.jpg.bohaha" ) ) - == "full/76c00cef2ef669ae65052661f68d451162829507" + == "full/e75f2fa260521b56f6b6a867447b8002d00b5841" ) assert ( file_path( diff --git a/tests/test_squeues.py b/tests/test_squeues.py index 8283d3d5d..ddc12766c 100644 --- a/tests/test_squeues.py +++ b/tests/test_squeues.py @@ -120,7 +120,7 @@ class PickleFifoDiskQueueTest(t.FifoDiskQueueTest, FifoDiskQueueTestMixin): match=r"Can't (get|pickle) local object|Can't pickle .*: it's not found as", ) as exc_info: q.push(lambda x: x) - if hasattr(sys, "pypy_version_info"): + if sys.version_info >= (3, 14) or hasattr(sys, "pypy_version_info"): assert isinstance(exc_info.value.__context__, pickle.PicklingError) else: assert isinstance(exc_info.value.__context__, AttributeError) diff --git a/tests/test_utils_reactor.py b/tests/test_utils_reactor.py index d11fbc582..7d39a478e 100644 --- a/tests/test_utils_reactor.py +++ b/tests/test_utils_reactor.py @@ -1,5 +1,4 @@ import asyncio -import warnings import pytest @@ -18,17 +17,6 @@ class TestAsyncio: # the result should depend only on the pytest --reactor argument assert is_asyncio_reactor_installed() == (reactor_pytest == "asyncio") - @pytest.mark.requires_reactor # installs a reactor - def test_install_asyncio_reactor(self): - from twisted.internet import reactor as original_reactor - - with warnings.catch_warnings(record=True) as w: - install_reactor(_asyncio_reactor_path) - assert len(w) == 0, [str(warning) for warning in w] - from twisted.internet import reactor # pylint: disable=reimported - - assert original_reactor == reactor - @pytest.mark.requires_reactor # installs a reactor @pytest.mark.only_asyncio @coroutine_test From 528745b059b8a545c18232c5dbcfdffaf5aaed8e Mon Sep 17 00:00:00 2001 From: Adrian Date: Tue, 28 Apr 2026 12:17:12 +0200 Subject: [PATCH 125/248] =?UTF-8?q?sphinx-scrapy:=200.8.3=20=E2=86=92=200.?= =?UTF-8?q?8.4=20(#7467)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .pre-commit-config.yaml | 2 +- docs/requirements.in | 2 +- docs/requirements.txt | 2 +- tox.ini | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 13709efde..ccbf3133b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -27,6 +27,6 @@ repos: hooks: - id: sphinx-lint - repo: https://github.com/scrapy/sphinx-scrapy - rev: 0.8.3 + rev: 0.8.4 hooks: - id: sphinx-scrapy diff --git a/docs/requirements.in b/docs/requirements.in index 13b9dfb5d..a02e8489b 100644 --- a/docs/requirements.in +++ b/docs/requirements.in @@ -5,4 +5,4 @@ sphinx sphinx-notfound-page sphinx-rtd-theme sphinx-rtd-dark-mode -sphinx-scrapy @ git+https://github.com/scrapy/sphinx-scrapy.git@0.8.3 +sphinx-scrapy @ git+https://github.com/scrapy/sphinx-scrapy.git@0.8.4 diff --git a/docs/requirements.txt b/docs/requirements.txt index 4d9eb5454..629da6c4d 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -153,7 +153,7 @@ sphinx-rtd-theme==3.1.0 # via # -r requirements.in # sphinx-rtd-dark-mode -sphinx-scrapy @ git+https://github.com/scrapy/sphinx-scrapy.git@f20366277f2598d0c8a60e55fe282aff2da40dcf +sphinx-scrapy @ git+https://github.com/scrapy/sphinx-scrapy.git@eef1f8c3ab3b74b6891752b8f4624373345bae26 # via -r requirements.in sphinx-sitemap==2.9.0 # via sphinx-scrapy diff --git a/tox.ini b/tox.ini index 5060a043f..d0fd6680d 100644 --- a/tox.ini +++ b/tox.ini @@ -5,7 +5,7 @@ [tox] requires = - sphinx-scrapy[tox] @ git+https://github.com/scrapy/sphinx-scrapy.git@0.8.3 + sphinx-scrapy[tox] @ git+https://github.com/scrapy/sphinx-scrapy.git@0.8.4 envlist = pre-commit,pylint,typing,py,docs minversion = 1.7.0 From 988afe1454c08f75b12dfba9732d690a651c43b6 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Tue, 28 Apr 2026 17:35:41 +0500 Subject: [PATCH 126/248] Clarify dataloss/http2 tests. (#7466) --- .../test_downloader_handler_twisted_http2.py | 58 +++++++------------ tests/test_downloader_handlers_http_base.py | 58 ++++++++++++++----- 2 files changed, 66 insertions(+), 50 deletions(-) diff --git a/tests/test_downloader_handler_twisted_http2.py b/tests/test_downloader_handler_twisted_http2.py index e99257b6f..86fc071f8 100644 --- a/tests/test_downloader_handler_twisted_http2.py +++ b/tests/test_downloader_handler_twisted_http2.py @@ -2,7 +2,6 @@ from __future__ import annotations -import json from typing import TYPE_CHECKING, Any import pytest @@ -11,9 +10,13 @@ from twisted.web.http import H2_ENABLED from scrapy import Spider from scrapy.crawler import Crawler -from scrapy.exceptions import NotConfigured, UnsupportedURLSchemeError +from scrapy.exceptions import ( + DownloadFailedError, + NotConfigured, + UnsupportedURLSchemeError, +) from scrapy.http import Request -from scrapy.utils.defer import deferred_f_from_coro_f, maybe_deferred_to_future +from scrapy.utils.defer import maybe_deferred_to_future from tests.test_downloader_handlers_http_base import ( TestHttpProxyBase, TestHttps11Base, @@ -23,6 +26,7 @@ from tests.test_downloader_handlers_http_base import ( TestHttpsWrongHostnameBase, TestHttpWithCrawlerBase, ) +from tests.utils.decorators import coroutine_test if TYPE_CHECKING: from scrapy.core.downloader.handlers import DownloadHandlerProtocol @@ -58,27 +62,16 @@ def test_not_configured_without_reactor() -> None: class TestHttps2(H2DownloadHandlerMixin, TestHttps11Base): - HTTP2_DATALOSS_SKIP_REASON = "Content-Length mismatch raises InvalidBodyLengthError" + http2 = True + handler_supports_http2_dataloss = False - @deferred_f_from_coro_f + @coroutine_test async def test_protocol(self, mockserver: MockServer) -> None: request = Request(mockserver.url("/host", is_secure=self.is_secure)) async with self.get_dh() as download_handler: response = await download_handler.download_request(request) assert response.protocol == "h2" - def test_download_cause_data_loss(self) -> None: # type: ignore[override] - pytest.skip(self.HTTP2_DATALOSS_SKIP_REASON) - - def test_download_cause_data_loss_double_warning(self) -> None: # type: ignore[override] - pytest.skip(self.HTTP2_DATALOSS_SKIP_REASON) - - def test_download_allow_data_loss(self) -> None: # type: ignore[override] - pytest.skip(self.HTTP2_DATALOSS_SKIP_REASON) - - def test_download_allow_data_loss_via_setting(self) -> None: # type: ignore[override] - pytest.skip(self.HTTP2_DATALOSS_SKIP_REASON) - def test_download_conn_failed(self) -> None: # type: ignore[override] # Unlike HTTP11DownloadHandler which raises it from download_request() # (without any special handling), here ConnectionRefusedError (raised in @@ -87,12 +80,6 @@ class TestHttps2(H2DownloadHandlerMixin, TestHttps11Base): # DOWNLOAD_TIMEOUT. pytest.skip("The handler doesn't properly reraise ConnectionRefusedError") - def test_download_conn_lost(self) -> None: # type: ignore[override] - pytest.skip(self.HTTP2_DATALOSS_SKIP_REASON) - - def test_download_conn_aborted(self) -> None: # type: ignore[override] - pytest.skip(self.HTTP2_DATALOSS_SKIP_REASON) - def test_download_dns_error(self) -> None: # type: ignore[override] # Unlike HTTP11DownloadHandler which raises it from download_request() # (without any special handling), here DNSLookupError (raised in @@ -101,7 +88,7 @@ class TestHttps2(H2DownloadHandlerMixin, TestHttps11Base): # DOWNLOAD_TIMEOUT. pytest.skip("The handler doesn't properly reraise DNSLookupError") - @deferred_f_from_coro_f + @coroutine_test async def test_concurrent_requests_same_domain( self, mockserver: MockServer ) -> None: @@ -116,7 +103,7 @@ class TestHttps2(H2DownloadHandlerMixin, TestHttps11Base): assert response2.headers["Content-Length"] == b"79" @pytest.mark.xfail(reason="https://github.com/python-hyper/h2/issues/1247") - @deferred_f_from_coro_f + @coroutine_test async def test_connect_request(self, mockserver: MockServer) -> None: request = Request( mockserver.url("/file", is_secure=self.is_secure), method="CONNECT" @@ -125,7 +112,7 @@ class TestHttps2(H2DownloadHandlerMixin, TestHttps11Base): response = await download_handler.download_request(request) assert response.body == b"" - @deferred_f_from_coro_f + @coroutine_test async def test_custom_content_length_good(self, mockserver: MockServer) -> None: request = Request(mockserver.url("/contentlength", is_secure=self.is_secure)) custom_content_length = str(len(request.body)) @@ -134,7 +121,7 @@ class TestHttps2(H2DownloadHandlerMixin, TestHttps11Base): response = await download_handler.download_request(request) assert response.text == custom_content_length - @deferred_f_from_coro_f + @coroutine_test async def test_custom_content_length_bad(self, mockserver: MockServer) -> None: request = Request(mockserver.url("/contentlength", is_secure=self.is_secure)) actual_content_length = str(len(request.body)) @@ -154,15 +141,12 @@ class TestHttps2(H2DownloadHandlerMixin, TestHttps11Base): ) ) - @deferred_f_from_coro_f - async def test_duplicate_header(self, mockserver: MockServer) -> None: - request = Request(mockserver.url("/echo", is_secure=self.is_secure)) - header, value1, value2 = "Custom-Header", "foo", "bar" - request.headers.appendlist(header, value1) - request.headers.appendlist(header, value2) + @coroutine_test + async def test_data_loss_handling(self, mockserver: MockServer) -> None: + request = Request(mockserver.url("/broken", is_secure=self.is_secure)) async with self.get_dh() as download_handler: - response = await download_handler.download_request(request) - assert json.loads(response.text)["headers"][header] == [value1, value2] + with pytest.raises(DownloadFailedError): + await download_handler.download_request(request) class TestHttps2WrongHostname(H2DownloadHandlerMixin, TestHttpsWrongHostnameBase): @@ -214,7 +198,7 @@ class TestHttps2Proxy(H2DownloadHandlerMixin, TestHttpProxyBase): is_secure = True expected_http_proxy_request_body = b"/" - @deferred_f_from_coro_f + @coroutine_test async def test_download_with_proxy_https_timeout( self, proxy_mockserver: ProxyEchoMockServer ) -> None: @@ -223,7 +207,7 @@ class TestHttps2Proxy(H2DownloadHandlerMixin, TestHttpProxyBase): super().test_download_with_proxy_https_timeout(proxy_mockserver) # type: ignore[arg-type] ) - @deferred_f_from_coro_f + @coroutine_test async def test_download_with_proxy_without_http_scheme( self, proxy_mockserver: ProxyEchoMockServer ) -> None: diff --git a/tests/test_downloader_handlers_http_base.py b/tests/test_downloader_handlers_http_base.py index 59b6d5754..66edf9325 100644 --- a/tests/test_downloader_handlers_http_base.py +++ b/tests/test_downloader_handlers_http_base.py @@ -521,7 +521,14 @@ class TestHttpBase(ABC): class TestHttp11Base(TestHttpBase): - """HTTP 1.1 test case""" + http2: bool = False + # RFC 9113 §8.1.1 explicitly says that a Content-Length mismatch is a + # stream error (of type PROTOCOL_ERROR) so the client will send + # RST_STREAM. Some libraries do only this while e.g. h2 also closes the + # connection (see handling of ProtocolError in + # h2.connection.H2Connection.receive_data()), thus closing all streams that + # were using it, and we handle this as a normal exception. + handler_supports_http2_dataloss: bool = True @coroutine_test async def test_download_without_maxsize_limit(self, mockserver: MockServer) -> None: @@ -638,12 +645,11 @@ class TestHttp11Base(TestHttpBase): response = await download_handler.download_request(request) assert response.body == b"chunked content\n" - @pytest.mark.parametrize("url", ["broken", "broken-chunked"]) @coroutine_test - async def test_download_cause_data_loss( - self, url: str, mockserver: MockServer - ) -> None: - request = Request(mockserver.url(f"/{url}", is_secure=self.is_secure)) + async def test_download_cause_data_loss(self, mockserver: MockServer) -> None: + if self.http2 and not self.handler_supports_http2_dataloss: + pytest.skip("This handler doesn't support dataloss on HTTP/2") + request = Request(mockserver.url("/broken", is_secure=self.is_secure)) async with self.get_dh() as download_handler: with pytest.raises(ResponseDataLossError): await download_handler.download_request(request) @@ -652,6 +658,8 @@ class TestHttp11Base(TestHttpBase): async def test_download_cause_data_loss_double_warning( self, caplog: pytest.LogCaptureFixture, mockserver: MockServer ) -> None: + if self.http2 and not self.handler_supports_http2_dataloss: + pytest.skip("This handler doesn't support dataloss on HTTP/2") request = Request(mockserver.url("/broken", is_secure=self.is_secure)) async with self.get_dh() as download_handler: with pytest.raises(ResponseDataLossError): @@ -663,25 +671,43 @@ class TestHttp11Base(TestHttpBase): # no repeated warning assert "Got data loss" not in caplog.text - @pytest.mark.parametrize("url", ["broken", "broken-chunked"]) @coroutine_test - async def test_download_allow_data_loss( - self, url: str, mockserver: MockServer + async def test_download_allow_data_loss_broken( + self, mockserver: MockServer ) -> None: + if self.http2 and not self.handler_supports_http2_dataloss: + pytest.skip("This handler doesn't support dataloss on HTTP/2") request = Request( - mockserver.url(f"/{url}", is_secure=self.is_secure), + mockserver.url("/broken", is_secure=self.is_secure), meta={"download_fail_on_dataloss": False}, ) async with self.get_dh() as download_handler: response = await download_handler.download_request(request) assert response.flags == ["dataloss"] + assert response.text == "partial" + + @coroutine_test + async def test_download_allow_data_loss_broken_chunked( + self, mockserver: MockServer + ) -> None: + if self.http2: + pytest.skip("Chunked encoding is specific to HTTP/1.1") + request = Request( + mockserver.url("/broken-chunked", is_secure=self.is_secure), + meta={"download_fail_on_dataloss": False}, + ) + async with self.get_dh() as download_handler: + response = await download_handler.download_request(request) + assert response.flags == ["dataloss"] + assert response.text == "chunked content\n" - @pytest.mark.parametrize("url", ["broken", "broken-chunked"]) @coroutine_test async def test_download_allow_data_loss_via_setting( - self, url: str, mockserver: MockServer + self, mockserver: MockServer ) -> None: - request = Request(mockserver.url(f"/{url}", is_secure=self.is_secure)) + if self.http2 and not self.handler_supports_http2_dataloss: + pytest.skip("This handler doesn't support dataloss on HTTP/2") + request = Request(mockserver.url("/broken", is_secure=self.is_secure)) async with self.get_dh( {"DOWNLOAD_FAIL_ON_DATALOSS": False} ) as download_handler: @@ -708,6 +734,12 @@ class TestHttp11Base(TestHttpBase): @coroutine_test async def test_download_conn_aborted(self, mockserver: MockServer) -> None: # copy of TestCrawl.test_retry_conn_aborted() + if self.http2: + # it may be possible to write a separate resource that does something + # suitable on HTTP/2 without sending Content-Length + pytest.skip( + "On HTTP/2 this triggers a Content-Length mismatch error instead." + ) request = Request(mockserver.url("/drop?abort=1", is_secure=self.is_secure)) async with self.get_dh() as download_handler: with pytest.raises(DownloadFailedError): From 3a88cd0e2b1c05d2ac81c93b71ca0f89f6484f2a Mon Sep 17 00:00:00 2001 From: Adrian Date: Tue, 28 Apr 2026 15:26:42 +0200 Subject: [PATCH 127/248] 2.15.2 (#7468) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * sphinx-scrapy: 0.8.3 → 0.8.4 (#7467) * Release notes for 2.15.2 --- .pre-commit-config.yaml | 2 +- docs/news.rst | 10 ++++++++++ docs/requirements.in | 2 +- docs/requirements.txt | 2 +- tox.ini | 2 +- 5 files changed, 14 insertions(+), 4 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 13709efde..ccbf3133b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -27,6 +27,6 @@ repos: hooks: - id: sphinx-lint - repo: https://github.com/scrapy/sphinx-scrapy - rev: 0.8.3 + rev: 0.8.4 hooks: - id: sphinx-scrapy diff --git a/docs/news.rst b/docs/news.rst index aef567a2a..b86d61d54 100644 --- a/docs/news.rst +++ b/docs/news.rst @@ -3,6 +3,16 @@ Release notes ============= +.. _release-2.15.2: + +Scrapy 2.15.2 (unreleased) +-------------------------- + +Bug fixes +~~~~~~~~~ + +- Fixed links in https://docs.scrapy.org/llms.txt (:issue:`7467`) + .. _release-2.15.1: Scrapy 2.15.1 (2026-04-23) diff --git a/docs/requirements.in b/docs/requirements.in index 13b9dfb5d..a02e8489b 100644 --- a/docs/requirements.in +++ b/docs/requirements.in @@ -5,4 +5,4 @@ sphinx sphinx-notfound-page sphinx-rtd-theme sphinx-rtd-dark-mode -sphinx-scrapy @ git+https://github.com/scrapy/sphinx-scrapy.git@0.8.3 +sphinx-scrapy @ git+https://github.com/scrapy/sphinx-scrapy.git@0.8.4 diff --git a/docs/requirements.txt b/docs/requirements.txt index 4d9eb5454..629da6c4d 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -153,7 +153,7 @@ sphinx-rtd-theme==3.1.0 # via # -r requirements.in # sphinx-rtd-dark-mode -sphinx-scrapy @ git+https://github.com/scrapy/sphinx-scrapy.git@f20366277f2598d0c8a60e55fe282aff2da40dcf +sphinx-scrapy @ git+https://github.com/scrapy/sphinx-scrapy.git@eef1f8c3ab3b74b6891752b8f4624373345bae26 # via -r requirements.in sphinx-sitemap==2.9.0 # via sphinx-scrapy diff --git a/tox.ini b/tox.ini index 5060a043f..d0fd6680d 100644 --- a/tox.ini +++ b/tox.ini @@ -5,7 +5,7 @@ [tox] requires = - sphinx-scrapy[tox] @ git+https://github.com/scrapy/sphinx-scrapy.git@0.8.3 + sphinx-scrapy[tox] @ git+https://github.com/scrapy/sphinx-scrapy.git@0.8.4 envlist = pre-commit,pylint,typing,py,docs minversion = 1.7.0 From 3d5ca9f43380ca2abebadd3277c38db840583766 Mon Sep 17 00:00:00 2001 From: Adrian Chaves Date: Tue, 28 Apr 2026 15:28:51 +0200 Subject: [PATCH 128/248] =?UTF-8?q?Bump=20version:=202.15.1=20=E2=86=92=20?= =?UTF-8?q?2.15.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/news.rst | 2 +- pyproject.toml | 2 +- scrapy/VERSION | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/news.rst b/docs/news.rst index b86d61d54..676e0cbbf 100644 --- a/docs/news.rst +++ b/docs/news.rst @@ -5,7 +5,7 @@ Release notes .. _release-2.15.2: -Scrapy 2.15.2 (unreleased) +Scrapy 2.15.2 (2026-04-28) -------------------------- Bug fixes diff --git a/pyproject.toml b/pyproject.toml index 42d00d526..d96790873 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -154,7 +154,7 @@ module = [ ignore_missing_imports = true [tool.bumpversion] -current_version = "2.15.1" +current_version = "2.15.2" commit = true tag = true tag_name = "{new_version}" diff --git a/scrapy/VERSION b/scrapy/VERSION index 3b1fc7950..07d875c2d 100644 --- a/scrapy/VERSION +++ b/scrapy/VERSION @@ -1 +1 @@ -2.15.1 +2.15.2 From dd36fb7859865eb91c0ba85c604b6da84f1f6db4 Mon Sep 17 00:00:00 2001 From: "Albert Eduardovich N." Date: Wed, 29 Apr 2026 10:53:40 +0300 Subject: [PATCH 129/248] Improve handling of time deltas (#7377) --- scrapy/core/downloader/__init__.py | 6 +++--- scrapy/core/downloader/handlers/http11.py | 6 +++--- scrapy/core/downloader/handlers/http2.py | 6 +++--- scrapy/core/downloader/webclient.py | 8 ++++++-- scrapy/extensions/corestats.py | 9 ++++++--- scrapy/utils/asyncio.py | 4 ++-- tests/test_stats.py | 1 + 7 files changed, 24 insertions(+), 16 deletions(-) diff --git a/scrapy/core/downloader/__init__.py b/scrapy/core/downloader/__init__.py index 905b32d08..01d5d42b0 100644 --- a/scrapy/core/downloader/__init__.py +++ b/scrapy/core/downloader/__init__.py @@ -4,7 +4,7 @@ import random from collections import deque from dataclasses import dataclass, field from datetime import datetime -from time import time +from time import monotonic from typing import TYPE_CHECKING, Any from twisted.internet.defer import Deferred, inlineCallbacks @@ -196,7 +196,7 @@ class Downloader: return # Delay queue processing if a download_delay is configured - now = time() + now = monotonic() delay = slot.download_delay() if delay: penalty = delay - now + slot.lastseen @@ -265,7 +265,7 @@ class Downloader: slot.close() def _slot_gc(self, age: float = 60) -> None: - mintime = time() - age + mintime = monotonic() - age for key, slot in list(self.slots.items()): if not slot.active and slot.lastseen + slot.delay < mintime: self.slots.pop(key).close() diff --git a/scrapy/core/downloader/handlers/http11.py b/scrapy/core/downloader/handlers/http11.py index 9d102aff8..439638d33 100644 --- a/scrapy/core/downloader/handlers/http11.py +++ b/scrapy/core/downloader/handlers/http11.py @@ -7,7 +7,7 @@ import logging import re from contextlib import suppress from io import BytesIO -from time import time +from time import monotonic from typing import TYPE_CHECKING, Any, TypedDict, TypeVar, cast from urllib.parse import urldefrag, urlparse @@ -460,7 +460,7 @@ class ScrapyAgent: if isinstance(agent, self._TunnelingAgent): headers.removeHeader(b"Proxy-Authorization") bodyproducer = _RequestBodyProducer(request.body) if request.body else None - start_time = time() + start_time = monotonic() d: Deferred[IResponse] = agent.request( method, to_bytes(url, encoding="ascii"), @@ -489,7 +489,7 @@ class ScrapyAgent: raise DownloadTimeoutError(f"Getting {url} took longer than {timeout} seconds.") def _cb_latency(self, result: _T, request: Request, start_time: float) -> _T: - request.meta["download_latency"] = time() - start_time + request.meta["download_latency"] = monotonic() - start_time return result @staticmethod diff --git a/scrapy/core/downloader/handlers/http2.py b/scrapy/core/downloader/handlers/http2.py index f2774cc07..a4f786363 100644 --- a/scrapy/core/downloader/handlers/http2.py +++ b/scrapy/core/downloader/handlers/http2.py @@ -1,6 +1,6 @@ from __future__ import annotations -from time import time +from time import monotonic from typing import TYPE_CHECKING from urllib.parse import urldefrag @@ -113,7 +113,7 @@ class ScrapyH2Agent: timeout = request.meta.get("download_timeout") or self._connect_timeout agent = self._get_agent(request, timeout) - start_time = time() + start_time = monotonic() d = agent.request(request, spider) d.addCallback(self._cb_latency, request, start_time) @@ -125,7 +125,7 @@ class ScrapyH2Agent: def _cb_latency( response: Response, request: Request, start_time: float ) -> Response: - request.meta["download_latency"] = time() - start_time + request.meta["download_latency"] = monotonic() - start_time return response @staticmethod diff --git a/scrapy/core/downloader/webclient.py b/scrapy/core/downloader/webclient.py index 52d287245..2a550cf78 100644 --- a/scrapy/core/downloader/webclient.py +++ b/scrapy/core/downloader/webclient.py @@ -3,7 +3,7 @@ from __future__ import annotations import warnings -from time import time +from time import monotonic, time from typing import TYPE_CHECKING from urllib.parse import urldefrag, urlparse, urlunparse @@ -100,7 +100,9 @@ class ScrapyHTTPClientFactory(ClientFactory): afterFoundGet = False def _build_response(self, body, request): - request.meta["download_latency"] = self.headers_time - self.start_time + request.meta["download_latency"] = ( + self._headers_time_mono - self._start_time_mono + ) status = int(self.status) headers = Headers(self.response_headers) respcls = responsetypes.from_args(headers=headers, url=self._url, body=body) @@ -153,6 +155,7 @@ class ScrapyHTTPClientFactory(ClientFactory): self.response_headers: Headers | None = None self.timeout: float = request.meta.get("download_timeout") or timeout self.start_time: float = time() + self._start_time_mono: float = monotonic() self.deferred: defer.Deferred[Response] = defer.Deferred().addCallback( self._build_response, request ) @@ -200,6 +203,7 @@ class ScrapyHTTPClientFactory(ClientFactory): def gotHeaders(self, headers): self.headers_time = time() + self._headers_time_mono = monotonic() self.response_headers = headers def gotStatus(self, version, status, message): diff --git a/scrapy/extensions/corestats.py b/scrapy/extensions/corestats.py index 1400f4c9b..6a5e55992 100644 --- a/scrapy/extensions/corestats.py +++ b/scrapy/extensions/corestats.py @@ -5,6 +5,7 @@ Extension for collecting core stats like items scraped and start/finish times from __future__ import annotations from datetime import datetime, timezone +from time import monotonic from typing import TYPE_CHECKING, Any from scrapy import Spider, signals @@ -21,6 +22,7 @@ class CoreStats: def __init__(self, stats: StatsCollector): self.stats: StatsCollector = stats self.start_time: datetime | None = None + self._start_time_mono: float | None = None @classmethod def from_crawler(cls, crawler: Crawler) -> Self: @@ -35,13 +37,14 @@ class CoreStats: def spider_opened(self, spider: Spider) -> None: self.start_time = datetime.now(tz=timezone.utc) + self._start_time_mono = monotonic() self.stats.set_value("start_time", self.start_time) def spider_closed(self, spider: Spider, reason: str) -> None: assert self.start_time is not None - finish_time = datetime.now(tz=timezone.utc) - elapsed_time = finish_time - self.start_time - elapsed_time_seconds = elapsed_time.total_seconds() + assert self._start_time_mono is not None + finish_time, finish_time_mono = datetime.now(tz=timezone.utc), monotonic() + elapsed_time_seconds = finish_time_mono - self._start_time_mono self.stats.set_value("elapsed_time_seconds", elapsed_time_seconds) self.stats.set_value("finish_time", finish_time) self.stats.set_value("finish_reason", reason) diff --git a/scrapy/utils/asyncio.py b/scrapy/utils/asyncio.py index 5b0c66658..ecb4c1492 100644 --- a/scrapy/utils/asyncio.py +++ b/scrapy/utils/asyncio.py @@ -172,7 +172,7 @@ class AsyncioLoopingCall: raise ValueError("Interval must be greater than 0") self.interval = interval - self._start_time = time.time() + self._start_time = time.monotonic() if now: self._call() loop = asyncio.get_event_loop() @@ -182,7 +182,7 @@ class AsyncioLoopingCall: """Return the time to sleep until the next call.""" assert self.interval is not None assert self._start_time is not None - now = time.time() + now = time.monotonic() running_for = now - self._start_time return self.interval - (running_for % self.interval) diff --git a/tests/test_stats.py b/tests/test_stats.py index 2869d302e..27af18bb1 100644 --- a/tests/test_stats.py +++ b/tests/test_stats.py @@ -29,6 +29,7 @@ def spider(crawler: Crawler) -> Spider: class TestCoreStatsExtension: + @mock.patch("scrapy.extensions.corestats.monotonic", return_value=0) @mock.patch("scrapy.extensions.corestats.datetime") def test_core_stats_default_stats_collector( self, mock_datetime: mock.Mock, crawler: Crawler, spider: Spider From b9c2240040cfcde264587eddb59a6f9b0abfd9b7 Mon Sep 17 00:00:00 2001 From: Adrian Date: Wed, 29 Apr 2026 14:17:26 +0200 Subject: [PATCH 130/248] =?UTF-8?q?sphinx-scrapy:=200.8.4=20=E2=86=92=200.?= =?UTF-8?q?8.5=20(#7472)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .pre-commit-config.yaml | 2 +- docs/requirements.in | 2 +- docs/requirements.txt | 20 ++++++++++---------- tox.ini | 2 +- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ccbf3133b..bbf24dd63 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -27,6 +27,6 @@ repos: hooks: - id: sphinx-lint - repo: https://github.com/scrapy/sphinx-scrapy - rev: 0.8.4 + rev: 0.8.5 hooks: - id: sphinx-scrapy diff --git a/docs/requirements.in b/docs/requirements.in index a02e8489b..6320721cd 100644 --- a/docs/requirements.in +++ b/docs/requirements.in @@ -5,4 +5,4 @@ sphinx sphinx-notfound-page sphinx-rtd-theme sphinx-rtd-dark-mode -sphinx-scrapy @ git+https://github.com/scrapy/sphinx-scrapy.git@0.8.4 +sphinx-scrapy @ git+https://github.com/scrapy/sphinx-scrapy.git@0.8.5 diff --git a/docs/requirements.txt b/docs/requirements.txt index 629da6c4d..53cb82638 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,5 +1,5 @@ # This file was autogenerated by uv via the following command: -# uv pip compile -p 3.13 requirements.in -o requirements.txt +# uv pip compile -p 3.13 docs/requirements.in -o docs/requirements.txt alabaster==1.0.0 # via sphinx annotated-types==0.7.0 @@ -39,7 +39,7 @@ docutils==0.22.4 filelock==3.25.2 # via tldextract h2==4.3.0 - # via -r requirements.in + # via -r docs/requirements.in hpack==4.1.0 # via h2 hyperframe==6.1.0 @@ -97,7 +97,7 @@ pycparser==3.0 # via cffi pydantic==2.12.5 # via - # -r requirements.in + # -r docs/requirements.in # scrapy-spider-metadata pydantic-core==2.41.5 # via pydantic @@ -121,14 +121,14 @@ roman-numerals==4.1.0 scrapy==2.14.2 # via scrapy-spider-metadata scrapy-spider-metadata==0.2.0 - # via -r requirements.in + # via -r docs/requirements.in service-identity==24.2.0 # via scrapy snowballstemmer==3.0.1 # via sphinx sphinx==9.1.0 # via - # -r requirements.in + # -r docs/requirements.in # sphinx-copybutton # sphinx-last-updated-by-git # sphinx-llms-txt @@ -146,15 +146,15 @@ sphinx-llms-txt @ git+https://github.com/zytedata/sphinx-llms-txt.git@5e8866cb0c sphinx-markdown-builder @ git+https://github.com/zytedata/sphinx-markdown-builder.git@cfe4c0bfd7b4542f7e6b65a58cdf9ec765829940 # via sphinx-scrapy sphinx-notfound-page==1.1.0 - # via -r requirements.in + # via -r docs/requirements.in sphinx-rtd-dark-mode==1.3.0 - # via -r requirements.in + # via -r docs/requirements.in sphinx-rtd-theme==3.1.0 # via - # -r requirements.in + # -r docs/requirements.in # sphinx-rtd-dark-mode -sphinx-scrapy @ git+https://github.com/scrapy/sphinx-scrapy.git@eef1f8c3ab3b74b6891752b8f4624373345bae26 - # via -r requirements.in +sphinx-scrapy @ git+https://github.com/scrapy/sphinx-scrapy.git@96826815002921f27a2e369b12c0c25af7a1f8b2 + # via -r docs/requirements.in sphinx-sitemap==2.9.0 # via sphinx-scrapy sphinxcontrib-applehelp==2.0.0 diff --git a/tox.ini b/tox.ini index d0fd6680d..219a2b616 100644 --- a/tox.ini +++ b/tox.ini @@ -5,7 +5,7 @@ [tox] requires = - sphinx-scrapy[tox] @ git+https://github.com/scrapy/sphinx-scrapy.git@0.8.4 + sphinx-scrapy[tox] @ git+https://github.com/scrapy/sphinx-scrapy.git@0.8.5 envlist = pre-commit,pylint,typing,py,docs minversion = 1.7.0 From 14f49ab63c54007a8914706454ba69db4199845e Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Sun, 3 May 2026 14:11:55 +0500 Subject: [PATCH 131/248] Remove deprecated test utils. --- conftest.py | 3 -- pyproject.toml | 1 - scrapy/utils/test.py | 103 --------------------------------------- scrapy/utils/testproc.py | 78 ----------------------------- scrapy/utils/testsite.py | 65 ------------------------ 5 files changed, 250 deletions(-) delete mode 100644 scrapy/utils/testproc.py delete mode 100644 scrapy/utils/testsite.py diff --git a/conftest.py b/conftest.py index d49901a7c..4f8d32e1b 100644 --- a/conftest.py +++ b/conftest.py @@ -23,9 +23,6 @@ def _py_files(folder): collect_ignore = [ # may need extra deps "docs/_ext", - # not a test, but looks like a test - "scrapy/utils/testproc.py", - "scrapy/utils/testsite.py", # contains scripts to be run by tests/test_crawler.py::AsyncCrawlerProcessSubprocess *_py_files("tests/AsyncCrawlerProcess"), # contains scripts to be run by tests/test_crawler.py::AsyncCrawlerRunnerSubprocess diff --git a/pyproject.toml b/pyproject.toml index d96790873..d35b333ff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -127,7 +127,6 @@ ignore_errors = true module = [ "scrapy.core.downloader.webclient", "scrapy.spiders.init", - "scrapy.utils.testsite", "tests.test_webclient", ] allow_any_generics = true diff --git a/scrapy/utils/test.py b/scrapy/utils/test.py index 5c69dc76d..b4e20c3c6 100644 --- a/scrapy/utils/test.py +++ b/scrapy/utils/test.py @@ -7,20 +7,14 @@ from __future__ import annotations import asyncio import os import warnings -from ftplib import FTP from importlib import import_module from pathlib import Path -from posixpath import split from typing import TYPE_CHECKING, Any, TypeVar, cast -from unittest import mock -from twisted.trial.unittest import SkipTest from twisted.web.client import Agent from scrapy.crawler import AsyncCrawlerRunner, CrawlerRunner, CrawlerRunnerBase from scrapy.exceptions import ScrapyDeprecationWarning -from scrapy.utils.boto import is_botocore_available -from scrapy.utils.deprecate import create_deprecated_class from scrapy.utils.reactor import is_asyncio_reactor_installed, is_reactor_installed from scrapy.utils.spider import DefaultSpider @@ -37,80 +31,6 @@ if TYPE_CHECKING: _T = TypeVar("_T") -def assert_gcs_environ() -> None: # pragma: no cover - warnings.warn( - "The assert_gcs_environ() function is deprecated and will be removed in a future version of Scrapy." - " Check GCS_PROJECT_ID directly.", - category=ScrapyDeprecationWarning, - stacklevel=2, - ) - if "GCS_PROJECT_ID" not in os.environ: - raise SkipTest("GCS_PROJECT_ID not found") - - -def skip_if_no_boto() -> None: # pragma: no cover - warnings.warn( - "The skip_if_no_boto() function is deprecated and will be removed in a future version of Scrapy." - " Check scrapy.utils.boto.is_botocore_available() directly.", - category=ScrapyDeprecationWarning, - stacklevel=2, - ) - if not is_botocore_available(): - raise SkipTest("missing botocore library") - - -def get_gcs_content_and_delete( - bucket: Any, path: str -) -> tuple[bytes, list[dict[str, str]], Any]: # pragma: no cover - from google.cloud import storage # noqa: PLC0415 - - warnings.warn( - "The get_gcs_content_and_delete() function is deprecated and will be removed in a future version of Scrapy.", - category=ScrapyDeprecationWarning, - stacklevel=2, - ) - client = storage.Client(project=os.environ.get("GCS_PROJECT_ID")) - bucket = client.get_bucket(bucket) - blob = bucket.get_blob(path) - content = blob.download_as_string() - acl = list(blob.acl) # loads acl before it will be deleted - bucket.delete_blob(path) - return content, acl, blob - - -def get_ftp_content_and_delete( - path: str, - host: str, - port: int, - username: str, - password: str, - use_active_mode: bool = False, -) -> bytes: # pragma: no cover - warnings.warn( - "The get_ftp_content_and_delete() function is deprecated and will be removed in a future version of Scrapy.", - category=ScrapyDeprecationWarning, - stacklevel=2, - ) - ftp = FTP() - ftp.connect(host, port) - ftp.login(username, password) - if use_active_mode: - ftp.set_pasv(False) - ftp_data: list[bytes] = [] - - def buffer_data(data: bytes) -> None: - ftp_data.append(data) - - ftp.retrbinary(f"RETR {path}", buffer_data) - dirname, filename = split(path) - ftp.cwd(dirname) - ftp.delete(filename) - return b"".join(ftp_data) - - -TestSpider = create_deprecated_class("TestSpider", DefaultSpider) - - def get_reactor_settings() -> dict[str, Any]: """Return a settings dict that works with the installed reactor. @@ -185,29 +105,6 @@ def get_from_asyncio_queue(value: _T) -> Awaitable[_T]: return getter -def mock_google_cloud_storage() -> tuple[Any, Any, Any]: # pragma: no cover - """Creates autospec mocks for google-cloud-storage Client, Bucket and Blob - classes and set their proper return values. - """ - from google.cloud.storage import Blob, Bucket, Client # noqa: PLC0415 - - warnings.warn( - "The mock_google_cloud_storage() function is deprecated and will be removed in a future version of Scrapy.", - category=ScrapyDeprecationWarning, - stacklevel=2, - ) - - client_mock = mock.create_autospec(Client) - - bucket_mock = mock.create_autospec(Bucket) - client_mock.get_bucket.return_value = bucket_mock - - blob_mock = mock.create_autospec(Blob) - bucket_mock.blob.return_value = blob_mock - - return (client_mock, bucket_mock, blob_mock) - - def get_web_client_agent_req(url: str) -> Deferred[TxResponse]: # pragma: no cover warnings.warn( "The get_web_client_agent_req() function is deprecated" diff --git a/scrapy/utils/testproc.py b/scrapy/utils/testproc.py deleted file mode 100644 index 7d548524a..000000000 --- a/scrapy/utils/testproc.py +++ /dev/null @@ -1,78 +0,0 @@ -# pragma: no file cover -from __future__ import annotations - -import os -import sys -import warnings -from typing import TYPE_CHECKING, ClassVar, cast - -from twisted.internet.defer import Deferred -from twisted.internet.protocol import ProcessProtocol - -from scrapy.exceptions import ScrapyDeprecationWarning - -if TYPE_CHECKING: - from collections.abc import Iterable - - from twisted.internet.error import ProcessTerminated - from twisted.python.failure import Failure - - -warnings.warn( - "The scrapy.utils.testproc module is deprecated.", - ScrapyDeprecationWarning, - stacklevel=2, -) - - -class ProcessTest: - command: str | None = None - prefix: ClassVar[list[str]] = [sys.executable, "-m", "scrapy.cmdline"] - cwd = os.getcwd() # trial chdirs to temp dir # noqa: PTH109 - - def execute( - self, - args: Iterable[str], - check_code: bool = True, - settings: str | None = None, - ) -> Deferred[TestProcessProtocol]: - from twisted.internet import reactor - - env = os.environ.copy() - if settings is not None: - env["SCRAPY_SETTINGS_MODULE"] = settings - assert self.command - cmd = [*self.prefix, self.command, *args] - pp = TestProcessProtocol() - pp.deferred.addCallback(self._process_finished, cmd, check_code) - reactor.spawnProcess(pp, cmd[0], cmd, env=env, path=self.cwd) - return pp.deferred - - def _process_finished( - self, pp: TestProcessProtocol, cmd: list[str], check_code: bool - ) -> tuple[int, bytes, bytes]: - if pp.exitcode and check_code: - msg = f"process {cmd} exit with code {pp.exitcode}" - msg += f"\n>>> stdout <<<\n{pp.out.decode()}" - msg += "\n" - msg += f"\n>>> stderr <<<\n{pp.err.decode()}" - raise RuntimeError(msg) - return cast("int", pp.exitcode), pp.out, pp.err - - -class TestProcessProtocol(ProcessProtocol): - def __init__(self) -> None: - self.deferred: Deferred[TestProcessProtocol] = Deferred() - self.out: bytes = b"" - self.err: bytes = b"" - self.exitcode: int | None = None - - def outReceived(self, data: bytes) -> None: - self.out += data - - def errReceived(self, data: bytes) -> None: - self.err += data - - def processEnded(self, status: Failure) -> None: - self.exitcode = cast("ProcessTerminated", status.value).exitCode - self.deferred.callback(self) diff --git a/scrapy/utils/testsite.py b/scrapy/utils/testsite.py deleted file mode 100644 index 4089e86ed..000000000 --- a/scrapy/utils/testsite.py +++ /dev/null @@ -1,65 +0,0 @@ -# pragma: no file cover -import warnings -from urllib.parse import urljoin - -from twisted.web import resource, server, static, util - -from scrapy.exceptions import ScrapyDeprecationWarning - -warnings.warn( - "The scrapy.utils.testsite module is deprecated.", - ScrapyDeprecationWarning, - stacklevel=2, -) - - -class SiteTest: - def setUp(self): - from twisted.internet import reactor - - super().setUp() - self.site = reactor.listenTCP(0, test_site(), interface="127.0.0.1") - self.baseurl = f"http://localhost:{self.site.getHost().port}/" - - def tearDown(self): - super().tearDown() - self.site.stopListening() - - def url(self, path: str) -> str: - return urljoin(self.baseurl, path) - - -class NoMetaRefreshRedirect(util.Redirect): - def render(self, request: server.Request) -> bytes: - content = util.Redirect.render(self, request) - return content.replace( - b'http-equiv="refresh"', b'http-no-equiv="do-not-refresh-me"' - ) - - -def test_site(): - r = resource.Resource() - r.putChild(b"text", static.Data(b"Works", "text/plain")) - r.putChild( - b"html", - static.Data( - b"

Works

World

", - "text/html", - ), - ) - r.putChild( - b"enc-gb18030", - static.Data(b"

gb18030 encoding

", "text/html; charset=gb18030"), - ) - r.putChild(b"redirect", util.Redirect(b"/redirected")) - r.putChild(b"redirect-no-meta-refresh", NoMetaRefreshRedirect(b"/redirected")) - r.putChild(b"redirected", static.Data(b"Redirected here", "text/plain")) - return server.Site(r) - - -if __name__ == "__main__": - from twisted.internet import reactor # pylint: disable=ungrouped-imports - - port = reactor.listenTCP(0, test_site(), interface="127.0.0.1") - print(f"http://localhost:{port.getHost().port}/") - reactor.run() From 8ecfd20fcd6ee5be2ed1761092a581ff5a9a9ed6 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Sun, 3 May 2026 14:12:16 +0500 Subject: [PATCH 132/248] Remove InitSpider. --- pyproject.toml | 2 -- scrapy/spiders/init.py | 64 ------------------------------------------ tests/test_spider.py | 24 +--------------- 3 files changed, 1 insertion(+), 89 deletions(-) delete mode 100644 scrapy/spiders/init.py diff --git a/pyproject.toml b/pyproject.toml index d35b333ff..ea7893e2a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -126,7 +126,6 @@ ignore_errors = true [[tool.mypy.overrides]] module = [ "scrapy.core.downloader.webclient", - "scrapy.spiders.init", "tests.test_webclient", ] allow_any_generics = true @@ -138,7 +137,6 @@ warn_return_any = false # usually no type hints [[tool.mypy.overrides]] module = [ -# "IPython.*", "bpython", "brotli", "brotlicffi", diff --git a/scrapy/spiders/init.py b/scrapy/spiders/init.py deleted file mode 100644 index 957bfffd3..000000000 --- a/scrapy/spiders/init.py +++ /dev/null @@ -1,64 +0,0 @@ -from __future__ import annotations - -import warnings -from typing import TYPE_CHECKING, Any, cast - -from scrapy.exceptions import ScrapyDeprecationWarning -from scrapy.spiders import Spider -from scrapy.utils.spider import iterate_spider_output - -if TYPE_CHECKING: - from collections.abc import AsyncIterator, Iterable - - from scrapy import Request - from scrapy.http import Response - - -class InitSpider(Spider): - """Base Spider with initialization facilities - - .. warning:: This class is deprecated. Copy its code into your project if needed. - It will be removed in a future Scrapy version. - """ - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - warnings.warn( - "InitSpider is deprecated. Copy its code from Scrapy's source if needed. " - "Will be removed in a future version.", - ScrapyDeprecationWarning, - stacklevel=2, - ) - - async def start(self) -> AsyncIterator[Any]: - with warnings.catch_warnings(): - warnings.filterwarnings( - "ignore", category=ScrapyDeprecationWarning, module=r"^scrapy\.spiders$" - ) - for item_or_request in self.start_requests(): - yield item_or_request - - def start_requests(self) -> Iterable[Request]: - self._postinit_reqs: Iterable[Request] = super().start_requests() - return cast("Iterable[Request]", iterate_spider_output(self.init_request())) - - def initialized(self, response: Response | None = None) -> Any: - """This method must be set as the callback of your last initialization - request. See self.init_request() docstring for more info. - """ - return self.__dict__.pop("_postinit_reqs") - - def init_request(self) -> Any: - """This function should return one initialization request, with the - self.initialized method as callback. When the self.initialized method - is called this spider is considered initialized. If you need to perform - several requests for initializing your spider, you can do so by using - different callbacks. The only requirement is that the final callback - (of the last initialization request) must be self.initialized. - - The default implementation calls self.initialized immediately, and - means that no initialization is needed. This method should be - overridden only when you need to perform requests to initialize your - spider - """ - return self.initialized() diff --git a/tests/test_spider.py b/tests/test_spider.py index ff9aed74f..23efed77a 100644 --- a/tests/test_spider.py +++ b/tests/test_spider.py @@ -11,10 +11,9 @@ from scrapy.crawler import Crawler from scrapy.http import Response, TextResponse, XmlResponse from scrapy.settings import Settings from scrapy.spiders import CSVFeedSpider, Spider, XMLFeedSpider -from scrapy.spiders.init import InitSpider from scrapy.utils.test import get_crawler, get_reactor_settings from tests import get_testdata -from tests.utils.decorators import coroutine_test, inline_callbacks_test +from tests.utils.decorators import inline_callbacks_test class TestSpider: @@ -122,27 +121,6 @@ class TestSpider: mock_logger.log.assert_called_once_with("INFO", "test log msg") -@pytest.mark.filterwarnings("ignore::scrapy.exceptions.ScrapyDeprecationWarning") -class TestInitSpider(TestSpider): - spider_class = InitSpider - - @coroutine_test - async def test_start_urls(self): - responses = [] - - class TestSpider(self.spider_class): - name = "test" - start_urls = ["data:,"] - - async def parse(self, response): - responses.append(response) - - crawler = get_crawler(TestSpider) - await crawler.crawl_async() - assert len(responses) == 1 - assert responses[0].url == "data:," - - class TestXMLFeedSpider(TestSpider): spider_class = XMLFeedSpider From af7dcabebb72aafbcd04425eaad3b2423406dd5a Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Sun, 26 Apr 2026 16:02:30 +0500 Subject: [PATCH 133/248] Drop AjaxCrawlMiddleware and escape_ajax(). --- docs/topics/settings.rst | 1 - scrapy/downloadermiddlewares/ajaxcrawl.py | 114 ------------------ scrapy/settings/default_settings.py | 1 - scrapy/utils/url.py | 37 +----- ...test_downloadermiddleware_ajaxcrawlable.py | 62 ---------- 5 files changed, 1 insertion(+), 214 deletions(-) delete mode 100644 scrapy/downloadermiddlewares/ajaxcrawl.py delete mode 100644 tests/test_downloadermiddleware_ajaxcrawlable.py diff --git a/docs/topics/settings.rst b/docs/topics/settings.rst index 84219095b..cf2658d60 100644 --- a/docs/topics/settings.rst +++ b/docs/topics/settings.rst @@ -809,7 +809,6 @@ Default: "scrapy.downloadermiddlewares.defaultheaders.DefaultHeadersMiddleware": 400, "scrapy.downloadermiddlewares.useragent.UserAgentMiddleware": 500, "scrapy.downloadermiddlewares.retry.RetryMiddleware": 550, - "scrapy.downloadermiddlewares.ajaxcrawl.AjaxCrawlMiddleware": 560, "scrapy.downloadermiddlewares.redirect.MetaRefreshMiddleware": 580, "scrapy.downloadermiddlewares.httpcompression.HttpCompressionMiddleware": 590, "scrapy.downloadermiddlewares.redirect.RedirectMiddleware": 600, diff --git a/scrapy/downloadermiddlewares/ajaxcrawl.py b/scrapy/downloadermiddlewares/ajaxcrawl.py deleted file mode 100644 index a23deaa45..000000000 --- a/scrapy/downloadermiddlewares/ajaxcrawl.py +++ /dev/null @@ -1,114 +0,0 @@ -from __future__ import annotations - -import logging -import re -from typing import TYPE_CHECKING -from warnings import warn - -from w3lib import html - -from scrapy.exceptions import NotConfigured, ScrapyDeprecationWarning -from scrapy.http import HtmlResponse, Response -from scrapy.utils.url import escape_ajax - -if TYPE_CHECKING: - # typing.Self requires Python 3.11 - from typing_extensions import Self - - from scrapy import Request, Spider - from scrapy.crawler import Crawler - from scrapy.settings import BaseSettings - - -logger = logging.getLogger(__name__) - - -class AjaxCrawlMiddleware: - """ - Handle 'AJAX crawlable' pages marked as crawlable via meta tag. - """ - - def __init__(self, settings: BaseSettings): - if not settings.getbool("AJAXCRAWL_ENABLED"): - raise NotConfigured - - warn( - "scrapy.downloadermiddlewares.ajaxcrawl.AjaxCrawlMiddleware is deprecated" - " and will be removed in a future Scrapy version.", - ScrapyDeprecationWarning, - stacklevel=2, - ) - - # XXX: Google parses at least first 100k bytes; scrapy's redirect - # middleware parses first 4k. 4k turns out to be insufficient - # for this middleware, and parsing 100k could be slow. - # We use something in between (32K) by default. - self.lookup_bytes: int = settings.getint("AJAXCRAWL_MAXSIZE") - - @classmethod - def from_crawler(cls, crawler: Crawler) -> Self: - return cls(crawler.settings) - - def process_response( - self, request: Request, response: Response, spider: Spider - ) -> Request | Response: - if not isinstance(response, HtmlResponse) or response.status != 200: - return response - - if request.method != "GET": - # other HTTP methods are either not safe or don't have a body - return response - - if "ajax_crawlable" in request.meta: # prevent loops - return response - - if not self._has_ajax_crawlable_variant(response): - return response - - ajax_crawl_request = request.replace(url=escape_ajax(request.url + "#!")) - logger.debug( - "Downloading AJAX crawlable %(ajax_crawl_request)s instead of %(request)s", - {"ajax_crawl_request": ajax_crawl_request, "request": request}, - extra={"spider": spider}, - ) - - ajax_crawl_request.meta["ajax_crawlable"] = True - return ajax_crawl_request - - def _has_ajax_crawlable_variant(self, response: Response) -> bool: - """ - Return True if a page without hash fragment could be "AJAX crawlable". - """ - body = response.text[: self.lookup_bytes] - return _has_ajaxcrawlable_meta(body) - - -_ajax_crawlable_re: re.Pattern[str] = re.compile( - r'' -) - - -def _has_ajaxcrawlable_meta(text: str) -> bool: - """ - >>> _has_ajaxcrawlable_meta('') - True - >>> _has_ajaxcrawlable_meta("") - True - >>> _has_ajaxcrawlable_meta('') - False - >>> _has_ajaxcrawlable_meta('') - False - """ - - # Stripping scripts and comments is slow (about 20x slower than - # just checking if a string is in text); this is a quick fail-fast - # path that should work for most pages. - if "fragment" not in text: - return False - if "content" not in text: - return False - - text = html.remove_tags_with_content(text, ("script", "noscript")) - text = html.replace_entities(text) - text = html.remove_comments(text) - return _ajax_crawlable_re.search(text) is not None diff --git a/scrapy/settings/default_settings.py b/scrapy/settings/default_settings.py index b80e48601..7b54a9c14 100644 --- a/scrapy/settings/default_settings.py +++ b/scrapy/settings/default_settings.py @@ -289,7 +289,6 @@ DOWNLOADER_MIDDLEWARES_BASE = { "scrapy.downloadermiddlewares.defaultheaders.DefaultHeadersMiddleware": 400, "scrapy.downloadermiddlewares.useragent.UserAgentMiddleware": 500, "scrapy.downloadermiddlewares.retry.RetryMiddleware": 550, - "scrapy.downloadermiddlewares.ajaxcrawl.AjaxCrawlMiddleware": 560, "scrapy.downloadermiddlewares.redirect.MetaRefreshMiddleware": 580, "scrapy.downloadermiddlewares.httpcompression.HttpCompressionMiddleware": 590, "scrapy.downloadermiddlewares.redirect.RedirectMiddleware": 600, diff --git a/scrapy/utils/url.py b/scrapy/utils/url.py index 39b581ff0..1c05ac20e 100644 --- a/scrapy/utils/url.py +++ b/scrapy/utils/url.py @@ -9,11 +9,9 @@ import re import warnings from importlib import import_module from typing import TYPE_CHECKING, Any, TypeAlias -from urllib.parse import ParseResult, urldefrag, urlparse, urlunparse -from warnings import warn +from urllib.parse import ParseResult, urlparse, urlunparse from w3lib.url import __all__ as _public_w3lib_objects -from w3lib.url import add_or_replace_parameter as _add_or_replace_parameter from w3lib.url import any_to_uri as _any_to_uri from w3lib.url import parse_url as _parse_url @@ -70,39 +68,6 @@ def url_has_any_extension(url: UrlT, extensions: Iterable[str]) -> bool: return any(lowercase_path.endswith(ext) for ext in extensions) -def escape_ajax(url: str) -> str: - """ - Return the crawlable url - - >>> escape_ajax("www.example.com/ajax.html#!key=value") - 'www.example.com/ajax.html?_escaped_fragment_=key%3Dvalue' - >>> escape_ajax("www.example.com/ajax.html?k1=v1&k2=v2#!key=value") - 'www.example.com/ajax.html?k1=v1&k2=v2&_escaped_fragment_=key%3Dvalue' - >>> escape_ajax("www.example.com/ajax.html?#!key=value") - 'www.example.com/ajax.html?_escaped_fragment_=key%3Dvalue' - >>> escape_ajax("www.example.com/ajax.html#!") - 'www.example.com/ajax.html?_escaped_fragment_=' - - URLs that are not "AJAX crawlable" (according to Google) returned as-is: - - >>> escape_ajax("www.example.com/ajax.html#key=value") - 'www.example.com/ajax.html#key=value' - >>> escape_ajax("www.example.com/ajax.html#") - 'www.example.com/ajax.html#' - >>> escape_ajax("www.example.com/ajax.html") - 'www.example.com/ajax.html' - """ - warn( - "escape_ajax() is deprecated and will be removed in a future Scrapy version.", - ScrapyDeprecationWarning, - stacklevel=2, - ) - defrag, frag = urldefrag(url) - if not frag.startswith("!"): - return url - return _add_or_replace_parameter(defrag, "_escaped_fragment_", frag[1:]) - - def add_http_if_no_scheme(url: str) -> str: """Add http as the default scheme if it is missing from the url.""" match = re.match(r"^\w+://", url, flags=re.IGNORECASE) diff --git a/tests/test_downloadermiddleware_ajaxcrawlable.py b/tests/test_downloadermiddleware_ajaxcrawlable.py deleted file mode 100644 index 44084f1e8..000000000 --- a/tests/test_downloadermiddleware_ajaxcrawlable.py +++ /dev/null @@ -1,62 +0,0 @@ -import pytest - -from scrapy.downloadermiddlewares.ajaxcrawl import AjaxCrawlMiddleware -from scrapy.http import HtmlResponse, Request, Response -from scrapy.spiders import Spider -from scrapy.utils.test import get_crawler - - -@pytest.mark.filterwarnings("ignore::scrapy.exceptions.ScrapyDeprecationWarning") -class TestAjaxCrawlMiddleware: - def setup_method(self): - crawler = get_crawler(Spider, {"AJAXCRAWL_ENABLED": True}) - self.spider = crawler._create_spider("foo") - self.mw = AjaxCrawlMiddleware.from_crawler(crawler) - - def _ajaxcrawlable_body(self): - return b'' - - def _req_resp(self, url, req_kwargs=None, resp_kwargs=None): - req = Request(url, **(req_kwargs or {})) - resp = HtmlResponse(url, request=req, **(resp_kwargs or {})) - return req, resp - - def test_non_get(self): - req, resp = self._req_resp("http://example.com/", {"method": "HEAD"}) - resp2 = self.mw.process_response(req, resp, self.spider) - assert resp == resp2 - - def test_binary_response(self): - req = Request("http://example.com/") - resp = Response("http://example.com/", body=b"foobar\x00\x01\x02", request=req) - resp2 = self.mw.process_response(req, resp, self.spider) - assert resp is resp2 - - def test_ajaxcrawl(self): - req, resp = self._req_resp( - "http://example.com/", - {"meta": {"foo": "bar"}}, - {"body": self._ajaxcrawlable_body()}, - ) - req2 = self.mw.process_response(req, resp, self.spider) - assert req2.url == "http://example.com/?_escaped_fragment_=" - assert req2.meta["foo"] == "bar" - - def test_ajaxcrawl_loop(self): - req, resp = self._req_resp( - "http://example.com/", {}, {"body": self._ajaxcrawlable_body()} - ) - req2 = self.mw.process_response(req, resp, self.spider) - resp2 = HtmlResponse(req2.url, body=resp.body, request=req2) - resp3 = self.mw.process_response(req2, resp2, self.spider) - - assert isinstance(resp3, HtmlResponse), (resp3.__class__, resp3) - assert resp3.request.url == "http://example.com/?_escaped_fragment_=" - assert resp3 is resp2 - - def test_noncrawlable_body(self): - req, resp = self._req_resp( - "http://example.com/", {}, {"body": b""} - ) - resp2 = self.mw.process_response(req, resp, self.spider) - assert resp is resp2 From 5fc40f07f3c3a13fd23ef694c15f0d2e772588d5 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Sun, 26 Apr 2026 16:15:09 +0500 Subject: [PATCH 134/248] Drop w3lib.url reexports. --- scrapy/utils/url.py | 34 +++++----------------------------- tests/test_utils_url.py | 26 +------------------------- 2 files changed, 6 insertions(+), 54 deletions(-) diff --git a/scrapy/utils/url.py b/scrapy/utils/url.py index 1c05ac20e..8f75e2618 100644 --- a/scrapy/utils/url.py +++ b/scrapy/utils/url.py @@ -6,34 +6,10 @@ library. from __future__ import annotations import re -import warnings -from importlib import import_module -from typing import TYPE_CHECKING, Any, TypeAlias +from typing import TYPE_CHECKING, TypeAlias from urllib.parse import ParseResult, urlparse, urlunparse -from w3lib.url import __all__ as _public_w3lib_objects -from w3lib.url import any_to_uri as _any_to_uri -from w3lib.url import parse_url as _parse_url - -from scrapy.exceptions import ScrapyDeprecationWarning - -_DEPRECATED_NAMES: frozenset[str] = frozenset( - {"_unquotepath", "_safe_chars", "parse_url", *_public_w3lib_objects} -) - - -def __getattr__(name: str) -> Any: - if name in _DEPRECATED_NAMES: - obj_type = "attribute" if name == "_safe_chars" else "function" - warnings.warn( - f"The scrapy.utils.url.{name} {obj_type} is deprecated, use w3lib.url.{name} instead.", - ScrapyDeprecationWarning, - stacklevel=2, - ) - return getattr(import_module("w3lib.url"), name) - - raise AttributeError - +from w3lib.url import any_to_uri, parse_url if TYPE_CHECKING: from collections.abc import Iterable @@ -45,7 +21,7 @@ UrlT: TypeAlias = str | bytes | ParseResult def url_is_from_any_domain(url: UrlT, domains: Iterable[str]) -> bool: """Return True if the url belongs to any of the given domains""" - host = _parse_url(url).netloc.lower() + host = parse_url(url).netloc.lower() if not host: return False return any((host == d) or (host.endswith(f".{d}")) for d in map(str.lower, domains)) @@ -64,7 +40,7 @@ def url_is_from_spider(url: UrlT, spider: type[Spider]) -> bool: def url_has_any_extension(url: UrlT, extensions: Iterable[str]) -> bool: """Return True if the url ends with one of the extensions provided""" - lowercase_path = _parse_url(url).path.lower() + lowercase_path = parse_url(url).path.lower() return any(lowercase_path.endswith(ext) for ext in extensions) @@ -125,7 +101,7 @@ def guess_scheme(url: str) -> str: """Add an URL scheme if missing: file:// for filepath-like input or http:// otherwise.""" if _is_filesystem_path(url): - return _any_to_uri(url) + return any_to_uri(url) return add_http_if_no_scheme(url) diff --git a/tests/test_utils_url.py b/tests/test_utils_url.py index bcbbe74a3..19a31c353 100644 --- a/tests/test_utils_url.py +++ b/tests/test_utils_url.py @@ -1,13 +1,9 @@ -import warnings -from importlib import import_module - import pytest from scrapy.linkextractors import IGNORED_EXTENSIONS from scrapy.spiders import Spider -from scrapy.utils.url import ( # type: ignore[attr-defined] +from scrapy.utils.url import ( _is_filesystem_path, - _public_w3lib_objects, add_http_if_no_scheme, guess_scheme, strip_url, @@ -446,23 +442,3 @@ class TestStripUrl: ) def test__is_filesystem_path(path: str, expected: bool) -> None: assert _is_filesystem_path(path) == expected - - -@pytest.mark.parametrize( - "obj_name", - [ - "_unquotepath", - "_safe_chars", - "parse_url", - *_public_w3lib_objects, - ], -) -def test_deprecated_imports_from_w3lib(obj_name: str) -> None: - with warnings.catch_warnings(record=True) as warns: - obj_type = "attribute" if obj_name == "_safe_chars" else "function" - message = f"The scrapy.utils.url.{obj_name} {obj_type} is deprecated, use w3lib.url.{obj_name} instead." - - getattr(import_module("scrapy.utils.url"), obj_name) - - assert isinstance(warns[0].message, Warning) - assert message in warns[0].message.args From f24bc749ea5b45e3529c2040b55878623ce3d8fc Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Sun, 26 Apr 2026 16:20:53 +0500 Subject: [PATCH 135/248] Require start_queue_cls. --- scrapy/core/scheduler.py | 64 ++++++++++------------------------------ 1 file changed, 15 insertions(+), 49 deletions(-) diff --git a/scrapy/core/scheduler.py b/scrapy/core/scheduler.py index 1b7fb652d..78329460e 100644 --- a/scrapy/core/scheduler.py +++ b/scrapy/core/scheduler.py @@ -5,16 +5,13 @@ import logging from abc import abstractmethod from pathlib import Path from typing import TYPE_CHECKING, Any -from warnings import warn # working around https://github.com/sphinx-doc/sphinx/issues/10400 from twisted.internet.defer import Deferred # noqa: TC002 -from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.spiders import Spider # noqa: TC001 from scrapy.utils.job import job_dir from scrapy.utils.misc import build_from_crawler, load_object -from scrapy.utils.python import global_object_name if TYPE_CHECKING: # requires queuelib >= 1.6.2 @@ -450,28 +447,13 @@ class Scheduler(BaseScheduler): """Create a new priority queue instance, with in-memory storage""" assert self.crawler assert self.pqclass - try: - return build_from_crawler( - self.pqclass, - self.crawler, - downstream_queue_cls=self.mqclass, - key="", - start_queue_cls=self._smqclass, - ) - except TypeError: # pragma: no cover - warn( - f"The __init__ method of {global_object_name(self.pqclass)} " - "does not support a `start_queue_cls` keyword-only " - "parameter.", - ScrapyDeprecationWarning, - stacklevel=2, - ) - return build_from_crawler( - self.pqclass, - self.crawler, - downstream_queue_cls=self.mqclass, - key="", - ) + return build_from_crawler( + self.pqclass, + self.crawler, + downstream_queue_cls=self.mqclass, + key="", + start_queue_cls=self._smqclass, + ) def _dq(self) -> ScrapyPriorityQueue: """Create a new priority queue instance, with disk storage""" @@ -479,30 +461,14 @@ class Scheduler(BaseScheduler): assert self.dqdir assert self.pqclass state = self._read_dqs_state(self.dqdir) - try: - q = build_from_crawler( - self.pqclass, - self.crawler, - downstream_queue_cls=self.dqclass, - key=self.dqdir, - startprios=state, - start_queue_cls=self._sdqclass, - ) - except TypeError: # pragma: no cover - warn( - f"The __init__ method of {global_object_name(self.pqclass)} " - "does not support a `start_queue_cls` keyword-only " - "parameter.", - ScrapyDeprecationWarning, - stacklevel=2, - ) - q = build_from_crawler( - self.pqclass, - self.crawler, - downstream_queue_cls=self.dqclass, - key=self.dqdir, - startprios=state, - ) + q = build_from_crawler( + self.pqclass, + self.crawler, + downstream_queue_cls=self.dqclass, + key=self.dqdir, + startprios=state, + start_queue_cls=self._sdqclass, + ) if q: logger.info( "Resuming crawl (%(queuesize)d requests scheduled)", From 6cb2fe1fc3b79c643487b0f051afdbe9c8cf7026 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Sun, 26 Apr 2026 16:22:01 +0500 Subject: [PATCH 136/248] Drop scrapy_components_versions(). --- scrapy/utils/versions.py | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/scrapy/utils/versions.py b/scrapy/utils/versions.py index 6d3572cb1..6bd96a215 100644 --- a/scrapy/utils/versions.py +++ b/scrapy/utils/versions.py @@ -3,11 +3,9 @@ from __future__ import annotations import platform import sys from importlib.metadata import version -from warnings import warn import lxml.etree -from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.settings.default_settings import LOG_VERSIONS from scrapy.utils.ssl import get_openssl_version @@ -32,15 +30,3 @@ def get_versions( ) -> list[tuple[str, str]]: software = software or _DEFAULT_SOFTWARE return [(item, _version(item)) for item in software] - - -def scrapy_components_versions() -> list[tuple[str, str]]: # pragma: no cover - warn( - ( - "scrapy.utils.versions.scrapy_components_versions() is deprecated, " - "use scrapy.utils.versions.get_versions() instead." - ), - ScrapyDeprecationWarning, - stacklevel=2, - ) - return get_versions() From 9f02f6c16acf94bce00a7047dcce2cfa253a5495 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Sun, 26 Apr 2026 16:28:06 +0500 Subject: [PATCH 137/248] Drop spider args of Scraper methods. --- scrapy/core/scraper.py | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/scrapy/core/scraper.py b/scrapy/core/scraper.py index 701f7cb46..e756d27eb 100644 --- a/scrapy/core/scraper.py +++ b/scrapy/core/scraper.py @@ -23,7 +23,6 @@ from scrapy.exceptions import ( from scrapy.http import Request, Response from scrapy.pipelines import ItemPipelineManager from scrapy.utils.asyncio import _parallel_asyncio, is_asyncio_available -from scrapy.utils.decorators import _warn_spider_arg from scrapy.utils.defer import ( _defer_sleep_async, _schedule_coro, @@ -178,9 +177,7 @@ class Scraper: self.itemproc.open_spider(self.crawler.spider) ) - def close_spider( - self, spider: Spider | None = None - ) -> Deferred[None]: # pragma: no cover + def close_spider(self) -> Deferred[None]: # pragma: no cover warnings.warn( "Scraper.close_spider() is deprecated, use close_spider_async() instead", ScrapyDeprecationWarning, @@ -217,9 +214,8 @@ class Scraper: self.slot.closing.callback(self.crawler.spider) @inlineCallbacks - @_warn_spider_arg def enqueue_scrape( - self, result: Response | Failure, request: Request, spider: Spider | None = None + self, result: Response | Failure, request: Request ) -> Generator[Deferred[Any], Any, None]: if self.slot is None: raise RuntimeError("Scraper slot not assigned") @@ -349,13 +345,11 @@ class Scraper: ) return await ensure_awaitable(iterate_spider_output(output)) - @_warn_spider_arg def handle_spider_error( self, _failure: Failure, request: Request, response: Response | Failure, - spider: Spider | None = None, ) -> None: """Handle an exception raised by a spider callback or errback.""" assert self.crawler.spider @@ -391,7 +385,6 @@ class Scraper: result: Iterable[_T] | AsyncIterator[_T], request: Request, response: Response | Failure, - spider: Spider | None = None, ) -> Deferred[None]: # pragma: no cover """Pass items/requests produced by a callback to ``_process_spidermw_output()`` in parallel.""" warnings.warn( From a9324fbf7618d0750678a43106c6d6576db10c1f Mon Sep 17 00:00:00 2001 From: Sanjay M Date: Mon, 4 May 2026 12:13:21 +0530 Subject: [PATCH 138/248] Fix small documentation wording issues (#7480) --- .github/pull_request_template.md | 4 ++-- docs/_ext/scrapyfixautodoc.py | 2 +- docs/topics/telnetconsole.rst | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index dd2edebdd..98a74f8ce 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -12,13 +12,13 @@ Key takeaways: > Note: What follows is based on > https://raw.githubusercontent.com/jackyzha0/quartz/acfaa472253a432d350e9b6904c0cde14f8c487f/.github/pull_request_template.md -We more than welcome contributions, and are OK with the use of LLMs tools. How +We more than welcome contributions, and are OK with the use of LLM tools. How you use those tools depends on whether or not they make you more productive. But one thing that bugs us a lot are PRs that are made entirely with these tools, without any revision or any effort trying to refine their output whatsoever. This is just pure laziness, and unacceptable. Doing so will just -end up wasting everyone time (ours and yours). +end up wasting everyone's time (ours and yours). So to be the most productive for all parties, we would encourage any contributors to, at the very least, pay attention to what the model is doing, diff --git a/docs/_ext/scrapyfixautodoc.py b/docs/_ext/scrapyfixautodoc.py index a8f2c6b68..e342e92cf 100644 --- a/docs/_ext/scrapyfixautodoc.py +++ b/docs/_ext/scrapyfixautodoc.py @@ -11,7 +11,7 @@ from sphinx.application import Sphinx def maybe_skip_member(app: Sphinx, what, name: str, obj, skip: bool, options) -> bool: if not skip: - # autodocs was generating a text "alias of" for the following members + # autodoc was generating the text "alias of" for the following members return name in {"default_item_class", "default_selector_class"} return skip diff --git a/docs/topics/telnetconsole.rst b/docs/topics/telnetconsole.rst index e74afe49b..6d99c756e 100644 --- a/docs/topics/telnetconsole.rst +++ b/docs/topics/telnetconsole.rst @@ -46,12 +46,12 @@ the console you need to type:: Password: >>> -By default Username is ``scrapy`` and Password is autogenerated. The -autogenerated Password can be seen on Scrapy logs like the example below:: +By default, the username is ``scrapy`` and the password is autogenerated. The +autogenerated password can be seen on Scrapy logs like the example below:: 2018-10-16 14:35:21 [scrapy.extensions.telnet] INFO: Telnet Password: 16f92501e8a59326 -Default Username and Password can be overridden by the settings +The default username and password can be overridden by the settings :setting:`TELNETCONSOLE_USERNAME` and :setting:`TELNETCONSOLE_PASSWORD`. .. warning:: From c62a81d7dd5f1a7ae3e745b227bc1a6d1ab51e36 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Mon, 4 May 2026 11:50:41 +0500 Subject: [PATCH 139/248] Drop HTTP/1.0 code. (#7486) --- pyproject.toml | 2 - scrapy/core/downloader/contextfactory.py | 6 +- scrapy/core/downloader/handlers/http.py | 2 - scrapy/core/downloader/handlers/http10.py | 79 ---- scrapy/core/downloader/webclient.py | 243 ----------- scrapy/settings/default_settings.py | 5 - tests/test_downloader_handler_httpx.py | 24 +- .../test_downloader_handler_twisted_http10.py | 55 --- .../test_downloader_handler_twisted_http11.py | 22 +- .../test_downloader_handler_twisted_http2.py | 16 +- tests/test_downloader_handlers_http_base.py | 25 +- tests/test_webclient.py | 404 ------------------ 12 files changed, 44 insertions(+), 839 deletions(-) delete mode 100644 scrapy/core/downloader/handlers/http10.py delete mode 100644 scrapy/core/downloader/webclient.py delete mode 100644 tests/test_downloader_handler_twisted_http10.py delete mode 100644 tests/test_webclient.py diff --git a/pyproject.toml b/pyproject.toml index d96790873..7ce2f2e4c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -125,10 +125,8 @@ ignore_errors = true # deprecated modules [[tool.mypy.overrides]] module = [ - "scrapy.core.downloader.webclient", "scrapy.spiders.init", "scrapy.utils.testsite", - "tests.test_webclient", ] allow_any_generics = true allow_untyped_calls = true diff --git a/scrapy/core/downloader/contextfactory.py b/scrapy/core/downloader/contextfactory.py index 19fc77959..bbda7efcd 100644 --- a/scrapy/core/downloader/contextfactory.py +++ b/scrapy/core/downloader/contextfactory.py @@ -114,10 +114,10 @@ class _ScrapyClientContextFactory(BrowserLikePolicyForHTTPS): acceptableCiphers=self.tls_ciphers, ) - # kept for old-style HTTP/1.0 downloader context twisted calls, - # e.g. connectSSL() # should be removed together with ScrapyClientContextFactory - def getContext(self, hostname: Any = None, port: Any = None) -> SSL.Context: + def getContext( + self, hostname: Any = None, port: Any = None + ) -> SSL.Context: # pragma: no cover return self._get_context() def _get_context(self) -> SSL.Context: diff --git a/scrapy/core/downloader/handlers/http.py b/scrapy/core/downloader/handlers/http.py index a7b592d00..dc1b1e375 100644 --- a/scrapy/core/downloader/handlers/http.py +++ b/scrapy/core/downloader/handlers/http.py @@ -1,7 +1,6 @@ # pragma: no file cover import warnings -from scrapy.core.downloader.handlers.http10 import HTTP10DownloadHandler from scrapy.core.downloader.handlers.http11 import ( HTTP11DownloadHandler as HTTPDownloadHandler, ) @@ -16,6 +15,5 @@ warnings.warn( ) __all__ = [ - "HTTP10DownloadHandler", "HTTPDownloadHandler", ] diff --git a/scrapy/core/downloader/handlers/http10.py b/scrapy/core/downloader/handlers/http10.py deleted file mode 100644 index f5d1bbd76..000000000 --- a/scrapy/core/downloader/handlers/http10.py +++ /dev/null @@ -1,79 +0,0 @@ -"""Download handlers for http and https schemes""" - -from __future__ import annotations - -import warnings -from typing import TYPE_CHECKING - -from scrapy.core.downloader.contextfactory import _ScrapyClientContextFactory -from scrapy.exceptions import NotConfigured, ScrapyDeprecationWarning -from scrapy.utils.defer import maybe_deferred_to_future -from scrapy.utils.misc import build_from_crawler, load_object -from scrapy.utils.python import to_unicode - -if TYPE_CHECKING: - from twisted.internet.interfaces import IConnector - - # typing.Self requires Python 3.11 - from typing_extensions import Self - - from scrapy import Request - from scrapy.core.downloader.webclient import ScrapyHTTPClientFactory - from scrapy.crawler import Crawler - from scrapy.http import Response - from scrapy.settings import BaseSettings - - -class HTTP10DownloadHandler: - lazy = False - - def __init__(self, settings: BaseSettings, crawler: Crawler): - warnings.warn( - "HTTP10DownloadHandler is deprecated and will be removed in a future Scrapy version.", - category=ScrapyDeprecationWarning, - stacklevel=2, - ) - if not crawler.settings.getbool("TWISTED_REACTOR_ENABLED"): # pragma: no cover - raise NotConfigured(f"{type(self).__name__} requires a Twisted reactor.") - self.HTTPClientFactory: type[ScrapyHTTPClientFactory] = load_object( - settings["DOWNLOADER_HTTPCLIENTFACTORY"] - ) - if settings["DOWNLOADER_CLIENTCONTEXTFACTORY"] == "SENTINEL": - self.ClientContextFactory: type[_ScrapyClientContextFactory] = ( - _ScrapyClientContextFactory - ) - else: # pragma: no cover - warnings.warn( - "The 'DOWNLOADER_CLIENTCONTEXTFACTORY' setting is deprecated.", - category=ScrapyDeprecationWarning, - stacklevel=2, - ) - self.ClientContextFactory = load_object( - settings["DOWNLOADER_CLIENTCONTEXTFACTORY"] - ) - self._settings: BaseSettings = settings - self._crawler: Crawler = crawler - - @classmethod - def from_crawler(cls, crawler: Crawler) -> Self: - return cls(crawler.settings, crawler) - - async def download_request(self, request: Request) -> Response: - factory = self.HTTPClientFactory(request) - self._connect(factory) - return await maybe_deferred_to_future(factory.deferred) - - def _connect(self, factory: ScrapyHTTPClientFactory) -> IConnector: - from twisted.internet import reactor - - host, port = to_unicode(factory.host), factory.port - if factory.scheme == b"https": - client_context_factory = build_from_crawler( - self.ClientContextFactory, - self._crawler, - ) - return reactor.connectSSL(host, port, factory, client_context_factory) - return reactor.connectTCP(host, port, factory) - - async def close(self) -> None: - pass diff --git a/scrapy/core/downloader/webclient.py b/scrapy/core/downloader/webclient.py deleted file mode 100644 index 2a550cf78..000000000 --- a/scrapy/core/downloader/webclient.py +++ /dev/null @@ -1,243 +0,0 @@ -"""Deprecated HTTP/1.0 helper classes used by HTTP10DownloadHandler.""" - -from __future__ import annotations - -import warnings -from time import monotonic, time -from typing import TYPE_CHECKING -from urllib.parse import urldefrag, urlparse, urlunparse - -from twisted.internet import defer -from twisted.internet.protocol import ClientFactory -from twisted.web.http import HTTPClient - -from scrapy.exceptions import DownloadTimeoutError, ScrapyDeprecationWarning -from scrapy.http import Headers, Response -from scrapy.responsetypes import responsetypes -from scrapy.utils.httpobj import urlparse_cached -from scrapy.utils.python import to_bytes, to_unicode - -if TYPE_CHECKING: - from scrapy import Request - - -class ScrapyHTTPPageGetter(HTTPClient): - delimiter = b"\n" - - def __init__(self): - warnings.warn( - "ScrapyHTTPPageGetter is deprecated and will be removed in a future Scrapy version.", - category=ScrapyDeprecationWarning, - stacklevel=2, - ) - super().__init__() - - def connectionMade(self): - self.headers = Headers() # bucket for response headers - - # Method command - self.sendCommand(self.factory.method, self.factory.path) - # Headers - for key, values in self.factory.headers.items(): - for value in values: - self.sendHeader(key, value) - self.endHeaders() - # Body - if self.factory.body is not None: - self.transport.write(self.factory.body) - - def lineReceived(self, line): - return HTTPClient.lineReceived(self, line.rstrip()) - - def handleHeader(self, key, value): - self.headers.appendlist(key, value) - - def handleStatus(self, version, status, message): - self.factory.gotStatus(version, status, message) - - def handleEndHeaders(self): - self.factory.gotHeaders(self.headers) - - def connectionLost(self, reason): - self._connection_lost_reason = reason - HTTPClient.connectionLost(self, reason) - self.factory.noPage(reason) - - def handleResponse(self, response): - if self.factory.method.upper() == b"HEAD": - self.factory.page(b"") - elif self.length is not None and self.length > 0: - self.factory.noPage(self._connection_lost_reason) - else: - self.factory.page(response) - self.transport.loseConnection() - - def timeout(self): - self.transport.loseConnection() - - # transport cleanup needed for HTTPS connections - if self.factory.url.startswith(b"https"): - self.transport.stopProducing() - - self.factory.noPage( - DownloadTimeoutError( - f"Getting {self.factory.url} took longer " - f"than {self.factory.timeout} seconds." - ) - ) - - -# This class used to inherit from Twisted’s -# twisted.web.client.HTTPClientFactory. When that class was deprecated in -# Twisted (https://github.com/twisted/twisted/pull/643), we merged its -# non-overridden code into this class. -class ScrapyHTTPClientFactory(ClientFactory): - protocol = ScrapyHTTPPageGetter - - waiting = 1 - noisy = False - followRedirect = False - afterFoundGet = False - - def _build_response(self, body, request): - request.meta["download_latency"] = ( - self._headers_time_mono - self._start_time_mono - ) - status = int(self.status) - headers = Headers(self.response_headers) - respcls = responsetypes.from_args(headers=headers, url=self._url, body=body) - return respcls( - url=self._url, - status=status, - headers=headers, - body=body, - protocol=to_unicode(self.version), - ) - - def _set_connection_attributes(self, request): - proxy = request.meta.get("proxy") - if proxy: - proxy_parsed = urlparse(to_bytes(proxy, encoding="ascii")) - self.scheme = proxy_parsed.scheme - self.host = proxy_parsed.hostname - self.port = proxy_parsed.port - self.netloc = proxy_parsed.netloc - if self.port is None: - self.port = 443 if proxy_parsed.scheme == b"https" else 80 - self.path = self.url - else: - parsed = urlparse_cached(request) - path_str = urlunparse( - ("", "", parsed.path or "/", parsed.params, parsed.query, "") - ) - self.path = to_bytes(path_str, encoding="ascii") - assert parsed.hostname is not None - self.host = to_bytes(parsed.hostname, encoding="ascii") - self.port = parsed.port - self.scheme = to_bytes(parsed.scheme, encoding="ascii") - self.netloc = to_bytes(parsed.netloc, encoding="ascii") - if self.port is None: - self.port = 443 if self.scheme == b"https" else 80 - - def __init__(self, request: Request, timeout: float = 180): - warnings.warn( - "ScrapyHTTPClientFactory is deprecated and will be removed in a future Scrapy version.", - category=ScrapyDeprecationWarning, - stacklevel=2, - ) - - self._url: str = urldefrag(request.url)[0] - # converting to bytes to comply to Twisted interface - self.url: bytes = to_bytes(self._url, encoding="ascii") - self.method: bytes = to_bytes(request.method, encoding="ascii") - self.body: bytes | None = request.body or None - self.headers: Headers = Headers(request.headers) - self.response_headers: Headers | None = None - self.timeout: float = request.meta.get("download_timeout") or timeout - self.start_time: float = time() - self._start_time_mono: float = monotonic() - self.deferred: defer.Deferred[Response] = defer.Deferred().addCallback( - self._build_response, request - ) - - # Fixes Twisted 11.1.0+ support as HTTPClientFactory is expected - # to have _disconnectedDeferred. See Twisted r32329. - # As Scrapy implements it's own logic to handle redirects is not - # needed to add the callback _waitForDisconnect. - # Specifically this avoids the AttributeError exception when - # clientConnectionFailed method is called. - self._disconnectedDeferred: defer.Deferred[None] = defer.Deferred() - - self._set_connection_attributes(request) - - # set Host header based on url - self.headers.setdefault("Host", self.netloc) - - # set Content-Length based len of body - if self.body is not None: - self.headers["Content-Length"] = len(self.body) - # just in case a broken http/1.1 decides to keep connection alive - self.headers.setdefault("Connection", "close") - # Content-Length must be specified in POST method even with no body - elif self.method == b"POST": - self.headers["Content-Length"] = 0 - - def __repr__(self) -> str: - return f"<{self.__class__.__name__}: {self._url}>" - - def _cancelTimeout(self, result, timeoutCall): - if timeoutCall.active(): - timeoutCall.cancel() - return result - - def buildProtocol(self, addr): - p = ClientFactory.buildProtocol(self, addr) - p.followRedirect = self.followRedirect - p.afterFoundGet = self.afterFoundGet - if self.timeout: - from twisted.internet import reactor - - timeoutCall = reactor.callLater(self.timeout, p.timeout) - self.deferred.addBoth(self._cancelTimeout, timeoutCall) - return p - - def gotHeaders(self, headers): - self.headers_time = time() - self._headers_time_mono = monotonic() - self.response_headers = headers - - def gotStatus(self, version, status, message): - """ - Set the status of the request on us. - @param version: The HTTP version. - @type version: L{bytes} - @param status: The HTTP status code, an integer represented as a - bytestring. - @type status: L{bytes} - @param message: The HTTP status message. - @type message: L{bytes} - """ - self.version, self.status, self.message = version, status, message - - def page(self, page): - if self.waiting: - self.waiting = 0 - self.deferred.callback(page) - - def noPage(self, reason): - if self.waiting: - self.waiting = 0 - self.deferred.errback(reason) - - def clientConnectionFailed(self, _, reason): - """ - When a connection attempt fails, the request cannot be issued. If no - result has yet been provided to the result Deferred, provide the - connection failure reason as an error result. - """ - if self.waiting: - self.waiting = 0 - # If the connection attempt failed, there is nothing more to - # disconnect, so just fire that Deferred now. - self._disconnectedDeferred.callback(None) - self.deferred.errback(reason) diff --git a/scrapy/settings/default_settings.py b/scrapy/settings/default_settings.py index b80e48601..f9297d4cf 100644 --- a/scrapy/settings/default_settings.py +++ b/scrapy/settings/default_settings.py @@ -54,7 +54,6 @@ __all__ = [ "DOWNLOADER_CLIENT_TLS_CIPHERS", "DOWNLOADER_CLIENT_TLS_METHOD", "DOWNLOADER_CLIENT_TLS_VERBOSE_LOGGING", - "DOWNLOADER_HTTPCLIENTFACTORY", "DOWNLOADER_MIDDLEWARES", "DOWNLOADER_MIDDLEWARES_BASE", "DOWNLOADER_STATS", @@ -275,10 +274,6 @@ DOWNLOADER_CLIENT_TLS_CIPHERS = "DEFAULT" DOWNLOADER_CLIENT_TLS_METHOD = "TLS" DOWNLOADER_CLIENT_TLS_VERBOSE_LOGGING = False -DOWNLOADER_HTTPCLIENTFACTORY = ( - "scrapy.core.downloader.webclient.ScrapyHTTPClientFactory" -) - DOWNLOADER_MIDDLEWARES = {} DOWNLOADER_MIDDLEWARES_BASE = { # Engine side diff --git a/tests/test_downloader_handler_httpx.py b/tests/test_downloader_handler_httpx.py index 3b1796af7..a0e3fccb3 100644 --- a/tests/test_downloader_handler_httpx.py +++ b/tests/test_downloader_handler_httpx.py @@ -9,9 +9,9 @@ import pytest from scrapy import Request from tests.test_downloader_handlers_http_base import ( - TestHttp11Base, + TestHttpBase, TestHttpProxyBase, - TestHttps11Base, + TestHttpsBase, TestHttpsCustomCiphersBase, TestHttpsInvalidDNSIdBase, TestHttpsInvalidDNSPatternBase, @@ -42,7 +42,7 @@ class HttpxDownloadHandlerMixin: return HttpxDownloadHandler -class TestHttp11(HttpxDownloadHandlerMixin, TestHttp11Base): +class TestHttp(HttpxDownloadHandlerMixin, TestHttpBase): handler_supports_bindaddress_meta = False @pytest.mark.skipif( @@ -77,7 +77,7 @@ class TestHttp11(HttpxDownloadHandlerMixin, TestHttp11Base): ) -class TestHttps11(HttpxDownloadHandlerMixin, TestHttps11Base): +class TestHttps(HttpxDownloadHandlerMixin, TestHttpsBase): handler_supports_bindaddress_meta = False tls_log_message = "SSL connection to 127.0.0.1 using protocol TLSv1.3, cipher" @@ -90,25 +90,25 @@ class TestSimpleHttps(HttpxDownloadHandlerMixin, TestSimpleHttpsBase): pass -class TestHttps11WrongHostname(HttpxDownloadHandlerMixin, TestHttpsWrongHostnameBase): +class TestHttpsWrongHostname(HttpxDownloadHandlerMixin, TestHttpsWrongHostnameBase): pass -class TestHttps11InvalidDNSId(HttpxDownloadHandlerMixin, TestHttpsInvalidDNSIdBase): +class TestHttpsInvalidDNSId(HttpxDownloadHandlerMixin, TestHttpsInvalidDNSIdBase): pass -class TestHttps11InvalidDNSPattern( +class TestHttpsInvalidDNSPattern( HttpxDownloadHandlerMixin, TestHttpsInvalidDNSPatternBase ): pass -class TestHttps11CustomCiphers(HttpxDownloadHandlerMixin, TestHttpsCustomCiphersBase): +class TestHttpsCustomCiphers(HttpxDownloadHandlerMixin, TestHttpsCustomCiphersBase): pass -class TestHttp11WithCrawler(TestHttpWithCrawlerBase): +class TestHttpWithCrawler(TestHttpWithCrawlerBase): @property def settings_dict(self) -> dict[str, Any] | None: return { @@ -119,7 +119,7 @@ class TestHttp11WithCrawler(TestHttpWithCrawlerBase): } -class TestHttps11WithCrawler(TestHttp11WithCrawler): +class TestHttpsWithCrawler(TestHttpWithCrawler): is_secure = True @pytest.mark.skip(reason="response.certificate is not implemented") @@ -129,10 +129,10 @@ class TestHttps11WithCrawler(TestHttp11WithCrawler): @pytest.mark.skip(reason="Proxy support is not implemented yet") -class TestHttp11Proxy(HttpxDownloadHandlerMixin, TestHttpProxyBase): +class TestHttpProxy(HttpxDownloadHandlerMixin, TestHttpProxyBase): pass @pytest.mark.skip(reason="Proxy support is not implemented yet") -class TestHttps11Proxy(HttpxDownloadHandlerMixin, TestHttpProxyBase): +class TestHttpsProxy(HttpxDownloadHandlerMixin, TestHttpProxyBase): is_secure = True diff --git a/tests/test_downloader_handler_twisted_http10.py b/tests/test_downloader_handler_twisted_http10.py deleted file mode 100644 index 8281337b0..000000000 --- a/tests/test_downloader_handler_twisted_http10.py +++ /dev/null @@ -1,55 +0,0 @@ -"""Tests for scrapy.core.downloader.handlers.http10.HTTP10DownloadHandler.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING - -import pytest - -from scrapy.core.downloader.handlers.http10 import HTTP10DownloadHandler -from scrapy.http import Request -from scrapy.utils.defer import deferred_f_from_coro_f -from tests.test_downloader_handlers_http_base import TestHttpBase, TestHttpProxyBase - -if TYPE_CHECKING: - from scrapy.core.downloader.handlers import DownloadHandlerProtocol - from tests.mockserver.http import MockServer - - -pytestmark = pytest.mark.requires_reactor # HTTP10DownloadHandler requires a reactor - - -class HTTP10DownloadHandlerMixin: - @property - def download_handler_cls(self) -> type[DownloadHandlerProtocol]: - return HTTP10DownloadHandler - - -@pytest.mark.filterwarnings("ignore::scrapy.exceptions.ScrapyDeprecationWarning") -class TestHttp10(HTTP10DownloadHandlerMixin, TestHttpBase): - """HTTP 1.0 test case""" - - def test_unsupported_scheme(self) -> None: # type: ignore[override] - pytest.skip("Check not implemented") - - @deferred_f_from_coro_f - async def test_protocol(self, mockserver: MockServer) -> None: - request = Request(mockserver.url("/host", is_secure=self.is_secure)) - async with self.get_dh() as download_handler: - response = await download_handler.download_request(request) - assert response.protocol == "HTTP/1.0" - - -class TestHttps10(TestHttp10): - is_secure = True - - -@pytest.mark.filterwarnings("ignore::scrapy.exceptions.ScrapyDeprecationWarning") -class TestHttp10Proxy(HTTP10DownloadHandlerMixin, TestHttpProxyBase): - @deferred_f_from_coro_f - async def test_download_with_proxy_https_timeout(self): - pytest.skip("Not implemented") - - @deferred_f_from_coro_f - async def test_download_with_proxy_without_http_scheme(self): - pytest.skip("Not implemented") diff --git a/tests/test_downloader_handler_twisted_http11.py b/tests/test_downloader_handler_twisted_http11.py index f6e86d3dc..01fb26d85 100644 --- a/tests/test_downloader_handler_twisted_http11.py +++ b/tests/test_downloader_handler_twisted_http11.py @@ -11,9 +11,9 @@ from scrapy.core.downloader.handlers.http11 import HTTP11DownloadHandler from scrapy.crawler import Crawler from scrapy.exceptions import NotConfigured from tests.test_downloader_handlers_http_base import ( - TestHttp11Base, + TestHttpBase, TestHttpProxyBase, - TestHttps11Base, + TestHttpsBase, TestHttpsCustomCiphersBase, TestHttpsInvalidDNSIdBase, TestHttpsInvalidDNSPatternBase, @@ -41,11 +41,11 @@ def test_not_configured_without_reactor() -> None: HTTP11DownloadHandler.from_crawler(crawler) -class TestHttp11(HTTP11DownloadHandlerMixin, TestHttp11Base): +class TestHttp(HTTP11DownloadHandlerMixin, TestHttpBase): pass -class TestHttps11(HTTP11DownloadHandlerMixin, TestHttps11Base): +class TestHttps(HTTP11DownloadHandlerMixin, TestHttpsBase): pass @@ -53,25 +53,25 @@ class TestSimpleHttps(HTTP11DownloadHandlerMixin, TestSimpleHttpsBase): pass -class TestHttps11WrongHostname(HTTP11DownloadHandlerMixin, TestHttpsWrongHostnameBase): +class TestHttpsWrongHostname(HTTP11DownloadHandlerMixin, TestHttpsWrongHostnameBase): pass -class TestHttps11InvalidDNSId(HTTP11DownloadHandlerMixin, TestHttpsInvalidDNSIdBase): +class TestHttpsInvalidDNSId(HTTP11DownloadHandlerMixin, TestHttpsInvalidDNSIdBase): pass -class TestHttps11InvalidDNSPattern( +class TestHttpsInvalidDNSPattern( HTTP11DownloadHandlerMixin, TestHttpsInvalidDNSPatternBase ): pass -class TestHttps11CustomCiphers(HTTP11DownloadHandlerMixin, TestHttpsCustomCiphersBase): +class TestHttpsCustomCiphers(HTTP11DownloadHandlerMixin, TestHttpsCustomCiphersBase): pass -class TestHttp11WithCrawler(TestHttpWithCrawlerBase): +class TestHttpWithCrawler(TestHttpWithCrawlerBase): @property def settings_dict(self) -> dict[str, Any] | None: return { @@ -82,9 +82,9 @@ class TestHttp11WithCrawler(TestHttpWithCrawlerBase): } -class TestHttps11WithCrawler(TestHttp11WithCrawler): +class TestHttpsWithCrawler(TestHttpWithCrawler): is_secure = True -class TestHttp11Proxy(HTTP11DownloadHandlerMixin, TestHttpProxyBase): +class TestHttpProxy(HTTP11DownloadHandlerMixin, TestHttpProxyBase): pass diff --git a/tests/test_downloader_handler_twisted_http2.py b/tests/test_downloader_handler_twisted_http2.py index 86fc071f8..3273a264e 100644 --- a/tests/test_downloader_handler_twisted_http2.py +++ b/tests/test_downloader_handler_twisted_http2.py @@ -19,7 +19,7 @@ from scrapy.http import Request from scrapy.utils.defer import maybe_deferred_to_future from tests.test_downloader_handlers_http_base import ( TestHttpProxyBase, - TestHttps11Base, + TestHttpsBase, TestHttpsCustomCiphersBase, TestHttpsInvalidDNSIdBase, TestHttpsInvalidDNSPatternBase, @@ -61,7 +61,7 @@ def test_not_configured_without_reactor() -> None: H2DownloadHandler.from_crawler(crawler) -class TestHttps2(H2DownloadHandlerMixin, TestHttps11Base): +class TestHttp2(H2DownloadHandlerMixin, TestHttpsBase): http2 = True handler_supports_http2_dataloss = False @@ -149,27 +149,25 @@ class TestHttps2(H2DownloadHandlerMixin, TestHttps11Base): await download_handler.download_request(request) -class TestHttps2WrongHostname(H2DownloadHandlerMixin, TestHttpsWrongHostnameBase): +class TestHttp2WrongHostname(H2DownloadHandlerMixin, TestHttpsWrongHostnameBase): pass -class TestHttps2InvalidDNSId(H2DownloadHandlerMixin, TestHttpsInvalidDNSIdBase): +class TestHttp2InvalidDNSId(H2DownloadHandlerMixin, TestHttpsInvalidDNSIdBase): pass -class TestHttps2InvalidDNSPattern( +class TestHttp2InvalidDNSPattern( H2DownloadHandlerMixin, TestHttpsInvalidDNSPatternBase ): pass -class TestHttps2CustomCiphers(H2DownloadHandlerMixin, TestHttpsCustomCiphersBase): +class TestHttp2CustomCiphers(H2DownloadHandlerMixin, TestHttpsCustomCiphersBase): pass class TestHttp2WithCrawler(TestHttpWithCrawlerBase): - """HTTP 2.0 test case with MockServer""" - @property def settings_dict(self) -> dict[str, Any] | None: return { @@ -194,7 +192,7 @@ class TestHttp2WithCrawler(TestHttpWithCrawlerBase): pytest.skip("headers_received support is not implemented") -class TestHttps2Proxy(H2DownloadHandlerMixin, TestHttpProxyBase): +class TestHttp2Proxy(H2DownloadHandlerMixin, TestHttpProxyBase): is_secure = True expected_http_proxy_request_body = b"/" diff --git a/tests/test_downloader_handlers_http_base.py b/tests/test_downloader_handlers_http_base.py index 66edf9325..2a781dace 100644 --- a/tests/test_downloader_handlers_http_base.py +++ b/tests/test_downloader_handlers_http_base.py @@ -55,9 +55,17 @@ if TYPE_CHECKING: class TestHttpBase(ABC): - is_secure = False + is_secure: bool = False + http2: bool = False # whether the handler supports per-request bindaddress - handler_supports_bindaddress_meta = True + handler_supports_bindaddress_meta: bool = True + # RFC 9113 §8.1.1 explicitly says that a Content-Length mismatch is a + # stream error (of type PROTOCOL_ERROR) so the client will send + # RST_STREAM. Some libraries do only this while e.g. h2 also closes the + # connection (see handling of ProtocolError in + # h2.connection.H2Connection.receive_data()), thus closing all streams that + # were using it, and we handle this as a normal exception. + handler_supports_http2_dataloss: bool = True # default headers added by the underlying library that cannot be suppressed always_present_req_headers: ClassVar[frozenset[str]] = frozenset() @@ -519,17 +527,6 @@ class TestHttpBase(ABC): else: assert latency > 0 - -class TestHttp11Base(TestHttpBase): - http2: bool = False - # RFC 9113 §8.1.1 explicitly says that a Content-Length mismatch is a - # stream error (of type PROTOCOL_ERROR) so the client will send - # RST_STREAM. Some libraries do only this while e.g. h2 also closes the - # connection (see handling of ProtocolError in - # h2.connection.H2Connection.receive_data()), thus closing all streams that - # were using it, and we handle this as a normal exception. - handler_supports_http2_dataloss: bool = True - @coroutine_test async def test_download_without_maxsize_limit(self, mockserver: MockServer) -> None: request = Request(mockserver.url("/text", is_secure=self.is_secure)) @@ -803,7 +800,7 @@ class TestHttp11Base(TestHttpBase): ) -class TestHttps11Base(TestHttp11Base): +class TestHttpsBase(TestHttpBase): is_secure = True tls_log_message = ( diff --git a/tests/test_webclient.py b/tests/test_webclient.py deleted file mode 100644 index ac05d457d..000000000 --- a/tests/test_webclient.py +++ /dev/null @@ -1,404 +0,0 @@ -""" -Tests borrowed from the twisted.web.client tests. -""" - -from __future__ import annotations - -from urllib.parse import urlparse - -import OpenSSL.SSL -import pytest -from pytest_twisted import async_yield_fixture -from twisted.internet.defer import inlineCallbacks -from twisted.internet.testing import StringTransport -from twisted.protocols.policies import WrappingFactory -from twisted.web import resource, server, static, util -from twisted.web.client import _makeGetterFactory - -from scrapy.core.downloader import webclient as client -from scrapy.core.downloader.contextfactory import _ScrapyClientContextFactory -from scrapy.exceptions import DownloadTimeoutError -from scrapy.http import Headers, Request -from scrapy.utils.misc import build_from_crawler -from scrapy.utils.python import to_bytes, to_unicode -from scrapy.utils.test import get_crawler -from tests.mockserver.http_resources import ( - ForeverTakingResource, - HostHeaderResource, - PayloadResource, -) -from tests.mockserver.utils import ssl_context_factory -from tests.test_core_downloader import TestContextFactoryBase - -# these tests are related to the Twisted HTTP code -pytestmark = pytest.mark.requires_reactor - - -def getPage(url, contextFactory=None, response_transform=None, *args, **kwargs): - """Adapted version of twisted.web.client.getPage""" - - def _clientfactory(url, *args, **kwargs): - url = to_unicode(url) - timeout = kwargs.pop("timeout", 0) - f = client.ScrapyHTTPClientFactory( - Request(url, *args, **kwargs), timeout=timeout - ) - f.deferred.addCallback(response_transform or (lambda r: r.body)) - return f - - return _makeGetterFactory( - to_bytes(url), - _clientfactory, - *args, - contextFactory=contextFactory, - **kwargs, - ).deferred - - -@pytest.mark.filterwarnings("ignore::scrapy.exceptions.ScrapyDeprecationWarning") -class TestScrapyHTTPPageGetter: - def test_earlyHeaders(self): - # basic test stolen from twisted HTTPageGetter - factory = client.ScrapyHTTPClientFactory( - Request( - url="http://foo/bar", - body="some data", - headers={ - "Host": "example.net", - "User-Agent": "fooble", - "Cookie": "blah blah", - "Content-Length": "12981", - "Useful": "value", - }, - ) - ) - - self._test( - factory, - b"GET /bar HTTP/1.0\r\n" - b"Content-Length: 9\r\n" - b"Useful: value\r\n" - b"Connection: close\r\n" - b"User-Agent: fooble\r\n" - b"Host: example.net\r\n" - b"Cookie: blah blah\r\n" - b"\r\n" - b"some data", - ) - - # test minimal sent headers - factory = client.ScrapyHTTPClientFactory(Request("http://foo/bar")) - self._test(factory, b"GET /bar HTTP/1.0\r\nHost: foo\r\n\r\n") - - # test a simple POST with body and content-type - factory = client.ScrapyHTTPClientFactory( - Request( - method="POST", - url="http://foo/bar", - body="name=value", - headers={"Content-Type": "application/x-www-form-urlencoded"}, - ) - ) - - self._test( - factory, - b"POST /bar HTTP/1.0\r\n" - b"Host: foo\r\n" - b"Connection: close\r\n" - b"Content-Type: application/x-www-form-urlencoded\r\n" - b"Content-Length: 10\r\n" - b"\r\n" - b"name=value", - ) - - # test a POST method with no body provided - factory = client.ScrapyHTTPClientFactory( - Request(method="POST", url="http://foo/bar") - ) - - self._test( - factory, - b"POST /bar HTTP/1.0\r\nHost: foo\r\nContent-Length: 0\r\n\r\n", - ) - - # test with single and multivalued headers - factory = client.ScrapyHTTPClientFactory( - Request( - url="http://foo/bar", - headers={ - "X-Meta-Single": "single", - "X-Meta-Multivalued": ["value1", "value2"], - }, - ) - ) - - self._test( - factory, - b"GET /bar HTTP/1.0\r\n" - b"Host: foo\r\n" - b"X-Meta-Multivalued: value1\r\n" - b"X-Meta-Multivalued: value2\r\n" - b"X-Meta-Single: single\r\n" - b"\r\n", - ) - - # same test with single and multivalued headers but using Headers class - factory = client.ScrapyHTTPClientFactory( - Request( - url="http://foo/bar", - headers=Headers( - { - "X-Meta-Single": "single", - "X-Meta-Multivalued": ["value1", "value2"], - } - ), - ) - ) - - self._test( - factory, - b"GET /bar HTTP/1.0\r\n" - b"Host: foo\r\n" - b"X-Meta-Multivalued: value1\r\n" - b"X-Meta-Multivalued: value2\r\n" - b"X-Meta-Single: single\r\n" - b"\r\n", - ) - - def _test(self, factory, testvalue): - transport = StringTransport() - protocol = client.ScrapyHTTPPageGetter() - protocol.factory = factory - protocol.makeConnection(transport) - assert set(transport.value().splitlines()) == set(testvalue.splitlines()) - return testvalue - - def test_non_standard_line_endings(self): - # regression test for: http://dev.scrapy.org/ticket/258 - factory = client.ScrapyHTTPClientFactory(Request(url="http://foo/bar")) - protocol = client.ScrapyHTTPPageGetter() - protocol.factory = factory - protocol.headers = Headers() - protocol.dataReceived(b"HTTP/1.0 200 OK\n") - protocol.dataReceived(b"Hello: World\n") - protocol.dataReceived(b"Foo: Bar\n") - protocol.dataReceived(b"\n") - assert protocol.headers == Headers({"Hello": ["World"], "Foo": ["Bar"]}) - - -class EncodingResource(resource.Resource): - out_encoding = "cp1251" - - def render(self, request): - body = to_unicode(request.content.read()) - request.setHeader(b"content-encoding", self.out_encoding) - return body.encode(self.out_encoding) - - -class BrokenDownloadResource(resource.Resource): - def render(self, request): - # only sends 3 bytes even though it claims to send 5 - request.setHeader(b"content-length", b"5") - request.write(b"abc") - return b"" - - -class ErrorResource(resource.Resource): - def render(self, request): - request.setResponseCode(401) - if request.args.get(b"showlength"): - request.setHeader(b"content-length", b"0") - return b"" - - -class NoLengthResource(resource.Resource): - def render(self, request): - return b"nolength" - - -@pytest.mark.filterwarnings("ignore::scrapy.exceptions.ScrapyDeprecationWarning") -class TestWebClient: - def _listen(self, site): - from twisted.internet import reactor - - return reactor.listenTCP(0, site, interface="127.0.0.1") - - @pytest.fixture - def wrapper(self, tmp_path): - (tmp_path / "file").write_bytes(b"0123456789") - r = static.File(str(tmp_path)) - r.putChild(b"redirect", util.Redirect(b"/file")) - r.putChild(b"wait", ForeverTakingResource()) - r.putChild(b"error", ErrorResource()) - r.putChild(b"nolength", NoLengthResource()) - r.putChild(b"host", HostHeaderResource()) - r.putChild(b"payload", PayloadResource()) - r.putChild(b"broken", BrokenDownloadResource()) - r.putChild(b"encoding", EncodingResource()) - site = server.Site(r, timeout=None) - return WrappingFactory(site) - - @async_yield_fixture - async def server_port(self, wrapper): - port = self._listen(wrapper) - - yield port.getHost().port - - await port.stopListening() - - @pytest.fixture - def server_url(self, server_port): - return f"http://127.0.0.1:{server_port}/" - - @inlineCallbacks - def testPayload(self, server_url): - s = "0123456789" * 10 - body = yield getPage(server_url + "payload", body=s) - assert body == to_bytes(s) - - @inlineCallbacks - def testHostHeader(self, server_port, server_url): - # if we pass Host header explicitly, it should be used, otherwise - # it should extract from url - body = yield getPage(server_url + "host") - assert body == to_bytes(f"127.0.0.1:{server_port}") - body = yield getPage(server_url + "host", headers={"Host": "www.example.com"}) - assert body == to_bytes("www.example.com") - - @inlineCallbacks - def test_getPage(self, server_url): - """ - L{client.getPage} returns a L{Deferred} which is called back with - the body of the response if the default method B{GET} is used. - """ - body = yield getPage(server_url + "file") - assert body == b"0123456789" - - @inlineCallbacks - def test_getPageHead(self, server_url): - """ - L{client.getPage} returns a L{Deferred} which is called back with - the empty string if the method is C{HEAD} and there is a successful - response code. - """ - - def _getPage(method): - return getPage(server_url + "file", method=method) - - body = yield _getPage("head") - assert body == b"" - body = yield _getPage("HEAD") - assert body == b"" - - @inlineCallbacks - def test_timeoutNotTriggering(self, server_port, server_url): - """ - When a non-zero timeout is passed to L{getPage} and the page is - retrieved before the timeout period elapses, the L{Deferred} is - called back with the contents of the page. - """ - body = yield getPage(server_url + "host", timeout=100) - assert body == to_bytes(f"127.0.0.1:{server_port}") - - @inlineCallbacks - def test_timeoutTriggering(self, wrapper, server_url): - """ - When a non-zero timeout is passed to L{getPage} and that many - seconds elapse before the server responds to the request. the - L{Deferred} is errbacked with a L{DownloadTimeoutError}. - """ - with pytest.raises(DownloadTimeoutError): - yield getPage(server_url + "wait", timeout=0.000001) - # Clean up the server which is hanging around not doing - # anything. - connected = list(wrapper.protocols.keys()) - # There might be nothing here if the server managed to already see - # that the connection was lost. - if connected: - connected[0].transport.loseConnection() - - @inlineCallbacks - def testNotFound(self, server_url): - body = yield getPage(server_url + "notsuchfile") - assert b"404 - No Such Resource" in body - - @inlineCallbacks - def testFactoryInfo(self, server_url): - from twisted.internet import reactor - - url = server_url + "file" - parsed = urlparse(url) - factory = client.ScrapyHTTPClientFactory(Request(url)) - reactor.connectTCP(parsed.hostname, parsed.port, factory) - yield factory.deferred - assert factory.status == b"200" - assert factory.version.startswith(b"HTTP/") - assert factory.message == b"OK" - assert factory.response_headers[b"content-length"] == b"10" - - @inlineCallbacks - def testRedirect(self, server_url): - body = yield getPage(server_url + "redirect") - assert ( - body - == b'\n\n \n \n' - b' \n \n ' - b'click here\n \n\n' - ) - - @inlineCallbacks - def test_encoding(self, server_url): - """Test that non-standart body encoding matches - Content-Encoding header""" - original_body = b"\xd0\x81\xd1\x8e\xd0\xaf" - response = yield getPage( - server_url + "encoding", body=original_body, response_transform=lambda r: r - ) - content_encoding = to_unicode(response.headers[b"Content-Encoding"]) - assert content_encoding == EncodingResource.out_encoding - assert response.body.decode(content_encoding) == to_unicode(original_body) - - -@pytest.mark.filterwarnings("ignore::scrapy.exceptions.ScrapyDeprecationWarning") -class TestWebClientSSL(TestContextFactoryBase): - @inlineCallbacks - def testPayload(self, server_url): - s = "0123456789" * 10 - body = yield getPage(server_url + "payload", body=s) - assert body == to_bytes(s) - - -class TestWebClientCustomCiphersSSL(TestWebClientSSL): - # we try to use a cipher that is not enabled by default in OpenSSL - custom_ciphers = "CAMELLIA256-SHA" - context_factory = ssl_context_factory(cipher_string=custom_ciphers) - - @inlineCallbacks - def testPayload(self, server_url): - s = "0123456789" * 10 - crawler = get_crawler( - settings_dict={"DOWNLOADER_CLIENT_TLS_CIPHERS": self.custom_ciphers} - ) - client_context_factory = build_from_crawler( - _ScrapyClientContextFactory, crawler - ) - body = yield getPage( - server_url + "payload", body=s, contextFactory=client_context_factory - ) - assert body == to_bytes(s) - - @inlineCallbacks - def testPayloadDisabledCipher(self, server_url): - s = "0123456789" * 10 - crawler = get_crawler( - settings_dict={ - "DOWNLOADER_CLIENT_TLS_CIPHERS": "ECDHE-RSA-AES256-GCM-SHA384" - } - ) - client_context_factory = build_from_crawler( - _ScrapyClientContextFactory, crawler - ) - with pytest.raises(OpenSSL.SSL.Error): - yield getPage( - server_url + "payload", body=s, contextFactory=client_context_factory - ) From d05b241f648f54a7b881a669ad8a60808d614d43 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Mon, 4 May 2026 11:59:27 +0500 Subject: [PATCH 140/248] Fix a code block in leaks.rst. (#7489) --- docs/topics/leaks.rst | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/docs/topics/leaks.rst b/docs/topics/leaks.rst index e61f33aed..ff05cd284 100644 --- a/docs/topics/leaks.rst +++ b/docs/topics/leaks.rst @@ -68,19 +68,21 @@ Response, Item, Spider and Selector objects. You can enter the telnet console and inspect how many objects (of the classes mentioned above) are currently alive using the ``prefs()`` function which is an -alias to the :func:`~scrapy.utils.trackref.print_live_refs` function:: +alias to the :func:`~scrapy.utils.trackref.print_live_refs` function: + +.. code-block:: bash telnet localhost 6023 - .. code-block:: pycon +.. code-block:: pycon - >>> prefs() - Live References + >>> prefs() + Live References - ExampleSpider 1 oldest: 15s ago - HtmlResponse 10 oldest: 1s ago - Selector 2 oldest: 0s ago - FormRequest 878 oldest: 7s ago + ExampleSpider 1 oldest: 15s ago + HtmlResponse 10 oldest: 1s ago + Selector 2 oldest: 0s ago + FormRequest 878 oldest: 7s ago As you can see, that report also shows the "age" of the oldest object in each class. If you're running multiple spiders per process chances are you can From f3868e11fb0ef3b0aeae3ebf64a78b1deb733fed Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Mon, 4 May 2026 17:34:24 +0500 Subject: [PATCH 141/248] Drop start_requests() support. (#7490) --- extras/qpsclient.py | 4 - scrapy/core/spidermw.py | 151 +----------- scrapy/spidermiddlewares/base.py | 7 - scrapy/spiders/__init__.py | 22 +- scrapy/spiders/sitemap.py | 4 - tests/test_spider_start.py | 99 -------- tests/test_spidermiddleware_base.py | 22 +- tests/test_spidermiddleware_process_start.py | 236 ------------------- tests/test_spidermiddleware_start.py | 17 -- 9 files changed, 18 insertions(+), 544 deletions(-) diff --git a/extras/qpsclient.py b/extras/qpsclient.py index 269b27336..8e5001c1d 100644 --- a/extras/qpsclient.py +++ b/extras/qpsclient.py @@ -35,10 +35,6 @@ class QPSSpider(Spider): self.download_delay = float(self.download_delay) async def start(self): - for item_or_request in self.start_requests(): - yield item_or_request - - def start_requests(self): url = self.benchurl if self.latency is not None: url += f"?latency={self.latency}" diff --git a/scrapy/core/spidermw.py b/scrapy/core/spidermw.py index 67342099d..790317357 100644 --- a/scrapy/core/spidermw.py +++ b/scrapy/core/spidermw.py @@ -33,7 +33,6 @@ from scrapy.utils.python import MutableAsyncChain, MutableChain, global_object_n if TYPE_CHECKING: from collections.abc import Generator - from scrapy.crawler import Crawler from scrapy.settings import BaseSettings @@ -60,76 +59,12 @@ class SpiderMiddlewareManager(MiddlewareManager): settings.get_component_priority_dict_with_base("SPIDER_MIDDLEWARES") ) - def __init__(self, *middlewares: Any, crawler: Crawler | None = None) -> None: - self._check_deprecated_process_start_requests_use(middlewares) - super().__init__(*middlewares, crawler=crawler) - - def _check_deprecated_process_start_requests_use( - self, middlewares: tuple[Any, ...] - ) -> None: - deprecated_middlewares = [ - middleware - for middleware in middlewares - if hasattr(middleware, "process_start_requests") - and not hasattr(middleware, "process_start") - ] - modern_middlewares = [ - middleware - for middleware in middlewares - if not hasattr(middleware, "process_start_requests") - and hasattr(middleware, "process_start") - ] - if deprecated_middlewares and modern_middlewares: - raise ValueError( - "You are trying to combine spider middlewares that only " - "define the deprecated process_start_requests() method () " - "with spider middlewares that only define the " - "process_start() method (). This is not possible. You must " - "either disable or make universal 1 of those 2 sets of " - "spider middlewares. Making a spider middleware universal " - "means having it define both methods. See the release notes " - "of Scrapy 2.13 for details: " - "https://docs.scrapy.org/en/2.13/news.html" - ) - - self._use_start_requests = bool(deprecated_middlewares) - if self._use_start_requests: - deprecated_middleware_list = ", ".join( - global_object_name(middleware.__class__) - for middleware in deprecated_middlewares - ) - warn( - f"The following enabled spider middlewares, directly or " - f"through their parent classes, define the deprecated " - f"process_start_requests() method: " - f"{deprecated_middleware_list}. process_start_requests() has " - f"been deprecated in favor of a new method, process_start(), " - f"to support asynchronous code execution. " - f"process_start_requests() will stop being called in a future " - f"version of Scrapy. If you use Scrapy 2.13 or higher " - f"only, replace process_start_requests() with " - f"process_start(); note that process_start() is a coroutine " - f"(async def). If you need to maintain compatibility with " - f"lower Scrapy versions, when defining " - f"process_start_requests() in a spider middleware class, " - f"define process_start() as well. See the release notes of " - f"Scrapy 2.13 for details: " - f"https://docs.scrapy.org/en/2.13/news.html", - ScrapyDeprecationWarning, - stacklevel=2, - ) - def _add_middleware(self, mw: Any) -> None: if hasattr(mw, "process_spider_input"): self.methods["process_spider_input"].append(mw.process_spider_input) self._check_mw_method_spider_arg(mw.process_spider_input) - if self._use_start_requests: - if hasattr(mw, "process_start_requests"): - self.methods["process_start_requests"].appendleft( - mw.process_start_requests - ) - elif hasattr(mw, "process_start"): + if hasattr(mw, "process_start"): self.methods["process_start"].appendleft(mw.process_start) process_spider_output = self._get_async_method_pair(mw, "process_spider_output") @@ -451,88 +386,8 @@ class SpiderMiddlewareManager(MiddlewareManager): ) warn(msg, category=ScrapyDeprecationWarning, stacklevel=2) self._set_compat_spider(spider) - self._check_deprecated_start_requests_use() - if self._use_start_requests: - sync_start = iter(self._spider.start_requests()) - sync_start = await self._process_chain( - "process_start_requests", sync_start, always_add_spider=True - ) - start: AsyncIterator[Any] = as_async_generator(sync_start) - else: - start = self._spider.start() - start = await self._process_chain("process_start", start) - return start - - def _check_deprecated_start_requests_use(self) -> None: - start_requests_cls = None - start_cls = None - spidercls = self._spider.__class__ - mro = spidercls.__mro__ - - for cls in mro: - cls_dict = cls.__dict__ - if start_requests_cls is None and "start_requests" in cls_dict: - start_requests_cls = cls - if start_cls is None and "start" in cls_dict: - start_cls = cls - if start_requests_cls is not None and start_cls is not None: - break - - # Spider defines both, start_requests and start. - assert start_requests_cls is not None - assert start_cls is not None - - if ( - start_requests_cls is not Spider - and start_cls is not start_requests_cls - and mro.index(start_requests_cls) < mro.index(start_cls) - ): - src = global_object_name(start_requests_cls) - if start_requests_cls is not spidercls: - src += f" (inherited by {global_object_name(spidercls)})" - warn( - f"{src} defines the deprecated start_requests() method. " - f"start_requests() has been deprecated in favor of a new " - f"method, start(), to support asynchronous code " - f"execution. start_requests() will stop being called in a " - f"future version of Scrapy. If you use Scrapy 2.13 or " - f"higher only, replace start_requests() with start(); " - f"note that start() is a coroutine (async def). If you " - f"need to maintain compatibility with lower Scrapy versions, " - f"when overriding start_requests() in a spider class, " - f"override start() as well; you can use super() to " - f"reuse the inherited start() implementation without " - f"copy-pasting. See the release notes of Scrapy 2.13 for " - f"details: https://docs.scrapy.org/en/2.13/news.html", - ScrapyDeprecationWarning, - stacklevel=2, - ) - - if ( - self._use_start_requests - and start_cls is not Spider - and start_requests_cls is not start_cls - and mro.index(start_cls) < mro.index(start_requests_cls) - ): - src = global_object_name(start_cls) - if start_cls is not spidercls: - src += f" (inherited by {global_object_name(spidercls)})" - raise ValueError( - f"{src} does not define the deprecated start_requests() " - f"method. However, one or more of your enabled spider " - f"middlewares (reported in an earlier deprecation warning) " - f"define the process_start_requests() method, and not the " - f"process_start() method, making them only compatible with " - f"(deprecated) spiders that define the start_requests() " - f"method. To solve this issue, disable the offending spider " - f"middlewares, upgrade them as described in that earlier " - f"deprecation warning, or make your spider compatible with " - f"deprecated spider middlewares (and earlier Scrapy versions) " - f"by defining a sync start_requests() method that works " - f"similarly to its existing start() method. See the " - f"release notes of Scrapy 2.13 for details: " - f"https://docs.scrapy.org/en/2.13/news.html" - ) + start = self._spider.start() + return await self._process_chain("process_start", start) # This method is only needed until _async compatibility methods are removed. @staticmethod diff --git a/scrapy/spidermiddlewares/base.py b/scrapy/spidermiddlewares/base.py index 889fc6df1..e09f2d10e 100644 --- a/scrapy/spidermiddlewares/base.py +++ b/scrapy/spidermiddlewares/base.py @@ -41,13 +41,6 @@ class BaseSpiderMiddleware: def from_crawler(cls, crawler: Crawler) -> Self: return cls(crawler) - def process_start_requests( - self, start: Iterable[Any], spider: Spider - ) -> Iterable[Any]: - for o in start: - if (o := self._get_processed(o, None)) is not None: - yield o - async def process_start(self, start: AsyncIterator[Any]) -> AsyncIterator[Any]: async for o in start: if (o := self._get_processed(o, None)) is not None: diff --git a/scrapy/spiders/__init__.py b/scrapy/spiders/__init__.py index 018e510c5..299a5d43f 100644 --- a/scrapy/spiders/__init__.py +++ b/scrapy/spiders/__init__.py @@ -7,17 +7,15 @@ See documentation in docs/topics/spiders.rst from __future__ import annotations import logging -import warnings from typing import TYPE_CHECKING, Any, cast from scrapy import signals -from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.http import Request, Response from scrapy.utils.trackref import object_ref from scrapy.utils.url import url_is_from_spider if TYPE_CHECKING: - from collections.abc import AsyncIterator, Iterable + from collections.abc import AsyncIterator from twisted.internet.defer import Deferred @@ -127,24 +125,6 @@ class Spider(object_ref): .. seealso:: :ref:`start-requests` """ - with warnings.catch_warnings(): - warnings.filterwarnings( - "ignore", category=ScrapyDeprecationWarning, module=r"^scrapy\.spiders$" - ) - for item_or_request in self.start_requests(): - yield item_or_request - - def start_requests(self) -> Iterable[Any]: - warnings.warn( - ( - "The Spider.start_requests() method is deprecated, use " - "Spider.start() instead. If you are calling " - "super().start_requests() from a Spider.start() override, " - "iterate super().start() instead." - ), - ScrapyDeprecationWarning, - stacklevel=2, - ) if not self.start_urls and hasattr(self, "start_url"): raise AttributeError( "Crawling could not start: 'start_urls' not found " diff --git a/scrapy/spiders/sitemap.py b/scrapy/spiders/sitemap.py index d87ffccaf..2a80b8d24 100644 --- a/scrapy/spiders/sitemap.py +++ b/scrapy/spiders/sitemap.py @@ -54,10 +54,6 @@ class SitemapSpider(Spider): self._follow: list[re.Pattern[str]] = [regex(x) for x in self.sitemap_follow] async def start(self) -> AsyncIterator[Any]: - for item_or_request in self.start_requests(): - yield item_or_request - - def start_requests(self) -> Iterable[Request]: for url in self.sitemap_urls: yield Request(url, self._parse_sitemap) diff --git a/tests/test_spider_start.py b/tests/test_spider_start.py index 4e359fd33..aef2093ac 100644 --- a/tests/test_spider_start.py +++ b/tests/test_spider_start.py @@ -1,15 +1,12 @@ from __future__ import annotations -import re import warnings from asyncio import sleep from typing import Any import pytest -from testfixtures import LogCapture from scrapy import Spider, signals -from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.utils.defer import maybe_deferred_to_future from scrapy.utils.test import get_crawler @@ -77,81 +74,6 @@ class TestMain: warnings.simplefilter("error") await self._test_spider(TestSpider, [ITEM_A]) - @coroutine_test - async def test_deprecated(self): - class TestSpider(Spider): - name = "test" - - def start_requests(self): - yield ITEM_A - - with pytest.warns(ScrapyDeprecationWarning): - await self._test_spider(TestSpider, [ITEM_A]) - - @coroutine_test - async def test_deprecated_subclass(self): - class BaseSpider(Spider): - def start_requests(self): - yield ITEM_A - - class TestSpider(BaseSpider): - name = "test" - - # The warning must be about the base class and not the subclass. - with pytest.warns(ScrapyDeprecationWarning, match="BaseSpider"): - await self._test_spider(TestSpider, [ITEM_A]) - - @coroutine_test - async def test_universal(self): - class TestSpider(Spider): - name = "test" - - async def start(self): - yield ITEM_A - - def start_requests(self): - yield ITEM_B - - with warnings.catch_warnings(): - warnings.simplefilter("error") - await self._test_spider(TestSpider, [ITEM_A]) - - @coroutine_test - async def test_universal_subclass(self): - class BaseSpider(Spider): - async def start(self): - yield ITEM_A - - def start_requests(self): - yield ITEM_B - - class TestSpider(BaseSpider): - name = "test" - - with warnings.catch_warnings(): - warnings.simplefilter("error") - await self._test_spider(TestSpider, [ITEM_A]) - - @coroutine_test - async def test_start_deprecated_super(self): - class TestSpider(Spider): - name = "test" - - async def start(self): - for item_or_request in super().start_requests(): - yield item_or_request - - msg = "use Spider.start() instead" - with pytest.warns(ScrapyDeprecationWarning, match=re.escape(msg)) as ws: - await self._test_spider(TestSpider, []) - - for w in ws: - if isinstance(w.message, ScrapyDeprecationWarning) and msg in str( - w.message - ): - assert w.filename.endswith("test_spider_start.py") - break - async def _test_start(self, start_, expected_items=None): class TestSpider(Spider): name = "test" @@ -176,24 +98,3 @@ class TestMain: yield ITEM_A await self._test_start(start, [ITEM_A]) - - # Exceptions - - @coroutine_test - async def test_deprecated_non_generator_exception(self): - class TestSpider(Spider): - name = "test" - - def start_requests(self): - raise RuntimeError - - with ( - LogCapture() as log, - pytest.warns( - ScrapyDeprecationWarning, - match=r"defines the deprecated start_requests\(\) method", - ), - ): - await self._test_spider(TestSpider, []) - - assert "in start_requests\n raise RuntimeError" in str(log) diff --git a/tests/test_spidermiddleware_base.py b/tests/test_spidermiddleware_base.py index da570904d..70326f3f1 100644 --- a/tests/test_spidermiddleware_base.py +++ b/tests/test_spidermiddleware_base.py @@ -7,7 +7,9 @@ import pytest from scrapy import Request, Spider from scrapy.http import Response from scrapy.spidermiddlewares.base import BaseSpiderMiddleware +from scrapy.utils.asyncgen import as_async_generator, collect_asyncgen from scrapy.utils.test import get_crawler +from tests.utils.decorators import coroutine_test if TYPE_CHECKING: from scrapy.crawler import Crawler @@ -18,7 +20,8 @@ def crawler() -> Crawler: return get_crawler(Spider) -def test_trivial(crawler: Crawler) -> None: +@coroutine_test +async def test_trivial(crawler: Crawler) -> None: class TrivialSpiderMiddleware(BaseSpiderMiddleware): pass @@ -29,12 +32,13 @@ def test_trivial(crawler: Crawler) -> None: spider_output = [test_req, {"foo": "bar"}] for processed in [ list(mw.process_spider_output(Response("data:,"), spider_output)), - list(mw.process_start_requests(spider_output, None)), # type: ignore[arg-type] + await collect_asyncgen(mw.process_start(as_async_generator(spider_output))), ]: assert processed == [test_req, {"foo": "bar"}] -def test_processed_request(crawler: Crawler) -> None: +@coroutine_test +async def test_processed_request(crawler: Crawler) -> None: class ProcessReqSpiderMiddleware(BaseSpiderMiddleware): def get_processed_request( self, request: Request, response: Response | None @@ -52,7 +56,7 @@ def test_processed_request(crawler: Crawler) -> None: spider_output = [test_req1, {"foo": "bar"}, test_req2, test_req3] for processed in [ list(mw.process_spider_output(Response("data:,"), spider_output)), - list(mw.process_start_requests(spider_output, None)), # type: ignore[arg-type] + await collect_asyncgen(mw.process_start(as_async_generator(spider_output))), ]: assert len(processed) == 3 assert isinstance(processed[0], Request) @@ -62,7 +66,8 @@ def test_processed_request(crawler: Crawler) -> None: assert processed[2].url == "data:30," -def test_processed_item(crawler: Crawler) -> None: +@coroutine_test +async def test_processed_item(crawler: Crawler) -> None: class ProcessItemSpiderMiddleware(BaseSpiderMiddleware): def get_processed_item(self, item: Any, response: Response | None) -> Any: if item["foo"] == 2: @@ -76,12 +81,13 @@ def test_processed_item(crawler: Crawler) -> None: spider_output = [{"foo": 1}, {"foo": 2}, test_req, {"foo": 3}] for processed in [ list(mw.process_spider_output(Response("data:,"), spider_output)), - list(mw.process_start_requests(spider_output, None)), # type: ignore[arg-type] + await collect_asyncgen(mw.process_start(as_async_generator(spider_output))), ]: assert processed == [{"foo": 1}, test_req, {"foo": 30}] -def test_processed_both(crawler: Crawler) -> None: +@coroutine_test +async def test_processed_both(crawler: Crawler) -> None: class ProcessBothSpiderMiddleware(BaseSpiderMiddleware): def get_processed_request( self, request: Request, response: Response | None @@ -113,7 +119,7 @@ def test_processed_both(crawler: Crawler) -> None: ] for processed in [ list(mw.process_spider_output(Response("data:,"), spider_output)), - list(mw.process_start_requests(spider_output, None)), # type: ignore[arg-type] + await collect_asyncgen(mw.process_start(as_async_generator(spider_output))), ]: assert len(processed) == 4 assert isinstance(processed[0], Request) diff --git a/tests/test_spidermiddleware_process_start.py b/tests/test_spidermiddleware_process_start.py index ddece52c4..c907c6d73 100644 --- a/tests/test_spidermiddleware_process_start.py +++ b/tests/test_spidermiddleware_process_start.py @@ -4,7 +4,6 @@ from asyncio import sleep import pytest from scrapy import Spider, signals -from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.utils.defer import maybe_deferred_to_future from scrapy.utils.test import get_crawler from tests.test_spider_start import SLEEP_SECONDS @@ -38,15 +37,6 @@ class TwistedSleepSpiderMiddleware: yield item_or_request -class UniversalSpiderMiddleware: - async def process_start(self, start): - async for item_or_request in start: - yield item_or_request - - def process_start_requests(self, start_requests, spider): - raise NotImplementedError - - # Spiders and spider middlewares for TestMain._test_wrap @@ -61,23 +51,6 @@ class ModernWrapSpiderSubclass(ModernWrapSpider): name = "test" -class UniversalWrapSpider(Spider): - name = "test" - - async def start(self): - yield ITEM_B - - def start_requests(self): - yield ITEM_D - - -class DeprecatedWrapSpider(Spider): - name = "test" - - def start_requests(self): - yield ITEM_B - - class ModernWrapSpiderMiddleware: async def process_start(self, start): yield ITEM_A @@ -86,26 +59,6 @@ class ModernWrapSpiderMiddleware: yield ITEM_C -class UniversalWrapSpiderMiddleware: - async def process_start(self, start): - yield ITEM_A - async for item_or_request in start: - yield item_or_request - yield ITEM_C - - def process_start_requests(self, start, spider): - yield ITEM_A - yield from start - yield ITEM_C - - -class DeprecatedWrapSpiderMiddleware: - def process_start_requests(self, start, spider): - yield ITEM_A - yield from start - yield ITEM_C - - class TestMain: async def _test(self, spider_middlewares, spider_cls, expected_items): actual_items = [] @@ -136,195 +89,6 @@ class TestMain: warnings.simplefilter("error") await self._test_wrap(ModernWrapSpiderMiddleware, ModernWrapSpider) - @coroutine_test - async def test_modern_mw_universal_spider(self): - with warnings.catch_warnings(): - warnings.simplefilter("error") - await self._test_wrap(ModernWrapSpiderMiddleware, UniversalWrapSpider) - - @coroutine_test - async def test_modern_mw_deprecated_spider(self): - with pytest.warns( - ScrapyDeprecationWarning, match=r"deprecated start_requests\(\)" - ): - await self._test_wrap(ModernWrapSpiderMiddleware, DeprecatedWrapSpider) - - @coroutine_test - async def test_universal_mw_modern_spider(self): - with warnings.catch_warnings(): - warnings.simplefilter("error") - await self._test_wrap(UniversalWrapSpiderMiddleware, ModernWrapSpider) - - @coroutine_test - async def test_universal_mw_universal_spider(self): - with warnings.catch_warnings(): - warnings.simplefilter("error") - await self._test_wrap(UniversalWrapSpiderMiddleware, UniversalWrapSpider) - - @coroutine_test - async def test_universal_mw_deprecated_spider(self): - with pytest.warns( - ScrapyDeprecationWarning, match=r"deprecated start_requests\(\)" - ): - await self._test_wrap(UniversalWrapSpiderMiddleware, DeprecatedWrapSpider) - - @coroutine_test - async def test_deprecated_mw_modern_spider(self): - with ( - pytest.warns( - ScrapyDeprecationWarning, match=r"deprecated process_start_requests\(\)" - ), - pytest.raises( - ValueError, match=r"only compatible with \(deprecated\) spiders" - ), - ): - await self._test_wrap(DeprecatedWrapSpiderMiddleware, ModernWrapSpider) - - @coroutine_test - async def test_deprecated_mw_modern_spider_subclass(self): - with ( - pytest.warns( - ScrapyDeprecationWarning, match=r"deprecated process_start_requests\(\)" - ), - pytest.raises( - ValueError, - match=r"^\S+?\.ModernWrapSpider \(inherited by \S+?.ModernWrapSpiderSubclass\) .*? only compatible with \(deprecated\) spiders", - ), - ): - await self._test_wrap( - DeprecatedWrapSpiderMiddleware, ModernWrapSpiderSubclass - ) - - @coroutine_test - async def test_deprecated_mw_universal_spider(self): - with pytest.warns( - ScrapyDeprecationWarning, match=r"deprecated process_start_requests\(\)" - ): - await self._test_wrap( - DeprecatedWrapSpiderMiddleware, - UniversalWrapSpider, - [ITEM_A, ITEM_D, ITEM_C], - ) - - @coroutine_test - async def test_deprecated_mw_deprecated_spider(self): - with ( - pytest.warns( - ScrapyDeprecationWarning, match=r"deprecated process_start_requests\(\)" - ), - pytest.warns( - ScrapyDeprecationWarning, match=r"deprecated start_requests\(\)" - ), - ): - await self._test_wrap(DeprecatedWrapSpiderMiddleware, DeprecatedWrapSpider) - - @coroutine_test - async def test_modern_mw_universal_mw_modern_spider(self): - with warnings.catch_warnings(): - warnings.simplefilter("error") - await self._test_douple_wrap( - ModernWrapSpiderMiddleware, - UniversalWrapSpiderMiddleware, - ModernWrapSpider, - ) - - @coroutine_test - async def test_modern_mw_deprecated_mw_modern_spider(self): - with pytest.raises(ValueError, match=r"trying to combine spider middlewares"): - await self._test_douple_wrap( - ModernWrapSpiderMiddleware, - DeprecatedWrapSpiderMiddleware, - ModernWrapSpider, - ) - - @coroutine_test - async def test_universal_mw_deprecated_mw_modern_spider(self): - with ( - pytest.warns( - ScrapyDeprecationWarning, match=r"deprecated process_start_requests\(\)" - ), - pytest.raises( - ValueError, match=r"only compatible with \(deprecated\) spiders" - ), - ): - await self._test_douple_wrap( - UniversalWrapSpiderMiddleware, - DeprecatedWrapSpiderMiddleware, - ModernWrapSpider, - ) - - @coroutine_test - async def test_modern_mw_universal_mw_universal_spider(self): - with warnings.catch_warnings(): - warnings.simplefilter("error") - await self._test_douple_wrap( - ModernWrapSpiderMiddleware, - UniversalWrapSpiderMiddleware, - UniversalWrapSpider, - ) - - @coroutine_test - async def test_modern_mw_deprecated_mw_universal_spider(self): - with pytest.raises(ValueError, match=r"trying to combine spider middlewares"): - await self._test_douple_wrap( - ModernWrapSpiderMiddleware, - DeprecatedWrapSpiderMiddleware, - UniversalWrapSpider, - ) - - @coroutine_test - async def test_universal_mw_deprecated_mw_universal_spider(self): - with pytest.warns( - ScrapyDeprecationWarning, match=r"deprecated process_start_requests\(\)" - ): - await self._test_douple_wrap( - UniversalWrapSpiderMiddleware, - DeprecatedWrapSpiderMiddleware, - UniversalWrapSpider, - [ITEM_A, ITEM_A, ITEM_D, ITEM_C, ITEM_C], - ) - - @coroutine_test - async def test_modern_mw_universal_mw_deprecated_spider(self): - with pytest.warns( - ScrapyDeprecationWarning, match=r"deprecated start_requests\(\)" - ): - await self._test_douple_wrap( - ModernWrapSpiderMiddleware, - UniversalWrapSpiderMiddleware, - DeprecatedWrapSpider, - ) - - @coroutine_test - async def test_modern_mw_deprecated_mw_deprecated_spider(self): - with pytest.raises(ValueError, match=r"trying to combine spider middlewares"): - await self._test_douple_wrap( - ModernWrapSpiderMiddleware, - DeprecatedWrapSpiderMiddleware, - DeprecatedWrapSpider, - ) - - @coroutine_test - async def test_universal_mw_deprecated_mw_deprecated_spider(self): - with ( - pytest.warns( - ScrapyDeprecationWarning, match=r"deprecated process_start_requests\(\)" - ), - pytest.warns( - ScrapyDeprecationWarning, match=r"deprecated start_requests\(\)" - ), - ): - await self._test_douple_wrap( - UniversalWrapSpiderMiddleware, - DeprecatedWrapSpiderMiddleware, - DeprecatedWrapSpider, - ) - - @coroutine_test - async def test_universal_mw_uses_process_start(self): - """Test that process_start_requests() isn't used when process_start() exists.""" - await self._test([UniversalSpiderMiddleware], ModernWrapSpider, [ITEM_B]) - async def _test_sleep(self, spider_middlewares): class TestSpider(Spider): name = "test" diff --git a/tests/test_spidermiddleware_start.py b/tests/test_spidermiddleware_start.py index 76976d962..26397d37c 100644 --- a/tests/test_spidermiddleware_start.py +++ b/tests/test_spidermiddleware_start.py @@ -23,20 +23,3 @@ class TestMiddleware: async for request in mw.process_start(start()) ] assert result == [True, True, False, "foo"] - - @coroutine_test - async def test_sync(self): - crawler = get_crawler(Spider) - mw = build_from_crawler(StartSpiderMiddleware, crawler) - - def start(): - yield Request("data:,1") - yield Request("data:,2", meta={"is_start_request": True}) - yield Request("data:,2", meta={"is_start_request": False}) - yield Request("data:,2", meta={"is_start_request": "foo"}) - - result = [ - request.meta["is_start_request"] - for request in mw.process_start_requests(start(), Spider("test")) - ] - assert result == [True, True, False, "foo"] From 8d69a7c8659ff207420a8c5d725fe0cafa2b866c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Mon, 4 May 2026 19:38:29 +0200 Subject: [PATCH 142/248] Deprecate FormRequest in favor of form2request (#6438) * WIP * Add docs * Remove FormRequest from tests * Silence mypy issue * Address docs-tests issues --------- Co-authored-by: Adrian Chaves Co-authored-by: Andrey Rakhmatullin --- docs/conf.py | 1 + docs/topics/dynamic-content.rst | 2 +- docs/topics/leaks.rst | 2 +- docs/topics/request-response.rst | 225 ++++++++++--------------------- scrapy/http/__init__.py | 17 ++- scrapy/http/request/form.py | 8 ++ tests/test_contracts.py | 32 +++-- tests/test_http_request_form.py | 2 +- tests/test_request_dict.py | 8 +- tox.ini | 1 + 10 files changed, 125 insertions(+), 173 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index e3e618860..99d5df7da 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -154,6 +154,7 @@ scrapy_intersphinx_enable = [ "coverage", "cryptography", "cssselect", + "form2request", "itemloaders", "parsel", "pytest", diff --git a/docs/topics/dynamic-content.rst b/docs/topics/dynamic-content.rst index 090caa6e0..5a399e094 100644 --- a/docs/topics/dynamic-content.rst +++ b/docs/topics/dynamic-content.rst @@ -83,7 +83,7 @@ request with Scrapy. It might be enough to yield a :class:`~scrapy.Request` with the same HTTP method and URL. However, you may also need to reproduce the body, headers and -form parameters (see :class:`~scrapy.FormRequest`) of that request. +form parameters (see :ref:`form`) of that request. As all major browsers allow to export the requests in curl_ format, Scrapy incorporates the method :meth:`~scrapy.Request.from_curl` to generate an equivalent diff --git a/docs/topics/leaks.rst b/docs/topics/leaks.rst index ff05cd284..0913a3310 100644 --- a/docs/topics/leaks.rst +++ b/docs/topics/leaks.rst @@ -82,7 +82,7 @@ alias to the :func:`~scrapy.utils.trackref.print_live_refs` function: ExampleSpider 1 oldest: 15s ago HtmlResponse 10 oldest: 1s ago Selector 2 oldest: 0s ago - FormRequest 878 oldest: 7s ago + Request 878 oldest: 7s ago As you can see, that report also shows the "age" of the oldest object in each class. If you're running multiple spiders per process chances are you can diff --git a/docs/topics/request-response.rst b/docs/topics/request-response.rst index f23c7611c..ba7b3ee3c 100644 --- a/docs/topics/request-response.rst +++ b/docs/topics/request-response.rst @@ -246,6 +246,78 @@ Request objects .. automethod:: to_dict +.. _form: + +Creating requests that submit HTML forms +---------------------------------------- + +Use :doc:`form2request ` to build request data from an HTML +```` element and convert it to a :class:`~scrapy.Request`. + +Install it with pip: + +.. code-block:: bash + + pip install form2request + +Select the desired form with CSS or XPath, then build and convert request +data: + +.. code-block:: python + + from form2request import form2request + + + def parse(self, response): + form = response.css("form#search") + request_data = form2request(form, data={"q": "scrapy"}) + yield request_data.to_scrapy(callback=self.parse_results) + +Use ``data`` to override field values. To drop a field from the resulting +request, set its value to ``None``. + +By default, form2request simulates clicking the first submit button. To submit +without clicking any button, pass ``click=False``. To click a specific submit +button, pass its element: + +.. code-block:: python + + def parse(self, response): + form = response.css("form#checkout") + submit = form.css('button[name="pay"]') + request_data = form2request(form, click=submit) + +.. _topics-request-response-ref-request-userlogin: + +Using form2request to simulate a user login +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +It is usual for web sites to provide pre-populated form fields through ```` elements, such as session related data or authentication +tokens (for login pages). Build the request from the form and only override the +credentials: + +.. code-block:: python + + import scrapy + from form2request import form2request + + + class LoginSpider(scrapy.Spider): + name = "example.com" + start_urls = ["http://www.example.com/users/login.php"] + + def parse(self, response): + form = response.css("form") + request_data = form2request( + form, + data={"username": "john", "password": "secret"}, + ) + yield request_data.to_scrapy(callback=self.after_login) + + def after_login(self, response): ... + + Other functions related to requests ----------------------------------- @@ -771,159 +843,6 @@ Request subclasses Here is the list of built-in :class:`~scrapy.Request` subclasses. You can also subclass it to implement your own custom functionality. -FormRequest objects -------------------- - -The FormRequest class extends the base :class:`~scrapy.Request` with functionality for -dealing with HTML forms. It uses `lxml.html forms`_ to pre-populate form -fields with form data from :class:`Response` objects. - -.. _lxml.html forms: https://lxml.de/lxmlhtml.html#forms - -.. currentmodule:: None - -.. class:: scrapy.FormRequest(url, [formdata, ...]) - :canonical: scrapy.http.request.form.FormRequest - - The :class:`~scrapy.FormRequest` class adds a new keyword parameter to the ``__init__()`` method. The - remaining arguments are the same as for the :class:`~scrapy.Request` class and are - not documented here. - - :param formdata: is a dictionary (or iterable of (key, value) tuples) - containing HTML Form data which will be url-encoded and assigned to the - body of the request. - :type formdata: dict or collections.abc.Iterable - - The :class:`~scrapy.FormRequest` objects support the following class method in - addition to the standard :class:`~scrapy.Request` methods: - - .. classmethod:: from_response(response, [formname=None, formid=None, formnumber=0, formdata=None, formxpath=None, formcss=None, clickdata=None, dont_click=False, ...]) - - Returns a new :class:`~scrapy.FormRequest` object with its form field values - pre-populated with those found in the HTML ```` element contained - in the given response. For an example see - :ref:`topics-request-response-ref-request-userlogin`. - - The policy is to automatically simulate a click, by default, on any form - control that looks clickable, like a ````. Even - though this is quite convenient, and often the desired behaviour, - sometimes it can cause problems which could be hard to debug. For - example, when working with forms that are filled and/or submitted using - javascript, the default :meth:`from_response` behaviour may not be the - most appropriate. To disable this behaviour you can set the - ``dont_click`` argument to ``True``. Also, if you want to change the - control clicked (instead of disabling it) you can also use the - ``clickdata`` argument. - - .. caution:: Using this method with select elements which have leading - or trailing whitespace in the option values will not work due to a - `bug in lxml`_, which should be fixed in lxml 3.8 and above. - - :param response: the response containing a HTML form which will be used - to pre-populate the form fields - :type response: :class:`~scrapy.http.Response` object - - :param formname: if given, the form with name attribute set to this value will be used. - :type formname: str - - :param formid: if given, the form with id attribute set to this value will be used. - :type formid: str - - :param formxpath: if given, the first form that matches the xpath will be used. - :type formxpath: str - - :param formcss: if given, the first form that matches the css selector will be used. - :type formcss: str - - :param formnumber: the number of form to use, when the response contains - multiple forms. The first one (and also the default) is ``0``. - :type formnumber: int - - :param formdata: fields to override in the form data. If a field was - already present in the response ```` element, its value is - overridden by the one passed in this parameter. If a value passed in - this parameter is ``None``, the field will not be included in the - request, even if it was present in the response ```` element. - :type formdata: dict - - :param clickdata: attributes to lookup the control clicked. If it's not - given, the form data will be submitted simulating a click on the - first clickable element. In addition to html attributes, the control - can be identified by its zero-based index relative to other - submittable inputs inside the form, via the ``nr`` attribute. - :type clickdata: dict - - :param dont_click: If True, the form data will be submitted without - clicking in any element. - :type dont_click: bool - - The other parameters of this class method are passed directly to the - :class:`~scrapy.FormRequest` ``__init__()`` method. - -.. currentmodule:: scrapy.http - -Request usage examples ----------------------- - -Using FormRequest to send data via HTTP POST -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -If you want to simulate a HTML Form POST in your spider and send a couple of -key-value fields, you can return a :class:`~scrapy.FormRequest` object (from your -spider) like this: - -.. skip: next -.. code-block:: python - - return [ - FormRequest( - url="http://www.example.com/post/action", - formdata={"name": "John Doe", "age": "27"}, - callback=self.after_post, - ) - ] - -.. _topics-request-response-ref-request-userlogin: - -Using FormRequest.from_response() to simulate a user login -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -It is usual for web sites to provide pre-populated form fields through ```` elements, such as session related data or authentication -tokens (for login pages). When scraping, you'll want these fields to be -automatically pre-populated and only override a couple of them, such as the -user name and password. You can use the :meth:`.FormRequest.from_response` -method for this job. Here's an example spider which uses it: - -.. code-block:: python - - import scrapy - - - def authentication_failed(response): - # TODO: Check the contents of the response and return True if it failed - # or False if it succeeded. - pass - - - class LoginSpider(scrapy.Spider): - name = "example.com" - start_urls = ["http://www.example.com/users/login.php"] - - def parse(self, response): - return scrapy.FormRequest.from_response( - response, - formdata={"username": "john", "password": "secret"}, - callback=self.after_login, - ) - - def after_login(self, response): - if authentication_failed(response): - self.logger.error("Login failed") - return - - # continue scraping with authenticated session... - JsonRequest ----------- diff --git a/scrapy/http/__init__.py b/scrapy/http/__init__.py index 0e5c2b53b..e20e894ae 100644 --- a/scrapy/http/__init__.py +++ b/scrapy/http/__init__.py @@ -5,9 +5,11 @@ Use this module (instead of the more specific ones) when importing Headers, Request and Response outside this module. """ +from warnings import catch_warnings, filterwarnings + +from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.http.headers import Headers from scrapy.http.request import Request -from scrapy.http.request.form import FormRequest from scrapy.http.request.json_request import JsonRequest from scrapy.http.request.rpc import XmlRpcRequest from scrapy.http.response import Response @@ -15,6 +17,19 @@ from scrapy.http.response.html import HtmlResponse from scrapy.http.response.json import JsonResponse from scrapy.http.response.text import TextResponse from scrapy.http.response.xml import XmlResponse +from scrapy.utils.deprecate import create_deprecated_class + +with catch_warnings(): + filterwarnings("ignore", category=ScrapyDeprecationWarning) + + from scrapy.http.request.form import FormRequest as _FormRequest + + FormRequest = create_deprecated_class( + name="FormRequest", + new_class=_FormRequest, + subclass_warn_message="{cls} inherits from deprecated class {old}, use the form2request library instead.", + instance_warn_message="{cls} is deprecated, use the form2request library instead.", + ) __all__ = [ "FormRequest", diff --git a/scrapy/http/request/form.py b/scrapy/http/request/form.py index affa14499..4da595b22 100644 --- a/scrapy/http/request/form.py +++ b/scrapy/http/request/form.py @@ -10,10 +10,12 @@ from __future__ import annotations from collections.abc import Iterable from typing import TYPE_CHECKING, Any, ClassVar, TypeAlias, cast from urllib.parse import urlencode, urljoin, urlsplit, urlunsplit +from warnings import warn from parsel.csstranslator import HTMLTranslator from w3lib.html import strip_html5_whitespace +from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.http.request import Request from scrapy.utils.python import is_listlike, to_bytes @@ -30,6 +32,12 @@ if TYPE_CHECKING: from scrapy.http.response.text import TextResponse +warn( + "The entire scrapy.http.request.form module is deprecated. Use the " + "form2request library instead.", + ScrapyDeprecationWarning, + stacklevel=2, +) FormdataVType: TypeAlias = str | Iterable[str] FormdataKVType: TypeAlias = tuple[str, FormdataVType] diff --git a/tests/test_contracts.py b/tests/test_contracts.py index a35ef7010..008e326ec 100644 --- a/tests/test_contracts.py +++ b/tests/test_contracts.py @@ -3,7 +3,6 @@ from unittest import TextTestResult import pytest from twisted.python import failure -from scrapy import FormRequest from scrapy.contracts import Contract, ContractsManager from scrapy.contracts.default import ( CallbackKeywordArgumentsContract, @@ -34,6 +33,12 @@ class ResponseMetaMock(ResponseMock): meta = None +class TaggedRequest(Request): + def __init__(self, url, contract_tag=None, **kwargs): + super().__init__(url, **kwargs) + self.contract_tag = contract_tag + + class CustomSuccessContract(Contract): name = "custom_success_contract" @@ -49,12 +54,13 @@ class CustomFailContract(Contract): raise TypeError("Error in adjust_request_args") -class CustomFormContract(Contract): - name = "custom_form" - request_cls = FormRequest +class CustomTaggedRequestContract(Contract): + name = "custom_tagged_request" + request_cls = TaggedRequest def adjust_request_args(self, args): - args["formdata"] = {"name": "scrapy"} + args["contract_tag"] = "custom" + args["method"] = "POST" return args @@ -179,10 +185,10 @@ class DemoSpider(Spider): @returns items 1 1 """ - def custom_form(self, response): + def custom_tagged_request(self, response): """ @url http://scrapy.org - @custom_form + @custom_tagged_request """ def invalid_regex(self, response): @@ -253,7 +259,7 @@ class TestContractsManager: MetadataContract, ReturnsContract, ScrapesContract, - CustomFormContract, + CustomTaggedRequestContract, CustomSuccessContract, CustomFailContract, ] @@ -533,17 +539,21 @@ class TestContractsManager: assert crawler.spider.visited == 2 - def test_form_contract(self): + def test_custom_tagged_request_contract(self): spider = DemoSpider() - request = self.conman.from_method(spider.custom_form, self.results) + request = self.conman.from_method(spider.custom_tagged_request, self.results) assert request.method == "POST" - assert isinstance(request, FormRequest) + assert isinstance(request, TaggedRequest) + assert request.contract_tag == "custom" def test_inherited_contracts(self): spider = InheritsDemoSpider() requests = self.conman.from_spider(spider, self.results) assert requests + assert any( + isinstance(request, TaggedRequest) for request in requests if request + ) class CustomFailContractPreProcess(Contract): diff --git a/tests/test_http_request_form.py b/tests/test_http_request_form.py index 92f2f3fac..8ba9f3422 100644 --- a/tests/test_http_request_form.py +++ b/tests/test_http_request_form.py @@ -27,7 +27,7 @@ def _qs(req, encoding="utf-8", to_unicode=False): class TestFormRequest(TestRequest): - request_class = FormRequest + request_class = FormRequest # type: ignore[assignment] def assertQueryEqual(self, first, second, msg=None): first = to_unicode(first).split("&") diff --git a/tests/test_request_dict.py b/tests/test_request_dict.py index ea7018541..78ff18b15 100644 --- a/tests/test_request_dict.py +++ b/tests/test_request_dict.py @@ -1,7 +1,7 @@ import pytest from scrapy import Request, Spider -from scrapy.http import FormRequest, JsonRequest +from scrapy.http import JsonRequest from scrapy.utils.request import request_from_dict @@ -67,12 +67,10 @@ class TestRequestSerialization: assert r1.dumps_kwargs == r2.dumps_kwargs def test_request_class(self): - r1 = FormRequest("http://www.example.com") + r1 = CustomRequest("http://www.example.com") self._assert_serializes_ok(r1, spider=self.spider) - r2 = CustomRequest("http://www.example.com") + r2 = JsonRequest("http://www.example.com", dumps_kwargs={"indent": 4}) self._assert_serializes_ok(r2, spider=self.spider) - r3 = JsonRequest("http://www.example.com", dumps_kwargs={"indent": 4}) - self._assert_serializes_ok(r3, spider=self.spider) def test_callback_serialization(self): r = Request( diff --git a/tox.ini b/tox.ini index 219a2b616..f1b004ab8 100644 --- a/tox.ini +++ b/tox.ini @@ -245,6 +245,7 @@ changedir = docs deps = {[test-requirements]deps} -rdocs/requirements.txt + form2request commands = pytest From 9776a72a6a7301b3c715363be6505c2f2a5ea23e Mon Sep 17 00:00:00 2001 From: "Albert Eduardovich N." Date: Mon, 4 May 2026 21:32:46 +0300 Subject: [PATCH 143/248] chore: simplify some code, get rid of nested fns where it's makes sence + pylint (#7401) * chore: simplify some code, get rid of nested fns where it's makes sence reasoning: https://wemake-python-styleguide.readthedocs.io/en/latest/pages/usage/violations/best_practices.html#wemake_python_styleguide.violations.best_practices.NestedFunctionViolation * chore: address some pylint rules * remove `pylint: disable=deprecated-class` from `reactor.py` * fix typo * fix another typo :) * revert backward incompatible arg renames --------- Co-authored-by: Andrey Rakhmatullin --- extras/qps-bench-server.py | 2 +- pyproject.toml | 13 +- scrapy/contracts/__init__.py | 2 +- scrapy/core/downloader/handlers/http11.py | 20 +-- scrapy/core/downloader/middleware.py | 151 +++++++++++----------- scrapy/core/engine.py | 40 +++--- scrapy/core/http2/stream.py | 18 +-- scrapy/core/spidermw.py | 98 +++++++------- scrapy/crawler.py | 46 ++++--- scrapy/extensions/feedexport.py | 16 ++- scrapy/pipelines/__init__.py | 69 +++++----- scrapy/pipelines/files.py | 134 ++++++++++--------- scrapy/settings/__init__.py | 8 +- scrapy/utils/benchserver.py | 2 +- scrapy/utils/defer.py | 6 +- scrapy/utils/misc.py | 13 +- scrapy/utils/python.py | 21 +-- tests/mockserver/http.py | 2 +- tests/mockserver/simple_https.py | 2 +- tox.ini | 3 +- 20 files changed, 350 insertions(+), 316 deletions(-) diff --git a/extras/qps-bench-server.py b/extras/qps-bench-server.py index 734614aa5..569438574 100755 --- a/extras/qps-bench-server.py +++ b/extras/qps-bench-server.py @@ -18,7 +18,7 @@ class Root(Resource): self.tail.clear() self.start = self.lastmark = self.lasttime = time() - def getChild(self, request, name): + def getChild(self, path, request): return self def render(self, request): diff --git a/pyproject.toml b/pyproject.toml index 2132c5c50..a68222b36 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -185,6 +185,7 @@ jobs = 1 # >1 hides results extension-pkg-allow-list=[ "lxml", ] +load-plugins = ["pylint_per_file_ignores"] [tool.pylint."MESSAGES CONTROL"] enable = [ @@ -244,15 +245,13 @@ disable = [ "unused-import", # Ones that we may want to address (fix, ignore per-line or move to "don't want to fix") - "abstract-method", "arguments-differ", - "arguments-renamed", - "dangerous-default-value", "keyword-arg-before-vararg", - "pointless-statement", - "raise-missing-from", - "unnecessary-dunder-call", - "used-before-assignment", +] +# requires `pylint_per_file_ignores` plugin +per-file-ignores = [ + # Extended list of ones that we may want to address, only for tests + "./tests/*:abstract-method,arguments-renamed,dangerous-default-value,pointless-statement,raise-missing-from,unnecessary-dunder-call,used-before-assignment", ] [tool.pytest.ini_options] diff --git a/scrapy/contracts/__init__.py b/scrapy/contracts/__init__.py index f84bbe0d7..24f56b7a6 100644 --- a/scrapy/contracts/__init__.py +++ b/scrapy/contracts/__init__.py @@ -125,7 +125,7 @@ class ContractsManager: def from_spider(self, spider: Spider, results: TestResult) -> list[Request | None]: requests: list[Request | None] = [] for method in self.tested_methods_from_spidercls(type(spider)): - bound_method = spider.__getattribute__(method) + bound_method = getattr(spider, method) try: requests.append(self.from_method(bound_method, results)) except Exception: diff --git a/scrapy/core/downloader/handlers/http11.py b/scrapy/core/downloader/handlers/http11.py index 439638d33..975078803 100644 --- a/scrapy/core/downloader/handlers/http11.py +++ b/scrapy/core/downloader/handlers/http11.py @@ -6,6 +6,7 @@ import ipaddress import logging import re from contextlib import suppress +from functools import partial from io import BytesIO from time import monotonic from typing import TYPE_CHECKING, Any, TypedDict, TypeVar, cast @@ -547,11 +548,7 @@ class ScrapyAgent: get_warnsize_msg(expected_size, warnsize, request, expected=True) ) - def _cancel(_: Any) -> None: - # Abort connection immediately. - txresponse._transport._producer.abortConnection() - - d: Deferred[_ResultT] = Deferred(_cancel) + d: Deferred[_ResultT] = Deferred(partial(self._cancel, txresponse=txresponse)) txresponse.deliverBody( _ResponseReader( finished=d, @@ -570,6 +567,11 @@ class ScrapyAgent: return d + @staticmethod + def _cancel(_: Any, txresponse: TxResponse) -> None: + # Abort connection immediately. + txresponse._transport._producer.abortConnection() + def _cb_bodydone(self, result: _ResultT, url: str) -> Response: headers = self._headers_from_twisted_response(result["txresponse"]) try: @@ -667,17 +669,17 @@ class _ResponseReader(Protocol): assert hostname is not None _log_ssl_conn_debug_info(hostname, connection) - def dataReceived(self, bodyBytes: bytes) -> None: + def dataReceived(self, data: bytes) -> None: # This maybe called several times after cancel was called with buffered data. if self._finished.called: return assert self.transport - self._bodybuf.write(bodyBytes) - self._bytes_received += len(bodyBytes) + self._bodybuf.write(data) + self._bytes_received += len(data) if stop_download := check_stop_download( - signals.bytes_received, self._crawler, self._request, data=bodyBytes + signals.bytes_received, self._crawler, self._request, data=data ): self.transport.stopProducing() self.transport.loseConnection() diff --git a/scrapy/core/downloader/middleware.py b/scrapy/core/downloader/middleware.py index d08c6f8e3..6685d90af 100644 --- a/scrapy/core/downloader/middleware.py +++ b/scrapy/core/downloader/middleware.py @@ -75,87 +75,82 @@ class DownloaderMiddlewareManager(MiddlewareManager): download_func: Callable[[Request], Coroutine[Any, Any, Response]], request: Request, ) -> Response | Request: - async def process_request(request: Request) -> Response | Request: - for method in self.methods["process_request"]: - method = cast("Callable", method) - if method in self._mw_methods_requiring_spider: - response = await ensure_awaitable( - method(request=request, spider=self._spider), - _warn=global_object_name(method), - ) - else: - response = await ensure_awaitable( - method(request=request), _warn=global_object_name(method) - ) - if response is not None and not isinstance( - response, (Response, Request) - ): - raise _InvalidOutput( - f"Middleware {method.__qualname__} must return None, Response or " - f"Request, got {response.__class__.__name__}" - ) - if response: - return response - return await download_func(request) - - async def process_response(response: Response | Request) -> Response | Request: - if response is None: - raise TypeError("Received None in process_response") - if isinstance(response, Request): - return response - - for method in self.methods["process_response"]: - method = cast("Callable", method) - if method in self._mw_methods_requiring_spider: - response = await ensure_awaitable( - method(request=request, response=response, spider=self._spider), - _warn=global_object_name(method), - ) - else: - response = await ensure_awaitable( - method(request=request, response=response), - _warn=global_object_name(method), - ) - if not isinstance(response, (Response, Request)): - raise _InvalidOutput( - f"Middleware {method.__qualname__} must return Response or Request, " - f"got {type(response)}" - ) - if isinstance(response, Request): - return response - return response - - async def process_exception(exception: Exception) -> Response | Request: - for method in self.methods["process_exception"]: - method = cast("Callable", method) - if method in self._mw_methods_requiring_spider: - response = await ensure_awaitable( - method( - request=request, exception=exception, spider=self._spider - ), - _warn=global_object_name(method), - ) - else: - response = await ensure_awaitable( - method(request=request, exception=exception), - _warn=global_object_name(method), - ) - if response is not None and not isinstance( - response, (Response, Request) - ): - raise _InvalidOutput( - f"Middleware {method.__qualname__} must return None, Response or " - f"Request, got {type(response)}" - ) - if response: - return response - raise exception try: - result: Response | Request = await process_request(request) + result: Response | Request = await self._process_request( + request, download_func + ) except Exception as ex: await _defer_sleep_async() # either returns a request or response (which we pass to process_response()) # or reraises the exception - result = await process_exception(ex) - return await process_response(result) + result = await self._process_exception(ex, request) + return await self._process_response(result, request) + + def _handle_mw_method(self, method: Callable, **kwargs: Any) -> Any: + if method in self._mw_methods_requiring_spider: + kwargs["spider"] = self._spider + + return method(**kwargs) + + async def _process_request( + self, + request: Request, + download_func: Callable[[Request], Coroutine[Any, Any, Response]], + ) -> Response | Request: + for method in self.methods["process_request"]: + method = cast("Callable", method) + response = await ensure_awaitable( + self._handle_mw_method(method, request=request), + _warn=global_object_name(method), + ) + if response is not None and not isinstance(response, (Response, Request)): + raise _InvalidOutput( + f"Middleware {method.__qualname__} must return None, Response or " + f"Request, got {response.__class__.__name__}" + ) + if response: + return response + return await download_func(request) + + async def _process_response( + self, response: Response | Request, request: Request + ) -> Response | Request: + if response is None: + raise TypeError("Received None in process_response") + if isinstance(response, Request): + return response + + for method in self.methods["process_response"]: + method = cast("Callable", method) + response = await ensure_awaitable( + self._handle_mw_method(method, request=request, response=response), + _warn=global_object_name(method), + ) + + if not isinstance(response, (Response, Request)): + raise _InvalidOutput( + f"Middleware {method.__qualname__} must return Response or Request, " + f"got {type(response)}" + ) + if isinstance(response, Request): + return response + return response + + async def _process_exception( + self, exception: Exception, request: Request | Response + ) -> Response | Request: + for method in self.methods["process_exception"]: + method = cast("Callable", method) + response = await ensure_awaitable( + self._handle_mw_method(method, request=request, exception=exception), + _warn=global_object_name(method), + ) + if response is not None and not isinstance(response, (Response, Request)): + raise _InvalidOutput( + f"Middleware {method.__qualname__} must return None, Response or " + f"Request, got {type(response)}" + ) + if response: + return response + raise exception diff --git a/scrapy/core/engine.py b/scrapy/core/engine.py index 94920e840..1033e874f 100644 --- a/scrapy/core/engine.py +++ b/scrapy/core/engine.py @@ -11,6 +11,7 @@ import asyncio import contextlib import logging import warnings +from functools import partial from time import time from traceback import format_exc from typing import TYPE_CHECKING, Any @@ -273,7 +274,7 @@ class ExecutionEngine: """ assert self._start is not None try: - item_or_request = await self._start.__anext__() + item_or_request = await anext(self._start) except StopAsyncIteration: self._start = None except Exception as exception: @@ -352,6 +353,10 @@ class ExecutionEngine: or self.scraper.slot.needs_backout() ) + def _remove_request(self, _: Any, request: Request) -> None: + assert self._slot + self._slot.remove_request(request) + def _start_scheduled_request(self) -> bool: assert self._slot is not None # typing assert self.spider is not None # typing @@ -371,11 +376,7 @@ class ExecutionEngine: ) ) - def _remove_request(_: Any) -> None: - assert self._slot - self._slot.remove_request(request) - - d2: Deferred[None] = d.addBoth(_remove_request) + d2: Deferred[None] = d.addBoth(partial(self._remove_request, request=request)) d2.addErrback( lambda f: logger.info( "Error while removing request from slot", @@ -612,30 +613,33 @@ class ExecutionEngine: "Closing spider (%(reason)s)", {"reason": reason}, extra={"spider": spider} ) - def log_failure(msg: str) -> None: - logger.error(msg, exc_info=True, extra={"spider": spider}) # noqa: LOG014 - try: await self._slot.close() except Exception: - log_failure("Slot close failure") + logger.error("Slot close failure", exc_info=True, extra={"spider": spider}) try: self.downloader.close() except Exception: - log_failure("Downloader close failure") + logger.error( + "Downloader close failure", exc_info=True, extra={"spider": spider} + ) try: await self.scraper.close_spider_async() except Exception: - log_failure("Scraper close failure") + logger.error( + "Scraper close failure", exc_info=True, extra={"spider": spider} + ) if hasattr(self._slot.scheduler, "close"): try: if (d := self._slot.scheduler.close(reason)) is not None: await maybe_deferred_to_future(d) except Exception: - log_failure("Scheduler close failure") + logger.error( + "Scheduler close failure", exc_info=True, extra={"spider": spider} + ) try: await self.signals.send_catch_log_async( @@ -644,7 +648,11 @@ class ExecutionEngine: reason=reason, ) except Exception: - log_failure("Error while sending spider_close signal") + logger.error( + "Error while sending spider_close signal", + exc_info=True, + extra={"spider": spider}, + ) assert self.crawler.stats try: @@ -661,7 +669,7 @@ class ExecutionEngine: else: self.crawler.stats.close_spider(reason=reason) except Exception: - log_failure("Stats close failure") + logger.error("Stats close failure") logger.info( "Spider closed (%(reason)s)", @@ -675,4 +683,4 @@ class ExecutionEngine: try: await ensure_awaitable(self._spider_closed_callback(spider)) except Exception: - log_failure("Error running spider_closed_callback") + logger.error("Error running spider_closed_callback") diff --git a/scrapy/core/http2/stream.py b/scrapy/core/http2/stream.py index 71569b217..4d072c555 100644 --- a/scrapy/core/http2/stream.py +++ b/scrapy/core/http2/stream.py @@ -155,16 +155,16 @@ class Stream: "status": None, } - def _cancel(_: Any) -> None: - # Close this stream as gracefully as possible - # If the associated request is initiated we reset this stream - # else we directly call close() method - if self.metadata["request_sent"]: - self.reset_stream(StreamCloseReason.CANCELLED) - else: - self.close(StreamCloseReason.CANCELLED) + self._deferred_response: Deferred[Response] = Deferred(self._cancel) - self._deferred_response: Deferred[Response] = Deferred(_cancel) + def _cancel(self, _: Any) -> None: + # Close this stream as gracefully as possible + # If the associated request is initiated we reset this stream + # else we directly call close() method + if self.metadata["request_sent"]: + self.reset_stream(StreamCloseReason.CANCELLED) + else: + self.close(StreamCloseReason.CANCELLED) def __repr__(self) -> str: return f"Stream(id={self.stream_id!r})" diff --git a/scrapy/core/spidermw.py b/scrapy/core/spidermw.py index 790317357..d48a65967 100644 --- a/scrapy/core/spidermw.py +++ b/scrapy/core/spidermw.py @@ -112,40 +112,59 @@ class SpiderMiddlewareManager(MiddlewareManager): exception_processor_index: int, recover_to: MutableChain[_T] | MutableAsyncChain[_T], ) -> Iterable[_T] | AsyncIterator[_T]: - def process_sync(iterable: Iterable[_T]) -> Iterable[_T]: - try: - yield from iterable - except Exception as ex: - exception_result = cast( - "Failure | MutableChain[_T]", - self._process_spider_exception( - response, ex, exception_processor_index - ), - ) - if isinstance(exception_result, Failure): - raise - assert isinstance(recover_to, MutableChain) - recover_to.extend(exception_result) - - async def process_async(iterable: AsyncIterator[_T]) -> AsyncIterator[_T]: - try: - async for r in iterable: - yield r - except Exception as ex: - exception_result = cast( - "Failure | MutableAsyncChain[_T]", - self._process_spider_exception( - response, ex, exception_processor_index - ), - ) - if isinstance(exception_result, Failure): - raise - assert isinstance(recover_to, MutableAsyncChain) - recover_to.extend(exception_result) if isinstance(iterable, AsyncIterator): - return process_async(iterable) - return process_sync(iterable) + return self._process_async( + response, + iterable, + exception_processor_index, + cast("MutableAsyncChain[_T]", recover_to), + ) + return self._process_sync( + response, + iterable, + exception_processor_index, + cast("MutableChain[_T]", recover_to), + ) + + def _process_sync( + self, + response: Response, + iterable: Iterable[_T], + exception_processor_index: int, + recover_to: MutableChain[_T], + ) -> Iterable[_T]: + try: + yield from iterable + except Exception as ex: + exception_result = cast( + "Failure | MutableChain[_T]", + self._process_spider_exception(response, ex, exception_processor_index), + ) + if isinstance(exception_result, Failure): + raise + assert isinstance(recover_to, MutableChain) + recover_to.extend(exception_result) + + async def _process_async( + self, + response: Response, + iterable: AsyncIterator[_T], + exception_processor_index: int, + recover_to: MutableAsyncChain[_T], + ) -> AsyncIterator[_T]: + try: + async for r in iterable: + yield r + except Exception as ex: + exception_result = cast( + "Failure | MutableAsyncChain[_T]", + self._process_spider_exception(response, ex, exception_processor_index), + ) + if isinstance(exception_result, Failure): + raise + assert isinstance(recover_to, MutableAsyncChain) + recover_to.extend(exception_result) def _process_spider_exception( self, @@ -350,25 +369,14 @@ class SpiderMiddlewareManager(MiddlewareManager): "scrape_response_async() called on a SpiderMiddlewareManager" " instance created without a crawler." ) - - async def process_callback_output( - result: Iterable[_T] | AsyncIterator[_T], - ) -> MutableChain[_T] | MutableAsyncChain[_T]: - return await self._process_callback_output(response, result) - - def process_spider_exception( - exception: Exception, - ) -> MutableChain[_T] | MutableAsyncChain[_T]: - return self._process_spider_exception(response, exception) - try: it: Iterable[_T] | AsyncIterator[_T] = await self._process_spider_input( scrape_func, response, request ) - return await process_callback_output(it) + return await self._process_callback_output(response, it) except Exception as ex: await _defer_sleep_async() - return process_spider_exception(ex) + return self._process_spider_exception(response, ex) async def process_start( self, spider: Spider | None = None diff --git a/scrapy/crawler.py b/scrapy/crawler.py index 0a19e9985..8e3ae7879 100644 --- a/scrapy/crawler.py +++ b/scrapy/crawler.py @@ -7,6 +7,7 @@ import pprint import signal import warnings from abc import ABC, abstractmethod +from functools import partial from typing import TYPE_CHECKING, Any, TypeVar from twisted.internet.defer import Deferred, DeferredList, inlineCallbacks @@ -568,28 +569,30 @@ class AsyncCrawlerRunner(CrawlerRunnerBase): crawler = self.create_crawler(crawler_or_spidercls) return self._crawl(crawler, *args, **kwargs) + async def _crawl_and_track( + self, crawler: Crawler, *args: Any, **kwargs: Any + ) -> None: + try: + await crawler.crawl_async(*args, **kwargs) + except Exception: + self.bootstrap_failed = True + raise # re-raise so asyncio still logs it to stderr naturally + + def _done(self, task: asyncio.Task[None], crawler: Crawler) -> None: + self._active.discard(task) + self.crawlers.discard(crawler) + self.bootstrap_failed |= not getattr(crawler, "spider", None) + def _crawl(self, crawler: Crawler, *args: Any, **kwargs: Any) -> asyncio.Task[None]: # At this point the asyncio loop has been installed either by the user # or by AsyncCrawlerProcess (but it isn't running yet, so no asyncio.create_task()). loop = asyncio.get_event_loop() self.crawlers.add(crawler) - async def _crawl_and_track() -> None: - try: - await crawler.crawl_async(*args, **kwargs) - except Exception: - self.bootstrap_failed = True - raise # re-raise so asyncio still logs it to stderr naturally - - task = loop.create_task(_crawl_and_track()) + task = loop.create_task(self._crawl_and_track(crawler, *args, **kwargs)) self._active.add(task) + task.add_done_callback(partial(self._done, crawler=crawler)) - def _done(_: asyncio.Task[None]) -> None: - self.crawlers.discard(crawler) - self._active.discard(task) - self.bootstrap_failed |= not getattr(crawler, "spider", None) - - task.add_done_callback(_done) return task async def stop(self) -> None: @@ -1007,14 +1010,15 @@ class AsyncCrawlerProcess(CrawlerProcessBase, AsyncCrawlerRunner): if (loop := self._reactorless_loop) is None: return - def _create_shutdown_task() -> None: - coro = self._shutdown_graceful_reactorless() - try: - loop.create_task(coro) - except RuntimeError: - coro.close() + loop.call_soon_threadsafe(self._create_shutdown_task) - loop.call_soon_threadsafe(_create_shutdown_task) + def _create_shutdown_task(self) -> None: + assert self._reactorless_loop + coro = self._shutdown_graceful_reactorless() + try: + self._reactorless_loop.create_task(coro) + except RuntimeError: + coro.close() async def _shutdown_graceful_reactorless(self) -> None: await self.stop() diff --git a/scrapy/extensions/feedexport.py b/scrapy/extensions/feedexport.py index 8f70dbc2e..d0430d0db 100644 --- a/scrapy/extensions/feedexport.py +++ b/scrapy/extensions/feedexport.py @@ -534,13 +534,15 @@ class FeedExporter: # Send FEED_EXPORTER_CLOSED signal await self.crawler.signals.send_catch_log_async(signals.feed_exporter_closed) + @staticmethod + def _get_file(slot_: FeedSlot) -> IO[bytes]: + assert slot_.file + if isinstance(slot_.file, PostProcessingManager): + slot_.file.close() + return slot_.file.file + return slot_.file + async def _close_slot(self, slot: FeedSlot, spider: Spider) -> None: - def get_file(slot_: FeedSlot) -> IO[bytes]: - assert slot_.file - if isinstance(slot_.file, PostProcessingManager): - slot_.file.close() - return slot_.file.file - return slot_.file if slot.itemcount: # Normal case @@ -557,7 +559,7 @@ class FeedExporter: slot_type = type(slot.storage).__name__ assert self.crawler.stats try: - await ensure_awaitable(slot.storage.store(get_file(slot))) + await ensure_awaitable(slot.storage.store(self._get_file(slot))) except Exception: logger.error( "Error storing %s", diff --git a/scrapy/pipelines/__init__.py b/scrapy/pipelines/__init__.py index 18473c534..84fb5f85e 100644 --- a/scrapy/pipelines/__init__.py +++ b/scrapy/pipelines/__init__.py @@ -37,16 +37,16 @@ class ItemPipelineManager(MiddlewareManager): settings.get_component_priority_dict_with_base("ITEM_PIPELINES") ) - def _add_middleware(self, pipe: Any) -> None: - if hasattr(pipe, "open_spider"): - self.methods["open_spider"].append(pipe.open_spider) - self._check_mw_method_spider_arg(pipe.open_spider) - if hasattr(pipe, "close_spider"): - self.methods["close_spider"].appendleft(pipe.close_spider) - self._check_mw_method_spider_arg(pipe.close_spider) - if hasattr(pipe, "process_item"): - self.methods["process_item"].append(pipe.process_item) - self._check_mw_method_spider_arg(pipe.process_item) + def _add_middleware(self, mw: Any) -> None: + if hasattr(mw, "open_spider"): + self.methods["open_spider"].append(mw.open_spider) + self._check_mw_method_spider_arg(mw.open_spider) + if hasattr(mw, "close_spider"): + self.methods["close_spider"].appendleft(mw.close_spider) + self._check_mw_method_spider_arg(mw.close_spider) + if hasattr(mw, "process_item"): + self.methods["process_item"].append(mw.process_item) + self._check_mw_method_spider_arg(mw.process_item) def process_item(self, item: Any, spider: Spider) -> Deferred[Any]: warnings.warn( @@ -62,32 +62,44 @@ class ItemPipelineManager(MiddlewareManager): "process_item", item, add_spider=True, warn_deferred=True ) + def _get_dfd( + self, + method: Callable[..., Coroutine[Any, Any, None] | Deferred[None] | None], + ) -> Deferred[None]: + if method in self._mw_methods_requiring_spider: + return _maybeDeferred_coro(method, True, self._spider) + return _maybeDeferred_coro(method, True) + + @staticmethod + def _eb(failure: Failure) -> Failure: + assert isinstance(failure.value, FirstError) + return failure.value.subFailure + def _process_parallel_dfd(self, methodname: str) -> Deferred[list[None]]: methods = cast( "Iterable[Callable[..., Coroutine[Any, Any, None] | Deferred[None] | None]]", self.methods[methodname], ) - def get_dfd( - method: Callable[..., Coroutine[Any, Any, None] | Deferred[None] | None], - ) -> Deferred[None]: - if method in self._mw_methods_requiring_spider: - return _maybeDeferred_coro(method, True, self._spider) - return _maybeDeferred_coro(method, True) - - dfds = [get_dfd(m) for m in methods] + dfds = [self._get_dfd(m) for m in methods] d: Deferred[list[tuple[bool, None]]] = DeferredList( dfds, fireOnOneErrback=True, consumeErrors=True ) d2: Deferred[list[None]] = d.addCallback(lambda r: [x[1] for x in r]) - def eb(failure: Failure) -> Failure: - assert isinstance(failure.value, FirstError) - return failure.value.subFailure - - d2.addErrback(eb) + d2.addErrback(self._eb) return d2 + def get_awaitable( + self, + method: Callable[..., Coroutine[Any, Any, None] | Deferred[None] | None], + ) -> Awaitable[None]: + if method in self._mw_methods_requiring_spider: + result = method(self._spider) + else: + result = method() + return ensure_awaitable(result, _warn=global_object_name(method)) + async def _process_parallel_asyncio(self, methodname: str) -> list[None]: methods = cast( "Iterable[Callable[..., Coroutine[Any, Any, None] | Deferred[None] | None]]", @@ -96,16 +108,7 @@ class ItemPipelineManager(MiddlewareManager): if not methods: return [] - def get_awaitable( - method: Callable[..., Coroutine[Any, Any, None] | Deferred[None] | None], - ) -> Awaitable[None]: - if method in self._mw_methods_requiring_spider: - result = method(self._spider) - else: - result = method() - return ensure_awaitable(result, _warn=global_object_name(method)) - - awaitables = [get_awaitable(m) for m in methods] + awaitables = [self.get_awaitable(m) for m in methods] await asyncio.gather(*awaitables) return [None for _ in methods] diff --git a/scrapy/pipelines/files.py b/scrapy/pipelines/files.py index 1eb17c3d3..ffed96446 100644 --- a/scrapy/pipelines/files.py +++ b/scrapy/pipelines/files.py @@ -186,16 +186,18 @@ class S3FilesStore: raise ValueError(f"Incorrect URI scheme in {uri}, expected 's3'") self.bucket, self.prefix = uri[5:].split("/", 1) + @staticmethod + def _onsuccess(boto_key: dict[str, Any]) -> StatInfo: + checksum = boto_key["ETag"].strip('"') + last_modified = boto_key["LastModified"] + modified_stamp = time.mktime(last_modified.timetuple()) + return {"checksum": checksum, "last_modified": modified_stamp} + def stat_file( self, path: str, info: MediaPipeline.SpiderInfo ) -> Deferred[StatInfo]: - def _onsuccess(boto_key: dict[str, Any]) -> StatInfo: - checksum = boto_key["ETag"].strip('"') - last_modified = boto_key["LastModified"] - modified_stamp = time.mktime(last_modified.timetuple()) - return {"checksum": checksum, "last_modified": modified_stamp} - return self._get_boto_key(path).addCallback(_onsuccess) + return self._get_boto_key(path).addCallback(self._onsuccess) def _get_boto_key(self, path: str) -> Deferred[dict[str, Any]]: key_name = f"{self.prefix}{path}" @@ -308,20 +310,22 @@ class GCSFilesStore: {"bucket": bucket}, ) + @staticmethod + def _onsuccess(blob: Any) -> StatInfo: + if blob: + checksum = base64.b64decode(blob.md5_hash).hex() + last_modified = time.mktime(blob.updated.timetuple()) + return {"checksum": checksum, "last_modified": last_modified} + return {} + def stat_file( self, path: str, info: MediaPipeline.SpiderInfo ) -> Deferred[StatInfo]: - def _onsuccess(blob: Any) -> StatInfo: - if blob: - checksum = base64.b64decode(blob.md5_hash).hex() - last_modified = time.mktime(blob.updated.timetuple()) - return {"checksum": checksum, "last_modified": last_modified} - return {} blob_path = self._get_blob_path(path) return deferred_from_coro( run_in_thread(self.bucket.get_blob, blob_path) - ).addCallback(_onsuccess) + ).addCallback(self._onsuccess) def _get_content_type(self, headers: dict[str, str] | None) -> str: if headers and "Content-Type" in headers: @@ -395,26 +399,26 @@ class FTPFilesStore: ) ) + def _stat_file(self, path: str) -> StatInfo: + try: + with FTP() as ftp: + ftp.connect(self.host, self.port) + ftp.login(self.username, self.password) + if self.USE_ACTIVE_MODE: + ftp.set_pasv(False) + file_path = f"{self.basedir}/{path}" + last_modified = float(ftp.voidcmd(f"MDTM {file_path}")[4:].strip()) + m = hashlib.md5() # noqa: S324 + ftp.retrbinary(f"RETR {file_path}", m.update) + return {"last_modified": last_modified, "checksum": m.hexdigest()} + # The file doesn't exist + except Exception: + return {} + def stat_file( self, path: str, info: MediaPipeline.SpiderInfo ) -> Deferred[StatInfo]: - def _stat_file(path: str) -> StatInfo: - try: - with FTP() as ftp: - ftp.connect(self.host, self.port) - ftp.login(self.username, self.password) - if self.USE_ACTIVE_MODE: - ftp.set_pasv(False) - file_path = f"{self.basedir}/{path}" - last_modified = float(ftp.voidcmd(f"MDTM {file_path}")[4:].strip()) - m = hashlib.md5() # noqa: S324 - ftp.retrbinary(f"RETR {file_path}", m.update) - return {"last_modified": last_modified, "checksum": m.hexdigest()} - # The file doesn't exist - except Exception: - return {} - - return deferred_from_coro(run_in_thread(_stat_file, path)) + return deferred_from_coro(run_in_thread(self._stat_file, path)) class FilesPipeline(MediaPipeline): @@ -534,43 +538,51 @@ class FilesPipeline(MediaPipeline): store_cls = self.STORE_SCHEMES[scheme] return store_cls(uri) + def _onsuccess( + self, + result: StatInfo, + request: Request, + info: MediaPipeline.SpiderInfo, + path: str, + ) -> FileInfo | None: + if not result: + return None # returning None force download + + last_modified = result.get("last_modified", None) + if not last_modified: + return None # returning None force download + + age_seconds = time.time() - last_modified + age_days = age_seconds / 60 / 60 / 24 + if age_days > self.expires: + return None # returning None force download + + referer = referer_str(request) + logger.debug( + "File (uptodate): Downloaded %(medianame)s from %(request)s " + "referred in <%(referer)s>", + {"medianame": self.MEDIA_NAME, "request": request, "referer": referer}, + extra={"spider": info.spider}, + ) + self.inc_stats("uptodate") + + checksum = result.get("checksum", None) + return { + "url": request.url, + "path": path, + "checksum": checksum, + "status": "uptodate", + } + def media_to_download( self, request: Request, info: MediaPipeline.SpiderInfo, *, item: Any = None ) -> Deferred[FileInfo | None] | None: - def _onsuccess(result: StatInfo) -> FileInfo | None: - if not result: - return None # returning None force download - - last_modified = result.get("last_modified", None) - if not last_modified: - return None # returning None force download - - age_seconds = time.time() - last_modified - age_days = age_seconds / 60 / 60 / 24 - if age_days > self.expires: - return None # returning None force download - - referer = referer_str(request) - logger.debug( - "File (uptodate): Downloaded %(medianame)s from %(request)s " - "referred in <%(referer)s>", - {"medianame": self.MEDIA_NAME, "request": request, "referer": referer}, - extra={"spider": info.spider}, - ) - self.inc_stats("uptodate") - - checksum = result.get("checksum", None) - return { - "url": request.url, - "path": path, - "checksum": checksum, - "status": "uptodate", - } - path = self.file_path(request, info=info, item=item) # maybeDeferred() overloads don't seem to support a Union[_T, Deferred[_T]] return type dfd: Deferred[StatInfo] = maybeDeferred(self.store.stat_file, path, info) # type: ignore[call-overload] - dfd2: Deferred[FileInfo | None] = dfd.addCallback(_onsuccess) + dfd2: Deferred[FileInfo | None] = dfd.addCallback( + functools.partial(self._onsuccess, request=request, info=info, path=path) + ) dfd2.addErrback(lambda _: None) dfd2.addErrback( lambda f: logger.error( diff --git a/scrapy/settings/__init__.py b/scrapy/settings/__init__.py index 2a1b1932c..d4671fd35 100644 --- a/scrapy/settings/__init__.py +++ b/scrapy/settings/__init__.py @@ -143,7 +143,7 @@ class BaseSettings(MutableMapping[_SettingsKey, Any]): raise ValueError(f"{item!r} not found in the {name} setting ({value!r}).") self.set(name, [v for v in value if v != item], self.getpriority(name) or 0) - def get(self, name: _SettingsKey, default: Any = None) -> Any: + def get(self, name: _SettingsKey, default: Any = None) -> Any: # pylint: disable=arguments-renamed """ Get a setting value without affecting its original type. @@ -510,7 +510,7 @@ class BaseSettings(MutableMapping[_SettingsKey, Any]): component_priority_dict[cls] = priority self.set(name, component_priority_dict, self.getpriority(name) or 0) - def setdefault( + def setdefault( # pylint: disable=arguments-renamed self, name: _SettingsKey, default: Any = None, @@ -691,14 +691,14 @@ class BaseSettings(MutableMapping[_SettingsKey, Any]): else: p.text(pformat(self.copy_to_dict())) - def pop(self, name: _SettingsKey, default: Any = __default) -> Any: + def pop(self, name: _SettingsKey, default: Any = __default) -> Any: # pylint: disable=arguments-renamed try: value = self.attributes[name].value except KeyError: if default is self.__default: raise return default - self.__delitem__(name) + del self[name] return value diff --git a/scrapy/utils/benchserver.py b/scrapy/utils/benchserver.py index 59f04b8ad..c4c96ef00 100644 --- a/scrapy/utils/benchserver.py +++ b/scrapy/utils/benchserver.py @@ -9,7 +9,7 @@ from twisted.web.server import Request, Site class Root(Resource): isLeaf = True - def getChild(self, name: str, request: Request) -> Resource: + def getChild(self, path: str, request: Request) -> Resource: return self def render(self, request: Request) -> bytes: diff --git a/scrapy/utils/defer.py b/scrapy/utils/defer.py index 7d7636fe6..90fd04777 100644 --- a/scrapy/utils/defer.py +++ b/scrapy/utils/defer.py @@ -262,7 +262,7 @@ class _AsyncCooperatorAdapter(Iterator, Generic[_T]): def _call_anext(self) -> None: # This starts waiting for the next result from aiterator. # If aiterator is exhausted, _errback will be called. - self.anext_deferred = deferred_from_coro(self.aiterator.__anext__()) + self.anext_deferred = deferred_from_coro(anext(self.aiterator)) self.anext_deferred.addCallbacks(self._callback, self._errback) def __next__(self) -> Deferred[Any]: @@ -370,10 +370,10 @@ async def aiter_errback( """Wrap an async iterable calling an errback if an error is caught while iterating it. Similar to :func:`scrapy.utils.defer.iter_errback`. """ - it = aiterable.__aiter__() + it = aiter(aiterable) while True: try: - yield await it.__anext__() + yield await anext(it) except StopAsyncIteration: break except Exception: diff --git a/scrapy/utils/misc.py b/scrapy/utils/misc.py index fba80484c..757756e65 100644 --- a/scrapy/utils/misc.py +++ b/scrapy/utils/misc.py @@ -255,6 +255,11 @@ def walk_callable(node: ast.AST) -> Iterable[ast.AST]: _generator_callbacks_cache = LocalWeakReferencedCache(limit=128) +def _returns_none(return_node: ast.Return) -> bool: + value = return_node.value + return value is None or (isinstance(value, ast.Constant) and value.value is None) + + def is_generator_with_return_value(callable: Callable[..., Any]) -> bool: # noqa: A002 """ Returns True if a callable is a generator function which includes a @@ -263,12 +268,6 @@ def is_generator_with_return_value(callable: Callable[..., Any]) -> bool: # noq if callable in _generator_callbacks_cache: return bool(_generator_callbacks_cache[callable]) - def returns_none(return_node: ast.Return) -> bool: - value = return_node.value - return value is None or ( - isinstance(value, ast.Constant) and value.value is None - ) - if inspect.isgeneratorfunction(callable): func = callable while isinstance(func, partial): @@ -284,7 +283,7 @@ def is_generator_with_return_value(callable: Callable[..., Any]) -> bool: # noq tree = ast.parse(code) for node in walk_callable(tree): - if isinstance(node, ast.Return) and not returns_none(node): + if isinstance(node, ast.Return) and not _returns_none(node): _generator_callbacks_cache[callable] = True return bool(_generator_callbacks_cache[callable]) diff --git a/scrapy/utils/python.py b/scrapy/utils/python.py index bf70fa7fc..9cb695111 100644 --- a/scrapy/utils/python.py +++ b/scrapy/utils/python.py @@ -99,6 +99,16 @@ def to_bytes( return text.encode(encoding, errors) +def _chunk_iter(text: str, chunk_size: int) -> Iterable[tuple[str, int]]: + offset = len(text) + while True: + offset -= chunk_size * 1024 + if offset <= 0: + break + yield (text[offset:], offset) + yield (text, 0) + + def re_rsearch( pattern: str | Pattern[str], text: str, chunk_size: int = 1024 ) -> tuple[int, int] | None: @@ -115,19 +125,10 @@ def re_rsearch( the start position of the match, and the ending (regarding the entire text). """ - def _chunk_iter() -> Iterable[tuple[str, int]]: - offset = len(text) - while True: - offset -= chunk_size * 1024 - if offset <= 0: - break - yield (text[offset:], offset) - yield (text, 0) - if isinstance(pattern, str): pattern = re.compile(pattern) - for chunk, offset in _chunk_iter(): + for chunk, offset in _chunk_iter(text, chunk_size): matches = list(pattern.finditer(chunk)) if matches: start, end = matches[-1].span() diff --git a/tests/mockserver/http.py b/tests/mockserver/http.py index 6cf0046c0..622324a10 100644 --- a/tests/mockserver/http.py +++ b/tests/mockserver/http.py @@ -85,7 +85,7 @@ class Root(resource.Resource): self.putChild(b"response-headers", ResponseHeadersResource()) self.putChild(b"set-cookie", SetCookie()) - def getChild(self, name, request): + def getChild(self, path, request): return self def render(self, request): diff --git a/tests/mockserver/simple_https.py b/tests/mockserver/simple_https.py index 5f23dd2c4..a8f483ee1 100644 --- a/tests/mockserver/simple_https.py +++ b/tests/mockserver/simple_https.py @@ -13,7 +13,7 @@ class Root(resource.Resource): resource.Resource.__init__(self) self.putChild(b"file", Data(b"0123456789", "text/plain")) - def getChild(self, name, request): + def getChild(self, path, request): return self diff --git a/tox.ini b/tox.ini index f1b004ab8..07583a11e 100644 --- a/tox.ini +++ b/tox.ini @@ -89,8 +89,9 @@ basepython = python3 deps = {[testenv:extra-deps]deps} pylint==4.0.2 + pylint-per-file-ignores # https://github.com/pylint-dev/pylint/issues/3767#issuecomment-1319916278 commands = - pylint conftest.py docs extras scrapy tests + pylint {posargs:conftest.py docs extras scrapy tests} [testenv:twinecheck] basepython = python3 From 33452f3aeb850c54ea13c74194cd633daaa31698 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Tue, 5 May 2026 01:20:21 +0500 Subject: [PATCH 144/248] Silence deprecation warnings in TestFormRequest. (#7491) --- tests/test_http_request_form.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_http_request_form.py b/tests/test_http_request_form.py index 8ba9f3422..a4f87d50d 100644 --- a/tests/test_http_request_form.py +++ b/tests/test_http_request_form.py @@ -26,6 +26,7 @@ def _qs(req, encoding="utf-8", to_unicode=False): return parse_qs(uqs, True) +@pytest.mark.filterwarnings("ignore::scrapy.exceptions.ScrapyDeprecationWarning") class TestFormRequest(TestRequest): request_class = FormRequest # type: ignore[assignment] From fc14a0ce5989e32794636a287c6914c0af396ed9 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Tue, 5 May 2026 01:24:47 +0500 Subject: [PATCH 145/248] Enable mypy warn_return_any. (#7492) --- pyproject.toml | 1 - scrapy/commands/parse.py | 11 ++++----- scrapy/core/downloader/__init__.py | 8 ++++--- scrapy/core/downloader/contextfactory.py | 8 +++---- scrapy/core/downloader/handlers/s3.py | 2 +- scrapy/core/http2/agent.py | 4 ++-- scrapy/core/scheduler.py | 4 ++-- scrapy/http/cookies.py | 2 +- scrapy/pipelines/files.py | 5 ++-- scrapy/pipelines/media.py | 6 ++--- scrapy/robotstxt.py | 4 ++-- scrapy/utils/benchserver.py | 2 +- scrapy/utils/datatypes.py | 4 ++-- scrapy/utils/decorators.py | 4 ++-- scrapy/utils/deprecate.py | 6 ++--- scrapy/utils/misc.py | 4 ++-- scrapy/utils/reactor.py | 6 ++--- scrapy/utils/signal.py | 8 +++++-- scrapy/utils/spider.py | 7 +++--- tests/mockserver/http_resources.py | 2 +- tests/mockserver/utils.py | 10 +++++--- tests/test_downloadermiddleware_robotstxt.py | 4 ++-- tests/test_http2_client_protocol.py | 2 +- tests/test_scheduler.py | 8 +++---- tests/test_spidermiddleware.py | 13 +++++------ tests/utils/decorators.py | 3 ++- tests_typing/test_spiders.mypy-testing | 2 +- tox.ini | 24 +++++++++++--------- 28 files changed, 87 insertions(+), 77 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a68222b36..11d36d0ac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -91,7 +91,6 @@ extra_checks = false # weird addErrback() errors untyped_calls_exclude = [ "twisted", ] -warn_return_any = false # 37 errors [[tool.mypy.overrides]] module = "tests.*" diff --git a/scrapy/commands/parse.py b/scrapy/commands/parse.py index 632186a7d..2ac65bf3f 100644 --- a/scrapy/commands/parse.py +++ b/scrapy/commands/parse.py @@ -144,17 +144,16 @@ class Command(BaseRunSpiderCommand): def iterate_spider_output(self, result: _T) -> Iterable[Any]: ... def iterate_spider_output(self, result: Any) -> Iterable[Any] | Deferred[Any]: + d: Deferred[Any] if inspect.isasyncgen(result): d = deferred_from_coro( collect_asyncgen(aiter_errback(result, self.handle_exception)) ) - d.addCallback(self.iterate_spider_output) - return d + return d.addCallback(self.iterate_spider_output) + d = deferred_from_coro(result) if inspect.iscoroutine(result): - d = deferred_from_coro(result) - d.addCallback(self.iterate_spider_output) - return d - return arg_to_iter(deferred_from_coro(result)) + return d.addCallback(self.iterate_spider_output) + return arg_to_iter(d) def add_items(self, lvl: int, new_items: list[Any]) -> None: old_items = self.items.get(lvl, []) diff --git a/scrapy/core/downloader/__init__.py b/scrapy/core/downloader/__init__.py index 01d5d42b0..7c0ee0eec 100644 --- a/scrapy/core/downloader/__init__.py +++ b/scrapy/core/downloader/__init__.py @@ -128,11 +128,12 @@ class Downloader: ) -> Generator[Deferred[Any], Any, Response | Request]: self.active.add(request) try: - return ( - yield deferred_from_coro( + result: Response | Request = yield ( + deferred_from_coro( self.middleware.download_async(self._enqueue_request, request) ) ) + return result finally: self.active.remove(request) @@ -163,7 +164,8 @@ class Downloader: return key, self.slots[key] def get_slot_key(self, request: Request) -> str: - if (meta_slot := request.meta.get(self.DOWNLOAD_SLOT)) is not None: + meta_slot: str | None = request.meta.get(self.DOWNLOAD_SLOT) + if meta_slot is not None: return meta_slot key = urlparse_cached(request).hostname or "" diff --git a/scrapy/core/downloader/contextfactory.py b/scrapy/core/downloader/contextfactory.py index bbda7efcd..6575d59c1 100644 --- a/scrapy/core/downloader/contextfactory.py +++ b/scrapy/core/downloader/contextfactory.py @@ -108,7 +108,7 @@ class _ScrapyClientContextFactory(BrowserLikePolicyForHTTPS): def _get_cert_options(self) -> CertificateOptions: with _filter_method_warning(): - return CertificateOptions( + return CertificateOptions( # type: ignore[no-any-return] method=self._ssl_method, fixBrokenPeers=True, acceptableCiphers=self.tls_ciphers, @@ -122,7 +122,7 @@ class _ScrapyClientContextFactory(BrowserLikePolicyForHTTPS): def _get_context(self) -> SSL.Context: cert_options = self._get_cert_options() - ctx = cert_options.getContext() + ctx: SSL.Context = cert_options.getContext() ctx.set_options(0x4) # OP_LEGACY_SERVER_CONNECT return ctx @@ -135,7 +135,7 @@ class _ScrapyClientContextFactory(BrowserLikePolicyForHTTPS): # Otherwise use the normal Twisted function. # Note that this doesn't use self._get_context(). with _filter_method_warning(): - return optionsForClientTLS( + return optionsForClientTLS( # type: ignore[no-any-return] hostname=hostname.decode("ascii"), extraCertificateOptions={ "method": self._ssl_method, @@ -186,7 +186,7 @@ class BrowserLikeContextFactory(_ScrapyClientContextFactory): def creatorForNetloc(self, hostname: bytes, port: int) -> ClientTLSOptions: with _filter_method_warning(): - return optionsForClientTLS( + return optionsForClientTLS( # type: ignore[no-any-return] hostname=hostname.decode("ascii"), extraCertificateOptions={"method": self._ssl_method}, ) diff --git a/scrapy/core/downloader/handlers/s3.py b/scrapy/core/downloader/handlers/s3.py index a609cedb3..19a1e8503 100644 --- a/scrapy/core/downloader/handlers/s3.py +++ b/scrapy/core/downloader/handlers/s3.py @@ -39,7 +39,7 @@ class S3DownloadHandler(BaseDownloadHandler): ) ) - _http_handler = build_from_crawler( + _http_handler: BaseDownloadHandler = build_from_crawler( load_object(crawler.settings.getwithbase("DOWNLOAD_HANDLERS")["https"]), crawler, ) diff --git a/scrapy/core/http2/agent.py b/scrapy/core/http2/agent.py index 540b9aa74..822dffc4b 100644 --- a/scrapy/core/http2/agent.py +++ b/scrapy/core/http2/agent.py @@ -143,7 +143,7 @@ class H2Agent: ) def get_endpoint(self, uri: URI) -> HostnameEndpoint: - return self.endpoint_factory.endpointForURI(uri) + return self.endpoint_factory.endpointForURI(uri) # type: ignore[no-any-return] def get_key(self, uri: URI) -> ConnectionKeyT: """ @@ -187,7 +187,7 @@ class ScrapyProxyH2Agent(H2Agent): self._proxy_uri = proxy_uri def get_endpoint(self, uri: URI) -> HostnameEndpoint: - return self.endpoint_factory.endpointForURI(self._proxy_uri) + return self.endpoint_factory.endpointForURI(self._proxy_uri) # type: ignore[no-any-return] def get_key(self, uri: URI) -> ConnectionKeyT: """We use the proxy uri instead of uri obtained from request url""" diff --git a/scrapy/core/scheduler.py b/scrapy/core/scheduler.py index 78329460e..7217da942 100644 --- a/scrapy/core/scheduler.py +++ b/scrapy/core/scheduler.py @@ -4,7 +4,7 @@ import json import logging from abc import abstractmethod from pathlib import Path -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast # working around https://github.com/sphinx-doc/sphinx/issues/10400 from twisted.internet.defer import Deferred # noqa: TC002 @@ -334,7 +334,7 @@ class Scheduler(BaseScheduler): cls = crawler.settings[f"SCHEDULER_START_{queue}_QUEUE"] if not cls: return None - return load_object(cls) + return cast("type[BaseQueue]", load_object(cls)) def has_pending_requests(self) -> bool: return len(self) > 0 diff --git a/scrapy/http/cookies.py b/scrapy/http/cookies.py index 09286606d..599f20947 100644 --- a/scrapy/http/cookies.py +++ b/scrapy/http/cookies.py @@ -74,7 +74,7 @@ class CookieJar: @property def _cookies(self) -> dict[str, dict[str, dict[str, Cookie]]]: - return self.jar._cookies # type: ignore[attr-defined] + return self.jar._cookies # type: ignore[attr-defined,no-any-return] def clear_session_cookies(self) -> None: return self.jar.clear_session_cookies() diff --git a/scrapy/pipelines/files.py b/scrapy/pipelines/files.py index ffed96446..0066fd38f 100644 --- a/scrapy/pipelines/files.py +++ b/scrapy/pipelines/files.py @@ -323,9 +323,10 @@ class GCSFilesStore: ) -> Deferred[StatInfo]: blob_path = self._get_blob_path(path) - return deferred_from_coro( + d: Deferred[Any] = deferred_from_coro( run_in_thread(self.bucket.get_blob, blob_path) - ).addCallback(self._onsuccess) + ) + return d.addCallback(self._onsuccess) def _get_content_type(self, headers: dict[str, str] | None) -> str: if headers and "Content-Type" in headers: diff --git a/scrapy/pipelines/media.py b/scrapy/pipelines/media.py index 1043da332..5b5d2dcb2 100644 --- a/scrapy/pipelines/media.py +++ b/scrapy/pipelines/media.py @@ -29,9 +29,9 @@ from scrapy.utils.misc import arg_to_iter from scrapy.utils.python import global_object_name if TYPE_CHECKING: - # typing.Self requires Python 3.11 - from collections.abc import Awaitable + from collections.abc import Awaitable, Callable + # typing.Self requires Python 3.11 from typing_extensions import Self from scrapy import Spider @@ -153,7 +153,7 @@ class MediaPipeline(ABC): ) -> FileInfo: fp = self._fingerprinter.fingerprint(request) - eb = request.errback + eb: Callable[[Failure], FileInfo] | None = request.errback request.callback = NO_CALLBACK request.errback = None diff --git a/scrapy/robotstxt.py b/scrapy/robotstxt.py index 18b622546..0c64ea5a5 100644 --- a/scrapy/robotstxt.py +++ b/scrapy/robotstxt.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging import sys from abc import ABCMeta, abstractmethod -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast from urllib.robotparser import RobotFileParser from protego import Protego @@ -103,7 +103,7 @@ class RerpRobotParser(RobotParser): def allowed(self, url: str | bytes, user_agent: str | bytes) -> bool: user_agent = to_unicode(user_agent) url = to_unicode(url) - return self.rp.is_allowed(user_agent, url) + return cast("bool", self.rp.is_allowed(user_agent, url)) class ProtegoRobotParser(RobotParser): diff --git a/scrapy/utils/benchserver.py b/scrapy/utils/benchserver.py index c4c96ef00..403cd54a8 100644 --- a/scrapy/utils/benchserver.py +++ b/scrapy/utils/benchserver.py @@ -30,7 +30,7 @@ class Root(Resource): def _getarg( request: Request, name: bytes, default: Any = None, type_: type = str ) -> Any: - return type_(request.args[name][0]) if name in request.args else default # type: ignore[index,operator] + return type_(request.args[name][0]) if name in request.args else default if __name__ == "__main__": diff --git a/scrapy/utils/datatypes.py b/scrapy/utils/datatypes.py index 64d2cde1b..146e3ae56 100644 --- a/scrapy/utils/datatypes.py +++ b/scrapy/utils/datatypes.py @@ -13,7 +13,7 @@ import warnings import weakref from collections import OrderedDict from collections.abc import Mapping -from typing import TYPE_CHECKING, Any, AnyStr, TypeVar +from typing import TYPE_CHECKING, Any, AnyStr, TypeVar, cast from scrapy.exceptions import ScrapyDeprecationWarning @@ -181,7 +181,7 @@ class LocalWeakReferencedCache(weakref.WeakKeyDictionary): def __getitem__(self, key: _KT) -> _VT | None: try: - return super().__getitem__(key) + return cast("_VT", super().__getitem__(key)) except (TypeError, KeyError): return None # key is either not weak-referenceable or not cached diff --git a/scrapy/utils/decorators.py b/scrapy/utils/decorators.py index aea2557d2..a5bb6fa24 100644 --- a/scrapy/utils/decorators.py +++ b/scrapy/utils/decorators.py @@ -3,7 +3,7 @@ from __future__ import annotations import inspect import warnings from functools import wraps -from typing import TYPE_CHECKING, Any, ParamSpec, TypeVar, overload +from typing import TYPE_CHECKING, Any, ParamSpec, TypeVar, cast, overload from twisted.internet.defer import Deferred, maybeDeferred @@ -115,7 +115,7 @@ def _warn_spider_arg( @wraps(func) async def async_inner(*args: _P.args, **kwargs: _P.kwargs) -> _T: check_args(*args, **kwargs) - return await func(*args, **kwargs) + return cast("_T", await func(*args, **kwargs)) return async_inner diff --git a/scrapy/utils/deprecate.py b/scrapy/utils/deprecate.py index da1838030..359f819d7 100644 --- a/scrapy/utils/deprecate.py +++ b/scrapy/utils/deprecate.py @@ -4,7 +4,7 @@ from __future__ import annotations import inspect import warnings -from typing import TYPE_CHECKING, Any, overload +from typing import TYPE_CHECKING, Any, cast, overload from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.utils.python import get_func_args_dict @@ -68,7 +68,7 @@ def create_deprecated_class( def __new__( # pylint: disable=bad-classmethod-argument metacls, name: str, bases: tuple[type, ...], clsdict_: dict[str, Any] ) -> type: - cls = super().__new__(metacls, name, bases, clsdict_) + cls: type = super().__new__(metacls, name, bases, clsdict_) if metacls.deprecated_class is None: metacls.deprecated_class = cls return cls @@ -100,7 +100,7 @@ def create_deprecated_class( # is the deprecated class itself - subclasses of the # deprecated class should not use custom `__subclasscheck__` # method. - return super().__subclasscheck__(sub) + return cast("bool", super().__subclasscheck__(sub)) if not inspect.isclass(sub): raise TypeError("issubclass() arg 1 must be a class") diff --git a/scrapy/utils/misc.py b/scrapy/utils/misc.py index 757756e65..3baa02e2b 100644 --- a/scrapy/utils/misc.py +++ b/scrapy/utils/misc.py @@ -13,7 +13,7 @@ from contextlib import contextmanager from functools import partial from importlib import import_module from pkgutil import iter_modules -from typing import IO, TYPE_CHECKING, Any, ParamSpec, Protocol, TypeVar, overload +from typing import IO, TYPE_CHECKING, Any, ParamSpec, Protocol, TypeVar, cast, overload from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.item import Item @@ -51,7 +51,7 @@ def arg_to_iter(arg: Any) -> Iterable[Any]: if arg is None: return () if not isinstance(arg, _ITERABLE_SINGLE_VALUES) and hasattr(arg, "__iter__"): - return arg + return cast("Iterable[Any]", arg) return [arg] diff --git a/scrapy/utils/reactor.py b/scrapy/utils/reactor.py index b41b8af38..ddebd8ba2 100644 --- a/scrapy/utils/reactor.py +++ b/scrapy/utils/reactor.py @@ -33,12 +33,12 @@ def listen_tcp(portrange: list[int], host: str, factory: ServerFactory) -> Port: if len(portrange) > 2: raise ValueError(f"invalid portrange: {portrange}") if not portrange: - return reactor.listenTCP(0, factory, interface=host) + return reactor.listenTCP(0, factory, interface=host) # type: ignore[no-any-return] if len(portrange) == 1: - return reactor.listenTCP(portrange[0], factory, interface=host) + return reactor.listenTCP(portrange[0], factory, interface=host) # type: ignore[no-any-return] for x in range(portrange[0], portrange[1] + 1): try: - return reactor.listenTCP(x, factory, interface=host) + return reactor.listenTCP(x, factory, interface=host) # type: ignore[no-any-return] except error.CannotListenError: if x == portrange[1]: raise diff --git a/scrapy/utils/signal.py b/scrapy/utils/signal.py index 997e8eba8..d9a72273a 100644 --- a/scrapy/utils/signal.py +++ b/scrapy/utils/signal.py @@ -7,6 +7,7 @@ import logging import warnings from collections.abc import Awaitable, Callable, Generator, Sequence from typing import Any as TypingAny +from typing import cast from pydispatch.dispatcher import ( Anonymous, @@ -185,7 +186,7 @@ async def _send_catch_log_asyncio( handlers: list[Awaitable[TypingAny]] = [] for receiver in liveReceivers(getAllReceivers(sender, signal)): - async def handler(receiver: Callable) -> TypingAny: + async def handler(receiver: Callable) -> tuple[Callable, TypingAny]: result: TypingAny try: result = await ensure_awaitable( @@ -208,7 +209,10 @@ async def _send_catch_log_asyncio( handlers.append(handler(receiver)) - return await asyncio.gather(*handlers, return_exceptions=True) + return cast( + "list[tuple[TypingAny, TypingAny]]", + await asyncio.gather(*handlers, return_exceptions=True), + ) def disconnect_all(signal: TypingAny = Any, sender: TypingAny = Any) -> None: diff --git a/scrapy/utils/spider.py b/scrapy/utils/spider.py index 75d6c9bb0..9f43df1ce 100644 --- a/scrapy/utils/spider.py +++ b/scrapy/utils/spider.py @@ -41,11 +41,10 @@ def iterate_spider_output( ) -> Iterable[Any] | AsyncGenerator[_T] | Deferred[_T]: if inspect.isasyncgen(result): return result + d: Deferred[_T] = deferred_from_coro(result) if inspect.iscoroutine(result): - d = deferred_from_coro(result) - d.addCallback(iterate_spider_output) - return d - return arg_to_iter(deferred_from_coro(result)) + return d.addCallback(iterate_spider_output) + return arg_to_iter(d) def iter_spider_classes(module: ModuleType) -> Iterable[type[Spider]]: diff --git a/tests/mockserver/http_resources.py b/tests/mockserver/http_resources.py index 7f52f0092..98ac6cf6a 100644 --- a/tests/mockserver/http_resources.py +++ b/tests/mockserver/http_resources.py @@ -239,7 +239,7 @@ class ArbitraryLengthPayloadResource(LeafResource): class NoMetaRefreshRedirect(Redirect): def render(self, request: server.Request) -> bytes: - content = Redirect.render(self, request) + content: bytes = Redirect.render(self, request) return content.replace( b'http-equiv="refresh"', b'http-no-equiv="do-not-refresh-me"' ) diff --git a/tests/mockserver/utils.py b/tests/mockserver/utils.py index f900ee693..188eef61d 100644 --- a/tests/mockserver/utils.py +++ b/tests/mockserver/utils.py @@ -1,22 +1,26 @@ from __future__ import annotations from pathlib import Path +from typing import TYPE_CHECKING from cryptography.hazmat.primitives.serialization import load_pem_private_key from cryptography.x509 import load_pem_x509_certificate from OpenSSL import SSL from OpenSSL.crypto import FILETYPE_PEM, load_certificate, load_privatekey -from twisted.internet.ssl import CertificateOptions, ContextFactory +from twisted.internet.ssl import CertificateOptions from scrapy.utils._deps_compat import PYOPENSSL_WANTS_X509_PKEY from scrapy.utils.python import to_bytes +if TYPE_CHECKING: + from twisted.internet.interfaces import IOpenSSLContextFactory + def ssl_context_factory( keyfile: str = "keys/localhost.key", certfile: str = "keys/localhost.crt", cipher_string: str | None = None, -) -> ContextFactory: +) -> IOpenSSLContextFactory: keyfile_path = Path(__file__).parent.parent / keyfile certfile_path = Path(__file__).parent.parent / certfile @@ -27,7 +31,7 @@ def ssl_context_factory( cert = load_certificate(FILETYPE_PEM, certfile_path.read_bytes()) # type: ignore[assignment] key = load_privatekey(FILETYPE_PEM, keyfile_path.read_bytes()) # type: ignore[assignment] - factory = CertificateOptions( + factory: CertificateOptions = CertificateOptions( privateKey=key, certificate=cert, ) diff --git a/tests/test_downloadermiddleware_robotstxt.py b/tests/test_downloadermiddleware_robotstxt.py index 15c68779d..082fc743e 100644 --- a/tests/test_downloadermiddleware_robotstxt.py +++ b/tests/test_downloadermiddleware_robotstxt.py @@ -23,8 +23,8 @@ if TYPE_CHECKING: class TestRobotsTxtMiddleware: - def setup_method(self): - self.crawler = mock.MagicMock() + def setup_method(self) -> None: + self.crawler: mock.MagicMock = mock.MagicMock() self.crawler.settings = Settings() self.crawler.engine.download_async = mock.AsyncMock() diff --git a/tests/test_http2_client_protocol.py b/tests/test_http2_client_protocol.py index 65da89472..6072e2f6d 100644 --- a/tests/test_http2_client_protocol.py +++ b/tests/test_http2_client_protocol.py @@ -234,7 +234,7 @@ class TestHttps2ClientProtocol: pem = self.key_file.read_text( encoding="utf-8" ) + self.certificate_file.read_text(encoding="utf-8") - return PrivateCertificate.loadPEM(pem) + return PrivateCertificate.loadPEM(pem) # type: ignore[no-any-return] @async_yield_fixture # type: ignore[untyped-decorator] async def client( diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index 7fec479f3..eb88baf01 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -4,7 +4,7 @@ import warnings from abc import ABC, abstractmethod from collections import deque from contextlib import AbstractAsyncContextManager, asynccontextmanager -from typing import TYPE_CHECKING, Any, NamedTuple +from typing import TYPE_CHECKING, Any, NamedTuple, cast from unittest.mock import Mock import pytest @@ -29,9 +29,9 @@ if TYPE_CHECKING: class MemoryScheduler(BaseScheduler): paused = False - def __init__(self, *args, **kwargs): + def __init__(self, *args: Any, **kwargs: Any): super().__init__(*args, **kwargs) - self.queue = deque( + self.queue: deque[Request] = deque( Request(value) if isinstance(value, str) else value for value in getattr(self, "queue", []) ) @@ -68,7 +68,7 @@ class MockDownloader: def get_slot_key(self, request: Request) -> str: if Downloader.DOWNLOAD_SLOT in request.meta: - return request.meta[Downloader.DOWNLOAD_SLOT] + return cast("str", request.meta[Downloader.DOWNLOAD_SLOT]) return urlparse_cached(request).hostname or "" diff --git a/tests/test_spidermiddleware.py b/tests/test_spidermiddleware.py index 9651e3f8b..bfa60ac9c 100644 --- a/tests/test_spidermiddleware.py +++ b/tests/test_spidermiddleware.py @@ -2,7 +2,7 @@ from __future__ import annotations from collections.abc import AsyncIterator, Iterable from inspect import isasyncgen -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast from unittest import mock import pytest @@ -27,7 +27,7 @@ if TYPE_CHECKING: class TestSpiderMiddleware: - def setup_method(self): + def setup_method(self) -> None: self.request = Request("http://example.com/index.html") self.response = Response(self.request.url, request=self.request) self.crawler = get_crawler(Spider, {"SPIDER_MIDDLEWARES_BASE": {}}) @@ -39,11 +39,10 @@ class TestSpiderMiddleware: Raise exception in case of failure. """ - def scrape_func( + async def scrape_func( response: Response | Failure, request: Request - ) -> defer.Deferred[Iterable[Any]]: - it = mock.MagicMock() - return defer.succeed(it) + ) -> Iterable[Any]: + return mock.MagicMock() return await self.mwman.scrape_response_async( scrape_func, self.response, self.request @@ -141,7 +140,7 @@ class TestBaseAsyncSpiderMiddleware(TestSpiderMiddleware): async def _scrape_func( self, response: Response | Failure, request: Request ) -> Iterable[Any] | AsyncIterator[Any]: - return self._callback() + return cast("Iterable[Any] | AsyncIterator[Any]", self._callback()) async def _get_middleware_result( self, *mw_classes: type[Any], start_index: int | None = None diff --git a/tests/utils/decorators.py b/tests/utils/decorators.py index 4750158e5..0e8988a0a 100644 --- a/tests/utils/decorators.py +++ b/tests/utils/decorators.py @@ -35,7 +35,8 @@ def inline_callbacks_test( async def wrapper_coro(*args: _P.args, **kwargs: _P.kwargs) -> None: await deferred_to_future(inlineCallbacks(f)(*args, **kwargs)) - return wrapper_coro + # Likely https://github.com/python/mypy/issues/17171 + return wrapper_coro # type: ignore[no-any-return] @wraps(f) @inlineCallbacks diff --git a/tests_typing/test_spiders.mypy-testing b/tests_typing/test_spiders.mypy-testing index 162e31d0c..dfdd2ba4d 100644 --- a/tests_typing/test_spiders.mypy-testing +++ b/tests_typing/test_spiders.mypy-testing @@ -59,7 +59,7 @@ def test_spider_parse_override_no_kwargs() -> None: @pytest.mark.mypy_testing def test_spider_parse_override_specific_kwargs() -> None: spider = SpecificKwargsSpider() - reveal_type(spider.parse) # R: def (response: scrapy.http.response.Response, page: builtins.int) -> Any + reveal_type(spider.parse) # R: def (response: scrapy.http.response.Response, page: int) -> Any @pytest.mark.mypy_testing diff --git a/tox.ini b/tox.ini index 07583a11e..a3b0cd91d 100644 --- a/tox.ini +++ b/tox.ini @@ -44,26 +44,28 @@ commands = [testenv:typing] basepython = python3.10 deps = - mypy==1.19.1 + mypy==1.20.2 typing-extensions==4.15.0 - Pillow==12.1.1 + Pillow==12.2.0 Protego==0.6.0 - attrs==25.4.0 - boto3-stubs[s3]==1.42.59 + Twisted==25.5.0 + attrs==26.1.0 + boto3-stubs[s3]==1.43.2 botocore-stubs==1.42.41 h2==4.3.0 httpx==0.28.1 itemadapter==0.13.1 ptpython==3.0.32 - ipython - pyOpenSSL==25.3.0 - pytest==9.0.2 - types-Pygments==2.19.0.20251121 - types-defusedxml==0.7.0.20250822 + # newer ones require newer Python + ipython==8.39.0 + pyOpenSSL==26.1.0 + pytest==9.0.3 + types-Pygments==2.20.0.20260408 + types-defusedxml==0.7.0.20260504 types-lxml==2026.2.16 - types-pexpect==4.9.0.20260127 + types-pexpect==4.9.0.20260408 uvloop==0.22.1 - w3lib==2.4.0 + w3lib==2.4.1 zstandard==0.25.0 commands = mypy {posargs:scrapy tests} From 5223dbe3fdf801a8a3e7877e271f54519ff73f0a Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Wed, 6 May 2026 13:42:24 +0500 Subject: [PATCH 146/248] Pin pyOpenSSL to the versions allowing mutable contexts. (#7494) --- pyproject.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 11d36d0ac..f13e67020 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,8 @@ dependencies = [ "packaging", "parsel>=1.5.0", "protego>=0.1.15", - "pyOpenSSL>=22.0.0", + # pyOpenSSL pinned until Twisted with immutable contexts is released and supported in Scrapy + "pyOpenSSL>=22.0.0,<26.2.0", "queuelib>=1.4.2", "service_identity>=18.1.0", "tldextract", From 7f15ca92fc5c486d3ba121dfafa5c061ac63eb71 Mon Sep 17 00:00:00 2001 From: Adrian Date: Tue, 12 May 2026 09:49:23 +0200 Subject: [PATCH 147/248] =?UTF-8?q?sphinx-scrapy:=200.8.5=20=E2=86=92=200.?= =?UTF-8?q?8.6=20(#7507)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .pre-commit-config.yaml | 2 +- docs/requirements.in | 2 +- docs/requirements.txt | 2 +- tox.ini | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index bbf24dd63..6b9ef3c04 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -27,6 +27,6 @@ repos: hooks: - id: sphinx-lint - repo: https://github.com/scrapy/sphinx-scrapy - rev: 0.8.5 + rev: 0.8.6 hooks: - id: sphinx-scrapy diff --git a/docs/requirements.in b/docs/requirements.in index 6320721cd..140791641 100644 --- a/docs/requirements.in +++ b/docs/requirements.in @@ -5,4 +5,4 @@ sphinx sphinx-notfound-page sphinx-rtd-theme sphinx-rtd-dark-mode -sphinx-scrapy @ git+https://github.com/scrapy/sphinx-scrapy.git@0.8.5 +sphinx-scrapy @ git+https://github.com/scrapy/sphinx-scrapy.git@0.8.6 diff --git a/docs/requirements.txt b/docs/requirements.txt index 53cb82638..9c93dacd0 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -153,7 +153,7 @@ sphinx-rtd-theme==3.1.0 # via # -r docs/requirements.in # sphinx-rtd-dark-mode -sphinx-scrapy @ git+https://github.com/scrapy/sphinx-scrapy.git@96826815002921f27a2e369b12c0c25af7a1f8b2 +sphinx-scrapy @ git+https://github.com/scrapy/sphinx-scrapy.git@b1d55db4d16a5425fc68576d63519bbfe26dd9c0 # via -r docs/requirements.in sphinx-sitemap==2.9.0 # via sphinx-scrapy diff --git a/tox.ini b/tox.ini index a3b0cd91d..e0e95c194 100644 --- a/tox.ini +++ b/tox.ini @@ -5,7 +5,7 @@ [tox] requires = - sphinx-scrapy[tox] @ git+https://github.com/scrapy/sphinx-scrapy.git@0.8.5 + sphinx-scrapy[tox] @ git+https://github.com/scrapy/sphinx-scrapy.git@0.8.6 envlist = pre-commit,pylint,typing,py,docs minversion = 1.7.0 From 7fc84d372aee8def89f427a5190bb837ff3bfa55 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Tue, 12 May 2026 17:45:54 +0500 Subject: [PATCH 148/248] Cleanup/clarifications of proxy support and improvements for proxy tests (#7496) * Run mitmproxy-based tests for every handler and improve them. * Fixes for H2DownloadHandler. * Revert "Fixes for H2DownloadHandler." This reverts commit bcdbd097cde54048a4b85e325937ef747a09e68a. * Add TestHttpsProxy for HTTP11DownloadHandler. * Use the configured context factory in ScrapyProxyAgent. * Reword. * Reword. * pragma: no cover for H2DownloadHandler proxy code. * Raise an exception for HTTPS proxies instead of using HTTP. * Remove non-working proxy support and explicitly forbid HTTP. * pragma: no cover * Add a docs note about HTTPS proxies. * Rename ScrapyProxyAgent. --- conftest.py | 27 ++++ docs/news.rst | 11 ++ docs/topics/download-handlers.rst | 2 + docs/topics/downloader-middleware.rst | 11 ++ scrapy/core/downloader/handlers/http11.py | 12 +- scrapy/core/downloader/handlers/http2.py | 34 ++--- scrapy/core/http2/agent.py | 27 ---- tests/mockserver/mitm_proxy.py | 64 +++++++++ tests/mockserver/mitm_proxy_addon.py | 5 + tests/test_downloader_handler_httpx.py | 27 ++-- .../test_downloader_handler_twisted_http11.py | 33 +++-- .../test_downloader_handler_twisted_http2.py | 51 +++---- tests/test_downloader_handlers_http_base.py | 123 +++++++++++++++++ tests/test_proxy_connect.py | 125 ------------------ 14 files changed, 323 insertions(+), 229 deletions(-) create mode 100644 tests/mockserver/mitm_proxy.py create mode 100644 tests/mockserver/mitm_proxy_addon.py delete mode 100644 tests/test_proxy_connect.py diff --git a/conftest.py b/conftest.py index 4f8d32e1b..fcbf59426 100644 --- a/conftest.py +++ b/conftest.py @@ -11,6 +11,7 @@ from scrapy.utils.reactor import set_asyncio_event_loop_policy from scrapy.utils.reactorless import install_reactor_import_hook from tests.keys import generate_keys from tests.mockserver.http import MockServer +from tests.mockserver.mitm_proxy import MitmProxy if TYPE_CHECKING: from collections.abc import Generator @@ -72,6 +73,32 @@ def mockserver() -> Generator[MockServer]: yield mockserver +@pytest.fixture # function scope because it modifies os.environ +def mitm_proxy_server(monkeypatch: pytest.MonkeyPatch) -> Generator[MitmProxy]: + proxy = MitmProxy() + url = proxy.start() + monkeypatch.setenv("http_proxy", url) + monkeypatch.setenv("https_proxy", url) + + try: + yield proxy + finally: + proxy.stop() + + +@pytest.fixture # function scope because it modifies os.environ +def mitm_proxy_server_https(monkeypatch: pytest.MonkeyPatch) -> Generator[MitmProxy]: + proxy = MitmProxy() + url = proxy.start().replace("http://", "https://") + monkeypatch.setenv("http_proxy", url) + monkeypatch.setenv("https_proxy", url) + + try: + yield proxy + finally: + proxy.stop() + + @pytest.fixture(scope="session") def reactor_pytest(request) -> str: return request.config.getoption("--reactor") diff --git a/docs/news.rst b/docs/news.rst index 676e0cbbf..7f3952eaf 100644 --- a/docs/news.rst +++ b/docs/news.rst @@ -3,6 +3,17 @@ Release notes ============= +Scrapy VERSION (unreleased) +--------------------------- + +Backward-incompatible changes +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +- ``scrapy.core.downloader.handlers.http11.ScrapyProxyAgent`` has been made + private as it's an implementation detail of + :class:`~scrapy.core.downloader.handlers.http11.HTTP11DownloadHandler`. + (:issue:`7496`) + .. _release-2.15.2: Scrapy 2.15.2 (2026-04-28) diff --git a/docs/topics/download-handlers.rst b/docs/topics/download-handlers.rst index 96cf46c35..1e95864c6 100644 --- a/docs/topics/download-handlers.rst +++ b/docs/topics/download-handlers.rst @@ -206,6 +206,8 @@ If you want to use this handler you need to replace the default one for the Known limitations of the HTTP/2 implementation in this handler include: + - No support for proxies. + - No support for HTTP/2 Cleartext (h2c), since no major browser supports HTTP/2 unencrypted (refer `http2 faq`_). diff --git a/docs/topics/downloader-middleware.rst b/docs/topics/downloader-middleware.rst index 75e48be41..873c32c13 100644 --- a/docs/topics/downloader-middleware.rst +++ b/docs/topics/downloader-middleware.rst @@ -745,8 +745,19 @@ HttpProxyMiddleware Handling of this meta key needs to be implemented inside the :ref:`download handler `, so it's not guaranteed to be supported by all 3rd-party handlers. It's currently unsupported by + :class:`~scrapy.core.downloader.handlers.http2.H2DownloadHandler` and :class:`~scrapy.core.downloader.handlers._httpx.HttpxDownloadHandler`. +.. note:: + + Usually a proxy URL uses the ``http://`` scheme. More rarely, it uses the + ``https://`` one. While both kinds of proxy URLs can be used with both HTTP + and HTTPS destination URLs, the specifics of the network exchange are + different for all 4 cases and it's possible that HTTPS proxies are fully or + partially unsupported by a given download handler. Currently, + :class:`~scrapy.core.downloader.handlers.http11.HTTP11DownloadHandler` + supports HTTPS proxies only for HTTP destinations. + HttpProxyMiddleware settings ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/scrapy/core/downloader/handlers/http11.py b/scrapy/core/downloader/handlers/http11.py index 975078803..1127c533f 100644 --- a/scrapy/core/downloader/handlers/http11.py +++ b/scrapy/core/downloader/handlers/http11.py @@ -338,17 +338,19 @@ class TunnelingAgent(Agent): ) -class ScrapyProxyAgent(Agent): +class _ScrapyProxyAgent(Agent): def __init__( self, reactor: ReactorBase, proxyURI: bytes, + contextFactory: IPolicyForHTTPS, connectTimeout: float | None = None, bindAddress: tuple[str, int] | None = None, pool: HTTPConnectionPool | None = None, ): super().__init__( # type: ignore[no-untyped-call] reactor=reactor, + contextFactory=contextFactory, connectTimeout=connectTimeout, bindAddress=bindAddress, pool=pool, @@ -380,7 +382,6 @@ class ScrapyProxyAgent(Agent): class ScrapyAgent: _Agent = Agent - _ProxyAgent = ScrapyProxyAgent _TunnelingAgent = TunnelingAgent def __init__( @@ -421,6 +422,10 @@ class ScrapyAgent: if not proxy_port: proxy_port = 443 if proxy_parsed.scheme == "https" else 80 if urlparse_cached(request).scheme == "https": + if proxy_parsed.scheme == "https": # pragma: no cover + raise NotImplementedError( + "HTTPS proxies for HTTPS destinations are not supported" + ) assert proxy_host is not None proxyAuth = request.headers.get(b"Proxy-Authorization", None) proxyConf = (proxy_host, proxy_port, proxyAuth) @@ -432,9 +437,10 @@ class ScrapyAgent: bindAddress=bindaddress, pool=self._pool, ) - return self._ProxyAgent( + return _ScrapyProxyAgent( reactor=reactor, proxyURI=to_bytes(proxy, encoding="ascii"), + contextFactory=self._contextFactory, connectTimeout=timeout, bindAddress=bindaddress, pool=self._pool, diff --git a/scrapy/core/downloader/handlers/http2.py b/scrapy/core/downloader/handlers/http2.py index a4f786363..b28bcfd89 100644 --- a/scrapy/core/downloader/handlers/http2.py +++ b/scrapy/core/downloader/handlers/http2.py @@ -4,19 +4,20 @@ from time import monotonic from typing import TYPE_CHECKING from urllib.parse import urldefrag -from twisted.web.client import URI - from scrapy.core.downloader.contextfactory import _load_context_factory_from_settings from scrapy.core.downloader.handlers.base import BaseDownloadHandler -from scrapy.core.http2.agent import H2Agent, H2ConnectionPool, ScrapyProxyH2Agent -from scrapy.exceptions import DownloadTimeoutError, NotConfigured +from scrapy.core.http2.agent import H2Agent, H2ConnectionPool +from scrapy.exceptions import ( + DownloadTimeoutError, + NotConfigured, + UnsupportedURLSchemeError, +) from scrapy.utils._download_handlers import ( normalize_bind_address, wrap_twisted_exceptions, ) from scrapy.utils.defer import maybe_deferred_to_future from scrapy.utils.httpobj import urlparse_cached -from scrapy.utils.python import to_bytes if TYPE_CHECKING: from twisted.internet.base import DelayedCall @@ -44,6 +45,10 @@ class H2DownloadHandler(BaseDownloadHandler): self._bind_address = crawler.settings.get("DOWNLOAD_BIND_ADDRESS") async def download_request(self, request: Request) -> Response: + if urlparse_cached(request).scheme == "http": # pragma: no cover + raise UnsupportedURLSchemeError( + f"{type(self).__name__} doesn't support plain HTTP." + ) agent = ScrapyH2Agent( context_factory=self._context_factory, pool=self._pool, @@ -62,7 +67,6 @@ class H2DownloadHandler(BaseDownloadHandler): class ScrapyH2Agent: _Agent = H2Agent - _ProxyAgent = ScrapyProxyH2Agent def __init__( self, @@ -81,24 +85,10 @@ class ScrapyH2Agent: def _get_agent(self, request: Request, timeout: float | None) -> H2Agent: from twisted.internet import reactor + if request.meta.get("proxy"): # pragma: no cover + raise NotImplementedError(f"{type(self).__name__} doesn't support proxies.") bind_address = request.meta.get("bindaddress") or self._bind_address bind_address = normalize_bind_address(bind_address) - proxy = request.meta.get("proxy") - if proxy: - if urlparse_cached(request).scheme == "https": - # ToDo - raise NotImplementedError( - "Tunneling via CONNECT method using HTTP/2.0 is not yet supported" - ) - return self._ProxyAgent( - reactor=reactor, - context_factory=self._context_factory, - proxy_uri=URI.fromBytes(to_bytes(proxy, encoding="ascii")), - connect_timeout=timeout, - bind_address=bind_address, - pool=self._pool, - ) - return self._Agent( reactor=reactor, context_factory=self._context_factory, diff --git a/scrapy/core/http2/agent.py b/scrapy/core/http2/agent.py index 822dffc4b..7137a0f2b 100644 --- a/scrapy/core/http2/agent.py +++ b/scrapy/core/http2/agent.py @@ -165,30 +165,3 @@ class H2Agent: lambda conn: conn.request(request, spider) ) return d2 - - -class ScrapyProxyH2Agent(H2Agent): - def __init__( - self, - reactor: ReactorBase, - proxy_uri: URI, - pool: H2ConnectionPool, - context_factory: BrowserLikePolicyForHTTPS = BrowserLikePolicyForHTTPS(), # noqa: B008 - connect_timeout: float | None = None, - bind_address: tuple[str, int] | None = None, - ) -> None: - super().__init__( - reactor=reactor, - pool=pool, - context_factory=context_factory, - connect_timeout=connect_timeout, - bind_address=bind_address, - ) - self._proxy_uri = proxy_uri - - def get_endpoint(self, uri: URI) -> HostnameEndpoint: - return self.endpoint_factory.endpointForURI(self._proxy_uri) # type: ignore[no-any-return] - - def get_key(self, uri: URI) -> ConnectionKeyT: - """We use the proxy uri instead of uri obtained from request url""" - return b"http-proxy", self._proxy_uri.host, self._proxy_uri.port diff --git a/tests/mockserver/mitm_proxy.py b/tests/mockserver/mitm_proxy.py new file mode 100644 index 000000000..1ec5a79b5 --- /dev/null +++ b/tests/mockserver/mitm_proxy.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +import re +import sys +from pathlib import Path +from subprocess import PIPE, Popen +from urllib.parse import urlsplit, urlunsplit + + +class MitmProxy: + auth_user = "scrapy" + auth_pass = "scrapy" + + def start(self) -> str: + script = """ +import sys +from mitmproxy.tools.main import mitmdump +sys.argv[0] = "mitmdump" +sys.exit(mitmdump()) + """ + cert_path = Path(__file__).parent.parent.resolve() / "keys" + args = [ + "--listen-host", + "127.0.0.1", + "--listen-port", + "0", + "--proxyauth", + f"{self.auth_user}:{self.auth_pass}", + "--set", + f"confdir={cert_path}", + "--ssl-insecure", + "-s", + str(Path(__file__).with_name("mitm_proxy_addon.py")), + ] + self.proc: Popen[str] = Popen( + [ + sys.executable, + "-u", + "-c", + script, + *args, + ], + stdout=PIPE, + text=True, + ) + assert self.proc.stdout is not None + line = "" + for line in self.proc.stdout: + m = re.search(r"listening at (?:http://)?([^:]+:\d+)", line) + if m: + host_port = m.group(1) + return f"http://{self.auth_user}:{self.auth_pass}@{host_port}" + self.stop() + raise RuntimeError(f"Failed to parse mitmdump output: {line}") + + def stop(self) -> None: + self.proc.kill() + self.proc.communicate() + + +def wrong_credentials(proxy_url: str) -> str: + bad_auth_proxy = list(urlsplit(proxy_url)) + bad_auth_proxy[1] = bad_auth_proxy[1].replace("scrapy:scrapy@", "wrong:wronger@") + return urlunsplit(bad_auth_proxy) diff --git a/tests/mockserver/mitm_proxy_addon.py b/tests/mockserver/mitm_proxy_addon.py new file mode 100644 index 000000000..04ba2e4a1 --- /dev/null +++ b/tests/mockserver/mitm_proxy_addon.py @@ -0,0 +1,5 @@ +def response(flow) -> None: + # add custom headers to be able to check that the request went through the proxy + flow.response.headers["X-Via-Mitmproxy"] = "1" + if flow.client_conn.tls_established: + flow.response.headers["X-Via-Mitmproxy-TLS"] = "1" diff --git a/tests/test_downloader_handler_httpx.py b/tests/test_downloader_handler_httpx.py index a0e3fccb3..d4dfe8396 100644 --- a/tests/test_downloader_handler_httpx.py +++ b/tests/test_downloader_handler_httpx.py @@ -17,6 +17,7 @@ from tests.test_downloader_handlers_http_base import ( TestHttpsInvalidDNSPatternBase, TestHttpsWrongHostnameBase, TestHttpWithCrawlerBase, + TestMitmProxyBase, TestSimpleHttpsBase, ) from tests.utils.decorators import coroutine_test @@ -41,6 +42,15 @@ class HttpxDownloadHandlerMixin: return HttpxDownloadHandler + @property + def settings_dict(self) -> dict[str, Any] | None: + return { + "DOWNLOAD_HANDLERS": { + "http": "scrapy.core.downloader.handlers._httpx.HttpxDownloadHandler", + "https": "scrapy.core.downloader.handlers._httpx.HttpxDownloadHandler", + } + } + class TestHttp(HttpxDownloadHandlerMixin, TestHttpBase): handler_supports_bindaddress_meta = False @@ -108,15 +118,8 @@ class TestHttpsCustomCiphers(HttpxDownloadHandlerMixin, TestHttpsCustomCiphersBa pass -class TestHttpWithCrawler(TestHttpWithCrawlerBase): - @property - def settings_dict(self) -> dict[str, Any] | None: - return { - "DOWNLOAD_HANDLERS": { - "http": "scrapy.core.downloader.handlers._httpx.HttpxDownloadHandler", - "https": "scrapy.core.downloader.handlers._httpx.HttpxDownloadHandler", - } - } +class TestHttpWithCrawler(HttpxDownloadHandlerMixin, TestHttpWithCrawlerBase): + pass class TestHttpsWithCrawler(TestHttpWithCrawler): @@ -136,3 +139,9 @@ class TestHttpProxy(HttpxDownloadHandlerMixin, TestHttpProxyBase): @pytest.mark.skip(reason="Proxy support is not implemented yet") class TestHttpsProxy(HttpxDownloadHandlerMixin, TestHttpProxyBase): is_secure = True + + +@pytest.mark.skip(reason="Proxy support is not implemented yet") +@pytest.mark.requires_mitmproxy +class TestMitmProxy(HttpxDownloadHandlerMixin, TestMitmProxyBase): + pass diff --git a/tests/test_downloader_handler_twisted_http11.py b/tests/test_downloader_handler_twisted_http11.py index 01fb26d85..89cf58c96 100644 --- a/tests/test_downloader_handler_twisted_http11.py +++ b/tests/test_downloader_handler_twisted_http11.py @@ -19,6 +19,7 @@ from tests.test_downloader_handlers_http_base import ( TestHttpsInvalidDNSPatternBase, TestHttpsWrongHostnameBase, TestHttpWithCrawlerBase, + TestMitmProxyBase, TestSimpleHttpsBase, ) @@ -34,6 +35,15 @@ class HTTP11DownloadHandlerMixin: def download_handler_cls(self) -> type[DownloadHandlerProtocol]: return HTTP11DownloadHandler + @property + def settings_dict(self) -> dict[str, Any] | None: + return { + "DOWNLOAD_HANDLERS": { + "http": "scrapy.core.downloader.handlers.http11.HTTP11DownloadHandler", + "https": "scrapy.core.downloader.handlers.http11.HTTP11DownloadHandler", + } + } + def test_not_configured_without_reactor() -> None: crawler = Crawler(Spider, {"TWISTED_REACTOR_ENABLED": False}) @@ -71,15 +81,8 @@ class TestHttpsCustomCiphers(HTTP11DownloadHandlerMixin, TestHttpsCustomCiphersB pass -class TestHttpWithCrawler(TestHttpWithCrawlerBase): - @property - def settings_dict(self) -> dict[str, Any] | None: - return { - "DOWNLOAD_HANDLERS": { - "http": "scrapy.core.downloader.handlers.http11.HTTP11DownloadHandler", - "https": "scrapy.core.downloader.handlers.http11.HTTP11DownloadHandler", - } - } +class TestHttpWithCrawler(HTTP11DownloadHandlerMixin, TestHttpWithCrawlerBase): + pass class TestHttpsWithCrawler(TestHttpWithCrawler): @@ -88,3 +91,15 @@ class TestHttpsWithCrawler(TestHttpWithCrawler): class TestHttpProxy(HTTP11DownloadHandlerMixin, TestHttpProxyBase): pass + + +class TestHttpsProxy(HTTP11DownloadHandlerMixin, TestHttpProxyBase): + is_secure = True + # not implemented + handler_supports_tls_in_tls = False + + +@pytest.mark.requires_mitmproxy +class TestMitmProxy(HTTP11DownloadHandlerMixin, TestMitmProxyBase): + # not implemented + handler_supports_tls_in_tls = False diff --git a/tests/test_downloader_handler_twisted_http2.py b/tests/test_downloader_handler_twisted_http2.py index 3273a264e..60b276166 100644 --- a/tests/test_downloader_handler_twisted_http2.py +++ b/tests/test_downloader_handler_twisted_http2.py @@ -10,13 +10,8 @@ from twisted.web.http import H2_ENABLED from scrapy import Spider from scrapy.crawler import Crawler -from scrapy.exceptions import ( - DownloadFailedError, - NotConfigured, - UnsupportedURLSchemeError, -) +from scrapy.exceptions import DownloadFailedError, NotConfigured from scrapy.http import Request -from scrapy.utils.defer import maybe_deferred_to_future from tests.test_downloader_handlers_http_base import ( TestHttpProxyBase, TestHttpsBase, @@ -25,13 +20,13 @@ from tests.test_downloader_handlers_http_base import ( TestHttpsInvalidDNSPatternBase, TestHttpsWrongHostnameBase, TestHttpWithCrawlerBase, + TestMitmProxyBase, ) from tests.utils.decorators import coroutine_test if TYPE_CHECKING: from scrapy.core.downloader.handlers import DownloadHandlerProtocol from tests.mockserver.http import MockServer - from tests.mockserver.proxy_echo import ProxyEchoMockServer pytestmark = [ @@ -52,6 +47,15 @@ class H2DownloadHandlerMixin: return H2DownloadHandler + @property + def settings_dict(self) -> dict[str, Any] | None: + return { + "DOWNLOAD_HANDLERS": { + "http": None, + "https": "scrapy.core.downloader.handlers.http2.H2DownloadHandler", + } + } + def test_not_configured_without_reactor() -> None: from scrapy.core.downloader.handlers.http2 import H2DownloadHandler # noqa: PLC0415 @@ -167,16 +171,7 @@ class TestHttp2CustomCiphers(H2DownloadHandlerMixin, TestHttpsCustomCiphersBase) pass -class TestHttp2WithCrawler(TestHttpWithCrawlerBase): - @property - def settings_dict(self) -> dict[str, Any] | None: - return { - "DOWNLOAD_HANDLERS": { - "http": None, - "https": "scrapy.core.downloader.handlers.http2.H2DownloadHandler", - } - } - +class TestHttp2WithCrawler(H2DownloadHandlerMixin, TestHttpWithCrawlerBase): is_secure = True def test_bytes_received_stop_download_callback(self) -> None: # type: ignore[override] @@ -192,24 +187,12 @@ class TestHttp2WithCrawler(TestHttpWithCrawlerBase): pytest.skip("headers_received support is not implemented") +@pytest.mark.skip(reason="Proxy support is not implemented yet") class TestHttp2Proxy(H2DownloadHandlerMixin, TestHttpProxyBase): is_secure = True - expected_http_proxy_request_body = b"/" - @coroutine_test - async def test_download_with_proxy_https_timeout( - self, proxy_mockserver: ProxyEchoMockServer - ) -> None: - with pytest.raises(NotImplementedError): - await maybe_deferred_to_future( - super().test_download_with_proxy_https_timeout(proxy_mockserver) # type: ignore[arg-type] - ) - @coroutine_test - async def test_download_with_proxy_without_http_scheme( - self, proxy_mockserver: ProxyEchoMockServer - ) -> None: - with pytest.raises(UnsupportedURLSchemeError): - await maybe_deferred_to_future( - super().test_download_with_proxy_without_http_scheme(proxy_mockserver) # type: ignore[arg-type] - ) +@pytest.mark.skip(reason="Proxy support is not implemented yet") +@pytest.mark.requires_mitmproxy +class TestMitmProxy(H2DownloadHandlerMixin, TestMitmProxyBase): + pass diff --git a/tests/test_downloader_handlers_http_base.py b/tests/test_downloader_handlers_http_base.py index 2a781dace..138ddd944 100644 --- a/tests/test_downloader_handlers_http_base.py +++ b/tests/test_downloader_handlers_http_base.py @@ -4,6 +4,8 @@ from __future__ import annotations import gzip import json +import logging +import os import platform import re import sys @@ -36,6 +38,7 @@ from scrapy.utils.misc import build_from_crawler from scrapy.utils.spider import DefaultSpider from scrapy.utils.test import get_crawler from tests import NON_EXISTING_RESOLVABLE +from tests.mockserver.mitm_proxy import MitmProxy, wrong_credentials from tests.mockserver.proxy_echo import ProxyEchoMockServer from tests.mockserver.simple_https import SimpleMockServer from tests.spiders import ( @@ -43,6 +46,7 @@ from tests.spiders import ( BytesReceivedErrbackSpider, HeadersReceivedCallbackSpider, HeadersReceivedErrbackSpider, + SimpleSpider, SingleRequestSpider, ) from tests.utils.decorators import coroutine_test @@ -1072,6 +1076,8 @@ class TestHttpWithCrawlerBase(ABC): class TestHttpProxyBase(ABC): is_secure = False expected_http_proxy_request_body = b"http://example.com" + # whether the handler supports HTTPS proxies with HTTPS destinations + handler_supports_tls_in_tls: bool = True @property @abstractmethod @@ -1124,6 +1130,8 @@ class TestHttpProxyBase(ABC): ) -> None: if NON_EXISTING_RESOLVABLE: pytest.skip("Non-existing hosts are resolvable") + if self.is_secure and not self.handler_supports_tls_in_tls: + pytest.skip("HTTPS proxies for HTTPS destinations are not supported") http_proxy = proxy_mockserver.url("", is_secure=self.is_secure) domain = "https://no-such-domain.nosuch" request = Request(domain, meta={"proxy": http_proxy, "download_timeout": 0.2}) @@ -1143,3 +1151,118 @@ class TestHttpProxyBase(ABC): assert response.status == 200 assert response.url == request.url assert response.body == self.expected_http_proxy_request_body + + +class TestMitmProxyBase(ABC): + # whether the handler supports HTTPS proxies with HTTPS destinations + handler_supports_tls_in_tls: bool = True + + @property + @abstractmethod + def settings_dict(self) -> dict[str, Any] | None: + raise NotImplementedError + + @pytest.mark.parametrize( + "https_dest", [False, True], ids=["HTTP dest", "HTTPS dest"] + ) + @coroutine_test + async def test_http_proxy( + self, + caplog: pytest.LogCaptureFixture, + mockserver: MockServer, + mitm_proxy_server: MitmProxy, + https_dest: bool, + ) -> None: + """HTTP proxy, HTTP or HTTPS destination.""" + crawler = get_crawler(SingleRequestSpider, self.settings_dict) + with caplog.at_level(logging.DEBUG): + await crawler.crawl_async( + seed=mockserver.url("/status?n=200", is_secure=https_dest) + ) + assert isinstance(crawler.spider, SingleRequestSpider) + self._assert_got_response_code(200, caplog.text) + self._assert_headers(crawler.spider.meta["responses"][0].headers, https_dest) + + @pytest.mark.parametrize( + "https_dest", [False, True], ids=["HTTP dest", "HTTPS dest"] + ) + @coroutine_test + async def test_https_proxy( + self, + caplog: pytest.LogCaptureFixture, + mockserver: MockServer, + mitm_proxy_server_https: MitmProxy, + https_dest: bool, + ) -> None: + """HTTPS proxy, HTTP or HTTPS destination.""" + if https_dest and not self.handler_supports_tls_in_tls: + pytest.skip("HTTPS proxies for HTTPS destinations are not supported") + crawler = get_crawler(SingleRequestSpider, self.settings_dict) + with caplog.at_level(logging.DEBUG): + await crawler.crawl_async( + seed=mockserver.url("/status?n=200", is_secure=https_dest) + ) + assert isinstance(crawler.spider, SingleRequestSpider) + self._assert_got_response_code(200, caplog.text) + self._assert_headers(crawler.spider.meta["responses"][0].headers, https_dest) + + @pytest.mark.parametrize( + "https_dest", [False, True], ids=["HTTP dest", "HTTPS dest"] + ) + @coroutine_test + async def test_http_proxy_auth_error( + self, + caplog: pytest.LogCaptureFixture, + monkeypatch: pytest.MonkeyPatch, + mockserver: MockServer, + mitm_proxy_server: MitmProxy, + https_dest: bool, + ) -> None: + """HTTP proxy, HTTP or HTTPS destination, wrong proxy creds.""" + envvar = "https_proxy" if https_dest else "http_proxy" + monkeypatch.setenv(envvar, wrong_credentials(os.environ[envvar])) + crawler = get_crawler(SimpleSpider, self.settings_dict) + with caplog.at_level(logging.DEBUG): + await crawler.crawl_async( + mockserver.url("/status?n=200", is_secure=https_dest) + ) + # The proxy returns a 407 error code but it does not reach the client; + # it just sees an exception. + self._assert_got_auth_exception(caplog.text) + + @pytest.mark.parametrize( + "https_dest", [False, True], ids=["HTTP dest", "HTTPS dest"] + ) + @coroutine_test + async def test_dont_leak_proxy_authorization_header( + self, + caplog: pytest.LogCaptureFixture, + mockserver: MockServer, + mitm_proxy_server: MitmProxy, + https_dest: bool, + ) -> None: + """HTTP proxy, HTTP or HTTPS destination. Check that the auth header + is not sent to the destination.""" + request = Request(mockserver.url("/echo", is_secure=https_dest)) + crawler = get_crawler(SingleRequestSpider, self.settings_dict) + with caplog.at_level(logging.DEBUG): + await crawler.crawl_async(seed=request) + assert isinstance(crawler.spider, SingleRequestSpider) + self._assert_got_response_code(200, caplog.text) + self._assert_headers(crawler.spider.meta["responses"][0].headers, https_dest) + echo = json.loads(crawler.spider.meta["responses"][0].text) + assert "Proxy-Authorization" not in echo["headers"] + + @staticmethod + def _assert_headers(headers: Headers, https_dest: bool) -> None: + assert b"X-Via-Mitmproxy" in headers + if https_dest: + assert b"X-Via-Mitmproxy-TLS" in headers + + @staticmethod + def _assert_got_response_code(code: int, log: str) -> None: + assert str(log).count(f"Crawled ({code})") == 1 + + @staticmethod + def _assert_got_auth_exception(log: str) -> None: + assert "Proxy Authentication Required" in log or "407" in log diff --git a/tests/test_proxy_connect.py b/tests/test_proxy_connect.py deleted file mode 100644 index c89b51ad3..000000000 --- a/tests/test_proxy_connect.py +++ /dev/null @@ -1,125 +0,0 @@ -import json -import os -import re -import sys -from pathlib import Path -from subprocess import PIPE, Popen -from urllib.parse import urlsplit, urlunsplit - -import pytest -from testfixtures import LogCapture - -from scrapy.http import Request -from scrapy.utils.test import get_crawler -from tests.mockserver.http import MockServer -from tests.spiders import SimpleSpider, SingleRequestSpider -from tests.utils.decorators import inline_callbacks_test - - -class MitmProxy: - auth_user = "scrapy" - auth_pass = "scrapy" - - def start(self) -> str: - script = """ -import sys -from mitmproxy.tools.main import mitmdump -sys.argv[0] = "mitmdump" -sys.exit(mitmdump()) - """ - cert_path = Path(__file__).parent.resolve() / "keys" - args = [ - "--listen-host", - "127.0.0.1", - "--listen-port", - "0", - "--proxyauth", - f"{self.auth_user}:{self.auth_pass}", - "--set", - f"confdir={cert_path}", - "--ssl-insecure", - ] - self.proc = Popen( - [ - sys.executable, - "-u", - "-c", - script, - *args, - ], - stdout=PIPE, - text=True, - ) - assert self.proc.stdout is not None - line = self.proc.stdout.readline() - m = re.search(r"listening at (?:http://)?([^:]+:\d+)", line) - if not m: - raise RuntimeError(f"Failed to parse mitmdump output: {line}") - host_port = m.group(1) - return f"http://{self.auth_user}:{self.auth_pass}@{host_port}" - - def stop(self) -> None: - self.proc.kill() - self.proc.communicate() - - -def _wrong_credentials(proxy_url: str) -> str: - bad_auth_proxy = list(urlsplit(proxy_url)) - bad_auth_proxy[1] = bad_auth_proxy[1].replace("scrapy:scrapy@", "wrong:wronger@") - return urlunsplit(bad_auth_proxy) - - -@pytest.mark.requires_mitmproxy -class TestProxyConnect: - @classmethod - def setup_class(cls): - cls.mockserver = MockServer() - cls.mockserver.__enter__() - - @classmethod - def teardown_class(cls): - cls.mockserver.__exit__(None, None, None) - - def setup_method(self): - self._oldenv = os.environ.copy() - self._proxy = MitmProxy() - proxy_url = self._proxy.start() - os.environ["https_proxy"] = proxy_url - os.environ["http_proxy"] = proxy_url - - def teardown_method(self): - self._proxy.stop() - os.environ = self._oldenv - - @inline_callbacks_test - def test_https_connect_tunnel(self): - crawler = get_crawler(SimpleSpider) - with LogCapture() as log: - yield crawler.crawl(self.mockserver.url("/status?n=200", is_secure=True)) - self._assert_got_response_code(200, log) - - @inline_callbacks_test - def test_https_tunnel_auth_error(self): - os.environ["https_proxy"] = _wrong_credentials(os.environ["https_proxy"]) - crawler = get_crawler(SimpleSpider) - with LogCapture() as log: - yield crawler.crawl(self.mockserver.url("/status?n=200", is_secure=True)) - # The proxy returns a 407 error code but it does not reach the client; - # he just sees a TunnelError. - self._assert_got_tunnel_error(log) - - @inline_callbacks_test - def test_https_tunnel_without_leak_proxy_authorization_header(self): - request = Request(self.mockserver.url("/echo", is_secure=True)) - crawler = get_crawler(SingleRequestSpider) - with LogCapture() as log: - yield crawler.crawl(seed=request) - self._assert_got_response_code(200, log) - echo = json.loads(crawler.spider.meta["responses"][0].text) - assert "Proxy-Authorization" not in echo["headers"] - - def _assert_got_response_code(self, code, log): - assert str(log).count(f"Crawled ({code})") == 1 - - def _assert_got_tunnel_error(self, log): - assert "TunnelError" in str(log) From f7db039d1ca2bcfee003453ddbb2210578ed9b62 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Tue, 12 May 2026 18:22:31 +0500 Subject: [PATCH 149/248] Update the TLS code to Twisted 26.4.0 (#7347) * Unpin Twisted, add a twisted-trunk tox env (without http2 for now). * Hide the _setAcceptableProtocols import. * Silence ScrapyClientContextFactory HTTP/1.0 warnings. * Update ScrapyClientTLSOptions for unreleased Twisted. * Add twisted-trunk to CI. * Update for new changes in Twisted trunk. * Xfail a test failing with newer Twisted. * Test Twisted trunk with extra-deps, update the ALPN code. * Cleanup. * Fix typing. * Update relevant type hints for Twisted trunk. * Update test_no_context_sharing(). * Silence a weird mypy error. * Update Twisted versions, unpin pyOpenSSL. * Update a comment. * Silence pylint. * Make a factory fixture. * Improve the _setAcceptableProtocols comment. * Set OP_LEGACY_SERVER_CONNECT on new Twisted too. * Add Twisted[http2] to extra-deps-pinned. --- pyproject.toml | 8 +- scrapy/core/downloader/contextfactory.py | 59 +++++++++++--- scrapy/core/downloader/handlers/http11.py | 3 +- scrapy/core/downloader/tls.py | 99 ++++++++++++++++++++++- scrapy/shell.py | 5 +- scrapy/utils/_deps_compat.py | 4 + tests/mockserver/utils.py | 11 +-- tests/test_core_downloader.py | 81 ++++++++++++++----- tests/test_crawler_subprocess.py | 4 + tox.ini | 7 +- 10 files changed, 232 insertions(+), 49 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index f13e67020..45e18ccc9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,8 +7,7 @@ name = "Scrapy" dynamic = ["version"] description = "A high-level Web Crawling and Web Scraping framework" dependencies = [ - # Twisted pinned until Scrapy is updated for its internal TLS API changes - "Twisted>=21.7.0,<=25.5.0", + "Twisted>=21.7.0", "cryptography>=37.0.0", "cssselect>=0.9.1", "defusedxml>=0.7.1", @@ -18,10 +17,9 @@ dependencies = [ "packaging", "parsel>=1.5.0", "protego>=0.1.15", - # pyOpenSSL pinned until Twisted with immutable contexts is released and supported in Scrapy - "pyOpenSSL>=22.0.0,<26.2.0", + "pyOpenSSL>=22.0.0", "queuelib>=1.4.2", - "service_identity>=18.1.0", + "service_identity>=23.1.0", "tldextract", "w3lib>=1.17.0", "zope.interface>=5.1.0", diff --git a/scrapy/core/downloader/contextfactory.py b/scrapy/core/downloader/contextfactory.py index 6575d59c1..7f7d34c21 100644 --- a/scrapy/core/downloader/contextfactory.py +++ b/scrapy/core/downloader/contextfactory.py @@ -5,7 +5,6 @@ from contextlib import contextmanager from typing import TYPE_CHECKING, Any, cast from OpenSSL import SSL -from twisted.internet._sslverify import _setAcceptableProtocols from twisted.internet.ssl import ( AcceptableCiphers, CertificateOptions, @@ -19,9 +18,11 @@ from zope.interface.verify import verifyObject from scrapy.core.downloader.tls import ( DEFAULT_CIPHERS, _ScrapyClientTLSOptions, + _ScrapyClientTLSOptions26, openssl_methods, ) from scrapy.exceptions import ScrapyDeprecationWarning +from scrapy.utils._deps_compat import TWISTED_TLS_NEW_IMPL from scrapy.utils.deprecate import create_deprecated_class from scrapy.utils.misc import build_from_crawler, load_object @@ -108,7 +109,7 @@ class _ScrapyClientContextFactory(BrowserLikePolicyForHTTPS): def _get_cert_options(self) -> CertificateOptions: with _filter_method_warning(): - return CertificateOptions( # type: ignore[no-any-return] + return _ScrapyCertificateOptions( method=self._ssl_method, fixBrokenPeers=True, acceptableCiphers=self.tls_ciphers, @@ -121,17 +122,21 @@ class _ScrapyClientContextFactory(BrowserLikePolicyForHTTPS): return self._get_context() def _get_context(self) -> SSL.Context: - cert_options = self._get_cert_options() - ctx: SSL.Context = cert_options.getContext() - ctx.set_options(0x4) # OP_LEGACY_SERVER_CONNECT - return ctx + return self._get_cert_options().getContext() def creatorForNetloc(self, hostname: bytes, port: int) -> ClientTLSOptions: if not self._verify_certificates: - # _ScrapyClientTLSOptions is needed to skip verification errors + # Our options class is needed to skip verification errors + if TWISTED_TLS_NEW_IMPL: + return _ScrapyClientTLSOptions26( + self._get_cert_options()._makeTLSConnection, + hostname.decode("ascii"), + verbose_logging=self.tls_verbose_logging, + ) return _ScrapyClientTLSOptions( - hostname.decode("ascii"), self._get_context() - ) # type: ignore[no-untyped-call] + hostname.decode("ascii"), # type: ignore[arg-type] + self._get_context(), # type: ignore[arg-type] + ) # Otherwise use the normal Twisted function. # Note that this doesn't use self._get_context(). with _filter_method_warning(): @@ -202,8 +207,25 @@ class _AcceptableProtocolsContextFactory: the acceptable protocols on the :class:`.ClientTLSOptions` instance returned by it. It's only needed because we support custom factories via :setting:`DOWNLOADER_CLIENTCONTEXTFACTORY`. + + It's a no-op on Twisted 26.4.0+, though using it with custom + factories on those Twisted versions may be not enough for HTTP/2 support. """ + # Something needs to call set_alpn_protos() for ALPN to work. + # + # Twisted < 26.4.0 does it in OpenSSLCertificateOptions._makeContext() + # (requires passing acceptableProtocols from the factory to + # OpenSSLCertificateOptions) and in TLSMemoryBIOFactory._createConnection() + # based on H2ClientFactory.acceptableProtocols (too late, it seems). + # + # Newer Twisted does it in OpenSSLCertificateOptions._makeContext() as + # well, and in OpenSSLCertificateOptions._makeTLSConnection() based on + # H2ClientFactory.acceptableProtocols (which now works). + # + # When we drop DOWNLOADER_CLIENTCONTEXTFACTORY it looks like we can replace + # all of this with _ScrapyClientContextFactory.acceptableProtocols. + def __init__(self, context_factory: Any, acceptable_protocols: list[bytes]): verifyObject(IPolicyForHTTPS, context_factory) self._wrapped_context_factory: Any = context_factory @@ -213,7 +235,12 @@ class _AcceptableProtocolsContextFactory: options: ClientTLSOptions = self._wrapped_context_factory.creatorForNetloc( hostname, port ) - _setAcceptableProtocols(options._ctx, self._acceptable_protocols) + if not TWISTED_TLS_NEW_IMPL: + from twisted.internet._sslverify import ( # type: ignore[attr-defined] # noqa: PLC0415 # pylint: disable=no-name-in-module + _setAcceptableProtocols, + ) + + _setAcceptableProtocols(options._ctx, self._acceptable_protocols) # type: ignore[attr-defined] return options @@ -225,6 +252,18 @@ AcceptableProtocolsContextFactory = create_deprecated_class( ) +class _ScrapyCertificateOptions(CertificateOptions): + """A wrapper needed to add flags to the SSL context before it's used.""" + + def _makeContext(self, skipCiphers: bool = False) -> SSL.Context: + if TWISTED_TLS_NEW_IMPL: + ctx = super()._makeContext(skipCiphers) + else: + ctx = super()._makeContext() + ctx.set_options(0x4) # OP_LEGACY_SERVER_CONNECT + return ctx + + def _load_context_factory_from_settings(crawler: Crawler) -> IPolicyForHTTPS: """Create an instance of :setting:`DOWNLOADER_CLIENTCONTEXTFACTORY`. diff --git a/scrapy/core/downloader/handlers/http11.py b/scrapy/core/downloader/handlers/http11.py index 1127c533f..e6451cdd7 100644 --- a/scrapy/core/downloader/handlers/http11.py +++ b/scrapy/core/downloader/handlers/http11.py @@ -225,7 +225,8 @@ class TunnelingTCP4ClientEndpoint(TCP4ClientEndpoint): if respm and int(respm.group("status")) == 200: # set proper Server Name Indication extension sslOptions = self._contextFactory.creatorForNetloc( # type: ignore[call-arg,misc] - self._tunneledHost, self._tunneledPort + self._tunneledHost, # type: ignore[arg-type] + self._tunneledPort, ) self._protocol.transport.startTLS(sslOptions, self._protocolFactory) self._tunnelReadyDeferred.callback(self._protocol) diff --git a/scrapy/core/downloader/tls.py b/scrapy/core/downloader/tls.py index 9ec5b8c48..779c05daa 100644 --- a/scrapy/core/downloader/tls.py +++ b/scrapy/core/downloader/tls.py @@ -1,15 +1,34 @@ +from __future__ import annotations + import logging -from typing import Any +from typing import TYPE_CHECKING, Any from OpenSSL import SSL from service_identity import VerificationError from service_identity.exceptions import CertificateError -from service_identity.pyopenssl import verify_hostname, verify_ip_address +from service_identity.hazmat import ( + DNS_ID, + IPAddress_ID, + ServiceID, + verify_service_identity, +) +from service_identity.pyopenssl import ( + extract_patterns, + verify_hostname, + verify_ip_address, +) from twisted.internet._sslverify import ClientTLSOptions from twisted.internet.ssl import AcceptableCiphers from scrapy.utils.deprecate import create_deprecated_class +if TYPE_CHECKING: + from collections.abc import Callable + + from OpenSSL.crypto import X509 + from twisted.protocols.tls import TLSMemoryBIOProtocol + + logger = logging.getLogger(__name__) @@ -39,6 +58,8 @@ class _ScrapyClientTLSOptions(ClientTLSOptions): Instances of this class are returned from :class:`._ScrapyClientContextFactory`. + + This class is used on Twisted older than 26.4.0. """ def _identityVerifyingInfoCallback( @@ -64,7 +85,7 @@ class _ScrapyClientTLSOptions(ClientTLSOptions): e, ) else: - super()._identityVerifyingInfoCallback(connection, where, ret) # type: ignore[no-untyped-call] + super()._identityVerifyingInfoCallback(connection, where, ret) # type: ignore[misc] ScrapyClientTLSOptions = create_deprecated_class( @@ -75,6 +96,78 @@ ScrapyClientTLSOptions = create_deprecated_class( ) +class _ScrapyClientTLSOptions26(ClientTLSOptions): + """ + SSL Client connection creator ignoring certificate verification errors + (for genuinely invalid certificates or bugs in verification code). + + Same as Twisted's private _sslverify.ClientTLSOptions, + except that VerificationError, CertificateError and ValueError + exceptions are caught, so that the connection is not closed, only + logging warnings. + + Instances of this class are returned from + :class:`.ScrapyClientContextFactory`. + + This class is used on Twisted 26.4.0 and newer. + """ + + def __init__( + self, + createConnection: Callable[[TLSMemoryBIOProtocol], SSL.Connection], + hostname: str, + verbose_logging: bool = False, + ): + super().__init__(createConnection, hostname) + self.verbose_logging: bool = verbose_logging + + def clientConnectionForTLS( + self, tlsProtocol: TLSMemoryBIOProtocol + ) -> SSL.Connection: + """This method is needed to override the verify callback.""" + conn = super().clientConnectionForTLS(tlsProtocol) + callback = self._verifyCB( + self._hostnameIsDnsName, self._hostnameASCII, self.verbose_logging + ) + conn.set_verify(SSL.VERIFY_PEER | SSL.VERIFY_FAIL_IF_NO_PEER_CERT, callback) + return conn + + @staticmethod + def _verifyCB( + hostIsDNS: bool, hostnameASCII: str, verbose_logging: bool + ) -> Callable[[SSL.Connection, X509, int, int, int], bool]: + svcid: ServiceID = ( + DNS_ID(hostnameASCII) if hostIsDNS else IPAddress_ID(hostnameASCII) + ) + + def verifyCallback( + conn: SSL.Connection, cert: X509, err: int, depth: int, ok: int + ) -> bool: + if depth != 0: + # We are only verifying the leaf certificate. + return bool(ok) + + try: + verify_service_identity(extract_patterns(cert), [svcid], []) + except (CertificateError, VerificationError) as e: + logger.warning( + 'Remote certificate is not valid for hostname "%s"; %s', + hostnameASCII, + e, + ) + except ValueError as e: + logger.warning( + "Ignoring error while verifying certificate " + 'from host "%s" (exception: %r)', + hostnameASCII, + e, + ) + + return True + + return verifyCallback + + DEFAULT_CIPHERS: AcceptableCiphers = AcceptableCiphers.fromOpenSSLCipherString( "DEFAULT" ) diff --git a/scrapy/shell.py b/scrapy/shell.py index 0966d9f55..44e542470 100644 --- a/scrapy/shell.py +++ b/scrapy/shell.py @@ -241,7 +241,10 @@ class Shell: with contextlib.suppress(IgnoreRequest): response = threads.blockingCallFromThread( - reactor, deferred_f_from_coro_f(self._schedule), request, spider + reactor, + deferred_f_from_coro_f(self._schedule), # type: ignore[arg-type] + request, + spider, ) else: assert self._loop diff --git a/scrapy/utils/_deps_compat.py b/scrapy/utils/_deps_compat.py index 2ca6f657c..b7086bc98 100644 --- a/scrapy/utils/_deps_compat.py +++ b/scrapy/utils/_deps_compat.py @@ -4,6 +4,10 @@ from twisted import version as TWISTED_VERSION from twisted.python.versions import Version as TxVersion TWISTED_FAILURE_HAS_STACK = TWISTED_VERSION < TxVersion("twisted", 24, 10, 0) +# changes to private _sslverify code, https://github.com/twisted/twisted/pull/12506 +TWISTED_TLS_NEW_IMPL = TWISTED_VERSION >= TxVersion("twisted", 26, 4, 0) +# AsyncioSelectorReactor no longer calls get_event_loop(), https://github.com/twisted/twisted/pull/12508 +TWISTED_LOOP_314_CHANGES = TWISTED_VERSION >= TxVersion("twisted", 26, 4, 0) PYOPENSSL_VERSION = Version(PYOPENSSL_VERSION_STRING) # SSL.Context.use_certificate() wants an X509 object, SSL.Context.use_privatekey() wants a PKey object diff --git a/tests/mockserver/utils.py b/tests/mockserver/utils.py index 188eef61d..17d78ecdd 100644 --- a/tests/mockserver/utils.py +++ b/tests/mockserver/utils.py @@ -1,13 +1,13 @@ from __future__ import annotations from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast from cryptography.hazmat.primitives.serialization import load_pem_private_key from cryptography.x509 import load_pem_x509_certificate from OpenSSL import SSL from OpenSSL.crypto import FILETYPE_PEM, load_certificate, load_privatekey -from twisted.internet.ssl import CertificateOptions +from twisted.internet.ssl import CertificateOptions, ContextFactory from scrapy.utils._deps_compat import PYOPENSSL_WANTS_X509_PKEY from scrapy.utils.python import to_bytes @@ -31,13 +31,14 @@ def ssl_context_factory( cert = load_certificate(FILETYPE_PEM, certfile_path.read_bytes()) # type: ignore[assignment] key = load_privatekey(FILETYPE_PEM, keyfile_path.read_bytes()) # type: ignore[assignment] + # https://github.com/twisted/twisted/issues/12638 factory: CertificateOptions = CertificateOptions( - privateKey=key, - certificate=cert, + privateKey=key, # type: ignore[arg-type] + certificate=cert, # type: ignore[arg-type] ) if cipher_string: ctx = factory.getContext() # disabling TLS1.3 because it unconditionally enables some strong ciphers ctx.set_options(SSL.OP_CIPHER_SERVER_PREFERENCE | SSL.OP_NO_TLSv1_3) ctx.set_cipher_list(to_bytes(cipher_string)) - return factory + return cast("ContextFactory", factory) diff --git a/tests/test_core_downloader.py b/tests/test_core_downloader.py index f9c5abf8a..43edf1774 100644 --- a/tests/test_core_downloader.py +++ b/tests/test_core_downloader.py @@ -6,6 +6,10 @@ from typing import TYPE_CHECKING, cast import OpenSSL.SSL import pytest from pytest_twisted import async_yield_fixture +from twisted.internet.protocol import Factory +from twisted.internet.protocol import Protocol as TxProtocol +from twisted.internet.ssl import optionsForClientTLS +from twisted.protocols.tls import TLSMemoryBIOFactory, TLSMemoryBIOProtocol from twisted.web import server, static from twisted.web.client import Agent, BrowserLikePolicyForHTTPS, readBody from twisted.web.client import Response as TxResponse @@ -17,7 +21,10 @@ from scrapy.core.downloader.contextfactory import ( ) from scrapy.core.downloader.handlers.http11 import _RequestBodyProducer from scrapy.exceptions import ScrapyDeprecationWarning -from scrapy.utils._deps_compat import PYOPENSSL_SET_CIPHER_LIST_TMP_CONN +from scrapy.utils._deps_compat import ( + PYOPENSSL_SET_CIPHER_LIST_TMP_CONN, + TWISTED_TLS_NEW_IMPL, +) from scrapy.utils.defer import maybe_deferred_to_future from scrapy.utils.misc import build_from_crawler from scrapy.utils.python import to_bytes @@ -98,50 +105,82 @@ class TestContextFactoryBase: class TestContextFactory(TestContextFactoryBase): - @coroutine_test - async def test_payload(self, server_url: str) -> None: - s = "0123456789" * 10 + @pytest.fixture + def factory(self) -> _ScrapyClientContextFactory: crawler = get_crawler() - client_context_factory = _load_context_factory_from_settings(crawler) - body = await self.get_page( - server_url + "payload", client_context_factory, body=s + return _load_context_factory_from_settings(crawler) + + @staticmethod + def _get_dummy_protocol() -> TLSMemoryBIOProtocol: + # from Twisted src/twisted/web/test/test_agent.py::dummyTLSProtocol() + factory = TLSMemoryBIOFactory( + optionsForClientTLS("example.com"), True, Factory.forProtocol(TxProtocol) ) + return factory.buildProtocol(None) + + @coroutine_test + async def test_payload( + self, factory: _ScrapyClientContextFactory, server_url: str + ) -> None: + s = "0123456789" * 10 + body = await self.get_page(server_url + "payload", factory, body=s) assert body == to_bytes(s) - def test_no_context_sharing(self) -> None: + @pytest.mark.skipif( + TWISTED_TLS_NEW_IMPL, + reason="The context is not stored on this Twisted version", + ) + def test_no_context_sharing(self, factory: _ScrapyClientContextFactory) -> None: """Every call to creatorForNetloc() should give a fresh context.""" - crawler = get_crawler() - client_context_factory: _ScrapyClientContextFactory = ( - _load_context_factory_from_settings(crawler) - ) - creator1 = client_context_factory.creatorForNetloc(b"website1.tld", 443) + creator1 = factory.creatorForNetloc(b"website1.tld", 443) assert creator1._hostnameBytes == b"website1.tld" - creator2 = client_context_factory.creatorForNetloc(b"website2.tld", 443) + creator2 = factory.creatorForNetloc(b"website2.tld", 443) assert creator2._hostnameBytes == b"website2.tld" - assert creator1._ctx is not creator2._ctx + assert creator1._ctx is not creator2._ctx # type: ignore[attr-defined] + + def test_no_context_sharing_with_conn( + self, factory: _ScrapyClientContextFactory + ) -> None: + """Like test_no_context_sharing() but get the context from a connection.""" + creator1 = factory.creatorForNetloc(b"website1.tld", 443) + assert creator1._hostnameBytes == b"website1.tld" + conn1 = creator1.clientConnectionForTLS(self._get_dummy_protocol()) + + creator2 = factory.creatorForNetloc(b"website2.tld", 443) + assert creator2._hostnameBytes == b"website2.tld" + conn2 = creator2.clientConnectionForTLS(self._get_dummy_protocol()) + + assert conn1.get_context() is not conn2.get_context() @pytest.mark.skipif( PYOPENSSL_SET_CIPHER_LIST_TMP_CONN, reason="Fails or doesn't make sense on this pyOpenSSL version", ) - def test_no_immutable_ctx_warning(self) -> None: + def test_no_immutable_ctx_warning( + self, factory: _ScrapyClientContextFactory + ) -> None: """There should be no pyOpenSSL context modification warning. pyOpenSSL < 25.1.0 doesn't produce this warning, and on 25.1.0 it's always produced due to https://github.com/scrapy/scrapy/issues/6859#issuecomment-4294917851. """ - crawler = get_crawler() - client_context_factory: _ScrapyClientContextFactory = ( - _load_context_factory_from_settings(crawler) - ) with warnings.catch_warnings(): warnings.filterwarnings( "error", category=DeprecationWarning, message="Attempting to mutate a Context after a Connection was created", ) - client_context_factory.creatorForNetloc(b"website.tld", 443) + factory.creatorForNetloc(b"website.tld", 443) + + def test_ctx_flags(self, factory: _ScrapyClientContextFactory) -> None: + """The context should have the expected flags set.""" + creator = factory.creatorForNetloc(b"website.tld", 443) + conn = creator.clientConnectionForTLS(self._get_dummy_protocol()) + ctx = conn.get_context() + # fragile but pyOpenSSL doesn't have Context.get_options() + options = OpenSSL.SSL._lib.SSL_CTX_get_options(ctx._context) # type: ignore[attr-defined] + assert options & 0x4 # OP_LEGACY_SERVER_CONNECT class TestContextFactoryTLSMethod(TestContextFactoryBase): diff --git a/tests/test_crawler_subprocess.py b/tests/test_crawler_subprocess.py index beae4f277..dc2fdda4c 100644 --- a/tests/test_crawler_subprocess.py +++ b/tests/test_crawler_subprocess.py @@ -14,6 +14,7 @@ from packaging.version import parse as parse_version from pexpect.popen_spawn import PopenSpawn from w3lib import __version__ as w3lib_version +from scrapy.utils._deps_compat import TWISTED_LOOP_314_CHANGES from tests.utils import async_sleep, get_script_run_env from tests.utils.decorators import coroutine_test @@ -171,6 +172,9 @@ class TestCrawlerProcessSubprocessBase(ScriptRunnerMixin): assert "Using asyncio event loop: uvloop.Loop" in log assert "async pipeline opened!" in log + @pytest.mark.xfail( + TWISTED_LOOP_314_CHANGES, reason="Breaks with Twisted changes for Python 3.14" + ) @pytest.mark.requires_uvloop def test_asyncio_enabled_reactor_same_loop(self): log = self.run_script("asyncio_enabled_reactor_same_loop.py") diff --git a/tox.ini b/tox.ini index e0e95c194..72686d3d0 100644 --- a/tox.ini +++ b/tox.ini @@ -48,7 +48,7 @@ deps = typing-extensions==4.15.0 Pillow==12.2.0 Protego==0.6.0 - Twisted==25.5.0 + Twisted==26.4.0 attrs==26.1.0 boto3-stubs[s3]==1.43.2 botocore-stubs==1.42.41 @@ -119,7 +119,7 @@ deps = parsel==1.5.0 pyOpenSSL==22.0.0 queuelib==1.4.2 - service_identity==18.1.0 + service_identity==23.1.0 w3lib==1.17.0 zope.interface==5.1.0 {[test-requirements]deps} @@ -159,6 +159,7 @@ basepython = {[pinned]basepython} deps = {[pinned]deps} Pillow==8.3.2 + Twisted[http2]==21.7.0 boto3==1.20.0 bpython==0.7.1 brotli==1.2.0; implementation_name != "pypy" @@ -234,7 +235,7 @@ deps = parsel==1.5.0 pyOpenSSL==24.3.0 queuelib==1.4.2 - service_identity==18.1.0 + service_identity==23.1.0 w3lib==1.20.0 zope.interface==5.1.0 commands = From 3b34ab88c0712edcc43a53b53bc2517d5d031a2a Mon Sep 17 00:00:00 2001 From: Abhinav W Date: Tue, 12 May 2026 20:04:51 +0530 Subject: [PATCH 150/248] docs: document daily log file rotation (#7501) * docs: document daily log file rotation * Skip the new snippet in doc tests --------- Co-authored-by: Adrian --- docs/topics/logging.rst | 42 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/docs/topics/logging.rst b/docs/topics/logging.rst index a398d6c83..bbb5d0458 100644 --- a/docs/topics/logging.rst +++ b/docs/topics/logging.rst @@ -194,6 +194,48 @@ If :setting:`LOG_SHORT_NAMES` is set, then the logs will not display the Scrapy component that prints the log. It is unset by default, hence logs contain the Scrapy component responsible for that log output. +Rotating log files +------------------ + +Scrapy's :setting:`LOG_FILE` setting writes logs to a single file. It does not +rotate log files automatically, but you can use Python's standard +:mod:`logging.handlers` module when running Scrapy from a script. + +For example, to rotate the log file every day: + +.. skip: next + +.. code-block:: python + + import logging + from logging.handlers import TimedRotatingFileHandler + + from scrapy.crawler import CrawlerProcess + from scrapy.utils.project import get_project_settings + + from myproject.spiders.myspider import MySpider + + settings = get_project_settings() + process = CrawlerProcess(settings, install_root_handler=False) + + handler = TimedRotatingFileHandler( + "scrapy.log", + when="midnight", + backupCount=7, + encoding=settings.get("LOG_ENCODING"), + ) + handler.setFormatter( + logging.Formatter(settings.get("LOG_FORMAT"), settings.get("LOG_DATEFORMAT")) + ) + + root_logger = logging.getLogger() + root_logger.setLevel(settings.get("LOG_LEVEL")) + root_logger.addHandler(handler) + + process.crawl(MySpider) + process.start() + + Command-line options -------------------- From 2798c03bb006b2041734d71a0046252e1239d631 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Tue, 12 May 2026 23:49:37 +0500 Subject: [PATCH 151/248] Add Python 3.14 to CI. (#6604) * Add Python 3.14 (alpha3) to CI. * Disable mitmproxy on 3.14 for now. * 3.14.0-alpha.4. * 3.14.0-alpha.5 * 3.14.0-beta2. * 3.14 release. * Fix test_non_pickable_object. * Fix handling of file:/path feed URIs. * Better mocking of streams for TextTestResult. * Do not use .php in test_file_path() as it's now a known extension. * Fix the URL in TestFeedExporterSignals. * Fix typing. * Bump more envs to 3.14. * Silence pylint. * Fix another test for .php handling change. * Remove test_install_asyncio_reactor. * More bumps to 3.14. * Revert docs-tests to use 3.13. * Debug options for Windows. * Re-enable xdist. * Revert Windows PYTEST_ADDOPTS. * Silence loop policy deprecation warnings. * Restore a lost pylint suppression. * Update asyncio_enabled_reactor_same_loop.py to new Twisted. * Fix RobotFileParser tests for Python 3.14.5. --- .github/workflows/checks.yml | 6 ++--- .github/workflows/publish.yml | 2 +- .github/workflows/tests-macos.yml | 4 +-- .github/workflows/tests-ubuntu.yml | 15 ++++++----- .github/workflows/tests-windows.yml | 9 ++++--- .readthedocs.yml | 2 +- docs/topics/downloader-middleware.rst | 4 +-- pyproject.toml | 3 ++- scrapy/utils/_deps_compat.py | 2 -- scrapy/utils/reactor.py | 24 ++++++++++------- .../asyncio_enabled_reactor_same_loop.py | 5 ++-- tests/test_crawler_subprocess.py | 4 --- tests/test_robotstxt_interface.py | 26 ++++++++++++++++--- 13 files changed, 65 insertions(+), 41 deletions(-) diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index cb05784fa..49ea3277a 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -17,7 +17,7 @@ jobs: fail-fast: false matrix: include: - - python-version: "3.13" + - python-version: "3.14" env: TOXENV: pylint - python-version: "3.10" @@ -27,13 +27,13 @@ jobs: env: TOXENV: typing-tests # Keep in sync with pyproject.toml tool.sphinx-scrapy.python-version. - - python-version: "3.13" + - python-version: "3.14" env: TOXENV: docs - python-version: "3.13" env: TOXENV: docs-tests - - python-version: "3.13" + - python-version: "3.14" env: TOXENV: twinecheck diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index ad327e465..7779bbb6b 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -21,7 +21,7 @@ jobs: - uses: actions/checkout@v6 - uses: actions/setup-python@v6 with: - python-version: "3.13" + python-version: "3.14" - run: | python -m pip install --upgrade build python -m build diff --git a/.github/workflows/tests-macos.yml b/.github/workflows/tests-macos.yml index 2a1c62833..0409b3ef2 100644 --- a/.github/workflows/tests-macos.yml +++ b/.github/workflows/tests-macos.yml @@ -18,11 +18,11 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.10", "3.11", "3.12", "3.13"] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] env: - TOXENV: py include: - - python-version: '3.13' + - python-version: '3.14' env: TOXENV: no-reactor diff --git a/.github/workflows/tests-ubuntu.yml b/.github/workflows/tests-ubuntu.yml index a193ca05f..524c79cdb 100644 --- a/.github/workflows/tests-ubuntu.yml +++ b/.github/workflows/tests-ubuntu.yml @@ -31,10 +31,13 @@ jobs: - python-version: "3.13" env: TOXENV: py - - python-version: "3.13" + - python-version: "3.14" + env: + TOXENV: py + - python-version: "3.14" env: TOXENV: default-reactor - - python-version: "3.13" + - python-version: "3.14" env: TOXENV: no-reactor # pinned due to https://github.com/pypy/pypy/issues/5388 @@ -63,20 +66,20 @@ jobs: env: TOXENV: botocore-pinned - - python-version: "3.13" + - python-version: "3.14" env: TOXENV: extra-deps - - python-version: "3.13" + - python-version: "3.14" env: TOXENV: no-reactor-extra-deps # pinned due to https://github.com/pypy/pypy/issues/5388 - python-version: pypy3.11-7.3.20 env: TOXENV: pypy3-extra-deps - - python-version: "3.13" + - python-version: "3.14" env: TOXENV: botocore - - python-version: "3.13" + - python-version: "3.14" env: TOXENV: mitmproxy diff --git a/.github/workflows/tests-windows.yml b/.github/workflows/tests-windows.yml index 48aa56e15..840c7f68e 100644 --- a/.github/workflows/tests-windows.yml +++ b/.github/workflows/tests-windows.yml @@ -31,10 +31,13 @@ jobs: - python-version: "3.13" env: TOXENV: py - - python-version: "3.13" + - python-version: "3.14" + env: + TOXENV: py + - python-version: "3.14" env: TOXENV: default-reactor - - python-version: "3.13" + - python-version: "3.14" env: TOXENV: no-reactor @@ -46,7 +49,7 @@ jobs: env: TOXENV: extra-deps-pinned - - python-version: "3.13" + - python-version: "3.14" env: TOXENV: extra-deps diff --git a/.readthedocs.yml b/.readthedocs.yml index 6d1aeb507..a2773dcf2 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -2,7 +2,7 @@ version: 2 build: os: ubuntu-24.04 tools: - python: "3.13" + python: "3.14" commands: - pip install tox - tox -e docs diff --git a/docs/topics/downloader-middleware.rst b/docs/topics/downloader-middleware.rst index 873c32c13..a51e431d2 100644 --- a/docs/topics/downloader-middleware.rst +++ b/docs/topics/downloader-middleware.rst @@ -1152,9 +1152,9 @@ Based on :class:`~urllib.robotparser.RobotFileParser`: * is compliant with `Martijn Koster's 1996 draft specification `_ -* lacks support for wildcard matching +* lacks support for wildcard matching (before Python 3.14.5) -* doesn't use the length based rule +* doesn't use the length based rule (before Python 3.14.5) It is faster than Protego and backward-compatible with versions of Scrapy before 1.8.0. diff --git a/pyproject.toml b/pyproject.toml index 45e18ccc9..6c9ff3100 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,7 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Internet :: WWW/HTTP", @@ -448,4 +449,4 @@ split-on-trailing-comma = false convention = "pep257" [tool.sphinx-scrapy] -python-version = "3.13" # Keep in sync with .github/workflows/checks.yml. +python-version = "3.14" # Keep in sync with .github/workflows/checks.yml. diff --git a/scrapy/utils/_deps_compat.py b/scrapy/utils/_deps_compat.py index b7086bc98..cb5424476 100644 --- a/scrapy/utils/_deps_compat.py +++ b/scrapy/utils/_deps_compat.py @@ -6,8 +6,6 @@ from twisted.python.versions import Version as TxVersion TWISTED_FAILURE_HAS_STACK = TWISTED_VERSION < TxVersion("twisted", 24, 10, 0) # changes to private _sslverify code, https://github.com/twisted/twisted/pull/12506 TWISTED_TLS_NEW_IMPL = TWISTED_VERSION >= TxVersion("twisted", 26, 4, 0) -# AsyncioSelectorReactor no longer calls get_event_loop(), https://github.com/twisted/twisted/pull/12508 -TWISTED_LOOP_314_CHANGES = TWISTED_VERSION >= TxVersion("twisted", 26, 4, 0) PYOPENSSL_VERSION = Version(PYOPENSSL_VERSION_STRING) # SSL.Context.use_certificate() wants an X509 object, SSL.Context.use_privatekey() wants a PKey object diff --git a/scrapy/utils/reactor.py b/scrapy/utils/reactor.py index ddebd8ba2..37db6c25f 100644 --- a/scrapy/utils/reactor.py +++ b/scrapy/utils/reactor.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio import sys +import warnings from contextlib import suppress from typing import TYPE_CHECKING, Any, Generic, ParamSpec, TypeVar from warnings import catch_warnings, filterwarnings @@ -93,16 +94,19 @@ _asyncio_reactor_path = "twisted.internet.asyncioreactor.AsyncioSelectorReactor" def set_asyncio_event_loop_policy() -> None: - """The policy functions from asyncio often behave unexpectedly, - so we restrict their use to the absolutely essential case. - This should only be used to install the reactor. - """ - policy = asyncio.get_event_loop_policy() - if sys.platform == "win32" and not isinstance( - policy, asyncio.WindowsSelectorEventLoopPolicy - ): - policy = asyncio.WindowsSelectorEventLoopPolicy() - asyncio.set_event_loop_policy(policy) + """Needed due to https://github.com/twisted/twisted/issues/12527.""" + if sys.platform != "win32": + return + with warnings.catch_warnings(): + warnings.filterwarnings( + "ignore", + message=r"'asyncio\.(get_event_loop_policy|WindowsSelectorEventLoopPolicy)' is deprecated", + category=DeprecationWarning, + ) + policy = asyncio.get_event_loop_policy() + if not isinstance(policy, asyncio.WindowsSelectorEventLoopPolicy): + policy = asyncio.WindowsSelectorEventLoopPolicy() # pylint: disable=deprecated-class + asyncio.set_event_loop_policy(policy) def install_reactor(reactor_path: str, event_loop_path: str | None = None) -> None: diff --git a/tests/CrawlerProcess/asyncio_enabled_reactor_same_loop.py b/tests/CrawlerProcess/asyncio_enabled_reactor_same_loop.py index 578e0029d..a2e63a0d0 100644 --- a/tests/CrawlerProcess/asyncio_enabled_reactor_same_loop.py +++ b/tests/CrawlerProcess/asyncio_enabled_reactor_same_loop.py @@ -9,8 +9,9 @@ from scrapy.crawler import CrawlerProcess if sys.platform == "win32": asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) -asyncio.set_event_loop(Loop()) -asyncioreactor.install() +loop = Loop() +asyncio.set_event_loop(loop) +asyncioreactor.install(loop) class NoRequestsSpider(scrapy.Spider): diff --git a/tests/test_crawler_subprocess.py b/tests/test_crawler_subprocess.py index dc2fdda4c..beae4f277 100644 --- a/tests/test_crawler_subprocess.py +++ b/tests/test_crawler_subprocess.py @@ -14,7 +14,6 @@ from packaging.version import parse as parse_version from pexpect.popen_spawn import PopenSpawn from w3lib import __version__ as w3lib_version -from scrapy.utils._deps_compat import TWISTED_LOOP_314_CHANGES from tests.utils import async_sleep, get_script_run_env from tests.utils.decorators import coroutine_test @@ -172,9 +171,6 @@ class TestCrawlerProcessSubprocessBase(ScriptRunnerMixin): assert "Using asyncio event loop: uvloop.Loop" in log assert "async pipeline opened!" in log - @pytest.mark.xfail( - TWISTED_LOOP_314_CHANGES, reason="Breaks with Twisted changes for Python 3.14" - ) @pytest.mark.requires_uvloop def test_asyncio_enabled_reactor_same_loop(self): log = self.run_script("asyncio_enabled_reactor_same_loop.py") diff --git a/tests/test_robotstxt_interface.py b/tests/test_robotstxt_interface.py index 6c0dd9d2c..29b23496a 100644 --- a/tests/test_robotstxt_interface.py +++ b/tests/test_robotstxt_interface.py @@ -1,3 +1,5 @@ +import sys + import pytest from scrapy.robotstxt import ( @@ -137,16 +139,32 @@ class TestDecodeRobotsTxt: class TestPythonRobotParser(BaseRobotParserTest): + # https://github.com/python/cpython/pull/149374 improves it + IMPROVED_ROBOTFILEPARSER = sys.version_info >= (3, 14, 5) + def setup_method(self): super()._setUp(PythonRobotParser) + @pytest.mark.skipif( + not IMPROVED_ROBOTFILEPARSER, + reason="RobotFileParser from this Python version does not support length based directives precedence.", + ) def test_length_based_precedence(self): - pytest.skip( - "RobotFileParser does not support length based directives precedence." - ) + super().test_length_based_precedence() + @pytest.mark.skipif( + IMPROVED_ROBOTFILEPARSER, + reason="RobotFileParser from this Python version does not support order based directives precedence.", + ) + def test_order_based_precedence(self): + super().test_order_based_precedence() + + @pytest.mark.skipif( + not IMPROVED_ROBOTFILEPARSER, + reason="RobotFileParser from this Python version does not support wildcards.", + ) def test_allowed_wildcards(self): - pytest.skip("RobotFileParser does not support wildcards.") + super().test_allowed_wildcards() @pytest.mark.skipif(not rerp_available(), reason="Rerp parser is not installed") From 2d007bc4508423bbd57ee9017da22c16bbd576e1 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Wed, 13 May 2026 01:03:14 +0500 Subject: [PATCH 152/248] Make more of the internal handler helpers private. (#7510) --- docs/news.rst | 22 ++++++++++++--- scrapy/core/downloader/handlers/http11.py | 33 +++++++++++------------ scrapy/core/downloader/handlers/http2.py | 8 +++--- 3 files changed, 36 insertions(+), 27 deletions(-) diff --git a/docs/news.rst b/docs/news.rst index 7f3952eaf..5eef10d84 100644 --- a/docs/news.rst +++ b/docs/news.rst @@ -9,10 +9,24 @@ Scrapy VERSION (unreleased) Backward-incompatible changes ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -- ``scrapy.core.downloader.handlers.http11.ScrapyProxyAgent`` has been made - private as it's an implementation detail of - :class:`~scrapy.core.downloader.handlers.http11.HTTP11DownloadHandler`. - (:issue:`7496`) +- The following classes and functions, intended for internal use by + :class:`~scrapy.core.downloader.handlers.http11.HTTP11DownloadHandler` + and :class:`~scrapy.core.downloader.handlers.http2.H2DownloadHandler`, have + been made private: + + - ``scrapy.core.downloader.handlers.http11.ScrapyAgent`` + + - ``scrapy.core.downloader.handlers.http11.ScrapyProxyAgent`` + + - ``scrapy.core.downloader.handlers.http11.TunnelingAgent`` + + - ``scrapy.core.downloader.handlers.http11.TunnelingTCP4ClientEndpoint`` + + - ``scrapy.core.downloader.handlers.http11.tunnel_request_data()`` + + - ``scrapy.core.downloader.handlers.http2.ScrapyH2Agent`` + + (:issue:`7496`, #TBD) .. _release-2.15.2: diff --git a/scrapy/core/downloader/handlers/http11.py b/scrapy/core/downloader/handlers/http11.py index e6451cdd7..fbe24e52e 100644 --- a/scrapy/core/downloader/handlers/http11.py +++ b/scrapy/core/downloader/handlers/http11.py @@ -111,7 +111,7 @@ class HTTP11DownloadHandler(BaseHttpDownloadHandler): "download_warnsize", "DOWNLOAD_WARNSIZE" ) - agent = ScrapyAgent( + agent = _ScrapyAgent( contextFactory=self._contextFactory, bindAddress=self._bind_address, pool=self._pool, @@ -161,7 +161,7 @@ class TunnelError(Exception): """An HTTP CONNECT tunnel could not be established by the proxy.""" -class TunnelingTCP4ClientEndpoint(TCP4ClientEndpoint): +class _TunnelingTCP4ClientEndpoint(TCP4ClientEndpoint): """An endpoint that tunnels through proxies to allow HTTPS downloads. To accomplish that, this endpoint sends an HTTP CONNECT to the proxy. The HTTP CONNECT is always sent when using this endpoint, I think this could @@ -197,7 +197,7 @@ class TunnelingTCP4ClientEndpoint(TCP4ClientEndpoint): def requestTunnel(self, protocol: Protocol) -> Protocol: """Asks the proxy to open a tunnel.""" assert protocol.transport - tunnelReq = tunnel_request_data( + tunnelReq = _tunnel_request_data( self._tunneledHost, self._tunneledPort, self._proxyAuthHeader ) protocol.transport.write(tunnelReq) @@ -221,7 +221,7 @@ class TunnelingTCP4ClientEndpoint(TCP4ClientEndpoint): if b"\r\n\r\n" not in self._connectBuffer: return self._protocol.dataReceived = self._protocolDataReceived # type: ignore[method-assign] - respm = TunnelingTCP4ClientEndpoint._responseMatcher.match(self._connectBuffer) + respm = _TunnelingTCP4ClientEndpoint._responseMatcher.match(self._connectBuffer) if respm and int(respm.group("status")) == 200: # set proper Server Name Indication extension sslOptions = self._contextFactory.creatorForNetloc( # type: ignore[call-arg,misc] @@ -258,18 +258,18 @@ class TunnelingTCP4ClientEndpoint(TCP4ClientEndpoint): return self._tunnelReadyDeferred -def tunnel_request_data( +def _tunnel_request_data( host: str, port: int, proxy_auth_header: bytes | None = None ) -> bytes: r""" Return binary content of a CONNECT request. >>> from scrapy.utils.python import to_unicode as s - >>> s(tunnel_request_data("example.com", 8080)) + >>> s(_tunnel_request_data("example.com", 8080)) 'CONNECT example.com:8080 HTTP/1.1\r\nHost: example.com:8080\r\n\r\n' - >>> s(tunnel_request_data("example.com", 8080, b"123")) + >>> s(_tunnel_request_data("example.com", 8080, b"123")) 'CONNECT example.com:8080 HTTP/1.1\r\nHost: example.com:8080\r\nProxy-Authorization: 123\r\n\r\n' - >>> s(tunnel_request_data(b"example.com", "8090")) + >>> s(_tunnel_request_data(b"example.com", "8090")) 'CONNECT example.com:8090 HTTP/1.1\r\nHost: example.com:8090\r\n\r\n' """ host_value = to_bytes(host, encoding="ascii") + b":" + to_bytes(str(port)) @@ -281,7 +281,7 @@ def tunnel_request_data( return tunnel_req -class TunnelingAgent(Agent): +class _TunnelingAgent(Agent): """An agent that uses a L{TunnelingTCP4ClientEndpoint} to make HTTPS downloads. It may look strange that we have chosen to subclass Agent and not ProxyAgent but consider that after the tunnel is opened the proxy is @@ -303,8 +303,8 @@ class TunnelingAgent(Agent): self._proxyConf: tuple[str, int, bytes | None] = proxyConf self._contextFactory: IPolicyForHTTPS = contextFactory - def _getEndpoint(self, uri: URI) -> TunnelingTCP4ClientEndpoint: - return TunnelingTCP4ClientEndpoint( + def _getEndpoint(self, uri: URI) -> _TunnelingTCP4ClientEndpoint: + return _TunnelingTCP4ClientEndpoint( reactor=self._reactor, host=uri.host, port=uri.port, @@ -381,10 +381,7 @@ class _ScrapyProxyAgent(Agent): ) -class ScrapyAgent: - _Agent = Agent - _TunnelingAgent = TunnelingAgent - +class _ScrapyAgent: def __init__( self, *, @@ -430,7 +427,7 @@ class ScrapyAgent: assert proxy_host is not None proxyAuth = request.headers.get(b"Proxy-Authorization", None) proxyConf = (proxy_host, proxy_port, proxyAuth) - return self._TunnelingAgent( + return _TunnelingAgent( reactor=reactor, proxyConf=proxyConf, contextFactory=self._contextFactory, @@ -447,7 +444,7 @@ class ScrapyAgent: pool=self._pool, ) - return self._Agent( # type: ignore[no-untyped-call] + return Agent( reactor=reactor, contextFactory=self._contextFactory, connectTimeout=timeout, @@ -465,7 +462,7 @@ class ScrapyAgent: url = urldefrag(request.url)[0] method = to_bytes(request.method) headers = TxHeaders(request.headers) - if isinstance(agent, self._TunnelingAgent): + if isinstance(agent, _TunnelingAgent): headers.removeHeader(b"Proxy-Authorization") bodyproducer = _RequestBodyProducer(request.body) if request.body else None start_time = monotonic() diff --git a/scrapy/core/downloader/handlers/http2.py b/scrapy/core/downloader/handlers/http2.py index b28bcfd89..f60c58d1b 100644 --- a/scrapy/core/downloader/handlers/http2.py +++ b/scrapy/core/downloader/handlers/http2.py @@ -49,7 +49,7 @@ class H2DownloadHandler(BaseDownloadHandler): raise UnsupportedURLSchemeError( f"{type(self).__name__} doesn't support plain HTTP." ) - agent = ScrapyH2Agent( + agent = _ScrapyH2Agent( context_factory=self._context_factory, pool=self._pool, bind_address=self._bind_address, @@ -65,9 +65,7 @@ class H2DownloadHandler(BaseDownloadHandler): self._pool.close_connections() -class ScrapyH2Agent: - _Agent = H2Agent - +class _ScrapyH2Agent: def __init__( self, context_factory: IPolicyForHTTPS, @@ -89,7 +87,7 @@ class ScrapyH2Agent: raise NotImplementedError(f"{type(self).__name__} doesn't support proxies.") bind_address = request.meta.get("bindaddress") or self._bind_address bind_address = normalize_bind_address(bind_address) - return self._Agent( + return H2Agent( reactor=reactor, context_factory=self._context_factory, connect_timeout=timeout, From 4cfe7a08cdbe502cea477f67958bdc51c60a13c4 Mon Sep 17 00:00:00 2001 From: Adrian Date: Thu, 14 May 2026 13:07:13 +0200 Subject: [PATCH 153/248] Add CITATION.cff (#7519) --- CITATION.cff | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 CITATION.cff diff --git a/CITATION.cff b/CITATION.cff new file mode 100644 index 000000000..24a426d36 --- /dev/null +++ b/CITATION.cff @@ -0,0 +1,6 @@ +cff-version: 1.2.0 +message: If you use Scrapy in published research, please cite it as below. +title: Scrapy +authors: + - name: Scrapy contributors +url: https://scrapy.org From ae4a8e39e10818e0c13b9e9c32173cee4a0fa00d Mon Sep 17 00:00:00 2001 From: Olivia Choi Date: Thu, 14 May 2026 04:27:43 -0700 Subject: [PATCH 154/248] Document TLS method setting in avoiding bans guide (#7518) * Document TLS method setting in avoiding bans guide Mention DOWNLOADER_CLIENT_TLS_METHOD in the avoiding getting banned section. * Update practices.rst --------- Co-authored-by: Andrey Rakhmatullin --- docs/topics/practices.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/topics/practices.rst b/docs/topics/practices.rst index 4f036db29..2a8f5b4c1 100644 --- a/docs/topics/practices.rst +++ b/docs/topics/practices.rst @@ -409,6 +409,10 @@ Here are some tips to keep in mind when dealing with these kinds of sites: * use a pool of rotating IPs. For example, the free `Tor project`_ or paid services like `ProxyMesh`_. An open source alternative is `scrapoxy`_, a super proxy that you can attach your own proxies to. +* for HTTPS websites, if blocking appears related to TLS behavior, consider + adjusting the :setting:`DOWNLOADER_CLIENT_TLS_METHOD` setting, since some + websites may respond differently depending on the TLS method used by the + client. * use a ban avoidance service, such as `Zyte API`_, which provides a `Scrapy plugin `__ and additional features, like `AI web scraping `__ From 85c616c5c71d4de01746f9c190274fe3d2ac9ed0 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Thu, 14 May 2026 22:56:05 +0500 Subject: [PATCH 155/248] Fix certificate issuer verification on new Twisted. (#7520) --- pyproject.toml | 1 + scrapy/core/downloader/contextfactory.py | 1 - scrapy/core/downloader/tls.py | 19 ++--- tests/test_downloader_handler_httpx.py | 6 ++ .../test_downloader_handler_twisted_http11.py | 9 +++ .../test_downloader_handler_twisted_http2.py | 9 +++ tests/test_downloader_handlers_http_base.py | 69 ++++++++++++++++++- 7 files changed, 96 insertions(+), 18 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 6c9ff3100..76b8bbead 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -267,6 +267,7 @@ markers = [ "requires_botocore: marks tests that need botocore (but not boto3)", "requires_boto3: marks tests that need botocore and boto3", "requires_mitmproxy: marks tests that need mitmproxy", + "requires_internet: marks tests that need real Internet access", ] filterwarnings = [ "ignore::DeprecationWarning:twisted.web.static", diff --git a/scrapy/core/downloader/contextfactory.py b/scrapy/core/downloader/contextfactory.py index 7f7d34c21..d2626fd9d 100644 --- a/scrapy/core/downloader/contextfactory.py +++ b/scrapy/core/downloader/contextfactory.py @@ -131,7 +131,6 @@ class _ScrapyClientContextFactory(BrowserLikePolicyForHTTPS): return _ScrapyClientTLSOptions26( self._get_cert_options()._makeTLSConnection, hostname.decode("ascii"), - verbose_logging=self.tls_verbose_logging, ) return _ScrapyClientTLSOptions( hostname.decode("ascii"), # type: ignore[arg-type] diff --git a/scrapy/core/downloader/tls.py b/scrapy/core/downloader/tls.py index 779c05daa..cb06f81df 100644 --- a/scrapy/core/downloader/tls.py +++ b/scrapy/core/downloader/tls.py @@ -107,34 +107,23 @@ class _ScrapyClientTLSOptions26(ClientTLSOptions): logging warnings. Instances of this class are returned from - :class:`.ScrapyClientContextFactory`. + :class:`._ScrapyClientContextFactory`. This class is used on Twisted 26.4.0 and newer. """ - def __init__( - self, - createConnection: Callable[[TLSMemoryBIOProtocol], SSL.Connection], - hostname: str, - verbose_logging: bool = False, - ): - super().__init__(createConnection, hostname) - self.verbose_logging: bool = verbose_logging - def clientConnectionForTLS( self, tlsProtocol: TLSMemoryBIOProtocol ) -> SSL.Connection: """This method is needed to override the verify callback.""" conn = super().clientConnectionForTLS(tlsProtocol) - callback = self._verifyCB( - self._hostnameIsDnsName, self._hostnameASCII, self.verbose_logging - ) + callback = self._verifyCB(self._hostnameIsDnsName, self._hostnameASCII) conn.set_verify(SSL.VERIFY_PEER | SSL.VERIFY_FAIL_IF_NO_PEER_CERT, callback) return conn @staticmethod def _verifyCB( - hostIsDNS: bool, hostnameASCII: str, verbose_logging: bool + hostIsDNS: bool, hostnameASCII: str ) -> Callable[[SSL.Connection, X509, int, int, int], bool]: svcid: ServiceID = ( DNS_ID(hostnameASCII) if hostIsDNS else IPAddress_ID(hostnameASCII) @@ -145,7 +134,7 @@ class _ScrapyClientTLSOptions26(ClientTLSOptions): ) -> bool: if depth != 0: # We are only verifying the leaf certificate. - return bool(ok) + return True try: verify_service_identity(extract_patterns(cert), [svcid], []) diff --git a/tests/test_downloader_handler_httpx.py b/tests/test_downloader_handler_httpx.py index d4dfe8396..bc1acf9f9 100644 --- a/tests/test_downloader_handler_httpx.py +++ b/tests/test_downloader_handler_httpx.py @@ -18,6 +18,7 @@ from tests.test_downloader_handlers_http_base import ( TestHttpsWrongHostnameBase, TestHttpWithCrawlerBase, TestMitmProxyBase, + TestRealWebsiteBase, TestSimpleHttpsBase, ) from tests.utils.decorators import coroutine_test @@ -145,3 +146,8 @@ class TestHttpsProxy(HttpxDownloadHandlerMixin, TestHttpProxyBase): @pytest.mark.requires_mitmproxy class TestMitmProxy(HttpxDownloadHandlerMixin, TestMitmProxyBase): pass + + +@pytest.mark.requires_internet +class TestRealWebsite(HttpxDownloadHandlerMixin, TestRealWebsiteBase): + pass diff --git a/tests/test_downloader_handler_twisted_http11.py b/tests/test_downloader_handler_twisted_http11.py index 89cf58c96..fb3305945 100644 --- a/tests/test_downloader_handler_twisted_http11.py +++ b/tests/test_downloader_handler_twisted_http11.py @@ -2,6 +2,7 @@ from __future__ import annotations +import sys from typing import TYPE_CHECKING, Any import pytest @@ -20,6 +21,7 @@ from tests.test_downloader_handlers_http_base import ( TestHttpsWrongHostnameBase, TestHttpWithCrawlerBase, TestMitmProxyBase, + TestRealWebsiteBase, TestSimpleHttpsBase, ) @@ -103,3 +105,10 @@ class TestHttpsProxy(HTTP11DownloadHandlerMixin, TestHttpProxyBase): class TestMitmProxy(HTTP11DownloadHandlerMixin, TestMitmProxyBase): # not implemented handler_supports_tls_in_tls = False + + +@pytest.mark.requires_internet +class TestRealWebsite(HTTP11DownloadHandlerMixin, TestRealWebsiteBase): + @property + def platform_cert_store_works(self) -> bool: + return sys.platform != "win32" diff --git a/tests/test_downloader_handler_twisted_http2.py b/tests/test_downloader_handler_twisted_http2.py index 60b276166..24cb9b1aa 100644 --- a/tests/test_downloader_handler_twisted_http2.py +++ b/tests/test_downloader_handler_twisted_http2.py @@ -2,6 +2,7 @@ from __future__ import annotations +import sys from typing import TYPE_CHECKING, Any import pytest @@ -21,6 +22,7 @@ from tests.test_downloader_handlers_http_base import ( TestHttpsWrongHostnameBase, TestHttpWithCrawlerBase, TestMitmProxyBase, + TestRealWebsiteBase, ) from tests.utils.decorators import coroutine_test @@ -196,3 +198,10 @@ class TestHttp2Proxy(H2DownloadHandlerMixin, TestHttpProxyBase): @pytest.mark.requires_mitmproxy class TestMitmProxy(H2DownloadHandlerMixin, TestMitmProxyBase): pass + + +@pytest.mark.requires_internet +class TestRealWebsite(H2DownloadHandlerMixin, TestRealWebsiteBase): + @property + def platform_cert_store_works(self) -> bool: + return sys.platform != "win32" diff --git a/tests/test_downloader_handlers_http_base.py b/tests/test_downloader_handlers_http_base.py index 138ddd944..a8d002d30 100644 --- a/tests/test_downloader_handlers_http_base.py +++ b/tests/test_downloader_handlers_http_base.py @@ -6,7 +6,6 @@ import gzip import json import logging import os -import platform import re import sys from abc import ABC, abstractmethod @@ -524,7 +523,7 @@ class TestHttpBase(ABC): await download_handler.download_request(request) assert "download_latency" in request.meta latency = request.meta["download_latency"] - if sys.version_info < (3, 13) and platform.system() == "Windows": + if sys.version_info < (3, 13) and sys.platform == "win32": # time.monotonic() resolution is too low here: # https://docs.python.org/3/whatsnew/3.13.html#time assert latency >= 0 @@ -1266,3 +1265,69 @@ class TestMitmProxyBase(ABC): @staticmethod def _assert_got_auth_exception(log: str) -> None: assert "Proxy Authentication Required" in log or "407" in log + + +class TestRealWebsiteBase(ABC): + @property + @abstractmethod + def download_handler_cls(self) -> type[DownloadHandlerProtocol]: + raise NotImplementedError + + @property + @abstractmethod + def settings_dict(self) -> dict[str, Any] | None: + raise NotImplementedError + + @property + @abstractmethod + def platform_cert_store_works(self) -> bool: + """Whether valid certificates can be verified. + + Twisted on Windows cannot do that out of the box, see e.g. + https://github.com/twisted/twisted/issues/6371. + """ + return True + + @asynccontextmanager + async def get_dh( + self, settings_dict: dict[str, Any] | None = None + ) -> AsyncGenerator[DownloadHandlerProtocol]: + crawler = get_crawler(DefaultSpider, settings_dict) + crawler.spider = crawler._create_spider() + dh = build_from_crawler(self.download_handler_cls, crawler) + try: + yield dh + finally: + await dh.close() + + @coroutine_test + async def test_download(self) -> None: + request = Request("https://books.toscrape.com/") + async with self.get_dh() as download_handler: + response = await download_handler.download_request(request) + assert response.status == 200 + assert "All products | Books to Scrape - Sandbox" in response.text + + @coroutine_test + async def test_download_with_spider(self) -> None: + crawler = get_crawler(SingleRequestSpider, self.settings_dict) + await maybe_deferred_to_future( + crawler.crawl(seed=Request("https://books.toscrape.com/")) + ) + assert isinstance(crawler.spider, SingleRequestSpider) + failure = crawler.spider.meta.get("failure") + assert failure is None + reason = crawler.spider.meta["close_reason"] + assert reason == "finished" + + @coroutine_test + async def test_verify_certs(self) -> None: + if not self.platform_cert_store_works: + pytest.skip("Cannot verify certificates") + request = Request("https://books.toscrape.com/") + async with self.get_dh( + {"DOWNLOAD_VERIFY_CERTIFICATES": True} + ) as download_handler: + response = await download_handler.download_request(request) + assert response.status == 200 + assert "All products | Books to Scrape - Sandbox" in response.text From a8a8f20d9c49ae7f611cbdc90676b64f6349dba2 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Fri, 15 May 2026 16:36:05 +0500 Subject: [PATCH 156/248] Remove support for sync process_spider_output(). (#7504) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Remove support for sync process_spider_output(). * Slight check fix. * Don't expect exceptions from calling process_spider_output(). * Remove dead code. * Fix test_deprecated_mw_spider_arg() to run all methods. * Fix typing. * Typos. * Remove references to the removed section, move the universal section to the spider middleware docs and write it from a different perspective * Fix indentation issue * Minor rewordings * older versions → lower versions * Update news.rst --------- Co-authored-by: Adrian Chaves --- docs/news.rst | 21 +- docs/topics/coroutines.rst | 146 +---------- docs/topics/spider-middleware.rst | 63 +++-- scrapy/core/spidermw.py | 253 ++++---------------- scrapy/middleware.py | 5 +- scrapy/utils/python.py | 13 +- tests/test_request_cb_kwargs.py | 4 +- tests/test_spidermiddleware.py | 244 ++++--------------- tests/test_spidermiddleware_output_chain.py | 130 +--------- tests/test_utils_python.py | 11 - 10 files changed, 170 insertions(+), 720 deletions(-) diff --git a/docs/news.rst b/docs/news.rst index 5eef10d84..3173dfbc1 100644 --- a/docs/news.rst +++ b/docs/news.rst @@ -1380,8 +1380,8 @@ Highlights: - Added the :reqmeta:`allow_offsite` request meta key -- :ref:`Spider middlewares that don't support asynchronous spider output - ` are deprecated +- Spider middlewares that don't support asynchronous spider output are + deprecated - Added a base class for :ref:`universal spider middlewares ` @@ -1519,13 +1519,11 @@ Deprecations ``start_queue_cls`` parameter. (:issue:`6752`) -- :ref:`Spider middlewares that don't support asynchronous spider output - ` are deprecated. The async iterable - downgrading feature, needed for using such middlewares with asynchronous - callbacks and with other spider middlewares that produce asynchronous - iterables, is also deprecated. Please update all such middlewares to - support asynchronous spider output. - (:issue:`6664`) +- Spider middlewares that don't support asynchronous spider output are + deprecated. The async iterable downgrading feature, needed for using such + middlewares with asynchronous callbacks and with other spider middlewares + that produce asynchronous iterables, is also deprecated. Please update all + such middlewares to support asynchronous spider output. (:issue:`6664`) - Functions that were imported from :mod:`w3lib.url` and re-exported in :mod:`scrapy.utils.url` are now deprecated, you should import them from @@ -1784,9 +1782,8 @@ Documentation - Documented the setting values set in the default project template. (:issue:`6762`, :issue:`6775`) -- Improved the :ref:`docs ` about asynchronous - iterable support in spider middlewares. - (:issue:`6688`) +- Improved the docs about asynchronous iterable support in spider + middlewares. (:issue:`6688`) - Improved the :ref:`docs ` about using :class:`~twisted.internet.defer.Deferred`-based APIs in coroutine-based diff --git a/docs/topics/coroutines.rst b/docs/topics/coroutines.rst index ea3a60b4c..ba68f0dbc 100644 --- a/docs/topics/coroutines.rst +++ b/docs/topics/coroutines.rst @@ -23,9 +23,6 @@ hence use coroutine syntax (e.g. ``await``, ``async for``, ``async with``): - :class:`~scrapy.Request` callbacks. - If you are using any custom or third-party :ref:`spider middleware - `, see :ref:`sync-async-spider-middleware`. - - The :meth:`process_item` method of :ref:`item pipelines `. @@ -39,13 +36,9 @@ hence use coroutine syntax (e.g. ``await``, ``async for``, ``async with``): - The :meth:`~scrapy.spidermiddlewares.SpiderMiddleware.process_spider_output` - method of :ref:`spider middlewares `. - - If defined as a coroutine, it must be an :term:`asynchronous generator`. - The input ``result`` parameter is an :term:`asynchronous iterable`. - - See also :ref:`sync-async-spider-middleware` and - :ref:`universal-spider-middleware`. + method of :ref:`spider middlewares `, which + *must* be defined as an :term:`asynchronous generator` except in + :ref:`universal spider middlewares `. - The :meth:`~scrapy.spidermiddlewares.SpiderMiddleware.process_start` method of :ref:`spider middlewares `, which *must* be @@ -277,136 +270,3 @@ You can also send multiple requests in parallel: "price": responses[0][1].css(".price::text").get(), "price2": responses[1][1].css(".color::text").get(), } - - -.. _sync-async-spider-middleware: - -Mixing synchronous and asynchronous spider middlewares -====================================================== - -The output of a :class:`~scrapy.Request` callback is passed as the ``result`` -parameter to the -:meth:`~scrapy.spidermiddlewares.SpiderMiddleware.process_spider_output` method -of the first :ref:`spider middleware ` from the -:ref:`list of active spider middlewares `. -Then the output of that ``process_spider_output`` method is passed to the -``process_spider_output`` method of the next spider middleware, and so on for -every active spider middleware. - -Scrapy supports mixing :ref:`coroutine methods ` and synchronous methods -in this chain of calls. - -However, if any of the ``process_spider_output`` methods is defined as a -synchronous method, and the previous ``Request`` callback or -``process_spider_output`` method is a coroutine, there are some drawbacks to -the asynchronous-to-synchronous conversion that Scrapy does so that the -synchronous ``process_spider_output`` method gets a synchronous iterable as its -``result`` parameter: - -- The whole output of the previous ``Request`` callback or - ``process_spider_output`` method is awaited at this point. - -- If an exception raises while awaiting the output of the previous - ``Request`` callback or ``process_spider_output`` method, none of that - output will be processed. - - This contrasts with the regular behavior, where all items yielded before - an exception raises are processed. - -Asynchronous-to-synchronous conversions are supported for backward -compatibility, but they are deprecated and will stop working in a future -version of Scrapy. - -To avoid asynchronous-to-synchronous conversions, when defining ``Request`` -callbacks as coroutine methods or when using spider middlewares whose -``process_spider_output`` method is an :term:`asynchronous generator`, all -active spider middlewares must either have their ``process_spider_output`` -method defined as an asynchronous generator or :ref:`define a -process_spider_output_async method `. - -.. _sync-async-spider-middleware-users: - -For middleware users --------------------- - -If you have asynchronous callbacks or use asynchronous-only spider middlewares -you should make sure the asynchronous-to-synchronous conversions -:ref:`described above ` don't happen. To do this, -make sure all spider middlewares you use support asynchronous spider output. -Even if you don't have asynchronous callbacks and don't use asynchronous-only -spider middlewares in your project, it's still a good idea to make sure all -middlewares you use support asynchronous spider output, so that it will be easy -to start using asynchronous callbacks in the future. Because of this, Scrapy -logs a warning when it detects a synchronous-only spider middleware. - -If you want to update middlewares you wrote, see the :ref:`following section -`. If you have 3rd-party middlewares that -aren't yet updated by their authors, you can :ref:`subclass ` -them to make them :ref:`universal ` and use the -subclasses in your projects. - -.. _sync-async-spider-middleware-authors: - -For middleware authors ----------------------- - -If you have a spider middleware that defines a synchronous -``process_spider_output`` method, you should update it to support asynchronous -spider output for :ref:`better compatibility `, -even if you don't yet use it with asynchronous callbacks, especially if you -publish this middleware for other people to use. You have two options for this: - -1. Make the middleware asynchronous, by making the ``process_spider_output`` - method an :term:`asynchronous generator`. -2. Make the middleware universal, as described in the :ref:`next section - `. - -If your middleware won't be used in projects with synchronous-only middlewares, -e.g. because it's an internal middleware and you know that all other -middlewares in your projects are already updated, it's safe to choose the first -option. Otherwise, it's better to choose the second option. - -.. _universal-spider-middleware: - -Universal spider middlewares ----------------------------- - -To allow writing a spider middleware that supports asynchronous execution of -its ``process_spider_output`` method in Scrapy 2.7 and later (avoiding -:ref:`asynchronous-to-synchronous conversions `) -while maintaining support for older Scrapy versions, you may define -``process_spider_output`` as a synchronous method and define an -:term:`asynchronous generator` version of that method with an alternative name: -``process_spider_output_async``. - -For example: - -.. code-block:: python - - class UniversalSpiderMiddleware: - def process_spider_output(self, response, result): - for r in result: - # ... do something with r - yield r - - async def process_spider_output_async(self, response, result): - async for r in result: - # ... do something with r - yield r - -.. note:: This is an interim measure to allow, for a time, to write code that - works in Scrapy 2.7 and later without requiring - asynchronous-to-synchronous conversions, and works in earlier Scrapy - versions as well. - - In some future version of Scrapy, however, this feature will be - deprecated and, eventually, in a later version of Scrapy, this - feature will be removed, and all spider middlewares will be expected - to define their ``process_spider_output`` method as an asynchronous - generator. - -Since 2.13.0, Scrapy provides a base class, -:class:`~scrapy.spidermiddlewares.base.BaseSpiderMiddleware`, which implements -the ``process_spider_output()`` and ``process_spider_output_async()`` methods, -so instead of duplicating the processing code you can override the -``get_processed_request()`` and/or the ``get_processed_item()`` method. diff --git a/docs/topics/spider-middleware.rst b/docs/topics/spider-middleware.rst index 8b000697a..820d5910c 100644 --- a/docs/topics/spider-middleware.rst +++ b/docs/topics/spider-middleware.rst @@ -117,36 +117,28 @@ one or more of these methods: :type response: :class:`~scrapy.http.Response` object .. method:: process_spider_output(response, result) + :async: - This method is called with the results returned from the Spider, after - it has processed the response. + This method is an :term:`asynchronous generator` called with the + results from the spider after the spider has processed the response. - :meth:`process_spider_output` must return an iterable of - :class:`~scrapy.Request` objects and :ref:`item objects - `. - - Consider defining this method as an :term:`asynchronous generator`, - which will be a requirement in a future version of Scrapy. However, if - you plan on sharing your spider middleware with other people, consider - either :ref:`enforcing Scrapy 2.7 ` - as a minimum requirement of your spider middleware, or :ref:`making - your spider middleware universal ` so that - it works with Scrapy versions earlier than Scrapy 2.7. + .. seealso:: :ref:`universal-spider-middleware`. :param response: the response which generated this output from the spider :type response: :class:`~scrapy.http.Response` object - :param result: the result returned by the spider - :type result: an iterable of :class:`~scrapy.Request` objects and - :ref:`item objects ` + :param result: the results from the spider + :type result: an :term:`asynchronous iterable` of + :class:`~scrapy.Request` objects and :ref:`item objects + ` .. method:: process_spider_output_async(response, result) :async: - If defined, this method must be an :term:`asynchronous generator`, - which will be called instead of :meth:`process_spider_output` if - ``result`` is an :term:`asynchronous iterable`. + Alternative name for :meth:`process_spider_output` used when + implementing a :ref:`universal spider middleware + `. .. method:: process_spider_exception(response, exception) @@ -174,13 +166,40 @@ one or more of these methods: :type exception: :exc:`Exception` object +.. _universal-spider-middleware: + +Universal spider middlewares +---------------------------- + +In Scrapy 2.6.3 and lower, ``process_spider_output()`` must be a *synchronous* +generator. + +To support those versions and higher Scrapy versions in the same middleware, +rename your asynchronous :method:`~SpiderMiddleware.process_spider_output()` +method to :method:`~SpiderMiddleware.process_spider_output_async()`, and define +a synchronous ``process_spider_output()`` method to be used by 2.6.3 and lower +versions. + +For example: + +.. code-block:: python + + class UniversalSpiderMiddleware: + async def process_spider_output_async(self, response, result): + async for r in result: + # ... do something with r + yield r + + def process_spider_output(self, response, result): + for r in result: + # ... do something with r + yield r + Base class for custom spider middlewares ---------------------------------------- Scrapy provides a base class for custom spider middlewares. It's not required -to use it but it can help with simplifying middleware implementations and -reducing the amount of boilerplate code in :ref:`universal middlewares -`. +to use it but it can help with simplifying middleware implementations. .. module:: scrapy.spidermiddlewares.base diff --git a/scrapy/core/spidermw.py b/scrapy/core/spidermw.py index d48a65967..bcf38bb75 100644 --- a/scrapy/core/spidermw.py +++ b/scrapy/core/spidermw.py @@ -9,29 +9,28 @@ from __future__ import annotations import logging from collections.abc import AsyncIterator, Callable, Coroutine, Iterable from functools import wraps -from inspect import isasyncgenfunction, iscoroutine +from inspect import isasyncgenfunction from itertools import islice from typing import TYPE_CHECKING, Any, TypeAlias, TypeVar, cast from warnings import warn -from twisted.internet.defer import Deferred, inlineCallbacks from twisted.python.failure import Failure from scrapy import Request, Spider from scrapy.exceptions import ScrapyDeprecationWarning, _InvalidOutput from scrapy.http import Response from scrapy.middleware import MiddlewareManager -from scrapy.utils.asyncgen import as_async_generator, collect_asyncgen +from scrapy.utils.asyncgen import as_async_generator from scrapy.utils.conf import build_component_list from scrapy.utils.defer import ( _defer_sleep_async, deferred_from_coro, maybe_deferred_to_future, ) -from scrapy.utils.python import MutableAsyncChain, MutableChain, global_object_name +from scrapy.utils.python import MutableAsyncChain, global_object_name if TYPE_CHECKING: - from collections.abc import Generator + from twisted.internet.defer import Deferred from scrapy.settings import BaseSettings @@ -46,10 +45,6 @@ ScrapeFunc: TypeAlias = Callable[ ] -def _isiterable(o: Any) -> bool: - return isinstance(o, (Iterable, AsyncIterator)) - - class SpiderMiddlewareManager(MiddlewareManager): component_name = "spider middleware" @@ -67,13 +62,10 @@ class SpiderMiddlewareManager(MiddlewareManager): if hasattr(mw, "process_start"): self.methods["process_start"].appendleft(mw.process_start) - process_spider_output = self._get_async_method_pair(mw, "process_spider_output") + process_spider_output = self._get_process_spider_output(mw) self.methods["process_spider_output"].appendleft(process_spider_output) - if callable(process_spider_output): + if process_spider_output is not None: self._check_mw_method_spider_arg(process_spider_output) - elif isinstance(process_spider_output, tuple): - for m in process_spider_output: - self._check_mw_method_spider_arg(m) process_spider_exception = getattr(mw, "process_spider_exception", None) self.methods["process_spider_exception"].appendleft(process_spider_exception) @@ -105,48 +97,7 @@ class SpiderMiddlewareManager(MiddlewareManager): return await scrape_func(Failure(), request) return await scrape_func(response, request) - def _evaluate_iterable( - self, - response: Response, - iterable: Iterable[_T] | AsyncIterator[_T], - exception_processor_index: int, - recover_to: MutableChain[_T] | MutableAsyncChain[_T], - ) -> Iterable[_T] | AsyncIterator[_T]: - - if isinstance(iterable, AsyncIterator): - return self._process_async( - response, - iterable, - exception_processor_index, - cast("MutableAsyncChain[_T]", recover_to), - ) - return self._process_sync( - response, - iterable, - exception_processor_index, - cast("MutableChain[_T]", recover_to), - ) - - def _process_sync( - self, - response: Response, - iterable: Iterable[_T], - exception_processor_index: int, - recover_to: MutableChain[_T], - ) -> Iterable[_T]: - try: - yield from iterable - except Exception as ex: - exception_result = cast( - "Failure | MutableChain[_T]", - self._process_spider_exception(response, ex, exception_processor_index), - ) - if isinstance(exception_result, Failure): - raise - assert isinstance(recover_to, MutableChain) - recover_to.extend(exception_result) - - async def _process_async( + async def _evaluate_iterable( self, response: Response, iterable: AsyncIterator[_T], @@ -157,13 +108,9 @@ class SpiderMiddlewareManager(MiddlewareManager): async for r in iterable: yield r except Exception as ex: - exception_result = cast( - "Failure | MutableAsyncChain[_T]", - self._process_spider_exception(response, ex, exception_processor_index), + exception_result: MutableAsyncChain[_T] = self._process_spider_exception( + response, ex, exception_processor_index ) - if isinstance(exception_result, Failure): - raise - assert isinstance(recover_to, MutableAsyncChain) recover_to.extend(exception_result) def _process_spider_exception( @@ -171,7 +118,7 @@ class SpiderMiddlewareManager(MiddlewareManager): response: Response, exception: Exception, start_index: int = 0, - ) -> MutableChain[_T] | MutableAsyncChain[_T]: + ) -> MutableAsyncChain[_T]: # don't handle _InvalidOutput exception if isinstance(exception, _InvalidOutput): raise exception @@ -181,28 +128,18 @@ class SpiderMiddlewareManager(MiddlewareManager): for method_index, method in enumerate(method_list, start=start_index): if method is None: continue - method = cast("Callable", method) if method in self._mw_methods_requiring_spider: result = method( response=response, exception=exception, spider=self._spider ) else: result = method(response=response, exception=exception) - if _isiterable(result): + if isinstance(result, (Iterable, AsyncIterator)): # stop exception handling by handing control over to the # process_spider_output chain if an iterable has been returned - dfd: Deferred[MutableChain[_T] | MutableAsyncChain[_T]] = ( - self._process_spider_output(response, result, method_index + 1) - ) - # _process_spider_output() returns a Deferred only because of downgrading so this can be - # simplified when downgrading is removed. - if dfd.called: - # the result is available immediately if _process_spider_output didn't do downgrading - return cast("MutableChain[_T] | MutableAsyncChain[_T]", dfd.result) - # we forbid waiting here because otherwise we would need to return a deferred from - # _process_spider_exception too, which complicates the architecture - msg = f"Async iterable returned from {global_object_name(method)} cannot be downgraded" - raise _InvalidOutput(msg) + if isinstance(result, Iterable): + result = as_async_generator(result) + return self._process_spider_output(response, result, method_index + 1) if result is None: continue msg = ( @@ -212,124 +149,35 @@ class SpiderMiddlewareManager(MiddlewareManager): raise _InvalidOutput(msg) raise exception - # This method cannot be made async def, as _process_spider_exception relies on the Deferred result - # being available immediately which doesn't work when it's a wrapped coroutine. - # It also needs @inlineCallbacks only because of downgrading so it can be removed when downgrading is removed. - @inlineCallbacks - def _process_spider_output( # noqa: PLR0912 + def _process_spider_output( self, response: Response, - result: Iterable[_T] | AsyncIterator[_T], + result: AsyncIterator[_T], start_index: int = 0, - ) -> Generator[Deferred[Any], Any, MutableChain[_T] | MutableAsyncChain[_T]]: + ) -> MutableAsyncChain[_T]: # items in this iterable do not need to go through the process_spider_output # chain, they went through it already from the process_spider_exception method - recovered: MutableChain[_T] | MutableAsyncChain[_T] - last_result_is_async = isinstance(result, AsyncIterator) - recovered = MutableAsyncChain() if last_result_is_async else MutableChain() - - # There are three cases for the middleware: def foo, async def foo, def foo + async def foo_async. - # 1. def foo. Sync iterables are passed as is, async ones are downgraded. - # 2. async def foo. Sync iterables are upgraded, async ones are passed as is. - # 3. def foo + async def foo_async. Iterables are passed to the respective method. - # Storing methods and method tuples in the same list is weird but we should be able to roll this back - # when we drop this compatibility feature. - + recovered: MutableAsyncChain[_T] = MutableAsyncChain() method_list = islice(self.methods["process_spider_output"], start_index, None) - for method_index, method_pair in enumerate(method_list, start=start_index): - if method_pair is None: + for method_index, method in enumerate(method_list, start=start_index): + if method is None: continue - need_upgrade = need_downgrade = False - if isinstance(method_pair, tuple): - # This tuple handling is only needed until _async compatibility methods are removed. - method_sync, method_async = method_pair - method = method_async if last_result_is_async else method_sync + if method in self._mw_methods_requiring_spider: + result = method(response=response, result=result, spider=self._spider) else: - method = method_pair - if not last_result_is_async and isasyncgenfunction(method): - need_upgrade = True - elif last_result_is_async and not isasyncgenfunction(method): - need_downgrade = True - try: - if need_upgrade: - # Iterable -> AsyncIterator - result = as_async_generator(result) - elif need_downgrade: - logger.warning( - f"Async iterable passed to {global_object_name(method)} was" - f" downgraded to a non-async one. This is deprecated and will" - f" stop working in a future version of Scrapy. Please see" - f" https://docs.scrapy.org/en/latest/topics/coroutines.html#for-middleware-users" - f" for more information." - ) - assert isinstance(result, AsyncIterator) - # AsyncIterator -> Iterable - result = yield deferred_from_coro(collect_asyncgen(result)) - if isinstance(recovered, AsyncIterator): - recovered_collected = yield deferred_from_coro( - collect_asyncgen(recovered) - ) - recovered = MutableChain(recovered_collected) - # might fail directly if the output value is not a generator - if method in self._mw_methods_requiring_spider: - result = method( - response=response, result=result, spider=self._spider - ) - else: - result = method(response=response, result=result) - except Exception as ex: - exception_result: Failure | MutableChain[_T] | MutableAsyncChain[_T] = ( - self._process_spider_exception(response, ex, method_index + 1) - ) - if isinstance(exception_result, Failure): - raise - return exception_result - if _isiterable(result): - result = self._evaluate_iterable( - response, result, method_index + 1, recovered - ) - else: - if iscoroutine(result): - result.close() # Silence warning about not awaiting - msg = ( - f"{global_object_name(method)} must be an asynchronous " - f"generator (i.e. use yield)" - ) - else: - msg = ( - f"{global_object_name(method)} must return an iterable, got " - f"{type(result)}" - ) - raise _InvalidOutput(msg) - last_result_is_async = isinstance(result, AsyncIterator) - - if last_result_is_async: - return MutableAsyncChain(result, recovered) - return MutableChain(result, recovered) # type: ignore[arg-type] + result = method(response=response, result=result) + result = self._evaluate_iterable( + response, result, method_index + 1, recovered + ) + return MutableAsyncChain(result, recovered) async def _process_callback_output( - self, - response: Response, - result: Iterable[_T] | AsyncIterator[_T], - ) -> MutableChain[_T] | MutableAsyncChain[_T]: - recovered: MutableChain[_T] | MutableAsyncChain[_T] - if isinstance(result, AsyncIterator): - recovered = MutableAsyncChain() - else: - recovered = MutableChain() + self, response: Response, result: AsyncIterator[_T] + ) -> MutableAsyncChain[_T]: + recovered: MutableAsyncChain[_T] = MutableAsyncChain() result = self._evaluate_iterable(response, result, 0, recovered) - result = await maybe_deferred_to_future( - cast( - "Deferred[Iterable[_T] | AsyncIterator[_T]]", - self._process_spider_output(response, result), - ) - ) - if isinstance(result, AsyncIterator): - return MutableAsyncChain(result, recovered) - if isinstance(recovered, AsyncIterator): - recovered_collected = await collect_asyncgen(recovered) - recovered = MutableChain(recovered_collected) - return MutableChain(result, recovered) + result = self._process_spider_output(response, result) + return MutableAsyncChain(result, recovered) def scrape_response( self, @@ -340,7 +188,7 @@ class SpiderMiddlewareManager(MiddlewareManager): response: Response, request: Request, spider: Spider, - ) -> Deferred[MutableChain[_T] | MutableAsyncChain[_T]]: # pragma: no cover + ) -> Deferred[MutableAsyncChain[_T]]: # pragma: no cover warn( "SpiderMiddlewareManager.scrape_response() is deprecated, use scrape_response_async() instead", ScrapyDeprecationWarning, @@ -363,7 +211,7 @@ class SpiderMiddlewareManager(MiddlewareManager): scrape_func: ScrapeFunc[_T], response: Response, request: Request, - ) -> MutableChain[_T] | MutableAsyncChain[_T]: + ) -> MutableAsyncChain[_T]: if not self.crawler: raise RuntimeError( "scrape_response_async() called on a SpiderMiddlewareManager" @@ -373,7 +221,8 @@ class SpiderMiddlewareManager(MiddlewareManager): it: Iterable[_T] | AsyncIterator[_T] = await self._process_spider_input( scrape_func, response, request ) - return await self._process_callback_output(response, it) + ait = it if isinstance(it, AsyncIterator) else as_async_generator(it) + return await self._process_callback_output(response, ait) except Exception as ex: await _defer_sleep_async() return self._process_spider_exception(response, ex) @@ -399,27 +248,23 @@ class SpiderMiddlewareManager(MiddlewareManager): # This method is only needed until _async compatibility methods are removed. @staticmethod - def _get_async_method_pair( - mw: Any, methodname: str - ) -> Callable | tuple[Callable, Callable] | None: - normal_method: Callable | None = getattr(mw, methodname, None) - methodname_async = methodname + "_async" - async_method: Callable | None = getattr(mw, methodname_async, None) + def _get_process_spider_output(mw: Any) -> Callable | None: + normal_method: Callable | None = getattr(mw, "process_spider_output", None) + async_method: Callable | None = getattr(mw, "process_spider_output_async", None) if not async_method: if normal_method and not isasyncgenfunction(normal_method): - logger.warning( + raise TypeError( f"Middleware {global_object_name(mw.__class__)} doesn't support" - f" asynchronous spider output, this is deprecated and will stop" - f" working in a future version of Scrapy. The middleware should" - f" be updated to support it. Please see" - f" https://docs.scrapy.org/en/latest/topics/coroutines.html#for-middleware-users" - f" for more information." + f" asynchronous spider output. Its process_spider_output() method" + f" should be an async generator function or it should additionally" + f" define a process_spider_output_async() method." ) return normal_method if not normal_method: logger.error( - f"Middleware {global_object_name(mw.__class__)} has {methodname_async} " - f"without {methodname}, skipping this method." + f"Middleware {global_object_name(mw.__class__)} has" + f" process_spider_output_async() without process_spider_output()," + f" skipping this method. Please rename it to process_spider_output()." ) return None if not isasyncgenfunction(async_method): @@ -431,8 +276,8 @@ class SpiderMiddlewareManager(MiddlewareManager): if isasyncgenfunction(normal_method): logger.error( f"{global_object_name(normal_method)} is an async " - f"generator function while {methodname_async} exists, " - f"skipping both methods." + f"generator function while process_spider_output_async() exists, " + f"skipping both methods. Please remove process_spider_output_async()." ) return None - return normal_method, async_method + return async_method diff --git a/scrapy/middleware.py b/scrapy/middleware.py index c4e0ce16e..14b591a5f 100644 --- a/scrapy/middleware.py +++ b/scrapy/middleware.py @@ -50,10 +50,7 @@ class MiddlewareManager(ABC): ) self.middlewares: tuple[Any, ...] = middlewares # Only process_spider_output and process_spider_exception can be None. - # Only process_spider_output can be a tuple, and only until _async compatibility methods are removed. - self.methods: dict[str, deque[Callable | tuple[Callable, Callable] | None]] = ( - defaultdict(deque) - ) + self.methods: dict[str, deque[Callable | None]] = defaultdict(deque) self._mw_methods_requiring_spider: set[Callable] = set() for mw in middlewares: self._add_middleware(mw) diff --git a/scrapy/utils/python.py b/scrapy/utils/python.py index 9cb695111..2d40e8555 100644 --- a/scrapy/utils/python.py +++ b/scrapy/utils/python.py @@ -8,12 +8,14 @@ import gc import inspect import re import sys +import warnings import weakref from collections.abc import AsyncIterator, Iterable, Mapping from functools import partial, wraps from itertools import chain from typing import TYPE_CHECKING, Any, Concatenate, ParamSpec, TypeVar, overload +from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.utils.asyncgen import as_async_generator if TYPE_CHECKING: @@ -294,12 +296,13 @@ else: gc.collect() -class MutableChain(Iterable[_T]): - """ - Thin wrapper around itertools.chain, allowing to add iterables "in-place" - """ - +class MutableChain(Iterable[_T]): # pragma: no cover def __init__(self, *args: Iterable[_T]): + warnings.warn( + "MutableChain is deprecated and will be removed in a future Scrapy version.", + category=ScrapyDeprecationWarning, + stacklevel=2, + ) self.data: Iterator[_T] = chain.from_iterable(args) def extend(self, *iterables: Iterable[_T]) -> None: diff --git a/tests/test_request_cb_kwargs.py b/tests/test_request_cb_kwargs.py index 8d3977452..0d96e1d88 100644 --- a/tests/test_request_cb_kwargs.py +++ b/tests/test_request_cb_kwargs.py @@ -38,8 +38,8 @@ class InjectArgumentsSpiderMiddleware: if request.callback.__name__ == "parse_spider_mw": request.cb_kwargs["from_process_spider_input"] = True - def process_spider_output(self, response, result): - for element in result: + async def process_spider_output(self, response, result): + async for element in result: if ( isinstance(element, Request) and element.callback.__name__ == "parse_spider_mw_2" diff --git a/tests/test_spidermiddleware.py b/tests/test_spidermiddleware.py index bfa60ac9c..2e0436ae1 100644 --- a/tests/test_spidermiddleware.py +++ b/tests/test_spidermiddleware.py @@ -6,7 +6,6 @@ from typing import TYPE_CHECKING, Any, cast from unittest import mock import pytest -from testfixtures import LogCapture from twisted.internet import defer from scrapy.core.spidermw import SpiderMiddlewareManager @@ -63,20 +62,6 @@ class TestProcessSpiderInputInvalidOutput(TestSpiderMiddleware): await self._scrape_response() -class TestProcessSpiderOutputInvalidOutput(TestSpiderMiddleware): - """Invalid return value for process_spider_output method""" - - @coroutine_test - async def test_invalid_process_spider_output(self): - class InvalidProcessSpiderOutputMiddleware: - def process_spider_output(self, response, result): - return 1 - - self.mwman._add_middleware(InvalidProcessSpiderOutputMiddleware()) - with pytest.raises(_InvalidOutput): - await self._scrape_response() - - class TestProcessSpiderExceptionInvalidOutput(TestSpiderMiddleware): """Invalid return value for process_spider_exception method""" @@ -87,13 +72,15 @@ class TestProcessSpiderExceptionInvalidOutput(TestSpiderMiddleware): return 1 class RaiseExceptionProcessSpiderOutputMiddleware: - def process_spider_output(self, response, result): + async def process_spider_output(self, response, result): raise RuntimeError + yield # pylint: disable=unreachable self.mwman._add_middleware(InvalidProcessSpiderOutputExceptionMiddleware()) self.mwman._add_middleware(RaiseExceptionProcessSpiderOutputMiddleware()) + it = await self._scrape_response() with pytest.raises(_InvalidOutput): - await self._scrape_response() + await collect_asyncgen(it) class TestProcessSpiderExceptionReRaise(TestSpiderMiddleware): @@ -106,13 +93,15 @@ class TestProcessSpiderExceptionReRaise(TestSpiderMiddleware): return None class RaiseExceptionProcessSpiderOutputMiddleware: - def process_spider_output(self, response, result): + async def process_spider_output(self, response, result): 1 / 0 + yield self.mwman._add_middleware(ProcessSpiderExceptionReturnNoneMiddleware()) self.mwman._add_middleware(RaiseExceptionProcessSpiderOutputMiddleware()) + it = await self._scrape_response() with pytest.raises(ZeroDivisionError): - await self._scrape_response() + await collect_asyncgen(it) class TestBaseAsyncSpiderMiddleware(TestSpiderMiddleware): @@ -155,43 +144,19 @@ class TestBaseAsyncSpiderMiddleware(TestSpiderMiddleware): self._scrape_func, self.response, self.request ) - async def _test_simple_base( - self, - *mw_classes: type[Any], - downgrade: bool = False, - start_index: int | None = None, - ) -> None: - with LogCapture() as log: - result = await self._get_middleware_result( - *mw_classes, start_index=start_index - ) - assert isinstance(result, Iterable) - result_list = list(result) - assert len(result_list) == self.RESULT_COUNT - assert isinstance(result_list[0], self.ITEM_TYPE) - assert ("downgraded to a non-async" in str(log)) == downgrade - assert ("doesn't support asynchronous spider output" in str(log)) == ( - ProcessSpiderOutputSimpleMiddleware in mw_classes - ) - async def _test_asyncgen_base( self, *mw_classes: type[Any], - downgrade: bool = False, start_index: int | None = None, ) -> None: - with LogCapture() as log: - result = await self._get_middleware_result( - *mw_classes, start_index=start_index - ) + result = await self._get_middleware_result(*mw_classes, start_index=start_index) assert isinstance(result, AsyncIterator) result_list = await collect_asyncgen(result) assert len(result_list) == self.RESULT_COUNT assert isinstance(result_list[0], self.ITEM_TYPE) - assert ("downgraded to a non-async" in str(log)) == downgrade -class ProcessSpiderOutputSimpleMiddleware: +class ProcessSpiderOutputSyncMiddleware: def process_spider_output(self, response, result): yield from result @@ -232,53 +197,36 @@ class TestProcessSpiderOutputSimple(TestBaseAsyncSpiderMiddleware): """process_spider_output tests for simple callbacks""" ITEM_TYPE = dict - MW_SIMPLE = ProcessSpiderOutputSimpleMiddleware + MW_SYNC = ProcessSpiderOutputSyncMiddleware MW_ASYNCGEN = ProcessSpiderOutputAsyncGenMiddleware MW_UNIVERSAL = ProcessSpiderOutputUniversalMiddleware @coroutine_test - async def test_simple(self): - """Simple mw""" - await self._test_simple_base(self.MW_SIMPLE) + async def test_sync(self): + """Unsupported sync mw""" + with pytest.raises( + TypeError, match=r"doesn't support asynchronous spider output" + ): + await self._get_middleware_result(self.MW_SYNC) @coroutine_test async def test_asyncgen(self): - """Asyncgen mw; upgrade""" + """Asyncgen mw""" await self._test_asyncgen_base(self.MW_ASYNCGEN) - @coroutine_test - async def test_simple_asyncgen(self): - """Simple mw -> asyncgen mw; upgrade""" - await self._test_asyncgen_base(self.MW_ASYNCGEN, self.MW_SIMPLE) - - @coroutine_test - async def test_asyncgen_simple(self): - """Asyncgen mw -> simple mw; upgrade then downgrade""" - await self._test_simple_base(self.MW_SIMPLE, self.MW_ASYNCGEN, downgrade=True) - @coroutine_test async def test_universal(self): """Universal mw""" - await self._test_simple_base(self.MW_UNIVERSAL) - - @coroutine_test - async def test_universal_simple(self): - """Universal mw -> simple mw""" - await self._test_simple_base(self.MW_SIMPLE, self.MW_UNIVERSAL) - - @coroutine_test - async def test_simple_universal(self): - """Simple mw -> universal mw""" - await self._test_simple_base(self.MW_UNIVERSAL, self.MW_SIMPLE) + await self._test_asyncgen_base(self.MW_UNIVERSAL) @coroutine_test async def test_universal_asyncgen(self): - """Universal mw -> asyncgen mw; upgrade""" + """Universal mw -> asyncgen mw""" await self._test_asyncgen_base(self.MW_ASYNCGEN, self.MW_UNIVERSAL) @coroutine_test async def test_asyncgen_universal(self): - """Asyncgen mw -> universal mw; upgrade""" + """Asyncgen mw -> universal mw""" await self._test_asyncgen_base(self.MW_UNIVERSAL, self.MW_ASYNCGEN) @@ -289,31 +237,6 @@ class TestProcessSpiderOutputAsyncGen(TestProcessSpiderOutputSimple): for item in super()._callback(): yield item - @coroutine_test - async def test_simple(self): - """Simple mw; downgrade""" - await self._test_simple_base(self.MW_SIMPLE, downgrade=True) - - @coroutine_test - async def test_simple_asyncgen(self): - """Simple mw -> asyncgen mw; downgrade then upgrade""" - await self._test_asyncgen_base(self.MW_ASYNCGEN, self.MW_SIMPLE, downgrade=True) - - @coroutine_test - async def test_universal(self): - """Universal mw""" - await self._test_asyncgen_base(self.MW_UNIVERSAL) - - @coroutine_test - async def test_universal_simple(self): - """Universal mw -> simple mw; downgrade""" - await self._test_simple_base(self.MW_SIMPLE, self.MW_UNIVERSAL, downgrade=True) - - @coroutine_test - async def test_simple_universal(self): - """Simple mw -> universal mw; downgrade""" - await self._test_simple_base(self.MW_UNIVERSAL, self.MW_SIMPLE, downgrade=True) - class ProcessSpiderOutputNonIterableMiddleware: def process_spider_output(self, response, result): @@ -325,24 +248,6 @@ class ProcessSpiderOutputCoroutineMiddleware: return result -class TestProcessSpiderOutputInvalidResult(TestBaseAsyncSpiderMiddleware): - @coroutine_test - async def test_non_iterable(self): - with pytest.raises( - _InvalidOutput, - match=r"\.process_spider_output must return an iterable, got ", - ): - await self._get_middleware_result(ProcessSpiderOutputNonIterableMiddleware) - - @coroutine_test - async def test_coroutine(self): - with pytest.raises( - _InvalidOutput, - match=r"\.process_spider_output must be an asynchronous generator", - ): - await self._get_middleware_result(ProcessSpiderOutputCoroutineMiddleware) - - class ProcessStartSimpleMiddleware: async def process_start(self, start): async for item_or_request in start: @@ -415,11 +320,11 @@ class TestUniversalMiddlewareManager: return SpiderMiddlewareManager.from_crawler(crawler) def test_simple_mw(self, mwman: SpiderMiddlewareManager) -> None: - mw = ProcessSpiderOutputSimpleMiddleware() - mwman._add_middleware(mw) - assert ( - mwman.methods["process_spider_output"][0] == mw.process_spider_output # pylint: disable=comparison-with-callable - ) + mw = ProcessSpiderOutputSyncMiddleware() + with pytest.raises( + TypeError, match=r"doesn't support asynchronous spider output" + ): + mwman._add_middleware(mw) def test_async_mw(self, mwman: SpiderMiddlewareManager) -> None: mw = ProcessSpiderOutputAsyncGenMiddleware() @@ -431,9 +336,8 @@ class TestUniversalMiddlewareManager: def test_universal_mw(self, mwman: SpiderMiddlewareManager) -> None: mw = ProcessSpiderOutputUniversalMiddleware() mwman._add_middleware(mw) - assert mwman.methods["process_spider_output"][0] == ( - mw.process_spider_output, - mw.process_spider_output_async, + assert ( + mwman.methods["process_spider_output"][0] == mw.process_spider_output_async # pylint: disable=comparison-with-callable ) def test_universal_mw_no_sync( @@ -441,7 +345,7 @@ class TestUniversalMiddlewareManager: ) -> None: mwman._add_middleware(UniversalMiddlewareNoSync()) assert ( - "UniversalMiddlewareNoSync has process_spider_output_async" + "UniversalMiddlewareNoSync has process_spider_output_async()" " without process_spider_output" in caplog.text ) assert mwman.methods["process_spider_output"][0] is None @@ -465,7 +369,7 @@ class TestUniversalMiddlewareManager: mwman._add_middleware(UniversalMiddlewareBothAsync()) assert ( "UniversalMiddlewareBothAsync.process_spider_output " - "is an async generator function while process_spider_output_async exists" + "is an async generator function while process_spider_output_async() exists" in caplog.text ) assert mwman.methods["process_spider_output"][0] is None @@ -473,7 +377,6 @@ class TestUniversalMiddlewareManager: class TestBuiltinMiddlewareSimple(TestBaseAsyncSpiderMiddleware): ITEM_TYPE = dict - MW_SIMPLE = ProcessSpiderOutputSimpleMiddleware MW_ASYNCGEN = ProcessSpiderOutputAsyncGenMiddleware MW_UNIVERSAL = ProcessSpiderOutputUniversalMiddleware @@ -488,66 +391,22 @@ class TestBuiltinMiddlewareSimple(TestBaseAsyncSpiderMiddleware): self._scrape_func, self.response, self.request ) - @coroutine_test - async def test_just_builtin(self): - await self._test_simple_base() - - @coroutine_test - async def test_builtin_simple(self): - await self._test_simple_base(self.MW_SIMPLE, start_index=1000) - - @coroutine_test - async def test_builtin_async(self): - """Upgrade""" - await self._test_asyncgen_base(self.MW_ASYNCGEN, start_index=1000) - - @coroutine_test - async def test_builtin_universal(self): - await self._test_simple_base(self.MW_UNIVERSAL, start_index=1000) - - @coroutine_test - async def test_simple_builtin(self): - await self._test_simple_base(self.MW_SIMPLE) - - @coroutine_test - async def test_async_builtin(self): - """Upgrade""" - await self._test_asyncgen_base(self.MW_ASYNCGEN) - - @coroutine_test - async def test_universal_builtin(self): - await self._test_simple_base(self.MW_UNIVERSAL) - - -class TestBuiltinMiddlewareAsyncGen(TestBuiltinMiddlewareSimple): - async def _callback(self) -> Any: - for item in super()._callback(): - yield item - @coroutine_test async def test_just_builtin(self): await self._test_asyncgen_base() - @coroutine_test - async def test_builtin_simple(self): - """Downgrade""" - await self._test_simple_base(self.MW_SIMPLE, downgrade=True, start_index=1000) - @coroutine_test async def test_builtin_async(self): + """Upgrade""" await self._test_asyncgen_base(self.MW_ASYNCGEN, start_index=1000) @coroutine_test async def test_builtin_universal(self): await self._test_asyncgen_base(self.MW_UNIVERSAL, start_index=1000) - @coroutine_test - async def test_simple_builtin(self): - """Downgrade""" - await self._test_simple_base(self.MW_SIMPLE, downgrade=True) - @coroutine_test async def test_async_builtin(self): + """Upgrade""" await self._test_asyncgen_base(self.MW_ASYNCGEN) @coroutine_test @@ -555,9 +414,14 @@ class TestBuiltinMiddlewareAsyncGen(TestBuiltinMiddlewareSimple): await self._test_asyncgen_base(self.MW_UNIVERSAL) +class TestBuiltinMiddlewareAsyncGen(TestBuiltinMiddlewareSimple): + async def _callback(self) -> Any: + for item in super()._callback(): + yield item + + class TestProcessSpiderException(TestBaseAsyncSpiderMiddleware): ITEM_TYPE = dict - MW_SIMPLE = ProcessSpiderOutputSimpleMiddleware MW_ASYNCGEN = ProcessSpiderOutputAsyncGenMiddleware MW_UNIVERSAL = ProcessSpiderOutputUniversalMiddleware MW_EXC_SIMPLE = ProcessSpiderExceptionSimpleIterableMiddleware @@ -576,18 +440,13 @@ class TestProcessSpiderException(TestBaseAsyncSpiderMiddleware): @coroutine_test async def test_exc_simple(self): """Simple exc mw""" - await self._test_simple_base(self.MW_EXC_SIMPLE) + await self._test_asyncgen_base(self.MW_EXC_SIMPLE) @coroutine_test async def test_exc_async(self): """Async exc mw""" await self._test_asyncgen_base(self.MW_EXC_ASYNCGEN) - @coroutine_test - async def test_exc_simple_simple(self): - """Simple exc mw -> simple output mw""" - await self._test_simple_base(self.MW_SIMPLE, self.MW_EXC_SIMPLE) - @coroutine_test async def test_exc_async_async(self): """Async exc mw -> async output mw""" @@ -598,25 +457,27 @@ class TestProcessSpiderException(TestBaseAsyncSpiderMiddleware): """Simple exc mw -> async output mw; upgrade""" await self._test_asyncgen_base(self.MW_ASYNCGEN, self.MW_EXC_SIMPLE) - @coroutine_test - async def test_exc_async_simple(self): - """Async exc mw -> simple output mw; cannot work as downgrading is not supported""" - await self._test_asyncgen_nodowngrade(self.MW_SIMPLE, self.MW_EXC_ASYNCGEN) - class TestDeprecatedSpiderArg(TestSpiderMiddleware): @coroutine_test async def test_deprecated_mw_spider_arg(self): - class DeprecatedSpiderArgMiddleware: + class DeprecatedSpiderArgMiddleware1: def process_spider_input(self, response, spider): return None - def process_spider_output(self, response, result, spider): + async def process_spider_output(self, response, result, spider): 1 / 0 + yield + class DeprecatedSpiderArgMiddleware2: def process_spider_exception(self, response, exception, spider): return [] + with pytest.warns( + ScrapyDeprecationWarning, + match=r"process_spider_exception\(\) requires a spider argument", + ): + self.mwman._add_middleware(DeprecatedSpiderArgMiddleware2()) with ( pytest.warns( ScrapyDeprecationWarning, @@ -626,13 +487,10 @@ class TestDeprecatedSpiderArg(TestSpiderMiddleware): ScrapyDeprecationWarning, match=r"process_spider_output\(\) requires a spider argument", ), - pytest.warns( - ScrapyDeprecationWarning, - match=r"process_spider_exception\(\) requires a spider argument", - ), ): - self.mwman._add_middleware(DeprecatedSpiderArgMiddleware()) - await self._scrape_response() + self.mwman._add_middleware(DeprecatedSpiderArgMiddleware1()) + it = await self._scrape_response() + await collect_asyncgen(it) @coroutine_test async def test_deprecated_mwman_spider_arg(self): diff --git a/tests/test_spidermiddleware_output_chain.py b/tests/test_spidermiddleware_output_chain.py index 8cf08dd94..afdfaa322 100644 --- a/tests/test_spidermiddleware_output_chain.py +++ b/tests/test_spidermiddleware_output_chain.py @@ -169,8 +169,8 @@ class NotGeneratorCallbackSpiderMiddlewareRightAfterSpider(NotGeneratorCallbackS # ================================================================================ # (4) exceptions from a middleware process_spider_output method (generator) class _GeneratorDoNothingMiddleware(_BaseSpiderMiddleware): - def process_spider_output(self, response, result): - for r in result: + async def process_spider_output(self, response, result): + async for r in result: r["processed"].append(f"{self.__class__.__name__}.process_spider_output") yield r @@ -182,8 +182,8 @@ class _GeneratorDoNothingMiddleware(_BaseSpiderMiddleware): class GeneratorFailMiddleware(_BaseSpiderMiddleware): - def process_spider_output(self, response, result): - for r in result: + async def process_spider_output(self, response, result): + async for r in result: r["processed"].append(f"{self.__class__.__name__}.process_spider_output") yield r raise LookupError @@ -201,8 +201,8 @@ class GeneratorDoNothingAfterFailureMiddleware(_GeneratorDoNothingMiddleware): class GeneratorRecoverMiddleware(_BaseSpiderMiddleware): - def process_spider_output(self, response, result): - for r in result: + async def process_spider_output(self, response, result): + async for r in result: r["processed"].append(f"{self.__class__.__name__}.process_spider_output") yield r @@ -237,86 +237,6 @@ class GeneratorOutputChainSpider(Spider): yield {"processed": ["parse-second-item"]} -# ================================================================================ -# (5) exceptions from a middleware process_spider_output method (not generator) - - -class _NotGeneratorDoNothingMiddleware(_BaseSpiderMiddleware): - def process_spider_output(self, response, result): - out = [] - for r in result: - r["processed"].append(f"{self.__class__.__name__}.process_spider_output") - out.append(r) - return out - - def process_spider_exception(self, response, exception): - method = f"{self.__class__.__name__}.process_spider_exception" - self.crawler.spider.logger.info( - "%s: %s caught", method, exception.__class__.__name__ - ) - - -class NotGeneratorFailMiddleware(_BaseSpiderMiddleware): - def process_spider_output(self, response, result): - out = [] - for r in result: - r["processed"].append(f"{self.__class__.__name__}.process_spider_output") - out.append(r) - raise ReferenceError - - def process_spider_exception(self, response, exception): - method = f"{self.__class__.__name__}.process_spider_exception" - self.crawler.spider.logger.info( - "%s: %s caught", method, exception.__class__.__name__ - ) - return [{"processed": [method]}] - - -class NotGeneratorDoNothingAfterFailureMiddleware(_NotGeneratorDoNothingMiddleware): - pass - - -class NotGeneratorRecoverMiddleware(_BaseSpiderMiddleware): - def process_spider_output(self, response, result): - out = [] - for r in result: - r["processed"].append(f"{self.__class__.__name__}.process_spider_output") - out.append(r) - return out - - def process_spider_exception(self, response, exception): - method = f"{self.__class__.__name__}.process_spider_exception" - self.crawler.spider.logger.info( - "%s: %s caught", method, exception.__class__.__name__ - ) - return [{"processed": [method]}] - - -class NotGeneratorDoNothingAfterRecoveryMiddleware(_NotGeneratorDoNothingMiddleware): - pass - - -class NotGeneratorOutputChainSpider(Spider): - name = "NotGeneratorOutputChainSpider" - custom_settings = { - "SPIDER_MIDDLEWARES": { - NotGeneratorFailMiddleware: 10, - NotGeneratorDoNothingAfterFailureMiddleware: 8, - NotGeneratorRecoverMiddleware: 5, - NotGeneratorDoNothingAfterRecoveryMiddleware: 3, - }, - } - - async def start(self): - yield Request(self.mockserver.url("/status?n=200")) - - def parse(self, response): - return [ - {"processed": ["parse-first-item"]}, - {"processed": ["parse-second-item"]}, - ] - - # ================================================================================ class TestSpiderMiddleware: mockserver: MockServer @@ -481,41 +401,3 @@ class TestSpiderMiddleware: assert str(item_from_callback) in str(log4) assert str(item_recovered) in str(log4) assert "parse-second-item" not in str(log4) - - @coroutine_test - async def test_not_a_generator_output_chain(self): - """ - (5) An exception from a middleware's process_spider_output method should be sent - to the process_spider_exception method from the next middleware in the chain. - The result of the recovery by the process_spider_exception method should be handled - by the process_spider_output method from the next middleware. - The final item count should be 1 (from the process_spider_exception chain, the items - from the spider callback are lost) - """ - log5 = await self.crawl_log(NotGeneratorOutputChainSpider) - assert "'item_scraped_count': 1" in str(log5) - assert ( - "GeneratorRecoverMiddleware.process_spider_exception: ReferenceError caught" - in str(log5) - ) - assert ( - "GeneratorDoNothingAfterFailureMiddleware.process_spider_exception: ReferenceError caught" - in str(log5) - ) - assert ( - "GeneratorFailMiddleware.process_spider_exception: ReferenceError caught" - not in str(log5) - ) - assert ( - "GeneratorDoNothingAfterRecoveryMiddleware.process_spider_exception: ReferenceError caught" - not in str(log5) - ) - item_recovered = { - "processed": [ - "NotGeneratorRecoverMiddleware.process_spider_exception", - "NotGeneratorDoNothingAfterRecoveryMiddleware.process_spider_output", - ] - } - assert str(item_recovered) in str(log5) - assert "parse-first-item" not in str(log5) - assert "parse-second-item" not in str(log5) diff --git a/tests/test_utils_python.py b/tests/test_utils_python.py index 8dfac5f02..e8fe45749 100644 --- a/tests/test_utils_python.py +++ b/tests/test_utils_python.py @@ -12,7 +12,6 @@ from scrapy.utils.asyncgen import as_async_generator, collect_asyncgen from scrapy.utils.defer import aiter_errback from scrapy.utils.python import ( MutableAsyncChain, - MutableChain, binary_is_text, get_func_args, memoizemethod_noargs, @@ -30,16 +29,6 @@ _KT = TypeVar("_KT") _VT = TypeVar("_VT") -def test_mutablechain(): - m = MutableChain(range(2), [2, 3], (4, 5)) - m.extend(range(6, 7)) - m.extend([7, 8]) - m.extend([9, 10], (11, 12)) - assert next(m) == 0 - assert m.__next__() == 1 - assert list(m) == list(range(2, 13)) - - class TestMutableAsyncChain: @staticmethod async def g1(): From f875af4a86b294eb168cb64daba0f60b63d389f4 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Mon, 18 May 2026 18:52:13 +0500 Subject: [PATCH 157/248] Add BaseStreamingDownloadHandler and migrate HttpxDownloadHandler to it. (#7524) --- docs/topics/download-handlers.rst | 5 +- docs/topics/request-response.rst | 6 +- scrapy/core/downloader/handlers/_base_http.py | 25 ++ .../downloader/handlers/_base_streaming.py | 307 +++++++++++++++ scrapy/core/downloader/handlers/_httpx.py | 358 ++++++------------ scrapy/core/downloader/handlers/http11.py | 3 +- scrapy/http/response/__init__.py | 5 +- scrapy/utils/_download_handlers.py | 30 +- scrapy/utils/ssl.py | 19 +- tests/test_crawl.py | 17 +- tests/test_downloader_handler_httpx.py | 29 +- tests/test_downloader_handlers_http_base.py | 33 +- tox.ini | 4 +- 13 files changed, 533 insertions(+), 308 deletions(-) create mode 100644 scrapy/core/downloader/handlers/_base_http.py create mode 100644 scrapy/core/downloader/handlers/_base_streaming.py diff --git a/docs/topics/download-handlers.rst b/docs/topics/download-handlers.rst index 1e95864c6..272ff0dcd 100644 --- a/docs/topics/download-handlers.rst +++ b/docs/topics/download-handlers.rst @@ -279,14 +279,11 @@ If you want to use this handler you need to replace the default ones for the some websites may be different. Additionally, these are the Scrapy features that are explicitly not supported when using it: - - Proxy support (the :reqmeta:`proxy` meta key). - - Per-request bind address support (the :reqmeta:`bindaddress` meta key). The global :setting:`DOWNLOAD_BIND_ADDRESS` setting is supported but the port number, if specified, will be ignored. - - The :setting:`DOWNLOADER_CLIENT_TLS_CIPHERS` and - :setting:`DOWNLOADER_CLIENT_TLS_METHOD` settings. + - The :setting:`DOWNLOADER_CLIENT_TLS_METHOD` setting. - Settings specific to the Twisted networking or HTTP implementation, like :setting:`DNS_RESOLVER`. diff --git a/docs/topics/request-response.rst b/docs/topics/request-response.rst index ba7b3ee3c..d1e4851d9 100644 --- a/docs/topics/request-response.rst +++ b/docs/topics/request-response.rst @@ -917,7 +917,7 @@ Response objects :type request: scrapy.Request :param certificate: an object representing the server's SSL certificate. - :type certificate: twisted.internet.ssl.Certificate + :type certificate: typing.Any :param ip_address: The IP address of the server from which the Response originated. :type ip_address: :class:`ipaddress.IPv4Address` or :class:`ipaddress.IPv6Address` @@ -1009,8 +1009,8 @@ Response objects .. attribute:: Response.certificate - A :class:`twisted.internet.ssl.Certificate` object representing - the server's SSL certificate. + An object representing the server's SSL certificate. Its type and + contents depend on the download handler that produced the response. Only populated for ``https`` responses, ``None`` otherwise. diff --git a/scrapy/core/downloader/handlers/_base_http.py b/scrapy/core/downloader/handlers/_base_http.py new file mode 100644 index 000000000..83d130462 --- /dev/null +++ b/scrapy/core/downloader/handlers/_base_http.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from abc import ABC +from typing import TYPE_CHECKING + +from .base import BaseDownloadHandler + +if TYPE_CHECKING: + from scrapy.crawler import Crawler + + +class BaseHttpDownloadHandler(BaseDownloadHandler, ABC): + """Base class for built-in HTTP download handlers.""" + + def __init__(self, crawler: Crawler): + super().__init__(crawler) + self._default_maxsize: int = crawler.settings.getint("DOWNLOAD_MAXSIZE") + self._default_warnsize: int = crawler.settings.getint("DOWNLOAD_WARNSIZE") + self._fail_on_dataloss: bool = crawler.settings.getbool( + "DOWNLOAD_FAIL_ON_DATALOSS" + ) + self._tls_verbose_logging: bool = crawler.settings.getbool( + "DOWNLOADER_CLIENT_TLS_VERBOSE_LOGGING" + ) + self._fail_on_dataloss_warned: bool = False diff --git a/scrapy/core/downloader/handlers/_base_streaming.py b/scrapy/core/downloader/handlers/_base_streaming.py new file mode 100644 index 000000000..16b655716 --- /dev/null +++ b/scrapy/core/downloader/handlers/_base_streaming.py @@ -0,0 +1,307 @@ +from __future__ import annotations + +import base64 +import logging +import time +from abc import ABC, abstractmethod +from io import BytesIO +from typing import TYPE_CHECKING, Any, ClassVar, Generic, NoReturn, TypedDict, TypeVar +from urllib.parse import quote, urlsplit + +from scrapy import Request, signals +from scrapy.exceptions import ( + DownloadCancelledError, + NotConfigured, + ResponseDataLossError, +) +from scrapy.utils._download_handlers import ( + check_stop_download, + get_dataloss_msg, + get_maxsize_msg, + get_warnsize_msg, + make_response, + normalize_bind_address, +) +from scrapy.utils.asyncio import is_asyncio_available +from scrapy.utils.url import add_http_if_no_scheme + +from ._base_http import BaseHttpDownloadHandler + +if TYPE_CHECKING: + from collections.abc import AsyncIterable + from contextlib import AbstractAsyncContextManager + from ipaddress import IPv4Address, IPv6Address + + from _typeshed import SizedBuffer + + # typing.NotRequired requires Python 3.11 + from typing_extensions import NotRequired + + from scrapy.crawler import Crawler + from scrapy.http import Headers, Response + + +logger = logging.getLogger(__name__) + +_ResponseT = TypeVar("_ResponseT") + + +class _BaseResponseArgs(TypedDict): + status: int + url: str + headers: Headers + certificate: NotRequired[Any] + ip_address: NotRequired[IPv4Address | IPv6Address | None] + protocol: str | None + + +class BaseStreamingDownloadHandler(BaseHttpDownloadHandler, ABC, Generic[_ResponseT]): + """A base class for HTTP download handlers that follow the streaming logic flow.""" + + _DEFAULT_CONNECT_TIMEOUT: ClassVar[float] = 10 + experimental: ClassVar[bool] = False + requires_asyncio: ClassVar[bool] = True + # require subclasses to disable proxies explicitly with an explanation + supports_proxies: ClassVar[bool] = True + supports_per_request_bindaddress: ClassVar[bool] = False + + def __init__(self, crawler: Crawler): + if self.requires_asyncio and not is_asyncio_available(): # pragma: no cover + raise NotConfigured( + f"{type(self).__name__} requires the asyncio support. Make" + f" sure that you have either enabled the asyncio Twisted" + f" reactor in the TWISTED_REACTOR setting or disabled the" + f" TWISTED_REACTOR_ENABLED setting. See the asyncio documentation" + f" of Scrapy for more information." + ) + self._check_deps_installed() + super().__init__(crawler) + if self.experimental: + logger.warning( + f"{type(self).__name__} is experimental and is not recommended for production use." + ) + self._bind_address = normalize_bind_address( + crawler.settings.get("DOWNLOAD_BIND_ADDRESS") + ) + self._proxy_auth_encoding: str = crawler.settings.get("HTTPPROXY_AUTH_ENCODING") + # these are useful for many handlers but used in different ways by them + self._pool_size_total: int = crawler.settings.getint("CONCURRENT_REQUESTS") + self._pool_size_per_host: int = crawler.settings.getint( + "CONCURRENT_REQUESTS_PER_DOMAIN" + ) + + @staticmethod + @abstractmethod + def _check_deps_installed() -> None: + """Raise NotConfigured if the required deps are not installed.""" + raise NotImplementedError + + @abstractmethod + def _make_request( + self, request: Request, timeout: float + ) -> AbstractAsyncContextManager[_ResponseT]: + """Return an async context manager yielding the library-specific response. + + Exceptions raised by the library should be reraised as Scrapy-specific ones. + """ + raise NotImplementedError + + @staticmethod + @abstractmethod + def _extract_headers(response: _ResponseT) -> Headers: + """Convert library-specific response headers to a + :class:`~scrapy.http.headers.Headers` object.""" + raise NotImplementedError + + @staticmethod + @abstractmethod + def _build_base_response_args( + response: _ResponseT, request: Request, headers: Headers + ) -> _BaseResponseArgs: + """Build kwargs for :func:`scrapy.utils._download_handlers.make_response`.""" + raise NotImplementedError + + @staticmethod + @abstractmethod + def _iter_body_chunks(response: _ResponseT) -> AsyncIterable[SizedBuffer]: + """Return an async iterable yielding body chunks from the response.""" + raise NotImplementedError + + @staticmethod + @abstractmethod + def _is_dataloss_exception(exc: Exception) -> bool: + """Return True if ``exc`` represents dataloss.""" + raise NotImplementedError + + def _log_tls_info(self, response: _ResponseT, request: Request) -> None: + """Log TLS connection details, if possible.""" + + async def download_request(self, request: Request) -> Response: + if not self.supports_proxies and request.meta.get("proxy"): + raise NotImplementedError(f"{type(self).__name__} doesn't support proxies.") + if not self.supports_per_request_bindaddress and request.meta.get( + "bindaddress" + ): + logger.error( + f"The 'bindaddress' request meta key is not supported by" + f" {type(self).__name__} and will be ignored." + ) + timeout: float = request.meta.get( + "download_timeout", self._DEFAULT_CONNECT_TIMEOUT + ) + start_time = time.monotonic() + async with self._make_request(request, timeout) as response: + request.meta["download_latency"] = time.monotonic() - start_time + return await self._read_response(response, request) + + async def _read_response(self, response: _ResponseT, request: Request) -> Response: + maxsize: int = request.meta.get("download_maxsize", self._default_maxsize) + warnsize: int = request.meta.get("download_warnsize", self._default_warnsize) + + headers = self._extract_headers(response) + content_length = headers.get("Content-Length") + expected_size = int(content_length) if content_length is not None else None + if maxsize and expected_size and expected_size > maxsize: + self._cancel_maxsize(expected_size, maxsize, request, expected=True) + + reached_warnsize = False + if warnsize and expected_size and expected_size > warnsize: + reached_warnsize = True + logger.warning( + get_warnsize_msg(expected_size, warnsize, request, expected=True) + ) + + make_response_base_args = self._build_base_response_args( + response, request, headers + ) + + if self._tls_verbose_logging: + self._log_tls_info(response, request) + + if stop_download := check_stop_download( + signals.headers_received, + self.crawler, + request, + headers=headers, + body_length=expected_size, + ): + return make_response( + **make_response_base_args, + stop_download=stop_download, + ) + + response_body = BytesIO() + bytes_received = 0 + try: + async for chunk in self._iter_body_chunks(response): + response_body.write(chunk) + bytes_received += len(chunk) + + if stop_download := check_stop_download( + signals.bytes_received, self.crawler, request, data=chunk + ): + return make_response( + **make_response_base_args, + body=response_body.getvalue(), + stop_download=stop_download, + ) + + if maxsize and bytes_received > maxsize: + response_body.truncate(0) + self._cancel_maxsize( + bytes_received, maxsize, request, expected=False + ) + + if warnsize and bytes_received > warnsize and not reached_warnsize: + reached_warnsize = True + logger.warning( + get_warnsize_msg( + bytes_received, warnsize, request, expected=False + ) + ) + except Exception as e: + if not self._is_dataloss_exception(e): + raise + fail_on_dataloss: bool = request.meta.get( + "download_fail_on_dataloss", self._fail_on_dataloss + ) + if not fail_on_dataloss: + return make_response( + **make_response_base_args, + body=response_body.getvalue(), + flags=["dataloss"], + ) + if not self._fail_on_dataloss_warned: + logger.warning(get_dataloss_msg(request.url)) + self._fail_on_dataloss_warned = True + raise ResponseDataLossError(str(e)) from e + + return make_response( + **make_response_base_args, + body=response_body.getvalue(), + ) + + def _get_bind_address_host(self) -> str | None: + """Return the host portion of the bind address. + + Needed for handlers that don't support the bind port. + """ + if self._bind_address is None: + return None + host, port = self._bind_address + if port != 0: + logger.warning( + "DOWNLOAD_BIND_ADDRESS specifies a port (%s), but %s does not " + "support binding to a specific local port. Ignoring the port " + "and binding only to %r.", + port, + type(self).__name__, + host, + ) + return host + + @staticmethod + def _cancel_maxsize( + size: int, limit: int, request: Request, *, expected: bool + ) -> NoReturn: + warning_msg = get_maxsize_msg(size, limit, request, expected=expected) + logger.warning(warning_msg) + raise DownloadCancelledError(warning_msg) + + @staticmethod + def _extract_proxy(request: Request) -> tuple[str | None, str | None]: + """Return a tuple of the proxy URL with a scheme and the value of the + Proxy-Authorization header. + + This is useful for handlers that take the proxy headers separately. + """ + proxy: str | None = request.meta.get("proxy") + if not proxy: + return None, None + proxy = add_http_if_no_scheme(proxy) + auth_header: list[bytes] | None = request.headers.pop( + b"Proxy-Authorization", None + ) + return proxy, auth_header[0].decode("ascii") if auth_header else None + + def _extract_proxy_url_with_creds(self, request: Request) -> str | None: + """Return the proxy URL with the userinfo added based on the + Proxy-Authorization header. + + This is useful for handlers that cannot take the proxy headers + separately. + """ + proxy_url, auth_header = self._extract_proxy(request) + if proxy_url is None or auth_header is None: + return proxy_url + scheme, token = auth_header.split(" ", 1) + if scheme != "Basic": + raise ValueError( + f"Expected Basic auth in Proxy-Authorization, got {scheme}" + ) + user, password = ( + base64.b64decode(token).decode(self._proxy_auth_encoding).split(":", 1) + ) + parts = urlsplit(proxy_url) + netloc = f"{quote(user)}:{quote(password)}@{parts.netloc}" + return parts._replace(netloc=netloc).geturl() diff --git a/scrapy/core/downloader/handlers/_httpx.py b/scrapy/core/downloader/handlers/_httpx.py index 313fb1c4f..43e1cd965 100644 --- a/scrapy/core/downloader/handlers/_httpx.py +++ b/scrapy/core/downloader/handlers/_httpx.py @@ -3,45 +3,34 @@ from __future__ import annotations import ipaddress -import logging import ssl -import time -from http.cookiejar import Cookie, CookieJar -from io import BytesIO -from typing import TYPE_CHECKING, Any, NoReturn, TypedDict +from contextlib import asynccontextmanager +from typing import TYPE_CHECKING, ClassVar -from scrapy import Request, signals from scrapy.exceptions import ( CannotResolveHostError, - DownloadCancelledError, DownloadConnectionRefusedError, DownloadFailedError, DownloadTimeoutError, NotConfigured, - ResponseDataLossError, UnsupportedURLSchemeError, ) -from scrapy.http import Headers, Response -from scrapy.utils._download_handlers import ( - BaseHttpDownloadHandler, - check_stop_download, - get_dataloss_msg, - get_maxsize_msg, - get_warnsize_msg, - make_response, - normalize_bind_address, +from scrapy.http import Headers +from scrapy.utils._download_handlers import NullCookieJar +from scrapy.utils.ssl import ( + _log_sslobj_debug_info, + _make_insecure_ssl_ctx, + _make_ssl_context, ) -from scrapy.utils.asyncio import is_asyncio_available -from scrapy.utils.ssl import _log_sslobj_debug_info, _make_ssl_context + +from ._base_streaming import BaseStreamingDownloadHandler, _BaseResponseArgs if TYPE_CHECKING: - from contextlib import AbstractAsyncContextManager - from http.client import HTTPResponse - from ipaddress import IPv4Address, IPv6Address - from urllib.request import Request as ULRequest + from collections.abc import AsyncIterator from httpcore import AsyncNetworkStream + from scrapy import Request from scrapy.crawler import Crawler @@ -50,89 +39,90 @@ try: except ImportError: httpx = None # type: ignore[assignment] -logger = logging.getLogger(__name__) + +if TYPE_CHECKING: + _Base = BaseStreamingDownloadHandler[httpx.Response] +else: + _Base = BaseStreamingDownloadHandler -class _BaseResponseArgs(TypedDict): - status: int - url: str - headers: Headers - ip_address: IPv4Address | IPv6Address - protocol: str - - -# workaround for (and from) https://github.com/encode/httpx/issues/2992 -class _NullCookieJar(CookieJar): # pragma: no cover - """A CookieJar that rejects all cookies.""" - - def extract_cookies(self, response: HTTPResponse, request: ULRequest) -> None: - pass - - def set_cookie(self, cookie: Cookie) -> None: - pass - - -class HttpxDownloadHandler(BaseHttpDownloadHandler): - _DEFAULT_CONNECT_TIMEOUT = 10 +class HttpxDownloadHandler(_Base): + experimental: ClassVar[bool] = True def __init__(self, crawler: Crawler): - # we skip HttpxDownloadHandler tests with the non-asyncio reactor - if not is_asyncio_available(): # pragma: no cover - raise NotConfigured( - f"{type(self).__name__} requires the asyncio support. Make" - f" sure that you have either enabled the asyncio Twisted" - f" reactor in the TWISTED_REACTOR setting or disabled the" - f" TWISTED_REACTOR_ENABLED setting. See the asyncio" - f" documentation of Scrapy for more information." - ) + super().__init__(crawler) + self._verify_certificates: bool = crawler.settings.getbool( + "DOWNLOAD_VERIFY_CERTIFICATES" + ) + self._ssl_context: ssl.SSLContext = _make_ssl_context(crawler.settings) + self._bind_host: str | None = self._get_bind_address_host() + self._limits: httpx.Limits = httpx.Limits( + # hard limit on simultaneous connections + max_connections=self._pool_size_total, + # total number of idle connections in the pool (extra ones are closed) + max_keepalive_connections=self._pool_size_total, + ) + + self._default_client: httpx.AsyncClient = self._make_client() + # httpx doesn't support per-request proxies: https://github.com/encode/httpx/discussions/3183, + # so we keep a pool of clients per proxy URL. LRU eviction can be added here if needed. + self._proxy_clients: dict[str, httpx.AsyncClient] = {} + + @staticmethod + def _check_deps_installed() -> None: if httpx is None: # pragma: no cover raise NotConfigured( - f"{type(self).__name__} requires the httpx library to be installed." + "HttpxDownloadHandler requires the httpx library to be installed." ) - super().__init__(crawler) - logger.warning( - "HttpxDownloadHandler is experimental and is not recommended for production use." - ) - bind_address = crawler.settings.get("DOWNLOAD_BIND_ADDRESS") - bind_address = normalize_bind_address(bind_address) - self._bind_address: str | None = None + def _make_client(self, proxy_url: str | None = None) -> httpx.AsyncClient: + if proxy_url: + if proxy_url.startswith("https:") and not self._verify_certificates: + proxy_ssl_context = _make_insecure_ssl_ctx() + else: + proxy_ssl_context = None + proxy = httpx.Proxy(proxy_url, ssl_context=proxy_ssl_context) + else: + proxy = None - if bind_address is not None: - host, port = bind_address - if port != 0: - logger.warning( - "DOWNLOAD_BIND_ADDRESS specifies a port (%s), but %s does not " - "support binding to a specific local port. Ignoring the port " - "and binding only to %r.", - port, - type(self).__name__, - host, - ) - self._bind_address = host - - self._client = httpx.AsyncClient( - cookies=_NullCookieJar(), + client = httpx.AsyncClient( + cookies=NullCookieJar(), transport=httpx.AsyncHTTPTransport( - verify=_make_ssl_context(crawler.settings), - local_address=self._bind_address, + verify=self._ssl_context, + local_address=self._bind_host, + limits=self._limits, + trust_env=False, + proxy=proxy, ), ) # https://github.com/encode/httpx/discussions/1566 for header_name in ("accept", "accept-encoding", "user-agent"): - self._client.headers.pop(header_name, None) + client.headers.pop(header_name, None) + return client - async def download_request(self, request: Request) -> Response: - self._warn_unsupported_meta(request.meta) + def _get_client(self, proxy_url: str | None) -> httpx.AsyncClient: + if proxy_url is None: + return self._default_client + if cached := self._proxy_clients.get(proxy_url): + return cached + client = self._make_client(proxy_url) + self._proxy_clients[proxy_url] = client + return client - timeout: float = request.meta.get( - "download_timeout", self._DEFAULT_CONNECT_TIMEOUT - ) - start_time = time.monotonic() + @asynccontextmanager + async def _make_request( + self, request: Request, timeout: float + ) -> AsyncIterator[httpx.Response]: + client = self._get_client(self._extract_proxy_url_with_creds(request)) try: - async with self._get_httpx_response(request, timeout) as httpx_response: - request.meta["download_latency"] = time.monotonic() - start_time - return await self._read_response(httpx_response, request) + async with client.stream( + request.method, + request.url, + content=request.body, + headers=request.headers.to_tuple_list(), + timeout=timeout, + ) as response: + yield response except httpx.TimeoutException as e: raise DownloadTimeoutError( f"Getting {request.url} took longer than {timeout} seconds." @@ -149,159 +139,55 @@ class HttpxDownloadHandler(BaseHttpDownloadHandler): ): raise CannotResolveHostError(error_message) from e raise DownloadConnectionRefusedError(str(e)) from e - except httpx.NetworkError as e: + except httpx.ProxyError as e: + raise DownloadConnectionRefusedError(str(e)) from e + except (httpx.NetworkError, httpx.RemoteProtocolError) as e: raise DownloadFailedError(str(e)) from e - except httpx.RemoteProtocolError as e: - raise DownloadFailedError(str(e)) from e - - def _warn_unsupported_meta(self, meta: dict[str, Any]) -> None: - if meta.get("bindaddress"): - # configurable only per-client: - # https://github.com/encode/httpx/issues/755#issuecomment-2746121794 - logger.error( - f"The 'bindaddress' request meta key is not supported by" - f" {type(self).__name__} and will be ignored." - ) - if meta.get("proxy"): - # configurable only per-client: - # https://github.com/encode/httpx/issues/486 - logger.error( - f"The 'proxy' request meta key is not supported by" - f" {type(self).__name__} and will be ignored." - ) - - def _get_httpx_response( - self, request: Request, timeout: float - ) -> AbstractAsyncContextManager[httpx.Response]: - return self._client.stream( - request.method, - request.url, - content=request.body, - headers=request.headers.to_tuple_list(), - timeout=timeout, - ) - - async def _read_response( - self, httpx_response: httpx.Response, request: Request - ) -> Response: - maxsize: int = request.meta.get("download_maxsize", self._default_maxsize) - warnsize: int = request.meta.get("download_warnsize", self._default_warnsize) - - content_length = httpx_response.headers.get("Content-Length") - expected_size = int(content_length) if content_length is not None else None - if maxsize and expected_size and expected_size > maxsize: - self._cancel_maxsize(expected_size, maxsize, request, expected=True) - - reached_warnsize = False - if warnsize and expected_size and expected_size > warnsize: - reached_warnsize = True - logger.warning( - get_warnsize_msg(expected_size, warnsize, request, expected=True) - ) - - headers = Headers(httpx_response.headers.multi_items()) - network_stream: AsyncNetworkStream = httpx_response.extensions["network_stream"] - - make_response_base_args: _BaseResponseArgs = { - "status": httpx_response.status_code, - "url": request.url, - "headers": headers, - "ip_address": self._get_server_ip(network_stream), - "protocol": httpx_response.http_version, - } - - self._log_tls_info(network_stream) - - if stop_download := check_stop_download( - signals.headers_received, - self.crawler, - request, - headers=headers, - body_length=expected_size, - ): - return make_response( - **make_response_base_args, - stop_download=stop_download, - ) - - response_body = BytesIO() - bytes_received = 0 - try: - async for chunk in httpx_response.aiter_raw(): - response_body.write(chunk) - bytes_received += len(chunk) - - if stop_download := check_stop_download( - signals.bytes_received, self.crawler, request, data=chunk - ): - return make_response( - **make_response_base_args, - body=response_body.getvalue(), - stop_download=stop_download, - ) - - if maxsize and bytes_received > maxsize: - response_body.truncate(0) - self._cancel_maxsize( - bytes_received, maxsize, request, expected=False - ) - - if warnsize and bytes_received > warnsize and not reached_warnsize: - reached_warnsize = True - logger.warning( - get_warnsize_msg( - bytes_received, warnsize, request, expected=False - ) - ) - except httpx.RemoteProtocolError as e: - # special handling of the dataloss case - if ( - "peer closed connection without sending complete message body" - not in str(e) - ): - raise - fail_on_dataloss: bool = request.meta.get( - "download_fail_on_dataloss", self._fail_on_dataloss - ) - if not fail_on_dataloss: - return make_response( - **make_response_base_args, - body=response_body.getvalue(), - flags=["dataloss"], - ) - self._log_dataloss_warning(request.url) - raise ResponseDataLossError(str(e)) from e - - return make_response( - **make_response_base_args, - body=response_body.getvalue(), - ) @staticmethod - def _get_server_ip(network_stream: AsyncNetworkStream) -> IPv4Address | IPv6Address: - extra_server_addr = network_stream.get_extra_info("server_addr") - return ipaddress.ip_address(extra_server_addr[0]) + def _extract_headers(response: httpx.Response) -> Headers: + return Headers(response.headers.multi_items()) - def _log_tls_info(self, network_stream: AsyncNetworkStream) -> None: - if not self._tls_verbose_logging: - return + @staticmethod + def _build_base_response_args( + response: httpx.Response, + request: Request, + headers: Headers, + ) -> _BaseResponseArgs: + network_stream: AsyncNetworkStream = response.extensions["network_stream"] + server_addr = network_stream.get_extra_info("server_addr") + ip_address = ipaddress.ip_address(server_addr[0]) + ssl_object = network_stream.get_extra_info("ssl_object") + if isinstance(ssl_object, ssl.SSLObject): + cert = ssl_object.getpeercert(binary_form=True) + else: + cert = None + return { + "status": response.status_code, + "url": request.url, + "headers": headers, + "certificate": cert, + "ip_address": ip_address, + "protocol": response.http_version, + } + + @staticmethod + def _iter_body_chunks(response: httpx.Response) -> AsyncIterator[bytes]: + return response.aiter_raw() + + @staticmethod + def _is_dataloss_exception(exc: Exception) -> bool: + return isinstance( + exc, httpx.RemoteProtocolError + ) and "peer closed connection without sending complete message body" in str(exc) + + def _log_tls_info(self, response: httpx.Response, request: Request) -> None: + network_stream: AsyncNetworkStream = response.extensions["network_stream"] extra_ssl_object = network_stream.get_extra_info("ssl_object") if isinstance(extra_ssl_object, ssl.SSLObject): _log_sslobj_debug_info(extra_ssl_object) - def _log_dataloss_warning(self, url: str) -> None: - if self._fail_on_dataloss_warned: - return - logger.warning(get_dataloss_msg(url)) - self._fail_on_dataloss_warned = True - - @staticmethod - def _cancel_maxsize( - size: int, limit: int, request: Request, *, expected: bool - ) -> NoReturn: - warning_msg = get_maxsize_msg(size, limit, request, expected=expected) - logger.warning(warning_msg) - raise DownloadCancelledError(warning_msg) - async def close(self) -> None: - await self._client.aclose() + await self._default_client.aclose() + for client in self._proxy_clients.values(): + await client.aclose() diff --git a/scrapy/core/downloader/handlers/http11.py b/scrapy/core/downloader/handlers/http11.py index fbe24e52e..85a5ae0d7 100644 --- a/scrapy/core/downloader/handlers/http11.py +++ b/scrapy/core/downloader/handlers/http11.py @@ -41,7 +41,6 @@ from scrapy.exceptions import ( ) from scrapy.http import Headers, Response from scrapy.utils._download_handlers import ( - BaseHttpDownloadHandler, check_stop_download, get_dataloss_msg, get_maxsize_msg, @@ -57,6 +56,8 @@ from scrapy.utils.python import to_bytes, to_unicode from scrapy.utils.ssl import _log_ssl_conn_debug_info from scrapy.utils.url import add_http_if_no_scheme +from ._base_http import BaseHttpDownloadHandler + if TYPE_CHECKING: from twisted.internet.base import ReactorBase from twisted.internet.interfaces import IConsumer diff --git a/scrapy/http/response/__init__.py b/scrapy/http/response/__init__.py index a80ea3da8..09b1c8b32 100644 --- a/scrapy/http/response/__init__.py +++ b/scrapy/http/response/__init__.py @@ -20,7 +20,6 @@ if TYPE_CHECKING: from collections.abc import Callable, Iterable, Mapping from ipaddress import IPv4Address, IPv6Address - from twisted.internet.ssl import Certificate from twisted.python.failure import Failure # typing.Self requires Python 3.11 @@ -77,7 +76,7 @@ class Response(object_ref): body: bytes = b"", flags: list[str] | None = None, request: Request | None = None, - certificate: Certificate | None = None, + certificate: Any = None, ip_address: IPv4Address | IPv6Address | None = None, protocol: str | None = None, ): @@ -87,7 +86,7 @@ class Response(object_ref): self._set_url(url) self.request: Request | None = request self._flags: list[str] | None = list(flags) if flags else None - self.certificate: Certificate | None = certificate + self.certificate: Any = certificate self.ip_address: IPv4Address | IPv6Address | None = ip_address self.protocol: str | None = protocol diff --git a/scrapy/utils/_download_handlers.py b/scrapy/utils/_download_handlers.py index 9538dd81e..23b715ac6 100644 --- a/scrapy/utils/_download_handlers.py +++ b/scrapy/utils/_download_handlers.py @@ -2,8 +2,8 @@ from __future__ import annotations -from abc import ABC from contextlib import contextmanager +from http.cookiejar import CookieJar from typing import TYPE_CHECKING, Any from twisted.internet.defer import CancelledError @@ -15,7 +15,6 @@ from twisted.web.client import ResponseFailed from twisted.web.error import SchemeNotSupported from scrapy import responsetypes -from scrapy.core.downloader.handlers.base import BaseDownloadHandler from scrapy.exceptions import ( CannotResolveHostError, DownloadCancelledError, @@ -29,29 +28,24 @@ from scrapy.utils.log import logger if TYPE_CHECKING: from collections.abc import Iterator + from http.client import HTTPResponse + from http.cookiejar import Cookie from ipaddress import IPv4Address, IPv6Address - - from twisted.internet.ssl import Certificate + from urllib.request import Request as ULRequest from scrapy import Request from scrapy.crawler import Crawler from scrapy.http import Headers, Response -class BaseHttpDownloadHandler(BaseDownloadHandler, ABC): - """Base class for built-in HTTP download handlers.""" +class NullCookieJar(CookieJar): # pragma: no cover + """A CookieJar that rejects all cookies.""" - def __init__(self, crawler: Crawler): - super().__init__(crawler) - self._default_maxsize: int = crawler.settings.getint("DOWNLOAD_MAXSIZE") - self._default_warnsize: int = crawler.settings.getint("DOWNLOAD_WARNSIZE") - self._fail_on_dataloss: bool = crawler.settings.getbool( - "DOWNLOAD_FAIL_ON_DATALOSS" - ) - self._tls_verbose_logging: bool = crawler.settings.getbool( - "DOWNLOADER_CLIENT_TLS_VERBOSE_LOGGING" - ) - self._fail_on_dataloss_warned: bool = False + def extract_cookies(self, response: HTTPResponse, request: ULRequest) -> None: + pass + + def set_cookie(self, cookie: Cookie) -> None: + pass @contextmanager @@ -103,7 +97,7 @@ def make_response( headers: Headers, body: bytes = b"", flags: list[str] | None = None, - certificate: Certificate | None = None, + certificate: Any = None, ip_address: IPv4Address | IPv6Address | None = None, protocol: str | None = None, stop_download: StopDownload | None = None, diff --git a/scrapy/utils/ssl.py b/scrapy/utils/ssl.py index a8b9cd225..3fa2c77ba 100644 --- a/scrapy/utils/ssl.py +++ b/scrapy/utils/ssl.py @@ -54,6 +54,18 @@ def _make_ssl_context(settings: BaseSettings) -> ssl.SSLContext: return ctx +def _make_insecure_ssl_ctx() -> ssl.SSLContext: + """Create an SSL context that doesn't verify certificates. + + Compared to :func:`~scrapy.utils.ssl._make_ssl_context` this is much more + simple. + """ + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + return ctx + + def _log_sslobj_debug_info(sslobj: ssl.SSLObject) -> None: cipher = sslobj.cipher() logger.debug( @@ -61,8 +73,11 @@ def _log_sslobj_debug_info(sslobj: ssl.SSLObject) -> None: f" using protocol {sslobj.version()}," f" cipher {cipher[0] if cipher else None}" ) - # The peer certificate is unavailable on SSLObject unless peer - # certificate verification is enabled, which we don't want. + if cert := sslobj.getpeercert(): + # Not available without certificate verification + logger.debug( + f'SSL connection certificate: issuer "{cert["issuer"]}", subject "{cert["subject"]}"' + ) # pyOpenSSL utils diff --git a/tests/test_crawl.py b/tests/test_crawl.py index 811ce1d18..52abb1ccc 100644 --- a/tests/test_crawl.py +++ b/tests/test_crawl.py @@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, Any from urllib.parse import urlencode, urlparse import pytest +from cryptography.x509 import load_der_x509_certificate from testfixtures import LogCapture from twisted.internet.defer import succeed from twisted.internet.ssl import Certificate @@ -641,11 +642,6 @@ class TestCrawlSpider: yield crawler.crawl(seed=url, mockserver=self.mockserver) assert crawler.spider.meta["responses"][0].certificate is None - @pytest.mark.xfail( - 'config.getoption("--reactor") == "none"', - reason="Not implemented in HttpxDownloadHandler", - strict=True, - ) @pytest.mark.parametrize( "url", [ @@ -669,9 +665,14 @@ class TestCrawlSpider: await crawler.crawl_async(seed=url, mockserver=mockserver) assert isinstance(crawler.spider, SingleRequestSpider) cert = crawler.spider.meta["responses"][0].certificate - assert isinstance(cert, Certificate) - assert cert.getSubject().commonName == b"localhost" - assert cert.getIssuer().commonName == b"localhost" + assert cert is not None + if isinstance(cert, Certificate): # Twisted + assert cert.getSubject().commonName == b"localhost" + assert cert.getIssuer().commonName == b"localhost" + elif isinstance(cert, bytes): # DER bytes + cert_x509 = load_der_x509_certificate(cert) + assert cert_x509.subject.rfc4514_string() == "CN=localhost,O=Scrapy,C=IE" + assert cert_x509.issuer.rfc4514_string() == "CN=localhost,O=Scrapy,C=IE" @pytest.mark.parametrize( "url", diff --git a/tests/test_downloader_handler_httpx.py b/tests/test_downloader_handler_httpx.py index bc1acf9f9..f26a17f02 100644 --- a/tests/test_downloader_handler_httpx.py +++ b/tests/test_downloader_handler_httpx.py @@ -36,7 +36,6 @@ pytest.importorskip("httpx") class HttpxDownloadHandlerMixin: @property def download_handler_cls(self) -> type[DownloadHandlerProtocol]: - # the import will fail if httpx is not installed from scrapy.core.downloader.handlers._httpx import ( # noqa: PLC0415 HttpxDownloadHandler, ) @@ -73,27 +72,13 @@ class TestHttp(HttpxDownloadHandlerMixin, TestHttpBase): assert "DOWNLOAD_BIND_ADDRESS specifies a port (12345)" in caplog.text assert "Ignoring the port" in caplog.text - @coroutine_test - async def test_unsupported_proxy( - self, caplog: pytest.LogCaptureFixture, mockserver: MockServer - ) -> None: - meta = {"proxy": "127.0.0.2"} - request = Request(mockserver.url("/text"), meta=meta) - async with self.get_dh() as download_handler: - response = await download_handler.download_request(request) - assert response.body == b"Works" - assert ( - "The 'proxy' request meta key is not supported by HttpxDownloadHandler" - in caplog.text - ) - class TestHttps(HttpxDownloadHandlerMixin, TestHttpsBase): handler_supports_bindaddress_meta = False tls_log_message = "SSL connection to 127.0.0.1 using protocol TLSv1.3, cipher" @pytest.mark.skip(reason="The check is Twisted-specific") - def test_verify_certs_deprecated(self): + def test_verify_certs_deprecated(self) -> None: # type: ignore[override] pass @@ -126,23 +111,15 @@ class TestHttpWithCrawler(HttpxDownloadHandlerMixin, TestHttpWithCrawlerBase): class TestHttpsWithCrawler(TestHttpWithCrawler): is_secure = True - @pytest.mark.skip(reason="response.certificate is not implemented") - @coroutine_test - async def test_response_ssl_certificate(self, mockserver: MockServer) -> None: - pass - -@pytest.mark.skip(reason="Proxy support is not implemented yet") class TestHttpProxy(HttpxDownloadHandlerMixin, TestHttpProxyBase): - pass + expected_http_proxy_request_body = b"http://example.com/" -@pytest.mark.skip(reason="Proxy support is not implemented yet") -class TestHttpsProxy(HttpxDownloadHandlerMixin, TestHttpProxyBase): +class TestHttpsProxy(TestHttpProxy): is_secure = True -@pytest.mark.skip(reason="Proxy support is not implemented yet") @pytest.mark.requires_mitmproxy class TestMitmProxy(HttpxDownloadHandlerMixin, TestMitmProxyBase): pass diff --git a/tests/test_downloader_handlers_http_base.py b/tests/test_downloader_handlers_http_base.py index a8d002d30..51da8b38f 100644 --- a/tests/test_downloader_handlers_http_base.py +++ b/tests/test_downloader_handlers_http_base.py @@ -17,6 +17,7 @@ from typing import TYPE_CHECKING, Any, ClassVar from urllib.parse import urlparse import pytest +from cryptography.x509 import load_der_x509_certificate from twisted.internet.ssl import Certificate from twisted.python.failure import Failure @@ -981,15 +982,19 @@ class TestHttpWithCrawlerBase(ABC): if not self.is_secure: pytest.skip("Only applies to HTTPS") # copy of TestCrawl.test_response_ssl_certificate() - # the current test implementation can only work for Twisted-based download handlers crawler = get_crawler(SingleRequestSpider, self.settings_dict) url = mockserver.url("/echo?body=test", is_secure=self.is_secure) await crawler.crawl_async(seed=url, mockserver=mockserver) assert isinstance(crawler.spider, SingleRequestSpider) cert = crawler.spider.meta["responses"][0].certificate - assert isinstance(cert, Certificate) - assert cert.getSubject().commonName == b"localhost" - assert cert.getIssuer().commonName == b"localhost" + assert cert is not None + if isinstance(cert, Certificate): # Twisted + assert cert.getSubject().commonName == b"localhost" + assert cert.getIssuer().commonName == b"localhost" + elif isinstance(cert, bytes): # DER bytes + cert_x509 = load_der_x509_certificate(cert) + assert cert_x509.subject.rfc4514_string() == "CN=localhost,O=Scrapy,C=IE" + assert cert_x509.issuer.rfc4514_string() == "CN=localhost,O=Scrapy,C=IE" @coroutine_test async def test_response_ip_address(self, mockserver: MockServer) -> None: @@ -1279,7 +1284,6 @@ class TestRealWebsiteBase(ABC): raise NotImplementedError @property - @abstractmethod def platform_cert_store_works(self) -> bool: """Whether valid certificates can be verified. @@ -1331,3 +1335,22 @@ class TestRealWebsiteBase(ABC): response = await download_handler.download_request(request) assert response.status == 200 assert "All products | Books to Scrape - Sandbox" in response.text + + @pytest.mark.parametrize("verify_certs", [True, False]) + @coroutine_test + async def test_tls_logging( + self, caplog: pytest.LogCaptureFixture, verify_certs: bool + ) -> None: + if verify_certs and not self.platform_cert_store_works: + pytest.skip("Cannot verify certificates") + request = Request("https://books.toscrape.com/") + async with self.get_dh( + { + "DOWNLOADER_CLIENT_TLS_VERBOSE_LOGGING": True, + "DOWNLOAD_VERIFY_CERTIFICATES": verify_certs, + } + ) as download_handler: + with caplog.at_level("DEBUG"): + response = await download_handler.download_request(request) + assert response.status == 200 + assert "SSL connection to books.toscrape.com using protocol" in caplog.text diff --git a/tox.ini b/tox.ini index 72686d3d0..da5a8a9a6 100644 --- a/tox.ini +++ b/tox.ini @@ -113,7 +113,7 @@ deps = Twisted==21.7.0 cryptography==37.0.0 cssselect==0.9.1 - httpx==0.26.0 + httpx==0.27.1 itemadapter==0.1.0 lxml==4.6.4 parsel==1.5.0 @@ -165,7 +165,7 @@ deps = brotli==1.2.0; implementation_name != "pypy" brotlicffi==1.2.0.0; implementation_name == "pypy" google-cloud-storage==1.29.0 - httpx==0.26.0 + httpx==0.27.1 ipython==7.1.0 robotexclusionrulesparser==1.6.2 uvloop==0.16.0; platform_system != "Windows" and implementation_name != "pypy" From 55c17a89858e72c2e85391b1db3bbb2ba6c77360 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Mon, 18 May 2026 18:54:06 +0500 Subject: [PATCH 158/248] Disable mypy allow_any_generics. (#7532) --- pyproject.toml | 1 - scrapy/commands/genspider.py | 2 +- scrapy/contracts/__init__.py | 12 +++++----- scrapy/core/downloader/handlers/s3.py | 6 +++-- scrapy/core/downloader/middleware.py | 10 ++++----- scrapy/core/spidermw.py | 14 +++++++----- scrapy/extensions/httpcache.py | 4 +++- scrapy/http/response/text.py | 2 +- scrapy/middleware.py | 6 ++--- scrapy/spiders/crawl.py | 4 +++- scrapy/squeues.py | 2 +- scrapy/utils/asyncio.py | 2 +- scrapy/utils/conf.py | 4 ++-- scrapy/utils/datatypes.py | 22 +++++++++---------- scrapy/utils/defer.py | 2 +- scrapy/utils/log.py | 3 ++- scrapy/utils/misc.py | 6 +++-- scrapy/utils/reactor.py | 2 +- scrapy/utils/signal.py | 4 +++- scrapy/utils/template.py | 2 +- scrapy/utils/trackref.py | 4 +++- .../reactorless_custom_settings.py | 2 +- .../twisted_reactor_custom_settings_select.py | 2 +- tests/mocks/dummydbm.py | 2 +- tests/spiders.py | 13 ++++++----- tests/test_command_genspider.py | 2 +- tests/test_command_shell.py | 5 ++--- tests/test_command_startproject.py | 2 +- tests/test_feedexport.py | 6 +++-- tests/test_feedexport_batch.py | 2 +- tests/test_http2_client_protocol.py | 2 +- tests/test_loader.py | 2 +- tests/test_pipeline_files.py | 8 +++---- tests/test_pipeline_images.py | 8 +++---- tests/test_scheduler_base.py | 4 ++-- tests/test_spidermiddleware.py | 2 +- tests/test_utils_datatypes.py | 5 +++-- tests/test_utils_log.py | 4 +++- tests/test_utils_request.py | 5 +++-- tox.ini | 10 ++++----- 40 files changed, 112 insertions(+), 88 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 76b8bbead..2f07dcd80 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -86,7 +86,6 @@ pattern = "^(?P.+)$" [tool.mypy] strict = true -allow_any_generics = true # 67 errors extra_checks = false # weird addErrback() errors untyped_calls_exclude = [ "twisted", diff --git a/scrapy/commands/genspider.py b/scrapy/commands/genspider.py index 17bcf19b0..cc8624fa1 100644 --- a/scrapy/commands/genspider.py +++ b/scrapy/commands/genspider.py @@ -147,7 +147,7 @@ class Command(ScrapyCommand): name: str, url: str, template_name: str, - template_file: str | os.PathLike, + template_file: str | os.PathLike[str], ) -> None: """Generate the spider module, based on the given template""" assert self.settings is not None diff --git a/scrapy/contracts/__init__.py b/scrapy/contracts/__init__.py index 24f56b7a6..c0da7dfa4 100644 --- a/scrapy/contracts/__init__.py +++ b/scrapy/contracts/__init__.py @@ -27,7 +27,7 @@ class Contract: request_cls: type[Request] | None = None name: str - def __init__(self, method: Callable, *args: Any): + def __init__(self, method: Callable[..., Any], *args: Any): self.testcase_pre = _create_testcase(method, f"@{self.name} pre-hook") self.testcase_post = _create_testcase(method, f"@{self.name} post-hook") self.args: tuple[Any, ...] = args @@ -105,7 +105,7 @@ class ContractsManager: return methods - def extract_contracts(self, method: Callable) -> list[Contract]: + def extract_contracts(self, method: Callable[..., Any]) -> list[Contract]: contracts: list[Contract] = [] assert method.__doc__ is not None for line_ in method.__doc__.split("\n"): @@ -134,7 +134,9 @@ class ContractsManager: return requests - def from_method(self, method: Callable, results: TestResult) -> Request | None: + def from_method( + self, method: Callable[..., Any], results: TestResult + ) -> Request | None: contracts = self.extract_contracts(method) if contracts: request_cls = Request @@ -170,7 +172,7 @@ class ContractsManager: return None def _clean_req( - self, request: Request, method: Callable, results: TestResult + self, request: Request, method: Callable[..., Any], results: TestResult ) -> None: """stop the request from returning objects and records any errors""" @@ -195,7 +197,7 @@ class ContractsManager: request.errback = eb_wrapper -def _create_testcase(method: Callable, desc: str) -> TestCase: +def _create_testcase(method: Callable[..., Any], desc: str) -> TestCase: spider = method.__self__.name # type: ignore[attr-defined] class ContractTestCase(TestCase): diff --git a/scrapy/core/downloader/handlers/s3.py b/scrapy/core/downloader/handlers/s3.py index 19a1e8503..c6660ff48 100644 --- a/scrapy/core/downloader/handlers/s3.py +++ b/scrapy/core/downloader/handlers/s3.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any, cast from scrapy.core.downloader.handlers.base import BaseDownloadHandler from scrapy.exceptions import NotConfigured @@ -9,6 +9,8 @@ from scrapy.utils.httpobj import urlparse_cached from scrapy.utils.misc import build_from_crawler, load_object if TYPE_CHECKING: + from collections.abc import Mapping + from scrapy import Request from scrapy.crawler import Crawler from scrapy.http import Response @@ -59,7 +61,7 @@ class S3DownloadHandler(BaseDownloadHandler): awsrequest = botocore.awsrequest.AWSRequest( method=request.method, url=f"{scheme}://s3.amazonaws.com/{bucket}{path}", - headers=request.headers.to_unicode_dict(), + headers=cast("Mapping[str, Any]", request.headers.to_unicode_dict()), data=request.body, ) assert self._signer diff --git a/scrapy/core/downloader/middleware.py b/scrapy/core/downloader/middleware.py index 6685d90af..ab74e22a4 100644 --- a/scrapy/core/downloader/middleware.py +++ b/scrapy/core/downloader/middleware.py @@ -8,7 +8,7 @@ from __future__ import annotations import warnings from functools import wraps -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any from scrapy.exceptions import ScrapyDeprecationWarning, _InvalidOutput from scrapy.http import Request, Response @@ -87,7 +87,7 @@ class DownloaderMiddlewareManager(MiddlewareManager): result = await self._process_exception(ex, request) return await self._process_response(result, request) - def _handle_mw_method(self, method: Callable, **kwargs: Any) -> Any: + def _handle_mw_method(self, method: Callable[..., Any], **kwargs: Any) -> Any: if method in self._mw_methods_requiring_spider: kwargs["spider"] = self._spider @@ -99,7 +99,7 @@ class DownloaderMiddlewareManager(MiddlewareManager): download_func: Callable[[Request], Coroutine[Any, Any, Response]], ) -> Response | Request: for method in self.methods["process_request"]: - method = cast("Callable", method) + assert method is not None response = await ensure_awaitable( self._handle_mw_method(method, request=request), _warn=global_object_name(method), @@ -122,7 +122,7 @@ class DownloaderMiddlewareManager(MiddlewareManager): return response for method in self.methods["process_response"]: - method = cast("Callable", method) + assert method is not None response = await ensure_awaitable( self._handle_mw_method(method, request=request, response=response), _warn=global_object_name(method), @@ -141,7 +141,7 @@ class DownloaderMiddlewareManager(MiddlewareManager): self, exception: Exception, request: Request | Response ) -> Response | Request: for method in self.methods["process_exception"]: - method = cast("Callable", method) + assert method is not None response = await ensure_awaitable( self._handle_mw_method(method, request=request, exception=exception), _warn=global_object_name(method), diff --git a/scrapy/core/spidermw.py b/scrapy/core/spidermw.py index bcf38bb75..fbc6f2530 100644 --- a/scrapy/core/spidermw.py +++ b/scrapy/core/spidermw.py @@ -11,7 +11,7 @@ from collections.abc import AsyncIterator, Callable, Coroutine, Iterable from functools import wraps from inspect import isasyncgenfunction from itertools import islice -from typing import TYPE_CHECKING, Any, TypeAlias, TypeVar, cast +from typing import TYPE_CHECKING, Any, TypeAlias, TypeVar from warnings import warn from twisted.python.failure import Failure @@ -79,7 +79,7 @@ class SpiderMiddlewareManager(MiddlewareManager): request: Request, ) -> Iterable[_T] | AsyncIterator[_T]: for method in self.methods["process_spider_input"]: - method = cast("Callable", method) + assert method is not None try: if method in self._mw_methods_requiring_spider: result = method(response=response, spider=self._spider) @@ -248,9 +248,13 @@ class SpiderMiddlewareManager(MiddlewareManager): # This method is only needed until _async compatibility methods are removed. @staticmethod - def _get_process_spider_output(mw: Any) -> Callable | None: - normal_method: Callable | None = getattr(mw, "process_spider_output", None) - async_method: Callable | None = getattr(mw, "process_spider_output_async", None) + def _get_process_spider_output(mw: Any) -> Callable[..., Any] | None: + normal_method: Callable[..., Any] | None = getattr( + mw, "process_spider_output", None + ) + async_method: Callable[..., Any] | None = getattr( + mw, "process_spider_output_async", None + ) if not async_method: if normal_method and not isasyncgenfunction(normal_method): raise TypeError( diff --git a/scrapy/extensions/httpcache.py b/scrapy/extensions/httpcache.py index 86b066a38..dbb79b02d 100644 --- a/scrapy/extensions/httpcache.py +++ b/scrapy/extensions/httpcache.py @@ -313,7 +313,9 @@ class FilesystemCacheStorage: self.expiration_secs: int = settings.getint("HTTPCACHE_EXPIRATION_SECS") self.use_gzip: bool = settings.getbool("HTTPCACHE_GZIP") # https://github.com/python/mypy/issues/10740 - self._open: Callable[Concatenate[str | os.PathLike, str, ...], IO[bytes]] = ( + self._open: Callable[ + Concatenate[str | os.PathLike[str], str, ...], IO[bytes] + ] = ( gzip.open if self.use_gzip else open # type: ignore[assignment] ) diff --git a/scrapy/http/response/text.py b/scrapy/http/response/text.py index 13853f64d..6876e35e8 100644 --- a/scrapy/http/response/text.py +++ b/scrapy/http/response/text.py @@ -220,7 +220,7 @@ class TextResponse(Response): def follow_all( self, - urls: Iterable[str | Link] | parsel.SelectorList | None = None, + urls: Iterable[str | Link] | parsel.SelectorList[Any] | None = None, callback: CallbackT | None = None, method: str = "GET", headers: Mapping[AnyStr, Any] | Iterable[tuple[AnyStr, Any]] | None = None, diff --git a/scrapy/middleware.py b/scrapy/middleware.py index 14b591a5f..37ec0f583 100644 --- a/scrapy/middleware.py +++ b/scrapy/middleware.py @@ -50,8 +50,8 @@ class MiddlewareManager(ABC): ) self.middlewares: tuple[Any, ...] = middlewares # Only process_spider_output and process_spider_exception can be None. - self.methods: dict[str, deque[Callable | None]] = defaultdict(deque) - self._mw_methods_requiring_spider: set[Callable] = set() + self.methods: dict[str, deque[Callable[..., Any] | None]] = defaultdict(deque) + self._mw_methods_requiring_spider: set[Callable[..., Any]] = set() for mw in middlewares: self._add_middleware(mw) @@ -116,7 +116,7 @@ class MiddlewareManager(ABC): def _add_middleware(self, mw: Any) -> None: # noqa: B027 pass - def _check_mw_method_spider_arg(self, method: Callable) -> None: + def _check_mw_method_spider_arg(self, method: Callable[..., Any]) -> None: if argument_is_required(method, "spider"): warnings.warn( f"{method.__qualname__}() requires a spider argument," diff --git a/scrapy/spiders/crawl.py b/scrapy/spiders/crawl.py index f0d093c6e..e14279b64 100644 --- a/scrapy/spiders/crawl.py +++ b/scrapy/spiders/crawl.py @@ -46,7 +46,9 @@ def _identity_process_request(request: Request, response: Response) -> Request | return request -def _get_method(method: Callable | str | None, spider: Spider) -> Callable | None: +def _get_method( + method: Callable[..., Any] | str | None, spider: Spider +) -> Callable[..., Any] | None: if callable(method): return method if isinstance(method, str): diff --git a/scrapy/squeues.py b/scrapy/squeues.py index 7007cd4b8..e06e38e23 100644 --- a/scrapy/squeues.py +++ b/scrapy/squeues.py @@ -26,7 +26,7 @@ if TYPE_CHECKING: def _with_mkdir(queue_class: type[queue.BaseQueue]) -> type[queue.BaseQueue]: class DirectoriesCreated(queue_class): # type: ignore[valid-type,misc] - def __init__(self, path: str | PathLike, *args: Any, **kwargs: Any): + def __init__(self, path: str | PathLike[str], *args: Any, **kwargs: Any): dirname = Path(path).parent if not dirname.exists(): dirname.mkdir(parents=True, exist_ok=True) diff --git a/scrapy/utils/asyncio.py b/scrapy/utils/asyncio.py index ecb4c1492..44604c0fe 100644 --- a/scrapy/utils/asyncio.py +++ b/scrapy/utils/asyncio.py @@ -148,7 +148,7 @@ class AsyncioLoopingCall: self._func: Callable[_P, _T] = func self._args: tuple[Any, ...] = args self._kwargs: dict[str, Any] = kwargs - self._task: asyncio.Task | None = None + self._task: asyncio.Task[None] | None = None self.interval: float | None = None self._start_time: float | None = None diff --git a/scrapy/utils/conf.py b/scrapy/utils/conf.py index 5869cf52e..4850b370b 100644 --- a/scrapy/utils/conf.py +++ b/scrapy/utils/conf.py @@ -71,8 +71,8 @@ def arglist_to_dict(arglist: list[str]) -> dict[str, str]: def closest_scrapy_cfg( - path: str | os.PathLike = ".", - prevpath: str | os.PathLike | None = None, + path: str | os.PathLike[str] = ".", + prevpath: str | os.PathLike[str] | None = None, ) -> str: """Return the path to the closest scrapy.cfg file by traversing the current directory and its parents diff --git a/scrapy/utils/datatypes.py b/scrapy/utils/datatypes.py index 146e3ae56..4e65c062e 100644 --- a/scrapy/utils/datatypes.py +++ b/scrapy/utils/datatypes.py @@ -28,7 +28,7 @@ _KT = TypeVar("_KT") _VT = TypeVar("_VT") -class CaselessDict(dict): +class CaselessDict(dict): # type: ignore[type-arg] __slots__ = () def __new__(cls, *args: Any, **kwargs: Any) -> Self: @@ -99,20 +99,20 @@ class CaselessDict(dict): return dict.pop(self, self.normkey(key), *args) -class CaseInsensitiveDict(collections.UserDict): +class CaseInsensitiveDict(collections.UserDict[str | bytes, Any]): """A dict-like structure that accepts strings or bytes as keys and allows case-insensitive lookups. """ def __init__(self, *args: Any, **kwargs: Any) -> None: - self._keys: dict = {} + self._keys: dict[str | bytes, Any] = {} super().__init__(*args, **kwargs) - def __getitem__(self, key: AnyStr) -> Any: + def __getitem__(self, key: str | bytes) -> Any: normalized_key = self._normkey(key) return super().__getitem__(self._keys[normalized_key.lower()]) - def __setitem__(self, key: AnyStr, value: Any) -> None: + def __setitem__(self, key: str | bytes, value: Any) -> None: normalized_key = self._normkey(key) try: lower_key = self._keys[normalized_key.lower()] @@ -122,19 +122,19 @@ class CaseInsensitiveDict(collections.UserDict): super().__setitem__(normalized_key, self._normvalue(value)) self._keys[normalized_key.lower()] = normalized_key - def __delitem__(self, key: AnyStr) -> None: + def __delitem__(self, key: str | bytes) -> None: normalized_key = self._normkey(key) stored_key = self._keys.pop(normalized_key.lower()) super().__delitem__(stored_key) - def __contains__(self, key: AnyStr) -> bool: # type: ignore[override] + def __contains__(self, key: str | bytes) -> bool: # type: ignore[override] normalized_key = self._normkey(key) return normalized_key.lower() in self._keys def __repr__(self) -> str: return f"<{self.__class__.__name__}: {super().__repr__()}>" - def _normkey(self, key: AnyStr) -> AnyStr: + def _normkey(self, key: str | bytes) -> str | bytes: return key def _normvalue(self, value: Any) -> Any: @@ -158,7 +158,7 @@ class LocalCache(OrderedDict[_KT, _VT]): super().__setitem__(key, value) -class LocalWeakReferencedCache(weakref.WeakKeyDictionary): +class LocalWeakReferencedCache(weakref.WeakKeyDictionary[_KT, _VT | None]): """ A weakref.WeakKeyDictionary implementation that uses LocalCache as its underlying data structure, making it ordered and capable of being size-limited. @@ -172,9 +172,9 @@ class LocalWeakReferencedCache(weakref.WeakKeyDictionary): def __init__(self, limit: int | None = None): super().__init__() - self.data: LocalCache = LocalCache(limit=limit) + self.data: LocalCache[_KT, _VT] = LocalCache(limit=limit) - def __setitem__(self, key: _KT, value: _VT) -> None: + def __setitem__(self, key: _KT, value: _VT | None) -> None: # if raised, key is not weak-referenceable, skip caching with contextlib.suppress(TypeError): super().__setitem__(key, value) diff --git a/scrapy/utils/defer.py b/scrapy/utils/defer.py index 90fd04777..29a34d4ef 100644 --- a/scrapy/utils/defer.py +++ b/scrapy/utils/defer.py @@ -173,7 +173,7 @@ def parallel( return DeferredList([coop.coiterate(work) for _ in range(count)]) -class _AsyncCooperatorAdapter(Iterator, Generic[_T]): +class _AsyncCooperatorAdapter(Iterator[Deferred[Any]], Generic[_T]): """A class that wraps an async iterable into a normal iterator suitable for using in Cooperator.coiterate(). As it's only needed for parallel_async(), it calls the callable directly in the callback, instead of providing a more diff --git a/scrapy/utils/log.py b/scrapy/utils/log.py index 102362506..ee65d4155 100644 --- a/scrapy/utils/log.py +++ b/scrapy/utils/log.py @@ -260,7 +260,8 @@ def logformatter_adapter( return (level, message, args) -class SpiderLoggerAdapter(logging.LoggerAdapter): +# LoggerAdapter is only parameterized since Python 3.11 +class SpiderLoggerAdapter(logging.LoggerAdapter): # type: ignore[type-arg] def process( self, msg: str, kwargs: MutableMapping[str, Any] ) -> tuple[str, MutableMapping[str, Any]]: diff --git a/scrapy/utils/misc.py b/scrapy/utils/misc.py index 3baa02e2b..0b67eaa34 100644 --- a/scrapy/utils/misc.py +++ b/scrapy/utils/misc.py @@ -28,7 +28,7 @@ if TYPE_CHECKING: _ITERABLE_SINGLE_VALUES = dict, Item, str, bytes -_ITER_T = TypeVar("_ITER_T", bound=dict | Item | str | bytes) +_ITER_T = TypeVar("_ITER_T", bound=dict[Any, Any] | Item | str | bytes) _T = TypeVar("_T") _T_co = TypeVar("_T_co", covariant=True) _P = ParamSpec("_P") @@ -252,7 +252,9 @@ def walk_callable(node: ast.AST) -> Iterable[ast.AST]: yield node -_generator_callbacks_cache = LocalWeakReferencedCache(limit=128) +_generator_callbacks_cache: LocalWeakReferencedCache[Callable[..., Any], bool] = ( + LocalWeakReferencedCache(limit=128) +) def _returns_none(return_node: ast.Return) -> bool: diff --git a/scrapy/utils/reactor.py b/scrapy/utils/reactor.py index 37db6c25f..7ab58093a 100644 --- a/scrapy/utils/reactor.py +++ b/scrapy/utils/reactor.py @@ -55,7 +55,7 @@ class CallLaterOnce(Generic[_T]): self._a: tuple[Any, ...] = a self._kw: dict[str, Any] = kw self._call: CallLaterResult | None = None - self._deferreds: list[Deferred] = [] + self._deferreds: list[Deferred[None]] = [] def schedule(self, delay: float = 0) -> None: # circular import diff --git a/scrapy/utils/signal.py b/scrapy/utils/signal.py index d9a72273a..ee391a49e 100644 --- a/scrapy/utils/signal.py +++ b/scrapy/utils/signal.py @@ -186,7 +186,9 @@ async def _send_catch_log_asyncio( handlers: list[Awaitable[TypingAny]] = [] for receiver in liveReceivers(getAllReceivers(sender, signal)): - async def handler(receiver: Callable) -> tuple[Callable, TypingAny]: + async def handler( + receiver: Callable[..., Any], + ) -> tuple[Callable[..., Any], TypingAny]: result: TypingAny try: result = await ensure_awaitable( diff --git a/scrapy/utils/template.py b/scrapy/utils/template.py index 3e4dae5c8..977d5a42a 100644 --- a/scrapy/utils/template.py +++ b/scrapy/utils/template.py @@ -11,7 +11,7 @@ if TYPE_CHECKING: from os import PathLike -def render_templatefile(path: str | PathLike, **kwargs: Any) -> None: +def render_templatefile(path: str | PathLike[str], **kwargs: Any) -> None: path_obj = Path(path) raw = path_obj.read_text("utf8") diff --git a/scrapy/utils/trackref.py b/scrapy/utils/trackref.py index 082aca4b1..87df10a02 100644 --- a/scrapy/utils/trackref.py +++ b/scrapy/utils/trackref.py @@ -30,7 +30,9 @@ if TYPE_CHECKING: from typing_extensions import Self -live_refs: defaultdict[type, WeakKeyDictionary] = defaultdict(WeakKeyDictionary) +live_refs: defaultdict[type, WeakKeyDictionary[object, float]] = defaultdict( + WeakKeyDictionary +) class object_ref: diff --git a/tests/AsyncCrawlerProcess/reactorless_custom_settings.py b/tests/AsyncCrawlerProcess/reactorless_custom_settings.py index 93259f696..f49741441 100644 --- a/tests/AsyncCrawlerProcess/reactorless_custom_settings.py +++ b/tests/AsyncCrawlerProcess/reactorless_custom_settings.py @@ -23,7 +23,7 @@ class NoRequestsSpider(scrapy.Spider): yield -def log_task_exception(task: Task) -> None: +def log_task_exception(task: Task[None]) -> None: try: task.result() except Exception: diff --git a/tests/AsyncCrawlerProcess/twisted_reactor_custom_settings_select.py b/tests/AsyncCrawlerProcess/twisted_reactor_custom_settings_select.py index f6fead718..b85f60f87 100644 --- a/tests/AsyncCrawlerProcess/twisted_reactor_custom_settings_select.py +++ b/tests/AsyncCrawlerProcess/twisted_reactor_custom_settings_select.py @@ -17,7 +17,7 @@ class AsyncioReactorSpider(scrapy.Spider): } -def log_task_exception(task: Task) -> None: +def log_task_exception(task: Task[None]) -> None: try: task.result() except Exception: diff --git a/tests/mocks/dummydbm.py b/tests/mocks/dummydbm.py index e358eaca4..98fe58860 100644 --- a/tests/mocks/dummydbm.py +++ b/tests/mocks/dummydbm.py @@ -4,7 +4,7 @@ from collections import defaultdict from typing import Any -class DummyDB(dict): +class DummyDB(dict): # type: ignore[type-arg] """Provide dummy DBM-like interface.""" def close(self): diff --git a/tests/spiders.py b/tests/spiders.py index 373dc00bd..065a0e6d7 100644 --- a/tests/spiders.py +++ b/tests/spiders.py @@ -22,6 +22,7 @@ from scrapy.utils.defer import deferred_to_future, maybe_deferred_to_future from scrapy.utils.test import get_from_asyncio_queue if TYPE_CHECKING: + from scrapy.settings import _SettingsKey from tests.mockserver.http import MockServer @@ -94,19 +95,19 @@ class DelaySpider(MetaSpider): class LogSpider(MetaSpider): name = "log_spider" - def log_debug(self, message: str, extra: dict | None = None): + def log_debug(self, message: str, extra: dict[str, Any] | None = None): self.logger.debug(message, extra=extra) - def log_info(self, message: str, extra: dict | None = None): + def log_info(self, message: str, extra: dict[str, Any] | None = None): self.logger.info(message, extra=extra) - def log_warning(self, message: str, extra: dict | None = None): + def log_warning(self, message: str, extra: dict[str, Any] | None = None): self.logger.warning(message, extra=extra) - def log_error(self, message: str, extra: dict | None = None): + def log_error(self, message: str, extra: dict[str, Any] | None = None): self.logger.error(message, extra=extra) - def log_critical(self, message: str, extra: dict | None = None): + def log_critical(self, message: str, extra: dict[str, Any] | None = None): self.logger.critical(message, extra=extra) def parse(self, response): @@ -417,7 +418,7 @@ class CrawlSpiderWithParseMethod(MockServerSpider, CrawlSpider): """ name = "crawl_spider_with_parse_method" - custom_settings: dict = { + custom_settings: dict[_SettingsKey, Any] = { "RETRY_HTTP_CODES": [], # no need to retry } rules = (Rule(LinkExtractor(), callback="parse", follow=True),) diff --git a/tests/test_command_genspider.py b/tests/test_command_genspider.py index fd9505060..67e3eb50a 100644 --- a/tests/test_command_genspider.py +++ b/tests/test_command_genspider.py @@ -9,7 +9,7 @@ from tests.test_commands import TestProjectBase from tests.utils.cmdline import call, proc -def find_in_file(filename: Path, regex: str) -> re.Match | None: +def find_in_file(filename: Path, regex: str) -> re.Match[str] | None: """Find first pattern occurrence in file""" pattern = re.compile(regex) with filename.open("r", encoding="utf-8") as f: diff --git a/tests/test_command_shell.py b/tests/test_command_shell.py index b407211dd..1585835cc 100644 --- a/tests/test_command_shell.py +++ b/tests/test_command_shell.py @@ -4,7 +4,7 @@ import os import sys from io import BytesIO from pathlib import Path -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING import pytest from pexpect.popen_spawn import PopenSpawn @@ -151,8 +151,7 @@ class TestInteractiveShell: env = os.environ.copy() env["SCRAPY_PYTHON_SHELL"] = "python" logfile = BytesIO() - # https://github.com/python/typeshed/issues/14915 - p = PopenSpawn(args, env=cast("os._Environ", env), timeout=5) + p = PopenSpawn(args, env=env, timeout=5) p.logfile_read = logfile p.expect_exact("Available Scrapy objects") p.sendline(f"fetch('{mockserver.url('/')}')") diff --git a/tests/test_command_startproject.py b/tests/test_command_startproject.py index 4eed09fcd..2a9d0ed57 100644 --- a/tests/test_command_startproject.py +++ b/tests/test_command_startproject.py @@ -79,7 +79,7 @@ class TestStartprojectCommand: def get_permissions_dict( - path: str | os.PathLike, renamings=None, ignore=None + path: str | os.PathLike[str], renamings=None, ignore=None ) -> dict[str, str]: def get_permissions(path: Path) -> str: return oct(path.stat().st_mode) diff --git a/tests/test_feedexport.py b/tests/test_feedexport.py index cbf568524..27e0c6445 100644 --- a/tests/test_feedexport.py +++ b/tests/test_feedexport.py @@ -41,7 +41,7 @@ from tests.spiders import ItemSpider from tests.utils.decorators import coroutine_test, inline_callbacks_test if TYPE_CHECKING: - from collections.abc import Callable, Iterable + from collections.abc import Awaitable, Callable, Iterable def path_to_url(path: str | Path) -> str: @@ -1312,7 +1312,9 @@ class TestFeedExporterSignals: self.feed_slot_closed_received = True async def run_signaled_feed_exporter( - self, feed_exporter_signal_handler: Callable, feed_slot_signal_handler: Callable + self, + feed_exporter_signal_handler: Callable[[], Awaitable[None] | None], + feed_slot_signal_handler: Callable[[Any], Awaitable[None] | None], ) -> None: crawler = get_crawler(settings_dict=self.settings) feed_exporter = FeedExporter.from_crawler(crawler) diff --git a/tests/test_feedexport_batch.py b/tests/test_feedexport_batch.py index 3b70e896c..d855d0f74 100644 --- a/tests/test_feedexport_batch.py +++ b/tests/test_feedexport_batch.py @@ -29,7 +29,7 @@ if TYPE_CHECKING: from os import PathLike -def build_url(path: str | PathLike) -> str: +def build_url(path: str | PathLike[str]) -> str: path_str = str(path) if path_str[0] != "/": path_str = "/" + path_str diff --git a/tests/test_http2_client_protocol.py b/tests/test_http2_client_protocol.py index 6072e2f6d..cec5d728b 100644 --- a/tests/test_http2_client_protocol.py +++ b/tests/test_http2_client_protocol.py @@ -61,7 +61,7 @@ def make_html_body(val: str) -> bytes: class DummySpider(Spider): name = "dummy" - start_urls: list = [] + start_urls = [] def parse(self, response): print(response) diff --git a/tests/test_loader.py b/tests/test_loader.py index 7cfc7ed26..c094d25d8 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -39,7 +39,7 @@ class AttrsNameItem: @dataclasses.dataclass class NameDataClass: - name: list = dataclasses.field(default_factory=list) + name: list[str] = dataclasses.field(default_factory=list) # test item loaders diff --git a/tests/test_pipeline_files.py b/tests/test_pipeline_files.py index dbe8d85a5..c6a41fa6e 100644 --- a/tests/test_pipeline_files.py +++ b/tests/test_pipeline_files.py @@ -383,11 +383,11 @@ class TestFilesPipelineFieldsItem(TestFilesPipelineFieldsMixin): class FilesPipelineTestDataClass: name: str # default fields - file_urls: list = dataclasses.field(default_factory=list) - files: list = dataclasses.field(default_factory=list) + file_urls: list[str] = dataclasses.field(default_factory=list) + files: list[dict[str, str]] = dataclasses.field(default_factory=list) # overridden fields - custom_file_urls: list = dataclasses.field(default_factory=list) - custom_files: list = dataclasses.field(default_factory=list) + custom_file_urls: list[str] = dataclasses.field(default_factory=list) + custom_files: list[dict[str, str]] = dataclasses.field(default_factory=list) class TestFilesPipelineFieldsDataClass(TestFilesPipelineFieldsMixin): diff --git a/tests/test_pipeline_images.py b/tests/test_pipeline_images.py index 199ec5afa..38662348f 100644 --- a/tests/test_pipeline_images.py +++ b/tests/test_pipeline_images.py @@ -314,11 +314,11 @@ class TestImagesPipelineFieldsItem(TestImagesPipelineFieldsMixin): class ImagesPipelineTestDataClass: name: str # default fields - image_urls: list = dataclasses.field(default_factory=list) - images: list = dataclasses.field(default_factory=list) + image_urls: list[str] = dataclasses.field(default_factory=list) + images: list[dict[str, str]] = dataclasses.field(default_factory=list) # overridden fields - custom_image_urls: list = dataclasses.field(default_factory=list) - custom_images: list = dataclasses.field(default_factory=list) + custom_image_urls: list[str] = dataclasses.field(default_factory=list) + custom_images: list[dict[str, str]] = dataclasses.field(default_factory=list) class TestImagesPipelineFieldsDataClass(TestImagesPipelineFieldsMixin): diff --git a/tests/test_scheduler_base.py b/tests/test_scheduler_base.py index 176fefbbf..db023e1f8 100644 --- a/tests/test_scheduler_base.py +++ b/tests/test_scheduler_base.py @@ -41,10 +41,10 @@ class MinimalScheduler: class SimpleScheduler(MinimalScheduler): - def open(self, spider: Spider) -> defer.Deferred: + def open(self, spider: Spider) -> defer.Deferred[str]: return defer.succeed("open") - def close(self, reason: str) -> defer.Deferred: + def close(self, reason: str) -> defer.Deferred[str]: return defer.succeed("close") def __len__(self) -> int: diff --git a/tests/test_spidermiddleware.py b/tests/test_spidermiddleware.py index 2e0436ae1..a0d296553 100644 --- a/tests/test_spidermiddleware.py +++ b/tests/test_spidermiddleware.py @@ -110,7 +110,7 @@ class TestBaseAsyncSpiderMiddleware(TestSpiderMiddleware): Should work for process_spider_output and, when it's supported, process_start. """ - ITEM_TYPE: type | tuple + ITEM_TYPE: type | tuple[type, ...] RESULT_COUNT = 3 # to simplify checks, let everything return 3 objects @staticmethod diff --git a/tests/test_utils_datatypes.py b/tests/test_utils_datatypes.py index c58b02ca5..f573993a1 100644 --- a/tests/test_utils_datatypes.py +++ b/tests/test_utils_datatypes.py @@ -2,6 +2,7 @@ import copy import warnings from abc import ABC, abstractmethod from collections.abc import Iterator, Mapping, MutableMapping +from typing import Any import pytest @@ -20,7 +21,7 @@ from scrapy.utils.python import garbage_collect class TestCaseInsensitiveDictBase(ABC): @property @abstractmethod - def dict_class(self) -> type[MutableMapping]: + def dict_class(self) -> type[MutableMapping[str, Any]]: raise NotImplementedError def test_init_dict(self): @@ -206,7 +207,7 @@ class TestCaseInsensitiveDictBase(ABC): class TestCaseInsensitiveDict(TestCaseInsensitiveDictBase): - dict_class = CaseInsensitiveDict + dict_class = CaseInsensitiveDict # type: ignore[assignment] def test_repr(self): d1 = self.dict_class({"foo": "bar"}) diff --git a/tests/test_utils_log.py b/tests/test_utils_log.py index f40e424ff..318370d40 100644 --- a/tests/test_utils_log.py +++ b/tests/test_utils_log.py @@ -144,7 +144,9 @@ class TestStreamLogger: ], ) def test_spider_logger_adapter_process( - base_extra: Mapping[str, Any], log_extra: MutableMapping, expected_extra: dict + base_extra: Mapping[str, Any], + log_extra: MutableMapping[str, Any], + expected_extra: dict[str, Any], ) -> None: logger = logging.getLogger("test") spider_logger_adapter = SpiderLoggerAdapter(logger, base_extra) diff --git a/tests/test_utils_request.py b/tests/test_utils_request.py index b25129c87..70800dcec 100644 --- a/tests/test_utils_request.py +++ b/tests/test_utils_request.py @@ -2,6 +2,7 @@ from __future__ import annotations import json from hashlib import sha1 +from typing import Any from weakref import WeakKeyDictionary import pytest @@ -56,13 +57,13 @@ def test_request_httprepr_for_non_http_request(r: Request) -> None: class TestFingerprint: - function: staticmethod = staticmethod(fingerprint) + function: staticmethod[[Request], bytes] = staticmethod(fingerprint) cache: ( WeakKeyDictionary[Request, dict[tuple[tuple[bytes, ...] | None, bool], bytes]] | WeakKeyDictionary[Request, dict[tuple[tuple[bytes, ...] | None, bool], str]] ) = _fingerprint_cache default_cache_key = (None, False) - known_hashes: tuple[tuple[Request, bytes | str, dict], ...] = ( + known_hashes: tuple[tuple[Request, bytes | str, dict[str, Any]], ...] = ( ( Request("http://example.org"), b"xs\xd7\x0c3uj\x15\xfe\xd7d\x9b\xa9\t\xe0d\xbf\x9cXD", diff --git a/tox.ini b/tox.ini index da5a8a9a6..5fef37e5b 100644 --- a/tox.ini +++ b/tox.ini @@ -44,13 +44,13 @@ commands = [testenv:typing] basepython = python3.10 deps = - mypy==1.20.2 + mypy==2.1.0 typing-extensions==4.15.0 Pillow==12.2.0 Protego==0.6.0 Twisted==26.4.0 attrs==26.1.0 - boto3-stubs[s3]==1.43.2 + boto3-stubs[s3]==1.43.9 botocore-stubs==1.42.41 h2==4.3.0 httpx==0.28.1 @@ -58,12 +58,12 @@ deps = ptpython==3.0.32 # newer ones require newer Python ipython==8.39.0 - pyOpenSSL==26.1.0 + pyOpenSSL==26.2.0 pytest==9.0.3 - types-Pygments==2.20.0.20260408 + types-Pygments==2.20.0.20260508 types-defusedxml==0.7.0.20260504 types-lxml==2026.2.16 - types-pexpect==4.9.0.20260408 + types-pexpect==4.9.0.20260508 uvloop==0.22.1 w3lib==2.4.1 zstandard==0.25.0 From a84b7850fc11b13345d5658ff7aef8a042d0e622 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Tue, 19 May 2026 17:26:21 +0500 Subject: [PATCH 159/248] Release notes for Scrapy 2.16.0. (#7536) --- docs/news.rst | 259 +++++++++++++++++++++++++++++- docs/topics/spider-middleware.rst | 6 +- 2 files changed, 259 insertions(+), 6 deletions(-) diff --git a/docs/news.rst b/docs/news.rst index 3173dfbc1..429ea09bf 100644 --- a/docs/news.rst +++ b/docs/news.rst @@ -3,8 +3,31 @@ Release notes ============= -Scrapy VERSION (unreleased) ---------------------------- +.. _release-2.16.0: + +Scrapy 2.16.0 (unreleased) +-------------------------- + +Highlights: + +- Official support for Python 3.14 + +- Support for Twisted 26.4.0+ + +Modified requirements +~~~~~~~~~~~~~~~~~~~~~ + +- Increased the minimum versions of the following dependencies: + + - service_identity_: 18.1.0 → 23.1.0 + + (:issue:`7347`) + +- Added support for Twisted 26.4.0+. + (:issue:`7347`, :issue:`7505`, :issue:`7520`) + +- Added support for Python 3.14. + (:issue:`6604`, :issue:`7460`) Backward-incompatible changes ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -26,7 +49,237 @@ Backward-incompatible changes - ``scrapy.core.downloader.handlers.http2.ScrapyH2Agent`` - (:issue:`7496`, #TBD) + (:issue:`7496`, :issue:`7510`) + +Deprecations +~~~~~~~~~~~~ + +- ``scrapy.FormRequest`` is deprecated. You can use the :doc:`form2request + ` library instead, see :ref:`form`. + (:issue:`6438`) + +- ``scrapy.utils.python.MutableChain`` is deprecated. + (:issue:`7504`) + +Deprecation removals +~~~~~~~~~~~~~~~~~~~~ + +- The ``start_requests()`` method of :class:`~scrapy.Spider`, deprecated in + 2.13.0, is removed and no longer called. Use :meth:`~scrapy.Spider.start` + instead, or both to maintain support for lower Scrapy versions. + (:issue:`7490`) + +- Support for ``process_start_requests()`` methods of :ref:`spider middlewares + `, deprecated in 2.13.0, is removed. Use + :meth:`~scrapy.spidermiddlewares.SpiderMiddleware.process_start` instead, + or both to maintain support for lower Scrapy versions. + (:issue:`7490`) + +- Support for synchronous ``process_spider_output()`` methods of spider + middlewares, deprecated in Scrapy 2.13.0, is removed. You should upgrade + the affected middlewares to have asynchronous ``process_spider_output()`` + methods. + (:issue:`7504`) + +- The ``spider`` arguments of the following methods of + :class:`~scrapy.core.scraper.Scraper`, deprecated in Scrapy 2.13.0, are + removed: + + - ``close_spider()`` + + - ``enqueue_scrape()`` + + - ``handle_spider_error()`` + + - ``handle_spider_output()`` + + (:issue:`7487`) + +- HTTP/1.0 support code, deprecated in Scrapy 2.13.0, is removed. This + includes: + + - ``scrapy.core.downloader.handlers.http10.HTTP10DownloadHandler`` + + - The ``scrapy.core.downloader.webclient`` module. + + - The ``DOWNLOADER_HTTPCLIENTFACTORY`` setting. + + (:issue:`7486`) + +- The following functions, deprecated in Scrapy 2.13.0, are removed, you + should import them from :mod:`w3lib.url` directly instead: + + - ``scrapy.utils.url.add_or_replace_parameter()`` + + - ``scrapy.utils.url.add_or_replace_parameters()`` + + - ``scrapy.utils.url.any_to_uri()`` + + - ``scrapy.utils.url.canonicalize_url()`` + + - ``scrapy.utils.url.file_uri_to_path()`` + + - ``scrapy.utils.url.is_url()`` + + - ``scrapy.utils.url.parse_data_uri()`` + + - ``scrapy.utils.url.parse_url()`` + + - ``scrapy.utils.url.path_to_file_uri()`` + + - ``scrapy.utils.url.safe_download_url()`` + + - ``scrapy.utils.url.safe_url_string()`` + + - ``scrapy.utils.url.url_query_cleaner()`` + + - ``scrapy.utils.url.url_query_parameter()`` + + (:issue:`7487`) + +- The following test-related code, deprecated in Scrapy 2.13.0, is removed: + + - the ``scrapy.utils.testproc`` module + + - the ``scrapy.utils.testsite`` module + + - ``scrapy.utils.test.assert_gcs_environ()`` + + - ``scrapy.utils.test.get_ftp_content_and_delete()`` + + - ``scrapy.utils.test.get_gcs_content_and_delete()`` + + - ``scrapy.utils.test.mock_google_cloud_storage()`` + + - ``scrapy.utils.test.skip_if_no_boto()`` + + - ``scrapy.utils.test.TestSpider`` + + (:issue:`7487`) + +- ``scrapy.utils.versions.scrapy_components_versions()``, deprecated in + Scrapy 2.13.0, is removed, you can use + :func:`scrapy.utils.versions.get_versions` instead. + (:issue:`7487`) + +- ``scrapy.downloadermiddlewares.ajaxcrawl.AjaxCrawlMiddleware`` and + ``scrapy.utils.url.escape_ajax()``, deprecated in Scrapy 2.13.0, are + removed. + (:issue:`7487`) + +- The ``__init__()`` method of priority queue classes (see + :setting:`SCHEDULER_PRIORITY_QUEUE`) now needs to support a keyword-only + ``start_queue_cls`` parameter, not supporting it was deprecated in Scrapy + 2.13.0. + (:issue:`7487`) + +- ``scrapy.spiders.init.InitSpider``, deprecated in Scrapy 2.13.0, is + removed. + (:issue:`7487`) + +New features +~~~~~~~~~~~~ + +- New features and improvements for + :class:`~scrapy.core.downloader.handlers._httpx.HttpxDownloadHandler`: + + - Support for proxies. + + - Support for the :reqmeta:`download_latency` meta key. + + - Support for :attr:`Response.certificate + `. + + - Default headers set by the ``httpx`` library are no longer added to + requests. + + (:issue:`7441`, :issue:`7524`) + +- :class:`~scrapy.core.downloader.handlers.http11.HTTP11DownloadHandler` now + skips HTTPS proxy certificate verification when the + :setting:`DOWNLOAD_VERIFY_CERTIFICATES` setting is set to ``False``. + (:issue:`7496`) + +Improvements +~~~~~~~~~~~~ + +- :func:`time.monotonic` is used instead of :func:`time.time` to calculate + elapsed time in various places. + (:issue:`7377`) + +- Improved extraction of the file extension from the URL in + :class:`~scrapy.pipelines.files.FilesPipeline`. + (:issue:`4225`, :issue:`7414`) + +- Other code refactoring and improvements. + (:issue:`7401`) + +Bug fixes +~~~~~~~~~ + +- :class:`~scrapy.core.downloader.handlers.http11.HTTP11DownloadHandler` now + raises an exception when a request has an ``https://`` destination and an + ``https://`` proxy, which is not supported by this handler. Previously it + tried to connect to the proxy via HTTP in this case. + (:issue:`7496`) + +- :class:`~scrapy.core.downloader.handlers.http2.H2DownloadHandler` now + raises an exception for requests with ``http://`` URLs instead of trying to + connect, which is not supported by this handler. + (:issue:`7496`) + +- :class:`~scrapy.core.downloader.handlers.http2.H2DownloadHandler` no longer + adds the ``:status`` pseudo-header to :attr:`Response.headers + `. + (:issue:`7441`) + +- Fixed :func:`scrapy.utils.response.open_in_browser` removing the ```` + tag when adding the ```` tag. + (:issue:`7459`) + +Documentation +~~~~~~~~~~~~~ + +- Documented that + :class:`~scrapy.core.downloader.handlers.http11.HTTP11DownloadHandler` + doesn't support HTTPS proxies for HTTPS destinations and that + :class:`~scrapy.core.downloader.handlers.http2.H2DownloadHandler` doesn't + support proxies at all. + (:issue:`7496`) + +- Added an example of using + :class:`logging.handlers.TimedRotatingFileHandler` to rotate Scrapy logs. + (:issue:`3628`, :issue:`7501`) + +- Added a ``CITATION.cff`` file. + (:issue:`7502`, :issue:`7519`) + +- Mentioned :setting:`DOWNLOADER_CLIENT_TLS_METHOD` in :ref:`bans`. + (:issue:`5232`, :issue:`7518`) + +- Other documentation improvements and fixes. + (:issue:`7417`, + :issue:`7463`, + :issue:`7472`, + :issue:`7480`, + :issue:`7489`, + :issue:`7503`, + :issue:`7507`) + +Quality assurance +~~~~~~~~~~~~~~~~~ + +- Added tests that connect to https://books.toscrape.com/ to test the + behavior with a real website. These tests are marked with the + ``requires_internet`` pytest mark and can be skipped with e.g. + ``-m 'not requires_internet'`` if you cannot or don't want to run them. + (:issue:`7520`) + +- Type hints improvements and fixes. + (:issue:`7492`, :issue:`7532`) + +- CI and test improvements and fixes. + (:issue:`7441`, :issue:`7466`, :issue:`7491`, :issue:`7496`) .. _release-2.15.2: diff --git a/docs/topics/spider-middleware.rst b/docs/topics/spider-middleware.rst index 820d5910c..99bbdf292 100644 --- a/docs/topics/spider-middleware.rst +++ b/docs/topics/spider-middleware.rst @@ -175,9 +175,9 @@ In Scrapy 2.6.3 and lower, ``process_spider_output()`` must be a *synchronous* generator. To support those versions and higher Scrapy versions in the same middleware, -rename your asynchronous :method:`~SpiderMiddleware.process_spider_output()` -method to :method:`~SpiderMiddleware.process_spider_output_async()`, and define -a synchronous ``process_spider_output()`` method to be used by 2.6.3 and lower +rename your asynchronous :meth:`~SpiderMiddleware.process_spider_output` +method to :meth:`~SpiderMiddleware.process_spider_output_async`, and define a +synchronous ``process_spider_output()`` method to be used by 2.6.3 and lower versions. For example: From abe9c638414e4a39a338763c85a3a0d84696b9b3 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Tue, 19 May 2026 17:27:16 +0500 Subject: [PATCH 160/248] =?UTF-8?q?Bump=20version:=202.15.2=20=E2=86=92=20?= =?UTF-8?q?2.16.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- SECURITY.md | 4 ++-- docs/news.rst | 2 +- pyproject.toml | 2 +- scrapy/VERSION | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index 752d6318a..49cafdf9e 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,8 +4,8 @@ | Version | Supported | | ------- | ------------------ | -| 2.15.x | :white_check_mark: | -| < 2.15.x | :x: | +| 2.16.x | :white_check_mark: | +| < 2.16.x | :x: | ## Reporting a Vulnerability diff --git a/docs/news.rst b/docs/news.rst index 429ea09bf..16aa02b25 100644 --- a/docs/news.rst +++ b/docs/news.rst @@ -5,7 +5,7 @@ Release notes .. _release-2.16.0: -Scrapy 2.16.0 (unreleased) +Scrapy 2.16.0 (2026-05-19) -------------------------- Highlights: diff --git a/pyproject.toml b/pyproject.toml index 2f07dcd80..888f95574 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -137,7 +137,7 @@ module = [ ignore_missing_imports = true [tool.bumpversion] -current_version = "2.15.2" +current_version = "2.16.0" commit = true tag = true tag_name = "{new_version}" diff --git a/scrapy/VERSION b/scrapy/VERSION index 07d875c2d..752490696 100644 --- a/scrapy/VERSION +++ b/scrapy/VERSION @@ -1 +1 @@ -2.15.2 +2.16.0 From 4a165508591da164393c448eebc86c9aa48081da Mon Sep 17 00:00:00 2001 From: SpiliosDmk <150211937+SpiliosDimakopoulos@users.noreply.github.com> Date: Wed, 20 May 2026 11:27:24 +0300 Subject: [PATCH 161/248] DOC -> Add missing documentation for CloseSpider & CoreStats (#7421) * DOC -> Add missing documentation for CloseSpider & CoreStats * Apply the suggestion from the PR review. --------- Co-authored-by: Andrey Rakhmatullin --- docs/topics/extensions.rst | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/docs/topics/extensions.rst b/docs/topics/extensions.rst index f0a8bb5ed..735e46c29 100644 --- a/docs/topics/extensions.rst +++ b/docs/topics/extensions.rst @@ -136,6 +136,19 @@ Core Stats extension Enable the collection of core statistics, provided the stats collection is enabled (see :ref:`topics-stats`). +The following stats are collected: + +* ``start_time``: start date/time of the crawl (:class:`~datetime.datetime`). +* ``finish_time``: end date/time of the crawl (:class:`~datetime.datetime`). +* ``elapsed_time_seconds``: total crawl duration in seconds (:class:`float`). +* ``finish_reason``: the closing reason string (e.g. ``"finished"``, + ``"closespider_timeout"``). +* ``item_scraped_count``: total number of items that passed all pipelines. +* ``item_dropped_count``: total number of items dropped by a pipeline. +* ``item_dropped_reasons_count/``: per-exception drop count + (e.g. ``item_dropped_reasons_count/DropItem``). +* ``response_received_count``: total number of HTTP responses received. + .. _topics-extensions-ref-telnetconsole: Log Count extension @@ -247,6 +260,7 @@ settings: * :setting:`CLOSESPIDER_TIMEOUT_NO_ITEM` * :setting:`CLOSESPIDER_ITEMCOUNT` * :setting:`CLOSESPIDER_PAGECOUNT` +* :setting:`CLOSESPIDER_PAGECOUNT_NO_ITEM` * :setting:`CLOSESPIDER_ERRORCOUNT` .. note:: @@ -260,12 +274,11 @@ settings: CLOSESPIDER_TIMEOUT """"""""""""""""""" -Default: ``0`` +Default: ``0.0`` -An integer which specifies a number of seconds. If the spider remains open for -more than that number of seconds, it will be automatically closed with the -reason ``closespider_timeout``. If zero (or non set), spiders won't be closed by -timeout. +If the spider remains open for more than this number of seconds, it will be +automatically closed with the reason ``closespider_timeout``. If zero (or non +set), spiders won't be closed by timeout. .. setting:: CLOSESPIDER_TIMEOUT_NO_ITEM From 44406806f819b0e5230ed0dbe619a31defeb7afc Mon Sep 17 00:00:00 2001 From: Fardin Alizadeh Date: Tue, 2 Jun 2026 12:34:38 +0330 Subject: [PATCH 162/248] fix typos (#7564) --- docs/_templates/layout.html | 2 +- docs/news.rst | 4 ++-- tests/test_downloadermiddleware_cookies.py | 2 +- tests/test_engine_loop.py | 2 +- tests/test_zz_resources.py | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/_templates/layout.html b/docs/_templates/layout.html index 6ec565e24..29394799b 100644 --- a/docs/_templates/layout.html +++ b/docs/_templates/layout.html @@ -1,6 +1,6 @@ {% extends "!layout.html" %} -{# Overriden to include a link to scrapy.org, not just to the docs root #} +{# Overridden to include a link to scrapy.org, not just to the docs root #} {%- block sidebartitle %} {# the logo helper function was removed in Sphinx 6 and deprecated since Sphinx 4 #} diff --git a/docs/news.rst b/docs/news.rst index 16aa02b25..fd9786433 100644 --- a/docs/news.rst +++ b/docs/news.rst @@ -2200,7 +2200,7 @@ Backward-incompatible changes ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - User-defined cookies for HTTPS requests will have the ``secure`` flag set - to ``True`` unless it's set to ``False`` explictly. This is important when + to ``True`` unless it's set to ``False`` explicitly. This is important when these cookies are reused in HTTP requests, e.g. after a redirect to an HTTP URL. (:issue:`6357`) @@ -2235,7 +2235,7 @@ Backward-incompatible changes ``crawler.settings`` instead. When they call ``__init__()`` of the base class they should pass the ``crawler`` argument to it too. - A ``from_settings()`` method shouldn't be defined. Class-specific - initialization code should go into either an overriden ``from_crawler()`` + initialization code should go into either an overridden ``from_crawler()`` method or into ``__init__()``. - It's now possible to override ``from_crawler()`` and it's not necessary to call ``MediaPipeline.from_crawler()`` in it if other recommendations diff --git a/tests/test_downloadermiddleware_cookies.py b/tests/test_downloadermiddleware_cookies.py index 49215329e..225562644 100644 --- a/tests/test_downloadermiddleware_cookies.py +++ b/tests/test_downloadermiddleware_cookies.py @@ -42,7 +42,7 @@ def _cookie_to_set_cookie_value(cookie): def _cookies_to_set_cookie_list(cookies): """Given a group of cookie defined either as a dictionary or as a list of dictionaries (i.e. in a format supported by the cookies parameter of - Request), return the equivalen list of strings that can be associated to a + Request), return the equivalent list of strings that can be associated to a ``Set-Cookie`` header.""" if not cookies: return [] diff --git a/tests/test_engine_loop.py b/tests/test_engine_loop.py index 8b5705487..6cc8c0650 100644 --- a/tests/test_engine_loop.py +++ b/tests/test_engine_loop.py @@ -263,7 +263,7 @@ class TestRequestSendOrder: @coroutine_test async def test_shared_queues(self): """If SCHEDULER_START_*_QUEUE is falsy, start requests and other - requests share the same queue, i.e. start requests are not priorized + requests share the same queue, i.e. start requests are not prioritized over other requests if their priority matches.""" nums = list(range(1, 14)) response_seconds = 0 diff --git a/tests/test_zz_resources.py b/tests/test_zz_resources.py index 1faa2487a..a8292745d 100644 --- a/tests/test_zz_resources.py +++ b/tests/test_zz_resources.py @@ -26,7 +26,7 @@ def test_stderr_log_handler() -> None: It's added in ``configure_logging()``, called by ``{Async,}CrawlerProcess`` (without ``install_root_handler=False``). It can be removed with - ``_uninstall_scrapy_root_handler()`` if installing it was really neeeded. + ``_uninstall_scrapy_root_handler()`` if installing it was really needed. """ c = sum(1 for h in logging.root.handlers if type(h) is logging.StreamHandler) # pylint: disable=unidiomatic-typecheck assert c == 0 From 90deebe75e49e7217592072e4cb30b56750093c8 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Wed, 3 Jun 2026 12:16:24 +0500 Subject: [PATCH 163/248] Convert tests that fail with testfixtures 12.0.0 (#7545) --- tests/test_spidermiddleware_httperror.py | 77 ++++++++++++------------ tests/test_utils_log.py | 36 +++++------ 2 files changed, 56 insertions(+), 57 deletions(-) diff --git a/tests/test_spidermiddleware_httperror.py b/tests/test_spidermiddleware_httperror.py index d5ad59346..6a054ded5 100644 --- a/tests/test_spidermiddleware_httperror.py +++ b/tests/test_spidermiddleware_httperror.py @@ -1,17 +1,19 @@ from __future__ import annotations import logging +from typing import TYPE_CHECKING import pytest -from testfixtures import LogCapture from scrapy.http import Request, Response from scrapy.spidermiddlewares.httperror import HttpError, HttpErrorMiddleware from scrapy.utils.spider import DefaultSpider from scrapy.utils.test import get_crawler -from tests.mockserver.http import MockServer from tests.spiders import MockServerSpider -from tests.utils.decorators import inline_callbacks_test +from tests.utils.decorators import coroutine_test + +if TYPE_CHECKING: + from tests.mockserver.http import MockServer class _HttpErrorSpider(MockServerSpider): @@ -192,65 +194,66 @@ class TestHttpErrorMiddlewareHandleAll: class TestHttpErrorMiddlewareIntegrational: - @classmethod - def setup_class(cls): - cls.mockserver = MockServer() - cls.mockserver.__enter__() - - @classmethod - def teardown_class(cls): - cls.mockserver.__exit__(None, None, None) - - @inline_callbacks_test - def test_middleware_works(self): + @coroutine_test + async def test_middleware_works(self, mockserver: MockServer) -> None: crawler = get_crawler(_HttpErrorSpider) - yield crawler.crawl(mockserver=self.mockserver) + await crawler.crawl_async(mockserver=mockserver) + assert isinstance(crawler.spider, _HttpErrorSpider) assert not crawler.spider.skipped assert crawler.spider.parsed == {"200"} assert crawler.spider.failed == {"404", "402", "500"} + assert crawler.stats get_value = crawler.stats.get_value assert get_value("httperror/response_ignored_count") == 3 assert get_value("httperror/response_ignored_status_count/404") == 1 assert get_value("httperror/response_ignored_status_count/402") == 1 assert get_value("httperror/response_ignored_status_count/500") == 1 - @inline_callbacks_test - def test_logging(self): + @coroutine_test + async def test_logging( + self, caplog: pytest.LogCaptureFixture, mockserver: MockServer + ) -> None: crawler = get_crawler(_HttpErrorSpider) - with LogCapture() as log: - yield crawler.crawl(mockserver=self.mockserver, bypass_status_codes={402}) + with caplog.at_level(logging.INFO): + await crawler.crawl_async(mockserver=mockserver, bypass_status_codes={402}) + assert isinstance(crawler.spider, _HttpErrorSpider) assert crawler.spider.parsed == {"200", "402"} assert crawler.spider.skipped == {"402"} assert crawler.spider.failed == {"404", "500"} - assert "Ignoring response <404" in str(log) - assert "Ignoring response <500" in str(log) - assert "Ignoring response <200" not in str(log) - assert "Ignoring response <402" not in str(log) + assert "Ignoring response <404" in caplog.text + assert "Ignoring response <500" in caplog.text + assert "Ignoring response <200" not in caplog.text + assert "Ignoring response <402" not in caplog.text - @inline_callbacks_test - def test_logging_level(self): + @coroutine_test + async def test_logging_level( + self, caplog: pytest.LogCaptureFixture, mockserver: MockServer + ) -> None: # HttpError logs ignored responses with level INFO crawler = get_crawler(_HttpErrorSpider) - with LogCapture(level=logging.INFO) as log: - yield crawler.crawl(mockserver=self.mockserver) + with caplog.at_level(logging.INFO): + await crawler.crawl_async(mockserver=mockserver) + assert isinstance(crawler.spider, _HttpErrorSpider) assert crawler.spider.parsed == {"200"} assert crawler.spider.failed == {"404", "402", "500"} - assert "Ignoring response <402" in str(log) - assert "Ignoring response <404" in str(log) - assert "Ignoring response <500" in str(log) - assert "Ignoring response <200" not in str(log) + assert "Ignoring response <402" in caplog.text + assert "Ignoring response <404" in caplog.text + assert "Ignoring response <500" in caplog.text + assert "Ignoring response <200" not in caplog.text # with level WARNING, we shouldn't capture anything from HttpError + caplog.clear() crawler = get_crawler(_HttpErrorSpider) - with LogCapture(level=logging.WARNING) as log: - yield crawler.crawl(mockserver=self.mockserver) + with caplog.at_level(logging.WARNING): + await crawler.crawl_async(mockserver=mockserver) + assert isinstance(crawler.spider, _HttpErrorSpider) assert crawler.spider.parsed == {"200"} assert crawler.spider.failed == {"404", "402", "500"} - assert "Ignoring response <402" not in str(log) - assert "Ignoring response <404" not in str(log) - assert "Ignoring response <500" not in str(log) - assert "Ignoring response <200" not in str(log) + assert "Ignoring response <402" not in caplog.text + assert "Ignoring response <404" not in caplog.text + assert "Ignoring response <500" not in caplog.text + assert "Ignoring response <200" not in caplog.text diff --git a/tests/test_utils_log.py b/tests/test_utils_log.py index 318370d40..8e5020022 100644 --- a/tests/test_utils_log.py +++ b/tests/test_utils_log.py @@ -42,33 +42,29 @@ class TestFailureToExcInfo: class TestTopLevelFormatter: - def setup_method(self): - self.handler = LogCapture() - self.handler.addFilter(TopLevelFormatter(["test"])) - - def test_top_level_logger(self): + def test_top_level_logger(self, caplog: pytest.LogCaptureFixture) -> None: + caplog.handler.addFilter(TopLevelFormatter(["test"])) logger = logging.getLogger("test") - with self.handler as log: - logger.warning("test log msg") - log.check(("test", "WARNING", "test log msg")) + logger.warning("test log msg") + assert ("test", logging.WARNING, "test log msg") in caplog.record_tuples - def test_children_logger(self): + def test_children_logger(self, caplog: pytest.LogCaptureFixture) -> None: + caplog.handler.addFilter(TopLevelFormatter(["test"])) logger = logging.getLogger("test.test1") - with self.handler as log: - logger.warning("test log msg") - log.check(("test", "WARNING", "test log msg")) + logger.warning("test log msg") + assert ("test", logging.WARNING, "test log msg") in caplog.record_tuples - def test_overlapping_name_logger(self): + def test_overlapping_name_logger(self, caplog: pytest.LogCaptureFixture) -> None: + caplog.handler.addFilter(TopLevelFormatter(["test"])) logger = logging.getLogger("test2") - with self.handler as log: - logger.warning("test log msg") - log.check(("test2", "WARNING", "test log msg")) + logger.warning("test log msg") + assert ("test2", logging.WARNING, "test log msg") in caplog.record_tuples - def test_different_name_logger(self): + def test_different_name_logger(self, caplog: pytest.LogCaptureFixture) -> None: + caplog.handler.addFilter(TopLevelFormatter(["test"])) logger = logging.getLogger("different") - with self.handler as log: - logger.warning("test log msg") - log.check(("different", "WARNING", "test log msg")) + logger.warning("test log msg") + assert ("different", logging.WARNING, "test log msg") in caplog.record_tuples class TestLogCounterHandler: From fed75a6c76ab846d0d00b8f100ba947302380a01 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Wed, 3 Jun 2026 17:48:12 +0500 Subject: [PATCH 164/248] Refactor test_crawl.py and test_crawler.py. (#7566) --- pyproject.toml | 1 + tests/test_crawl.py | 773 ++++++++++++++++++------------- tests/test_crawler.py | 266 ++++++----- tests/test_crawler_subprocess.py | 100 ++-- 4 files changed, 636 insertions(+), 504 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 888f95574..e027ea74d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -225,6 +225,7 @@ disable = [ "too-many-positional-arguments", "too-many-public-methods", "too-many-return-statements", + "undefined-variable", "unused-argument", "unused-variable", "useless-import-alias", # used as a hint to mypy diff --git a/tests/test_crawl.py b/tests/test_crawl.py index 52abb1ccc..ada4c31ce 100644 --- a/tests/test_crawl.py +++ b/tests/test_crawl.py @@ -9,7 +9,6 @@ from urllib.parse import urlencode, urlparse import pytest from cryptography.x509 import load_der_x509_certificate -from testfixtures import LogCapture from twisted.internet.defer import succeed from twisted.internet.ssl import Certificate from twisted.python.failure import Failure @@ -24,7 +23,6 @@ from scrapy.utils.engine import format_engine_status, get_engine_status from scrapy.utils.python import to_unicode from scrapy.utils.test import get_crawler, get_reactor_settings from tests import NON_EXISTING_RESOLVABLE -from tests.mockserver.http import MockServer from tests.spiders import ( AsyncDefAsyncioGenComplexSpider, AsyncDefAsyncioGenExcSpider, @@ -56,44 +54,36 @@ from tests.spiders import ( StartGoodAndBadOutput, StartItemSpider, ) -from tests.utils.decorators import coroutine_test, inline_callbacks_test +from tests.utils.decorators import coroutine_test if TYPE_CHECKING: from scrapy.statscollectors import StatsCollector + from tests.mockserver.http import MockServer class TestCrawl: - mockserver: MockServer - - @classmethod - def setup_class(cls): - cls.mockserver = MockServer() - cls.mockserver.__enter__() - - @classmethod - def teardown_class(cls): - cls.mockserver.__exit__(None, None, None) - - @inline_callbacks_test - def test_follow_all(self): + @coroutine_test + async def test_follow_all(self, mockserver: MockServer) -> None: crawler = get_crawler(FollowAllSpider) - yield crawler.crawl(mockserver=self.mockserver) + await crawler.crawl_async(mockserver=mockserver) + assert isinstance(crawler.spider, FollowAllSpider) assert len(crawler.spider.urls_visited) == 11 # 10 + start_url @coroutine_test - async def test_fixed_delay(self): - await self._test_delay(total=3, delay=0.2) + async def test_fixed_delay(self, mockserver: MockServer) -> None: + await self._test_delay(mockserver, total=3, delay=0.2) @coroutine_test - async def test_randomized_delay(self): - await self._test_delay(total=3, delay=0.1, randomize=True) + async def test_randomized_delay(self, mockserver: MockServer) -> None: + await self._test_delay(mockserver, total=3, delay=0.1, randomize=True) + @staticmethod async def _test_delay( - self, total: int, delay: float, randomize: bool = False + mockserver: MockServer, total: int, delay: float, randomize: bool = False ) -> None: crawl_kwargs = { "maxlatency": delay * 2, - "mockserver": self.mockserver, + "mockserver": mockserver, "total": total, } tolerance = 1 - (0.6 if randomize else 0.2) @@ -122,18 +112,20 @@ class TestCrawl: average = total_time / (len(times) - 1) assert average <= delay / tolerance, "test total or delay values are too small" - @inline_callbacks_test - def test_timeout_success(self): + @coroutine_test + async def test_timeout_success(self, mockserver: MockServer) -> None: crawler = get_crawler(DelaySpider) - yield crawler.crawl(n=0.5, mockserver=self.mockserver) + await crawler.crawl_async(n=0.5, mockserver=mockserver) + assert isinstance(crawler.spider, DelaySpider) assert crawler.spider.t1 > 0 assert crawler.spider.t2 > 0 assert crawler.spider.t2 > crawler.spider.t1 - @inline_callbacks_test - def test_timeout_failure(self): + @coroutine_test + async def test_timeout_failure(self, mockserver: MockServer) -> None: crawler = get_crawler(DelaySpider, {"DOWNLOAD_TIMEOUT": 0.35}) - yield crawler.crawl(n=0.5, mockserver=self.mockserver) + await crawler.crawl_async(n=0.5, mockserver=mockserver) + assert isinstance(crawler.spider, DelaySpider) assert crawler.spider.t1 > 0 assert crawler.spider.t2 == 0 assert crawler.spider.t2_err > 0 @@ -141,81 +133,96 @@ class TestCrawl: # server hangs after receiving response headers crawler = get_crawler(DelaySpider, {"DOWNLOAD_TIMEOUT": 0.35}) - yield crawler.crawl(n=0.5, b=1, mockserver=self.mockserver) + await crawler.crawl_async(n=0.5, b=1, mockserver=mockserver) + assert isinstance(crawler.spider, DelaySpider) assert crawler.spider.t1 > 0 assert crawler.spider.t2 == 0 assert crawler.spider.t2_err > 0 assert crawler.spider.t2_err > crawler.spider.t1 - @inline_callbacks_test - def test_retry_503(self): + @coroutine_test + async def test_retry_503( + self, caplog: pytest.LogCaptureFixture, mockserver: MockServer + ) -> None: crawler = get_crawler(SimpleSpider) - with LogCapture() as log: - yield crawler.crawl( - self.mockserver.url("/status?n=503"), mockserver=self.mockserver + with caplog.at_level(logging.DEBUG): + await crawler.crawl_async( + mockserver.url("/status?n=503"), mockserver=mockserver ) - self._assert_retried(log) + self._assert_retried(caplog.text) - @inline_callbacks_test - def test_retry_conn_failed(self): + @coroutine_test + async def test_retry_conn_failed( + self, caplog: pytest.LogCaptureFixture, mockserver: MockServer + ) -> None: crawler = get_crawler(SimpleSpider) - with LogCapture() as log: - yield crawler.crawl( - "http://localhost:65432/status?n=503", mockserver=self.mockserver + with caplog.at_level(logging.DEBUG): + await crawler.crawl_async( + "http://localhost:65432/status?n=503", mockserver=mockserver ) - self._assert_retried(log) + self._assert_retried(caplog.text) - @inline_callbacks_test - def test_retry_dns_error(self): + @coroutine_test + async def test_retry_dns_error( + self, caplog: pytest.LogCaptureFixture, mockserver: MockServer + ) -> None: if NON_EXISTING_RESOLVABLE: pytest.skip("Non-existing hosts are resolvable") crawler = get_crawler(SimpleSpider) - with LogCapture() as log: + with caplog.at_level(logging.DEBUG): # try to fetch the homepage of a nonexistent domain - yield crawler.crawl( - "http://dns.resolution.invalid./", mockserver=self.mockserver + await crawler.crawl_async( + "http://dns.resolution.invalid./", mockserver=mockserver ) - self._assert_retried(log) + self._assert_retried(caplog.text) - @inline_callbacks_test - def test_start_bug_before_yield(self): - with LogCapture("scrapy", level=logging.ERROR) as log: + @coroutine_test + async def test_start_bug_before_yield( + self, caplog: pytest.LogCaptureFixture, mockserver: MockServer + ) -> None: + with caplog.at_level(logging.ERROR): crawler = get_crawler(BrokenStartSpider) - yield crawler.crawl(fail_before_yield=1, mockserver=self.mockserver) + await crawler.crawl_async(fail_before_yield=1, mockserver=mockserver) - assert len(log.records) == 1 - record = log.records[0] + assert len(caplog.records) == 1 + record = caplog.records[0] assert record.exc_info is not None assert record.exc_info[0] is ZeroDivisionError - @inline_callbacks_test - def test_start_bug_yielding(self): - with LogCapture("scrapy", level=logging.ERROR) as log: + @coroutine_test + async def test_start_bug_yielding( + self, caplog: pytest.LogCaptureFixture, mockserver: MockServer + ) -> None: + with caplog.at_level(logging.ERROR): crawler = get_crawler(BrokenStartSpider) - yield crawler.crawl(fail_yielding=1, mockserver=self.mockserver) + await crawler.crawl_async(fail_yielding=1, mockserver=mockserver) - assert len(log.records) == 1 - record = log.records[0] + assert len(caplog.records) == 1 + record = caplog.records[0] assert record.exc_info is not None assert record.exc_info[0] is ZeroDivisionError - @inline_callbacks_test - def test_start_items(self): + @coroutine_test + async def test_start_items( + self, caplog: pytest.LogCaptureFixture, mockserver: MockServer + ) -> None: items = [] def _on_item_scraped(item): items.append(item) - with LogCapture("scrapy", level=logging.ERROR) as log: + with caplog.at_level(logging.ERROR): crawler = get_crawler(StartItemSpider) crawler.signals.connect(_on_item_scraped, signals.item_scraped) - yield crawler.crawl(mockserver=self.mockserver) + await crawler.crawl_async(mockserver=mockserver) - assert len(log.records) == 0 + assert len(caplog.records) == 0 assert items == [{"name": "test item"}] - @inline_callbacks_test - def test_start_unsupported_output(self): + @coroutine_test + async def test_start_unsupported_output( + self, caplog: pytest.LogCaptureFixture, mockserver: MockServer + ) -> None: """Anything that is not a request is assumed to be an item, avoiding a potentially expensive call to itemadapter.is_item(), and letting instead things fail when ItemAdapter is actually used on the @@ -226,35 +233,39 @@ class TestCrawl: def _on_item_scraped(item): items.append(item) - with LogCapture("scrapy", level=logging.ERROR) as log: + with caplog.at_level(logging.ERROR): crawler = get_crawler(StartGoodAndBadOutput) crawler.signals.connect(_on_item_scraped, signals.item_scraped) - yield crawler.crawl(mockserver=self.mockserver) + await crawler.crawl_async(mockserver=mockserver) - assert len(log.records) == 0 + assert len(caplog.records) == 0 assert len(items) == 3 assert not any(isinstance(item, Request) for item in items) - @inline_callbacks_test - def test_start_dupes(self): + @coroutine_test + async def test_start_dupes(self, mockserver: MockServer) -> None: settings = {"CONCURRENT_REQUESTS": 1} crawler = get_crawler(DuplicateStartSpider, settings) - yield crawler.crawl( - dont_filter=True, distinct_urls=2, dupe_factor=3, mockserver=self.mockserver + await crawler.crawl_async( + dont_filter=True, distinct_urls=2, dupe_factor=3, mockserver=mockserver ) + assert isinstance(crawler.spider, DuplicateStartSpider) assert crawler.spider.visited == 6 crawler = get_crawler(DuplicateStartSpider, settings) - yield crawler.crawl( + await crawler.crawl_async( dont_filter=False, distinct_urls=3, dupe_factor=4, - mockserver=self.mockserver, + mockserver=mockserver, ) + assert isinstance(crawler.spider, DuplicateStartSpider) assert crawler.spider.visited == 3 - @inline_callbacks_test - def test_unbounded_response(self): + @coroutine_test + async def test_unbounded_response( + self, caplog: pytest.LogCaptureFixture, mockserver: MockServer + ) -> None: # Completeness of responses without Content-Length or Transfer-Encoding # can not be determined, we treat them as valid but flagged as "partial" query = urlencode( @@ -279,41 +290,45 @@ with multiples lines } ) crawler = get_crawler(SimpleSpider) - with LogCapture() as log: - yield crawler.crawl( - self.mockserver.url(f"/raw?{query}"), mockserver=self.mockserver + with caplog.at_level(logging.INFO): + await crawler.crawl_async( + mockserver.url(f"/raw?{query}"), mockserver=mockserver ) - assert str(log).count("Got response 200") == 1 + assert caplog.text.count("Got response 200") == 1 - @inline_callbacks_test - def test_retry_conn_lost(self): + @coroutine_test + async def test_retry_conn_lost( + self, caplog: pytest.LogCaptureFixture, mockserver: MockServer + ) -> None: # connection lost after receiving data crawler = get_crawler(SimpleSpider) - with LogCapture() as log: - yield crawler.crawl( - self.mockserver.url("/drop?abort=0"), mockserver=self.mockserver + with caplog.at_level(logging.DEBUG): + await crawler.crawl_async( + mockserver.url("/drop?abort=0"), mockserver=mockserver ) - self._assert_retried(log) + self._assert_retried(caplog.text) - @inline_callbacks_test - def test_retry_conn_aborted(self): + @coroutine_test + async def test_retry_conn_aborted( + self, caplog: pytest.LogCaptureFixture, mockserver: MockServer + ) -> None: # connection lost before receiving data crawler = get_crawler(SimpleSpider) - with LogCapture() as log: - yield crawler.crawl( - self.mockserver.url("/drop?abort=1"), mockserver=self.mockserver + with caplog.at_level(logging.DEBUG): + await crawler.crawl_async( + mockserver.url("/drop?abort=1"), mockserver=mockserver ) - self._assert_retried(log) + self._assert_retried(caplog.text) @staticmethod - def _assert_retried(log: LogCapture | str) -> None: + def _assert_retried(log: str) -> None: assert str(log).count("Retrying") == 2 assert str(log).count("Gave up retrying") == 1 - @inline_callbacks_test - def test_referer_header(self): + @coroutine_test + async def test_referer_header(self, mockserver: MockServer) -> None: """Referer header is set by RefererMiddleware unless it is already set""" - req0 = Request(self.mockserver.url("/echo?headers=1&body=0"), dont_filter=1) + req0 = Request(mockserver.url("/echo?headers=1&body=0"), dont_filter=True) req1 = req0.replace() req2 = req0.replace(headers={"Referer": None}) req3 = req0.replace(headers={"Referer": "http://example.com"}) @@ -321,7 +336,8 @@ with multiples lines req1.meta["next"] = req2 req2.meta["next"] = req3 crawler = get_crawler(SingleRequestSpider) - yield crawler.crawl(seed=req0, mockserver=self.mockserver) + await crawler.crawl_async(seed=req0, mockserver=mockserver) + assert isinstance(crawler.spider, SingleRequestSpider) # basic asserts in case of weird communication errors assert "responses" in crawler.spider.meta assert "failures" not in crawler.spider.meta @@ -338,33 +354,35 @@ with multiples lines echo3 = json.loads(to_unicode(crawler.spider.meta["responses"][3].body)) assert echo3["headers"].get("Referer") == ["http://example.com"] - @inline_callbacks_test - def test_engine_status(self): + @coroutine_test + async def test_engine_status(self, mockserver: MockServer) -> None: est = [] def cb(response): est.append(get_engine_status(crawler.engine)) crawler = get_crawler(SingleRequestSpider) - yield crawler.crawl( - seed=self.mockserver.url("/"), callback_func=cb, mockserver=self.mockserver + await crawler.crawl_async( + seed=mockserver.url("/"), callback_func=cb, mockserver=mockserver ) + assert isinstance(crawler.spider, SingleRequestSpider) assert len(est) == 1, est s = dict(est[0]) assert s["engine.spider.name"] == crawler.spider.name assert s["len(engine.scraper.slot.active)"] == 1 - @inline_callbacks_test - def test_format_engine_status(self): + @coroutine_test + async def test_format_engine_status(self, mockserver: MockServer) -> None: est = [] def cb(response): est.append(format_engine_status(crawler.engine)) crawler = get_crawler(SingleRequestSpider) - yield crawler.crawl( - seed=self.mockserver.url("/"), callback_func=cb, mockserver=self.mockserver + await crawler.crawl_async( + seed=mockserver.url("/"), callback_func=cb, mockserver=mockserver ) + assert isinstance(crawler.spider, SingleRequestSpider) assert len(est) == 1, est est = est[0].split("\n")[2:-2] # remove header & footer # convert to dict @@ -377,8 +395,10 @@ with multiples lines assert s["engine.spider.name"] == crawler.spider.name assert s["len(engine.scraper.slot.active)"] == "1" - @inline_callbacks_test - def test_open_spider_error_on_faulty_pipeline(self): + @coroutine_test + async def test_open_spider_error_on_faulty_pipeline( + self, mockserver: MockServer + ) -> None: settings = { "ITEM_PIPELINES": { "tests.pipelines.ZeroDivisionErrorPipeline": 300, @@ -386,25 +406,48 @@ with multiples lines } crawler = get_crawler(SimpleSpider, settings) with pytest.raises(ZeroDivisionError): - yield crawler.crawl( - self.mockserver.url("/status?n=200"), mockserver=self.mockserver + await crawler.crawl_async( + mockserver.url("/status?n=200"), mockserver=mockserver ) assert not crawler.crawling - @inline_callbacks_test - def test_crawlerrunner_accepts_crawler(self): - crawler = get_crawler(SimpleSpider) - runner = CrawlerRunner() - with LogCapture() as log: - yield runner.crawl( - crawler, - self.mockserver.url("/status?n=200"), - mockserver=self.mockserver, + @coroutine_test + async def test_open_spider_error_on_faulty_pipeline_crawl( + self, mockserver: MockServer + ) -> None: + # cover the except block in Crawler.crawl() + settings = { + "ITEM_PIPELINES": { + "tests.pipelines.ZeroDivisionErrorPipeline": 300, + } + } + crawler = get_crawler(SimpleSpider, settings) + with pytest.raises(ZeroDivisionError): + await maybe_deferred_to_future( + crawler.crawl(mockserver.url("/status?n=200"), mockserver=mockserver) ) - assert "Got response 200" in str(log) + assert not crawler.crawling @coroutine_test - async def test_crawl_multiple(self, caplog: pytest.LogCaptureFixture) -> None: + async def test_crawlerrunner_accepts_crawler( + self, caplog: pytest.LogCaptureFixture, mockserver: MockServer + ) -> None: + crawler = get_crawler(SimpleSpider) + runner = CrawlerRunner() + with caplog.at_level(logging.DEBUG): + await maybe_deferred_to_future( + runner.crawl( + crawler, + mockserver.url("/status?n=200"), + mockserver=mockserver, + ) + ) + assert "Got response 200" in caplog.text + + @coroutine_test + async def test_crawl_multiple( + self, caplog: pytest.LogCaptureFixture, mockserver: MockServer + ) -> None: settings_dict = get_reactor_settings() runner_cls = ( CrawlerRunner @@ -414,13 +457,13 @@ with multiples lines runner = runner_cls(settings_dict) runner.crawl( SimpleSpider, - self.mockserver.url("/status?n=200"), - mockserver=self.mockserver, + mockserver.url("/status?n=200"), + mockserver=mockserver, ) runner.crawl( SimpleSpider, - self.mockserver.url("/status?n=503"), - mockserver=self.mockserver, + mockserver.url("/status?n=503"), + mockserver=mockserver, ) with caplog.at_level(logging.DEBUG): @@ -432,25 +475,15 @@ with multiples lines @coroutine_test async def test_unknown_url_scheme(self, caplog: pytest.LogCaptureFixture) -> None: crawler = get_crawler(SimpleSpider) - await maybe_deferred_to_future(crawler.crawl("foo://bar")) + await crawler.crawl_async("foo://bar") assert "NotSupported: Unsupported URL scheme 'foo'" in caplog.text class TestCrawlSpider: - mockserver: MockServer - - @classmethod - def setup_class(cls): - cls.mockserver = MockServer() - cls.mockserver.__enter__() - - @classmethod - def teardown_class(cls): - cls.mockserver.__exit__(None, None, None) - + @staticmethod async def _run_spider( - self, spider_cls: type[Spider] - ) -> tuple[LogCapture, list[Any], StatsCollector]: + spider_cls: type[Spider], mockserver: MockServer + ) -> tuple[list[Any], StatsCollector]: items = [] def _on_item_scraped(item): @@ -458,103 +491,119 @@ class TestCrawlSpider: crawler = get_crawler(spider_cls) crawler.signals.connect(_on_item_scraped, signals.item_scraped) - with LogCapture() as log: - await maybe_deferred_to_future( - crawler.crawl( - self.mockserver.url("/status?n=200"), mockserver=self.mockserver - ) - ) + await crawler.crawl_async( + mockserver.url("/status?n=200"), mockserver=mockserver + ) assert crawler.stats - return log, items, crawler.stats + return items, crawler.stats - @inline_callbacks_test - def test_crawlspider_with_parse(self): + @coroutine_test + async def test_crawlspider_with_parse( + self, caplog: pytest.LogCaptureFixture, mockserver: MockServer + ) -> None: crawler = get_crawler(CrawlSpiderWithParseMethod) - with LogCapture() as log: - yield crawler.crawl(mockserver=self.mockserver) + with caplog.at_level(logging.INFO): + await crawler.crawl_async(mockserver=mockserver) - assert "[parse] status 200 (foo: None)" in str(log) - assert "[parse] status 201 (foo: None)" in str(log) - assert "[parse] status 202 (foo: bar)" in str(log) + assert "[parse] status 200 (foo: None)" in caplog.text + assert "[parse] status 201 (foo: None)" in caplog.text + assert "[parse] status 202 (foo: bar)" in caplog.text - @inline_callbacks_test - def test_crawlspider_with_async_callback(self): + @coroutine_test + async def test_crawlspider_with_async_callback( + self, caplog: pytest.LogCaptureFixture, mockserver: MockServer + ) -> None: crawler = get_crawler(CrawlSpiderWithAsyncCallback) - with LogCapture() as log: - yield crawler.crawl(mockserver=self.mockserver) + with caplog.at_level(logging.INFO): + await crawler.crawl_async(mockserver=mockserver) - assert "[parse_async] status 200 (foo: None)" in str(log) - assert "[parse_async] status 201 (foo: None)" in str(log) - assert "[parse_async] status 202 (foo: bar)" in str(log) + assert "[parse_async] status 200 (foo: None)" in caplog.text + assert "[parse_async] status 201 (foo: None)" in caplog.text + assert "[parse_async] status 202 (foo: bar)" in caplog.text - @inline_callbacks_test - def test_crawlspider_with_async_generator_callback(self): + @coroutine_test + async def test_crawlspider_with_async_generator_callback( + self, caplog: pytest.LogCaptureFixture, mockserver: MockServer + ) -> None: crawler = get_crawler(CrawlSpiderWithAsyncGeneratorCallback) - with LogCapture() as log: - yield crawler.crawl(mockserver=self.mockserver) + with caplog.at_level(logging.INFO): + await crawler.crawl_async(mockserver=mockserver) - assert "[parse_async_gen] status 200 (foo: None)" in str(log) - assert "[parse_async_gen] status 201 (foo: None)" in str(log) - assert "[parse_async_gen] status 202 (foo: bar)" in str(log) + assert "[parse_async_gen] status 200 (foo: None)" in caplog.text + assert "[parse_async_gen] status 201 (foo: None)" in caplog.text + assert "[parse_async_gen] status 202 (foo: bar)" in caplog.text - @inline_callbacks_test - def test_crawlspider_with_errback(self): + @coroutine_test + async def test_crawlspider_with_errback( + self, caplog: pytest.LogCaptureFixture, mockserver: MockServer + ) -> None: crawler = get_crawler(CrawlSpiderWithErrback) - with LogCapture() as log: - yield crawler.crawl(mockserver=self.mockserver) + with caplog.at_level(logging.INFO): + await crawler.crawl_async(mockserver=mockserver) - assert "[parse] status 200 (foo: None)" in str(log) - assert "[parse] status 201 (foo: None)" in str(log) - assert "[parse] status 202 (foo: bar)" in str(log) - assert "[errback] status 404" in str(log) - assert "[errback] status 500" in str(log) - assert "[errback] status 501" in str(log) + assert "[parse] status 200 (foo: None)" in caplog.text + assert "[parse] status 201 (foo: None)" in caplog.text + assert "[parse] status 202 (foo: bar)" in caplog.text + assert "[errback] status 404" in caplog.text + assert "[errback] status 500" in caplog.text + assert "[errback] status 501" in caplog.text - @inline_callbacks_test - def test_crawlspider_process_request_cb_kwargs(self): + @coroutine_test + async def test_crawlspider_process_request_cb_kwargs( + self, caplog: pytest.LogCaptureFixture, mockserver: MockServer + ) -> None: crawler = get_crawler(CrawlSpiderWithProcessRequestCallbackKeywordArguments) - with LogCapture() as log: - yield crawler.crawl(mockserver=self.mockserver) + with caplog.at_level(logging.INFO): + await crawler.crawl_async(mockserver=mockserver) - assert "[parse] status 200 (foo: process_request)" in str(log) - assert "[parse] status 201 (foo: process_request)" in str(log) - assert "[parse] status 202 (foo: bar)" in str(log) + assert "[parse] status 200 (foo: process_request)" in caplog.text + assert "[parse] status 201 (foo: process_request)" in caplog.text + assert "[parse] status 202 (foo: bar)" in caplog.text - @inline_callbacks_test - def test_async_def_parse(self): + @coroutine_test + async def test_async_def_parse( + self, caplog: pytest.LogCaptureFixture, mockserver: MockServer + ) -> None: crawler = get_crawler(AsyncDefSpider) - with LogCapture() as log: - yield crawler.crawl( - self.mockserver.url("/status?n=200"), mockserver=self.mockserver + with caplog.at_level(logging.INFO): + await crawler.crawl_async( + mockserver.url("/status?n=200"), mockserver=mockserver ) - assert "Got response 200" in str(log) + assert "Got response 200" in caplog.text @pytest.mark.only_asyncio - @inline_callbacks_test - def test_async_def_asyncio_parse(self): + @coroutine_test + async def test_async_def_asyncio_parse( + self, caplog: pytest.LogCaptureFixture, mockserver: MockServer + ) -> None: crawler = get_crawler( AsyncDefAsyncioSpider, { "TWISTED_REACTOR": "twisted.internet.asyncioreactor.AsyncioSelectorReactor" }, ) - with LogCapture() as log: - yield crawler.crawl( - self.mockserver.url("/status?n=200"), mockserver=self.mockserver + with caplog.at_level(logging.INFO): + await crawler.crawl_async( + mockserver.url("/status?n=200"), mockserver=mockserver ) - assert "Got response 200" in str(log) + assert "Got response 200" in caplog.text @pytest.mark.only_asyncio @coroutine_test - async def test_async_def_asyncio_parse_items_list(self): - log, items, _ = await self._run_spider(AsyncDefAsyncioReturnSpider) - assert "Got response 200" in str(log) + async def test_async_def_asyncio_parse_items_list( + self, caplog: pytest.LogCaptureFixture, mockserver: MockServer + ) -> None: + with caplog.at_level(logging.INFO): + items, _ = await self._run_spider(AsyncDefAsyncioReturnSpider, mockserver) + assert "Got response 200" in caplog.text assert {"id": 1} in items assert {"id": 2} in items @pytest.mark.only_asyncio - @inline_callbacks_test - def test_async_def_asyncio_parse_items_single_element(self): + @coroutine_test + async def test_async_def_asyncio_parse_items_single_element( + self, caplog: pytest.LogCaptureFixture, mockserver: MockServer + ) -> None: items = [] def _on_item_scraped(item): @@ -562,26 +611,34 @@ class TestCrawlSpider: crawler = get_crawler(AsyncDefAsyncioReturnSingleElementSpider) crawler.signals.connect(_on_item_scraped, signals.item_scraped) - with LogCapture() as log: - yield crawler.crawl( - self.mockserver.url("/status?n=200"), mockserver=self.mockserver + with caplog.at_level(logging.INFO): + await crawler.crawl_async( + mockserver.url("/status?n=200"), mockserver=mockserver ) - assert "Got response 200" in str(log) + assert "Got response 200" in caplog.text assert {"foo": 42} in items @pytest.mark.only_asyncio @coroutine_test - async def test_async_def_asyncgen_parse(self): - log, _, stats = await self._run_spider(AsyncDefAsyncioGenSpider) - assert "Got response 200" in str(log) + async def test_async_def_asyncgen_parse( + self, caplog: pytest.LogCaptureFixture, mockserver: MockServer + ) -> None: + with caplog.at_level(logging.INFO): + _, stats = await self._run_spider(AsyncDefAsyncioGenSpider, mockserver) + assert "Got response 200" in caplog.text itemcount = stats.get_value("item_scraped_count") assert itemcount == 1 @pytest.mark.only_asyncio @coroutine_test - async def test_async_def_asyncgen_parse_loop(self): - log, items, stats = await self._run_spider(AsyncDefAsyncioGenLoopSpider) - assert "Got response 200" in str(log) + async def test_async_def_asyncgen_parse_loop( + self, caplog: pytest.LogCaptureFixture, mockserver: MockServer + ) -> None: + with caplog.at_level(logging.INFO): + items, stats = await self._run_spider( + AsyncDefAsyncioGenLoopSpider, mockserver + ) + assert "Got response 200" in caplog.text itemcount = stats.get_value("item_scraped_count") assert itemcount == 10 for i in range(10): @@ -589,11 +646,15 @@ class TestCrawlSpider: @pytest.mark.only_asyncio @coroutine_test - async def test_async_def_asyncgen_parse_exc(self): - log, items, stats = await self._run_spider(AsyncDefAsyncioGenExcSpider) - log = str(log) - assert "Spider error processing" in log - assert "ValueError" in log + async def test_async_def_asyncgen_parse_exc( + self, caplog: pytest.LogCaptureFixture, mockserver: MockServer + ) -> None: + with caplog.at_level(logging.INFO): + items, stats = await self._run_spider( + AsyncDefAsyncioGenExcSpider, mockserver + ) + assert "Spider error processing" in caplog.text + assert "ValueError" in caplog.text itemcount = stats.get_value("item_scraped_count") assert itemcount == 7 for i in range(7): @@ -601,8 +662,12 @@ class TestCrawlSpider: @pytest.mark.only_asyncio @coroutine_test - async def test_async_def_asyncgen_parse_complex(self): - _, items, stats = await self._run_spider(AsyncDefAsyncioGenComplexSpider) + async def test_async_def_asyncgen_parse_complex( + self, mockserver: MockServer + ) -> None: + items, stats = await self._run_spider( + AsyncDefAsyncioGenComplexSpider, mockserver + ) itemcount = stats.get_value("item_scraped_count") assert itemcount == 156 # some random items @@ -613,33 +678,41 @@ class TestCrawlSpider: @pytest.mark.only_asyncio @coroutine_test - async def test_async_def_asyncio_parse_reqs_list(self): - log, *_ = await self._run_spider(AsyncDefAsyncioReqsReturnSpider) + async def test_async_def_asyncio_parse_reqs_list( + self, caplog: pytest.LogCaptureFixture, mockserver: MockServer + ) -> None: + with caplog.at_level(logging.INFO): + await self._run_spider(AsyncDefAsyncioReqsReturnSpider, mockserver) for req_id in range(3): - assert f"Got response 200, req_id {req_id}" in str(log) + assert f"Got response 200, req_id {req_id}" in caplog.text @pytest.mark.only_not_asyncio @coroutine_test - async def test_async_def_deferred_direct(self): - _, items, _ = await self._run_spider(AsyncDefDeferredDirectSpider) + async def test_async_def_deferred_direct(self, mockserver: MockServer) -> None: + items, _ = await self._run_spider(AsyncDefDeferredDirectSpider, mockserver) assert items == [{"code": 200}] @pytest.mark.only_asyncio @coroutine_test - async def test_async_def_deferred_wrapped(self): - _, items, _ = await self._run_spider(AsyncDefDeferredWrappedSpider) + async def test_async_def_deferred_wrapped(self, mockserver: MockServer) -> None: + items, _ = await self._run_spider(AsyncDefDeferredWrappedSpider, mockserver) assert items == [{"code": 200}] @coroutine_test - async def test_async_def_deferred_maybe_wrapped(self): - _, items, _ = await self._run_spider(AsyncDefDeferredMaybeWrappedSpider) + async def test_async_def_deferred_maybe_wrapped( + self, mockserver: MockServer + ) -> None: + items, _ = await self._run_spider( + AsyncDefDeferredMaybeWrappedSpider, mockserver + ) assert items == [{"code": 200}] - @inline_callbacks_test - def test_response_ssl_certificate_none(self): + @coroutine_test + async def test_response_ssl_certificate_none(self, mockserver: MockServer) -> None: crawler = get_crawler(SingleRequestSpider) - url = self.mockserver.url("/echo?body=test", is_secure=False) - yield crawler.crawl(seed=url, mockserver=self.mockserver) + url = mockserver.url("/echo?body=test", is_secure=False) + await crawler.crawl_async(seed=url, mockserver=mockserver) + assert isinstance(crawler.spider, SingleRequestSpider) assert crawler.spider.meta["responses"][0].certificate is None @pytest.mark.parametrize( @@ -699,10 +772,13 @@ class TestCrawlSpider: assert isinstance(ip_address, IPv4Address) assert str(ip_address) == gethostbyname(expected_netloc) - @inline_callbacks_test - def test_bytes_received_stop_download_callback(self): + @coroutine_test + async def test_bytes_received_stop_download_callback( + self, mockserver: MockServer + ) -> None: crawler = get_crawler(BytesReceivedCallbackSpider) - yield crawler.crawl(mockserver=self.mockserver) + await crawler.crawl_async(mockserver=mockserver) + assert isinstance(crawler.spider, BytesReceivedCallbackSpider) assert crawler.spider.meta.get("failure") is None assert isinstance(crawler.spider.meta["response"], Response) assert crawler.spider.meta["response"].body == crawler.spider.meta.get( @@ -713,10 +789,13 @@ class TestCrawlSpider: < crawler.spider.full_response_length ) - @inline_callbacks_test - def test_bytes_received_stop_download_errback(self): + @coroutine_test + async def test_bytes_received_stop_download_errback( + self, mockserver: MockServer + ) -> None: crawler = get_crawler(BytesReceivedErrbackSpider) - yield crawler.crawl(mockserver=self.mockserver) + await crawler.crawl_async(mockserver=mockserver) + assert isinstance(crawler.spider, BytesReceivedErrbackSpider) assert crawler.spider.meta.get("response") is None assert isinstance(crawler.spider.meta["failure"], Failure) assert isinstance(crawler.spider.meta["failure"].value, StopDownload) @@ -729,20 +808,26 @@ class TestCrawlSpider: < crawler.spider.full_response_length ) - @inline_callbacks_test - def test_headers_received_stop_download_callback(self): + @coroutine_test + async def test_headers_received_stop_download_callback( + self, mockserver: MockServer + ) -> None: crawler = get_crawler(HeadersReceivedCallbackSpider) - yield crawler.crawl(mockserver=self.mockserver) + await crawler.crawl_async(mockserver=mockserver) + assert isinstance(crawler.spider, HeadersReceivedCallbackSpider) assert crawler.spider.meta.get("failure") is None assert isinstance(crawler.spider.meta["response"], Response) assert crawler.spider.meta["response"].headers == crawler.spider.meta.get( "headers_received" ) - @inline_callbacks_test - def test_headers_received_stop_download_errback(self): + @coroutine_test + async def test_headers_received_stop_download_errback( + self, mockserver: MockServer + ) -> None: crawler = get_crawler(HeadersReceivedErrbackSpider) - yield crawler.crawl(mockserver=self.mockserver) + await crawler.crawl_async(mockserver=mockserver) + assert isinstance(crawler.spider, HeadersReceivedErrbackSpider) assert crawler.spider.meta.get("response") is None assert isinstance(crawler.spider.meta["failure"], Failure) assert isinstance(crawler.spider.meta["failure"].value, StopDownload) @@ -751,8 +836,10 @@ class TestCrawlSpider: "failure" ].value.response.headers == crawler.spider.meta.get("headers_received") - @inline_callbacks_test - def test_spider_callback_deferred_deprecated(self): + @coroutine_test + async def test_spider_callback_deferred_deprecated( + self, mockserver: MockServer + ) -> None: def cb(response: Response) -> Any: return succeed(None) @@ -761,10 +848,12 @@ class TestCrawlSpider: ScrapyDeprecationWarning, match="Returning Deferreds from spider callbacks is deprecated", ): - yield crawler.crawl(seed=self.mockserver.url("/"), callback_func=cb) + await crawler.crawl_async(seed=mockserver.url("/"), callback_func=cb) - @inline_callbacks_test - def test_spider_errback(self): + @coroutine_test + async def test_spider_errback( + self, caplog: pytest.LogCaptureFixture, mockserver: MockServer + ) -> None: failures = [] def eb(failure: Failure) -> Failure: @@ -772,72 +861,82 @@ class TestCrawlSpider: return failure crawler = get_crawler(SingleRequestSpider) - with LogCapture() as log: - yield crawler.crawl( - seed=self.mockserver.url("/status?n=400"), errback_func=eb + with caplog.at_level(logging.INFO): + await crawler.crawl_async( + seed=mockserver.url("/status?n=400"), errback_func=eb ) assert len(failures) == 1 - assert "HTTP status code is not handled or not allowed" in str(log) - assert "Spider error processing" not in str(log) + assert "HTTP status code is not handled or not allowed" in caplog.text + assert "Spider error processing" not in caplog.text - @inline_callbacks_test - def test_spider_errback_silence(self): + @coroutine_test + async def test_spider_errback_silence( + self, caplog: pytest.LogCaptureFixture, mockserver: MockServer + ) -> None: failures = [] def eb(failure: Failure) -> None: failures.append(failure) crawler = get_crawler(SingleRequestSpider) - with LogCapture() as log: - yield crawler.crawl( - seed=self.mockserver.url("/status?n=400"), errback_func=eb + with caplog.at_level(logging.INFO): + await crawler.crawl_async( + seed=mockserver.url("/status?n=400"), errback_func=eb ) assert len(failures) == 1 - assert "HTTP status code is not handled or not allowed" not in str(log) - assert "Spider error processing" not in str(log) + assert "HTTP status code is not handled or not allowed" not in caplog.text + assert "Spider error processing" not in caplog.text - @inline_callbacks_test - def test_spider_errback_exception(self): + @coroutine_test + async def test_spider_errback_exception( + self, caplog: pytest.LogCaptureFixture, mockserver: MockServer + ) -> None: def eb(failure: Failure) -> None: raise ValueError("foo") crawler = get_crawler(SingleRequestSpider) - with LogCapture() as log: - yield crawler.crawl( - seed=self.mockserver.url("/status?n=400"), errback_func=eb + with caplog.at_level(logging.INFO): + await crawler.crawl_async( + seed=mockserver.url("/status?n=400"), errback_func=eb ) - assert "Spider error processing" in str(log) + assert "Spider error processing" in caplog.text - @inline_callbacks_test - def test_spider_errback_item(self): + @coroutine_test + async def test_spider_errback_item( + self, caplog: pytest.LogCaptureFixture, mockserver: MockServer + ) -> None: def eb(failure: Failure) -> Any: return {"foo": "bar"} crawler = get_crawler(SingleRequestSpider) - with LogCapture() as log: - yield crawler.crawl( - seed=self.mockserver.url("/status?n=400"), errback_func=eb + with caplog.at_level(logging.INFO): + await crawler.crawl_async( + seed=mockserver.url("/status?n=400"), errback_func=eb ) - assert "HTTP status code is not handled or not allowed" not in str(log) - assert "Spider error processing" not in str(log) - assert "'item_scraped_count': 1" in str(log) + assert "HTTP status code is not handled or not allowed" not in caplog.text + assert "Spider error processing" not in caplog.text + assert "'item_scraped_count': 1" in caplog.text - @inline_callbacks_test - def test_spider_errback_request(self): + @coroutine_test + async def test_spider_errback_request( + self, caplog: pytest.LogCaptureFixture, mockserver: MockServer + ) -> None: def eb(failure: Failure) -> Request: - return Request(self.mockserver.url("/")) + return Request(mockserver.url("/")) crawler = get_crawler(SingleRequestSpider) - with LogCapture() as log: - yield crawler.crawl( - seed=self.mockserver.url("/status?n=400"), errback_func=eb + with caplog.at_level(logging.DEBUG): + await crawler.crawl_async( + seed=mockserver.url("/status?n=400"), errback_func=eb ) - assert "HTTP status code is not handled or not allowed" not in str(log) - assert "Spider error processing" not in str(log) - assert "Crawled (200)" in str(log) + assert "HTTP status code is not handled or not allowed" not in caplog.text + assert "Spider error processing" not in caplog.text + assert "Crawled (200)" in caplog.text - @inline_callbacks_test - def test_spider_errback_downloader_error(self): + @coroutine_test + async def test_spider_errback_downloader_error( + self, caplog: pytest.LogCaptureFixture, mockserver: MockServer + ) -> None: failures = [] def eb(failure: Failure) -> Failure: @@ -845,57 +944,65 @@ class TestCrawlSpider: return failure crawler = get_crawler(SingleRequestSpider) - with LogCapture() as log: - yield crawler.crawl( - seed=self.mockserver.url("/drop?abort=1"), errback_func=eb + with caplog.at_level(logging.INFO): + await crawler.crawl_async( + seed=mockserver.url("/drop?abort=1"), errback_func=eb ) assert len(failures) == 1 - assert "Error downloading" in str(log) - assert "Spider error processing" not in str(log) + assert "Error downloading" in caplog.text + assert "Spider error processing" not in caplog.text - @inline_callbacks_test - def test_spider_errback_downloader_error_exception(self): + @coroutine_test + async def test_spider_errback_downloader_error_exception( + self, caplog: pytest.LogCaptureFixture, mockserver: MockServer + ) -> None: def eb(failure: Failure) -> None: raise ValueError("foo") crawler = get_crawler(SingleRequestSpider) - with LogCapture() as log: - yield crawler.crawl( - seed=self.mockserver.url("/drop?abort=1"), errback_func=eb + with caplog.at_level(logging.INFO): + await crawler.crawl_async( + seed=mockserver.url("/drop?abort=1"), errback_func=eb ) - assert "Error downloading" in str(log) - assert "Spider error processing" in str(log) + assert "Error downloading" in caplog.text + assert "Spider error processing" in caplog.text - @inline_callbacks_test - def test_spider_errback_downloader_error_item(self): + @coroutine_test + async def test_spider_errback_downloader_error_item( + self, caplog: pytest.LogCaptureFixture, mockserver: MockServer + ) -> None: def eb(failure: Failure) -> Any: return {"foo": "bar"} crawler = get_crawler(SingleRequestSpider) - with LogCapture() as log: - yield crawler.crawl( - seed=self.mockserver.url("/drop?abort=1"), errback_func=eb + with caplog.at_level(logging.INFO): + await crawler.crawl_async( + seed=mockserver.url("/drop?abort=1"), errback_func=eb ) - assert "HTTP status code is not handled or not allowed" not in str(log) - assert "Spider error processing" not in str(log) - assert "'item_scraped_count': 1" in str(log) + assert "HTTP status code is not handled or not allowed" not in caplog.text + assert "Spider error processing" not in caplog.text + assert "'item_scraped_count': 1" in caplog.text - @inline_callbacks_test - def test_spider_errback_downloader_error_request(self): + @coroutine_test + async def test_spider_errback_downloader_error_request( + self, caplog: pytest.LogCaptureFixture, mockserver: MockServer + ) -> None: def eb(failure: Failure) -> Request: - return Request(self.mockserver.url("/")) + return Request(mockserver.url("/")) crawler = get_crawler(SingleRequestSpider) - with LogCapture() as log: - yield crawler.crawl( - seed=self.mockserver.url("/drop?abort=1"), errback_func=eb + with caplog.at_level(logging.DEBUG): + await crawler.crawl_async( + seed=mockserver.url("/drop?abort=1"), errback_func=eb ) - assert "HTTP status code is not handled or not allowed" not in str(log) - assert "Spider error processing" not in str(log) - assert "Crawled (200)" in str(log) + assert "HTTP status code is not handled or not allowed" not in caplog.text + assert "Spider error processing" not in caplog.text + assert "Crawled (200)" in caplog.text - @inline_callbacks_test - def test_spider_errback_deferred_deprecated(self): + @coroutine_test + async def test_spider_errback_deferred_deprecated( + self, mockserver: MockServer + ) -> None: def eb(failure: Failure) -> Any: return succeed(None) @@ -904,28 +1011,32 @@ class TestCrawlSpider: ScrapyDeprecationWarning, match="Returning Deferreds from spider errbacks is deprecated", ): - yield crawler.crawl( - seed=self.mockserver.url("/status?n=400"), errback_func=eb + await crawler.crawl_async( + seed=mockserver.url("/status?n=400"), errback_func=eb ) - @inline_callbacks_test - def test_raise_closespider(self): + @coroutine_test + async def test_raise_closespider( + self, caplog: pytest.LogCaptureFixture, mockserver: MockServer + ) -> None: def cb(response): raise CloseSpider crawler = get_crawler(SingleRequestSpider) - with LogCapture() as log: - yield crawler.crawl(seed=self.mockserver.url("/"), callback_func=cb) - assert "Closing spider (cancelled)" in str(log) - assert "Spider error processing" not in str(log) + with caplog.at_level(logging.INFO): + await crawler.crawl_async(seed=mockserver.url("/"), callback_func=cb) + assert "Closing spider (cancelled)" in caplog.text + assert "Spider error processing" not in caplog.text - @inline_callbacks_test - def test_raise_closespider_reason(self): + @coroutine_test + async def test_raise_closespider_reason( + self, caplog: pytest.LogCaptureFixture, mockserver: MockServer + ) -> None: def cb(response): raise CloseSpider("my_reason") crawler = get_crawler(SingleRequestSpider) - with LogCapture() as log: - yield crawler.crawl(seed=self.mockserver.url("/"), callback_func=cb) - assert "Closing spider (my_reason)" in str(log) - assert "Spider error processing" not in str(log) + with caplog.at_level(logging.INFO): + await crawler.crawl_async(seed=mockserver.url("/"), callback_func=cb) + assert "Closing spider (my_reason)" in caplog.text + assert "Spider error processing" not in caplog.text diff --git a/tests/test_crawler.py b/tests/test_crawler.py index cbcb7e274..853d6cfaa 100644 --- a/tests/test_crawler.py +++ b/tests/test_crawler.py @@ -1,13 +1,13 @@ +from __future__ import annotations + import asyncio import logging import re import warnings -from collections.abc import Generator from pathlib import Path -from typing import Any, cast +from typing import Any, ClassVar import pytest -from twisted.internet.defer import Deferred from zope.interface.exceptions import MultipleInvalid import scrapy @@ -22,8 +22,8 @@ from scrapy.crawler import ( ) from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.extensions.throttle import AutoThrottle -from scrapy.settings import Settings, default_settings -from scrapy.utils.defer import deferred_from_coro, maybe_deferred_to_future +from scrapy.settings import Settings, _SettingsKey, default_settings +from scrapy.utils.defer import ensure_awaitable, maybe_deferred_to_future from scrapy.utils.log import ( _uninstall_scrapy_root_handler, configure_logging, @@ -31,12 +31,14 @@ from scrapy.utils.log import ( ) from scrapy.utils.spider import DefaultSpider from scrapy.utils.test import get_crawler, get_reactor_settings -from tests.utils.decorators import coroutine_test, inline_callbacks_test +from tests.utils.decorators import coroutine_test BASE_SETTINGS: dict[str, Any] = {} -def get_raw_crawler(spidercls=None, settings_dict=None): +def get_raw_crawler( + spidercls: type[Spider] | None = None, settings_dict: dict[str, Any] | None = None +) -> Crawler: """get_crawler alternative that only calls the __init__ method of the crawler.""" settings = Settings() @@ -46,14 +48,18 @@ def get_raw_crawler(spidercls=None, settings_dict=None): class TestBaseCrawler: - def assertOptionIsDefault(self, settings: Settings, key: str) -> None: + @staticmethod + def assertOptionIsDefault(settings: Settings, key: str) -> None: assert isinstance(settings, Settings) assert settings[key] == getattr(default_settings, key) class TestCrawler(TestBaseCrawler): - def test_populate_spidercls_settings(self): - spider_settings = {"TEST1": "spider", "TEST2": "spider"} + def test_populate_spidercls_settings(self) -> None: + spider_settings: dict[_SettingsKey, Any] = { + "TEST1": "spider", + "TEST2": "spider", + } project_settings = { **BASE_SETTINGS, "TEST1": "project", @@ -76,47 +82,47 @@ class TestCrawler(TestBaseCrawler): assert not settings.frozen assert crawler.settings.frozen - def test_crawler_accepts_dict(self): + def test_crawler_accepts_dict(self) -> None: crawler = get_crawler(DefaultSpider, {"foo": "bar"}) assert crawler.settings["foo"] == "bar" self.assertOptionIsDefault(crawler.settings, "RETRY_ENABLED") - def test_crawler_accepts_None(self): + def test_crawler_accepts_None(self) -> None: with warnings.catch_warnings(): warnings.simplefilter("ignore", ScrapyDeprecationWarning) crawler = Crawler(DefaultSpider) self.assertOptionIsDefault(crawler.settings, "RETRY_ENABLED") - def test_crawler_rejects_spider_objects(self): + def test_crawler_rejects_spider_objects(self) -> None: with pytest.raises(ValueError, match="spidercls argument must be a class"): - Crawler(DefaultSpider()) - - @inline_callbacks_test - def test_crawler_crawl_twice_seq_unsupported(self): - crawler = get_raw_crawler(NoRequestsSpider, BASE_SETTINGS) - yield crawler.crawl() - with pytest.raises(RuntimeError, match="more than once on the same instance"): - yield crawler.crawl() + Crawler(DefaultSpider()) # type: ignore[arg-type] @coroutine_test - async def test_crawler_crawl_async_twice_seq_unsupported(self): + async def test_crawler_crawl_twice_seq_unsupported(self) -> None: + crawler = get_raw_crawler(NoRequestsSpider, BASE_SETTINGS) + await maybe_deferred_to_future(crawler.crawl()) + with pytest.raises(RuntimeError, match="more than once on the same instance"): + await maybe_deferred_to_future(crawler.crawl()) + + @coroutine_test + async def test_crawler_crawl_async_twice_seq_unsupported(self) -> None: crawler = get_raw_crawler(NoRequestsSpider, BASE_SETTINGS) await crawler.crawl_async() with pytest.raises(RuntimeError, match="more than once on the same instance"): await crawler.crawl_async() - @inline_callbacks_test - def test_crawler_crawl_twice_parallel_unsupported(self): + @coroutine_test + async def test_crawler_crawl_twice_parallel_unsupported(self) -> None: crawler = get_raw_crawler(NoRequestsSpider, BASE_SETTINGS) d1 = crawler.crawl() d2 = crawler.crawl() - yield d1 + await maybe_deferred_to_future(d1) with pytest.raises(RuntimeError, match="Crawling already taking place"): - yield d2 + await maybe_deferred_to_future(d2) @pytest.mark.only_asyncio @coroutine_test - async def test_crawler_crawl_async_twice_parallel_unsupported(self): + async def test_crawler_crawl_async_twice_parallel_unsupported(self) -> None: crawler = get_raw_crawler(NoRequestsSpider, BASE_SETTINGS) t1 = asyncio.create_task(crawler.crawl_async()) t2 = asyncio.create_task(crawler.crawl_async()) @@ -124,12 +130,12 @@ class TestCrawler(TestBaseCrawler): with pytest.raises(RuntimeError, match="Crawling already taking place"): await t2 - def test_get_addon(self): + def test_get_addon(self) -> None: class ParentAddon: pass class TrackingAddon(ParentAddon): - instances = [] + instances: ClassVar[list[TrackingAddon]] = [] def __init__(self): TrackingAddon.instances.append(self) @@ -150,7 +156,7 @@ class TestCrawler(TestBaseCrawler): addon = crawler.get_addon(TrackingAddon) assert addon == expected - addon = crawler.get_addon(DefaultSpider) + addon = crawler.get_addon(DefaultSpider) # type: ignore[assignment] assert addon is None addon = crawler.get_addon(ParentAddon) @@ -162,19 +168,21 @@ class TestCrawler(TestBaseCrawler): addon = crawler.get_addon(ChildAddon) assert addon is None - @inline_callbacks_test - def test_get_downloader_middleware(self): + @coroutine_test + async def test_get_downloader_middleware(self) -> None: class ParentDownloaderMiddleware: pass class TrackingDownloaderMiddleware(ParentDownloaderMiddleware): - instances = [] + instances: ClassVar[list[TrackingDownloaderMiddleware]] = [] def __init__(self): TrackingDownloaderMiddleware.instances.append(self) class MySpider(Spider): name = "myspider" + cls: ClassVar[type[Any]] + result: ClassVar[Any] @classmethod def from_crawler(cls, crawler): @@ -198,18 +206,18 @@ class TestCrawler(TestBaseCrawler): crawler = get_raw_crawler(MySpider, settings) MySpider.cls = TrackingDownloaderMiddleware - yield crawler.crawl() + await crawler.crawl_async() assert len(TrackingDownloaderMiddleware.instances) == 1 assert MySpider.result == TrackingDownloaderMiddleware.instances[-1] crawler = get_raw_crawler(MySpider, settings) MySpider.cls = DefaultSpider - yield crawler.crawl() + await crawler.crawl_async() assert MySpider.result is None crawler = get_raw_crawler(MySpider, settings) MySpider.cls = ParentDownloaderMiddleware - yield crawler.crawl() + await crawler.crawl_async() assert MySpider.result == TrackingDownloaderMiddleware.instances[-1] class ChildDownloaderMiddleware(TrackingDownloaderMiddleware): @@ -217,16 +225,16 @@ class TestCrawler(TestBaseCrawler): crawler = get_raw_crawler(MySpider, settings) MySpider.cls = ChildDownloaderMiddleware - yield crawler.crawl() + await crawler.crawl_async() assert MySpider.result is None - def test_get_downloader_middleware_not_crawling(self): + def test_get_downloader_middleware_not_crawling(self) -> None: crawler = get_raw_crawler(settings_dict=BASE_SETTINGS) with pytest.raises(RuntimeError): crawler.get_downloader_middleware(DefaultSpider) - @inline_callbacks_test - def test_get_downloader_middleware_no_engine(self): + @coroutine_test + async def test_get_downloader_middleware_no_engine(self) -> None: class MySpider(Spider): name = "myspider" @@ -240,21 +248,23 @@ class TestCrawler(TestBaseCrawler): crawler = get_raw_crawler(MySpider, BASE_SETTINGS) with pytest.raises(RuntimeError): - yield crawler.crawl() + await crawler.crawl_async() - @inline_callbacks_test - def test_get_extension(self): + @coroutine_test + async def test_get_extension(self) -> None: class ParentExtension: pass class TrackingExtension(ParentExtension): - instances = [] + instances: ClassVar[list[TrackingExtension]] = [] def __init__(self): TrackingExtension.instances.append(self) class MySpider(Spider): name = "myspider" + cls: ClassVar[type[Any]] + result: ClassVar[Any] @classmethod def from_crawler(cls, crawler): @@ -278,18 +288,18 @@ class TestCrawler(TestBaseCrawler): crawler = get_raw_crawler(MySpider, settings) MySpider.cls = TrackingExtension - yield crawler.crawl() + await crawler.crawl_async() assert len(TrackingExtension.instances) == 1 assert MySpider.result == TrackingExtension.instances[-1] crawler = get_raw_crawler(MySpider, settings) MySpider.cls = DefaultSpider - yield crawler.crawl() + await crawler.crawl_async() assert MySpider.result is None crawler = get_raw_crawler(MySpider, settings) MySpider.cls = ParentExtension - yield crawler.crawl() + await crawler.crawl_async() assert MySpider.result == TrackingExtension.instances[-1] class ChildExtension(TrackingExtension): @@ -297,16 +307,16 @@ class TestCrawler(TestBaseCrawler): crawler = get_raw_crawler(MySpider, settings) MySpider.cls = ChildExtension - yield crawler.crawl() + await crawler.crawl_async() assert MySpider.result is None - def test_get_extension_not_crawling(self): + def test_get_extension_not_crawling(self) -> None: crawler = get_raw_crawler(settings_dict=BASE_SETTINGS) with pytest.raises(RuntimeError): crawler.get_extension(DefaultSpider) - @inline_callbacks_test - def test_get_extension_no_engine(self): + @coroutine_test + async def test_get_extension_no_engine(self) -> None: class MySpider(Spider): name = "myspider" @@ -320,21 +330,23 @@ class TestCrawler(TestBaseCrawler): crawler = get_raw_crawler(MySpider, BASE_SETTINGS) with pytest.raises(RuntimeError): - yield crawler.crawl() + await crawler.crawl_async() - @inline_callbacks_test - def test_get_item_pipeline(self): + @coroutine_test + async def test_get_item_pipeline(self) -> None: class ParentItemPipeline: pass class TrackingItemPipeline(ParentItemPipeline): - instances = [] + instances: ClassVar[list[TrackingItemPipeline]] = [] def __init__(self): TrackingItemPipeline.instances.append(self) class MySpider(Spider): name = "myspider" + cls: ClassVar[type[Any]] + result: ClassVar[Any] @classmethod def from_crawler(cls, crawler): @@ -358,18 +370,18 @@ class TestCrawler(TestBaseCrawler): crawler = get_raw_crawler(MySpider, settings) MySpider.cls = TrackingItemPipeline - yield crawler.crawl() + await crawler.crawl_async() assert len(TrackingItemPipeline.instances) == 1 assert MySpider.result == TrackingItemPipeline.instances[-1] crawler = get_raw_crawler(MySpider, settings) MySpider.cls = DefaultSpider - yield crawler.crawl() + await crawler.crawl_async() assert MySpider.result is None crawler = get_raw_crawler(MySpider, settings) MySpider.cls = ParentItemPipeline - yield crawler.crawl() + await crawler.crawl_async() assert MySpider.result == TrackingItemPipeline.instances[-1] class ChildItemPipeline(TrackingItemPipeline): @@ -377,16 +389,16 @@ class TestCrawler(TestBaseCrawler): crawler = get_raw_crawler(MySpider, settings) MySpider.cls = ChildItemPipeline - yield crawler.crawl() + await crawler.crawl_async() assert MySpider.result is None - def test_get_item_pipeline_not_crawling(self): + def test_get_item_pipeline_not_crawling(self) -> None: crawler = get_raw_crawler(settings_dict=BASE_SETTINGS) with pytest.raises(RuntimeError): crawler.get_item_pipeline(DefaultSpider) - @inline_callbacks_test - def test_get_item_pipeline_no_engine(self): + @coroutine_test + async def test_get_item_pipeline_no_engine(self) -> None: class MySpider(Spider): name = "myspider" @@ -400,21 +412,23 @@ class TestCrawler(TestBaseCrawler): crawler = get_raw_crawler(MySpider, BASE_SETTINGS) with pytest.raises(RuntimeError): - yield crawler.crawl() + await crawler.crawl_async() - @inline_callbacks_test - def test_get_spider_middleware(self): + @coroutine_test + async def test_get_spider_middleware(self) -> None: class ParentSpiderMiddleware: pass class TrackingSpiderMiddleware(ParentSpiderMiddleware): - instances = [] + instances: ClassVar[list[TrackingSpiderMiddleware]] = [] def __init__(self): TrackingSpiderMiddleware.instances.append(self) class MySpider(Spider): name = "myspider" + cls: ClassVar[type[Any]] + result: ClassVar[Any] @classmethod def from_crawler(cls, crawler): @@ -438,18 +452,18 @@ class TestCrawler(TestBaseCrawler): crawler = get_raw_crawler(MySpider, settings) MySpider.cls = TrackingSpiderMiddleware - yield crawler.crawl() + await crawler.crawl_async() assert len(TrackingSpiderMiddleware.instances) == 1 assert MySpider.result == TrackingSpiderMiddleware.instances[-1] crawler = get_raw_crawler(MySpider, settings) MySpider.cls = DefaultSpider - yield crawler.crawl() + await crawler.crawl_async() assert MySpider.result is None crawler = get_raw_crawler(MySpider, settings) MySpider.cls = ParentSpiderMiddleware - yield crawler.crawl() + await crawler.crawl_async() assert MySpider.result == TrackingSpiderMiddleware.instances[-1] class ChildSpiderMiddleware(TrackingSpiderMiddleware): @@ -457,16 +471,16 @@ class TestCrawler(TestBaseCrawler): crawler = get_raw_crawler(MySpider, settings) MySpider.cls = ChildSpiderMiddleware - yield crawler.crawl() + await crawler.crawl_async() assert MySpider.result is None - def test_get_spider_middleware_not_crawling(self): + def test_get_spider_middleware_not_crawling(self) -> None: crawler = get_raw_crawler(settings_dict=BASE_SETTINGS) with pytest.raises(RuntimeError): crawler.get_spider_middleware(DefaultSpider) - @inline_callbacks_test - def test_get_spider_middleware_no_engine(self): + @coroutine_test + async def test_get_spider_middleware_no_engine(self) -> None: class MySpider(Spider): name = "myspider" @@ -480,22 +494,23 @@ class TestCrawler(TestBaseCrawler): crawler = get_raw_crawler(MySpider, BASE_SETTINGS) with pytest.raises(RuntimeError): - yield crawler.crawl() + await crawler.crawl_async() class TestSpiderSettings: - def test_spider_custom_settings(self): + def test_spider_custom_settings(self) -> None: class MySpider(scrapy.Spider): name = "spider" custom_settings = {"AUTOTHROTTLE_ENABLED": True} crawler = get_crawler(MySpider) + assert crawler.extensions enabled_exts = [e.__class__ for e in crawler.extensions.middlewares] assert AutoThrottle in enabled_exts class TestCrawlerLogging: - def test_no_root_handler_installed(self): + def test_no_root_handler_installed(self) -> None: handler = get_scrapy_root_handler() if handler is not None: logging.root.removeHandler(handler) @@ -507,7 +522,7 @@ class TestCrawlerLogging: assert get_scrapy_root_handler() is None @coroutine_test - async def test_spider_custom_settings_log_level(self, tmp_path): + async def test_spider_custom_settings_log_level(self, tmp_path: Path) -> None: log_file = Path(tmp_path, "log.txt") log_file.write_text("previous message\n", encoding="utf-8") @@ -535,9 +550,13 @@ class TestCrawlerLogging: try: configure_logging() - assert get_scrapy_root_handler().level == logging.DEBUG + handler = get_scrapy_root_handler() + assert handler is not None + assert handler.level == logging.DEBUG crawler = get_crawler(MySpider) - assert get_scrapy_root_handler().level == logging.INFO + handler = get_scrapy_root_handler() + assert handler is not None + assert handler.level == logging.INFO await crawler.crawl_async() finally: _uninstall_scrapy_root_handler() @@ -549,12 +568,13 @@ class TestCrawlerLogging: assert "info message" in logged assert "warning message" in logged assert "error message" in logged + assert crawler.stats assert crawler.stats.get_value("log_count/ERROR") == 1 assert crawler.stats.get_value("log_count/WARNING") == 1 assert info_count == 1 assert crawler.stats.get_value("log_count/DEBUG", 0) == 0 - def test_spider_custom_settings_log_append(self, tmp_path): + def test_spider_custom_settings_log_append(self, tmp_path: Path) -> None: log_file = Path(tmp_path, "log.txt") log_file.write_text("previous message\n", encoding="utf-8") @@ -579,12 +599,12 @@ class TestCrawlerLogging: class SpiderLoaderWithWrongInterface: - def unneeded_method(self): + def unneeded_method(self) -> None: pass class TestCrawlerRunner(TestBaseCrawler): - def test_spider_manager_verify_interface(self): + def test_spider_manager_verify_interface(self) -> None: settings = Settings( { "SPIDER_LOADER_CLASS": SpiderLoaderWithWrongInterface, @@ -593,18 +613,18 @@ class TestCrawlerRunner(TestBaseCrawler): with pytest.raises(MultipleInvalid): CrawlerRunner(settings) - def test_crawler_runner_accepts_dict(self): + def test_crawler_runner_accepts_dict(self) -> None: runner = CrawlerRunner({"foo": "bar"}) assert runner.settings["foo"] == "bar" self.assertOptionIsDefault(runner.settings, "RETRY_ENABLED") - def test_crawler_runner_accepts_None(self): + def test_crawler_runner_accepts_None(self) -> None: runner = CrawlerRunner() self.assertOptionIsDefault(runner.settings, "RETRY_ENABLED") class TestAsyncCrawlerRunner(TestBaseCrawler): - def test_spider_manager_verify_interface(self): + def test_spider_manager_verify_interface(self) -> None: settings = Settings( { "SPIDER_LOADER_CLASS": SpiderLoaderWithWrongInterface, @@ -613,23 +633,23 @@ class TestAsyncCrawlerRunner(TestBaseCrawler): with pytest.raises(MultipleInvalid): AsyncCrawlerRunner(settings) - def test_crawler_runner_accepts_dict(self): + def test_crawler_runner_accepts_dict(self) -> None: runner = AsyncCrawlerRunner({"foo": "bar"}) assert runner.settings["foo"] == "bar" self.assertOptionIsDefault(runner.settings, "RETRY_ENABLED") - def test_crawler_runner_accepts_None(self): + def test_crawler_runner_accepts_None(self) -> None: runner = AsyncCrawlerRunner() self.assertOptionIsDefault(runner.settings, "RETRY_ENABLED") class TestCrawlerProcess(TestBaseCrawler): - def test_crawler_process_accepts_dict(self): + def test_crawler_process_accepts_dict(self) -> None: runner = CrawlerProcess({"foo": "bar"}, install_root_handler=False) assert runner.settings["foo"] == "bar" self.assertOptionIsDefault(runner.settings, "RETRY_ENABLED") - def test_crawler_process_accepts_None(self): + def test_crawler_process_accepts_None(self) -> None: runner = CrawlerProcess(install_root_handler=False) self.assertOptionIsDefault(runner.settings, "RETRY_ENABLED") @@ -668,33 +688,35 @@ class NoRequestsSpider(scrapy.Spider): @pytest.mark.requires_reactor # CrawlerRunner requires a reactor class TestCrawlerRunnerHasSpider: - @staticmethod - def _runner() -> CrawlerRunnerBase: + @pytest.fixture + def runner(self) -> CrawlerRunnerBase: return CrawlerRunner(get_reactor_settings()) @staticmethod - def _crawl(runner: CrawlerRunnerBase, spider: type[Spider]) -> Deferred[None]: - return cast("Deferred[None]", runner.crawl(spider)) + async def _crawl(runner: CrawlerRunnerBase, spider: type[Spider]) -> None: + await ensure_awaitable(runner.crawl(spider)) - @inline_callbacks_test - def test_crawler_runner_bootstrap_successful(self): - runner = self._runner() - yield self._crawl(runner, NoRequestsSpider) + @coroutine_test + async def test_crawler_runner_bootstrap_successful( + self, runner: CrawlerRunnerBase + ) -> None: + await self._crawl(runner, NoRequestsSpider) assert not runner.bootstrap_failed - @inline_callbacks_test - def test_crawler_runner_bootstrap_successful_for_several(self): - runner = self._runner() - yield self._crawl(runner, NoRequestsSpider) - yield self._crawl(runner, NoRequestsSpider) + @coroutine_test + async def test_crawler_runner_bootstrap_successful_for_several( + self, runner: CrawlerRunnerBase + ) -> None: + await self._crawl(runner, NoRequestsSpider) + await self._crawl(runner, NoRequestsSpider) assert not runner.bootstrap_failed - @inline_callbacks_test - def test_crawler_runner_bootstrap_failed(self): - runner = self._runner() - + @coroutine_test + async def test_crawler_runner_bootstrap_failed( + self, runner: CrawlerRunnerBase + ) -> None: try: - yield self._crawl(runner, ExceptionSpider) + await self._crawl(runner, ExceptionSpider) except ValueError: pass else: @@ -702,25 +724,25 @@ class TestCrawlerRunnerHasSpider: assert runner.bootstrap_failed - @inline_callbacks_test - def test_crawler_runner_bootstrap_failed_for_several(self): - runner = self._runner() - + @coroutine_test + async def test_crawler_runner_bootstrap_failed_for_several( + self, runner: CrawlerRunnerBase + ) -> None: try: - yield self._crawl(runner, ExceptionSpider) + await self._crawl(runner, ExceptionSpider) except ValueError: pass else: pytest.fail("Exception should be raised from spider") - yield self._crawl(runner, NoRequestsSpider) + await self._crawl(runner, NoRequestsSpider) assert runner.bootstrap_failed - @inline_callbacks_test - def test_crawler_runner_asyncio_enabled_true( + @coroutine_test + async def test_crawler_runner_asyncio_enabled_true( self, reactor_pytest: str - ) -> Generator[Deferred[Any], Any, None]: + ) -> None: if reactor_pytest != "asyncio": runner = CrawlerRunner( settings={ @@ -731,7 +753,7 @@ class TestCrawlerRunnerHasSpider: Exception, match=r"The installed reactor \(.*?\) does not match the requested one \(.*?\)", ): - yield self._crawl(runner, NoRequestsSpider) + await self._crawl(runner, NoRequestsSpider) else: CrawlerRunner( settings={ @@ -746,11 +768,7 @@ class TestAsyncCrawlerRunnerHasSpider(TestCrawlerRunnerHasSpider): def _runner() -> CrawlerRunnerBase: return AsyncCrawlerRunner(get_reactor_settings()) - @staticmethod - def _crawl(runner: CrawlerRunnerBase, spider: type[Spider]) -> Deferred[None]: - return deferred_from_coro(runner.crawl(spider)) - - def test_crawler_runner_asyncio_enabled_true(self): + def test_crawler_runner_asyncio_enabled_true(self) -> None: # type: ignore[override] pytest.skip("This test is only for CrawlerRunner") @@ -762,7 +780,9 @@ class TestAsyncCrawlerRunnerHasSpider(TestCrawlerRunnerHasSpider): ({"LOG_VERSIONS": []}, None), ], ) -def test_log_scrapy_info(settings, items, caplog): +def test_log_scrapy_info( + settings: dict[str, Any], items: list[str] | None, caplog: pytest.LogCaptureFixture +) -> None: with caplog.at_level("INFO"): CrawlerProcess(settings, install_root_handler=False) assert ( diff --git a/tests/test_crawler_subprocess.py b/tests/test_crawler_subprocess.py index beae4f277..146d94ecd 100644 --- a/tests/test_crawler_subprocess.py +++ b/tests/test_crawler_subprocess.py @@ -52,7 +52,7 @@ class TestCrawlerProcessSubprocessBase(ScriptRunnerMixin): with the same file names and expectations. """ - def test_simple(self): + def test_simple(self) -> None: log = self.run_script("simple.py") assert "Spider closed (finished)" in log assert ( @@ -61,7 +61,7 @@ class TestCrawlerProcessSubprocessBase(ScriptRunnerMixin): ) assert "is_reactorless(): False" in log - def test_multi(self): + def test_multi(self) -> None: log = self.run_script("multi.py") assert "Spider closed (finished)" in log assert ( @@ -70,7 +70,7 @@ class TestCrawlerProcessSubprocessBase(ScriptRunnerMixin): ) assert "ReactorAlreadyInstalledError" not in log - def test_reactor_default(self): + def test_reactor_default(self) -> None: log = self.run_script("reactor_default.py") assert "Spider closed (finished)" not in log assert ( @@ -78,7 +78,7 @@ class TestCrawlerProcessSubprocessBase(ScriptRunnerMixin): "(twisted.internet.asyncioreactor.AsyncioSelectorReactor)" ) in log - def test_asyncio_enabled_no_reactor(self): + def test_asyncio_enabled_no_reactor(self) -> None: log = self.run_script("asyncio_enabled_no_reactor.py") assert "Spider closed (finished)" in log assert ( @@ -87,7 +87,7 @@ class TestCrawlerProcessSubprocessBase(ScriptRunnerMixin): ) assert "RuntimeError" not in log - def test_asyncio_enabled_reactor(self): + def test_asyncio_enabled_reactor(self) -> None: log = self.run_script("asyncio_enabled_reactor.py") assert "Spider closed (finished)" in log assert ( @@ -100,7 +100,7 @@ class TestCrawlerProcessSubprocessBase(ScriptRunnerMixin): parse_version(w3lib_version) >= parse_version("2.0.0"), reason="w3lib 2.0.0 and later do not allow invalid domains.", ) - def test_ipv6_default_name_resolver(self): + def test_ipv6_default_name_resolver(self) -> None: log = self.run_script("default_name_resolver.py") assert "Spider closed (finished)" in log assert ( @@ -112,7 +112,7 @@ class TestCrawlerProcessSubprocessBase(ScriptRunnerMixin): in log ) - def test_caching_hostname_resolver_ipv6(self): + def test_caching_hostname_resolver_ipv6(self) -> None: log = self.run_script("caching_hostname_resolver_ipv6.py") assert "Spider closed (finished)" in log assert "scrapy.exceptions.CannotResolveHostError" not in log @@ -126,7 +126,7 @@ class TestCrawlerProcessSubprocessBase(ScriptRunnerMixin): assert "TimeoutError" not in log assert "scrapy.exceptions.CannotResolveHostError" not in log - def test_twisted_reactor_asyncio(self): + def test_twisted_reactor_asyncio(self) -> None: log = self.run_script("twisted_reactor_asyncio.py") assert "Spider closed (finished)" in log assert ( @@ -134,7 +134,7 @@ class TestCrawlerProcessSubprocessBase(ScriptRunnerMixin): in log ) - def test_twisted_reactor_asyncio_custom_settings(self): + def test_twisted_reactor_asyncio_custom_settings(self) -> None: log = self.run_script("twisted_reactor_custom_settings.py") assert "Spider closed (finished)" in log assert ( @@ -142,7 +142,7 @@ class TestCrawlerProcessSubprocessBase(ScriptRunnerMixin): in log ) - def test_twisted_reactor_asyncio_custom_settings_same(self): + def test_twisted_reactor_asyncio_custom_settings_same(self) -> None: log = self.run_script("twisted_reactor_custom_settings_same.py") assert "Spider closed (finished)" in log assert ( @@ -151,7 +151,7 @@ class TestCrawlerProcessSubprocessBase(ScriptRunnerMixin): ) @pytest.mark.requires_uvloop - def test_custom_loop_asyncio(self): + def test_custom_loop_asyncio(self) -> None: log = self.run_script("asyncio_custom_loop.py") assert "Spider closed (finished)" in log assert ( @@ -161,7 +161,7 @@ class TestCrawlerProcessSubprocessBase(ScriptRunnerMixin): assert "Using asyncio event loop: uvloop.Loop" in log @pytest.mark.requires_uvloop - def test_custom_loop_asyncio_deferred_signal(self): + def test_custom_loop_asyncio_deferred_signal(self) -> None: log = self.run_script("asyncio_deferred_signal.py", "uvloop.Loop") assert "Spider closed (finished)" in log assert ( @@ -172,7 +172,7 @@ class TestCrawlerProcessSubprocessBase(ScriptRunnerMixin): assert "async pipeline opened!" in log @pytest.mark.requires_uvloop - def test_asyncio_enabled_reactor_same_loop(self): + def test_asyncio_enabled_reactor_same_loop(self) -> None: log = self.run_script("asyncio_enabled_reactor_same_loop.py") assert "Spider closed (finished)" in log assert ( @@ -182,7 +182,7 @@ class TestCrawlerProcessSubprocessBase(ScriptRunnerMixin): assert "Using asyncio event loop: uvloop.Loop" in log @pytest.mark.requires_uvloop - def test_asyncio_enabled_reactor_different_loop(self): + def test_asyncio_enabled_reactor_different_loop(self) -> None: log = self.run_script("asyncio_enabled_reactor_different_loop.py") assert "Spider closed (finished)" not in log assert ( @@ -190,7 +190,7 @@ class TestCrawlerProcessSubprocessBase(ScriptRunnerMixin): "setting (uvloop.Loop)" ) in log - def test_default_loop_asyncio_deferred_signal(self): + def test_default_loop_asyncio_deferred_signal(self) -> None: log = self.run_script("asyncio_deferred_signal.py") assert "Spider closed (finished)" in log assert ( @@ -200,7 +200,7 @@ class TestCrawlerProcessSubprocessBase(ScriptRunnerMixin): assert "Using asyncio event loop: uvloop.Loop" not in log assert "async pipeline opened!" in log - def test_args_change_settings(self): + def test_args_change_settings(self) -> None: log = self.run_script("args_settings.py") assert "Spider closed (finished)" in log assert "The value of FOO is 42" in log @@ -243,7 +243,7 @@ class TestCrawlerProcessSubprocess(TestCrawlerProcessSubprocessBase): def script_dir(self) -> Path: return self.get_script_dir("CrawlerProcess") - def test_reactor_default_twisted_reactor_select(self): + def test_reactor_default_twisted_reactor_select(self) -> None: log = self.run_script("reactor_default_twisted_reactor_select.py") if platform.system() in ["Windows", "Darwin"]: # The goal of this test function is to test that, when a reactor is @@ -264,7 +264,7 @@ class TestCrawlerProcessSubprocess(TestCrawlerProcessSubprocessBase): "(twisted.internet.selectreactor.SelectReactor)" ) in log - def test_reactor_select(self): + def test_reactor_select(self) -> None: log = self.run_script("reactor_select.py") assert "Spider closed (finished)" not in log assert ( @@ -272,12 +272,12 @@ class TestCrawlerProcessSubprocess(TestCrawlerProcessSubprocessBase): "(twisted.internet.asyncioreactor.AsyncioSelectorReactor)" ) in log - def test_reactor_select_twisted_reactor_select(self): + def test_reactor_select_twisted_reactor_select(self) -> None: log = self.run_script("reactor_select_twisted_reactor_select.py") assert "Spider closed (finished)" in log assert "ReactorAlreadyInstalledError" not in log - def test_reactor_select_subclass_twisted_reactor_select(self): + def test_reactor_select_subclass_twisted_reactor_select(self) -> None: log = self.run_script("reactor_select_subclass_twisted_reactor_select.py") assert "Spider closed (finished)" not in log assert ( @@ -285,7 +285,7 @@ class TestCrawlerProcessSubprocess(TestCrawlerProcessSubprocessBase): "(twisted.internet.selectreactor.SelectReactor)" ) in log - def test_twisted_reactor_select(self): + def test_twisted_reactor_select(self) -> None: log = self.run_script("twisted_reactor_select.py") assert "Spider closed (finished)" in log assert "Using reactor: twisted.internet.selectreactor.SelectReactor" in log @@ -293,12 +293,12 @@ class TestCrawlerProcessSubprocess(TestCrawlerProcessSubprocessBase): @pytest.mark.skipif( platform.system() == "Windows", reason="PollReactor is not supported on Windows" ) - def test_twisted_reactor_poll(self): + def test_twisted_reactor_poll(self) -> None: log = self.run_script("twisted_reactor_poll.py") assert "Spider closed (finished)" in log assert "Using reactor: twisted.internet.pollreactor.PollReactor" in log - def test_twisted_reactor_asyncio_custom_settings_conflict(self): + def test_twisted_reactor_asyncio_custom_settings_conflict(self) -> None: log = self.run_script("twisted_reactor_custom_settings_conflict.py") assert "Using reactor: twisted.internet.selectreactor.SelectReactor" in log assert ( @@ -306,7 +306,7 @@ class TestCrawlerProcessSubprocess(TestCrawlerProcessSubprocessBase): in log ) - def test_reactorless(self): + def test_reactorless(self) -> None: log = self.run_script("reactorless.py") assert ( "RuntimeError: CrawlerProcess doesn't support TWISTED_REACTOR_ENABLED=False" @@ -319,7 +319,7 @@ class TestAsyncCrawlerProcessSubprocess(TestCrawlerProcessSubprocessBase): def script_dir(self) -> Path: return self.get_script_dir("AsyncCrawlerProcess") - def test_twisted_reactor_custom_settings_select(self): + def test_twisted_reactor_custom_settings_select(self) -> None: log = self.run_script("twisted_reactor_custom_settings_select.py") assert "Spider closed (finished)" not in log assert ( @@ -329,7 +329,7 @@ class TestAsyncCrawlerProcessSubprocess(TestCrawlerProcessSubprocessBase): ) in log @pytest.mark.requires_uvloop - def test_asyncio_enabled_reactor_same_loop(self): + def test_asyncio_enabled_reactor_same_loop(self) -> None: log = self.run_script("asyncio_custom_loop_custom_settings_same.py") assert "Spider closed (finished)" in log assert ( @@ -339,7 +339,7 @@ class TestAsyncCrawlerProcessSubprocess(TestCrawlerProcessSubprocessBase): assert "Using asyncio event loop: uvloop.Loop" in log @pytest.mark.requires_uvloop - def test_asyncio_enabled_reactor_different_loop(self): + def test_asyncio_enabled_reactor_different_loop(self) -> None: log = self.run_script("asyncio_custom_loop_custom_settings_different.py") assert "Spider closed (finished)" not in log assert ( @@ -347,7 +347,7 @@ class TestAsyncCrawlerProcessSubprocess(TestCrawlerProcessSubprocessBase): "setting (uvloop.Loop)" ) in log - def test_reactorless_simple(self): + def test_reactorless_simple(self) -> None: log = self.run_script("reactorless_simple.py") assert "Not using a Twisted reactor" in log assert "Spider closed (finished)" in log @@ -356,7 +356,7 @@ class TestAsyncCrawlerProcessSubprocess(TestCrawlerProcessSubprocessBase): assert log.count("WARNING: HttpxDownloadHandler is experimental") == 2 assert log.count("WARNING: ") == 2 - def test_reactorless_custom_settings(self): + def test_reactorless_custom_settings(self) -> None: """Setting TWISTED_REACTOR_ENABLED=False in spider settings is not currently supported, AsyncCrawlerProcess will install a reactor in this case. @@ -368,7 +368,7 @@ class TestAsyncCrawlerProcessSubprocess(TestCrawlerProcessSubprocessBase): in log ) - def test_reactorless_datauri(self): + def test_reactorless_datauri(self) -> None: log = self.run_script("reactorless_datauri.py") assert "Not using a Twisted reactor" in log assert "Spider closed (finished)" in log @@ -378,13 +378,13 @@ class TestAsyncCrawlerProcessSubprocess(TestCrawlerProcessSubprocessBase): assert log.count("WARNING: HttpxDownloadHandler is experimental") == 2 assert log.count("WARNING: ") == 2 - def test_reactorless_import_hook(self): + def test_reactorless_import_hook(self) -> None: log = self.run_script("reactorless_import_hook.py") assert "Not using a Twisted reactor" in log assert "Spider closed (finished)" in log assert "ImportError: Import of twisted.internet.reactor is forbidden" in log - def test_reactorless_telnetconsole_default(self): + def test_reactorless_telnetconsole_default(self) -> None: """By default TWISTED_REACTOR_ENABLED=False silently sets TELNETCONSOLE_ENABLED=False.""" log = self.run_script("reactorless_simple.py") # no need for a separate script assert "Not using a Twisted reactor" in log @@ -392,7 +392,7 @@ class TestAsyncCrawlerProcessSubprocess(TestCrawlerProcessSubprocessBase): assert "The TelnetConsole extension requires a Twisted reactor" not in log assert "scrapy.extensions.telnet.TelnetConsole" not in log - def test_reactorless_telnetconsole_disabled(self): + def test_reactorless_telnetconsole_disabled(self) -> None: """Explicit TELNETCONSOLE_ENABLED=False, there are no warnings.""" log = self.run_script("reactorless_telnetconsole_disabled.py") assert "Not using a Twisted reactor" in log @@ -400,14 +400,14 @@ class TestAsyncCrawlerProcessSubprocess(TestCrawlerProcessSubprocessBase): assert "The TelnetConsole extension requires a Twisted reactor" not in log assert "scrapy.extensions.telnet.TelnetConsole" not in log - def test_reactorless_telnetconsole_enabled(self): + def test_reactorless_telnetconsole_enabled(self) -> None: """Explicit TELNETCONSOLE_ENABLED=True, the user gets a warning.""" log = self.run_script("reactorless_telnetconsole_enabled.py") assert "Not using a Twisted reactor" in log assert "Spider closed (finished)" in log assert "The TelnetConsole extension requires a Twisted reactor" in log - def test_reactorless_reactor(self): + def test_reactorless_reactor(self) -> None: log = self.run_script("reactorless_reactor.py") assert ( "RuntimeError: TWISTED_REACTOR_ENABLED is False but a Twisted reactor is installed" @@ -427,7 +427,7 @@ class TestCrawlerRunnerSubprocessBase(ScriptRunnerMixin): with the same file names and expectations. """ - def test_simple(self): + def test_simple(self) -> None: log = self.run_script("simple.py") assert "Spider closed (finished)" in log assert ( @@ -436,7 +436,7 @@ class TestCrawlerRunnerSubprocessBase(ScriptRunnerMixin): ) assert "is_reactorless(): False" in log - def test_multi_parallel(self): + def test_multi_parallel(self) -> None: log = self.run_script("multi_parallel.py") assert "Spider closed (finished)" in log assert ( @@ -449,7 +449,7 @@ class TestCrawlerRunnerSubprocessBase(ScriptRunnerMixin): re.DOTALL, ) - def test_multi_seq(self): + def test_multi_seq(self) -> None: log = self.run_script("multi_seq.py") assert "Spider closed (finished)" in log assert ( @@ -463,7 +463,7 @@ class TestCrawlerRunnerSubprocessBase(ScriptRunnerMixin): ) @pytest.mark.requires_uvloop - def test_custom_loop_same(self): + def test_custom_loop_same(self) -> None: log = self.run_script("custom_loop_same.py") assert "Spider closed (finished)" in log assert ( @@ -473,7 +473,7 @@ class TestCrawlerRunnerSubprocessBase(ScriptRunnerMixin): assert "Using asyncio event loop: uvloop.Loop" in log @pytest.mark.requires_uvloop - def test_custom_loop_different(self): + def test_custom_loop_different(self) -> None: log = self.run_script("custom_loop_different.py") assert "Spider closed (finished)" not in log assert ( @@ -481,7 +481,7 @@ class TestCrawlerRunnerSubprocessBase(ScriptRunnerMixin): "setting (uvloop.Loop)" ) in log - def test_no_reactor(self): + def test_no_reactor(self) -> None: log = self.run_script("no_reactor.py") assert "Spider closed (finished)" not in log assert ( @@ -495,7 +495,7 @@ class TestCrawlerRunnerSubprocess(TestCrawlerRunnerSubprocessBase): def script_dir(self) -> Path: return self.get_script_dir("CrawlerRunner") - def test_explicit_default_reactor(self): + def test_explicit_default_reactor(self) -> None: log = self.run_script("explicit_default_reactor.py") assert "Spider closed (finished)" in log assert ( @@ -503,14 +503,14 @@ class TestCrawlerRunnerSubprocess(TestCrawlerRunnerSubprocessBase): not in log ) - def test_response_ip_address(self): + def test_response_ip_address(self) -> None: log = self.run_script("ip_address.py") assert "INFO: Spider closed (finished)" in log assert "INFO: Host: not.a.real.domain" in log assert "INFO: Type: " in log assert "INFO: IP address: 127.0.0.1" in log - def test_change_default_reactor(self): + def test_change_default_reactor(self) -> None: log = self.run_script("change_reactor.py") assert ( "DEBUG: Using reactor: twisted.internet.asyncioreactor.AsyncioSelectorReactor" @@ -518,7 +518,7 @@ class TestCrawlerRunnerSubprocess(TestCrawlerRunnerSubprocessBase): ) assert "DEBUG: Using asyncio event loop" in log - def test_reactorless(self): + def test_reactorless(self) -> None: log = self.run_script("reactorless.py") assert ( "RuntimeError: CrawlerRunner doesn't support TWISTED_REACTOR_ENABLED=False" @@ -531,7 +531,7 @@ class TestAsyncCrawlerRunnerSubprocess(TestCrawlerRunnerSubprocessBase): def script_dir(self) -> Path: return self.get_script_dir("AsyncCrawlerRunner") - def test_simple_default_reactor(self): + def test_simple_default_reactor(self) -> None: log = self.run_script("simple_default_reactor.py") assert "Spider closed (finished)" not in log assert ( @@ -539,7 +539,7 @@ class TestAsyncCrawlerRunnerSubprocess(TestCrawlerRunnerSubprocessBase): "AsyncCrawlerRunner requires that the installed Twisted reactor" ) in log - def test_reactorless_simple(self): + def test_reactorless_simple(self) -> None: log = self.run_script("reactorless_simple.py") assert "Not using a Twisted reactor" in log assert "Spider closed (finished)" in log @@ -548,7 +548,7 @@ class TestAsyncCrawlerRunnerSubprocess(TestCrawlerRunnerSubprocessBase): assert log.count("WARNING: HttpxDownloadHandler is experimental") == 2 assert log.count("WARNING: ") == 2 - def test_reactorless_custom_settings(self): + def test_reactorless_custom_settings(self) -> None: """Setting TWISTED_REACTOR_ENABLED=False in spider settings is not currently supported, AsyncCrawlerRunner will expect a reactor installed by the user. @@ -557,7 +557,7 @@ class TestAsyncCrawlerRunnerSubprocess(TestCrawlerRunnerSubprocessBase): assert "Spider closed (finished)" not in log assert "We expected a Twisted reactor to be installed but it isn't." in log - def test_reactorless_datauri(self): + def test_reactorless_datauri(self) -> None: log = self.run_script("reactorless_datauri.py") assert "Not using a Twisted reactor" in log assert "Spider closed (finished)" in log @@ -567,7 +567,7 @@ class TestAsyncCrawlerRunnerSubprocess(TestCrawlerRunnerSubprocessBase): assert log.count("WARNING: HttpxDownloadHandler is experimental") == 2 assert log.count("WARNING: ") == 2 - def test_reactorless_reactor(self): + def test_reactorless_reactor(self) -> None: log = self.run_script("reactorless_reactor.py") assert ( "RuntimeError: TWISTED_REACTOR_ENABLED is False but a Twisted reactor is installed" From df2f3d708e1ca313380a9e28e26f752b49c2693b Mon Sep 17 00:00:00 2001 From: Ethan Kuo Date: Thu, 4 Jun 2026 07:24:50 -0700 Subject: [PATCH 165/248] fix open_in_browser() logic issue plus add new tests (#7506) --- scrapy/utils/response.py | 3 +- tests/test_utils_response.py | 116 ++++++++++++++++++++++++++++++++--- 2 files changed, 108 insertions(+), 11 deletions(-) diff --git a/scrapy/utils/response.py b/scrapy/utils/response.py index abb5f6a70..c068d9b1e 100644 --- a/scrapy/utils/response.py +++ b/scrapy/utils/response.py @@ -97,8 +97,7 @@ def open_in_browser( # XXX: this implementation is a bit dirty and could be improved body = response.body if isinstance(response, HtmlResponse): - if b"' body = re.sub(rb"]*?>)", to_bytes(repl), body, count=1) ext = ".html" diff --git a/tests/test_utils_response.py b/tests/test_utils_response.py index 381a9c3ff..e02bdfb69 100644 --- a/tests/test_utils_response.py +++ b/tests/test_utils_response.py @@ -4,7 +4,7 @@ from urllib.parse import urlparse import pytest -from scrapy.http import HtmlResponse, Response +from scrapy.http import HtmlResponse, Response, TextResponse from scrapy.utils.python import to_bytes from scrapy.utils.response import ( _remove_html_comments, @@ -15,6 +15,13 @@ from scrapy.utils.response import ( ) +def _read_browser_output(burl: str): + path = urlparse(burl).path + if not path or not Path(path).exists(): + path = burl.replace("file://", "") + return Path(path).read_bytes() + + def test_open_in_browser(): url = "http:///www.example.com/some/page.html" body = ( @@ -22,10 +29,7 @@ def test_open_in_browser(): ) def browser_open(burl: str) -> bool: - path = urlparse(burl).path - if not path or not Path(path).exists(): - path = burl.replace("file://", "") - bbody = Path(path).read_bytes() + bbody = _read_browser_output(burl) assert b'' in bbody return True @@ -169,10 +173,7 @@ def test_inject_base_url(body: bytes) -> None: url = "http://www.example.com" def check_base_url(burl): - path = urlparse(burl).path - if not path or not Path(path).exists(): - path = burl.replace("file://", "") - bbody = Path(path).read_bytes() + bbody = _read_browser_output(burl) assert bbody.count(b'>') == 1 assert b"ccd", b"acd"), (b"ad", b"ad"), + (b"a -->b", b"a -->b"), + (b"real", b"real"), ], ) def test_remove_html_comments(input_body, output_body): assert _remove_html_comments(input_body) == output_body + + +def test_open_in_browser_preserves_html_comments(): + url = "http://www.example.com" + body = ( + b"" + b"" + b"Real" + b"content" + b"" + ) + + def check(burl): + bbody = _read_browser_output(burl) + assert b"" in bbody + return True + + response = HtmlResponse(url, body=body) + assert open_in_browser(response, _openfunc=check) + + +def test_open_in_browser_does_not_inject_base_when_present(): + url = "http://www.example.com" + body = ( + b"" + b'T' + b"hi" + b"" + ) + + def check(burl): + bbody = _read_browser_output(burl) + assert b'' not in bbody + assert b'' in bbody + return True + + response = HtmlResponse(url, body=body) + assert open_in_browser(response, _openfunc=check) + + +def test_open_in_browser_injects_base_when_only_in_comment(): + url = "http://www.example.com" + body = ( + b"" + b"" + b"Real" + b"content" + b"" + ) + + def check(burl): + bbody = _read_browser_output(burl) + assert b'' in bbody + return True + + response = HtmlResponse(url, body=body) + assert open_in_browser(response, _openfunc=check) + + +def test_open_in_browser_injects_base_at_real_head_not_commented_head(): + url = "http://www.example.com" + body = ( + b"" + b"" + b"Actual" + b"hello" + b"" + ) + + def check(burl): + bbody = _read_browser_output(burl) + assert bbody.count(b'') == 1 + base_pos = bbody.find(b'') + title_pos = bbody.find(b"Actual") + assert base_pos < title_pos + return True + + response = HtmlResponse(url, body=body) + assert open_in_browser(response, _openfunc=check) + + +def test_open_in_browser_text_response_uses_txt_extension(): + response = TextResponse("http://www.example.com", body=b"plain text content") + + def check(burl): + assert burl.endswith(".txt") + return True + + assert open_in_browser(response, _openfunc=check) + + +def test_open_in_browser_raises_for_unsupported_response_type(): + response = Response("http://www.example.com", body=b"binary") + with pytest.raises(TypeError): + open_in_browser(response, _openfunc=lambda _: True) From 13c1c1faf81100394cffc6cf088c18c315f09ede Mon Sep 17 00:00:00 2001 From: Ayush Singh <135635937+Ayush442842q@users.noreply.github.com> Date: Thu, 4 Jun 2026 20:41:50 +0530 Subject: [PATCH 166/248] Close GCS feed temp files after upload (#7546) --- scrapy/extensions/feedexport.py | 13 ++++++++----- tests/test_feedexport_storages.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/scrapy/extensions/feedexport.py b/scrapy/extensions/feedexport.py index d0430d0db..8029f85c9 100644 --- a/scrapy/extensions/feedexport.py +++ b/scrapy/extensions/feedexport.py @@ -306,12 +306,15 @@ class GCSFeedStorage(BlockingFeedStorage): def _store_in_thread(self, file: IO[bytes]) -> None: file.seek(0) - from google.cloud.storage import Client # noqa: PLC0415 + try: + from google.cloud.storage import Client # noqa: PLC0415 - client = Client(project=self.project_id) - bucket = client.get_bucket(self.bucket_name) - blob = bucket.blob(self.blob_name) - blob.upload_from_file(file, predefined_acl=self.acl) + client = Client(project=self.project_id) + bucket = client.get_bucket(self.bucket_name) + blob = bucket.blob(self.blob_name) + blob.upload_from_file(file, predefined_acl=self.acl) + finally: + file.close() class FTPFeedStorage(BlockingFeedStorage): diff --git a/tests/test_feedexport_storages.py b/tests/test_feedexport_storages.py index 5102f8e37..11e79775f 100644 --- a/tests/test_feedexport_storages.py +++ b/tests/test_feedexport_storages.py @@ -513,6 +513,34 @@ class TestGCSFeedStorage: client_mock.get_bucket.assert_called_once_with("mybucket") bucket_mock.blob.assert_called_once_with("export.csv") blob_mock.upload_from_file.assert_called_once_with(f, predefined_acl=acl) + f.close.assert_called_once_with() + + @coroutine_test + async def test_store_closes_file_on_upload_error(self): + try: + from google.cloud.storage import Client # noqa: F401,PLC0415 + except ImportError: + pytest.skip("GCSFeedStorage requires google-cloud-storage") + + uri = "gs://mybucket/export.csv" + project_id = "myproject-123" + acl = "publicRead" + (client_mock, bucket_mock, blob_mock) = mock_google_cloud_storage() + blob_mock.upload_from_file.side_effect = OSError("Upload failed") + with mock.patch("google.cloud.storage.Client") as m: + m.return_value = client_mock + + f = mock.Mock() + storage = GCSFeedStorage(uri, project_id, acl) + with pytest.raises(OSError, match="Upload failed"): + await maybe_deferred_to_future(storage.store(f)) + + f.seek.assert_called_once_with(0) + m.assert_called_once_with(project=project_id) + client_mock.get_bucket.assert_called_once_with("mybucket") + bucket_mock.blob.assert_called_once_with("export.csv") + blob_mock.upload_from_file.assert_called_once_with(f, predefined_acl=acl) + f.close.assert_called_once_with() def test_overwrite_default(self): with LogCapture() as log: From b2d8b06be61df40fd652af2f3fa8238a82b8645d Mon Sep 17 00:00:00 2001 From: Adrian Date: Fri, 5 Jun 2026 11:51:03 +0200 Subject: [PATCH 167/248] Support sending requests with "unsafe" URLs (#7473) --- docs/topics/request-response.rst | 34 +++++++++++++++++-- scrapy/http/request/__init__.py | 10 ++++-- scrapy/utils/request.py | 17 +++++++--- tests/mockserver/http.py | 2 ++ tests/test_downloader_handlers_http_base.py | 19 +++++++++++ tests/test_http_request.py | 13 ++++++++ tests/test_utils_request.py | 36 +++++++++++++++++++-- tox.ini | 7 +++- 8 files changed, 124 insertions(+), 14 deletions(-) diff --git a/docs/topics/request-response.rst b/docs/topics/request-response.rst index d1e4851d9..a4f031803 100644 --- a/docs/topics/request-response.rst +++ b/docs/topics/request-response.rst @@ -117,6 +117,9 @@ Request objects :param encoding: the encoding of this request (defaults to ``'utf-8'``). This encoding will be used to percent-encode the URL and to convert the body to bytes (if given as a string). + + To disable URL percent-encoding for a request, use the + :reqmeta:`verbatim_url` request meta key. :type encoding: str :param priority: sets :attr:`priority`, defaults to ``0``. @@ -136,9 +139,13 @@ Request objects .. attribute:: Request.url - A string containing the URL of this request. Keep in mind that this - attribute contains the escaped URL, so it can differ from the URL passed in - the ``__init__()`` method. + A string containing the URL of this request. + + Keep in mind that this attribute contains the escaped URL, so it can + differ from the URL passed in the ``__init__()`` method. + + If :reqmeta:`verbatim_url` is set to ``True``, the URL is kept as + passed to ``__init__()``. This attribute is read-only. To change the URL of a Request use :meth:`replace`. @@ -541,6 +548,11 @@ in your :meth:`fingerprint` method implementation: .. autofunction:: scrapy.utils.request.fingerprint +By default, request fingerprinting canonicalizes the request URL. If +:reqmeta:`verbatim_url` is set to ``True``, fingerprinting does not +canonicalize the URL, and the ``keep_fragments`` parameter is ignored (it is +effectively true). + For example, to take the value of a request header named ``X-ID`` into account: @@ -710,6 +722,7 @@ Those are: * :reqmeta:`redirect_reasons` * :reqmeta:`redirect_urls` * :reqmeta:`referrer_policy` +* :reqmeta:`verbatim_url` .. reqmeta:: bindaddress @@ -786,6 +799,21 @@ The meta key is used set retry times per request. When initialized, the :reqmeta:`max_retry_times` meta key takes higher precedence over the :setting:`RETRY_TIMES` setting. +.. reqmeta:: verbatim_url + +verbatim_url +------------ + +Set this key to ``True`` to keep the request URL as passed to +:class:`~scrapy.Request`, without URL percent-encoding. + +When this key is enabled, :func:`~scrapy.utils.request.fingerprint` does not +canonicalize the request URL, so requests whose URLs differ only in +characters that would otherwise be canonicalized get different fingerprints. + +In this mode, the ``keep_fragments`` parameter is ignored, and it is +effectively true. + .. _topics-stop-response-download: diff --git a/scrapy/http/request/__init__.py b/scrapy/http/request/__init__.py index 00042e093..7db648a45 100644 --- a/scrapy/http/request/__init__.py +++ b/scrapy/http/request/__init__.py @@ -138,6 +138,7 @@ class Request(object_ref): ) -> None: self._encoding: str = encoding # this one has to be set first self.method: str = str(method).upper() + self._meta: dict[str, Any] | None = dict(meta) if meta else None self._set_url(url) self._set_body(body) if not isinstance(priority, int): @@ -232,7 +233,6 @@ class Request(object_ref): #: default. See :meth:`~scrapy.Spider.start`. self.dont_filter: bool = dont_filter - self._meta: dict[str, Any] | None = dict(meta) if meta else None self._cb_kwargs: dict[str, Any] | None = dict(cb_kwargs) if cb_kwargs else None self._flags: list[str] | None = list(flags) if flags else None @@ -252,11 +252,17 @@ class Request(object_ref): def url(self) -> str: return self._url + def _url_is_verbatim(self) -> bool: + return bool(self._meta and self._meta.get("verbatim_url")) + def _set_url(self, url: str) -> None: if not isinstance(url, str): raise TypeError(f"Request url must be str, got {type(url).__name__}") - self._url = safe_url_string(url, self.encoding) + if self._url_is_verbatim(): + self._url = url + else: + self._url = safe_url_string(url, self.encoding) if ( "://" not in self._url diff --git a/scrapy/utils/request.py b/scrapy/utils/request.py index 27d669e71..ffb7fae49 100644 --- a/scrapy/utils/request.py +++ b/scrapy/utils/request.py @@ -28,7 +28,7 @@ if TYPE_CHECKING: _fingerprint_cache: WeakKeyDictionary[ - Request, dict[tuple[tuple[bytes, ...] | None, bool], bytes] + Request, dict[tuple[tuple[bytes, ...] | None, bool, bool], bytes] ] = WeakKeyDictionary() @@ -71,8 +71,10 @@ def fingerprint( processed_include_headers = tuple( to_bytes(h.lower()) for h in sorted(include_headers) ) + verbatim_url = bool(request.meta.get("verbatim_url")) + effective_keep_fragments = keep_fragments and not verbatim_url cache = _fingerprint_cache.setdefault(request, {}) - cache_key = (processed_include_headers, keep_fragments) + cache_key = (processed_include_headers, effective_keep_fragments, verbatim_url) if cache_key not in cache: # To decode bytes reliably (JSON does not support bytes), regardless of # character encoding, we use bytes.hex() @@ -84,9 +86,13 @@ def fingerprint( header_value.hex() for header_value in request.headers.getlist(header) ] + if verbatim_url: + url = request.url + else: + url = canonicalize_url(request.url, keep_fragments=keep_fragments) fingerprint_data = { "method": to_unicode(request.method), - "url": canonicalize_url(request.url, keep_fragments=keep_fragments), + "url": url, "body": (request.body or b"").hex(), "headers": headers, } @@ -108,8 +114,9 @@ class RequestFingerprinter: (:func:`w3lib.url.canonicalize_url`) of :attr:`request.url ` and the values of :attr:`request.method ` and :attr:`request.body - `. It then generates an `SHA1 - `_ hash. + `, unless :reqmeta:`verbatim_url` is true for that + request. It then generates an `SHA1 `_ + hash. """ @classmethod diff --git a/tests/mockserver/http.py b/tests/mockserver/http.py index 622324a10..7ad873c02 100644 --- a/tests/mockserver/http.py +++ b/tests/mockserver/http.py @@ -34,6 +34,7 @@ from .http_resources import ( ResponseHeadersResource, SetCookie, Status, + UriResource, ) @@ -84,6 +85,7 @@ class Root(resource.Resource): self.putChild(b"duplicate-header", DuplicateHeaderResource()) self.putChild(b"response-headers", ResponseHeadersResource()) self.putChild(b"set-cookie", SetCookie()) + self.putChild(b"uri", UriResource()) def getChild(self, path, request): return self diff --git a/tests/test_downloader_handlers_http_base.py b/tests/test_downloader_handlers_http_base.py index 51da8b38f..3b60fa555 100644 --- a/tests/test_downloader_handlers_http_base.py +++ b/tests/test_downloader_handlers_http_base.py @@ -803,6 +803,25 @@ class TestHttpBase(ABC): "The 'bindaddress' request meta key is not supported by" in caplog.text ) + @coroutine_test + async def test_verbatim_url(self, mockserver: MockServer) -> None: + # Square brackets are encoded by safe_url_string (w3lib). + path = "/uri/items?data[0]=a" + url = mockserver.url(path, is_secure=self.is_secure) + + # Without verbatim_url, the brackets are percent-encoded before the + # request reaches the server. + request = Request(url) + async with self.get_dh() as download_handler: + response = await download_handler.download_request(request) + assert response.body == b"/uri/items?data%5B0%5D=a" + + # With verbatim_url=True the URL is sent to the server as-is. + request = Request(url, meta={"verbatim_url": True}) + async with self.get_dh() as download_handler: + response = await download_handler.download_request(request) + assert response.body == path.encode() + class TestHttpsBase(TestHttpBase): is_secure = True diff --git a/tests/test_http_request.py b/tests/test_http_request.py index 4c77bb1ec..fed5dbab7 100644 --- a/tests/test_http_request.py +++ b/tests/test_http_request.py @@ -162,6 +162,19 @@ class TestRequest: r4 = self.request_class(url="http://www.example.org/r%E9sum%E9.html") assert r4.url == "http://www.example.org/r%E9sum%E9.html" + def test_url_verbatim(self): + r = self.request_class( + url="http://www.scrapy.org/price/£", + meta={"verbatim_url": True}, + ) + assert r.url == "http://www.scrapy.org/price/£" + + r = self.request_class( + url="http://www.scrapy.org/blank space", + meta={"verbatim_url": True}, + ) + assert r.url == "http://www.scrapy.org/blank space" + def test_body(self): r1 = self.request_class(url="http://www.example.com/") assert r1.body == b"" diff --git a/tests/test_utils_request.py b/tests/test_utils_request.py index 70800dcec..55c46059e 100644 --- a/tests/test_utils_request.py +++ b/tests/test_utils_request.py @@ -59,10 +59,14 @@ def test_request_httprepr_for_non_http_request(r: Request) -> None: class TestFingerprint: function: staticmethod[[Request], bytes] = staticmethod(fingerprint) cache: ( - WeakKeyDictionary[Request, dict[tuple[tuple[bytes, ...] | None, bool], bytes]] - | WeakKeyDictionary[Request, dict[tuple[tuple[bytes, ...] | None, bool], str]] + WeakKeyDictionary[ + Request, dict[tuple[tuple[bytes, ...] | None, bool, bool], bytes] + ] + | WeakKeyDictionary[ + Request, dict[tuple[tuple[bytes, ...] | None, bool, bool], str] + ] ) = _fingerprint_cache - default_cache_key = (None, False) + default_cache_key = (None, False, False) known_hashes: tuple[tuple[Request, bytes | str, dict[str, Any]], ...] = ( ( Request("http://example.org"), @@ -192,6 +196,32 @@ class TestFingerprint: assert self.function(r2) != self.function(r2, keep_fragments=True) assert self.function(r1) != self.function(r2, keep_fragments=True) + def test_verbatim_url(self): + # verbatim_url requests skip URL canonicalization + r1 = Request( + "http://www.example.com/query?a=1&b=2", meta={"verbatim_url": True} + ) + r2 = Request( + "http://www.example.com/query?b=2&a=1", meta={"verbatim_url": True} + ) + assert self.function(r1) != self.function(r2) + + # without verbatim_url, canonicalization makes query-param order irrelevant + r3 = Request("http://www.example.com/query?a=1&b=2") + r4 = Request("http://www.example.com/query?b=2&a=1") + assert self.function(r3) == self.function(r4) + + # with verbatim_url, the fragment is always kept in the fingerprint + r5 = Request( + "http://www.example.com/test#fragment", meta={"verbatim_url": True} + ) + r6 = Request("http://www.example.com/test", meta={"verbatim_url": True}) + assert self.function(r5) != self.function(r6) + + # keep_fragments parameter is ignored for verbatim_url requests + assert self.function(r5) == self.function(r5, keep_fragments=True) + assert self.function(r5) == self.function(r5, keep_fragments=False) + def test_method_and_body(self): r1 = Request("http://www.example.com") r2 = Request("http://www.example.com", method="POST") diff --git a/tox.ini b/tox.ini index 5fef37e5b..cc916caea 100644 --- a/tox.ini +++ b/tox.ini @@ -236,7 +236,12 @@ deps = pyOpenSSL==24.3.0 queuelib==1.4.2 service_identity==23.1.0 - w3lib==1.20.0 + # w3lib 1.17 fails to import on PyPy 3.11 because its encoding regex uses + # an inline flag placement that Python 3.11 treats as an error: global + # flags not at the start of the expression. w3lib 1.18 stopped encoding [] + # in URLs until 2.1.0 brought that behavior back. Tests for verbatim_url + # rely on that encoding. + w3lib==2.1.0 zope.interface==5.1.0 commands = ; disabling coverage From 58af57a3ea3a109f05c5b3e9f7b6e1e24adc9834 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Fri, 5 Jun 2026 18:34:02 +0500 Subject: [PATCH 168/248] Work around coverage slowness on Python 3.14. (#7574) --- pyproject.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index e027ea74d..2d857dc6d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -157,6 +157,8 @@ parse = """(?P0|[1-9]\\d*)\\.(?P0|[1-9]\\d*)""" serialize = ["{major}.{minor}"] [tool.coverage.run] +# sysmon, default on 3.14, is too slow: https://github.com/coveragepy/coveragepy/issues/2172 +core = "ctrace" branch = true include = ["scrapy/*"] omit = ["tests/*"] From 5149e2c67993c642a384072ac43157491a3b2052 Mon Sep 17 00:00:00 2001 From: Omkar Kabde Date: Mon, 8 Jun 2026 13:10:28 +0530 Subject: [PATCH 169/248] docs: switch `scrapy.Item` examples to dataclasses (#7513) * docs: switch `scrapy.Item` examples to dataclasses * make serializer doc generic * use modern type hints * docs/spiders: switch TestItem consumer snippets to attribute access Since the TestItem migration to @dataclass, the existing item["id"] = ... assignments would raise TypeError on copy-paste. Switch to item.id = ... to match the new dataclass declaration. The snippets sit under .. skip: next so docs-tests still pass either way, but the change keeps the examples runnable for readers. --- docs/topics/exporters.rst | 15 ++++----- docs/topics/loaders.rst | 31 ++++++++++++------- docs/topics/media-pipeline.rst | 11 ++++--- docs/topics/spiders.rst | 26 ++++++++-------- scrapy/templates/project/module/items.py.tmpl | 7 +++-- 5 files changed, 50 insertions(+), 40 deletions(-) diff --git a/docs/topics/exporters.rst b/docs/topics/exporters.rst index 74256eef4..c4cd05683 100644 --- a/docs/topics/exporters.rst +++ b/docs/topics/exporters.rst @@ -93,24 +93,25 @@ described next. 1. Declaring a serializer in the field -------------------------------------- -If you use :class:`~scrapy.Item` you can declare a serializer in the -:ref:`field metadata `. The serializer must be -a callable which receives a value and returns its serialized form. +Every :ref:`item type ` except :class:`dict` lets you declare a +serializer in the :ref:`field metadata `. The serializer +must be a callable which receives a value and returns its serialized form. Example: .. code-block:: python - import scrapy + from dataclasses import dataclass, field def serialize_price(value): return f"$ {str(value)}" - class Product(scrapy.Item): - name = scrapy.Field() - price = scrapy.Field(serializer=serialize_price) + @dataclass + class Product: + name: str + price: float = field(metadata={"serializer": serialize_price}) 2. Overriding the serialize_field() method diff --git a/docs/topics/loaders.rst b/docs/topics/loaders.rst index 5ad005893..3ccc90bf9 100644 --- a/docs/topics/loaders.rst +++ b/docs/topics/loaders.rst @@ -102,14 +102,13 @@ One approach to overcome this is to define items using the .. code-block:: python from dataclasses import dataclass, field - from typing import Optional @dataclass class InventoryItem: - name: Optional[str] = field(default=None) - price: Optional[float] = field(default=None) - stock: Optional[int] = field(default=None) + name: str | None = field(default=None) + price: float | None = field(default=None) + stock: int | None = field(default=None) .. _topics-loaders-processors: @@ -228,7 +227,8 @@ metadata. Here is an example: .. code-block:: python - import scrapy + from dataclasses import dataclass, field + from itemloaders.processors import Join, MapCompose, TakeFirst from w3lib.html import remove_tags @@ -238,14 +238,21 @@ metadata. Here is an example: return value - class Product(scrapy.Item): - name = scrapy.Field( - input_processor=MapCompose(remove_tags), - output_processor=Join(), + @dataclass + class Product: + name: str | None = field( + default=None, + metadata={ + "input_processor": MapCompose(remove_tags), + "output_processor": Join(), + }, ) - price = scrapy.Field( - input_processor=MapCompose(remove_tags, filter_price), - output_processor=TakeFirst(), + price: str | None = field( + default=None, + metadata={ + "input_processor": MapCompose(remove_tags, filter_price), + "output_processor": TakeFirst(), + }, ) diff --git a/docs/topics/media-pipeline.rst b/docs/topics/media-pipeline.rst index 8c04c578d..037fe87fa 100644 --- a/docs/topics/media-pipeline.rst +++ b/docs/topics/media-pipeline.rst @@ -337,17 +337,18 @@ respectively), the pipeline will put the results under the respective field When using :ref:`item types ` for which fields are defined beforehand, you must define both the URLs field and the results field. For example, when using the images pipeline, items must define both the ``image_urls`` and the -``images`` field. For instance, using the :class:`~scrapy.Item` class: +``images`` field. For instance, using a dataclass: .. code-block:: python - import scrapy + from dataclasses import dataclass, field - class MyItem(scrapy.Item): + @dataclass + class MyItem: # ... other item fields ... - image_urls = scrapy.Field() - images = scrapy.Field() + image_urls: list[str] = field(default_factory=list) + images: list[dict] = field(default_factory=list) If you want to use another field name for the URLs key or for the results key, it is also possible to override it. diff --git a/docs/topics/spiders.rst b/docs/topics/spiders.rst index 806af509f..bcef9d5f6 100644 --- a/docs/topics/spiders.rst +++ b/docs/topics/spiders.rst @@ -457,13 +457,14 @@ with a ``TestItem`` declared in a ``myproject.items`` module: .. code-block:: python - import scrapy + from dataclasses import dataclass - class TestItem(scrapy.Item): - id = scrapy.Field() - name = scrapy.Field() - description = scrapy.Field() + @dataclass + class TestItem: + id: str | None = None + name: str | None = None + description: str | None = None .. currentmodule:: scrapy.spiders @@ -556,7 +557,6 @@ Let's now take a look at an example CrawlSpider with rules: .. code-block:: python - import scrapy from scrapy.spiders import CrawlSpider, Rule from scrapy.linkextractors import LinkExtractor @@ -576,7 +576,7 @@ Let's now take a look at an example CrawlSpider with rules: def parse_item(self, response): self.logger.info("Hi, this is an item page! %s", response.url) - item = scrapy.Item() + item = {} item["id"] = response.xpath('//td[@id="item_id"]/text()').re(r"ID: (\d+)") item["name"] = response.xpath('//td[@id="item_name"]/text()').get() item["description"] = response.xpath( @@ -714,9 +714,9 @@ These spiders are pretty easy to use, let's have a look at one example: ) item = TestItem() - item["id"] = node.xpath("@id").get() - item["name"] = node.xpath("name").get() - item["description"] = node.xpath("description").get() + item.id = node.xpath("@id").get() + item.name = node.xpath("name").get() + item.description = node.xpath("description").get() return item Basically what we did up there was to create a spider that downloads a feed from @@ -778,9 +778,9 @@ Let's see an example similar to the previous one, but using a self.logger.info("Hi, this is a row!: %r", row) item = TestItem() - item["id"] = row["id"] - item["name"] = row["name"] - item["description"] = row["description"] + item.id = row["id"] + item.name = row["name"] + item.description = row["description"] return item diff --git a/scrapy/templates/project/module/items.py.tmpl b/scrapy/templates/project/module/items.py.tmpl index 88a18331c..e7d525f36 100644 --- a/scrapy/templates/project/module/items.py.tmpl +++ b/scrapy/templates/project/module/items.py.tmpl @@ -3,10 +3,11 @@ # See documentation in: # https://docs.scrapy.org/en/latest/topics/items.html -import scrapy +from dataclasses import dataclass -class ${ProjectName}Item(scrapy.Item): +@dataclass +class ${ProjectName}Item: # define the fields for your item here like: - # name = scrapy.Field() + # name: str | None = None pass From d9e2f5fbf7da59ea61595cffea5e3399fb9c3574 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Mon, 8 Jun 2026 14:54:10 +0500 Subject: [PATCH 170/248] Add settings for TLS min/max version as a replacement for the TLS method (#6546) --- docs/news.rst | 4 +- docs/topics/download-handlers.rst | 2 - docs/topics/practices.rst | 6 +- docs/topics/settings.rst | 50 +++++-- scrapy/core/downloader/contextfactory.py | 107 +++++++------ scrapy/core/downloader/tls.py | 42 ++++-- scrapy/settings/default_settings.py | 11 +- scrapy/utils/_deps_compat.py | 2 + scrapy/utils/ssl.py | 94 ++++++++++-- tests/mockserver/http_base.py | 14 ++ tests/mockserver/simple_https.py | 16 +- tests/mockserver/utils.py | 9 ++ tests/test_core_downloader.py | 43 +++++- tests/test_downloader_handler_httpx.py | 5 + .../test_downloader_handler_twisted_http11.py | 5 + .../test_downloader_handler_twisted_http2.py | 5 + tests/test_downloader_handlers_http_base.py | 140 +++++++++++++++++- 17 files changed, 454 insertions(+), 101 deletions(-) diff --git a/docs/news.rst b/docs/news.rst index fd9786433..b8b976df9 100644 --- a/docs/news.rst +++ b/docs/news.rst @@ -254,7 +254,7 @@ Documentation - Added a ``CITATION.cff`` file. (:issue:`7502`, :issue:`7519`) -- Mentioned :setting:`DOWNLOADER_CLIENT_TLS_METHOD` in :ref:`bans`. +- Mentioned ``DOWNLOADER_CLIENT_TLS_METHOD`` in :ref:`bans`. (:issue:`5232`, :issue:`7518`) - Other documentation improvements and fixes. @@ -7464,7 +7464,7 @@ This 1.1 release brings a lot of interesting features and bug fixes: selectors engine without needing to upgrade Scrapy. - HTTPS downloader now does TLS protocol negotiation by default, instead of forcing TLS 1.0. You can also set the SSL/TLS method - using the new :setting:`DOWNLOADER_CLIENT_TLS_METHOD`. + using the new ``DOWNLOADER_CLIENT_TLS_METHOD`` setting. - These bug fixes may require your attention: diff --git a/docs/topics/download-handlers.rst b/docs/topics/download-handlers.rst index 272ff0dcd..84711e5af 100644 --- a/docs/topics/download-handlers.rst +++ b/docs/topics/download-handlers.rst @@ -283,8 +283,6 @@ If you want to use this handler you need to replace the default ones for the The global :setting:`DOWNLOAD_BIND_ADDRESS` setting is supported but the port number, if specified, will be ignored. - - The :setting:`DOWNLOADER_CLIENT_TLS_METHOD` setting. - - Settings specific to the Twisted networking or HTTP implementation, like :setting:`DNS_RESOLVER`. diff --git a/docs/topics/practices.rst b/docs/topics/practices.rst index 2a8f5b4c1..11c2656da 100644 --- a/docs/topics/practices.rst +++ b/docs/topics/practices.rst @@ -410,9 +410,9 @@ Here are some tips to keep in mind when dealing with these kinds of sites: services like `ProxyMesh`_. An open source alternative is `scrapoxy`_, a super proxy that you can attach your own proxies to. * for HTTPS websites, if blocking appears related to TLS behavior, consider - adjusting the :setting:`DOWNLOADER_CLIENT_TLS_METHOD` setting, since some - websites may respond differently depending on the TLS method used by the - client. + adjusting the :setting:`DOWNLOAD_TLS_MIN_VERSION` and + :setting:`DOWNLOAD_TLS_MAX_VERSION` settings, since some websites may respond + differently depending on the TLS method used by the client. * use a ban avoidance service, such as `Zyte API`_, which provides a `Scrapy plugin `__ and additional features, like `AI web scraping `__ diff --git a/docs/topics/settings.rst b/docs/topics/settings.rst index cf2658d60..06de33e6f 100644 --- a/docs/topics/settings.rst +++ b/docs/topics/settings.rst @@ -737,32 +737,50 @@ specific cipher that is not included in ``DEFAULT`` if a website requires it. by all 3rd-party handlers. It's currently unsupported by :class:`~scrapy.core.downloader.handlers._httpx.HttpxDownloadHandler`. -.. setting:: DOWNLOADER_CLIENT_TLS_METHOD +.. setting:: DOWNLOAD_TLS_MAX_VERSION -DOWNLOADER_CLIENT_TLS_METHOD ----------------------------- +DOWNLOAD_TLS_MAX_VERSION +------------------------ -Default: ``'TLS'`` +Default: ``None`` -Use this setting to customize the TLS/SSL method used by the HTTPS download -handler. +Use this setting to change the maximum version of the TLS protocol allowed to +be used by Scrapy. -This setting must be one of these string values: +This setting must be either ``None``, in which case it doesn't affect the +version selection, or one of these string values: -- ``'TLS'``: maps to OpenSSL's ``TLS_method()`` (a.k.a ``SSLv23_method()``), - which allows protocol negotiation, starting from the highest supported - by the platform; **default, recommended** -- ``'TLSv1.0'``: this value forces HTTPS connections to use TLS version 1.0 ; - set this if you want the behavior of Scrapy<1.1 -- ``'TLSv1.1'``: forces TLS version 1.1 -- ``'TLSv1.2'``: forces TLS version 1.2 +- ``'TLSv1.0'`` +- ``'TLSv1.1'`` +- ``'TLSv1.2'`` +- ``'TLSv1.3'`` + +The range of allowed TLS versions advertised by Scrapy when making TLS +connections will depend on the TLS implementation defaults and the values of +:setting:`DOWNLOAD_TLS_MIN_VERSION` and :setting:`DOWNLOAD_TLS_MAX_VERSION`. +It's possible to re-enable versions that are supported by the TLS +implementation but disabled by default by adjusting these settings, but it's +impossible to enable unsupported ones, such as any versions below 1.2 in many +modern environments. .. note:: Handling of this setting needs to be implemented inside the :ref:`download handler `, so it's not guaranteed to be supported - by all 3rd-party handlers. It's currently unsupported by - :class:`~scrapy.core.downloader.handlers._httpx.HttpxDownloadHandler`. + by all 3rd-party handlers. Additionally, the set of supported TLS versions + depends on the TLS implementation being used by the handler. + +.. setting:: DOWNLOAD_TLS_MIN_VERSION + +DOWNLOAD_TLS_MIN_VERSION +------------------------ + +Default: ``None`` + +Use this setting to change the minimum version of the TLS protocol allowed to +be used by Scrapy. + +See :setting:`DOWNLOAD_TLS_MAX_VERSION` for the details and limitations. .. setting:: DOWNLOADER_CLIENT_TLS_VERBOSE_LOGGING diff --git a/scrapy/core/downloader/contextfactory.py b/scrapy/core/downloader/contextfactory.py index d2626fd9d..40f09ffb2 100644 --- a/scrapy/core/downloader/contextfactory.py +++ b/scrapy/core/downloader/contextfactory.py @@ -1,13 +1,13 @@ from __future__ import annotations import warnings -from contextlib import contextmanager from typing import TYPE_CHECKING, Any, cast from OpenSSL import SSL from twisted.internet.ssl import ( AcceptableCiphers, CertificateOptions, + TLSVersion, optionsForClientTLS, ) from twisted.web.client import BrowserLikePolicyForHTTPS @@ -16,19 +16,19 @@ from zope.interface.declarations import implementer from zope.interface.verify import verifyObject from scrapy.core.downloader.tls import ( + _TWISTED_VERSION_MAP, DEFAULT_CIPHERS, + _openssl_methods, _ScrapyClientTLSOptions, _ScrapyClientTLSOptions26, - openssl_methods, ) from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.utils._deps_compat import TWISTED_TLS_NEW_IMPL from scrapy.utils.deprecate import create_deprecated_class from scrapy.utils.misc import build_from_crawler, load_object +from scrapy.utils.ssl import _get_cert_options_version_kwargs, _get_tls_version_limits if TYPE_CHECKING: - from collections.abc import Generator - from twisted.internet._sslverify import ClientTLSOptions # typing.Self requires Python 3.11 @@ -38,24 +38,14 @@ if TYPE_CHECKING: from scrapy.settings import BaseSettings -@contextmanager -def _filter_method_warning() -> Generator[None]: - with warnings.catch_warnings(): - # Twisted deprecation, https://github.com/scrapy/scrapy/issues/3288 - warnings.filterwarnings( - "ignore", - message=r"Passing method to twisted\.internet\.ssl\.CertificateOptions", - category=DeprecationWarning, - ) - yield - - @implementer(IPolicyForHTTPS) class _ScrapyClientContextFactory(BrowserLikePolicyForHTTPS): """Non-peer-certificate verifying HTTPS context factory. - Default OpenSSL method is ``TLS_METHOD`` (also called ``SSLv23_METHOD``) - which allows TLS protocol negotiation. + Uses :setting:`DOWNLOADER_CLIENT_TLS_CIPHERS`, + :setting:`DOWNLOAD_TLS_MIN_VERSION` and :setting:`DOWNLOAD_TLS_MAX_VERSION` + to configure the :class:`~twisted.internet.ssl.CertificateOptions` + instance. The purpose of this custom class is to provide a ``creatorForNetloc()`` method that returns a ``_ScrapyClientTLSOptions`` instance configured based @@ -64,15 +54,19 @@ class _ScrapyClientContextFactory(BrowserLikePolicyForHTTPS): def __init__( self, - method: int = SSL.SSLv23_METHOD, # noqa: S503 + method: int | None = SSL.SSLv23_METHOD, # noqa: S503 tls_verbose_logging: bool = False, tls_ciphers: str | None = None, *args: Any, verify_certificates: bool = False, + tls_min_version: TLSVersion | None = None, + tls_max_version: TLSVersion | None = None, **kwargs: Any, ): super().__init__(*args, **kwargs) # type: ignore[no-untyped-call] - self._ssl_method: int = method + self._ssl_method: int | None = method + self.tls_min_version: TLSVersion | None = tls_min_version + self.tls_max_version: TLSVersion | None = tls_max_version self.tls_verbose_logging: bool = tls_verbose_logging # unused self.tls_ciphers: AcceptableCiphers if tls_ciphers: @@ -85,7 +79,7 @@ class _ScrapyClientContextFactory(BrowserLikePolicyForHTTPS): def from_crawler( cls, crawler: Crawler, - method: int = SSL.SSLv23_METHOD, # noqa: S503 + method: int | None = SSL.SSLv23_METHOD, # noqa: S503 *args: Any, **kwargs: Any, ) -> Self: @@ -93,12 +87,21 @@ class _ScrapyClientContextFactory(BrowserLikePolicyForHTTPS): "DOWNLOADER_CLIENT_TLS_VERBOSE_LOGGING" ) tls_ciphers: str | None = crawler.settings["DOWNLOADER_CLIENT_TLS_CIPHERS"] + # DOWNLOADER_CLIENT_TLS_METHOD reading and handling should be also moved here + # when the deprecated load_context_factory_from_settings() is removed + tls_min_ver, tls_max_ver = _get_tls_version_limits( + crawler.settings, _TWISTED_VERSION_MAP.__getitem__ + ) + if tls_min_ver or tls_max_ver: + method = None verify_certificates = crawler.settings.getbool("DOWNLOAD_VERIFY_CERTIFICATES") return cls( # type: ignore[misc] *args, method=method, tls_verbose_logging=tls_verbose_logging, tls_ciphers=tls_ciphers, + tls_min_version=tls_min_ver, + tls_max_version=tls_max_ver, verify_certificates=verify_certificates, **kwargs, ) @@ -108,12 +111,23 @@ class _ScrapyClientContextFactory(BrowserLikePolicyForHTTPS): return self._get_cert_options() def _get_cert_options(self) -> CertificateOptions: - with _filter_method_warning(): - return _ScrapyCertificateOptions( - method=self._ssl_method, - fixBrokenPeers=True, - acceptableCiphers=self.tls_ciphers, + return _ScrapyCertificateOptions(**self._get_cert_options_kwargs()) + + def _get_cert_options_kwargs(self) -> dict[str, Any]: + kwargs: dict[str, Any] = { + "fixBrokenPeers": True, + "acceptableCiphers": self.tls_ciphers, + } + if self.tls_min_version or self.tls_max_version: + kwargs.update( + _get_cert_options_version_kwargs( + self.tls_min_version, self.tls_max_version + ) ) + # when ScrapyClientContextFactory is removed self._ssl_method can just be None by default + elif self._ssl_method != SSL.SSLv23_METHOD: + kwargs["method"] = self._ssl_method + return kwargs # should be removed together with ScrapyClientContextFactory def getContext( @@ -137,15 +151,10 @@ class _ScrapyClientContextFactory(BrowserLikePolicyForHTTPS): self._get_context(), # type: ignore[arg-type] ) # Otherwise use the normal Twisted function. - # Note that this doesn't use self._get_context(). - with _filter_method_warning(): - return optionsForClientTLS( # type: ignore[no-any-return] - hostname=hostname.decode("ascii"), - extraCertificateOptions={ - "method": self._ssl_method, - "acceptableCiphers": self.tls_ciphers, - }, - ) + return optionsForClientTLS( # type: ignore[no-any-return] + hostname=hostname.decode("ascii"), + extraCertificateOptions=self._get_cert_options_kwargs(), + ) ScrapyClientContextFactory = create_deprecated_class( @@ -170,12 +179,6 @@ class BrowserLikeContextFactory(_ScrapyClientContextFactory): :meth:`creatorForNetloc` is the same as :class:`~twisted.web.client.BrowserLikePolicyForHTTPS` except this context factory allows setting the TLS/SSL method to use. - - The default OpenSSL method is ``TLS_METHOD`` (also called - ``SSLv23_METHOD``) which allows TLS protocol negotiation. - - As this overrides the parent ``creatorForNetloc()`` method, only - ``self._ssl_method`` is used from the parent class. """ def __init__(self, *args: Any, **kwargs: Any): @@ -189,11 +192,10 @@ class BrowserLikeContextFactory(_ScrapyClientContextFactory): super().__init__(*args, **kwargs) def creatorForNetloc(self, hostname: bytes, port: int) -> ClientTLSOptions: - with _filter_method_warning(): - return optionsForClientTLS( # type: ignore[no-any-return] - hostname=hostname.decode("ascii"), - extraCertificateOptions={"method": self._ssl_method}, - ) + return optionsForClientTLS( # type: ignore[no-any-return] + hostname=hostname.decode("ascii"), + extraCertificateOptions=self._get_cert_options_kwargs(), + ) @implementer(IPolicyForHTTPS) @@ -268,6 +270,16 @@ def _load_context_factory_from_settings(crawler: Crawler) -> IPolicyForHTTPS: Also passes values of other relevant settings to the factory class. """ + tls_method_setting: str = crawler.settings["DOWNLOADER_CLIENT_TLS_METHOD"] + if tls_method_setting != "TLS": + warnings.warn( + "Setting DOWNLOADER_CLIENT_TLS_METHOD to a non-default value is" + " deprecated, please use DOWNLOAD_TLS_MIN_VERSION and/or" + " DOWNLOAD_TLS_MAX_VERSION instead.", + ScrapyDeprecationWarning, + stacklevel=2, + ) + tls_method = _openssl_methods[tls_method_setting] if crawler.settings["DOWNLOADER_CLIENTCONTEXTFACTORY"] == "SENTINEL": context_factory_cls = _ScrapyClientContextFactory else: # pragma: no cover @@ -279,13 +291,12 @@ def _load_context_factory_from_settings(crawler: Crawler) -> IPolicyForHTTPS: context_factory_cls = load_object( crawler.settings["DOWNLOADER_CLIENTCONTEXTFACTORY"] ) - ssl_method = openssl_methods[crawler.settings.get("DOWNLOADER_CLIENT_TLS_METHOD")] return cast( "IPolicyForHTTPS", build_from_crawler( context_factory_cls, crawler, - method=ssl_method, + method=tls_method, ), ) diff --git a/scrapy/core/downloader/tls.py b/scrapy/core/downloader/tls.py index cb06f81df..3b903b39d 100644 --- a/scrapy/core/downloader/tls.py +++ b/scrapy/core/downloader/tls.py @@ -1,6 +1,7 @@ from __future__ import annotations import logging +import warnings from typing import TYPE_CHECKING, Any from OpenSSL import SSL @@ -18,8 +19,9 @@ from service_identity.pyopenssl import ( verify_ip_address, ) from twisted.internet._sslverify import ClientTLSOptions -from twisted.internet.ssl import AcceptableCiphers +from twisted.internet.ssl import AcceptableCiphers, TLSVersion +from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.utils.deprecate import create_deprecated_class if TYPE_CHECKING: @@ -32,17 +34,37 @@ if TYPE_CHECKING: logger = logging.getLogger(__name__) -METHOD_TLS = "TLS" -METHOD_TLSv10 = "TLSv1.0" -METHOD_TLSv11 = "TLSv1.1" -METHOD_TLSv12 = "TLSv1.2" +_openssl_methods: dict[str, int] = { + "TLS": SSL.SSLv23_METHOD, # protocol negotiation (recommended) + "TLSv1.0": SSL.TLSv1_METHOD, # TLS 1.0 only + "TLSv1.1": SSL.TLSv1_1_METHOD, # TLS 1.1 only + "TLSv1.2": SSL.TLSv1_2_METHOD, # TLS 1.2 only +} -openssl_methods: dict[str, int] = { - METHOD_TLS: SSL.SSLv23_METHOD, # protocol negotiation (recommended) - METHOD_TLSv10: SSL.TLSv1_METHOD, # TLS 1.0 only - METHOD_TLSv11: SSL.TLSv1_1_METHOD, # TLS 1.1 only - METHOD_TLSv12: SSL.TLSv1_2_METHOD, # TLS 1.2 only +def __getattr__(name: str) -> Any: + deprecated = { + "METHOD_TLS": "TLS", + "METHOD_TLSv10": "TLSv1.0", + "METHOD_TLSv11": "TLSv1.1", + "METHOD_TLSv12": "TLSv1.2", + "openssl_methods": _openssl_methods, + } + if name in deprecated: + warnings.warn( + f"scrapy.core.downloader.tls.{name} is deprecated.", + ScrapyDeprecationWarning, + stacklevel=2, + ) + return deprecated[name] + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + +_TWISTED_VERSION_MAP: dict[str, TLSVersion] = { + "TLSv1.0": TLSVersion.TLSv1_0, + "TLSv1.1": TLSVersion.TLSv1_1, + "TLSv1.2": TLSVersion.TLSv1_2, + "TLSv1.3": TLSVersion.TLSv1_3, } diff --git a/scrapy/settings/default_settings.py b/scrapy/settings/default_settings.py index 5d671feb3..de03c0107 100644 --- a/scrapy/settings/default_settings.py +++ b/scrapy/settings/default_settings.py @@ -16,6 +16,7 @@ Scrapy developers, if you add a setting here remember to: import sys from importlib import import_module from pathlib import Path +from typing import Any __all__ = [ "ADDONS", @@ -64,6 +65,8 @@ __all__ = [ "DOWNLOAD_HANDLERS_BASE", "DOWNLOAD_MAXSIZE", "DOWNLOAD_TIMEOUT", + "DOWNLOAD_TLS_MAX_VERSION", + "DOWNLOAD_TLS_MIN_VERSION", "DOWNLOAD_WARNSIZE", "DUPEFILTER_CLASS", "EDITOR", @@ -264,13 +267,15 @@ DOWNLOAD_WARNSIZE = 32 * 1024 * 1024 # 32m DOWNLOAD_TIMEOUT = 180 # 3mins +DOWNLOAD_TLS_MAX_VERSION = None +DOWNLOAD_TLS_MIN_VERSION = None + DOWNLOAD_VERIFY_CERTIFICATES = False DOWNLOADER = "scrapy.core.downloader.Downloader" DOWNLOADER_CLIENTCONTEXTFACTORY = "SENTINEL" DOWNLOADER_CLIENT_TLS_CIPHERS = "DEFAULT" -# Use highest TLS/SSL protocol version supported by the platform, also allowing negotiation: DOWNLOADER_CLIENT_TLS_METHOD = "TLS" DOWNLOADER_CLIENT_TLS_VERBOSE_LOGGING = False @@ -532,7 +537,7 @@ USER_AGENT = f"Scrapy/{import_module('scrapy').__version__} (+https://scrapy.org WARN_ON_GENERATOR_RETURN_VALUE = True -def __getattr__(name: str): +def __getattr__(name: str) -> Any: if name == "CONCURRENT_REQUESTS_PER_IP": import warnings # noqa: PLC0415 @@ -545,4 +550,4 @@ def __getattr__(name: str): ) return 0 - raise AttributeError + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/scrapy/utils/_deps_compat.py b/scrapy/utils/_deps_compat.py index cb5424476..fad7e6f6b 100644 --- a/scrapy/utils/_deps_compat.py +++ b/scrapy/utils/_deps_compat.py @@ -6,6 +6,8 @@ from twisted.python.versions import Version as TxVersion TWISTED_FAILURE_HAS_STACK = TWISTED_VERSION < TxVersion("twisted", 24, 10, 0) # changes to private _sslverify code, https://github.com/twisted/twisted/pull/12506 TWISTED_TLS_NEW_IMPL = TWISTED_VERSION >= TxVersion("twisted", 26, 4, 0) +# lowerMaximumSecurityTo off-by-1, https://github.com/twisted/twisted/issues/10232 +TWISTED_TLS_LIMITS_OFFBY1 = TWISTED_VERSION < TxVersion("twisted", 26, 4, 0) PYOPENSSL_VERSION = Version(PYOPENSSL_VERSION_STRING) # SSL.Context.use_certificate() wants an X509 object, SSL.Context.use_privatekey() wants a PKey object diff --git a/scrapy/utils/ssl.py b/scrapy/utils/ssl.py index 3fa2c77ba..22e9414b8 100644 --- a/scrapy/utils/ssl.py +++ b/scrapy/utils/ssl.py @@ -2,30 +2,59 @@ from __future__ import annotations import logging import ssl -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, TypedDict, TypeVar import OpenSSL._util as pyOpenSSLutil import OpenSSL.SSL import OpenSSL.version +from twisted.internet.ssl import CertificateOptions, TLSVersion +from scrapy.utils._deps_compat import TWISTED_TLS_LIMITS_OFFBY1 from scrapy.utils.python import to_unicode if TYPE_CHECKING: + from collections.abc import Callable + from OpenSSL.crypto import X509Name from scrapy.settings import BaseSettings logger = logging.getLogger(__name__) +_T = TypeVar("_T") + + +# common + + +def _get_tls_version_limit( + settings: BaseSettings, setting_name: str, converter: Callable[[str], _T] +) -> _T | None: + setting: str | None = settings[setting_name] + if setting is None: + return None + try: + return converter(setting) + except Exception as ex: + raise ValueError(f"Unknown {setting_name} value: {setting}") from ex + + +def _get_tls_version_limits( + settings: BaseSettings, converter: Callable[[str], _T] +) -> tuple[_T | None, _T | None]: + return ( + _get_tls_version_limit(settings, "DOWNLOAD_TLS_MIN_VERSION", converter), + _get_tls_version_limit(settings, "DOWNLOAD_TLS_MAX_VERSION", converter), + ) + # stdlib ssl module utils -# possible documented values for DOWNLOADER_CLIENT_TLS_METHOD -_STDLIB_PROTOCOL_MAP = { - "TLS": ssl.PROTOCOL_TLS_CLIENT, - "TLSv1.0": ssl.PROTOCOL_TLSv1, - "TLSv1.1": ssl.PROTOCOL_TLSv1_1, - "TLSv1.2": ssl.PROTOCOL_TLSv1_2, +_STDLIB_VERSION_MAP: dict[str, ssl.TLSVersion] = { + "TLSv1.0": ssl.TLSVersion.TLSv1, + "TLSv1.1": ssl.TLSVersion.TLSv1_1, + "TLSv1.2": ssl.TLSVersion.TLSv1_2, + "TLSv1.3": ssl.TLSVersion.TLSv1_3, } @@ -35,13 +64,13 @@ def _make_ssl_context(settings: BaseSettings) -> ssl.SSLContext: It's intended to be used in an HTTPS download handler. """ - method_setting: str = settings["DOWNLOADER_CLIENT_TLS_METHOD"] - if method_setting not in _STDLIB_PROTOCOL_MAP: - raise ValueError(f"Unsupported TLS method: {method_setting}") + tls_min_ver, tls_max_ver = _get_tls_version_limits( + settings, _STDLIB_VERSION_MAP.__getitem__ + ) ciphers_setting: str | None = settings["DOWNLOADER_CLIENT_TLS_CIPHERS"] verify_setting = settings.getbool("DOWNLOAD_VERIFY_CERTIFICATES") - ctx = ssl.SSLContext(_STDLIB_PROTOCOL_MAP[method_setting]) + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) if verify_setting: ctx.check_hostname = True ctx.verify_mode = ssl.CERT_REQUIRED @@ -49,6 +78,10 @@ def _make_ssl_context(settings: BaseSettings) -> ssl.SSLContext: else: ctx.check_hostname = False ctx.verify_mode = ssl.CERT_NONE + if tls_min_ver is not None: + ctx.minimum_version = tls_min_ver + if tls_max_ver is not None: + ctx.maximum_version = tls_max_ver if ciphers_setting: ctx.set_ciphers(ciphers_setting) return ctx @@ -154,3 +187,42 @@ def _log_ssl_conn_debug_info(hostname: str, connection: OpenSSL.SSL.Connection) key_info = get_temp_key_info(connection._ssl) if key_info: logger.debug("SSL temp key: %s", key_info) + + +# Twisted-specific + + +class _CertificateOptionsVersionKwargs(TypedDict, total=False): + lowerMaximumSecurityTo: TLSVersion + insecurelyLowerMinimumTo: TLSVersion + raiseMinimumTo: TLSVersion + + +def _get_cert_options_version_kwargs( + min_version: TLSVersion | None, max_version: TLSVersion | None +) -> _CertificateOptionsVersionKwargs: + """Get TLS version kwargs for + :class:`~twisted.internet.ssl.CertificateOptions` for the given limits.""" + result: _CertificateOptionsVersionKwargs = {} + if max_version: + if TWISTED_TLS_LIMITS_OFFBY1: + # lowerMaximumSecurityTo is treated as 1 version lower than the passed one + versions = list(TLSVersion.iterconstants()) + max_index = versions.index(max_version) + if max_index + 1 >= len(versions): + raise ValueError( + f"Due to an error in Twisted < 26.4.0 cannot set the maximum TLS version to {max_version.name}" + ) + max_version = versions[max_index + 1] + result["lowerMaximumSecurityTo"] = max_version + if min_version: + # We cannot pass both insecurelyLowerMinimumTo and raiseMinimumTo, + # so we need to know the direction. + + # 1.0 in Twisted 22.8.0 and older, 1.2 in Twisted 22.10.0 and newer + default_min = CertificateOptions._defaultMinimumTLSVersion + if min_version < default_min: + result["insecurelyLowerMinimumTo"] = min_version + elif min_version > default_min: + result["raiseMinimumTo"] = min_version + return result diff --git a/tests/mockserver/http_base.py b/tests/mockserver/http_base.py index ffd11c2dd..7b38409ff 100644 --- a/tests/mockserver/http_base.py +++ b/tests/mockserver/http_base.py @@ -106,6 +106,16 @@ def main_factory( default=None, help="SSL cipher string (optional)", ) + parser.add_argument( + "--tls-min-version", + default=None, + help="Minimum accepted TLS version (optional)", + ) + parser.add_argument( + "--tls-max-version", + default=None, + help="Maximum accepted TLS version (optional)", + ) args = parser.parse_args() context_factory_kw = {} if args.keyfile: @@ -114,6 +124,10 @@ def main_factory( context_factory_kw["certfile"] = args.certfile if args.cipher_string: context_factory_kw["cipher_string"] = args.cipher_string + if args.tls_min_version: + context_factory_kw["tls_min_version"] = args.tls_min_version + if args.tls_max_version: + context_factory_kw["tls_max_version"] = args.tls_max_version context_factory = ssl_context_factory(**context_factory_kw) https_port = reactor.listenSSL(0, factory, context_factory) diff --git a/tests/mockserver/simple_https.py b/tests/mockserver/simple_https.py index a8f483ee1..943775fa5 100644 --- a/tests/mockserver/simple_https.py +++ b/tests/mockserver/simple_https.py @@ -21,11 +21,21 @@ class SimpleMockServer(BaseMockServer): listen_http = False module_name = "tests.mockserver.simple_https" - def __init__(self, keyfile: str, certfile: str, cipher_string: str | None): + def __init__( + self, + keyfile: str, + certfile: str, + *, + cipher_string: str | None = None, + tls_min_version: str | None = None, + tls_max_version: str | None = None, + ): super().__init__() self.keyfile = keyfile self.certfile = certfile self.cipher_string = cipher_string or "" + self.tls_min_version = tls_min_version + self.tls_max_version = tls_max_version def get_additional_args(self) -> list[str]: args = [ @@ -36,6 +46,10 @@ class SimpleMockServer(BaseMockServer): ] if self.cipher_string is not None: args.extend(["--cipher-string", self.cipher_string]) + if self.tls_min_version is not None: + args.extend(["--tls-min-version", self.tls_min_version]) + if self.tls_max_version is not None: + args.extend(["--tls-max-version", self.tls_max_version]) return args diff --git a/tests/mockserver/utils.py b/tests/mockserver/utils.py index 17d78ecdd..7aa656780 100644 --- a/tests/mockserver/utils.py +++ b/tests/mockserver/utils.py @@ -9,8 +9,10 @@ from OpenSSL import SSL from OpenSSL.crypto import FILETYPE_PEM, load_certificate, load_privatekey from twisted.internet.ssl import CertificateOptions, ContextFactory +from scrapy.core.downloader.tls import _TWISTED_VERSION_MAP from scrapy.utils._deps_compat import PYOPENSSL_WANTS_X509_PKEY from scrapy.utils.python import to_bytes +from scrapy.utils.ssl import _get_cert_options_version_kwargs if TYPE_CHECKING: from twisted.internet.interfaces import IOpenSSLContextFactory @@ -19,7 +21,10 @@ if TYPE_CHECKING: def ssl_context_factory( keyfile: str = "keys/localhost.key", certfile: str = "keys/localhost.crt", + *, cipher_string: str | None = None, + tls_min_version: str | None = None, + tls_max_version: str | None = None, ) -> IOpenSSLContextFactory: keyfile_path = Path(__file__).parent.parent / keyfile certfile_path = Path(__file__).parent.parent / certfile @@ -31,10 +36,14 @@ def ssl_context_factory( cert = load_certificate(FILETYPE_PEM, certfile_path.read_bytes()) # type: ignore[assignment] key = load_privatekey(FILETYPE_PEM, keyfile_path.read_bytes()) # type: ignore[assignment] + tls_min = _TWISTED_VERSION_MAP.get(tls_min_version) if tls_min_version else None + tls_max = _TWISTED_VERSION_MAP.get(tls_max_version) if tls_max_version else None + tls_version_kwargs = _get_cert_options_version_kwargs(tls_min, tls_max) # https://github.com/twisted/twisted/issues/12638 factory: CertificateOptions = CertificateOptions( privateKey=key, # type: ignore[arg-type] certificate=cert, # type: ignore[arg-type] + **tls_version_kwargs, ) if cipher_string: ctx = factory.getContext() diff --git a/tests/test_core_downloader.py b/tests/test_core_downloader.py index 43edf1774..abeaa2f65 100644 --- a/tests/test_core_downloader.py +++ b/tests/test_core_downloader.py @@ -14,7 +14,7 @@ from twisted.web import server, static from twisted.web.client import Agent, BrowserLikePolicyForHTTPS, readBody from twisted.web.client import Response as TxResponse -from scrapy.core.downloader import Downloader, Slot +from scrapy.core.downloader import Downloader, Slot, tls from scrapy.core.downloader.contextfactory import ( _load_context_factory_from_settings, _ScrapyClientContextFactory, @@ -202,18 +202,37 @@ class TestContextFactoryTLSMethod(TestContextFactoryBase): def test_setting_none(self): crawler = get_crawler(settings_dict={"DOWNLOADER_CLIENT_TLS_METHOD": None}) - with pytest.raises(KeyError): + with ( + pytest.warns( + ScrapyDeprecationWarning, + match="Setting DOWNLOADER_CLIENT_TLS_METHOD to a non-default value is deprecated", + ), + pytest.raises(KeyError), + ): _load_context_factory_from_settings(crawler) def test_setting_bad(self): crawler = get_crawler(settings_dict={"DOWNLOADER_CLIENT_TLS_METHOD": "bad"}) - with pytest.raises(KeyError): + with ( + pytest.warns( + ScrapyDeprecationWarning, + match="Setting DOWNLOADER_CLIENT_TLS_METHOD to a non-default value is deprecated", + ), + pytest.raises(KeyError), + ): _load_context_factory_from_settings(crawler) + @pytest.mark.filterwarnings( + r"ignore:Passing method to twisted\.internet\.ssl\.CertificateOptions:DeprecationWarning" + ) @coroutine_test async def test_setting_explicit(self, server_url: str) -> None: crawler = get_crawler(settings_dict={"DOWNLOADER_CLIENT_TLS_METHOD": "TLSv1.2"}) - client_context_factory = _load_context_factory_from_settings(crawler) + with pytest.warns( + ScrapyDeprecationWarning, + match="Setting DOWNLOADER_CLIENT_TLS_METHOD to a non-default value is deprecated", + ): + client_context_factory = _load_context_factory_from_settings(crawler) assert client_context_factory._ssl_method == OpenSSL.SSL.TLSv1_2_METHOD await self._assert_factory_works(server_url, client_context_factory) @@ -227,6 +246,9 @@ class TestContextFactoryTLSMethod(TestContextFactoryBase): assert client_context_factory._ssl_method == OpenSSL.SSL.SSLv23_METHOD await self._assert_factory_works(server_url, client_context_factory) + @pytest.mark.filterwarnings( + r"ignore:Passing method to twisted\.internet\.ssl\.CertificateOptions:DeprecationWarning" + ) @coroutine_test async def test_direct_init(self, server_url: str) -> None: client_context_factory = _ScrapyClientContextFactory(OpenSSL.SSL.TLSv1_2_METHOD) @@ -246,3 +268,16 @@ async def test_fetch_deprecated_spider_arg(): match=r"The fetch\(\) method of .+\.CustomDownloader requires a spider argument", ): await crawler.crawl_async() + + +def test_deprecated_tls_module_names() -> None: + with pytest.warns( + ScrapyDeprecationWarning, + match="scrapy.core.downloader.tls.METHOD_TLS is deprecated", + ): + assert tls.METHOD_TLS == "TLS" + with pytest.warns( + ScrapyDeprecationWarning, + match="scrapy.core.downloader.tls.openssl_methods is deprecated", + ): + assert isinstance(tls.openssl_methods, dict) diff --git a/tests/test_downloader_handler_httpx.py b/tests/test_downloader_handler_httpx.py index f26a17f02..e7a2ac0ba 100644 --- a/tests/test_downloader_handler_httpx.py +++ b/tests/test_downloader_handler_httpx.py @@ -15,6 +15,7 @@ from tests.test_downloader_handlers_http_base import ( TestHttpsCustomCiphersBase, TestHttpsInvalidDNSIdBase, TestHttpsInvalidDNSPatternBase, + TestHttpsTLSVersionBase, TestHttpsWrongHostnameBase, TestHttpWithCrawlerBase, TestMitmProxyBase, @@ -104,6 +105,10 @@ class TestHttpsCustomCiphers(HttpxDownloadHandlerMixin, TestHttpsCustomCiphersBa pass +class TestHttpsTLSVersion(HttpxDownloadHandlerMixin, TestHttpsTLSVersionBase): + pass + + class TestHttpWithCrawler(HttpxDownloadHandlerMixin, TestHttpWithCrawlerBase): pass diff --git a/tests/test_downloader_handler_twisted_http11.py b/tests/test_downloader_handler_twisted_http11.py index fb3305945..79750a136 100644 --- a/tests/test_downloader_handler_twisted_http11.py +++ b/tests/test_downloader_handler_twisted_http11.py @@ -18,6 +18,7 @@ from tests.test_downloader_handlers_http_base import ( TestHttpsCustomCiphersBase, TestHttpsInvalidDNSIdBase, TestHttpsInvalidDNSPatternBase, + TestHttpsTLSVersionBase, TestHttpsWrongHostnameBase, TestHttpWithCrawlerBase, TestMitmProxyBase, @@ -83,6 +84,10 @@ class TestHttpsCustomCiphers(HTTP11DownloadHandlerMixin, TestHttpsCustomCiphersB pass +class TestHttpsTLSVersion(HTTP11DownloadHandlerMixin, TestHttpsTLSVersionBase): + pass + + class TestHttpWithCrawler(HTTP11DownloadHandlerMixin, TestHttpWithCrawlerBase): pass diff --git a/tests/test_downloader_handler_twisted_http2.py b/tests/test_downloader_handler_twisted_http2.py index 24cb9b1aa..5f79a5453 100644 --- a/tests/test_downloader_handler_twisted_http2.py +++ b/tests/test_downloader_handler_twisted_http2.py @@ -19,6 +19,7 @@ from tests.test_downloader_handlers_http_base import ( TestHttpsCustomCiphersBase, TestHttpsInvalidDNSIdBase, TestHttpsInvalidDNSPatternBase, + TestHttpsTLSVersionBase, TestHttpsWrongHostnameBase, TestHttpWithCrawlerBase, TestMitmProxyBase, @@ -173,6 +174,10 @@ class TestHttp2CustomCiphers(H2DownloadHandlerMixin, TestHttpsCustomCiphersBase) pass +class TestHttp2TLSVersion(H2DownloadHandlerMixin, TestHttpsTLSVersionBase): + pass + + class TestHttp2WithCrawler(H2DownloadHandlerMixin, TestHttpWithCrawlerBase): is_secure = True diff --git a/tests/test_downloader_handlers_http_base.py b/tests/test_downloader_handlers_http_base.py index 3b60fa555..e67129abf 100644 --- a/tests/test_downloader_handlers_http_base.py +++ b/tests/test_downloader_handlers_http_base.py @@ -33,6 +33,7 @@ from scrapy.exceptions import ( UnsupportedURLSchemeError, ) from scrapy.http import Headers, HtmlResponse, Request, Response, TextResponse +from scrapy.utils._deps_compat import TWISTED_TLS_LIMITS_OFFBY1 from scrapy.utils.defer import deferred_from_coro, maybe_deferred_to_future from scrapy.utils.misc import build_from_crawler from scrapy.utils.spider import DefaultSpider @@ -894,7 +895,7 @@ class TestSimpleHttpsBase(ABC): @pytest.fixture(scope="class") def simple_mockserver(self) -> Generator[SimpleMockServer]: with SimpleMockServer( - self.keyfile, self.certfile, self.cipher_string + self.keyfile, self.certfile, cipher_string=self.cipher_string ) as simple_mockserver: yield simple_mockserver @@ -957,6 +958,143 @@ class TestHttpsCustomCiphersBase(TestSimpleHttpsBase): cipher_string = "CAMELLIA256-SHA" +class TestHttpsTLSVersionBase(ABC): + keyfile = "keys/localhost.key" + certfile = "keys/localhost.crt" + + @property + @abstractmethod + def download_handler_cls(self) -> type[DownloadHandlerProtocol]: + raise NotImplementedError + + @asynccontextmanager + async def get_dh( + self, client_tls_min: str | None, client_tls_max: str | None + ) -> AsyncGenerator[DownloadHandlerProtocol]: + settings = {} + if client_tls_min is not None: + settings["DOWNLOAD_TLS_MIN_VERSION"] = client_tls_min + if client_tls_max is not None: + settings["DOWNLOAD_TLS_MAX_VERSION"] = client_tls_max + crawler = get_crawler(DefaultSpider, settings_dict=settings) + crawler.spider = crawler._create_spider() + dh = build_from_crawler(self.download_handler_cls, crawler) + try: + yield dh + finally: + await dh.close() + + @pytest.mark.parametrize( + ( + "server_tls_min", + "server_tls_max", + "client_tls_min", + "client_tls_max", + "expect_success", + ), + [ + pytest.param(None, None, None, None, True, id="no-limits"), + pytest.param(None, None, None, "TLSv1.2", True, id="client-max-tls1.2"), + pytest.param(None, None, "TLSv1.3", None, True, id="client-min-tls1.3"), + pytest.param( + "TLSv1.3", + None, + None, + "TLSv1.2", + False, + id="client-max-below-server-min", + ), + pytest.param( + None, + "TLSv1.2", + "TLSv1.3", + None, + False, + id="client-min-above-server-max", + ), + pytest.param(None, "TLSv1.2", None, "TLSv1.2", True, id="both-tls1.2"), + pytest.param("TLSv1.3", None, "TLSv1.3", None, True, id="both-tls1.3"), + pytest.param( + None, + None, + "TLSv1.0", + None, + True, + id="client-min-tls1.0", + marks=pytest.mark.filterwarnings( + r"ignore:ssl\.TLSVersion\.TLSv1 is deprecated:DeprecationWarning" + ), + ), + pytest.param( + "TLSv1.0", + None, + "TLSv1.0", + None, + True, + id="both-min-tls1.0", + marks=pytest.mark.filterwarnings( + r"ignore:ssl\.TLSVersion\.TLSv1 is deprecated:DeprecationWarning" + ), + ), + pytest.param( + "TLSv1.2", + "TLSv1.3", + "TLSv1.2", + "TLSv1.3", + True, + id="both-tls1.2-1.3", + marks=pytest.mark.xfail( + TWISTED_TLS_LIMITS_OFFBY1, + reason="Can't set max to 1.3 on this Twisted version", + strict=True, + ), + ), + ], + ) + @coroutine_test + async def test_download( + self, + server_tls_min: str | None, + server_tls_max: str | None, + client_tls_min: str | None, + client_tls_max: str | None, + expect_success: bool, + ) -> None: + with SimpleMockServer( + self.keyfile, + self.certfile, + tls_min_version=server_tls_min, + tls_max_version=server_tls_max, + ) as simple_mockserver: + url = f"https://localhost:{simple_mockserver.port(is_secure=True)}/file" + request = Request(url) + async with self.get_dh(client_tls_min, client_tls_max) as dh: + if expect_success: + response = await dh.download_request(request) + assert response.body == b"0123456789" + else: + with pytest.raises( + (DownloadConnectionRefusedError, DownloadFailedError) + ): + await dh.download_request(request) + + @coroutine_test + async def test_invalid_min_version_setting(self) -> None: + with pytest.raises( + ValueError, match="Unknown DOWNLOAD_TLS_MIN_VERSION value: invalid" + ): + async with self.get_dh(client_tls_min="invalid", client_tls_max=None): + pass + + @coroutine_test + async def test_invalid_max_version_setting(self) -> None: + with pytest.raises( + ValueError, match="Unknown DOWNLOAD_TLS_MAX_VERSION value: invalid" + ): + async with self.get_dh(client_tls_min=None, client_tls_max="invalid"): + pass + + class TestHttpWithCrawlerBase(ABC): @property @abstractmethod From d2290c35c23b9fe1973764f75a5f7cb72033108a Mon Sep 17 00:00:00 2001 From: Adnan Awan Date: Mon, 8 Jun 2026 18:44:30 +0500 Subject: [PATCH 171/248] Allow configuring the log level of the retry give-up message (#7567) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Allow configuring the log level of the retry give-up message The "Gave up retrying ..." message was always logged at ERROR, which inflates the log_count/ERROR stat even when giving up on a request is expected (e.g. broad crawls hitting dead hosts). Add a RETRY_GIVE_UP_LOG_LEVEL setting, a give_up_log_level argument to get_retry_request(), and a give_up_log_level request meta key to override it per request. The value accepts a level name ("WARNING") or number (logging.WARNING). The default ("ERROR") preserves the previous behaviour. Fixes #5297, fixes #4622 Co-Authored-By: Claude Opus 4.8 (1M context) * Address Adrian's review feedback: simplify and reorganize docs - Move give_up_log_level reqmeta section before max_retry_times (alphabetical order) - Simplify give_up_log_level section in request-response.rst (brief, links to setting) - Simplify RETRY_GIVE_UP_LOG_LEVEL setting docs in downloader-middleware.rst - Change 'When initialized' to 'When set' in max_retry_times section - Docs now follow pattern of linking to complementary setting/meta key rather than duplicating information Per Adrian's feedback: keep docs concise and cross-link setting ↔ meta key * Address Adrian's code review feedback on RETRY_GIVE_UP_LOG_LEVEL feature - Fix alphabetical ordering of give_up_log_level in request-response.rst - Remove circular references: change 'see X for details' to 'see also X' pattern - Simplify docstring for give_up_log_level parameter (4 lines → 2 lines) - Update test domains from www.scrapytest.org to example.com * Apply suggestion from @AdrianAtZyte * Minor changes * Fix example formatting. --------- Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Adrian Co-authored-by: Andrey Rakhmatullin --- docs/topics/downloader-middleware.rst | 15 +++ docs/topics/request-response.rst | 11 ++- scrapy/downloadermiddlewares/retry.py | 28 +++++- scrapy/settings/default_settings.py | 2 + tests/test_downloadermiddleware_retry.py | 114 +++++++++++++++++++++++ 5 files changed, 165 insertions(+), 5 deletions(-) diff --git a/docs/topics/downloader-middleware.rst b/docs/topics/downloader-middleware.rst index a51e431d2..91ee38b85 100644 --- a/docs/topics/downloader-middleware.rst +++ b/docs/topics/downloader-middleware.rst @@ -1053,6 +1053,21 @@ has been exceeded (see :setting:`RETRY_TIMES`). To learn about uncaught exception propagation, see :meth:`~scrapy.downloadermiddlewares.DownloaderMiddleware.process_exception`. +.. setting:: RETRY_GIVE_UP_LOG_LEVEL + +RETRY_GIVE_UP_LOG_LEVEL +^^^^^^^^^^^^^^^^^^^^^^^ + +Default: ``"ERROR"`` + +:ref:`Logging level ` used for the message logged when a request +exceeds its retries. + +Can be a level name (e.g. ``"WARNING"``) or a number (e.g. ``logging.WARNING`` +or ``30``). + +See also: :reqmeta:`give_up_log_level`, :func:`get_retry_request`. + .. setting:: RETRY_PRIORITY_ADJUST RETRY_PRIORITY_ADJUST diff --git a/docs/topics/request-response.rst b/docs/topics/request-response.rst index a4f031803..8fd3de621 100644 --- a/docs/topics/request-response.rst +++ b/docs/topics/request-response.rst @@ -714,6 +714,7 @@ Those are: * :reqmeta:`download_timeout` * ``ftp_password`` (See :setting:`FTP_PASSWORD` for more info) * ``ftp_user`` (See :setting:`FTP_USER` for more info) +* :reqmeta:`give_up_log_level` * :reqmeta:`handle_httpstatus_all` * :reqmeta:`handle_httpstatus_list` * :reqmeta:`is_start_request` @@ -790,12 +791,20 @@ download_fail_on_dataloss Whether or not to fail on broken responses. See: :setting:`DOWNLOAD_FAIL_ON_DATALOSS`. +.. reqmeta:: give_up_log_level + +give_up_log_level +----------------- + +:ref:`Logging level ` used for the message logged when a request +exceeds its retries. See :setting:`RETRY_GIVE_UP_LOG_LEVEL` for details. + .. reqmeta:: max_retry_times max_retry_times --------------- -The meta key is used set retry times per request. When initialized, the +The meta key is used set retry times per request. When set, the :reqmeta:`max_retry_times` meta key takes higher precedence over the :setting:`RETRY_TIMES` setting. diff --git a/scrapy/downloadermiddlewares/retry.py b/scrapy/downloadermiddlewares/retry.py index d38b4b9db..5f125cae4 100644 --- a/scrapy/downloadermiddlewares/retry.py +++ b/scrapy/downloadermiddlewares/retry.py @@ -12,7 +12,7 @@ once the spider has finished crawling all regular (non-failed) pages. from __future__ import annotations -from logging import Logger, getLogger +from logging import Logger, getLevelName, getLogger from typing import TYPE_CHECKING from scrapy.exceptions import NotConfigured @@ -43,6 +43,7 @@ def get_retry_request( max_retry_times: int | None = None, priority_adjust: int | None = None, logger: Logger = retry_logger, + give_up_log_level: int | str | None = None, stats_base_key: str = "retry", ) -> Request | None: """ @@ -51,14 +52,16 @@ def get_retry_request( exhausted. For example, in a :class:`~scrapy.Spider` callback, you could use it as - follows:: + follows: + + .. code-block:: python def parse(self, response): if not response.text: new_request_or_none = get_retry_request( response.request, spider=self, - reason='empty', + reason="empty", ) return new_request_or_none @@ -82,6 +85,10 @@ def get_retry_request( *logger* is the logging.Logger object to be used when logging messages + *give_up_log_level* is the :ref:`logging level ` used for the + message logged when a request exceeds its retries. See + :setting:`RETRY_GIVE_UP_LOG_LEVEL` for details. + *stats_base_key* is a string to be used as the base key for the retry-related job stats """ @@ -114,8 +121,16 @@ def get_retry_request( stats.inc_value(f"{stats_base_key}/count") stats.inc_value(f"{stats_base_key}/reason_count/{reason}") return new_request + if give_up_log_level is None: + give_up_log_level = settings["RETRY_GIVE_UP_LOG_LEVEL"] + if isinstance(give_up_log_level, str): + level = getLevelName(give_up_log_level) + if not isinstance(level, int): + raise ValueError(f"Invalid give-up log level: {give_up_log_level!r}") + give_up_log_level = level stats.inc_value(f"{stats_base_key}/max_reached") - logger.error( + logger.log( + give_up_log_level, "Gave up retrying %(request)s (failed %(retry_times)d times): %(reason)s", {"request": request, "retry_times": retry_times, "reason": reason}, extra={"spider": spider}, @@ -132,6 +147,7 @@ class RetryMiddleware: self.max_retry_times = settings.getint("RETRY_TIMES") self.retry_http_codes = {int(x) for x in settings.getlist("RETRY_HTTP_CODES")} self.priority_adjust = settings.getint("RETRY_PRIORITY_ADJUST") + self.give_up_log_level = settings["RETRY_GIVE_UP_LOG_LEVEL"] self.exceptions_to_retry = tuple( load_object(x) if isinstance(x, str) else x for x in settings.getlist("RETRY_EXCEPTIONS") @@ -175,6 +191,9 @@ class RetryMiddleware: ) -> Request | None: max_retry_times = request.meta.get("max_retry_times", self.max_retry_times) priority_adjust = request.meta.get("priority_adjust", self.priority_adjust) + give_up_log_level = request.meta.get( + "give_up_log_level", self.give_up_log_level + ) assert self.crawler.spider return get_retry_request( request, @@ -182,4 +201,5 @@ class RetryMiddleware: spider=self.crawler.spider, max_retry_times=max_retry_times, priority_adjust=priority_adjust, + give_up_log_level=give_up_log_level, ) diff --git a/scrapy/settings/default_settings.py b/scrapy/settings/default_settings.py index de03c0107..909a5fd5c 100644 --- a/scrapy/settings/default_settings.py +++ b/scrapy/settings/default_settings.py @@ -156,6 +156,7 @@ __all__ = [ "REQUEST_FINGERPRINTER_CLASS", "RETRY_ENABLED", "RETRY_EXCEPTIONS", + "RETRY_GIVE_UP_LOG_LEVEL", "RETRY_HTTP_CODES", "RETRY_PRIORITY_ADJUST", "RETRY_TIMES", @@ -469,6 +470,7 @@ RETRY_EXCEPTIONS = [ OSError, "scrapy.core.downloader.handlers.http11.TunnelError", ] +RETRY_GIVE_UP_LOG_LEVEL = "ERROR" RETRY_HTTP_CODES = [500, 502, 503, 504, 522, 524, 408, 429] RETRY_PRIORITY_ADJUST = -1 RETRY_TIMES = 2 # initial response + 2 retries = 3 requests diff --git a/tests/test_downloadermiddleware_retry.py b/tests/test_downloadermiddleware_retry.py index 50946899a..56d21a4d2 100644 --- a/tests/test_downloadermiddleware_retry.py +++ b/tests/test_downloadermiddleware_retry.py @@ -84,6 +84,39 @@ class TestRetry: ) assert self.crawler.stats.get_value("retry/count") == 2 + def test_give_up_log_level_setting(self): + crawler = get_crawler( + DefaultSpider, settings_dict={"RETRY_GIVE_UP_LOG_LEVEL": "WARNING"} + ) + crawler.spider = crawler._create_spider() + mw = RetryMiddleware.from_crawler(crawler) + mw.max_retry_times = 0 + req = Request("http://example.com/503") + rsp = Response("http://example.com/503", body=b"", status=503) + with LogCapture() as log: + assert mw.process_response(req, rsp) is rsp + log.check_present( + ( + "scrapy.downloadermiddlewares.retry", + "WARNING", + f"Gave up retrying {req} (failed 1 times): 503 Service Unavailable", + ) + ) + + def test_give_up_log_level_meta(self): + self.mw.max_retry_times = 0 + req = Request("http://example.com/503", meta={"give_up_log_level": "WARNING"}) + rsp = Response("http://example.com/503", body=b"", status=503) + with LogCapture() as log: + assert self.mw.process_response(req, rsp) is rsp + log.check_present( + ( + "scrapy.downloadermiddlewares.retry", + "WARNING", + f"Gave up retrying {req} (failed 1 times): 503 Service Unavailable", + ) + ) + def test_twistederrors(self): exceptions = [ ConnectError, @@ -612,6 +645,87 @@ class TestGetRetryRequest: ) ) + def test_give_up_log_level_default(self): + request = Request("https://example.com") + spider = self.get_spider() + with LogCapture() as log: + get_retry_request( + request, + spider=spider, + max_retry_times=0, + ) + log.check_present( + ( + "scrapy.downloadermiddlewares.retry", + "ERROR", + f"Gave up retrying {request} (failed 1 times): unspecified", + ) + ) + + def test_give_up_log_level_argument_name(self): + request = Request("https://example.com") + spider = self.get_spider() + with LogCapture() as log: + get_retry_request( + request, + spider=spider, + max_retry_times=0, + give_up_log_level="WARNING", + ) + log.check_present( + ( + "scrapy.downloadermiddlewares.retry", + "WARNING", + f"Gave up retrying {request} (failed 1 times): unspecified", + ) + ) + + def test_give_up_log_level_argument_number(self): + request = Request("https://example.com") + spider = self.get_spider() + with LogCapture() as log: + get_retry_request( + request, + spider=spider, + max_retry_times=0, + give_up_log_level=logging.WARNING, + ) + log.check_present( + ( + "scrapy.downloadermiddlewares.retry", + "WARNING", + f"Gave up retrying {request} (failed 1 times): unspecified", + ) + ) + + def test_give_up_log_level_setting(self): + request = Request("https://example.com") + spider = self.get_spider({"RETRY_GIVE_UP_LOG_LEVEL": "WARNING"}) + with LogCapture() as log: + get_retry_request( + request, + spider=spider, + max_retry_times=0, + ) + log.check_present( + ( + "scrapy.downloadermiddlewares.retry", + "WARNING", + f"Gave up retrying {request} (failed 1 times): unspecified", + ) + ) + + def test_give_up_log_level_invalid(self): + request = Request("https://example.com") + spider = self.get_spider() + with pytest.raises(ValueError, match="Invalid give-up log level"): + get_retry_request( + request, + spider=spider, + max_retry_times=0, + give_up_log_level="NOT_A_LEVEL", + ) + def test_custom_stats_key(self): request = Request("https://example.com") spider = self.get_spider() From 4e956bd2de5e319bebad2d603a2f5ee34d9d2ffb Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Tue, 9 Jun 2026 01:10:44 +0500 Subject: [PATCH 172/248] Add support for HTTP/2 and for SOCKS proxies to HttpxDownloadHandler, improve handler docs (#7575) * Add support for HTTP/2 and SOCKS proxies to HttpxDownloadHandler. * Update the docs. * Trim the tables. * Restore lost wording. * Handlers docs improvements and fixes. --- conftest.py | 13 ++ docs/faq.rst | 17 +- docs/topics/download-handlers.rst | 219 ++++++++++++++------ docs/topics/downloader-middleware.rst | 10 +- scrapy/core/downloader/handlers/_httpx.py | 53 ++++- scrapy/settings/default_settings.py | 3 + scrapy/utils/python.py | 10 + tests/mockserver/mitm_proxy.py | 10 +- tests/test_downloader_handler_httpx.py | 38 +++- tests/test_downloader_handlers_http_base.py | 78 +++++-- tox.ini | 4 +- 11 files changed, 345 insertions(+), 110 deletions(-) diff --git a/conftest.py b/conftest.py index fcbf59426..1674086ec 100644 --- a/conftest.py +++ b/conftest.py @@ -99,6 +99,19 @@ def mitm_proxy_server_https(monkeypatch: pytest.MonkeyPatch) -> Generator[MitmPr proxy.stop() +@pytest.fixture # function scope because it modifies os.environ +def socks5_proxy_server(monkeypatch: pytest.MonkeyPatch) -> Generator[MitmProxy]: + proxy = MitmProxy(mode="socks5") + url = proxy.start() + monkeypatch.setenv("http_proxy", url) + monkeypatch.setenv("https_proxy", url) + + try: + yield proxy + finally: + proxy.stop() + + @pytest.fixture(scope="session") def reactor_pytest(request) -> str: return request.config.getoption("--reactor") diff --git a/docs/faq.rst b/docs/faq.rst index 87adbfa4b..df90122f5 100644 --- a/docs/faq.rst +++ b/docs/faq.rst @@ -82,10 +82,18 @@ to steal from us! Does Scrapy work with HTTP proxies? ----------------------------------- -Yes. Support for HTTP proxies is provided (since Scrapy 0.8) through the HTTP -Proxy downloader middleware. See +Yes. Support for HTTP proxies is provided through the HTTP Proxy downloader +middleware. See :class:`~scrapy.downloadermiddlewares.httpproxy.HttpProxyMiddleware`. +Does Scrapy work with SOCKS proxies? +------------------------------------ + +Yes, when using +:class:`~scrapy.core.downloader.handlers._httpx.HttpxDownloadHandler`. See +:class:`~scrapy.downloadermiddlewares.httpproxy.HttpProxyMiddleware` and the +handler documentation. + How can I scrape an item with attributes in different pages? ------------------------------------------------------------ @@ -360,7 +368,10 @@ method for this purpose. For example: Does Scrapy support IPv6 addresses? ----------------------------------- -Yes, by setting :setting:`TWISTED_DNS_RESOLVER` to ``scrapy.resolver.CachingHostnameResolver``. +Yes, but when using +:class:`~scrapy.core.downloader.handlers.http11.HTTP11DownloadHandler` or +:class:`~scrapy.core.downloader.handlers.http2.H2DownloadHandler` you need to +set :setting:`TWISTED_DNS_RESOLVER` to ``scrapy.resolver.CachingHostnameResolver``. Note that by doing so, you lose the ability to set a specific timeout for DNS requests (the value of the :setting:`DNS_TIMEOUT` setting is ignored). diff --git a/docs/topics/download-handlers.rst b/docs/topics/download-handlers.rst index 84711e5af..888bfaf08 100644 --- a/docs/topics/download-handlers.rst +++ b/docs/topics/download-handlers.rst @@ -130,44 +130,39 @@ these exceptions. .. _download-handlers-ref: -Built-in download handlers reference -==================================== +Built-in HTTP download handlers reference +========================================= -DataURIDownloadHandler ----------------------- +Scrapy ships several handlers for HTTP and HTTPS requests. While all of them +support basic features, they may differ in support of specific Scrapy features +and settings and HTTP protocol features. See the documentation of specific +handlers and specific settings for more information. Additionally, as the +underlying HTTP client implementations differ between handlers, the behavior of +specific websites may be different when doing the same Scrapy requests but +using different handlers. -.. autoclass:: scrapy.core.downloader.handlers.datauri.DataURIDownloadHandler +Here is a comparison of some features of the built-in HTTP handlers, see the +individual handler docs for more differences: -| Supported scheme: ``data``. -| Lazy: no. +================== ================= ===================== ==================== +Feature H2DownloadHandler HTTP11DownloadHandler HttpxDownloadHandler +================== ================= ===================== ==================== +Requires asyncio No No Yes +Requires a reactor Yes Yes No +HTTP/1.1 No Yes Yes +HTTP/2 Yes No Yes +TLS implementation ``cryptography`` ``cryptography`` Stdlib ``ssl`` +HTTP proxies No Yes Yes +SOCKS proxies No No Yes +================== ================= ===================== ==================== -This handler supports RFC 2397 ``data:content/type;base64,`` data URIs. +You can find additional HTTP download handlers in the +scrapy-download-handlers-incubator_ package. This package is made by the Scrapy +developers and contains experimental handlers that may be included in some +later Scrapy version but can already be used. Please refer to the documentation +of this package for more information. -FileDownloadHandler -------------------- - -.. autoclass:: scrapy.core.downloader.handlers.file.FileDownloadHandler - -| Supported scheme: ``file``. -| Lazy: no. - -This handler supports ``file:///path`` local file URIs. It doesn't -support remote files. - -FTPDownloadHandler ------------------- - -.. autoclass:: scrapy.core.downloader.handlers.ftp.FTPDownloadHandler - -| Supported scheme: ``ftp``. -| Lazy: no. - -This handler supports ``ftp://host/path`` FTP URIs. - -It's implemented using :mod:`twisted.protocols.ftp`. - -.. note:: - This handler is not supported when :setting:`TWISTED_REACTOR_ENABLED` is ``False``. +.. _scrapy-download-handlers-incubator: https://github.com/scrapy-plugins/scrapy-download-handlers-incubator .. _twisted-http2-handler: @@ -177,7 +172,9 @@ H2DownloadHandler .. autoclass:: scrapy.core.downloader.handlers.http2.H2DownloadHandler | Supported scheme: ``https``. -| Lazy: yes. +| :ref:`Lazy `: yes. +| :ref:`Requires asyncio support `: no. +| :ref:`Requires a Twisted reactor `: yes. This handler supports ``https://host/path`` URLs and uses the HTTP/2 protocol for them. @@ -196,63 +193,96 @@ If you want to use this handler you need to replace the default one for the "https": "scrapy.core.downloader.handlers.http2.H2DownloadHandler", } +Features and limitations +^^^^^^^^^^^^^^^^^^^^^^^^ + .. warning:: This handler is experimental, and not yet recommended for production environments. Future Scrapy versions may introduce related changes without a deprecation period or warning. -.. note:: +=========================== ================================================ +HTTP proxies No (not implemented) +SOCKS proxies No (not supported by the library) +HTTP/2 Yes +``response.certificate`` :class:`twisted.internet.ssl.Certificate` object +Per-request ``bindaddress`` Yes +TLS implementation ``pyOpenSSL``/``cryptography`` +=========================== ================================================ - Known limitations of the HTTP/2 implementation in this handler include: +Other limitations: - - No support for proxies. +- No support for HTTP/1.1. - - No support for HTTP/2 Cleartext (h2c), since no major browser supports - HTTP/2 unencrypted (refer `http2 faq`_). +- IPv6 support requires setting :setting:`TWISTED_DNS_RESOLVER` + to ``scrapy.resolver.CachingHostnameResolver``. - - No setting to specify a maximum `frame size`_ larger than the default - value, 16384. Connections to servers that send a larger frame will - fail. +- No support for the :signal:`bytes_received` and :signal:`headers_received` + signals. - - No support for `server pushes`_, which are ignored. +Known limitations of the HTTP/2 support: - - No support for the :signal:`bytes_received` and - :signal:`headers_received` signals. +- No support for HTTP/2 Cleartext (h2c), since no major browser supports + HTTP/2 unencrypted (refer `http2 faq`_). + +- No setting to specify a maximum `frame size`_ larger than the default + value, 16384. Connections to servers that send a larger frame will fail. + +- No support for `server pushes`_, which are ignored. .. _frame size: https://datatracker.ietf.org/doc/html/rfc7540#section-4.2 .. _http2 faq: https://http2.github.io/faq/#does-http2-require-encryption .. _server pushes: https://datatracker.ietf.org/doc/html/rfc7540#section-8.2 -.. note:: - This handler is not supported when :setting:`TWISTED_REACTOR_ENABLED` is ``False``. - HTTP11DownloadHandler --------------------- .. autoclass:: scrapy.core.downloader.handlers.http11.HTTP11DownloadHandler | Supported schemes: ``http``, ``https``. -| Lazy: no. +| :ref:`Lazy `: no. +| :ref:`Requires asyncio support `: no. +| :ref:`Requires a Twisted reactor `: yes. This handler supports ``http://host/path`` and ``https://host/path`` URLs and uses the HTTP/1.1 protocol for them. It's implemented using :mod:`twisted.web.client`. -.. note:: - This handler is not supported when :setting:`TWISTED_REACTOR_ENABLED` is ``False``. +Features and limitations +^^^^^^^^^^^^^^^^^^^^^^^^ + +=========================== ================================================ +HTTP proxies Yes +SOCKS proxies No (not supported by the library) +HTTP/2 No (implemented as a separate handler) +``response.certificate`` :class:`twisted.internet.ssl.Certificate` object +Per-request ``bindaddress`` Yes +TLS implementation ``pyOpenSSL``/``cryptography`` +=========================== ================================================ + +Other limitations: + +- IPv6 support requires setting :setting:`TWISTED_DNS_RESOLVER` + to ``scrapy.resolver.CachingHostnameResolver``. + +- HTTPS proxies to HTTPS destinations are not supported. HttpxDownloadHandler -------------------- +.. versionadded:: 2.15.0 + .. autoclass:: scrapy.core.downloader.handlers._httpx.HttpxDownloadHandler | Supported schemes: ``http``, ``https``. -| Lazy: no. +| :ref:`Lazy `: no. +| :ref:`Requires asyncio support `: yes. +| :ref:`Requires a Twisted reactor `: no. This handler supports ``http://host/path`` and ``https://host/path`` URLs and -uses the HTTP/1.1 protocol for them. +uses the HTTP/1.1 or HTTP/2 protocol for them. It's implemented using the ``httpx`` library and needs it to be installed. @@ -266,28 +296,83 @@ If you want to use this handler you need to replace the default ones for the "https": "scrapy.core.downloader.handlers._httpx.HttpxDownloadHandler", } +Features and limitations +^^^^^^^^^^^^^^^^^^^^^^^^ + .. warning:: This handler is experimental, and not yet recommended for production environments. Future Scrapy versions may introduce related changes without a deprecation period or warning or even remove it altogether. -.. note:: +=========================== ======================================= +HTTP proxies Yes +SOCKS proxies Yes (SOCKS5; requires ``httpx[socks]``) +HTTP/2 Yes (requires ``httpx[http2]``) +``response.certificate`` DER bytes +Per-request ``bindaddress`` No (not supported by the library) +TLS implementation Standard library ``ssl`` +=========================== ======================================= - As this handler is based on a different HTTP client implementation compared - to :class:`~.HTTP11DownloadHandler`, it's expected that its behavior on - some websites may be different. Additionally, these are the Scrapy features - that are explicitly not supported when using it: +Other limitations: - - Per-request bind address support (the :reqmeta:`bindaddress` meta key). - The global :setting:`DOWNLOAD_BIND_ADDRESS` setting is supported but the - port number, if specified, will be ignored. +- The handler creates a separate connection pool for each proxy URL (due to + limitations of ``httpx``) which may lead to higher resource usage when + using proxy rotation. - - Settings specific to the Twisted networking or HTTP implementation, like - :setting:`DNS_RESOLVER`. +.. setting:: HTTPX_HTTP2_ENABLED - - Using :ref:`non-asyncio reactors ` (``httpx`` requires - ``asyncio``). +HTTPX_HTTP2_ENABLED +^^^^^^^^^^^^^^^^^^^ + +Default: ``False`` + +Whether to enable HTTP/2 support in this handler. The ``httpx[http2]`` extra +needs to be installed if you want to enable this setting. + +.. versionadded:: VERSION + +Built-in non-HTTP download handlers reference +============================================= + +DataURIDownloadHandler +---------------------- + +.. autoclass:: scrapy.core.downloader.handlers.datauri.DataURIDownloadHandler + +| Supported scheme: ``data``. +| :ref:`Lazy `: no. +| :ref:`Requires asyncio support `: no. +| :ref:`Requires a Twisted reactor `: no. + +This handler supports RFC 2397 ``data:content/type;base64,`` data URIs. + +FileDownloadHandler +------------------- + +.. autoclass:: scrapy.core.downloader.handlers.file.FileDownloadHandler + +| Supported scheme: ``file``. +| :ref:`Lazy `: no. +| :ref:`Requires asyncio support `: no. +| :ref:`Requires a Twisted reactor `: no. + +This handler supports ``file:///path`` local file URIs. It doesn't +support remote files. + +FTPDownloadHandler +------------------ + +.. autoclass:: scrapy.core.downloader.handlers.ftp.FTPDownloadHandler + +| Supported scheme: ``ftp``. +| :ref:`Lazy `: no. +| :ref:`Requires asyncio support `: no. +| :ref:`Requires a Twisted reactor `: yes. + +This handler supports ``ftp://host/path`` FTP URIs. + +It's implemented using :mod:`twisted.protocols.ftp`. S3DownloadHandler ----------------- @@ -295,7 +380,9 @@ S3DownloadHandler .. autoclass:: scrapy.core.downloader.handlers.s3.S3DownloadHandler | Supported scheme: ``s3``. -| Lazy: yes. +| :ref:`Lazy `: yes. +| :ref:`Requires asyncio support `: no. +| :ref:`Requires a Twisted reactor `: no. This handler supports ``s3://bucket/path`` S3 URIs. diff --git a/docs/topics/downloader-middleware.rst b/docs/topics/downloader-middleware.rst index 91ee38b85..8cb29deff 100644 --- a/docs/topics/downloader-middleware.rst +++ b/docs/topics/downloader-middleware.rst @@ -745,8 +745,7 @@ HttpProxyMiddleware Handling of this meta key needs to be implemented inside the :ref:`download handler `, so it's not guaranteed to be supported by all 3rd-party handlers. It's currently unsupported by - :class:`~scrapy.core.downloader.handlers.http2.H2DownloadHandler` and - :class:`~scrapy.core.downloader.handlers._httpx.HttpxDownloadHandler`. + :class:`~scrapy.core.downloader.handlers.http2.H2DownloadHandler`. .. note:: @@ -758,6 +757,13 @@ HttpProxyMiddleware :class:`~scrapy.core.downloader.handlers.http11.HTTP11DownloadHandler` supports HTTPS proxies only for HTTP destinations. +.. note:: + + If the download handler supports it, you can use a SOCKS proxy URL (e.g. + ``socks5://username:password@some_proxy_server:port``). + :class:`~scrapy.core.downloader.handlers._httpx.HttpxDownloadHandler` + supports SOCKS proxies while other built-in handlers don't. + HttpProxyMiddleware settings ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/scrapy/core/downloader/handlers/_httpx.py b/scrapy/core/downloader/handlers/_httpx.py index 43e1cd965..c960fc152 100644 --- a/scrapy/core/downloader/handlers/_httpx.py +++ b/scrapy/core/downloader/handlers/_httpx.py @@ -5,6 +5,7 @@ from __future__ import annotations import ipaddress import ssl from contextlib import asynccontextmanager +from socket import gaierror from typing import TYPE_CHECKING, ClassVar from scrapy.exceptions import ( @@ -17,6 +18,7 @@ from scrapy.exceptions import ( ) from scrapy.http import Headers from scrapy.utils._download_handlers import NullCookieJar +from scrapy.utils.python import _iter_exc_causes from scrapy.utils.ssl import ( _log_sslobj_debug_info, _make_insecure_ssl_ctx, @@ -34,10 +36,35 @@ if TYPE_CHECKING: from scrapy.crawler import Crawler +HAS_SOCKS = HAS_HTTP2 = False + try: import httpx except ImportError: httpx = None # type: ignore[assignment] +else: + # a small hack to avoid importing these optional extras unconditionally + + DOWNLOAD_FAILED_EXCEPTIONS: tuple[type[BaseException], ...] = ( + httpx.RequestError, + httpx.InvalidURL, + ) + + try: + import h2.exceptions + + HAS_HTTP2 = True + DOWNLOAD_FAILED_EXCEPTIONS += (h2.exceptions.InvalidBodyLengthError,) + except ImportError: # pragma: no cover + pass + + try: + import socksio.exceptions + + HAS_SOCKS = True + DOWNLOAD_FAILED_EXCEPTIONS += (socksio.exceptions.ProtocolError,) + except ImportError: # pragma: no cover + pass if TYPE_CHECKING: @@ -54,6 +81,11 @@ class HttpxDownloadHandler(_Base): self._verify_certificates: bool = crawler.settings.getbool( "DOWNLOAD_VERIFY_CERTIFICATES" ) + self._enable_h2: bool = crawler.settings.getbool("HTTPX_HTTP2_ENABLED") + if self._enable_h2 and not HAS_HTTP2: # pragma: no cover + raise NotConfigured( + f"HTTP/2 support in {type(self).__name__} requires the 'httpx[http2]' extra to be installed." + ) self._ssl_context: ssl.SSLContext = _make_ssl_context(crawler.settings) self._bind_host: str | None = self._get_bind_address_host() self._limits: httpx.Limits = httpx.Limits( @@ -90,6 +122,7 @@ class HttpxDownloadHandler(_Base): transport=httpx.AsyncHTTPTransport( verify=self._ssl_context, local_address=self._bind_host, + http2=self._enable_h2, limits=self._limits, trust_env=False, proxy=proxy, @@ -113,7 +146,13 @@ class HttpxDownloadHandler(_Base): async def _make_request( self, request: Request, timeout: float ) -> AsyncIterator[httpx.Response]: - client = self._get_client(self._extract_proxy_url_with_creds(request)) + proxy = self._extract_proxy_url_with_creds(request) + if proxy and proxy.startswith("socks") and not HAS_SOCKS: # pragma: no cover + raise ValueError( + f"SOCKS proxy support in {type(self).__name__} requires the 'httpx[socks]' extra to be installed." + ) + client = self._get_client(proxy) + try: async with client.stream( request.method, @@ -130,18 +169,12 @@ class HttpxDownloadHandler(_Base): except httpx.UnsupportedProtocol as e: raise UnsupportedURLSchemeError(str(e)) from e except httpx.ConnectError as e: - error_message = str(e) - if ( - "Name or service not known" in error_message - or "getaddrinfo failed" in error_message - or "nodename nor servname" in error_message - or "Temporary failure in name resolution" in error_message - ): - raise CannotResolveHostError(error_message) from e + if any(isinstance(c, gaierror) for c in _iter_exc_causes(e)): + raise CannotResolveHostError(str(e)) from e raise DownloadConnectionRefusedError(str(e)) from e except httpx.ProxyError as e: raise DownloadConnectionRefusedError(str(e)) from e - except (httpx.NetworkError, httpx.RemoteProtocolError) as e: + except DOWNLOAD_FAILED_EXCEPTIONS as e: raise DownloadFailedError(str(e)) from e @staticmethod diff --git a/scrapy/settings/default_settings.py b/scrapy/settings/default_settings.py index 909a5fd5c..7d5612026 100644 --- a/scrapy/settings/default_settings.py +++ b/scrapy/settings/default_settings.py @@ -109,6 +109,7 @@ __all__ = [ "HTTPCACHE_STORAGE", "HTTPPROXY_AUTH_ENCODING", "HTTPPROXY_ENABLED", + "HTTPX_HTTP2_ENABLED", "IMAGES_STORE_GCS_ACL", "IMAGES_STORE_S3_ACL", "ITEM_PIPELINES", @@ -382,6 +383,8 @@ HTTPCACHE_STORAGE = "scrapy.extensions.httpcache.FilesystemCacheStorage" HTTPPROXY_ENABLED = True HTTPPROXY_AUTH_ENCODING = "latin-1" +HTTPX_HTTP2_ENABLED = False + IMAGES_STORE_GCS_ACL = "" IMAGES_STORE_S3_ACL = "private" diff --git a/scrapy/utils/python.py b/scrapy/utils/python.py index 2d40e8555..00cf818b7 100644 --- a/scrapy/utils/python.py +++ b/scrapy/utils/python.py @@ -359,3 +359,13 @@ def _looks_like_import_path(value: str) -> bool: if any(part == "" for part in parts): return False return all(part.isidentifier() for part in parts) + + +def _iter_exc_causes(exc: BaseException) -> Iterable[BaseException]: + """Iterate over the exception causes/contexts.""" + seen: set[int] = set() + cur: BaseException | None = exc + while cur is not None and id(cur) not in seen: + seen.add(id(cur)) + yield cur + cur = cur.__cause__ or cur.__context__ diff --git a/tests/mockserver/mitm_proxy.py b/tests/mockserver/mitm_proxy.py index 1ec5a79b5..56620f84e 100644 --- a/tests/mockserver/mitm_proxy.py +++ b/tests/mockserver/mitm_proxy.py @@ -11,6 +11,9 @@ class MitmProxy: auth_user = "scrapy" auth_pass = "scrapy" + def __init__(self, mode: str | None = None) -> None: + self.mode = mode + def start(self) -> str: script = """ import sys @@ -32,6 +35,8 @@ sys.exit(mitmdump()) "-s", str(Path(__file__).with_name("mitm_proxy_addon.py")), ] + if self.mode: + args += ["--mode", self.mode] self.proc: Popen[str] = Popen( [ sys.executable, @@ -44,12 +49,13 @@ sys.exit(mitmdump()) text=True, ) assert self.proc.stdout is not None + scheme = "socks5" if self.mode == "socks5" else "http" line = "" for line in self.proc.stdout: - m = re.search(r"listening at (?:http://)?([^:]+:\d+)", line) + m = re.search(r"listening at (?:\w+://)?([^:]+:\d+)", line) if m: host_port = m.group(1) - return f"http://{self.auth_user}:{self.auth_pass}@{host_port}" + return f"{scheme}://{self.auth_user}:{self.auth_pass}@{host_port}" self.stop() raise RuntimeError(f"Failed to parse mitmdump output: {line}") diff --git a/tests/test_downloader_handler_httpx.py b/tests/test_downloader_handler_httpx.py index e7a2ac0ba..fdacad963 100644 --- a/tests/test_downloader_handler_httpx.py +++ b/tests/test_downloader_handler_httpx.py @@ -3,11 +3,17 @@ from __future__ import annotations import sys -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, ClassVar import pytest from scrapy import Request +from scrapy.core.downloader.handlers._httpx import ( + HAS_HTTP2, + HAS_SOCKS, + HttpxDownloadHandler, +) +from scrapy.exceptions import DownloadFailedError from tests.test_downloader_handlers_http_base import ( TestHttpBase, TestHttpProxyBase, @@ -37,10 +43,6 @@ pytest.importorskip("httpx") class HttpxDownloadHandlerMixin: @property def download_handler_cls(self) -> type[DownloadHandlerProtocol]: - from scrapy.core.downloader.handlers._httpx import ( # noqa: PLC0415 - HttpxDownloadHandler, - ) - return HttpxDownloadHandler @property @@ -83,6 +85,30 @@ class TestHttps(HttpxDownloadHandlerMixin, TestHttpsBase): pass +@pytest.mark.skipif(not HAS_HTTP2, reason="No HTTP/2 support in HttpxDownloadHandler") +class TestHttp2(TestHttps): + http2 = True + handler_supports_http2_dataloss = False + + default_handler_settings: ClassVar[dict[str, Any]] = { + "HTTPX_HTTP2_ENABLED": True, + } + + @coroutine_test + async def test_protocol(self, mockserver: MockServer) -> None: + request = Request(mockserver.url("/host", is_secure=self.is_secure)) + async with self.get_dh() as download_handler: + response = await download_handler.download_request(request) + assert response.protocol == "HTTP/2" + + @coroutine_test + async def test_data_loss_handling(self, mockserver: MockServer) -> None: + request = Request(mockserver.url("/broken", is_secure=self.is_secure)) + async with self.get_dh() as download_handler: + with pytest.raises(DownloadFailedError): + await download_handler.download_request(request) + + class TestSimpleHttps(HttpxDownloadHandlerMixin, TestSimpleHttpsBase): pass @@ -127,7 +153,7 @@ class TestHttpsProxy(TestHttpProxy): @pytest.mark.requires_mitmproxy class TestMitmProxy(HttpxDownloadHandlerMixin, TestMitmProxyBase): - pass + handler_supports_socks = HAS_SOCKS @pytest.mark.requires_internet diff --git a/tests/test_downloader_handlers_http_base.py b/tests/test_downloader_handlers_http_base.py index e67129abf..0e1ff07c9 100644 --- a/tests/test_downloader_handlers_http_base.py +++ b/tests/test_downloader_handlers_http_base.py @@ -39,7 +39,7 @@ from scrapy.utils.misc import build_from_crawler from scrapy.utils.spider import DefaultSpider from scrapy.utils.test import get_crawler from tests import NON_EXISTING_RESOLVABLE -from tests.mockserver.mitm_proxy import MitmProxy, wrong_credentials +from tests.mockserver.mitm_proxy import wrong_credentials from tests.mockserver.proxy_echo import ProxyEchoMockServer from tests.mockserver.simple_https import SimpleMockServer from tests.spiders import ( @@ -73,6 +73,7 @@ class TestHttpBase(ABC): handler_supports_http2_dataloss: bool = True # default headers added by the underlying library that cannot be suppressed always_present_req_headers: ClassVar[frozenset[str]] = frozenset() + default_handler_settings: ClassVar[dict[str, Any]] = {} @property @abstractmethod @@ -83,6 +84,10 @@ class TestHttpBase(ABC): async def get_dh( self, settings_dict: dict[str, Any] | None = None ) -> AsyncGenerator[DownloadHandlerProtocol]: + settings_dict = { + **self.default_handler_settings, + **(settings_dict or {}), + } crawler = get_crawler(DefaultSpider, settings_dict) crawler.spider = crawler._create_spider() dh = build_from_crawler(self.download_handler_cls, crawler) @@ -339,9 +344,7 @@ class TestHttpBase(ABC): @coroutine_test async def test_timeout_download_from_spider_server_hangs( - self, - mockserver: MockServer, - reactor_pytest: str, + self, mockserver: MockServer, reactor_pytest: str ) -> None: if reactor_pytest == "asyncio" and sys.platform == "win32": # https://twistedmatrix.com/trac/ticket/10279 @@ -1317,6 +1320,7 @@ class TestHttpProxyBase(ABC): class TestMitmProxyBase(ABC): # whether the handler supports HTTPS proxies with HTTPS destinations handler_supports_tls_in_tls: bool = True + handler_supports_socks: bool = False @property @abstractmethod @@ -1326,13 +1330,10 @@ class TestMitmProxyBase(ABC): @pytest.mark.parametrize( "https_dest", [False, True], ids=["HTTP dest", "HTTPS dest"] ) + @pytest.mark.usefixtures("mitm_proxy_server") @coroutine_test async def test_http_proxy( - self, - caplog: pytest.LogCaptureFixture, - mockserver: MockServer, - mitm_proxy_server: MitmProxy, - https_dest: bool, + self, caplog: pytest.LogCaptureFixture, mockserver: MockServer, https_dest: bool ) -> None: """HTTP proxy, HTTP or HTTPS destination.""" crawler = get_crawler(SingleRequestSpider, self.settings_dict) @@ -1347,13 +1348,10 @@ class TestMitmProxyBase(ABC): @pytest.mark.parametrize( "https_dest", [False, True], ids=["HTTP dest", "HTTPS dest"] ) + @pytest.mark.usefixtures("mitm_proxy_server_https") @coroutine_test async def test_https_proxy( - self, - caplog: pytest.LogCaptureFixture, - mockserver: MockServer, - mitm_proxy_server_https: MitmProxy, - https_dest: bool, + self, caplog: pytest.LogCaptureFixture, mockserver: MockServer, https_dest: bool ) -> None: """HTTPS proxy, HTTP or HTTPS destination.""" if https_dest and not self.handler_supports_tls_in_tls: @@ -1370,13 +1368,13 @@ class TestMitmProxyBase(ABC): @pytest.mark.parametrize( "https_dest", [False, True], ids=["HTTP dest", "HTTPS dest"] ) + @pytest.mark.usefixtures("mitm_proxy_server") @coroutine_test async def test_http_proxy_auth_error( self, caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch, mockserver: MockServer, - mitm_proxy_server: MitmProxy, https_dest: bool, ) -> None: """HTTP proxy, HTTP or HTTPS destination, wrong proxy creds.""" @@ -1394,13 +1392,10 @@ class TestMitmProxyBase(ABC): @pytest.mark.parametrize( "https_dest", [False, True], ids=["HTTP dest", "HTTPS dest"] ) + @pytest.mark.usefixtures("mitm_proxy_server") @coroutine_test async def test_dont_leak_proxy_authorization_header( - self, - caplog: pytest.LogCaptureFixture, - mockserver: MockServer, - mitm_proxy_server: MitmProxy, - https_dest: bool, + self, caplog: pytest.LogCaptureFixture, mockserver: MockServer, https_dest: bool ) -> None: """HTTP proxy, HTTP or HTTPS destination. Check that the auth header is not sent to the destination.""" @@ -1414,6 +1409,49 @@ class TestMitmProxyBase(ABC): echo = json.loads(crawler.spider.meta["responses"][0].text) assert "Proxy-Authorization" not in echo["headers"] + @pytest.mark.parametrize( + "https_dest", [False, True], ids=["HTTP dest", "HTTPS dest"] + ) + @pytest.mark.usefixtures("socks5_proxy_server") + @coroutine_test + async def test_download_with_socks_proxy( + self, caplog: pytest.LogCaptureFixture, mockserver: MockServer, https_dest: bool + ) -> None: + """SOCKS5 proxy, HTTP or HTTPS destination.""" + if not self.handler_supports_socks: + pytest.skip("SOCKS proxies are not supported") + crawler = get_crawler(SingleRequestSpider, self.settings_dict) + with caplog.at_level(logging.DEBUG): + await crawler.crawl_async( + seed=mockserver.url("/status?n=200", is_secure=https_dest) + ) + assert isinstance(crawler.spider, SingleRequestSpider) + self._assert_got_response_code(200, caplog.text) + self._assert_headers(crawler.spider.meta["responses"][0].headers, https_dest) + + @pytest.mark.parametrize( + "https_dest", [False, True], ids=["HTTP dest", "HTTPS dest"] + ) + @pytest.mark.usefixtures("socks5_proxy_server") + @coroutine_test + async def test_socks_proxy_auth_error( + self, + caplog: pytest.LogCaptureFixture, + monkeypatch: pytest.MonkeyPatch, + mockserver: MockServer, + https_dest: bool, + ) -> None: + if not self.handler_supports_socks: + pytest.skip("SOCKS proxies are not supported") + envvar = "https_proxy" if https_dest else "http_proxy" + monkeypatch.setenv(envvar, wrong_credentials(os.environ[envvar])) + crawler = get_crawler(SimpleSpider, self.settings_dict) + with caplog.at_level(logging.DEBUG): + await crawler.crawl_async( + mockserver.url("/status?n=200", is_secure=https_dest) + ) + assert "DownloadConnectionRefusedError" in caplog.text + @staticmethod def _assert_headers(headers: Headers, https_dest: bool) -> None: assert b"X-Via-Mitmproxy" in headers diff --git a/tox.ini b/tox.ini index cc916caea..602394761 100644 --- a/tox.ini +++ b/tox.ini @@ -60,6 +60,7 @@ deps = ipython==8.39.0 pyOpenSSL==26.2.0 pytest==9.0.3 + socksio==1.0.0 types-Pygments==2.20.0.20260508 types-defusedxml==0.7.0.20260504 types-lxml==2026.2.16 @@ -148,7 +149,7 @@ deps = brotli >= 1.2.0; implementation_name != "pypy" # optional for HTTP compress downloader middleware tests brotlicffi >= 1.2.0.0; implementation_name == "pypy" # optional for HTTP compress downloader middleware tests google-cloud-storage - httpx + httpx[http2,socks] ipython robotexclusionrulesparser uvloop; platform_system != "Windows" and implementation_name != "pypy" @@ -304,5 +305,6 @@ deps = {[testenv]deps} # mitmproxy does not support PyPy mitmproxy; implementation_name != "pypy" + httpx[http2,socks] commands = pytest {posargs:--cov-config=pyproject.toml --cov=scrapy --cov-report=xml --cov-report= tests --junitxml=botocore.junit.xml -o junit_family=legacy} -m requires_mitmproxy From ddafb37a7c6c5c55b8736ce4040b5f50711bf952 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Wed, 10 Jun 2026 14:01:06 +0500 Subject: [PATCH 173/248] Assorted test fixes (#7585) * Fix the mitmproxy junitxml file name. * Fix TestAsyncCrawlerRunnerHasSpider regression. * Update split out file refs. * Update the test_counter_handler() docstring. --- conftest.py | 8 ++++---- tests/test_crawler.py | 4 ++-- tests/test_zz_resources.py | 3 ++- tox.ini | 2 +- 4 files changed, 9 insertions(+), 8 deletions(-) diff --git a/conftest.py b/conftest.py index 1674086ec..cf0568111 100644 --- a/conftest.py +++ b/conftest.py @@ -24,13 +24,13 @@ def _py_files(folder): collect_ignore = [ # may need extra deps "docs/_ext", - # contains scripts to be run by tests/test_crawler.py::AsyncCrawlerProcessSubprocess + # contains scripts to be run by tests/test_crawler_subprocess.py::AsyncCrawlerProcessSubprocess *_py_files("tests/AsyncCrawlerProcess"), - # contains scripts to be run by tests/test_crawler.py::AsyncCrawlerRunnerSubprocess + # contains scripts to be run by tests/test_crawler_subprocess.py::AsyncCrawlerRunnerSubprocess *_py_files("tests/AsyncCrawlerRunner"), - # contains scripts to be run by tests/test_crawler.py::CrawlerProcessSubprocess + # contains scripts to be run by tests/test_crawler_subprocess.py::CrawlerProcessSubprocess *_py_files("tests/CrawlerProcess"), - # contains scripts to be run by tests/test_crawler.py::CrawlerRunnerSubprocess + # contains scripts to be run by tests/test_crawler_subprocess.py::CrawlerRunnerSubprocess *_py_files("tests/CrawlerRunner"), ] diff --git a/tests/test_crawler.py b/tests/test_crawler.py index 853d6cfaa..3cde38a6f 100644 --- a/tests/test_crawler.py +++ b/tests/test_crawler.py @@ -764,8 +764,8 @@ class TestCrawlerRunnerHasSpider: @pytest.mark.only_asyncio class TestAsyncCrawlerRunnerHasSpider(TestCrawlerRunnerHasSpider): - @staticmethod - def _runner() -> CrawlerRunnerBase: + @pytest.fixture + def runner(self) -> CrawlerRunnerBase: return AsyncCrawlerRunner(get_reactor_settings()) def test_crawler_runner_asyncio_enabled_true(self) -> None: # type: ignore[override] diff --git a/tests/test_zz_resources.py b/tests/test_zz_resources.py index a8292745d..b2ba013f1 100644 --- a/tests/test_zz_resources.py +++ b/tests/test_zz_resources.py @@ -15,7 +15,8 @@ from tests.utils.decorators import coroutine_test def test_counter_handler() -> None: """Test that ``LogCounterHandler`` is always properly removed. - It's added in ``Crawler.crawl{,_async}()`` and removed on engine_stopped. + It's added in ``LogCount.spider_opened()`` and removed in + ``LogCount.spider_closed()``. """ c = sum(1 for h in logging.root.handlers if isinstance(h, LogCounterHandler)) assert c == 0 diff --git a/tox.ini b/tox.ini index 602394761..e21e1f6cf 100644 --- a/tox.ini +++ b/tox.ini @@ -307,4 +307,4 @@ deps = mitmproxy; implementation_name != "pypy" httpx[http2,socks] commands = - pytest {posargs:--cov-config=pyproject.toml --cov=scrapy --cov-report=xml --cov-report= tests --junitxml=botocore.junit.xml -o junit_family=legacy} -m requires_mitmproxy + pytest {posargs:--cov-config=pyproject.toml --cov=scrapy --cov-report=xml --cov-report= tests --junitxml=mitmproxy.junit.xml -o junit_family=legacy} -m requires_mitmproxy From d59f9b644af2adcdd9842dc4ffc7505335427a7e Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Wed, 10 Jun 2026 14:12:53 +0500 Subject: [PATCH 174/248] Replace _SettingsKeyT with str. (#7586) --- scrapy/settings/__init__.py | 77 +++++++++++++-------------------- scrapy/spiders/__init__.py | 4 +- scrapy/utils/log.py | 4 +- tests/spiders.py | 3 +- tests/test_crawler.py | 4 +- tests/test_settings/__init__.py | 12 ++--- 6 files changed, 44 insertions(+), 60 deletions(-) diff --git a/scrapy/settings/__init__.py b/scrapy/settings/__init__.py index d4671fd35..be298e9b1 100644 --- a/scrapy/settings/__init__.py +++ b/scrapy/settings/__init__.py @@ -16,10 +16,6 @@ from scrapy.utils.python import global_object_name logger = getLogger(__name__) -# The key types are restricted in BaseSettings._get_key() to ones supported by JSON, -# see https://github.com/scrapy/scrapy/issues/5383. -_SettingsKey: TypeAlias = bool | float | int | str | None - if TYPE_CHECKING: from types import ModuleType @@ -29,7 +25,7 @@ if TYPE_CHECKING: # typing.Self requires Python 3.11 from typing_extensions import Self - _SettingsInput: TypeAlias = SupportsItems[_SettingsKey, Any] | str | None + _SettingsInput: TypeAlias = SupportsItems[str, Any] | str | None SETTINGS_PRIORITIES: dict[str, int] = { @@ -80,7 +76,7 @@ class SettingsAttribute: return f"" -class BaseSettings(MutableMapping[_SettingsKey, Any]): +class BaseSettings(MutableMapping[str, Any]): """ Instances of this class behave like dictionaries, but store priorities along with their ``(key, value)`` pairs, and can be frozen (i.e. marked @@ -106,11 +102,11 @@ class BaseSettings(MutableMapping[_SettingsKey, Any]): def __init__(self, values: _SettingsInput = None, priority: int | str = "project"): self.frozen: bool = False - self.attributes: dict[_SettingsKey, SettingsAttribute] = {} + self.attributes: dict[str, SettingsAttribute] = {} if values: self.update(values, priority) - def __getitem__(self, opt_name: _SettingsKey) -> Any: + def __getitem__(self, opt_name: str) -> Any: if opt_name not in self: return None return self.attributes[opt_name].value @@ -118,7 +114,7 @@ class BaseSettings(MutableMapping[_SettingsKey, Any]): def __contains__(self, name: Any) -> bool: return name in self.attributes - def add_to_list(self, name: _SettingsKey, item: Any) -> None: + def add_to_list(self, name: str, item: Any) -> None: """Append *item* to the :class:`list` setting with the specified *name* if *item* is not already in that list. @@ -129,7 +125,7 @@ class BaseSettings(MutableMapping[_SettingsKey, Any]): if item not in value: self.set(name, [*value, item], self.getpriority(name) or 0) - def remove_from_list(self, name: _SettingsKey, item: Any) -> None: + def remove_from_list(self, name: str, item: Any) -> None: """Remove *item* from the :class:`list` setting with the specified *name*. @@ -143,7 +139,7 @@ class BaseSettings(MutableMapping[_SettingsKey, Any]): raise ValueError(f"{item!r} not found in the {name} setting ({value!r}).") self.set(name, [v for v in value if v != item], self.getpriority(name) or 0) - def get(self, name: _SettingsKey, default: Any = None) -> Any: # pylint: disable=arguments-renamed + def get(self, name: str, default: Any = None) -> Any: # pylint: disable=arguments-renamed """ Get a setting value without affecting its original type. @@ -172,7 +168,7 @@ class BaseSettings(MutableMapping[_SettingsKey, Any]): return self[name] if self[name] is not None else default - def getbool(self, name: _SettingsKey, default: bool = False) -> bool: + def getbool(self, name: str, default: bool = False) -> bool: """ Get a setting value as a boolean. @@ -202,7 +198,7 @@ class BaseSettings(MutableMapping[_SettingsKey, Any]): "'True'/'False' and 'true'/'false'" ) from None - def getint(self, name: _SettingsKey, default: int = 0) -> int: + def getint(self, name: str, default: int = 0) -> int: """ Get a setting value as an int. @@ -214,7 +210,7 @@ class BaseSettings(MutableMapping[_SettingsKey, Any]): """ return int(self.get(name, default)) - def getfloat(self, name: _SettingsKey, default: float = 0.0) -> float: + def getfloat(self, name: str, default: float = 0.0) -> float: """ Get a setting value as a float. @@ -226,9 +222,7 @@ class BaseSettings(MutableMapping[_SettingsKey, Any]): """ return float(self.get(name, default)) - def getlist( - self, name: _SettingsKey, default: list[Any] | None = None - ) -> list[Any]: + def getlist(self, name: str, default: list[Any] | None = None) -> list[Any]: """ Get a setting value as a list. If the setting original type is a list, a copy of it will be returned. If it's a string it will be split by @@ -251,7 +245,7 @@ class BaseSettings(MutableMapping[_SettingsKey, Any]): return list(value) def getdict( - self, name: _SettingsKey, default: dict[Any, Any] | None = None + self, name: str, default: dict[Any, Any] | None = None ) -> dict[Any, Any]: """ Get a setting value as a dictionary. If the setting original type is a @@ -275,7 +269,7 @@ class BaseSettings(MutableMapping[_SettingsKey, Any]): def getdictorlist( self, - name: _SettingsKey, + name: str, default: dict[Any, Any] | list[Any] | tuple[Any] | None = None, ) -> dict[Any, Any] | list[Any]: """Get a setting value as either a :class:`dict` or a :class:`list`. @@ -322,7 +316,7 @@ class BaseSettings(MutableMapping[_SettingsKey, Any]): ) return copy.deepcopy(value) - def getwithbase(self, name: _SettingsKey) -> BaseSettings: + def getwithbase(self, name: str) -> BaseSettings: """Get a composition of a dictionary-like setting and its ``_BASE`` counterpart. @@ -341,7 +335,7 @@ class BaseSettings(MutableMapping[_SettingsKey, Any]): compbs.update(self[name]) return compbs - def get_component_priority_dict_with_base(self, name: _SettingsKey) -> BaseSettings: + def get_component_priority_dict_with_base(self, name: str) -> BaseSettings: """Get a composition of a component priority dictionary setting and its ``_BASE`` counterpart. @@ -389,7 +383,7 @@ class BaseSettings(MutableMapping[_SettingsKey, Any]): {restore_key(k): v for k, v in result.items() if v is not None} ) - def getpriority(self, name: _SettingsKey) -> int | None: + def getpriority(self, name: str) -> int | None: """ Return the current numerical priority value of a setting, or ``None`` if the given ``name`` does not exist. @@ -414,7 +408,7 @@ class BaseSettings(MutableMapping[_SettingsKey, Any]): def replace_in_component_priority_dict( self, - name: _SettingsKey, + name: str, old_cls: type, new_cls: type, priority: int | None = None, @@ -453,12 +447,10 @@ class BaseSettings(MutableMapping[_SettingsKey, Any]): ) self.set(name, component_priority_dict, priority=self.getpriority(name) or 0) - def __setitem__(self, name: _SettingsKey, value: Any) -> None: + def __setitem__(self, name: str, value: Any) -> None: self.set(name, value) - def set( - self, name: _SettingsKey, value: Any, priority: int | str = "project" - ) -> None: + def set(self, name: str, value: Any, priority: int | str = "project") -> None: """ Store a key/value attribute with a given priority. @@ -487,7 +479,7 @@ class BaseSettings(MutableMapping[_SettingsKey, Any]): self.attributes[name].set(value, priority) def set_in_component_priority_dict( - self, name: _SettingsKey, cls: type, priority: int | None + self, name: str, cls: type, priority: int | None ) -> None: """Set the *cls* component in the *name* :ref:`component priority dictionary ` setting with *priority*. @@ -512,7 +504,7 @@ class BaseSettings(MutableMapping[_SettingsKey, Any]): def setdefault( # pylint: disable=arguments-renamed self, - name: _SettingsKey, + name: str, default: Any = None, priority: int | str = "project", ) -> Any: @@ -523,7 +515,7 @@ class BaseSettings(MutableMapping[_SettingsKey, Any]): return self.attributes[name].value def setdefault_in_component_priority_dict( - self, name: _SettingsKey, cls: type, priority: int | None + self, name: str, cls: type, priority: int | None ) -> None: """Set the *cls* component in the *name* :ref:`component priority dictionary ` setting with *priority* @@ -592,7 +584,7 @@ class BaseSettings(MutableMapping[_SettingsKey, Any]): """ self._assert_mutability() if isinstance(values, str): - values = cast("dict[_SettingsKey, Any]", json.loads(values)) + values = cast("dict[str, Any]", json.loads(values)) if values is not None: if isinstance(values, BaseSettings): for name, value in values.items(): @@ -601,7 +593,7 @@ class BaseSettings(MutableMapping[_SettingsKey, Any]): for name, value in values.items(): self.set(name, value, priority) - def delete(self, name: _SettingsKey, priority: int | str = "project") -> None: + def delete(self, name: str, priority: int | str = "project") -> None: if name not in self: raise KeyError(name) self._assert_mutability() @@ -609,7 +601,7 @@ class BaseSettings(MutableMapping[_SettingsKey, Any]): if priority >= cast("int", self.getpriority(name)): del self.attributes[name] - def __delitem__(self, name: _SettingsKey) -> None: + def __delitem__(self, name: str) -> None: self._assert_mutability() del self.attributes[name] @@ -649,26 +641,19 @@ class BaseSettings(MutableMapping[_SettingsKey, Any]): copy.freeze() return copy - def __iter__(self) -> Iterator[_SettingsKey]: + def __iter__(self) -> Iterator[str]: return iter(self.attributes) def __len__(self) -> int: return len(self.attributes) - def _to_dict(self) -> dict[_SettingsKey, Any]: + def _to_dict(self) -> dict[str, Any]: return { - self._get_key(k): (v._to_dict() if isinstance(v, BaseSettings) else v) + str(k): (v._to_dict() if isinstance(v, BaseSettings) else v) for k, v in self.items() } - def _get_key(self, key_value: Any) -> _SettingsKey: - return ( - key_value - if isinstance(key_value, (bool, float, int, str, type(None))) - else str(key_value) - ) - - def copy_to_dict(self) -> dict[_SettingsKey, Any]: + def copy_to_dict(self) -> dict[str, Any]: """ Make a copy of current settings and convert to a dict. @@ -691,7 +676,7 @@ class BaseSettings(MutableMapping[_SettingsKey, Any]): else: p.text(pformat(self.copy_to_dict())) - def pop(self, name: _SettingsKey, default: Any = __default) -> Any: # pylint: disable=arguments-renamed + def pop(self, name: str, default: Any = __default) -> Any: # pylint: disable=arguments-renamed try: value = self.attributes[name].value except KeyError: @@ -735,7 +720,7 @@ def iter_default_settings() -> Iterable[tuple[str, Any]]: def overridden_settings( - settings: Mapping[_SettingsKey, Any], + settings: Mapping[str, Any], ) -> Iterable[tuple[str, Any]]: """Return an iterable of the settings that have been overridden""" for name, defvalue in iter_default_settings(): diff --git a/scrapy/spiders/__init__.py b/scrapy/spiders/__init__.py index 299a5d43f..6e8c67adb 100644 --- a/scrapy/spiders/__init__.py +++ b/scrapy/spiders/__init__.py @@ -24,7 +24,7 @@ if TYPE_CHECKING: from scrapy.crawler import Crawler from scrapy.http.request import CallbackT - from scrapy.settings import BaseSettings, _SettingsKey + from scrapy.settings import BaseSettings from scrapy.utils.log import SpiderLoggerAdapter @@ -37,7 +37,7 @@ class Spider(object_ref): """ name: str - custom_settings: dict[_SettingsKey, Any] | None = None + custom_settings: dict[str, Any] | None = None #: Start URLs. See :meth:`start`. start_urls: list[str] diff --git a/scrapy/utils/log.py b/scrapy/utils/log.py index ee65d4155..09b67805d 100644 --- a/scrapy/utils/log.py +++ b/scrapy/utils/log.py @@ -12,7 +12,7 @@ from twisted.python import log as twisted_log from twisted.python.failure import Failure import scrapy -from scrapy.settings import Settings, _SettingsKey +from scrapy.settings import Settings from scrapy.utils.versions import get_versions if TYPE_CHECKING: @@ -89,7 +89,7 @@ DEFAULT_LOGGING = { def configure_logging( - settings: Settings | dict[_SettingsKey, Any] | None = None, + settings: Settings | dict[str, Any] | None = None, install_root_handler: bool = True, ) -> None: """ diff --git a/tests/spiders.py b/tests/spiders.py index 065a0e6d7..55d1ea365 100644 --- a/tests/spiders.py +++ b/tests/spiders.py @@ -22,7 +22,6 @@ from scrapy.utils.defer import deferred_to_future, maybe_deferred_to_future from scrapy.utils.test import get_from_asyncio_queue if TYPE_CHECKING: - from scrapy.settings import _SettingsKey from tests.mockserver.http import MockServer @@ -418,7 +417,7 @@ class CrawlSpiderWithParseMethod(MockServerSpider, CrawlSpider): """ name = "crawl_spider_with_parse_method" - custom_settings: dict[_SettingsKey, Any] = { + custom_settings: dict[str, Any] = { "RETRY_HTTP_CODES": [], # no need to retry } rules = (Rule(LinkExtractor(), callback="parse", follow=True),) diff --git a/tests/test_crawler.py b/tests/test_crawler.py index 3cde38a6f..82956735b 100644 --- a/tests/test_crawler.py +++ b/tests/test_crawler.py @@ -22,7 +22,7 @@ from scrapy.crawler import ( ) from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.extensions.throttle import AutoThrottle -from scrapy.settings import Settings, _SettingsKey, default_settings +from scrapy.settings import Settings, default_settings from scrapy.utils.defer import ensure_awaitable, maybe_deferred_to_future from scrapy.utils.log import ( _uninstall_scrapy_root_handler, @@ -56,7 +56,7 @@ class TestBaseCrawler: class TestCrawler(TestBaseCrawler): def test_populate_spidercls_settings(self) -> None: - spider_settings: dict[_SettingsKey, Any] = { + spider_settings: dict[str, Any] = { "TEST1": "spider", "TEST2": "spider", } diff --git a/tests/test_settings/__init__.py b/tests/test_settings/__init__.py index 1d4c3d487..7b3c52d65 100644 --- a/tests/test_settings/__init__.py +++ b/tests/test_settings/__init__.py @@ -384,15 +384,15 @@ class TestBaseSettings: "TEST_STRING": "a string", "TEST_LIST": [1, 2], "TEST_BOOLEAN": False, - "TEST_BASE": BaseSettings({1: 1, 2: 2}, "project"), - "TEST": BaseSettings({1: 10, 3: 30}, "default"), - "HASNOBASE": BaseSettings({3: 3000}, "default"), + "TEST_BASE": BaseSettings({"foo": 1, "bar": 2}, "project"), + "TEST": BaseSettings({"foo": 10, "baz": 30}, "default"), + "HASNOBASE": BaseSettings({"baz": 3000}, "default"), } ) assert s.copy_to_dict() == { - "HASNOBASE": {3: 3000}, - "TEST": {1: 10, 3: 30}, - "TEST_BASE": {1: 1, 2: 2}, + "HASNOBASE": {"baz": 3000}, + "TEST": {"foo": 10, "baz": 30}, + "TEST_BASE": {"foo": 1, "bar": 2}, "TEST_LIST": [1, 2], "TEST_BOOLEAN": False, "TEST_STRING": "a string", From beb6c51c173e16046dfbefa49e6637c10cd16407 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Thu, 11 Jun 2026 00:22:09 +0500 Subject: [PATCH 175/248] Update default_settings, deprecate CRAWLSPIDER_FOLLOW_LINKS. (#7592) --- docs/topics/media-pipeline.rst | 5 ++- scrapy/settings/default_settings.py | 47 ++++++++++++++++++++++++++--- scrapy/spiders/crawl.py | 8 +++++ tests/test_spider_crawl.py | 2 ++ 4 files changed, 54 insertions(+), 8 deletions(-) diff --git a/docs/topics/media-pipeline.rst b/docs/topics/media-pipeline.rst index 037fe87fa..b542c6c05 100644 --- a/docs/topics/media-pipeline.rst +++ b/docs/topics/media-pipeline.rst @@ -81,9 +81,6 @@ thumbnailing and normalizing images to JPEG/RGB format. Enabling your Media Pipeline ============================ -.. setting:: IMAGES_STORE -.. setting:: FILES_STORE - To enable your media pipeline you must first add it to your project :setting:`ITEM_PIPELINES` setting. @@ -102,6 +99,8 @@ For Files Pipeline, use: .. note:: You can also use both the Files and Images Pipeline at the same time. +.. setting:: IMAGES_STORE +.. setting:: FILES_STORE Then, configure the target storage setting to a valid value that will be used for storing the downloaded images. Otherwise the pipeline will remain disabled, diff --git a/scrapy/settings/default_settings.py b/scrapy/settings/default_settings.py index 7d5612026..e8a9400d7 100644 --- a/scrapy/settings/default_settings.py +++ b/scrapy/settings/default_settings.py @@ -20,19 +20,26 @@ from typing import Any __all__ = [ "ADDONS", - "AJAXCRAWL_ENABLED", - "AJAXCRAWL_MAXSIZE", "ASYNCIO_EVENT_LOOP", "AUTOTHROTTLE_DEBUG", "AUTOTHROTTLE_ENABLED", "AUTOTHROTTLE_MAX_DELAY", "AUTOTHROTTLE_START_DELAY", "AUTOTHROTTLE_TARGET_CONCURRENCY", + "AWS_ACCESS_KEY_ID", + "AWS_ENDPOINT_URL", + "AWS_REGION_NAME", + "AWS_SECRET_ACCESS_KEY", + "AWS_SESSION_TOKEN", + "AWS_USE_SSL", + "AWS_VERIFY", "BOT_NAME", "CLOSESPIDER_ERRORCOUNT", "CLOSESPIDER_ITEMCOUNT", "CLOSESPIDER_PAGECOUNT", + "CLOSESPIDER_PAGECOUNT_NO_ITEM", "CLOSESPIDER_TIMEOUT", + "CLOSESPIDER_TIMEOUT_NO_ITEM", "COMMANDS_MODULE", "COMPRESSION_ENABLED", "CONCURRENT_ITEMS", @@ -64,11 +71,14 @@ __all__ = [ "DOWNLOAD_HANDLERS", "DOWNLOAD_HANDLERS_BASE", "DOWNLOAD_MAXSIZE", + "DOWNLOAD_SLOTS", "DOWNLOAD_TIMEOUT", "DOWNLOAD_TLS_MAX_VERSION", "DOWNLOAD_TLS_MIN_VERSION", + "DOWNLOAD_VERIFY_CERTIFICATES", "DOWNLOAD_WARNSIZE", "DUPEFILTER_CLASS", + "DUPEFILTER_DEBUG", "EDITOR", "EXTENSIONS", "EXTENSIONS_BASE", @@ -87,7 +97,9 @@ __all__ = [ "FEED_STORAGE_S3_ACL", "FEED_STORE_EMPTY", "FEED_TEMPDIR", + "FEED_URI", "FEED_URI_PARAMS", + "FILES_STORE", "FILES_STORE_GCS_ACL", "FILES_STORE_S3_ACL", "FORCE_CRAWLER_PROCESS", @@ -107,9 +119,12 @@ __all__ = [ "HTTPCACHE_IGNORE_SCHEMES", "HTTPCACHE_POLICY", "HTTPCACHE_STORAGE", + "HTTPERROR_ALLOWED_CODES", + "HTTPERROR_ALLOW_ALL", "HTTPPROXY_AUTH_ENCODING", "HTTPPROXY_ENABLED", "HTTPX_HTTP2_ENABLED", + "IMAGES_STORE", "IMAGES_STORE_GCS_ACL", "IMAGES_STORE_S3_ACL", "ITEM_PIPELINES", @@ -132,6 +147,8 @@ __all__ = [ "MAIL_HOST", "MAIL_PASS", "MAIL_PORT", + "MAIL_SSL", + "MAIL_TLS", "MAIL_USER", "MEMDEBUG_ENABLED", "MEMDEBUG_NOTIFY", @@ -153,6 +170,7 @@ __all__ = [ "REDIRECT_MAX_TIMES", "REDIRECT_PRIORITY_ADJUST", "REFERER_ENABLED", + "REFERRER_POLICIES", "REFERRER_POLICY", "REQUEST_FINGERPRINTER_CLASS", "RETRY_ENABLED", @@ -198,9 +216,6 @@ __all__ = [ ADDONS = {} -AJAXCRAWL_ENABLED = False -AJAXCRAWL_MAXSIZE = 32768 - ASYNCIO_EVENT_LOOP = None AUTOTHROTTLE_ENABLED = False @@ -209,12 +224,22 @@ AUTOTHROTTLE_MAX_DELAY = 60.0 AUTOTHROTTLE_START_DELAY = 5.0 AUTOTHROTTLE_TARGET_CONCURRENCY = 1.0 +AWS_ACCESS_KEY_ID = None +AWS_SECRET_ACCESS_KEY = None +AWS_ENDPOINT_URL = None +AWS_REGION_NAME = None +AWS_SESSION_TOKEN = None +AWS_USE_SSL = None +AWS_VERIFY = None + BOT_NAME = "scrapybot" CLOSESPIDER_ERRORCOUNT = 0 CLOSESPIDER_ITEMCOUNT = 0 CLOSESPIDER_PAGECOUNT = 0 CLOSESPIDER_TIMEOUT = 0 +CLOSESPIDER_PAGECOUNT_NO_ITEM = 0 +CLOSESPIDER_TIMEOUT_NO_ITEM = 0 COMMANDS_MODULE = "" @@ -267,6 +292,8 @@ DOWNLOAD_HANDLERS_BASE = { DOWNLOAD_MAXSIZE = 1024 * 1024 * 1024 # 1024m DOWNLOAD_WARNSIZE = 32 * 1024 * 1024 # 32m +DOWNLOAD_SLOTS = {} + DOWNLOAD_TIMEOUT = 180 # 3mins DOWNLOAD_TLS_MAX_VERSION = None @@ -304,6 +331,7 @@ DOWNLOADER_MIDDLEWARES_BASE = { DOWNLOADER_STATS = True DUPEFILTER_CLASS = "scrapy.dupefilters.RFPDupeFilter" +DUPEFILTER_DEBUG = False EDITOR = "vi" if sys.platform == "win32": @@ -354,8 +382,10 @@ FEED_STORAGE_FTP_ACTIVE = False FEED_STORAGE_GCS_ACL = "" FEED_STORAGE_S3_ACL = "" FEED_TEMPDIR = None +FEED_URI = None FEED_URI_PARAMS = None # a function to extend uri arguments +FILES_STORE = None FILES_STORE_GCS_ACL = "" FILES_STORE_S3_ACL = "private" @@ -380,11 +410,15 @@ HTTPCACHE_IGNORE_SCHEMES = ["file"] HTTPCACHE_POLICY = "scrapy.extensions.httpcache.DummyPolicy" HTTPCACHE_STORAGE = "scrapy.extensions.httpcache.FilesystemCacheStorage" +HTTPERROR_ALLOW_ALL = False +HTTPERROR_ALLOWED_CODES = [] + HTTPPROXY_ENABLED = True HTTPPROXY_AUTH_ENCODING = "latin-1" HTTPX_HTTP2_ENABLED = False +IMAGES_STORE = None IMAGES_STORE_GCS_ACL = "" IMAGES_STORE_S3_ACL = "private" @@ -425,6 +459,8 @@ MAIL_HOST = "localhost" MAIL_PORT = 25 MAIL_USER = None MAIL_PASS = None +MAIL_SSL = False +MAIL_TLS = False MEMDEBUG_ENABLED = False # enable memory debugging MEMDEBUG_NOTIFY = [] # send memory debugging report by mail at engine shutdown @@ -455,6 +491,7 @@ REDIRECT_PRIORITY_ADJUST = +2 REFERER_ENABLED = True REFERRER_POLICY = "scrapy.spidermiddlewares.referer.DefaultReferrerPolicy" +REFERRER_POLICIES = {} REQUEST_FINGERPRINTER_CLASS = "scrapy.utils.request.RequestFingerprinter" diff --git a/scrapy/spiders/crawl.py b/scrapy/spiders/crawl.py index e14279b64..d2da31f35 100644 --- a/scrapy/spiders/crawl.py +++ b/scrapy/spiders/crawl.py @@ -12,6 +12,7 @@ import warnings from collections.abc import AsyncIterator, Awaitable, Callable from typing import TYPE_CHECKING, Any, TypeAlias, TypeVar, cast +from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.http import HtmlResponse, Request, Response from scrapy.link import Link from scrapy.linkextractors import LinkExtractor @@ -220,4 +221,11 @@ class CrawlSpider(Spider): def from_crawler(cls, crawler: Crawler, *args: Any, **kwargs: Any) -> Self: spider = super().from_crawler(crawler, *args, **kwargs) spider._follow_links = crawler.settings.getbool("CRAWLSPIDER_FOLLOW_LINKS") + if not spider._follow_links: + warnings.warn( + "The CRAWLSPIDER_FOLLOW_LINKS setting is deprecated." + " You can set follow=False in your rules to achieve the same effect.", + category=ScrapyDeprecationWarning, + stacklevel=2, + ) return spider diff --git a/tests/test_spider_crawl.py b/tests/test_spider_crawl.py index 05d77912f..c97934b74 100644 --- a/tests/test_spider_crawl.py +++ b/tests/test_spider_crawl.py @@ -4,6 +4,7 @@ import re import warnings from logging import ERROR +import pytest from testfixtures import LogCapture from w3lib.url import safe_url_string @@ -232,6 +233,7 @@ class TestCrawlSpider(TestSpider): "HtmlResponse", ] + @pytest.mark.filterwarnings("ignore::scrapy.exceptions.ScrapyDeprecationWarning") def test_follow_links_attribute_population(self): crawler = get_crawler() spider = self.spider_class.from_crawler(crawler, "example.com") From cd25ece58a551be6fbf20d8675f4d7c97bb3d6a0 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Thu, 11 Jun 2026 00:22:30 +0500 Subject: [PATCH 176/248] Fix the check for sync ITEM_PROCESSOR methods. (#7589) * Fix the check for sync ITEM_PROCESSOR methods. * Typo. --- scrapy/core/scraper.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scrapy/core/scraper.py b/scrapy/core/scraper.py index e756d27eb..466ce656d 100644 --- a/scrapy/core/scraper.py +++ b/scrapy/core/scraper.py @@ -125,7 +125,7 @@ class Scraper: def _check_deprecated_itemproc_method(self, method: str) -> None: itemproc_cls = type(self.itemproc) - if not hasattr(self.itemproc, "process_item_async"): + if not hasattr(self.itemproc, f"{method}_async"): warnings.warn( f"{global_object_name(itemproc_cls)} doesn't define a {method}_async() method," f" this is deprecated and the method will be required in future Scrapy versions.", From 93a627ba1c9af14dd7a5a7cd0f3d258811e1be1f Mon Sep 17 00:00:00 2001 From: msn <82942483+msn-2006@users.noreply.github.com> Date: Thu, 11 Jun 2026 01:29:03 +0530 Subject: [PATCH 177/248] Prevent scrapy shell from starting spider requests (#7557) * Prevent scrapy shell from starting spider requests * Run pre-commit fixes * Update shell architecture notes --------- Co-authored-by: Mayuresh --- scrapy/shell.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/scrapy/shell.py b/scrapy/shell.py index 44e542470..0bd71feb2 100644 --- a/scrapy/shell.py +++ b/scrapy/shell.py @@ -28,11 +28,7 @@ from scrapy.spiders import Spider from scrapy.utils.conf import get_config from scrapy.utils.console import DEFAULT_PYTHON_SHELLS, start_python_console from scrapy.utils.datatypes import SequenceExclude -from scrapy.utils.defer import ( - _schedule_coro, - deferred_f_from_coro_f, - maybe_deferred_to_future, -) +from scrapy.utils.defer import deferred_f_from_coro_f, maybe_deferred_to_future from scrapy.utils.misc import load_object from scrapy.utils.reactor import is_asyncio_reactor_installed, set_asyncio_event_loop from scrapy.utils.response import open_in_browser @@ -69,8 +65,8 @@ if TYPE_CHECKING: # 3. When fetch() is called, it prepares a request and calls Shell._schedule() # in the reactor thread (via threads.blockingCallFromThread()). # 4. Shell._schedule() calls Shell._open_spider() (on the first call). -# 5. Shell._open_spider() calls engine.open_spider_async(close_if_idle=False) -# and engine._start_request_processing(). +# 5. Shell._open_spider() calls +# engine.open_spider_async(close_if_idle=False). # 6. Shell._schedule() calls engine.crawl(request), scheduling the request. # 7. Shell._schedule() via _request_deferred() waits until the request callback # is called. When it's called, the response becomes available. @@ -214,7 +210,6 @@ class Shell: self.crawler.spider = spider assert self.crawler.engine await self.crawler.engine.open_spider_async(close_if_idle=False) - _schedule_coro(self.crawler.engine._start_request_processing()) self.spider = spider def fetch( From 2d0a898e2dc50b2d5d458346a9329c29bf7e759c Mon Sep 17 00:00:00 2001 From: Adrian Date: Thu, 11 Jun 2026 09:10:48 +0200 Subject: [PATCH 178/248] =?UTF-8?q?tox.ini:=20pinned,=20typing=20=E2=86=92?= =?UTF-8?q?=20min,=20mypy=20(#7595)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/checks.yml | 4 +- .github/workflows/tests-ubuntu.yml | 16 ++--- .github/workflows/tests-windows.yml | 6 +- tests/test_dependencies.py | 6 +- tox.ini | 92 ++++++++++++++++++----------- 5 files changed, 74 insertions(+), 50 deletions(-) diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 49ea3277a..ed2388a59 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -22,10 +22,10 @@ jobs: TOXENV: pylint - python-version: "3.10" env: - TOXENV: typing + TOXENV: mypy - python-version: "3.10" env: - TOXENV: typing-tests + TOXENV: mypy-tests # Keep in sync with pyproject.toml tool.sphinx-scrapy.python-version. - python-version: "3.14" env: diff --git a/.github/workflows/tests-ubuntu.yml b/.github/workflows/tests-ubuntu.yml index 524c79cdb..f51dd9799 100644 --- a/.github/workflows/tests-ubuntu.yml +++ b/.github/workflows/tests-ubuntu.yml @@ -45,26 +45,26 @@ jobs: env: TOXENV: pypy3 - # pinned deps + # min deps - python-version: "3.10.19" env: - TOXENV: pinned + TOXENV: min - python-version: "3.10.19" env: - TOXENV: default-reactor-pinned + TOXENV: min-default-reactor - python-version: "3.10.19" env: - TOXENV: no-reactor-pinned + TOXENV: min-no-reactor # pinned due to https://github.com/pypy/pypy/issues/5388 - python-version: pypy3.11-7.3.20 env: - TOXENV: pypy3-pinned + TOXENV: min-pypy3 - python-version: "3.10.19" env: - TOXENV: extra-deps-pinned + TOXENV: min-extra-deps - python-version: "3.10.19" env: - TOXENV: botocore-pinned + TOXENV: min-botocore - python-version: "3.14" env: @@ -92,7 +92,7 @@ jobs: python-version: ${{ matrix.python-version }} - name: Install system libraries - if: contains(matrix.python-version, 'pypy') || contains(matrix.env.TOXENV, 'pinned') + if: contains(matrix.python-version, 'pypy') || contains(matrix.env.TOXENV, 'min') run: | sudo apt-get update sudo apt-get install libxml2-dev libxslt-dev diff --git a/.github/workflows/tests-windows.yml b/.github/workflows/tests-windows.yml index 840c7f68e..f413782bc 100644 --- a/.github/workflows/tests-windows.yml +++ b/.github/workflows/tests-windows.yml @@ -41,13 +41,13 @@ jobs: env: TOXENV: no-reactor - # pinned deps + # min deps - python-version: "3.10.11" env: - TOXENV: pinned + TOXENV: min - python-version: "3.10.11" env: - TOXENV: extra-deps-pinned + TOXENV: min-extra-deps - python-version: "3.14" env: diff --git a/tests/test_dependencies.py b/tests/test_dependencies.py index 4436efd9b..578e84359 100644 --- a/tests/test_dependencies.py +++ b/tests/test_dependencies.py @@ -15,14 +15,14 @@ class TestScrapyUtils: See https://github.com/scrapy/scrapy/pull/4814#issuecomment-706230011 """ - if not os.environ.get("_SCRAPY_PINNED", None): - pytest.skip("Not in a pinned environment") + if not os.environ.get("_SCRAPY_MIN", None): + pytest.skip("Not in a min environment") tox_config_file_path = Path(__file__).parent / ".." / "tox.ini" config_parser = ConfigParser() config_parser.read(tox_config_file_path) pattern = r"Twisted==([\d.]+)" - match = re.search(pattern, config_parser["pinned"]["deps"]) + match = re.search(pattern, config_parser["min"]["deps"]) pinned_twisted_version_string = match[1] assert twisted_version.short() == pinned_twisted_version_string diff --git a/tox.ini b/tox.ini index e21e1f6cf..50ac7aa86 100644 --- a/tox.ini +++ b/tox.ini @@ -6,7 +6,31 @@ [tox] requires = sphinx-scrapy[tox] @ git+https://github.com/scrapy/sphinx-scrapy.git@0.8.6 -envlist = pre-commit,pylint,typing,py,docs +envlist = + pre-commit + pylint + mypy + mypy-tests + twinecheck + docs + docs-tests + docs-links + docs-coverage + min + min-extra-deps + min-default-reactor + min-no-reactor + min-botocore + min-pypy3 + py{310,311,312,313,314} + extra-deps + default-reactor + no-reactor + no-reactor-extra-deps + botocore + mitmproxy + pypy3 + pypy3-extra-deps minversion = 1.7.0 [test-requirements] @@ -41,7 +65,7 @@ download = true commands = pytest {posargs:--cov-config=pyproject.toml --cov=scrapy --cov-report= --cov-report=term-missing --cov-report=xml --junitxml=testenv.junit.xml -o junit_family=legacy --durations=10 scrapy tests --doctest-modules} -[testenv:typing] +[testenv:mypy] basepython = python3.10 deps = mypy==2.1.0 @@ -71,11 +95,11 @@ deps = commands = mypy {posargs:scrapy tests} -[testenv:typing-tests] +[testenv:mypy-tests] basepython = python3.10 deps = {[test-requirements]deps} - {[testenv:typing]deps} + {[testenv:mypy]deps} pytest-mypy-testing==0.2.0 commands = pytest {posargs:tests_typing} @@ -105,7 +129,7 @@ commands = python -m build --sdist twine check dist/* -[pinned] +[min] basepython = python3.10 deps = # pytest 8.4.1 adds support for Twisted 25.5.0 but drops support for Twisted < 24.10.0 @@ -125,18 +149,18 @@ deps = zope.interface==5.1.0 {[test-requirements]deps} setenv = - _SCRAPY_PINNED=true + _SCRAPY_MIN=true commands = - pytest {posargs:--cov-config=pyproject.toml --cov=scrapy --cov-report=xml --cov-report= --junitxml=pinned.junit.xml -o junit_family=legacy --durations=10 scrapy tests} + pytest {posargs:--cov-config=pyproject.toml --cov=scrapy --cov-report=xml --cov-report= --junitxml=min.junit.xml -o junit_family=legacy --durations=10 scrapy tests} -[testenv:pinned] -basepython = {[pinned]basepython} +[testenv:min] +basepython = {[min]basepython} deps = - {[pinned]deps} + {[min]deps} PyDispatcher==2.0.5 setenv = - {[pinned]setenv} -commands = {[pinned]commands} + {[min]setenv} +commands = {[min]commands} [testenv:extra-deps] basepython = python3 @@ -155,10 +179,10 @@ deps = uvloop; platform_system != "Windows" and implementation_name != "pypy" zstandard; implementation_name != "pypy" # optional for HTTP compress downloader middleware tests -[testenv:extra-deps-pinned] -basepython = {[pinned]basepython} +[testenv:min-extra-deps] +basepython = {[min]basepython} deps = - {[pinned]deps} + {[min]deps} Pillow==8.3.2 Twisted[http2]==21.7.0 boto3==1.20.0 @@ -172,19 +196,19 @@ deps = uvloop==0.16.0; platform_system != "Windows" and implementation_name != "pypy" zstandard==0.16.0; implementation_name != "pypy" setenv = - {[pinned]setenv} -commands = {[pinned]commands} + {[min]setenv} +commands = {[min]commands} [testenv:default-reactor] commands = {[testenv]commands} --reactor=default -[testenv:default-reactor-pinned] -basepython = {[pinned]basepython} -deps = {[testenv:pinned]deps} -commands = {[pinned]commands} --reactor=default +[testenv:min-default-reactor] +basepython = {[min]basepython} +deps = {[testenv:min]deps} +commands = {[min]commands} --reactor=default setenv = - {[pinned]setenv} + {[min]setenv} [testenv:no-reactor] deps = @@ -200,14 +224,14 @@ deps = commands = {[testenv]commands} -p no:twisted --reactor=none -[testenv:no-reactor-pinned] -basepython = {[pinned]basepython} +[testenv:min-no-reactor] +basepython = {[min]basepython} deps = - {[testenv:pinned]deps} + {[testenv:min]deps} pytest-asyncio -commands = {[pinned]commands} -p no:twisted --reactor=none +commands = {[min]commands} -p no:twisted --reactor=none setenv = - {[pinned]setenv} + {[min]setenv} [testenv:pypy3] basepython = pypy3 @@ -221,7 +245,7 @@ deps = {[testenv:extra-deps]deps} commands = {[testenv:pypy3]commands} -[testenv:pypy3-pinned] +[testenv:min-pypy3] basepython = pypy3.11 deps = PyPyDispatcher==2.1.0 @@ -248,7 +272,7 @@ commands = ; disabling coverage pytest {posargs:--durations=10 scrapy tests} setenv = - {[pinned]setenv} + {[min]setenv} [testenv:docs-tests] changedir = docs @@ -283,17 +307,17 @@ deps = commands = pytest {posargs:--cov-config=pyproject.toml --cov=scrapy --cov-report=xml --cov-report= tests --junitxml=botocore.junit.xml -o junit_family=legacy} -m requires_botocore -[testenv:botocore-pinned] -basepython = {[pinned]basepython} +[testenv:min-botocore] +basepython = {[min]basepython} deps = - {[pinned]deps} + {[min]deps} botocore==1.13.45 # botocore 1.13.45 requires urllib3>=1.20,<1.26; requests 2.33.0 requires urllib3>=1.26,<3 requests<2.33.0 setenv = - {[pinned]setenv} + {[min]setenv} commands = - pytest {posargs:--cov-config=pyproject.toml --cov=scrapy --cov-report=xml --cov-report= tests --junitxml=botocore-pinned.junit.xml -o junit_family=legacy} -m requires_botocore + pytest {posargs:--cov-config=pyproject.toml --cov=scrapy --cov-report=xml --cov-report= tests --junitxml=min-botocore.junit.xml -o junit_family=legacy} -m requires_botocore # Run proxy tests that use mitmproxy in a separate env to avoid installing From ba28630c981203e996114cfc4f12b484d2f021e2 Mon Sep 17 00:00:00 2001 From: Syncrain <71864702+syncrain@users.noreply.github.com> Date: Thu, 11 Jun 2026 13:17:00 +0530 Subject: [PATCH 179/248] Validate reversed telnet console port ranges (#7593) --- scrapy/utils/reactor.py | 2 ++ tests/test_extension_telnet.py | 6 ++++++ 2 files changed, 8 insertions(+) diff --git a/scrapy/utils/reactor.py b/scrapy/utils/reactor.py index 7ab58093a..c60bb215d 100644 --- a/scrapy/utils/reactor.py +++ b/scrapy/utils/reactor.py @@ -33,6 +33,8 @@ def listen_tcp(portrange: list[int], host: str, factory: ServerFactory) -> Port: if len(portrange) > 2: raise ValueError(f"invalid portrange: {portrange}") + if len(portrange) == 2 and portrange[0] > portrange[1]: + raise ValueError(f"invalid portrange: {portrange}") if not portrange: return reactor.listenTCP(0, factory, interface=host) # type: ignore[no-any-return] if len(portrange) == 1: diff --git a/tests/test_extension_telnet.py b/tests/test_extension_telnet.py index f1c86ce62..3f9135867 100644 --- a/tests/test_extension_telnet.py +++ b/tests/test_extension_telnet.py @@ -53,3 +53,9 @@ class TestTelnetExtension: d = portal.login(creds, None, ITelnetProtocol) yield d console.stop_listening() + + def test_invalid_reversed_portrange(self): + settings = {"TELNETCONSOLE_PORT": [2, 1]} + console = TelnetConsole(get_crawler(settings_dict=settings)) + with pytest.raises(ValueError, match=r"invalid portrange: \[2, 1\]"): + console.start_listening() From ad4549673bdc1a99175ca478ab3a2fea1d4a8a55 Mon Sep 17 00:00:00 2001 From: msn <82942483+msn-2006@users.noreply.github.com> Date: Thu, 11 Jun 2026 14:15:25 +0530 Subject: [PATCH 180/248] Remove set_asyncio_event_loop call from Shell._schedule (#7594) Co-authored-by: Mayuresh --- scrapy/shell.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/scrapy/shell.py b/scrapy/shell.py index 0bd71feb2..8bb5994e3 100644 --- a/scrapy/shell.py +++ b/scrapy/shell.py @@ -30,7 +30,6 @@ from scrapy.utils.console import DEFAULT_PYTHON_SHELLS, start_python_console from scrapy.utils.datatypes import SequenceExclude from scrapy.utils.defer import deferred_f_from_coro_f, maybe_deferred_to_future from scrapy.utils.misc import load_object -from scrapy.utils.reactor import is_asyncio_reactor_installed, set_asyncio_event_loop from scrapy.utils.response import open_in_browser if TYPE_CHECKING: @@ -191,10 +190,6 @@ class Shell: Runs in the reactor thread. """ - if self._use_reactor and is_asyncio_reactor_installed(): - # set the asyncio event loop for the current thread - event_loop_path = self.crawler.settings["ASYNCIO_EVENT_LOOP"] - set_asyncio_event_loop(event_loop_path) if not self.spider: await self._open_spider(spider) assert self.crawler.engine is not None From 4cb049cb15207de0967e4c2b0ff2527e130f5662 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Thu, 11 Jun 2026 13:51:03 +0500 Subject: [PATCH 181/248] Small docs fixes. (#7598) --- docs/topics/coroutines.rst | 4 ++-- docs/topics/extensions.rst | 4 ++-- docs/topics/request-response.rst | 2 +- docs/topics/settings.rst | 3 +-- docs/topics/telnetconsole.rst | 2 -- scrapy/loader/__init__.py | 2 +- scrapy/shell.py | 4 ++-- scrapy/utils/reactor.py | 2 +- 8 files changed, 10 insertions(+), 13 deletions(-) diff --git a/docs/topics/coroutines.rst b/docs/topics/coroutines.rst index ba68f0dbc..116aac323 100644 --- a/docs/topics/coroutines.rst +++ b/docs/topics/coroutines.rst @@ -267,6 +267,6 @@ You can also send multiple requests in parallel: responses = await asyncio.gather(*tasks) yield { "h1": response.css("h1::text").get(), - "price": responses[0][1].css(".price::text").get(), - "price2": responses[1][1].css(".color::text").get(), + "price": responses[0].css(".price::text").get(), + "price2": responses[1].css(".color::text").get(), } diff --git a/docs/topics/extensions.rst b/docs/topics/extensions.rst index 735e46c29..5a05f67d9 100644 --- a/docs/topics/extensions.rst +++ b/docs/topics/extensions.rst @@ -149,8 +149,6 @@ The following stats are collected: (e.g. ``item_dropped_reasons_count/DropItem``). * ``response_received_count``: total number of HTTP responses received. -.. _topics-extensions-ref-telnetconsole: - Log Count extension ~~~~~~~~~~~~~~~~~~~ @@ -159,6 +157,8 @@ Log Count extension .. autoclass:: LogCount +.. _topics-extensions-ref-telnetconsole: + Telnet console extension ~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/docs/topics/request-response.rst b/docs/topics/request-response.rst index 8fd3de621..a0fff6acb 100644 --- a/docs/topics/request-response.rst +++ b/docs/topics/request-response.rst @@ -979,7 +979,7 @@ Response objects A dictionary-like (:class:`scrapy.http.headers.Headers`) object which contains the response headers. Values can be accessed using - :meth:`~scrapy.http.headers.Headers.get` to return the first header value with + :meth:`~scrapy.http.headers.Headers.get` to return the last header value with the specified name or :meth:`~scrapy.http.headers.Headers.getlist` to return all header values with the specified name. For example, this call will give you all cookies in the headers:: diff --git a/docs/topics/settings.rst b/docs/topics/settings.rst index 06de33e6f..455985a56 100644 --- a/docs/topics/settings.rst +++ b/docs/topics/settings.rst @@ -734,8 +734,7 @@ specific cipher that is not included in ``DEFAULT`` if a website requires it. Handling of this setting needs to be implemented inside the :ref:`download handler `, so it's not guaranteed to be supported - by all 3rd-party handlers. It's currently unsupported by - :class:`~scrapy.core.downloader.handlers._httpx.HttpxDownloadHandler`. + by all 3rd-party handlers. .. setting:: DOWNLOAD_TLS_MAX_VERSION diff --git a/docs/topics/telnetconsole.rst b/docs/topics/telnetconsole.rst index 6d99c756e..e274edc9b 100644 --- a/docs/topics/telnetconsole.rst +++ b/docs/topics/telnetconsole.rst @@ -94,8 +94,6 @@ convenience: +----------------+-------------------------------------------------------------------+ | ``p`` | a shortcut to the :func:`pprint.pprint` function | +----------------+-------------------------------------------------------------------+ -| ``hpy`` | for memory debugging (see :ref:`topics-leaks`) | -+----------------+-------------------------------------------------------------------+ Telnet console usage examples ============================= diff --git a/scrapy/loader/__init__.py b/scrapy/loader/__init__.py index 2f5c0343b..8ee7dc16c 100644 --- a/scrapy/loader/__init__.py +++ b/scrapy/loader/__init__.py @@ -27,7 +27,7 @@ class ItemLoader(itemloaders.ItemLoader): :param item: The item instance to populate using subsequent calls to :meth:`~ItemLoader.add_xpath`, :meth:`~ItemLoader.add_css`, or :meth:`~ItemLoader.add_value`. - :type item: scrapy.item.Item + :type item: :ref:`item object ` :param selector: The selector to extract data from, when using the :meth:`add_xpath`, :meth:`add_css`, :meth:`replace_xpath`, or diff --git a/scrapy/shell.py b/scrapy/shell.py index 8bb5994e3..44dcd880e 100644 --- a/scrapy/shell.py +++ b/scrapy/shell.py @@ -75,8 +75,8 @@ if TYPE_CHECKING: # running event loop. # # Side note: it should be possible to remove _request_deferred() by using -# engine.download() instead of engine.schedule(), losing the usual stuff like -# spider middlewares (none of which should be important). +# engine.download_async() instead of engine.schedule(), losing the usual stuff +# like spider middlewares (none of which should be important). # # Other architecture problems: # * scrapy.cmdline.execute() creates an AsyncCrawlerProcess instance which diff --git a/scrapy/utils/reactor.py b/scrapy/utils/reactor.py index c60bb215d..5499e3f48 100644 --- a/scrapy/utils/reactor.py +++ b/scrapy/utils/reactor.py @@ -187,7 +187,7 @@ def verify_installed_reactor(reactor_path: str) -> None: def verify_installed_asyncio_event_loop(loop_path: str) -> None: - """Raise :exc:`RuntimeError` if the even loop of the installed + """Raise :exc:`RuntimeError` if the event loop of the installed :class:`~twisted.internet.asyncioreactor.AsyncioSelectorReactor` does not match the specified import path or if no reactor is installed.""" if not is_reactor_installed(): From e7f229f5b2efa7168f4240cbe5971bc4ab5ef151 Mon Sep 17 00:00:00 2001 From: Adrian Date: Thu, 11 Jun 2026 10:51:46 +0200 Subject: [PATCH 182/248] Clarify the first-class-citizen nature of asyncio support (#7599) --- docs/topics/asyncio.rst | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/docs/topics/asyncio.rst b/docs/topics/asyncio.rst index 8efa2559e..1a151044f 100644 --- a/docs/topics/asyncio.rst +++ b/docs/topics/asyncio.rst @@ -4,19 +4,27 @@ asyncio ======= -Scrapy has partial support for :mod:`asyncio`. After you :ref:`install the -asyncio reactor `, you may use :mod:`asyncio` and -:mod:`asyncio`-powered libraries in any :doc:`coroutine `. +Scrapy supports :mod:`asyncio` natively. New projects created with +:command:`scrapy startproject` have asyncio enabled by default, and you can use +:mod:`asyncio` and :mod:`asyncio`-powered libraries in any :doc:`coroutine +`. + +The rest of this page covers advanced topics. If you are starting a new project, +no additional setup is needed. .. _install-asyncio: -Installing the asyncio reactor -============================== +Configuring the asyncio reactor +=============================== -To enable :mod:`asyncio` support, your :setting:`TWISTED_REACTOR` setting needs -to be set to ``'twisted.internet.asyncioreactor.AsyncioSelectorReactor'``, -which is the default value. +New projects generated with :command:`scrapy startproject` have the asyncio +reactor configured by default. No manual setup is needed. + +The :setting:`TWISTED_REACTOR` setting controls which Twisted reactor Scrapy +uses. Its default value is +``'twisted.internet.asyncioreactor.AsyncioSelectorReactor'``, which enables +:mod:`asyncio` support. If you are using :class:`~scrapy.crawler.AsyncCrawlerRunner` or :class:`~scrapy.crawler.CrawlerRunner`, you also need to From e0a7de7213e2f06a94917624bad572a41dcc3905 Mon Sep 17 00:00:00 2001 From: Labib Bin Salam <98468420+Labib-Bin-Salam@users.noreply.github.com> Date: Thu, 11 Jun 2026 10:40:15 +0100 Subject: [PATCH 183/248] Document cb_kwargs/meta deep-copy when JOBDIR is set (#7573) When JOBDIR is enabled, requests are serialized to disk with pickle, so the objects stored in a request's cb_kwargs and meta are deep-copied on the round trip. Callbacks then receive copies rather than the original objects, which is easy to miss and can silently break code that relies on sharing mutable state. Add a note to the request serialization section of the jobs docs and a cross-referenced caution to the Request.cb_kwargs attribute docs. Closes #6120 --- docs/topics/jobs.rst | 9 +++++++++ docs/topics/request-response.rst | 7 +++++++ 2 files changed, 16 insertions(+) diff --git a/docs/topics/jobs.rst b/docs/topics/jobs.rst index 769925dd5..8d6b2458b 100644 --- a/docs/topics/jobs.rst +++ b/docs/topics/jobs.rst @@ -104,6 +104,15 @@ If you wish to log the requests that couldn't be serialized, you can set the :setting:`SCHEDULER_DEBUG` setting to ``True`` in the project's settings page. It is ``False`` by default. +.. note:: Because requests are serialized with :mod:`pickle`, the objects you + store on a request, such as the values of its + :attr:`~scrapy.Request.cb_kwargs` and :attr:`~scrapy.Request.meta` + dictionaries, are deep-copied when the request is written to and later read + back from the job directory. As a result, the callback receives a *copy* of + those objects rather than the original ones, and changes made to the copy are + not reflected in the original object. Keep this in mind if you rely on + sharing mutable state through ``cb_kwargs`` or ``meta``. + .. _job-dir-contents: Job directory contents diff --git a/docs/topics/request-response.rst b/docs/topics/request-response.rst index a0fff6acb..67b435067 100644 --- a/docs/topics/request-response.rst +++ b/docs/topics/request-response.rst @@ -188,6 +188,13 @@ Request objects ``failure.request.cb_kwargs`` in the request's errback. For more information, see :ref:`errback-cb_kwargs`. + .. note:: When :setting:`JOBDIR` is set, requests are serialized to disk + with :mod:`pickle` (see :ref:`request-serialization`). As a result, + the callback receives a deep copy of any object stored in + ``cb_kwargs``, so mutating such an object in the callback does not + affect the original. Avoid relying on shared mutable state passed + through ``cb_kwargs`` in that case. + .. attribute:: Request.meta :value: {} From c99f6e22094ef1ba65a2015c10badcb685779a55 Mon Sep 17 00:00:00 2001 From: Adrian Date: Thu, 11 Jun 2026 12:10:49 +0200 Subject: [PATCH 184/248] Document the SPIDER_MODULES hack to lower startup time and memory usage (#7600) --- docs/topics/practices.rst | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docs/topics/practices.rst b/docs/topics/practices.rst index 11c2656da..b3c58d6d3 100644 --- a/docs/topics/practices.rst +++ b/docs/topics/practices.rst @@ -387,6 +387,26 @@ crawl:: curl http://scrapy2.mycompany.com:6800/schedule.json -d project=myproject -d spider=spider1 -d part=2 curl http://scrapy3.mycompany.com:6800/schedule.json -d project=myproject -d spider=spider1 -d part=3 +.. _large-project-startup: + +Reducing startup time in large projects +======================================= + +When running a spider with ``scrapy crawl``, Scrapy loads all modules listed in +:setting:`SPIDER_MODULES` to find the target spider. In large projects with +many spiders, this can noticeably increase startup time and memory usage. + +To avoid loading every spider module, override :setting:`SPIDER_MODULES` on the +command line to point only to the module that contains the spider you want to +run: + +.. code-block:: shell + + scrapy crawl myspider -s SPIDER_MODULES=myproject.spiders.myspider + +Because :setting:`SPIDER_MODULES` is a list setting, you can include multiple +modules by separating them with commas. + .. _bans: Avoiding getting banned From a8ffdcf8517a8973391a14635234b6993b15a86a Mon Sep 17 00:00:00 2001 From: Adrian Date: Thu, 11 Jun 2026 12:56:53 +0200 Subject: [PATCH 185/248] Implement HTTP Auth settings and request metadata keys (#7590) * Implement HTTP Auth settings and request metadata keys * Fix doc-tests * Require the domain to be set when using settings --- docs/topics/downloader-middleware.rst | 80 +++++++--- docs/topics/request-response.rst | 24 +++ docs/topics/spiders.rst | 5 - scrapy/downloadermiddlewares/httpauth.py | 55 +++++-- scrapy/settings/default_settings.py | 7 + tests/test_downloadermiddleware_httpauth.py | 167 +++++++++++++++----- 6 files changed, 264 insertions(+), 74 deletions(-) diff --git a/docs/topics/downloader-middleware.rst b/docs/topics/downloader-middleware.rst index 8cb29deff..0c1af5276 100644 --- a/docs/topics/downloader-middleware.rst +++ b/docs/topics/downloader-middleware.rst @@ -307,26 +307,15 @@ HttpAuthMiddleware .. class:: HttpAuthMiddleware - This middleware authenticates all requests generated from certain spiders - using `Basic access authentication`_ (aka. HTTP auth). + This middleware authenticates requests using `Basic access authentication`_ + (aka. HTTP auth). - To enable HTTP authentication for a spider, set the ``http_user`` and - ``http_pass`` spider attributes to the authentication data and the - ``http_auth_domain`` spider attribute to the domain which requires this - authentication (its subdomains will be also handled in the same way). - You can set ``http_auth_domain`` to ``None`` to enable the - authentication for all requests but you risk leaking your authentication - credentials to unrelated domains. + Use the :setting:`HTTPAUTH_USER`, :setting:`HTTPAUTH_PASS`, and + :setting:`HTTPAUTH_DOMAIN` settings to configure it. You can also override + the credentials per request via :attr:`~scrapy.Request.meta` keys + :reqmeta:`http_user`, :reqmeta:`http_pass`, and :reqmeta:`http_auth_domain`. - .. warning:: - In previous Scrapy versions HttpAuthMiddleware sent the authentication - data with all requests, which is a security problem if the spider - makes requests to several different domains. Currently if the - ``http_auth_domain`` attribute is not set, the middleware will use the - domain of the first request, which will work for some spiders but not - for others. In the future the middleware will produce an error instead. - - Example: + Example using settings (e.g. in :attr:`~scrapy.Spider.custom_settings`): .. code-block:: python @@ -334,13 +323,62 @@ HttpAuthMiddleware class SomeIntranetSiteSpider(CrawlSpider): - http_user = "someuser" - http_pass = "somepass" - http_auth_domain = "intranet.example.com" name = "intranet.example.com" + custom_settings = { + "HTTPAUTH_USER": "someuser", + "HTTPAUTH_PASS": "somepass", + "HTTPAUTH_DOMAIN": "intranet.example.com", + } # .. rest of the spider code omitted ... + Example using per-request meta: + + .. code-block:: python + + async def start(self): + yield Request( + "https://intranet.example.com/protected/", + meta={ + "http_user": "someuser", + "http_pass": "somepass", + "http_auth_domain": "intranet.example.com", + }, + ) + +.. setting:: HTTPAUTH_USER + +HTTPAUTH_USER +~~~~~~~~~~~~~ + +Default: ``""`` + +The username to use for HTTP basic authentication, applied to all requests +whose URL matches :setting:`HTTPAUTH_DOMAIN`. + +.. setting:: HTTPAUTH_PASS + +HTTPAUTH_PASS +~~~~~~~~~~~~~ + +Default: ``""`` + +The password to use for HTTP basic authentication. + +.. setting:: HTTPAUTH_DOMAIN + +HTTPAUTH_DOMAIN +~~~~~~~~~~~~~~~ + +Default: ``None`` + +The domain (and its subdomains) to which HTTP basic authentication credentials +are sent. Set to ``None`` to send credentials with all requests, but be aware +that this risks leaking credentials to unrelated domains. + +This setting must be explicitly configured whenever :setting:`HTTPAUTH_USER` +or :setting:`HTTPAUTH_PASS` is set. + .. _Basic access authentication: https://en.wikipedia.org/wiki/Basic_access_authentication diff --git a/docs/topics/request-response.rst b/docs/topics/request-response.rst index 67b435067..700238fe9 100644 --- a/docs/topics/request-response.rst +++ b/docs/topics/request-response.rst @@ -724,6 +724,9 @@ Those are: * :reqmeta:`give_up_log_level` * :reqmeta:`handle_httpstatus_all` * :reqmeta:`handle_httpstatus_list` +* :reqmeta:`http_auth_domain` +* :reqmeta:`http_pass` +* :reqmeta:`http_user` * :reqmeta:`is_start_request` * :reqmeta:`max_retry_times` * :reqmeta:`proxy` @@ -806,6 +809,27 @@ give_up_log_level :ref:`Logging level ` used for the message logged when a request exceeds its retries. See :setting:`RETRY_GIVE_UP_LOG_LEVEL` for details. +.. reqmeta:: http_auth_domain + +http_auth_domain +---------------- + +Overrides :setting:`HTTPAUTH_DOMAIN` for this request. + +.. reqmeta:: http_pass + +http_pass +--------- + +Overrides :setting:`HTTPAUTH_PASS` for this request. + +.. reqmeta:: http_user + +http_user +--------- + +Overrides :setting:`HTTPAUTH_USER` for this request. + .. reqmeta:: max_retry_times max_retry_times diff --git a/docs/topics/spiders.rst b/docs/topics/spiders.rst index bcef9d5f6..506daf930 100644 --- a/docs/topics/spiders.rst +++ b/docs/topics/spiders.rst @@ -354,11 +354,6 @@ Otherwise, you would cause iteration over a ``start_urls`` string (a very common python pitfall) resulting in each character being seen as a separate url. -A valid use case is to set the http auth credentials -used by :class:`~scrapy.downloadermiddlewares.httpauth.HttpAuthMiddleware`:: - - scrapy crawl myspider -a http_user=myuser -a http_pass=mypassword - Spider arguments can also be passed through the Scrapyd ``schedule.json`` API. See `Scrapyd documentation`_. diff --git a/scrapy/downloadermiddlewares/httpauth.py b/scrapy/downloadermiddlewares/httpauth.py index c28c93d4e..b09abdadd 100644 --- a/scrapy/downloadermiddlewares/httpauth.py +++ b/scrapy/downloadermiddlewares/httpauth.py @@ -6,11 +6,14 @@ See documentation in docs/topics/downloader-middleware.rst from __future__ import annotations +import warnings from typing import TYPE_CHECKING from w3lib.http import basic_auth_header from scrapy import Request, Spider, signals +from scrapy.exceptions import ScrapyDeprecationWarning +from scrapy.settings import SETTINGS_PRIORITIES from scrapy.utils.decorators import _warn_spider_arg from scrapy.utils.url import url_is_from_any_domain @@ -23,12 +26,28 @@ if TYPE_CHECKING: class HttpAuthMiddleware: - """Set Basic HTTP Authorization header - (http_user and http_pass spider class attributes)""" + """Set Basic HTTP Authorization header.""" + + def __init__(self) -> None: + self._auth: bytes | None = None + self._domain: str | None = None @classmethod def from_crawler(cls, crawler: Crawler) -> Self: o = cls() + usr = crawler.settings.get("HTTPAUTH_USER", "") + pwd = crawler.settings.get("HTTPAUTH_PASS", "") + if usr or pwd: + domain_priority = crawler.settings.getpriority("HTTPAUTH_DOMAIN") or 0 + if domain_priority <= SETTINGS_PRIORITIES["default"]: + raise ValueError( + "HTTPAUTH_DOMAIN must be set when HTTPAUTH_USER or HTTPAUTH_PASS " + "is configured. Set it to a domain (e.g. 'example.com') to restrict " + "credentials to that domain, or set it to None to send credentials " + "with all requests." + ) + o._auth = basic_auth_header(usr, pwd) + o._domain = crawler.settings.get("HTTPAUTH_DOMAIN") crawler.signals.connect(o.spider_opened, signal=signals.spider_opened) return o @@ -36,18 +55,34 @@ class HttpAuthMiddleware: usr = getattr(spider, "http_user", "") pwd = getattr(spider, "http_pass", "") if usr or pwd: - self.auth = basic_auth_header(usr, pwd) - self.domain = spider.http_auth_domain # type: ignore[attr-defined] + warnings.warn( + "Use the HTTPAUTH_USER, HTTPAUTH_PASS, and HTTPAUTH_DOMAIN settings " + "instead of the http_user, http_pass, and http_auth_domain spider " + "attributes. Support for the spider attributes will be removed in a " + "future version of Scrapy.", + category=ScrapyDeprecationWarning, + stacklevel=2, + ) + self._auth = basic_auth_header(usr, pwd) + self._domain = spider.http_auth_domain # type: ignore[attr-defined] @_warn_spider_arg def process_request( self, request: Request, spider: Spider | None = None ) -> Request | Response | None: - auth = getattr(self, "auth", None) - if ( - auth - and b"Authorization" not in request.headers - and (not self.domain or url_is_from_any_domain(request.url, [self.domain])) + if b"Authorization" in request.headers: + return None + # Per-request meta overrides + usr = request.meta.get("http_user", "") + pwd = request.meta.get("http_pass", "") + if usr or pwd: + domain = request.meta.get("http_auth_domain") + if not domain or url_is_from_any_domain(request.url, [domain]): + request.headers[b"Authorization"] = basic_auth_header(usr, pwd) + return None + # Middleware-level auth + if self._auth and ( + not self._domain or url_is_from_any_domain(request.url, [self._domain]) ): - request.headers[b"Authorization"] = auth + request.headers[b"Authorization"] = self._auth return None diff --git a/scrapy/settings/default_settings.py b/scrapy/settings/default_settings.py index e8a9400d7..049921ba7 100644 --- a/scrapy/settings/default_settings.py +++ b/scrapy/settings/default_settings.py @@ -107,6 +107,9 @@ __all__ = [ "FTP_PASSWORD", "FTP_USER", "GCS_PROJECT_ID", + "HTTPAUTH_DOMAIN", + "HTTPAUTH_PASS", + "HTTPAUTH_USER", "HTTPCACHE_ALWAYS_STORE", "HTTPCACHE_DBM_MODULE", "HTTPCACHE_DIR", @@ -397,6 +400,10 @@ FTP_PASSWORD = "guest" # noqa: S105 GCS_PROJECT_ID = None +HTTPAUTH_USER = "" +HTTPAUTH_PASS = "" +HTTPAUTH_DOMAIN = None + HTTPCACHE_ENABLED = False HTTPCACHE_ALWAYS_STORE = False HTTPCACHE_DBM_MODULE = "dbm" diff --git a/tests/test_downloadermiddleware_httpauth.py b/tests/test_downloadermiddleware_httpauth.py index 522a3002f..827133d2e 100644 --- a/tests/test_downloadermiddleware_httpauth.py +++ b/tests/test_downloadermiddleware_httpauth.py @@ -2,8 +2,25 @@ import pytest from w3lib.http import basic_auth_header from scrapy.downloadermiddlewares.httpauth import HttpAuthMiddleware +from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.http import Request from scrapy.spiders import Spider +from scrapy.utils.test import get_crawler + +_DOMAIN_NOT_SET = object() + + +def make_mw(user="", passwd="", domain=_DOMAIN_NOT_SET): + settings: dict = { + "HTTPAUTH_USER": user, + "HTTPAUTH_PASS": passwd, + } + if domain is not _DOMAIN_NOT_SET: + settings["HTTPAUTH_DOMAIN"] = domain + return HttpAuthMiddleware.from_crawler(get_crawler(settings_dict=settings)) + + +# --- Spider attribute tests (deprecated) --- class LegacySpider(Spider): @@ -23,61 +40,135 @@ class AnyDomainSpider(Spider): http_auth_domain = None -class TestHttpAuthMiddlewareLegacy: - def setup_method(self): - self.spider = LegacySpider("foo") - - def test_auth(self): +class TestHttpAuthMiddlewareLegacySpiderAttr: + def test_missing_domain_raises(self): mw = HttpAuthMiddleware() - with pytest.raises(AttributeError): - mw.spider_opened(self.spider) + with pytest.warns(ScrapyDeprecationWarning), pytest.raises(AttributeError): + mw.spider_opened(LegacySpider("foo")) + def test_domain_spider(self): + mw = HttpAuthMiddleware() + with pytest.warns(ScrapyDeprecationWarning): + mw.spider_opened(DomainSpider("foo")) + req = Request("http://example.com/") + mw.process_request(req) + assert req.headers["Authorization"] == basic_auth_header("foo", "bar") -class TestHttpAuthMiddleware: - def setup_method(self): - self.mw = HttpAuthMiddleware() - spider = DomainSpider("foo") - self.mw.spider_opened(spider) - - def teardown_method(self): - del self.mw - - def test_no_auth(self): - req = Request("http://example-noauth.com/") - assert self.mw.process_request(req) is None + def test_no_auth_wrong_domain(self): + mw = HttpAuthMiddleware() + with pytest.warns(ScrapyDeprecationWarning): + mw.spider_opened(DomainSpider("foo")) + req = Request("http://other.com/") + mw.process_request(req) assert "Authorization" not in req.headers - def test_auth_domain(self): + def test_any_domain_spider(self): + mw = HttpAuthMiddleware() + with pytest.warns(ScrapyDeprecationWarning): + mw.spider_opened(AnyDomainSpider("foo")) + req = Request("http://anywhere.com/") + mw.process_request(req) + assert req.headers["Authorization"] == basic_auth_header("foo", "bar") + + +# --- Settings-based tests --- + + +class TestHttpAuthMiddlewareSettings: + def test_no_auth(self): + mw = make_mw() req = Request("http://example.com/") - assert self.mw.process_request(req) is None + mw.process_request(req) + assert "Authorization" not in req.headers + + def test_auth_without_domain_raises(self): + with pytest.raises(ValueError, match="HTTPAUTH_DOMAIN"): + make_mw(user="foo", passwd="bar") + + def test_auth_all_domains(self): + mw = make_mw(user="foo", passwd="bar", domain=None) + req = Request("http://example.com/") + mw.process_request(req) + assert req.headers["Authorization"] == basic_auth_header("foo", "bar") + + def test_auth_domain_match(self): + mw = make_mw(user="foo", passwd="bar", domain="example.com") + req = Request("http://example.com/") + mw.process_request(req) assert req.headers["Authorization"] == basic_auth_header("foo", "bar") def test_auth_subdomain(self): - req = Request("http://foo.example.com/") - assert self.mw.process_request(req) is None + mw = make_mw(user="foo", passwd="bar", domain="example.com") + req = Request("http://sub.example.com/") + mw.process_request(req) assert req.headers["Authorization"] == basic_auth_header("foo", "bar") + def test_no_auth_wrong_domain(self): + mw = make_mw(user="foo", passwd="bar", domain="example.com") + req = Request("http://other.com/") + mw.process_request(req) + assert "Authorization" not in req.headers + def test_auth_already_set(self): + mw = make_mw(user="foo", passwd="bar", domain="example.com") req = Request("http://example.com/", headers={"Authorization": "Digest 123"}) - assert self.mw.process_request(req) is None + mw.process_request(req) assert req.headers["Authorization"] == b"Digest 123" -class TestHttpAuthAnyMiddleware: - def setup_method(self): - self.mw = HttpAuthMiddleware() - spider = AnyDomainSpider("foo") - self.mw.spider_opened(spider) +# --- Per-request meta tests --- - def teardown_method(self): - del self.mw - def test_auth(self): - req = Request("http://example.com/") - assert self.mw.process_request(req) is None - assert req.headers["Authorization"] == basic_auth_header("foo", "bar") +class TestHttpAuthMiddlewareMeta: + def test_meta_auth_no_domain(self): + mw = make_mw() + req = Request("http://example.com/", meta={"http_user": "u", "http_pass": "p"}) + mw.process_request(req) + assert req.headers["Authorization"] == basic_auth_header("u", "p") - def test_auth_already_set(self): - req = Request("http://example.com/", headers={"Authorization": "Digest 123"}) - assert self.mw.process_request(req) is None + def test_meta_auth_domain_match(self): + mw = make_mw() + req = Request( + "http://example.com/", + meta={ + "http_user": "u", + "http_pass": "p", + "http_auth_domain": "example.com", + }, + ) + mw.process_request(req) + assert req.headers["Authorization"] == basic_auth_header("u", "p") + + def test_meta_auth_domain_no_match(self): + mw = make_mw() + req = Request( + "http://other.com/", + meta={ + "http_user": "u", + "http_pass": "p", + "http_auth_domain": "example.com", + }, + ) + mw.process_request(req) + assert "Authorization" not in req.headers + + def test_meta_overrides_middleware(self): + mw = make_mw(user="mw_user", passwd="mw_pass", domain="example.com") + req = Request( + "http://example.com/", + meta={"http_user": "meta_user", "http_pass": "meta_pass"}, + ) + mw.process_request(req) + assert req.headers["Authorization"] == basic_auth_header( + "meta_user", "meta_pass" + ) + + def test_meta_already_set(self): + mw = make_mw() + req = Request( + "http://example.com/", + headers={"Authorization": "Digest 123"}, + meta={"http_user": "u", "http_pass": "p"}, + ) + mw.process_request(req) assert req.headers["Authorization"] == b"Digest 123" From 7afc875081c5a3a26fdc8d717818d84c3bd789fd Mon Sep 17 00:00:00 2001 From: Adrian Date: Fri, 12 Jun 2026 12:32:38 +0200 Subject: [PATCH 186/248] Add coverage for Item.__delitem__() (#7610) --- tests/test_item.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/test_item.py b/tests/test_item.py index 4eb37a344..34b054e12 100644 --- a/tests/test_item.py +++ b/tests/test_item.py @@ -48,6 +48,18 @@ class TestItem: with pytest.raises(KeyError): i["field"] + def test_delitem(self): + class TestItem(Item): + name = Field() + + i = TestItem(name="John") + del i["name"] + with pytest.raises(KeyError): + i["name"] + + with pytest.raises(KeyError): + del i["name"] + def test_repr(self): class TestItem(Item): name = Field() From b08ed1cf05b983030e9b742a1ffe2891cb18d321 Mon Sep 17 00:00:00 2001 From: Adrian Date: Fri, 12 Jun 2026 13:13:17 +0200 Subject: [PATCH 187/248] Add coverage for DummySpiderLoader.find_by_request() (#7608) --- tests/test_spiderloader/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_spiderloader/__init__.py b/tests/test_spiderloader/__init__.py index 245507c0b..de27ad519 100644 --- a/tests/test_spiderloader/__init__.py +++ b/tests/test_spiderloader/__init__.py @@ -252,3 +252,4 @@ def test_dummy_spider_loader(spider_loader_env): assert not spider_loader.list() with pytest.raises(KeyError): spider_loader.load("spider1") + assert not spider_loader.find_by_request(Request("http://example.com")) From 983e6c11826f376272edd9a8819345fd43ffdca7 Mon Sep 17 00:00:00 2001 From: Adrian Date: Fri, 12 Jun 2026 14:56:15 +0200 Subject: [PATCH 188/248] Link.__eq__: return NotImplemented instead of raising NotImplementedError (#7611) --- scrapy/link.py | 2 +- tests/test_link.py | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/scrapy/link.py b/scrapy/link.py index 9c272ab2f..046630403 100644 --- a/scrapy/link.py +++ b/scrapy/link.py @@ -39,7 +39,7 @@ class Link: def __eq__(self, other: object) -> bool: if not isinstance(other, Link): - raise NotImplementedError + return NotImplemented return ( self.url == other.url and self.text == other.text diff --git a/tests/test_link.py b/tests/test_link.py index c49e5c090..0eeffe12b 100644 --- a/tests/test_link.py +++ b/tests/test_link.py @@ -55,3 +55,7 @@ class TestLink: def test_bytes_url(self): with pytest.raises(TypeError): Link(b"http://www.example.com/\xc2\xa3") + + def test_eq_non_link(self): + url = "http://example.com" + assert Link(url) != url From af30cfea120a0b0853984bedc350e8dc114bba25 Mon Sep 17 00:00:00 2001 From: Adrian Date: Fri, 12 Jun 2026 16:15:36 +0200 Subject: [PATCH 189/248] Complete coverage for addons.py (#7612) --- tests/test_addons.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/test_addons.py b/tests/test_addons.py index 3ad8cf7ff..db0fb2f31 100644 --- a/tests/test_addons.py +++ b/tests/test_addons.py @@ -71,6 +71,37 @@ class TestAddonManager: manager = crawler.addons assert not manager.addons + def test_notconfigured_with_args(self): + class NotConfiguredAddon: + def update_settings(self, settings): + raise NotConfigured("addon disabled reason") + + settings_dict = { + "ADDONS": {NotConfiguredAddon: 0}, + } + with patch("scrapy.addons.logger") as logger_mock: + crawler = get_crawler(settings_dict=settings_dict) + assert not crawler.addons.addons + logger_mock.warning.assert_called_once_with( + "Disabled %(clspath)s: %(eargs)s", + {"clspath": NotConfiguredAddon, "eargs": "addon disabled reason"}, + extra={"crawler": crawler}, + ) + + def test_no_update_settings(self): + class PreCrawlerOnlyAddon: + @classmethod + def update_pre_crawler_settings(cls, settings): + settings.set("PRE_CRAWLER_KEY", "value", priority="addon") + + settings_dict = { + "ADDONS": {PreCrawlerOnlyAddon: 0}, + } + crawler = get_crawler(settings_dict=settings_dict) + manager = crawler.addons + assert len(manager.addons) == 1 + assert isinstance(manager.addons[0], PreCrawlerOnlyAddon) + def test_load_settings_order(self): # Get three addons with different settings addonlist = [] From 3a3695526122b9e3ccaf477ec32b6ff1ad750a60 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Mon, 15 Jun 2026 11:39:58 +0500 Subject: [PATCH 190/248] Assorted test fixes (#7616) --- scrapy/utils/_deps_compat.py | 12 ++++ tests/ignores.txt | 2 - tests/mockserver/simple_https.py | 2 +- tests/spiders.py | 2 +- tests/test_addons.py | 1 + tests/test_cmdline/extensions.py | 12 ---- tests/test_cmdline/settings.py | 2 +- tests/test_command_startproject.py | 2 +- tests/test_commands.py | 4 +- tests/test_contracts.py | 10 +-- tests/test_core_downloader.py | 5 +- tests/test_crawl.py | 4 +- tests/test_crawler.py | 27 ++------ tests/test_downloader_handler_twisted_ftp.py | 6 +- .../test_downloader_handler_twisted_http2.py | 5 ++ tests/test_downloader_handlers.py | 17 +++--- tests/test_downloader_handlers_http_base.py | 2 +- tests/test_downloadermiddleware_httpcache.py | 4 +- ...st_downloadermiddleware_httpcompression.py | 6 +- ...test_downloadermiddleware_redirect_base.py | 2 +- tests/test_downloadermiddleware_stats.py | 2 +- tests/test_downloaderslotssettings.py | 4 -- tests/test_dupefilters.py | 2 +- tests/test_engine.py | 5 +- tests/test_feedexport.py | 4 +- tests/test_feedexport_batch.py | 6 +- tests/test_feedexport_postprocess.py | 4 +- tests/test_http2_client_protocol.py | 6 +- tests/test_http_request.py | 5 +- tests/test_http_response.py | 61 +++++++++++++------ tests/test_http_response_text.py | 14 ++--- tests/test_logformatter.py | 8 +-- tests/test_pipeline_media.py | 4 -- tests/test_robotstxt_interface.py | 12 ++-- tests/test_scheduler_base.py | 2 +- tests/test_spidermiddleware.py | 18 ------ tests/test_spidermiddleware_process_start.py | 9 --- tests/test_spidermiddleware_referer.py | 2 +- tests/test_spiderstate.py | 17 ++++-- tests/test_utils_asyncio.py | 2 + tests/test_utils_deprecate.py | 4 +- tests/test_utils_httpobj.py | 2 +- tests/test_utils_log.py | 17 +++--- tests/test_utils_response.py | 2 +- 44 files changed, 154 insertions(+), 185 deletions(-) diff --git a/scrapy/utils/_deps_compat.py b/scrapy/utils/_deps_compat.py index fad7e6f6b..08e8cad24 100644 --- a/scrapy/utils/_deps_compat.py +++ b/scrapy/utils/_deps_compat.py @@ -1,7 +1,15 @@ +import sys + from OpenSSL import __version__ as PYOPENSSL_VERSION_STRING from packaging.version import Version from twisted import version as TWISTED_VERSION from twisted.python.versions import Version as TxVersion +from w3lib import __version__ as W3LIB_VERSION_STRING + +# improved urllib.robotparser, https://github.com/python/cpython/pull/149374 +STDLIB_IMPROVED_ROBOTFILEPARSER = sys.version_info >= (3, 14, 5) or ( + (3, 13, 14) <= sys.version_info < (3, 14) +) TWISTED_FAILURE_HAS_STACK = TWISTED_VERSION < TxVersion("twisted", 24, 10, 0) # changes to private _sslverify code, https://github.com/twisted/twisted/pull/12506 @@ -14,3 +22,7 @@ PYOPENSSL_VERSION = Version(PYOPENSSL_VERSION_STRING) PYOPENSSL_WANTS_X509_PKEY = PYOPENSSL_VERSION < Version("24.3.0") # SSL.Context.set_cipher_list() creates a temporary connection, making the context immutable PYOPENSSL_SET_CIPHER_LIST_TMP_CONN = PYOPENSSL_VERSION < Version("25.2.0") + +W3LIB_VERSION = Version(W3LIB_VERSION_STRING) +# safe_url_string() strips the input, https://github.com/scrapy/w3lib/pull/207 +W3LIB_STRIPS_URLS = W3LIB_VERSION >= Version("2.1.1") diff --git a/tests/ignores.txt b/tests/ignores.txt index 222288841..94edcf186 100644 --- a/tests/ignores.txt +++ b/tests/ignores.txt @@ -1,3 +1 @@ -scrapy/downloadermiddlewares/cookies.py scrapy/extensions/statsmailer.py -scrapy/extensions/memusage.py diff --git a/tests/mockserver/simple_https.py b/tests/mockserver/simple_https.py index 943775fa5..fdea666e1 100644 --- a/tests/mockserver/simple_https.py +++ b/tests/mockserver/simple_https.py @@ -33,7 +33,7 @@ class SimpleMockServer(BaseMockServer): super().__init__() self.keyfile = keyfile self.certfile = certfile - self.cipher_string = cipher_string or "" + self.cipher_string = cipher_string self.tls_min_version = tls_min_version self.tls_max_version = tls_max_version diff --git a/tests/spiders.py b/tests/spiders.py index 55d1ea365..612dc11c9 100644 --- a/tests/spiders.py +++ b/tests/spiders.py @@ -222,7 +222,7 @@ class AsyncDefDeferredWrappedSpider(SimpleSpider): class AsyncDefDeferredMaybeWrappedSpider(SimpleSpider): - name = "asyncdef_deferred_wrapped" + name = "asyncdef_deferred_maybe_wrapped" async def parse(self, response): await maybe_deferred_to_future(defer.succeed(None)) diff --git a/tests/test_addons.py b/tests/test_addons.py index db0fb2f31..14ebddda8 100644 --- a/tests/test_addons.py +++ b/tests/test_addons.py @@ -161,6 +161,7 @@ class TestAddonManager: settings.set("KEY", 0, priority="default") runner = runner_cls(settings) crawler = runner.create_crawler(Spider) + crawler._apply_settings() assert crawler.settings.getint("KEY") == 20 def test_fallback_workflow(self): diff --git a/tests/test_cmdline/extensions.py b/tests/test_cmdline/extensions.py index 11c821f8d..ef1e50c0c 100644 --- a/tests/test_cmdline/extensions.py +++ b/tests/test_cmdline/extensions.py @@ -1,14 +1,2 @@ -"""A test extension used to check the settings loading order""" - - -class TestExtension: - def __init__(self, settings): - settings.set("TEST1", f"{settings['TEST1']} + started") - - @classmethod - def from_crawler(cls, crawler): - return cls(crawler.settings) - - class DummyExtension: pass diff --git a/tests/test_cmdline/settings.py b/tests/test_cmdline/settings.py index 32b15e191..ec71bba0c 100644 --- a/tests/test_cmdline/settings.py +++ b/tests/test_cmdline/settings.py @@ -1,7 +1,7 @@ from pathlib import Path EXTENSIONS = { - "tests.test_cmdline.extensions.TestExtension": 0, + "tests.test_cmdline.extensions.DummyExtension": 0, } TEST1 = "default" diff --git a/tests/test_command_startproject.py b/tests/test_command_startproject.py index 2a9d0ed57..7ac6c4fb0 100644 --- a/tests/test_command_startproject.py +++ b/tests/test_command_startproject.py @@ -257,7 +257,7 @@ class TestStartprojectTemplates: assert actual_permissions == expected_permissions - def test_startproject_permissions_umask_022(self, tmp_path: Path) -> None: + def test_startproject_permissions_umask_002(self, tmp_path: Path) -> None: """Check that generated files have the right permissions when the system uses a umask value that causes new files to have different permissions than those from the template folder.""" diff --git a/tests/test_commands.py b/tests/test_commands.py index edb03da1b..0657f393c 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -214,9 +214,7 @@ class MySpider(scrapy.Spider): self._append_settings(proj_path / self.project_name, "TWISTED_REACTOR = None\n") self._assert_spider_works(self.NORMAL_MSG, proj_path, "sp") - self._assert_spider_asyncio_fail( - self.NORMAL_MSG, proj_path, "aiosp", "-s", "TWISTED_REACTOR=" - ) + self._assert_spider_asyncio_fail(self.NORMAL_MSG, proj_path, "aiosp") def test_spider_settings_asyncio(self, proj_path: Path) -> None: """The reactor is set via the spider settings to the asyncio value. diff --git a/tests/test_contracts.py b/tests/test_contracts.py index 008e326ec..e80945b93 100644 --- a/tests/test_contracts.py +++ b/tests/test_contracts.py @@ -388,7 +388,7 @@ class TestContractsManager: request = self.conman.from_method(spider.returns_item_meta, self.results) assert request.meta["key"] == "example" response.meta = request.meta - request.callback(ResponseMetaMock) + request.callback(response) assert response.meta["key"] == "example" self.should_succeed() @@ -476,14 +476,14 @@ class TestContractsManager: # invalid regex request = self.conman.from_method(spider.invalid_regex, self.results) - self.should_succeed() + assert request is None # invalid regex with valid contract request = self.conman.from_method( spider.invalid_regex_with_valid_contract, self.results ) - self.should_succeed() request.callback(response) + self.should_succeed() def test_custom_contracts(self): self.conman.from_spider(CustomContractSuccessSpider(), self.results) @@ -578,7 +578,7 @@ class TestCustomContractPrePostProcess: spider = DemoSpider() response = ResponseMock() contract = CustomFailContractPreProcess(spider.returns_request) - conman = ContractsManager([contract]) + conman = ContractsManager([UrlContract, ReturnsContract, contract]) request = conman.from_method(spider.returns_request, self.results) contract.add_pre_hook(request, self.results) @@ -592,7 +592,7 @@ class TestCustomContractPrePostProcess: spider = DemoSpider() response = ResponseMock() contract = CustomFailContractPostProcess(spider.returns_request) - conman = ContractsManager([contract]) + conman = ContractsManager([UrlContract, ReturnsContract, contract]) request = conman.from_method(spider.returns_request, self.results) contract.add_post_hook(request, self.results) diff --git a/tests/test_core_downloader.py b/tests/test_core_downloader.py index abeaa2f65..e348bfb7f 100644 --- a/tests/test_core_downloader.py +++ b/tests/test_core_downloader.py @@ -36,7 +36,6 @@ from tests.utils.decorators import coroutine_test if TYPE_CHECKING: from twisted.internet.defer import Deferred - from twisted.internet.ssl import ContextFactory from twisted.web.iweb import IBodyProducer @@ -48,8 +47,6 @@ class TestSlot: @pytest.mark.requires_reactor # this test is related to the Twisted HTTP code class TestContextFactoryBase: - context_factory: ContextFactory | None = None - @async_yield_fixture async def server_url(self, tmp_path): (tmp_path / "file").write_bytes(b"0123456789") @@ -69,7 +66,7 @@ class TestContextFactoryBase: return reactor.listenSSL( 0, site, - contextFactory=self.context_factory or ssl_context_factory(), + contextFactory=ssl_context_factory(), interface="127.0.0.1", ) diff --git a/tests/test_crawl.py b/tests/test_crawl.py index ada4c31ce..85d98f847 100644 --- a/tests/test_crawl.py +++ b/tests/test_crawl.py @@ -342,7 +342,7 @@ with multiples lines assert "responses" in crawler.spider.meta assert "failures" not in crawler.spider.meta # start() doesn't set Referer header - echo0 = json.loads(to_unicode(crawler.spider.meta["responses"][2].body)) + echo0 = json.loads(to_unicode(crawler.spider.meta["responses"][0].body)) assert "Referer" not in echo0["headers"] # following request sets Referer to the source request url echo1 = json.loads(to_unicode(crawler.spider.meta["responses"][1].body)) @@ -390,7 +390,7 @@ with multiples lines est = [x for sublist in est for x in sublist] # flatten est = [x.lstrip().rstrip() for x in est] it = iter(est) - s = dict(zip(it, it, strict=False)) + s = dict(zip(it, it, strict=True)) assert s["engine.spider.name"] == crawler.spider.name assert s["len(engine.scraper.slot.active)"] == "1" diff --git a/tests/test_crawler.py b/tests/test_crawler.py index 82956735b..31d195c4b 100644 --- a/tests/test_crawler.py +++ b/tests/test_crawler.py @@ -240,11 +240,7 @@ class TestCrawler(TestBaseCrawler): @classmethod def from_crawler(cls, crawler): - try: - crawler.get_downloader_middleware(DefaultSpider) - except Exception as e: - MySpider.result = e - raise + crawler.get_downloader_middleware(DefaultSpider) crawler = get_raw_crawler(MySpider, BASE_SETTINGS) with pytest.raises(RuntimeError): @@ -322,11 +318,7 @@ class TestCrawler(TestBaseCrawler): @classmethod def from_crawler(cls, crawler): - try: - crawler.get_extension(DefaultSpider) - except Exception as e: - MySpider.result = e - raise + crawler.get_extension(DefaultSpider) crawler = get_raw_crawler(MySpider, BASE_SETTINGS) with pytest.raises(RuntimeError): @@ -404,11 +396,7 @@ class TestCrawler(TestBaseCrawler): @classmethod def from_crawler(cls, crawler): - try: - crawler.get_item_pipeline(DefaultSpider) - except Exception as e: - MySpider.result = e - raise + crawler.get_item_pipeline(DefaultSpider) crawler = get_raw_crawler(MySpider, BASE_SETTINGS) with pytest.raises(RuntimeError): @@ -486,11 +474,7 @@ class TestCrawler(TestBaseCrawler): @classmethod def from_crawler(cls, crawler): - try: - crawler.get_spider_middleware(DefaultSpider) - except Exception as e: - MySpider.result = e - raise + crawler.get_spider_middleware(DefaultSpider) crawler = get_raw_crawler(MySpider, BASE_SETTINGS) with pytest.raises(RuntimeError): @@ -755,11 +739,12 @@ class TestCrawlerRunnerHasSpider: ): await self._crawl(runner, NoRequestsSpider) else: - CrawlerRunner( + runner = CrawlerRunner( settings={ "TWISTED_REACTOR": "twisted.internet.asyncioreactor.AsyncioSelectorReactor", } ) + await self._crawl(runner, NoRequestsSpider) @pytest.mark.only_asyncio diff --git a/tests/test_downloader_handler_twisted_ftp.py b/tests/test_downloader_handler_twisted_ftp.py index 361e91382..489b70e74 100644 --- a/tests/test_downloader_handler_twisted_ftp.py +++ b/tests/test_downloader_handler_twisted_ftp.py @@ -59,7 +59,7 @@ class TestFTPBase(ABC): port = reactor.listenTCP(0, factory, interface="127.0.0.1") portno = port.getHost().port - yield f"https://127.0.0.1:{portno}/" + yield f"ftp://127.0.0.1:{portno}/" await port.stopListening() @@ -142,15 +142,11 @@ class TestFTPBase(ABC): server_url: str, dh: FTPDownloadHandler, ) -> None: - f, local_fname = mkstemp() - local_fname_path = Path(local_fname) - os.close(f) meta = {} meta.update(self.req_meta) request = Request(url=server_url + filename, meta=meta) r = await dh.download_request(request) assert type(r) is response_class # pylint: disable=unidiomatic-typecheck - local_fname_path.unlink() class TestFTP(TestFTPBase): diff --git a/tests/test_downloader_handler_twisted_http2.py b/tests/test_downloader_handler_twisted_http2.py index 5f79a5453..bea97642e 100644 --- a/tests/test_downloader_handler_twisted_http2.py +++ b/tests/test_downloader_handler_twisted_http2.py @@ -24,6 +24,7 @@ from tests.test_downloader_handlers_http_base import ( TestHttpWithCrawlerBase, TestMitmProxyBase, TestRealWebsiteBase, + TestSimpleHttpsBase, ) from tests.utils.decorators import coroutine_test @@ -156,6 +157,10 @@ class TestHttp2(H2DownloadHandlerMixin, TestHttpsBase): await download_handler.download_request(request) +class TestSimpleHttp2(H2DownloadHandlerMixin, TestSimpleHttpsBase): + pass + + class TestHttp2WrongHostname(H2DownloadHandlerMixin, TestHttpsWrongHostnameBase): pass diff --git a/tests/test_downloader_handlers.py b/tests/test_downloader_handlers.py index eadb7740e..e685f607a 100644 --- a/tests/test_downloader_handlers.py +++ b/tests/test_downloader_handlers.py @@ -191,17 +191,14 @@ class TestS3: @contextlib.contextmanager def _mocked_date(self, date): - try: - import botocore.auth # noqa: F401,PLC0415 - except ImportError: + import botocore.auth # noqa: F401,PLC0415 + + # We need to mock botocore.auth.formatdate, because otherwise + # botocore overrides Date header with current date and time + # and Authorization header is different each time + with mock.patch("botocore.auth.formatdate") as mock_formatdate: + mock_formatdate.return_value = date yield - else: - # We need to mock botocore.auth.formatdate, because otherwise - # botocore overrides Date header with current date and time - # and Authorization header is different each time - with mock.patch("botocore.auth.formatdate") as mock_formatdate: - mock_formatdate.return_value = date - yield @coroutine_test async def test_request_signing1(self): diff --git a/tests/test_downloader_handlers_http_base.py b/tests/test_downloader_handlers_http_base.py index 0e1ff07c9..223f288bf 100644 --- a/tests/test_downloader_handlers_http_base.py +++ b/tests/test_downloader_handlers_http_base.py @@ -563,7 +563,7 @@ class TestHttpBase(ABC): ) -> None: request = Request(mockserver.url("/text", is_secure=self.is_secure)) - # 10 is minimal size for this request and the limit is only counted on + # 5 is minimal size for this request and the limit is only counted on # response body. (regardless of headers) async with self.get_dh({"DOWNLOAD_MAXSIZE": 5}) as download_handler: response = await download_handler.download_request(request) diff --git a/tests/test_downloadermiddleware_httpcache.py b/tests/test_downloadermiddleware_httpcache.py index 548c0d8ee..e5d726764 100644 --- a/tests/test_downloadermiddleware_httpcache.py +++ b/tests/test_downloadermiddleware_httpcache.py @@ -203,7 +203,7 @@ class DummyPolicyTestMixin(PolicyTestMixin): assert mw.process_request(req) is None # s3 scheme response is cached by default - req, res = Request("s3://bucket/key"), Response("http://bucket/key") + req, res = Request("s3://bucket/key"), Response("s3://bucket/key") with self._middleware() as mw: assert mw.process_request(req) is None mw.process_response(req, res) @@ -214,7 +214,7 @@ class DummyPolicyTestMixin(PolicyTestMixin): assert "cached" in cached.flags # ignore s3 scheme - req, res = Request("s3://bucket/key2"), Response("http://bucket/key2") + req, res = Request("s3://bucket/key2"), Response("s3://bucket/key2") with self._middleware(HTTPCACHE_IGNORE_SCHEMES=["s3"]) as mw: assert mw.process_request(req) is None mw.process_response(req, res) diff --git a/tests/test_downloadermiddleware_httpcompression.py b/tests/test_downloadermiddleware_httpcompression.py index bb7fcd6c7..30caa094f 100644 --- a/tests/test_downloadermiddleware_httpcompression.py +++ b/tests/test_downloadermiddleware_httpcompression.py @@ -361,7 +361,7 @@ class TestHttpCompression: zf.write(plainbody) zf.close() response = Response( - "http;//www.example.com/", headers=headers, body=f.getvalue() + "http://www.example.com/", headers=headers, body=f.getvalue() ) request = Request("http://www.example.com/") @@ -386,7 +386,7 @@ class TestHttpCompression: zf.write(plainbody) zf.close() response = HtmlResponse( - "http;//www.example.com/page.html", headers=headers, body=f.getvalue() + "http://www.example.com/page.html", headers=headers, body=f.getvalue() ) request = Request("http://www.example.com/") @@ -493,7 +493,7 @@ class TestHttpCompression: gz_resp.close() response = Response( - "http;//www.example.com/", headers=headers, body=r.getvalue() + "http://www.example.com/", headers=headers, body=r.getvalue() ) request = Request("http://www.example.com/") diff --git a/tests/test_downloadermiddleware_redirect_base.py b/tests/test_downloadermiddleware_redirect_base.py index 44ade93b7..32935769f 100644 --- a/tests/test_downloadermiddleware_redirect_base.py +++ b/tests/test_downloadermiddleware_redirect_base.py @@ -122,7 +122,7 @@ class Base: req1 = Request("http://a.example/first") rsp1 = self.get_response(req1, "/redirected") req2 = self.mw.process_response(req1, rsp1) - rsp2 = self.get_response(req1, "/redirected2") + rsp2 = self.get_response(req2, "/redirected2") req3 = self.mw.process_response(req2, rsp2) assert req2.url == "http://a.example/redirected" diff --git a/tests/test_downloadermiddleware_stats.py b/tests/test_downloadermiddleware_stats.py index 67af4264c..cf7b614c4 100644 --- a/tests/test_downloadermiddleware_stats.py +++ b/tests/test_downloadermiddleware_stats.py @@ -16,7 +16,7 @@ class TestDownloaderStats: self.crawler.stats.open_spider() self.req = Request("http://scrapytest.org") - self.res = Response("scrapytest.org", status=400) + self.res = Response("http://scrapytest.org", status=400) def assertStatsEqual(self, key, value): assert self.crawler.stats.get_value(key) == value, str( diff --git a/tests/test_downloaderslotssettings.py b/tests/test_downloaderslotssettings.py index a717b18a6..9d58a6e09 100644 --- a/tests/test_downloaderslotssettings.py +++ b/tests/test_downloaderslotssettings.py @@ -5,7 +5,6 @@ import pytest from scrapy import Request from scrapy.core.downloader import Downloader, Slot -from scrapy.crawler import CrawlerRunner from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.utils.spider import DefaultSpider from scrapy.utils.test import get_crawler @@ -65,9 +64,6 @@ class TestCrawl: def teardown_class(cls): cls.mockserver.__exit__(None, None, None) - def setup_method(self): - self.runner = CrawlerRunner() - @inline_callbacks_test def test_delay(self): crawler = get_crawler(DownloaderSlotsSettingsTestSpider) diff --git a/tests/test_dupefilters.py b/tests/test_dupefilters.py index b38bf9570..412a59fcd 100644 --- a/tests/test_dupefilters.py +++ b/tests/test_dupefilters.py @@ -86,7 +86,7 @@ class TestRFPDupeFilter: df.close("finished") df2 = _get_dupefilter(settings={"JOBDIR": path}, open_=False) - assert df != df2 + assert df is not df2 try: df2.open() assert df2.request_seen(r1) diff --git a/tests/test_engine.py b/tests/test_engine.py index 2cd583721..e51eb4664 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -6,7 +6,6 @@ import subprocess import sys from collections import defaultdict from dataclasses import dataclass -from logging import DEBUG from typing import TYPE_CHECKING, Any, cast from unittest.mock import Mock, call from urllib.parse import urlparse @@ -395,6 +394,7 @@ class TestEngine(TestEngineBase): self._assert_downloaded_responses(run, count=9) self._assert_scraped_items(run) self._assert_signals_caught(run) + self._assert_headers_received(run) self._assert_bytes_received(run) @coroutine_test @@ -606,7 +606,7 @@ class TestEngineDownload(TestEngineDownloadAsync): @coroutine_test -async def test_request_scheduled_signal(caplog): +async def test_request_scheduled_signal(): class TestScheduler(BaseScheduler): def __init__(self): self.enqueued = [] @@ -633,7 +633,6 @@ async def test_request_scheduled_signal(caplog): keep_request = Request("https://keep.example") engine._schedule_request(keep_request) drop_request = Request("https://drop.example") - caplog.set_level(DEBUG) engine._schedule_request(drop_request) assert scheduler.enqueued == [keep_request], ( f"{scheduler.enqueued!r} != [{keep_request!r}]" diff --git a/tests/test_feedexport.py b/tests/test_feedexport.py index 27e0c6445..c1d6f04eb 100644 --- a/tests/test_feedexport.py +++ b/tests/test_feedexport.py @@ -32,7 +32,6 @@ from scrapy.extensions.feedexport import ( FeedSlot, FileFeedStorage, IFeedStorage, - S3FeedStorage, ) from scrapy.utils.python import to_unicode from scrapy.utils.test import get_crawler @@ -499,8 +498,7 @@ class TestFeedExport(TestFeedExportBase): }, } crawler = get_crawler(ItemSpider, settings) - with mock.patch.object(S3FeedStorage, "store"): - yield crawler.crawl(mockserver=self.mockserver) + yield crawler.crawl(mockserver=self.mockserver) assert "feedexport/success_count/FileFeedStorage" in crawler.stats.get_stats() assert "feedexport/success_count/StdoutFeedStorage" in crawler.stats.get_stats() assert crawler.stats.get_value("feedexport/success_count/FileFeedStorage") == 1 diff --git a/tests/test_feedexport_batch.py b/tests/test_feedexport_batch.py index d855d0f74..0a926479b 100644 --- a/tests/test_feedexport_batch.py +++ b/tests/test_feedexport_batch.py @@ -315,7 +315,7 @@ class TestBatchDeliveries(TestFeedExportBase): } data = await self.exported_data(items, settings) for fmt, expected in formats.items(): - for expected_batch, got_batch in zip(expected, data[fmt], strict=False): + for expected_batch, got_batch in zip(expected, data[fmt], strict=True): assert got_batch == expected_batch @coroutine_test @@ -339,7 +339,7 @@ class TestBatchDeliveries(TestFeedExportBase): } data = await self.exported_data(items, settings) for fmt, expected in formats.items(): - for expected_batch, got_batch in zip(expected, data[fmt], strict=False): + for expected_batch, got_batch in zip(expected, data[fmt], strict=True): assert got_batch == expected_batch @coroutine_test @@ -447,7 +447,7 @@ class TestBatchDeliveries(TestFeedExportBase): yield crawler.crawl() assert len(CustomS3FeedStorage.stubs) == len(items) - for stub in CustomS3FeedStorage.stubs[:-1]: + for stub in CustomS3FeedStorage.stubs: stub.assert_no_pending_responses() assert ( "feedexport/success_count/CustomS3FeedStorage" in crawler.stats.get_stats() diff --git a/tests/test_feedexport_postprocess.py b/tests/test_feedexport_postprocess.py index fa1c0586a..6ebcab152 100644 --- a/tests/test_feedexport_postprocess.py +++ b/tests/test_feedexport_postprocess.py @@ -270,7 +270,7 @@ class TestFeedPostProcessedExports(TestFeedExportBase): self._named_tempfile("check_CHECK_NONE"): lzma.compress( self.expected, check=lzma.CHECK_NONE ), - self._named_tempfile("check_CHECK_CRC256"): lzma.compress( + self._named_tempfile("CHECK_SHA256"): lzma.compress( self.expected, check=lzma.CHECK_SHA256 ), } @@ -282,7 +282,7 @@ class TestFeedPostProcessedExports(TestFeedExportBase): "postprocessing": ["scrapy.extensions.postprocessing.LZMAPlugin"], "lzma_check": lzma.CHECK_NONE, }, - self._named_tempfile("check_CHECK_CRC256"): { + self._named_tempfile("CHECK_SHA256"): { "format": "csv", "postprocessing": ["scrapy.extensions.postprocessing.LZMAPlugin"], "lzma_check": lzma.CHECK_SHA256, diff --git a/tests/test_http2_client_protocol.py b/tests/test_http2_client_protocol.py index cec5d728b..28f306e31 100644 --- a/tests/test_http2_client_protocol.py +++ b/tests/test_http2_client_protocol.py @@ -141,7 +141,7 @@ class Dataloss(LeafResource): class NoContentLengthHeader(LeafResource): def render_GET(self, request: TxRequest): - request.requestHeaders.removeHeader("Content-Length") + request.responseHeaders.removeHeader("Content-Length") self.deferRequest(request, 0, self._delayed_render, request) return NOT_DONE_YET @@ -460,9 +460,7 @@ class TestHttps2ClientProtocol: def test_invalid_negotiated_protocol( self, server_port: int, client: H2ClientProtocol ) -> Generator[Deferred[Any], Any, None]: - with mock.patch( - "scrapy.core.http2.protocol.PROTOCOL_NAME", return_value=b"not-h2" - ): + with mock.patch("scrapy.core.http2.protocol.PROTOCOL_NAME", new=b"not-h2"): request = Request(url=self.get_url(server_port, "/status?n=200")) with pytest.raises(ResponseFailed): yield make_request_dfd(client, request) diff --git a/tests/test_http_request.py b/tests/test_http_request.py index fed5dbab7..fd494504d 100644 --- a/tests/test_http_request.py +++ b/tests/test_http_request.py @@ -23,7 +23,6 @@ class TestRequest: # url argument must be basestring with pytest.raises(TypeError): self.request_class(123) - r = self.request_class("http://www.example.com") r = self.request_class("http://www.example.com") assert isinstance(r.url, str) @@ -211,11 +210,11 @@ class TestRequest: r1.cb_kwargs["key"] = "value" r2 = r1.copy() - # make sure copy does not propagate callbacks + # make sure callbaclks are copied assert r1.callback is somecallback assert r1.errback is somecallback assert r2.callback is r1.callback - assert r2.errback is r2.errback + assert r2.errback is r1.errback # make sure flags list is shallow copied assert r1.flags is not r2.flags, "flags must be a shallow copy, not identical" diff --git a/tests/test_http_response.py b/tests/test_http_response.py index 09c95dc29..079c547c7 100644 --- a/tests/test_http_response.py +++ b/tests/test_http_response.py @@ -1,13 +1,19 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + import pytest -from packaging.version import Version as parse_version -from w3lib import __version__ as w3lib_version from w3lib.encoding import resolve_encoding from scrapy.exceptions import NotSupported from scrapy.http import Headers, Request, Response from scrapy.link import Link +from scrapy.utils._deps_compat import W3LIB_STRIPS_URLS from tests import get_testdata +if TYPE_CHECKING: + from collections.abc import Iterable + class TestResponse: response_class = Response @@ -249,7 +255,7 @@ class TestResponse: r.follow(None) @pytest.mark.xfail( - parse_version(w3lib_version) < parse_version("2.1.1"), + not W3LIB_STRIPS_URLS, reason="https://github.com/scrapy/w3lib/pull/207", strict=True, ) @@ -257,7 +263,7 @@ class TestResponse: self._assert_followed_url("foo ", "http://example.com/foo") @pytest.mark.xfail( - parse_version(w3lib_version) < parse_version("2.1.1"), + not W3LIB_STRIPS_URLS, reason="https://github.com/scrapy/w3lib/pull/207", strict=True, ) @@ -325,16 +331,26 @@ class TestResponse: with pytest.raises(ValueError, match="url can't be None"): list(r.follow_all(urls=[None])) + @pytest.mark.xfail( + not W3LIB_STRIPS_URLS, + reason="https://github.com/scrapy/w3lib/pull/207", + strict=True, + ) def test_follow_all_whitespace(self): relative = ["foo ", "bar ", "foo/bar ", "bar/foo "] absolute = [ - "http://example.com/foo%20", - "http://example.com/bar%20", - "http://example.com/foo/bar%20", - "http://example.com/bar/foo%20", + "http://example.com/foo", + "http://example.com/bar", + "http://example.com/foo/bar", + "http://example.com/bar/foo", ] self._assert_followed_all_urls(relative, absolute) + @pytest.mark.xfail( + not W3LIB_STRIPS_URLS, + reason="https://github.com/scrapy/w3lib/pull/207", + strict=True, + ) def test_follow_all_whitespace_links(self): absolute = [ "http://example.com/foo ", @@ -342,8 +358,8 @@ class TestResponse: "http://example.com/foo/bar ", "http://example.com/bar/foo ", ] - links = map(Link, absolute) - expected = [u.replace(" ", "%20") for u in absolute] + links = [Link(u) for u in absolute] + expected = [u.strip() for u in absolute] self._assert_followed_all_urls(links, expected) def test_follow_all_flags(self): @@ -357,25 +373,36 @@ class TestResponse: for req in fol: assert req.flags == ["cached", "allowed"] - def _assert_followed_url(self, follow_obj, target_url, response=None): + def _assert_followed_url( + self, + follow_obj: str | Link, + target_url: str, + response: Response | None = None, + encoding: str | None = None, + ) -> None: if response is None: response = self._links_response() req = response.follow(follow_obj) assert req.url == target_url - return req + if encoding is not None: + assert req.encoding == encoding - def _assert_followed_all_urls(self, follow_obj, target_urls, response=None): + def _assert_followed_all_urls( + self, + follow_obj: Iterable[str | Link], + target_urls: Iterable[str], + response: Response | None = None, + ) -> None: if response is None: response = self._links_response() followed = response.follow_all(follow_obj) - for req, target in zip(followed, target_urls, strict=False): + for req, target in zip(followed, target_urls, strict=True): assert req.url == target - yield req - def _links_response(self): + def _links_response(self) -> Response: body = get_testdata("link_extractor", "linkextractor.html") return self.response_class("http://example.com/index", body=body) - def _links_response_no_href(self): + def _links_response_no_href(self) -> Response: body = get_testdata("link_extractor", "linkextractor_no_href.html") return self.response_class("http://example.com/index", body=body) diff --git a/tests/test_http_response_text.py b/tests/test_http_response_text.py index c16af52b9..4b3fa2302 100644 --- a/tests/test_http_response_text.py +++ b/tests/test_http_response_text.py @@ -179,7 +179,6 @@ class TestTextResponse(TestResponse): # Inferring encoding from body also cache decoded body as sideeffect, # this test tries to ensure that calling response.encoding and # response.text in indistinct order doesn't affect final - # response.text in indistinct order doesn't affect final # values for encoding and decoded body. url = "http://example.com" body = b"\xef\xbb\xbfWORD" @@ -308,11 +307,12 @@ class TestTextResponse(TestResponse): "http://example.com/sample3.html#foo", "http://www.google.com/something", "http://example.com/innertag.html", + "http://example.com/page%204.html", ] # select elements for sellist in [resp.css("a"), resp.xpath("//a")]: - for sel, url in zip(sellist, urls, strict=False): + for sel, url in zip(sellist, urls, strict=True): self._assert_followed_url(sel, url, response=resp) # select elements @@ -324,7 +324,7 @@ class TestTextResponse(TestResponse): # href attributes should work for sellist in [resp.css("a::attr(href)"), resp.xpath("//a/@href")]: - for sel, url in zip(sellist, urls, strict=False): + for sel, url in zip(sellist, urls, strict=True): self._assert_followed_url(sel, url, response=resp) # non-a elements are not supported @@ -376,12 +376,12 @@ class TestTextResponse(TestResponse): encoding="utf8", body='click me'.encode(), ) - req = self._assert_followed_url( + self._assert_followed_url( resp1.css("a")[0], "http://example.com/foo?%D0%BF%D1%80%D0%B8%D0%B2%D0%B5%D1%82", response=resp1, + encoding="utf8", ) - assert req.encoding == "utf8" resp2 = self.response_class( "http://example.com", @@ -390,12 +390,12 @@ class TestTextResponse(TestResponse): "cp1251" ), ) - req = self._assert_followed_url( + self._assert_followed_url( resp2.css("a")[0], "http://example.com/foo?%EF%F0%E8%E2%E5%F2", response=resp2, + encoding="cp1251", ) - assert req.encoding == "cp1251" def test_follow_flags(self): res = self.response_class("http://example.com/") diff --git a/tests/test_logformatter.py b/tests/test_logformatter.py index 9806315b4..360aa613e 100644 --- a/tests/test_logformatter.py +++ b/tests/test_logformatter.py @@ -28,14 +28,14 @@ class TestLogFormatter: self.spider = Spider("default") self.spider.crawler = get_crawler() - def test_crawled_with_referer(self): + def test_crawled_without_referer(self): req = Request("http://www.example.com") res = Response("http://www.example.com") logkws = self.formatter.crawled(req, res, self.spider) logline = logkws["msg"] % logkws["args"] assert logline == "Crawled (200) (referer: None)" - def test_crawled_without_referer(self): + def test_crawled_with_referer(self): req = Request( "http://www.example.com", headers={"referer": "http://example.com"} ) @@ -198,7 +198,7 @@ class TestLogformatterSubclass(TestLogFormatter): self.spider = Spider("default") self.spider.crawler = get_crawler(Spider) - def test_crawled_with_referer(self): + def test_crawled_without_referer(self): req = Request("http://www.example.com") res = Response("http://www.example.com") logkws = self.formatter.crawled(req, res, self.spider) @@ -207,7 +207,7 @@ class TestLogformatterSubclass(TestLogFormatter): logline == "Crawled (200) (referer: None) []" ) - def test_crawled_without_referer(self): + def test_crawled_with_referer(self): req = Request( "http://www.example.com", headers={"referer": "http://example.com"}, diff --git a/tests/test_pipeline_media.py b/tests/test_pipeline_media.py index 44df0bdd4..da1bfa317 100644 --- a/tests/test_pipeline_media.py +++ b/tests/test_pipeline_media.py @@ -175,10 +175,6 @@ class MockedMediaPipeline(UserDefinedPipeline): super().__init__(*args, crawler=crawler, **kwargs) self._mockcalled = [] - def download(self, request, info): - self._mockcalled.append("download") - return super().download(request, info) - def media_to_download(self, request, info, *, item=None): self._mockcalled.append("media_to_download") if "result" in request.meta: diff --git a/tests/test_robotstxt_interface.py b/tests/test_robotstxt_interface.py index 29b23496a..5249736f2 100644 --- a/tests/test_robotstxt_interface.py +++ b/tests/test_robotstxt_interface.py @@ -1,5 +1,3 @@ -import sys - import pytest from scrapy.robotstxt import ( @@ -8,6 +6,7 @@ from scrapy.robotstxt import ( RerpRobotParser, decode_robotstxt, ) +from scrapy.utils._deps_compat import STDLIB_IMPROVED_ROBOTFILEPARSER def rerp_available() -> bool: @@ -139,28 +138,25 @@ class TestDecodeRobotsTxt: class TestPythonRobotParser(BaseRobotParserTest): - # https://github.com/python/cpython/pull/149374 improves it - IMPROVED_ROBOTFILEPARSER = sys.version_info >= (3, 14, 5) - def setup_method(self): super()._setUp(PythonRobotParser) @pytest.mark.skipif( - not IMPROVED_ROBOTFILEPARSER, + not STDLIB_IMPROVED_ROBOTFILEPARSER, reason="RobotFileParser from this Python version does not support length based directives precedence.", ) def test_length_based_precedence(self): super().test_length_based_precedence() @pytest.mark.skipif( - IMPROVED_ROBOTFILEPARSER, + STDLIB_IMPROVED_ROBOTFILEPARSER, reason="RobotFileParser from this Python version does not support order based directives precedence.", ) def test_order_based_precedence(self): super().test_order_based_precedence() @pytest.mark.skipif( - not IMPROVED_ROBOTFILEPARSER, + not STDLIB_IMPROVED_ROBOTFILEPARSER, reason="RobotFileParser from this Python version does not support wildcards.", ) def test_allowed_wildcards(self): diff --git a/tests/test_scheduler_base.py b/tests/test_scheduler_base.py index db023e1f8..08acacae7 100644 --- a/tests/test_scheduler_base.py +++ b/tests/test_scheduler_base.py @@ -104,7 +104,7 @@ class TestMinimalScheduler(InterfaceCheckMixin): for url in URLS: assert self.scheduler.enqueue_request(Request(url)) assert not self.scheduler.enqueue_request(Request(url)) - assert self.scheduler.has_pending_requests + assert self.scheduler.has_pending_requests() dequeued = [] while self.scheduler.has_pending_requests(): diff --git a/tests/test_spidermiddleware.py b/tests/test_spidermiddleware.py index a0d296553..e5891474b 100644 --- a/tests/test_spidermiddleware.py +++ b/tests/test_spidermiddleware.py @@ -238,16 +238,6 @@ class TestProcessSpiderOutputAsyncGen(TestProcessSpiderOutputSimple): yield item -class ProcessSpiderOutputNonIterableMiddleware: - def process_spider_output(self, response, result): - return - - -class ProcessSpiderOutputCoroutineMiddleware: - async def process_spider_output(self, response, result): - return result - - class ProcessStartSimpleMiddleware: async def process_start(self, start): async for item_or_request in start: @@ -423,20 +413,12 @@ class TestBuiltinMiddlewareAsyncGen(TestBuiltinMiddlewareSimple): class TestProcessSpiderException(TestBaseAsyncSpiderMiddleware): ITEM_TYPE = dict MW_ASYNCGEN = ProcessSpiderOutputAsyncGenMiddleware - MW_UNIVERSAL = ProcessSpiderOutputUniversalMiddleware MW_EXC_SIMPLE = ProcessSpiderExceptionSimpleIterableMiddleware MW_EXC_ASYNCGEN = ProcessSpiderExceptionAsyncIteratorMiddleware def _callback(self) -> Any: 1 / 0 - async def _test_asyncgen_nodowngrade(self, *mw_classes: type[Any]) -> None: - with pytest.raises( - _InvalidOutput, - match=r"Async iterable returned from .+ cannot be downgraded", - ): - await self._get_middleware_result(*mw_classes) - @coroutine_test async def test_exc_simple(self): """Simple exc mw""" diff --git a/tests/test_spidermiddleware_process_start.py b/tests/test_spidermiddleware_process_start.py index c907c6d73..21df73a65 100644 --- a/tests/test_spidermiddleware_process_start.py +++ b/tests/test_spidermiddleware_process_start.py @@ -14,7 +14,6 @@ from .utils.decorators import coroutine_test ITEM_A = {"id": "a"} ITEM_B = {"id": "b"} ITEM_C = {"id": "c"} -ITEM_D = {"id": "d"} class AsyncioSleepSpiderMiddleware: @@ -47,10 +46,6 @@ class ModernWrapSpider(Spider): yield ITEM_B -class ModernWrapSpiderSubclass(ModernWrapSpider): - name = "test" - - class ModernWrapSpiderMiddleware: async def process_start(self, start): yield ITEM_A @@ -79,10 +74,6 @@ class TestMain: expected_items = expected_items or [ITEM_A, ITEM_B, ITEM_C] await self._test([spider_middleware], spider_cls, expected_items) - async def _test_douple_wrap(self, smw1, smw2, spider_cls, expected_items=None): - expected_items = expected_items or [ITEM_A, ITEM_A, ITEM_B, ITEM_C, ITEM_C] - await self._test([smw1, smw2], spider_cls, expected_items) - @coroutine_test async def test_modern_mw_modern_spider(self): with warnings.catch_warnings(): diff --git a/tests/test_spidermiddleware_referer.py b/tests/test_spidermiddleware_referer.py index 7431ea6ac..a9089419a 100644 --- a/tests/test_spidermiddleware_referer.py +++ b/tests/test_spidermiddleware_referer.py @@ -836,7 +836,7 @@ class TestRequestMetaSettingFallback: request_meta, policy_class, check_warning, - ) in self.params[3:]: + ) in self.params: mw = RefererMiddleware(Settings(settings)) response = Response(origin, headers=response_headers) diff --git a/tests/test_spiderstate.py b/tests/test_spiderstate.py index 491fc88f7..e44cfca90 100644 --- a/tests/test_spiderstate.py +++ b/tests/test_spiderstate.py @@ -1,4 +1,7 @@ +from __future__ import annotations + from datetime import datetime, timezone +from typing import TYPE_CHECKING import pytest @@ -7,8 +10,11 @@ from scrapy.extensions.spiderstate import SpiderState from scrapy.spiders import Spider from scrapy.utils.test import get_crawler +if TYPE_CHECKING: + from pathlib import Path -def test_store_load(tmp_path): + +def test_store_load(tmp_path: Path) -> None: jobdir = str(tmp_path) spider = Spider(name="default") @@ -16,6 +22,7 @@ def test_store_load(tmp_path): ss = SpiderState(jobdir) ss.spider_opened(spider) + assert hasattr(spider, "state") spider.state["one"] = 1 spider.state["dt"] = dt ss.spider_closed(spider) @@ -23,21 +30,23 @@ def test_store_load(tmp_path): spider2 = Spider(name="default") ss2 = SpiderState(jobdir) ss2.spider_opened(spider2) - assert spider.state == {"one": 1, "dt": dt} + assert hasattr(spider2, "state") + assert spider2.state == {"one": 1, "dt": dt} ss2.spider_closed(spider2) -def test_state_attribute(): +def test_state_attribute() -> None: # state attribute must be present if jobdir is not set, to provide a # consistent interface spider = Spider(name="default") ss = SpiderState() ss.spider_opened(spider) + assert hasattr(spider, "state") assert spider.state == {} ss.spider_closed(spider) -def test_not_configured(): +def test_not_configured() -> None: crawler = get_crawler(Spider) with pytest.raises(NotConfigured): SpiderState.from_crawler(crawler) diff --git a/tests/test_utils_asyncio.py b/tests/test_utils_asyncio.py index a871a282e..5532b4a31 100644 --- a/tests/test_utils_asyncio.py +++ b/tests/test_utils_asyncio.py @@ -83,6 +83,7 @@ class TestParallelAsyncio: max_parallel_count, ) assert list(range(length)) == sorted(results) + assert parallel_count[0] == 0 assert max_parallel_count[0] <= self.CONCURRENT_ITEMS @coroutine_test @@ -101,6 +102,7 @@ class TestParallelAsyncio: max_parallel_count, ) assert list(range(length)) == sorted(results) + assert parallel_count[0] == 0 assert max_parallel_count[0] <= self.CONCURRENT_ITEMS diff --git a/tests/test_utils_deprecate.py b/tests/test_utils_deprecate.py index c5425d99d..5ea6f678e 100644 --- a/tests/test_utils_deprecate.py +++ b/tests/test_utils_deprecate.py @@ -22,9 +22,7 @@ class NewName(SomeBaseClass): class TestWarnWhenSubclassed: - def _mywarnings( - self, w: list[WarningMessage], category: type[Warning] = MyWarning - ) -> list[WarningMessage]: + def _mywarnings(self, w: list[WarningMessage]) -> list[WarningMessage]: return [x for x in w if x.category is MyWarning] def test_no_warning_on_definition(self): diff --git a/tests/test_utils_httpobj.py b/tests/test_utils_httpobj.py index 9bd86f7fb..0eb330461 100644 --- a/tests/test_utils_httpobj.py +++ b/tests/test_utils_httpobj.py @@ -17,4 +17,4 @@ def test_urlparse_cached(): assert req1a == urlp assert req1a is req1b assert req1a is not req2 - assert req1a is not req2 + assert req1b is not req2 diff --git a/tests/test_utils_log.py b/tests/test_utils_log.py index 8e5020022..ee552df64 100644 --- a/tests/test_utils_log.py +++ b/tests/test_utils_log.py @@ -76,15 +76,16 @@ class TestLogCounterHandler: @pytest.fixture def logger(self, crawler: Crawler) -> Generator[logging.Logger]: logger = logging.getLogger("test") - logger.setLevel(logging.NOTSET) + logger.setLevel(logging.DEBUG) logger.propagate = False - handler = LogCounterHandler(crawler) + handler = LogCounterHandler(crawler, level=crawler.settings.get("LOG_LEVEL")) logger.addHandler(handler) - - yield logger - - logger.propagate = True - logger.removeHandler(handler) + try: + yield logger + finally: + logger.propagate = True + logger.setLevel(logging.NOTSET) + logger.removeHandler(handler) def test_init(self, crawler: Crawler, logger: logging.Logger) -> None: assert crawler.stats @@ -102,7 +103,7 @@ class TestLogCounterHandler: def test_filtered_out_level(self, crawler: Crawler, logger: logging.Logger) -> None: logger.debug("test log msg") assert crawler.stats - assert crawler.stats.get_value("log_count/INFO") is None + assert crawler.stats.get_value("log_count/DEBUG") is None class TestStreamLogger: diff --git a/tests/test_utils_response.py b/tests/test_utils_response.py index e02bdfb69..4544cd29e 100644 --- a/tests/test_utils_response.py +++ b/tests/test_utils_response.py @@ -23,7 +23,7 @@ def _read_browser_output(burl: str): def test_open_in_browser(): - url = "http:///www.example.com/some/page.html" + url = "http://www.example.com/some/page.html" body = ( b" test page test body " ) From f63a3aff254f43f06fa7de074159ff1f42546789 Mon Sep 17 00:00:00 2001 From: Nishita Matlani <86043614+Nishieee@users.noreply.github.com> Date: Mon, 15 Jun 2026 02:41:55 -0400 Subject: [PATCH 191/248] Fix strip_url() removing default port from password. (#7605) --- scrapy/utils/url.py | 3 ++- tests/test_utils_url.py | 12 ++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/scrapy/utils/url.py b/scrapy/utils/url.py index 8f75e2618..4d2bbdda2 100644 --- a/scrapy/utils/url.py +++ b/scrapy/utils/url.py @@ -139,7 +139,8 @@ def strip_url( ("ftp", 21), } ): - netloc = netloc.replace(f":{parsed_url.port}", "") + port_suffix = f":{parsed_url.port}" + netloc = netloc.removesuffix(port_suffix) return urlunparse( ( diff --git a/tests/test_utils_url.py b/tests/test_utils_url.py index 19a31c353..a74b9a41d 100644 --- a/tests/test_utils_url.py +++ b/tests/test_utils_url.py @@ -346,6 +346,18 @@ class TestStripUrl: "ftp://username:password@www.example.com:221/file.txt", "ftp://username:password@www.example.com:221/file.txt", ), + ( + "http://user:80@www.example.com:80/index.html", + "http://user:80@www.example.com/index.html", + ), + ( + "https://user:443@www.example.com:443/index.html", + "https://user:443@www.example.com/index.html", + ), + ( + "ftp://user:21@www.example.com:21/file.txt", + "ftp://user:21@www.example.com/file.txt", + ), ], ) def test_default_ports(self, url: str, expected: str) -> None: From 9893a7fac62a5aa29cf2bbf3282742fbfac3b7c5 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Mon, 15 Jun 2026 11:44:33 +0500 Subject: [PATCH 192/248] Fix deprecation warnings with pyOpenSSL 26.3.0. (#7619) --- scrapy/utils/_deps_compat.py | 4 +- scrapy/utils/ssl.py | 72 ++++++++++++++++----- tests/mockserver/utils.py | 4 +- tests/test_downloader_handlers_http_base.py | 16 ++++- tox.ini | 2 +- 5 files changed, 74 insertions(+), 24 deletions(-) diff --git a/scrapy/utils/_deps_compat.py b/scrapy/utils/_deps_compat.py index 08e8cad24..17957aba4 100644 --- a/scrapy/utils/_deps_compat.py +++ b/scrapy/utils/_deps_compat.py @@ -18,8 +18,8 @@ TWISTED_TLS_NEW_IMPL = TWISTED_VERSION >= TxVersion("twisted", 26, 4, 0) TWISTED_TLS_LIMITS_OFFBY1 = TWISTED_VERSION < TxVersion("twisted", 26, 4, 0) PYOPENSSL_VERSION = Version(PYOPENSSL_VERSION_STRING) -# SSL.Context.use_certificate() wants an X509 object, SSL.Context.use_privatekey() wants a PKey object -PYOPENSSL_WANTS_X509_PKEY = PYOPENSSL_VERSION < Version("24.3.0") +# pyOpenSSL X.509 APIs are deprecated and cryptography-based ones are preferred +PYOPENSSL_X509_DEPRECATED = PYOPENSSL_VERSION >= Version("24.3.0") # SSL.Context.set_cipher_list() creates a temporary connection, making the context immutable PYOPENSSL_SET_CIPHER_LIST_TMP_CONN = PYOPENSSL_VERSION < Version("25.2.0") diff --git a/scrapy/utils/ssl.py b/scrapy/utils/ssl.py index 22e9414b8..828a99e23 100644 --- a/scrapy/utils/ssl.py +++ b/scrapy/utils/ssl.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging import ssl +import warnings from typing import TYPE_CHECKING, Any, TypedDict, TypeVar import OpenSSL._util as pyOpenSSLutil @@ -9,7 +10,11 @@ import OpenSSL.SSL import OpenSSL.version from twisted.internet.ssl import CertificateOptions, TLSVersion -from scrapy.utils._deps_compat import TWISTED_TLS_LIMITS_OFFBY1 +from scrapy.exceptions import ScrapyDeprecationWarning +from scrapy.utils._deps_compat import ( + PYOPENSSL_X509_DEPRECATED, + TWISTED_TLS_LIMITS_OFFBY1, +) from scrapy.utils.python import to_unicode if TYPE_CHECKING: @@ -116,23 +121,42 @@ def _log_sslobj_debug_info(sslobj: ssl.SSLObject) -> None: # pyOpenSSL utils -def ffi_buf_to_string(buf: Any) -> str: +def _ffi_buf_to_string(buf: Any) -> str: return to_unicode(pyOpenSSLutil.ffi.string(buf)) -def x509name_to_string(x509name: X509Name) -> str: +def ffi_buf_to_string(buf: Any) -> str: # pragma: no cover + warnings.warn( + "ffi_buf_to_string() is deprecated.", + ScrapyDeprecationWarning, + stacklevel=2, + ) + return ffi_buf_to_string(buf) + + +def _x509name_to_string(x509name: X509Name) -> str: # from OpenSSL.crypto.X509Name.__repr__ + # only used on pyOpenSSL < 24.3.0 result_buffer: Any = pyOpenSSLutil.ffi.new("char[]", 512) pyOpenSSLutil.lib.X509_NAME_oneline( x509name._name, result_buffer, len(result_buffer) ) - return ffi_buf_to_string(result_buffer) + return _ffi_buf_to_string(result_buffer) -def get_temp_key_info(ssl_object: Any) -> str | None: +def x509name_to_string(x509name: X509Name) -> str: # pragma: no cover + warnings.warn( + "x509name_to_string() is deprecated.", + ScrapyDeprecationWarning, + stacklevel=2, + ) + return _x509name_to_string(x509name) + + +def _get_temp_key_info(ssl_object: Any) -> str | None: # adapted from OpenSSL apps/s_cb.c::ssl_print_tmp_key() if not hasattr(pyOpenSSLutil.lib, "SSL_get_server_tmp_key"): - # removed in cryptography 40.0.0 + # removed in cryptography 40.0.0 (required starting from pyOpenSSL 23.1.0) return None temp_key_p = pyOpenSSLutil.ffi.new("EVP_PKEY **") if not pyOpenSSLutil.lib.SSL_get_server_tmp_key(ssl_object, temp_key_p): @@ -157,13 +181,22 @@ def get_temp_key_info(ssl_object: Any) -> str | None: cname = pyOpenSSLutil.lib.EC_curve_nid2nist(nid) if cname == pyOpenSSLutil.ffi.NULL: cname = pyOpenSSLutil.lib.OBJ_nid2sn(nid) - key_info.append(ffi_buf_to_string(cname)) + key_info.append(_ffi_buf_to_string(cname)) else: - key_info.append(ffi_buf_to_string(pyOpenSSLutil.lib.OBJ_nid2sn(key_type))) + key_info.append(_ffi_buf_to_string(pyOpenSSLutil.lib.OBJ_nid2sn(key_type))) key_info.append(f"{pyOpenSSLutil.lib.EVP_PKEY_bits(temp_key)} bits") return ", ".join(key_info) +def get_temp_key_info(ssl_object: Any) -> str | None: # pragma: no cover + warnings.warn( + "get_temp_key_info() is deprecated. It's also a no-op with cryptography 40.0.0+.", + ScrapyDeprecationWarning, + stacklevel=2, + ) + return _get_temp_key_info(ssl_object) + + def get_openssl_version() -> str: system_openssl_bytes = OpenSSL.SSL.SSLeay_version(OpenSSL.SSL.SSLEAY_VERSION) system_openssl = system_openssl_bytes.decode("ascii", errors="replace") @@ -177,14 +210,21 @@ def _log_ssl_conn_debug_info(hostname: str, connection: OpenSSL.SSL.Connection) connection.get_protocol_version_name(), connection.get_cipher_name(), ) - server_cert = connection.get_peer_certificate() - if server_cert: - logger.debug( - 'SSL connection certificate: issuer "%s", subject "%s"', - x509name_to_string(server_cert.get_issuer()), - x509name_to_string(server_cert.get_subject()), - ) - key_info = get_temp_key_info(connection._ssl) + if PYOPENSSL_X509_DEPRECATED: + if server_cert := connection.get_peer_certificate(as_cryptography=True): + logger.debug( + 'SSL connection certificate: issuer "%s", subject "%s"', + server_cert.issuer.rfc4514_string(), + server_cert.subject.rfc4514_string(), + ) + else: # noqa: PLR5501 + if server_cert_pyopenssl := connection.get_peer_certificate(): + logger.debug( + 'SSL connection certificate: issuer "%s", subject "%s"', + _x509name_to_string(server_cert_pyopenssl.get_issuer()), + _x509name_to_string(server_cert_pyopenssl.get_subject()), + ) + key_info = _get_temp_key_info(connection._ssl) if key_info: logger.debug("SSL temp key: %s", key_info) diff --git a/tests/mockserver/utils.py b/tests/mockserver/utils.py index 7aa656780..5c4ca7457 100644 --- a/tests/mockserver/utils.py +++ b/tests/mockserver/utils.py @@ -10,7 +10,7 @@ from OpenSSL.crypto import FILETYPE_PEM, load_certificate, load_privatekey from twisted.internet.ssl import CertificateOptions, ContextFactory from scrapy.core.downloader.tls import _TWISTED_VERSION_MAP -from scrapy.utils._deps_compat import PYOPENSSL_WANTS_X509_PKEY +from scrapy.utils._deps_compat import PYOPENSSL_X509_DEPRECATED from scrapy.utils.python import to_bytes from scrapy.utils.ssl import _get_cert_options_version_kwargs @@ -29,7 +29,7 @@ def ssl_context_factory( keyfile_path = Path(__file__).parent.parent / keyfile certfile_path = Path(__file__).parent.parent / certfile - if not PYOPENSSL_WANTS_X509_PKEY: + if PYOPENSSL_X509_DEPRECATED: cert = load_pem_x509_certificate(certfile_path.read_bytes()) key = load_pem_private_key(keyfile_path.read_bytes(), password=None) else: diff --git a/tests/test_downloader_handlers_http_base.py b/tests/test_downloader_handlers_http_base.py index 223f288bf..25a200817 100644 --- a/tests/test_downloader_handlers_http_base.py +++ b/tests/test_downloader_handlers_http_base.py @@ -33,7 +33,10 @@ from scrapy.exceptions import ( UnsupportedURLSchemeError, ) from scrapy.http import Headers, HtmlResponse, Request, Response, TextResponse -from scrapy.utils._deps_compat import TWISTED_TLS_LIMITS_OFFBY1 +from scrapy.utils._deps_compat import ( + PYOPENSSL_X509_DEPRECATED, + TWISTED_TLS_LIMITS_OFFBY1, +) from scrapy.utils.defer import deferred_from_coro, maybe_deferred_to_future from scrapy.utils.misc import build_from_crawler from scrapy.utils.spider import DefaultSpider @@ -831,8 +834,15 @@ class TestHttpsBase(TestHttpBase): is_secure = True tls_log_message = ( - 'SSL connection certificate: issuer "/C=IE/O=Scrapy/CN=localhost", ' - 'subject "/C=IE/O=Scrapy/CN=localhost"' + ( + 'SSL connection certificate: issuer "CN=localhost,O=Scrapy,C=IE", ' + 'subject "CN=localhost,O=Scrapy,C=IE"' + ) + if PYOPENSSL_X509_DEPRECATED + else ( + 'SSL connection certificate: issuer "/C=IE/O=Scrapy/CN=localhost", ' + 'subject "/C=IE/O=Scrapy/CN=localhost"' + ) ) def test_download_conn_lost(self) -> None: # type: ignore[override] diff --git a/tox.ini b/tox.ini index 50ac7aa86..44e240e03 100644 --- a/tox.ini +++ b/tox.ini @@ -82,7 +82,7 @@ deps = ptpython==3.0.32 # newer ones require newer Python ipython==8.39.0 - pyOpenSSL==26.2.0 + pyOpenSSL==26.3.0 pytest==9.0.3 socksio==1.0.0 types-Pygments==2.20.0.20260508 From 4f241b73be197b2a5aa3858302eb241f98e4f944 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Mon, 15 Jun 2026 11:45:26 +0500 Subject: [PATCH 193/248] Fix deprecation warnings with pytest 9.1. (#7621) --- tests/test_downloader_handlers_http_base.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/tests/test_downloader_handlers_http_base.py b/tests/test_downloader_handlers_http_base.py index 25a200817..304ef9f2b 100644 --- a/tests/test_downloader_handlers_http_base.py +++ b/tests/test_downloader_handlers_http_base.py @@ -906,16 +906,17 @@ class TestSimpleHttpsBase(ABC): cipher_string: str | None = None @pytest.fixture(scope="class") - def simple_mockserver(self) -> Generator[SimpleMockServer]: + @classmethod + def simple_mockserver(cls) -> Generator[SimpleMockServer]: with SimpleMockServer( - self.keyfile, self.certfile, cipher_string=self.cipher_string + cls.keyfile, cls.certfile, cipher_string=cls.cipher_string ) as simple_mockserver: yield simple_mockserver @pytest.fixture(scope="class") - def url(self, simple_mockserver: SimpleMockServer) -> str: - # need to use self.host instead of what mockserver returns - return f"https://{self.host}:{simple_mockserver.port(is_secure=True)}/file" + @classmethod + def url(cls, simple_mockserver: SimpleMockServer) -> str: + return f"https://{cls.host}:{simple_mockserver.port(is_secure=True)}/file" @property @abstractmethod From cfef12392a52591bf6bbb1d3c5dc57175913a63b Mon Sep 17 00:00:00 2001 From: Syncrain <71864702+syncrain@users.noreply.github.com> Date: Mon, 15 Jun 2026 12:16:30 +0530 Subject: [PATCH 194/248] Fix request_to_curl handling of verbose cookies dictionaries (#7603) --- scrapy/utils/request.py | 10 ++++++++- tests/test_utils_request.py | 42 +++++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/scrapy/utils/request.py b/scrapy/utils/request.py index ffb7fae49..398403d90 100644 --- a/scrapy/utils/request.py +++ b/scrapy/utils/request.py @@ -179,6 +179,12 @@ def _get_method(obj: Any, name: Any) -> Any: raise ValueError(f"Method {name!r} not found in: {obj}") from None +def _cookie_value_to_unicode(value: str | bytes | float) -> str: + if isinstance(value, bytes): + return value.decode() + return str(value) + + def request_to_curl(request: Request) -> str: """ Converts a :class:`~scrapy.Request` object to a curl command. @@ -202,7 +208,9 @@ def request_to_curl(request: Request) -> str: cookies = f"--cookie '{cookie}'" elif isinstance(request.cookies, list): cookie = "; ".join( - f"{next(iter(c.keys()))}={next(iter(c.values()))}" + f"{_cookie_value_to_unicode(c['name'])}={_cookie_value_to_unicode(c['value'])}" + if "name" in c and "value" in c + else f"{next(iter(c.keys()))}={next(iter(c.values()))}" for c in request.cookies ) cookies = f"--cookie '{cookie}'" diff --git a/tests/test_utils_request.py b/tests/test_utils_request.py index 55c46059e..e4967d4e7 100644 --- a/tests/test_utils_request.py +++ b/tests/test_utils_request.py @@ -413,3 +413,45 @@ class TestRequestToCurl: " --data-raw '{\"foo\": \"bar\"}' --cookie 'foo=bar'" ) self._test_request(request_object, expected_curl_command) + + def test_cookies_list_verbose(self): + request_object = Request( + "https://www.httpbin.org/post", + method="POST", + cookies=[ + { + "name": b"foo", + "value": b"bar", + "domain": "example.com", + "path": "/", + "secure": True, + } + ], + body=json.dumps({"foo": "bar"}), + ) + expected_curl_command = ( + "curl -X POST https://www.httpbin.org/post" + " --data-raw '{\"foo\": \"bar\"}' --cookie 'foo=bar'" + ) + self._test_request(request_object, expected_curl_command) + + def test_cookies_list_verbose_non_string_value(self): + request_object = Request( + "https://www.httpbin.org/post", + method="POST", + cookies=[ + { + "name": "foo", + "value": 1, + "domain": "example.com", + "path": "/", + "secure": True, + } + ], + body=json.dumps({"foo": "bar"}), + ) + expected_curl_command = ( + "curl -X POST https://www.httpbin.org/post" + " --data-raw '{\"foo\": \"bar\"}' --cookie 'foo=1'" + ) + self._test_request(request_object, expected_curl_command) From e74647572d692598ca943db539d45aefdc4956ac Mon Sep 17 00:00:00 2001 From: Adrian Date: Mon, 15 Jun 2026 11:18:29 +0200 Subject: [PATCH 195/248] Test SignalManager.disconnect_all() (#7625) --- tests/test_signalmanager.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 tests/test_signalmanager.py diff --git a/tests/test_signalmanager.py b/tests/test_signalmanager.py new file mode 100644 index 000000000..ce4d97adb --- /dev/null +++ b/tests/test_signalmanager.py @@ -0,0 +1,21 @@ +from scrapy.signalmanager import SignalManager + + +class TestSignalManager: + def test_disconnect_all(self): + signal = object() + sender = object() + sm = SignalManager(sender) + + calls = [] + + def handler(): + calls.append(1) + + sm.connect(handler, signal) + sm.send_catch_log(signal) + assert calls == [1] + + sm.disconnect_all(signal) + sm.send_catch_log(signal) + assert calls == [1] # handler no longer called after disconnect_all From 0a4a92e84334770a3aca3a3c44985cb08692535c Mon Sep 17 00:00:00 2001 From: Kushal Gupta <98078018+guptakushal03@users.noreply.github.com> Date: Tue, 16 Jun 2026 00:54:44 +0530 Subject: [PATCH 196/248] Fix image store ACL settings for S3 and GCS (#7614) * Fix image store ACL settings for S3 and GCS * Fix typing issues in ImagesPipeline ACL settings * Fix typing issues in ImagesPipeline ACL settings * Call parent _update_stores in ImagesPipeline --- scrapy/pipelines/images.py | 25 ++++++++++++++++++++-- tests/test_pipeline_images.py | 39 +++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/scrapy/pipelines/images.py b/scrapy/pipelines/images.py index 83d04e6ca..762b0fdf1 100644 --- a/scrapy/pipelines/images.py +++ b/scrapy/pipelines/images.py @@ -11,14 +11,20 @@ import hashlib import warnings from contextlib import suppress from io import BytesIO -from typing import TYPE_CHECKING, Any, ClassVar +from typing import TYPE_CHECKING, Any, ClassVar, cast from itemadapter import ItemAdapter from scrapy.exceptions import NotConfigured, ScrapyDeprecationWarning from scrapy.http import Request, Response from scrapy.http.request import NO_CALLBACK -from scrapy.pipelines.files import FileException, FilesPipeline, _md5sum +from scrapy.pipelines.files import ( + FileException, + FilesPipeline, + GCSFilesStore, + S3FilesStore, + _md5sum, +) from scrapy.utils.defer import ensure_awaitable from scrapy.utils.python import to_bytes @@ -33,6 +39,7 @@ if TYPE_CHECKING: from scrapy.crawler import Crawler from scrapy.pipelines.media import FileInfoOrError, MediaPipeline + from scrapy.settings import BaseSettings class ImageException(FileException): @@ -126,6 +133,20 @@ class ImagesPipeline(FilesPipeline): ) -> str: return await self.image_downloaded(response, request, info, item=item) + @classmethod + def _update_stores(cls, settings: BaseSettings) -> None: + super()._update_stores(settings) + + s3store: type[S3FilesStore] = cast( + "type[S3FilesStore]", cls.STORE_SCHEMES["s3"] + ) + s3store.POLICY = settings["IMAGES_STORE_S3_ACL"] + + gcs_store: type[GCSFilesStore] = cast( + "type[GCSFilesStore]", cls.STORE_SCHEMES["gs"] + ) + gcs_store.POLICY = settings["IMAGES_STORE_GCS_ACL"] or None + async def image_downloaded( self, response: Response, diff --git a/tests/test_pipeline_images.py b/tests/test_pipeline_images.py index 38662348f..1b73dd157 100644 --- a/tests/test_pipeline_images.py +++ b/tests/test_pipeline_images.py @@ -14,6 +14,7 @@ from itemadapter import ItemAdapter from scrapy.http import Request, Response from scrapy.item import Field, Item +from scrapy.pipelines.files import GCSFilesStore, S3FilesStore from scrapy.pipelines.images import ImageException, ImagesPipeline from scrapy.utils.test import get_crawler @@ -541,6 +542,44 @@ class TestImagesPipelineCustomSettings: expected_value = settings.get(settings_attr) assert getattr(pipeline_cls, pipe_attr.lower()) == expected_value + def test_images_store_s3_acl_setting_used(self, tmp_path): + old_policy = S3FilesStore.POLICY + + try: + crawler = get_crawler( + None, + { + "IMAGES_STORE": tmp_path, + "IMAGES_STORE_S3_ACL": "public-read", + "FILES_STORE_S3_ACL": "private", + }, + ) + + ImagesPipeline.from_crawler(crawler) + + assert S3FilesStore.POLICY == "public-read" + finally: + S3FilesStore.POLICY = old_policy + + def test_images_store_gcs_acl_setting_used(self, tmp_path): + old_policy = GCSFilesStore.POLICY + + try: + crawler = get_crawler( + None, + { + "IMAGES_STORE": tmp_path, + "IMAGES_STORE_GCS_ACL": "authenticatedRead", + "FILES_STORE_GCS_ACL": "", + }, + ) + + ImagesPipeline.from_crawler(crawler) + + assert GCSFilesStore.POLICY == "authenticatedRead" + finally: + GCSFilesStore.POLICY = old_policy + def _create_image(format_, *a, **kw): buf = io.BytesIO() From b7824db573ca5a50890024f3e2e63c714a62aa20 Mon Sep 17 00:00:00 2001 From: JSap0914 <116227558+JSap0914@users.noreply.github.com> Date: Wed, 17 Jun 2026 18:48:43 +0900 Subject: [PATCH 197/248] Fix rel_has_nofollow to be case-insensitive per HTML spec (#7632) The HTML specification states that link type keywords like 'nofollow' are ASCII case-insensitive. Sites using rel="NoFollow" or rel="NOFOLLOW" were incorrectly treated as follow links, causing Scrapy to crawl pages it should skip. Fix: add .lower() before the token split so all casing variants of 'nofollow' are correctly recognized. Co-authored-by: JSap0914 --- scrapy/utils/misc.py | 2 +- tests/test_utils_misc/__init__.py | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/scrapy/utils/misc.py b/scrapy/utils/misc.py index 0b67eaa34..47568e656 100644 --- a/scrapy/utils/misc.py +++ b/scrapy/utils/misc.py @@ -162,7 +162,7 @@ def md5sum(file: IO[bytes]) -> str: def rel_has_nofollow(rel: str | None) -> bool: """Return True if link rel attribute has nofollow type""" - return rel is not None and "nofollow" in rel.replace(",", " ").split() + return rel is not None and "nofollow" in rel.lower().replace(",", " ").split() class SupportsFromCrawler(Protocol[_T_co, _P]): diff --git a/tests/test_utils_misc/__init__.py b/tests/test_utils_misc/__init__.py index a995e38e6..c4c861404 100644 --- a/tests/test_utils_misc/__init__.py +++ b/tests/test_utils_misc/__init__.py @@ -160,3 +160,8 @@ class TestUtilsMisc: assert rel_has_nofollow("nofollowfoo") is False assert rel_has_nofollow("foonofollow") is False assert rel_has_nofollow("ugc, , nofollow") is True + # rel attribute values are ASCII case-insensitive per the HTML spec + assert rel_has_nofollow("NoFollow") is True + assert rel_has_nofollow("NOFOLLOW") is True + assert rel_has_nofollow("UGC NoFollow") is True + assert rel_has_nofollow("ugc,NoFollow") is True From fada8be1db6479e235d7e06afa057632dbc2adf1 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Wed, 17 Jun 2026 14:56:22 +0500 Subject: [PATCH 198/248] Fix Proxy-Authorization handling in BaseStreamingDownloadHandler (#7630) --- conftest.py | 37 +---- .../downloader/handlers/_base_streaming.py | 16 +- scrapy/core/downloader/handlers/_httpx.py | 3 +- tests/test_downloader_handlers_http_base.py | 152 +++++++++--------- 4 files changed, 96 insertions(+), 112 deletions(-) diff --git a/conftest.py b/conftest.py index cf0568111..532f83f56 100644 --- a/conftest.py +++ b/conftest.py @@ -74,40 +74,19 @@ def mockserver() -> Generator[MockServer]: @pytest.fixture # function scope because it modifies os.environ -def mitm_proxy_server(monkeypatch: pytest.MonkeyPatch) -> Generator[MitmProxy]: - proxy = MitmProxy() +def proxy_server( + request: pytest.FixtureRequest, monkeypatch: pytest.MonkeyPatch +) -> Generator[str]: + kind = request.param + proxy = MitmProxy(mode="socks5" if kind == "socks5" else None) url = proxy.start() + if kind == "https": + url = url.replace("http://", "https://") monkeypatch.setenv("http_proxy", url) monkeypatch.setenv("https_proxy", url) try: - yield proxy - finally: - proxy.stop() - - -@pytest.fixture # function scope because it modifies os.environ -def mitm_proxy_server_https(monkeypatch: pytest.MonkeyPatch) -> Generator[MitmProxy]: - proxy = MitmProxy() - url = proxy.start().replace("http://", "https://") - monkeypatch.setenv("http_proxy", url) - monkeypatch.setenv("https_proxy", url) - - try: - yield proxy - finally: - proxy.stop() - - -@pytest.fixture # function scope because it modifies os.environ -def socks5_proxy_server(monkeypatch: pytest.MonkeyPatch) -> Generator[MitmProxy]: - proxy = MitmProxy(mode="socks5") - url = proxy.start() - monkeypatch.setenv("http_proxy", url) - monkeypatch.setenv("https_proxy", url) - - try: - yield proxy + yield kind finally: proxy.stop() diff --git a/scrapy/core/downloader/handlers/_base_streaming.py b/scrapy/core/downloader/handlers/_base_streaming.py index 16b655716..5a669c732 100644 --- a/scrapy/core/downloader/handlers/_base_streaming.py +++ b/scrapy/core/downloader/handlers/_base_streaming.py @@ -241,6 +241,16 @@ class BaseStreamingDownloadHandler(BaseHttpDownloadHandler, ABC, Generic[_Respon body=response_body.getvalue(), ) + @staticmethod + def _request_headers(request: Request) -> Headers: + """Get a prepared copy of the request headers. + + This removes the Proxy-Authorization header. + """ + headers = request.headers.copy() + headers.pop(b"Proxy-Authorization", None) + return headers + def _get_bind_address_host(self) -> str | None: """Return the host portion of the bind address. @@ -279,10 +289,8 @@ class BaseStreamingDownloadHandler(BaseHttpDownloadHandler, ABC, Generic[_Respon if not proxy: return None, None proxy = add_http_if_no_scheme(proxy) - auth_header: list[bytes] | None = request.headers.pop( - b"Proxy-Authorization", None - ) - return proxy, auth_header[0].decode("ascii") if auth_header else None + auth_header: bytes | None = request.headers.get(b"Proxy-Authorization") + return proxy, auth_header.decode("ascii") if auth_header else None def _extract_proxy_url_with_creds(self, request: Request) -> str | None: """Return the proxy URL with the userinfo added based on the diff --git a/scrapy/core/downloader/handlers/_httpx.py b/scrapy/core/downloader/handlers/_httpx.py index c960fc152..9b54b44d8 100644 --- a/scrapy/core/downloader/handlers/_httpx.py +++ b/scrapy/core/downloader/handlers/_httpx.py @@ -152,13 +152,14 @@ class HttpxDownloadHandler(_Base): f"SOCKS proxy support in {type(self).__name__} requires the 'httpx[socks]' extra to be installed." ) client = self._get_client(proxy) + headers = self._request_headers(request).to_tuple_list() try: async with client.stream( request.method, request.url, content=request.body, - headers=request.headers.to_tuple_list(), + headers=headers, timeout=timeout, ) as response: yield response diff --git a/tests/test_downloader_handlers_http_base.py b/tests/test_downloader_handlers_http_base.py index 304ef9f2b..2ae9206a0 100644 --- a/tests/test_downloader_handlers_http_base.py +++ b/tests/test_downloader_handlers_http_base.py @@ -1328,6 +1328,9 @@ class TestHttpProxyBase(ABC): assert response.body == self.expected_http_proxy_request_body +PROXY_KINDS = ["http", "https", "socks5"] + + class TestMitmProxyBase(ABC): # whether the handler supports HTTPS proxies with HTTPS destinations handler_supports_tls_in_tls: bool = True @@ -1338,57 +1341,54 @@ class TestMitmProxyBase(ABC): def settings_dict(self) -> dict[str, Any] | None: raise NotImplementedError - @pytest.mark.parametrize( - "https_dest", [False, True], ids=["HTTP dest", "HTTPS dest"] - ) - @pytest.mark.usefixtures("mitm_proxy_server") - @coroutine_test - async def test_http_proxy( - self, caplog: pytest.LogCaptureFixture, mockserver: MockServer, https_dest: bool - ) -> None: - """HTTP proxy, HTTP or HTTPS destination.""" - crawler = get_crawler(SingleRequestSpider, self.settings_dict) - with caplog.at_level(logging.DEBUG): - await crawler.crawl_async( - seed=mockserver.url("/status?n=200", is_secure=https_dest) - ) - assert isinstance(crawler.spider, SingleRequestSpider) - self._assert_got_response_code(200, caplog.text) - self._assert_headers(crawler.spider.meta["responses"][0].headers, https_dest) - - @pytest.mark.parametrize( - "https_dest", [False, True], ids=["HTTP dest", "HTTPS dest"] - ) - @pytest.mark.usefixtures("mitm_proxy_server_https") - @coroutine_test - async def test_https_proxy( - self, caplog: pytest.LogCaptureFixture, mockserver: MockServer, https_dest: bool - ) -> None: - """HTTPS proxy, HTTP or HTTPS destination.""" - if https_dest and not self.handler_supports_tls_in_tls: + def _maybe_skip(self, proxy_kind: str, https_dest: bool) -> None: + if proxy_kind == "socks5" and not self.handler_supports_socks: + pytest.skip("SOCKS proxies are not supported") + if ( + proxy_kind == "https" + and https_dest + and not self.handler_supports_tls_in_tls + ): pytest.skip("HTTPS proxies for HTTPS destinations are not supported") - crawler = get_crawler(SingleRequestSpider, self.settings_dict) - with caplog.at_level(logging.DEBUG): - await crawler.crawl_async( - seed=mockserver.url("/status?n=200", is_secure=https_dest) - ) - assert isinstance(crawler.spider, SingleRequestSpider) - self._assert_got_response_code(200, caplog.text) - self._assert_headers(crawler.spider.meta["responses"][0].headers, https_dest) + @pytest.mark.parametrize("proxy_server", PROXY_KINDS, indirect=True) @pytest.mark.parametrize( "https_dest", [False, True], ids=["HTTP dest", "HTTPS dest"] ) - @pytest.mark.usefixtures("mitm_proxy_server") @coroutine_test - async def test_http_proxy_auth_error( + async def test_proxy( self, caplog: pytest.LogCaptureFixture, - monkeypatch: pytest.MonkeyPatch, + proxy_server: str, mockserver: MockServer, https_dest: bool, ) -> None: - """HTTP proxy, HTTP or HTTPS destination, wrong proxy creds.""" + """HTTP/HTTPS/SOCKS5 proxy, HTTP or HTTPS destination.""" + self._maybe_skip(proxy_server, https_dest) + crawler = get_crawler(SingleRequestSpider, self.settings_dict) + with caplog.at_level(logging.DEBUG): + await crawler.crawl_async( + seed=mockserver.url("/status?n=200", is_secure=https_dest) + ) + assert isinstance(crawler.spider, SingleRequestSpider) + self._assert_got_response_code(200, caplog.text) + self._assert_headers(crawler.spider.meta["responses"][0].headers, https_dest) + + @pytest.mark.parametrize("proxy_server", PROXY_KINDS, indirect=True) + @pytest.mark.parametrize( + "https_dest", [False, True], ids=["HTTP dest", "HTTPS dest"] + ) + @coroutine_test + async def test_proxy_auth_error( + self, + caplog: pytest.LogCaptureFixture, + monkeypatch: pytest.MonkeyPatch, + proxy_server: str, + mockserver: MockServer, + https_dest: bool, + ) -> None: + """HTTP/HTTPS/SOCKS5 proxy, HTTP or HTTPS destination, wrong proxy creds.""" + self._maybe_skip(proxy_server, https_dest) envvar = "https_proxy" if https_dest else "http_proxy" monkeypatch.setenv(envvar, wrong_credentials(os.environ[envvar])) crawler = get_crawler(SimpleSpider, self.settings_dict) @@ -1396,20 +1396,28 @@ class TestMitmProxyBase(ABC): await crawler.crawl_async( mockserver.url("/status?n=200", is_secure=https_dest) ) - # The proxy returns a 407 error code but it does not reach the client; - # it just sees an exception. - self._assert_got_auth_exception(caplog.text) + if proxy_server == "socks5": + assert "DownloadConnectionRefusedError" in caplog.text + else: + # The proxy returns a 407 error code but it does not reach the + # client; it just sees an exception. + self._assert_got_auth_exception(caplog.text) + @pytest.mark.parametrize("proxy_server", PROXY_KINDS, indirect=True) @pytest.mark.parametrize( "https_dest", [False, True], ids=["HTTP dest", "HTTPS dest"] ) - @pytest.mark.usefixtures("mitm_proxy_server") @coroutine_test - async def test_dont_leak_proxy_authorization_header( - self, caplog: pytest.LogCaptureFixture, mockserver: MockServer, https_dest: bool + async def test_proxy_dont_leak_auth_header( + self, + caplog: pytest.LogCaptureFixture, + proxy_server: str, + mockserver: MockServer, + https_dest: bool, ) -> None: - """HTTP proxy, HTTP or HTTPS destination. Check that the auth header - is not sent to the destination.""" + """HTTP/HTTPS/SOCKS5 proxy, HTTP or HTTPS destination. Check that the + auth header is not sent to the destination.""" + self._maybe_skip(proxy_server, https_dest) request = Request(mockserver.url("/echo", is_secure=https_dest)) crawler = get_crawler(SingleRequestSpider, self.settings_dict) with caplog.at_level(logging.DEBUG): @@ -1420,48 +1428,36 @@ class TestMitmProxyBase(ABC): echo = json.loads(crawler.spider.meta["responses"][0].text) assert "Proxy-Authorization" not in echo["headers"] + @pytest.mark.parametrize("proxy_server", PROXY_KINDS, indirect=True) @pytest.mark.parametrize( "https_dest", [False, True], ids=["HTTP dest", "HTTPS dest"] ) - @pytest.mark.usefixtures("socks5_proxy_server") @coroutine_test - async def test_download_with_socks_proxy( - self, caplog: pytest.LogCaptureFixture, mockserver: MockServer, https_dest: bool - ) -> None: - """SOCKS5 proxy, HTTP or HTTPS destination.""" - if not self.handler_supports_socks: - pytest.skip("SOCKS proxies are not supported") - crawler = get_crawler(SingleRequestSpider, self.settings_dict) - with caplog.at_level(logging.DEBUG): - await crawler.crawl_async( - seed=mockserver.url("/status?n=200", is_secure=https_dest) - ) - assert isinstance(crawler.spider, SingleRequestSpider) - self._assert_got_response_code(200, caplog.text) - self._assert_headers(crawler.spider.meta["responses"][0].headers, https_dest) - - @pytest.mark.parametrize( - "https_dest", [False, True], ids=["HTTP dest", "HTTPS dest"] - ) - @pytest.mark.usefixtures("socks5_proxy_server") - @coroutine_test - async def test_socks_proxy_auth_error( + async def test_proxy_redirect( self, caplog: pytest.LogCaptureFixture, - monkeypatch: pytest.MonkeyPatch, + proxy_server: str, mockserver: MockServer, https_dest: bool, ) -> None: - if not self.handler_supports_socks: - pytest.skip("SOCKS proxies are not supported") - envvar = "https_proxy" if https_dest else "http_proxy" - monkeypatch.setenv(envvar, wrong_credentials(os.environ[envvar])) - crawler = get_crawler(SimpleSpider, self.settings_dict) + """HTTP/HTTPS/SOCKS5 proxy, HTTP or HTTPS destination, following a + redirect. Check that the redirected request still goes through the + proxy and doesn't lose the proxy auth. + """ + self._maybe_skip(proxy_server, https_dest) + crawler = get_crawler(SingleRequestSpider, self.settings_dict) with caplog.at_level(logging.DEBUG): await crawler.crawl_async( - mockserver.url("/status?n=200", is_secure=https_dest) + seed=mockserver.url("/redirect", is_secure=https_dest) ) - assert "DownloadConnectionRefusedError" in caplog.text + assert isinstance(crawler.spider, SingleRequestSpider) + assert crawler.spider.meta.get("failure") is None + responses = crawler.spider.meta.get("responses", []) + assert len(responses) == 1 + assert responses[0].status == 200 + assert responses[0].url == mockserver.url("/redirected", is_secure=https_dest) + self._assert_got_response_code(200, caplog.text) + self._assert_headers(responses[0].headers, https_dest) @staticmethod def _assert_headers(headers: Headers, https_dest: bool) -> None: From abbf3b95fcdf814c3926029ea80e4c33bd866c71 Mon Sep 17 00:00:00 2001 From: Lions-1 <128939687+Lions-1@users.noreply.github.com> Date: Fri, 19 Jun 2026 05:41:26 +0100 Subject: [PATCH 199/248] tests: integration coverage for memusage extension (#7017) --- scrapy/extensions/memusage.py | 6 +- tests/test_extension_memusage.py | 115 +++++++++++++++++++++++++++++++ tests/utils/__init__.py | 17 +++++ 3 files changed, 135 insertions(+), 3 deletions(-) create mode 100644 tests/test_extension_memusage.py diff --git a/scrapy/extensions/memusage.py b/scrapy/extensions/memusage.py index 01af02ba3..1444c8941 100644 --- a/scrapy/extensions/memusage.py +++ b/scrapy/extensions/memusage.py @@ -113,7 +113,7 @@ class MemoryUsage: {"memusage": mem}, extra={"crawler": self.crawler}, ) - if self.notify_mails: + if self.notify_mails: # pragma: no cover subj = ( f"{self.crawler.settings['BOT_NAME']} terminated: " f"memory usage exceeded {mem}MiB at {socket.gethostname()}" @@ -146,7 +146,7 @@ class MemoryUsage: {"memusage": mem}, extra={"crawler": self.crawler}, ) - if self.notify_mails: + if self.notify_mails: # pragma: no cover subj = ( f"{self.crawler.settings['BOT_NAME']} warning: " f"memory usage reached {mem}MiB at {socket.gethostname()}" @@ -155,7 +155,7 @@ class MemoryUsage: self.crawler.stats.set_value("memusage/warning_notified", 1) self.warned = True - def _send_report(self, rcpts: list[str], subject: str) -> None: + def _send_report(self, rcpts: list[str], subject: str) -> None: # pragma: no cover """send notification mail with some additional useful info""" assert self.crawler.engine assert self.crawler.stats diff --git a/tests/test_extension_memusage.py b/tests/test_extension_memusage.py new file mode 100644 index 000000000..a474725d8 --- /dev/null +++ b/tests/test_extension_memusage.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +import logging +import sys + +import pytest + +from scrapy import signals +from scrapy.core import engine as engine_mod +from scrapy.exceptions import NotConfigured +from scrapy.extensions import memusage as memusage_mod +from scrapy.extensions.memusage import MemoryUsage +from scrapy.spiders import Spider +from scrapy.utils.test import get_crawler +from tests.utils import OneShotLoop +from tests.utils.decorators import coroutine_test + +# MemoryUsage relies on the stdlib 'resource' module (not available on Windows) +pytestmark = pytest.mark.skipif( + sys.platform.startswith("win"), + reason="MemoryUsage extension not available on Windows", +) + + +MB = 1024 * 1024 + + +class _LoopSpider(Spider): + name = "loop-data-spider" + + def __init__(self, url: str, loops: int = 60, **kw): + super().__init__(**kw) + self.url = url + self.loops = loops + self.start_urls = [url] + + def parse(self, response): + count = response.meta.get("count", 0) + if count + 1 < self.loops: + yield response.follow( + self.url, callback=self.parse, meta={"count": count + 1} + ) + + +def test_memusage_disabled() -> None: + settings = { + "MEMUSAGE_ENABLED": False, + } + with pytest.raises(NotConfigured): + MemoryUsage.from_crawler(get_crawler(settings_dict=settings)) + + +@coroutine_test +async def test_memusage_limit_closes_spider_with_reason_and_error_log( + caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch +) -> None: + settings = { + "MEMUSAGE_LIMIT_MB": 10, + "MEMUSAGE_CHECK_INTERVAL_SECONDS": 0.01, + "TELNETCONSOLE_ENABLED": False, + "LOG_LEVEL": "INFO", + } + + # Avoid background LoopingCall that can log after the test finishes. + monkeypatch.setattr(memusage_mod, "create_looping_call", OneShotLoop) + # Avoid engine start/stop races (the extension stops the engine in engine_started). + monkeypatch.setattr(engine_mod, "create_looping_call", OneShotLoop) + monkeypatch.setattr(MemoryUsage, "get_virtual_size", lambda _: 250 * MB) + + crawler = get_crawler(spidercls=_LoopSpider, settings_dict=settings) + + with caplog.at_level(logging.ERROR, logger="scrapy.extensions.memusage"): + await crawler.crawl_async(url="data:,", loops=100) + + assert crawler.stats + assert crawler.stats.get_value("memusage/limit_reached") == 1 + assert crawler.stats.get_value("finish_reason") == "memusage_exceeded" + assert any( + "memory usage exceeded" in r.getMessage().lower() for r in caplog.records + ) + + +@coroutine_test +async def test_memusage_warning_logs_but_allows_normal_finish( + caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch +) -> None: + settings = { + "MEMUSAGE_WARNING_MB": 50, + "MEMUSAGE_LIMIT_MB": 0, # no hard limit + "MEMUSAGE_CHECK_INTERVAL_SECONDS": 0.01, + "TELNETCONSOLE_ENABLED": False, + "LOG_LEVEL": "INFO", + } + + # Avoid background LoopingCall that can log after the test finishes. + monkeypatch.setattr(memusage_mod, "create_looping_call", OneShotLoop) + monkeypatch.setattr(MemoryUsage, "get_virtual_size", lambda self: 75 * MB) + + crawler = get_crawler(spidercls=_LoopSpider, settings_dict=settings) + + warning_signals: list[int] = [] + + def on_warning_reached() -> None: + warning_signals.append(1) + + crawler.signals.connect(on_warning_reached, signal=signals.memusage_warning_reached) + + with caplog.at_level(logging.WARNING, logger="scrapy.extensions.memusage"): + await crawler.crawl_async(url="data:,", loops=60) + + assert warning_signals == [1] + assert crawler.stats + assert crawler.stats.get_value("memusage/warning_reached") == 1 + assert crawler.stats.get_value("finish_reason") == "finished" + assert any("memory usage reached" in r.getMessage().lower() for r in caplog.records) diff --git a/tests/utils/__init__.py b/tests/utils/__init__.py index 5b7848d54..73c0e3776 100644 --- a/tests/utils/__init__.py +++ b/tests/utils/__init__.py @@ -1,5 +1,6 @@ import asyncio import os +from collections.abc import Callable from pathlib import Path from twisted.internet.defer import Deferred @@ -31,3 +32,19 @@ def get_script_run_env() -> dict[str, str]: env = os.environ.copy() env["PYTHONPATH"] = pythonpath return env + + +class OneShotLoop: + """Test stub for create_looping_call: run once immediately, no background task.""" + + def __init__(self, func: Callable[[], None]): + self.func = func + self.running = False + + def start(self, _interval: float, now: bool = True) -> None: + self.running = True + if now: + self.func() + + def stop(self) -> None: + self.running = False From e5e48883b580b2fcfe8d30b301675f1f3ef3d67c Mon Sep 17 00:00:00 2001 From: Vishal Gawade Date: Fri, 19 Jun 2026 00:49:32 -0400 Subject: [PATCH 200/248] Deprecate ScrapyCommand.help() (#7633) Co-authored-by: Vishal Gawade --- scrapy/commands/__init__.py | 19 ++++++++++++---- tests/test_commands.py | 45 +++++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 4 deletions(-) diff --git a/scrapy/commands/__init__.py b/scrapy/commands/__init__.py index 19b6f6681..598e8060e 100644 --- a/scrapy/commands/__init__.py +++ b/scrapy/commands/__init__.py @@ -16,6 +16,8 @@ from twisted.python import failure from scrapy.exceptions import ScrapyDeprecationWarning, UsageError from scrapy.utils.conf import arglist_to_dict, feed_process_params_from_cli +from scrapy.utils.deprecate import method_is_overridden +from scrapy.utils.python import global_object_name if TYPE_CHECKING: from collections.abc import Iterable @@ -36,6 +38,14 @@ class ScrapyCommand(ABC): def __init__(self) -> None: self.settings: Settings | None = None # set in scrapy.cmdline + if method_is_overridden(self.__class__, ScrapyCommand, "help"): + warnings.warn( + "The ScrapyCommand.help() method is deprecated and overriding " + f"it, as the {global_object_name(self.__class__)} class does, " + "has no effect; override long_desc() instead.", + ScrapyDeprecationWarning, + stacklevel=2, + ) def set_crawler(self, crawler: Crawler) -> None: # pragma: no cover warnings.warn( @@ -68,10 +78,11 @@ class ScrapyCommand(ABC): return self.short_desc() def help(self) -> str: - """An extensive help for the command. It will be shown when using the - "help" command. It can contain newlines since no post-formatting will - be applied to its contents. - """ + warnings.warn( + "ScrapyCommand.help() is deprecated, use long_desc() instead.", + ScrapyDeprecationWarning, + stacklevel=2, + ) return self.long_desc() def add_options(self, parser: argparse.ArgumentParser) -> None: diff --git a/tests/test_commands.py b/tests/test_commands.py index 0657f393c..d7b7a9ff2 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -12,6 +12,7 @@ import pytest import scrapy from scrapy.cmdline import _pop_command_name, _print_unknown_command_msg from scrapy.commands import ScrapyCommand, ScrapyHelpFormatter, view +from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.settings import Settings from scrapy.utils.reactor import _asyncio_reactor_path from tests.utils.cmdline import call, proc @@ -28,6 +29,50 @@ class EmptyCommand(ScrapyCommand): pass +class TestHelpDeprecation: + def test_calling_help_is_deprecated(self) -> None: + command = EmptyCommand() + with pytest.warns( + ScrapyDeprecationWarning, + match=r"ScrapyCommand\.help\(\) is deprecated, use long_desc\(\) instead\.", + ): + result = command.help() + # help() still delegates to long_desc() for backward compatibility. + assert result == command.long_desc() + + def test_overriding_help_is_deprecated(self) -> None: + class HelpCommand(ScrapyCommand): + def short_desc(self) -> str: + return "" + + def run(self, args: list[str], opts: argparse.Namespace) -> None: + pass + + def help(self) -> str: + return "custom help" + + with pytest.warns( + ScrapyDeprecationWarning, + match=r"The ScrapyCommand\.help\(\) method is deprecated and " + r"overriding it, as the .*HelpCommand class does, has no effect; " + r"override long_desc\(\) instead\.", + ): + HelpCommand() + + def test_not_overriding_help_does_not_warn(self, recwarn) -> None: + # Commands that do not override help() must not emit the + # override-deprecation warning when instantiated, including subclasses + # several levels below ScrapyCommand (as the built-in commands are). + class SubCommand(EmptyCommand): + pass + + EmptyCommand() + SubCommand() + assert not [ + w for w in recwarn.list if issubclass(w.category, ScrapyDeprecationWarning) + ] + + class TestCommandSettings: def setup_method(self): self.command = EmptyCommand() From 3f3cb885eddf34fc6fd76a1acbeff601cbc6f506 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Fri, 19 Jun 2026 09:55:02 +0500 Subject: [PATCH 201/248] Address warnings in tests. (#7637) --- scrapy/contracts/__init__.py | 4 ++++ scrapy/utils/misc.py | 9 ++------ tests/ignores.txt | 2 ++ tests/test_crawl.py | 3 +++ tests/test_downloader_handlers_http_base.py | 3 +++ tests/test_extension_statsmailer.py | 23 +++++++++++++------- tests/test_http2_client_protocol.py | 3 +++ tests/test_spider_sitemap.py | 5 +---- tests/test_spider_start.py | 13 +++-------- tests/test_spidermiddleware_process_start.py | 5 +---- tests/test_utils_project.py | 8 ++----- 11 files changed, 39 insertions(+), 39 deletions(-) diff --git a/scrapy/contracts/__init__.py b/scrapy/contracts/__init__.py index c0da7dfa4..ebbdf1b98 100644 --- a/scrapy/contracts/__init__.py +++ b/scrapy/contracts/__init__.py @@ -51,6 +51,8 @@ class Contract: results.addSuccess(self.testcase_pre) cb_result = cb(response, **cb_kwargs) if isinstance(cb_result, (AsyncGenerator, CoroutineType)): + if isinstance(cb_result, CoroutineType): + cb_result.close() raise TypeError("Contracts don't support async callbacks") return list(cast("Iterable[Any]", iterate_spider_output(cb_result))) @@ -67,6 +69,8 @@ class Contract: def wrapper(response: Response, **cb_kwargs: Any) -> list[Any]: cb_result = cb(response, **cb_kwargs) if isinstance(cb_result, (AsyncGenerator, CoroutineType)): + if isinstance(cb_result, CoroutineType): + cb_result.close() raise TypeError("Contracts don't support async callbacks") output = list(cast("Iterable[Any]", iterate_spider_output(cb_result))) try: diff --git a/scrapy/utils/misc.py b/scrapy/utils/misc.py index 47568e656..20e7cb381 100644 --- a/scrapy/utils/misc.py +++ b/scrapy/utils/misc.py @@ -135,14 +135,9 @@ def walk_modules(path: str) -> list[ModuleType]: # pragma: no cover return list(walk_modules_iter(path)) -def md5sum(file: IO[bytes]) -> str: +def md5sum(file: IO[bytes]) -> str: # pragma: no cover """Calculate the md5 checksum of a file-like object without reading its - whole content in memory. - - >>> from io import BytesIO - >>> md5sum(BytesIO(b'file content to hash')) - '784406af91dd5a54fbb9c84c2236595a' - """ + whole content in memory.""" warnings.warn( ( "The scrapy.utils.misc.md5sum function is deprecated and will be " diff --git a/tests/ignores.txt b/tests/ignores.txt index 94edcf186..3717bbc95 100644 --- a/tests/ignores.txt +++ b/tests/ignores.txt @@ -1 +1,3 @@ +scrapy/core/downloader/handlers/http.py scrapy/extensions/statsmailer.py +scrapy/mail.py diff --git a/tests/test_crawl.py b/tests/test_crawl.py index 85d98f847..66b15bfdd 100644 --- a/tests/test_crawl.py +++ b/tests/test_crawl.py @@ -715,6 +715,9 @@ class TestCrawlSpider: assert isinstance(crawler.spider, SingleRequestSpider) assert crawler.spider.meta["responses"][0].certificate is None + @pytest.mark.filterwarnings( + r"ignore:.*You should use cryptography's X\.509 APIs:DeprecationWarning" + ) @pytest.mark.parametrize( "url", [ diff --git a/tests/test_downloader_handlers_http_base.py b/tests/test_downloader_handlers_http_base.py index 2ae9206a0..c4a8193c8 100644 --- a/tests/test_downloader_handlers_http_base.py +++ b/tests/test_downloader_handlers_http_base.py @@ -1148,6 +1148,9 @@ class TestHttpWithCrawlerBase(ABC): reason = crawler.spider.meta["close_reason"] # type: ignore[attr-defined] assert reason == "finished" + @pytest.mark.filterwarnings( + r"ignore:.*You should use cryptography's X\.509 APIs:DeprecationWarning" + ) @coroutine_test async def test_response_ssl_certificate(self, mockserver: MockServer) -> None: if not self.is_secure: diff --git a/tests/test_extension_statsmailer.py b/tests/test_extension_statsmailer.py index 28db389c3..4d208a1ea 100644 --- a/tests/test_extension_statsmailer.py +++ b/tests/test_extension_statsmailer.py @@ -1,20 +1,27 @@ +import warnings from unittest.mock import MagicMock import pytest from scrapy import signals -from scrapy.exceptions import NotConfigured +from scrapy.exceptions import NotConfigured, ScrapyDeprecationWarning from scrapy.signalmanager import SignalManager from scrapy.statscollectors import StatsCollector from scrapy.utils.spider import DefaultSpider -pytestmark = pytest.mark.filterwarnings( - "ignore:The scrapy.extensions.statsmailer module is deprecated:scrapy.exceptions.ScrapyDeprecationWarning", - "ignore:The scrapy.mail module is deprecated:scrapy.exceptions.ScrapyDeprecationWarning", -) - -from scrapy.extensions import statsmailer # noqa: E402 -from scrapy.mail import MailSender # noqa: E402 +with warnings.catch_warnings(): + warnings.filterwarnings( + "ignore", + r"The scrapy\.extensions\.statsmailer module is deprecated", + ScrapyDeprecationWarning, + ) + warnings.filterwarnings( + "ignore", + r"The scrapy\.mail module is deprecated", + ScrapyDeprecationWarning, + ) + from scrapy.extensions import statsmailer + from scrapy.mail import MailSender @pytest.fixture diff --git a/tests/test_http2_client_protocol.py b/tests/test_http2_client_protocol.py index 28f306e31..3c1347fd3 100644 --- a/tests/test_http2_client_protocol.py +++ b/tests/test_http2_client_protocol.py @@ -669,6 +669,9 @@ class TestHttps2ClientProtocol: response = await make_request(client, request) assert response.status == status + @pytest.mark.filterwarnings( + r"ignore:.*You should use cryptography's X\.509 APIs:DeprecationWarning" + ) @deferred_f_from_coro_f async def test_response_has_correct_certificate_ip_address( self, diff --git a/tests/test_spider_sitemap.py b/tests/test_spider_sitemap.py index 57c209615..0af99ab6d 100644 --- a/tests/test_spider_sitemap.py +++ b/tests/test_spider_sitemap.py @@ -2,7 +2,6 @@ from __future__ import annotations import gzip import re -import warnings from datetime import datetime from io import BytesIO from logging import WARNING @@ -429,9 +428,7 @@ Sitemap: /sitemap-relative-url.xml crawler = get_crawler(TestSpider) spider = TestSpider.from_crawler(crawler) - with warnings.catch_warnings(): - warnings.simplefilter("error") - requests = [request async for request in spider.start()] + requests = [request async for request in spider.start()] assert len(requests) == 1 request = requests[0] diff --git a/tests/test_spider_start.py b/tests/test_spider_start.py index aef2093ac..9257e0232 100644 --- a/tests/test_spider_start.py +++ b/tests/test_spider_start.py @@ -1,6 +1,5 @@ from __future__ import annotations -import warnings from asyncio import sleep from typing import Any @@ -45,9 +44,7 @@ class TestMain: async def parse(self, response): yield ITEM_A - with warnings.catch_warnings(): - warnings.simplefilter("error") - await self._test_spider(TestSpider, [ITEM_A]) + await self._test_spider(TestSpider, [ITEM_A]) @coroutine_test async def test_start(self): @@ -57,9 +54,7 @@ class TestMain: async def start(self): yield ITEM_A - with warnings.catch_warnings(): - warnings.simplefilter("error") - await self._test_spider(TestSpider, [ITEM_A]) + await self._test_spider(TestSpider, [ITEM_A]) @coroutine_test async def test_start_subclass(self): @@ -70,9 +65,7 @@ class TestMain: class TestSpider(BaseSpider): name = "test" - with warnings.catch_warnings(): - warnings.simplefilter("error") - await self._test_spider(TestSpider, [ITEM_A]) + await self._test_spider(TestSpider, [ITEM_A]) async def _test_start(self, start_, expected_items=None): class TestSpider(Spider): diff --git a/tests/test_spidermiddleware_process_start.py b/tests/test_spidermiddleware_process_start.py index 21df73a65..67ce3a920 100644 --- a/tests/test_spidermiddleware_process_start.py +++ b/tests/test_spidermiddleware_process_start.py @@ -1,4 +1,3 @@ -import warnings from asyncio import sleep import pytest @@ -76,9 +75,7 @@ class TestMain: @coroutine_test async def test_modern_mw_modern_spider(self): - with warnings.catch_warnings(): - warnings.simplefilter("error") - await self._test_wrap(ModernWrapSpiderMiddleware, ModernWrapSpider) + await self._test_wrap(ModernWrapSpiderMiddleware, ModernWrapSpider) async def _test_sleep(self, spider_middlewares): class TestSpider(Spider): diff --git a/tests/test_utils_project.py b/tests/test_utils_project.py index 20a3d940c..5333a55cb 100644 --- a/tests/test_utils_project.py +++ b/tests/test_utils_project.py @@ -1,5 +1,4 @@ import os -import warnings from pathlib import Path import pytest @@ -41,11 +40,8 @@ class TestGetProjectSettings: envvars = { "SCRAPY_SETTINGS_MODULE": value, } - with warnings.catch_warnings(): - warnings.simplefilter("error") - with set_environ(**envvars): - settings = get_project_settings() - + with set_environ(**envvars): + settings = get_project_settings() assert settings.get("SETTINGS_MODULE") == value def test_invalid_envvar(self): From 699c93f6b2623df87c56ac486cb2cd44717a9cbf Mon Sep 17 00:00:00 2001 From: Adrian Date: Fri, 19 Jun 2026 11:22:03 +0200 Subject: [PATCH 202/248] Complete test coverage for linkextractors (#7639) * Complete test coverage for linkextractors * pylint: disable use-implicit-booleaness-not-comparison --- pyproject.toml | 1 + tests/test_linkextractors.py | 35 ++++++++++++++++++++++++++++++++- tests/test_request_cb_kwargs.py | 4 +--- tests/test_settings/__init__.py | 2 +- tests/test_spider.py | 2 +- tests/test_utils_python.py | 2 +- 6 files changed, 39 insertions(+), 7 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 2d857dc6d..6b0c561f8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -230,6 +230,7 @@ disable = [ "undefined-variable", "unused-argument", "unused-variable", + "use-implicit-booleaness-not-comparison", "useless-import-alias", # used as a hint to mypy "useless-return", # https://github.com/pylint-dev/pylint/issues/6530 "wrong-import-position", diff --git a/tests/test_linkextractors.py b/tests/test_linkextractors.py index 15d358d2a..063b92dac 100644 --- a/tests/test_linkextractors.py +++ b/tests/test_linkextractors.py @@ -9,7 +9,7 @@ from w3lib import __version__ as w3lib_version from scrapy.http import HtmlResponse, XmlResponse from scrapy.link import Link -from scrapy.linkextractors.lxmlhtml import LxmlLinkExtractor +from scrapy.linkextractors.lxmlhtml import LxmlLinkExtractor, LxmlParserLinkExtractor from tests import get_testdata @@ -837,3 +837,36 @@ class TestLxmlLinkExtractor(Base.TestLinkExtractorBase): def test_link_allowed_is_false_with_missing_url_prefix(self): bad_link = Link("should_have_prefix.example") assert not LxmlLinkExtractor()._link_allowed(bad_link) + + +class TestLxmlParserLinkExtractor: + def test_extract_links(self): + html = b'Link' + response = HtmlResponse("http://example.com/", body=html) + lx = LxmlParserLinkExtractor() + assert lx.extract_links(response) == [ + Link(url="http://example.com/page.html", text="Link", nofollow=False), + ] + + def test_strip_false(self): + # With strip=False, trailing whitespace on a relative href survives urljoin + # and is visible to process_value (safe_url_string cleans it up afterward). + # Here process_value rejects URLs that still carry trailing whitespace, + # demonstrating the difference from strip=True. + def reject_trailing_whitespace(url): + return None if url != url.rstrip() else url + + html = b'Link' + response = HtmlResponse("http://example.com/", body=html) + + lx_strip = LxmlParserLinkExtractor( + strip=True, process=reject_trailing_whitespace + ) + assert lx_strip.extract_links(response) == [ + Link(url="http://example.com/page.html", text="Link", nofollow=False), + ] + + lx_no_strip = LxmlParserLinkExtractor( + strip=False, process=reject_trailing_whitespace + ) + assert lx_no_strip.extract_links(response) == [] diff --git a/tests/test_request_cb_kwargs.py b/tests/test_request_cb_kwargs.py index 0d96e1d88..ee9d2ed51 100644 --- a/tests/test_request_cb_kwargs.py +++ b/tests/test_request_cb_kwargs.py @@ -102,9 +102,7 @@ class KeywordArgumentsSpider(MockServerSpider): self.checks.append(kwargs["callback"] == "some_callback") self.crawler.stats.inc_value("boolean_checks", 3) elif response.url.endswith("/general_without"): - self.checks.append( - kwargs == {} # pylint: disable=use-implicit-booleaness-not-comparison - ) + self.checks.append(kwargs == {}) self.crawler.stats.inc_value("boolean_checks") def parse_no_kwargs(self, response): diff --git a/tests/test_settings/__init__.py b/tests/test_settings/__init__.py index 7b3c52d65..9599ed6c9 100644 --- a/tests/test_settings/__init__.py +++ b/tests/test_settings/__init__.py @@ -1,4 +1,4 @@ -# pylint: disable=unsubscriptable-object,unsupported-membership-test,use-implicit-booleaness-not-comparison +# pylint: disable=unsubscriptable-object,unsupported-membership-test # (too many false positives) import logging diff --git a/tests/test_spider.py b/tests/test_spider.py index 23efed77a..526cc8f23 100644 --- a/tests/test_spider.py +++ b/tests/test_spider.py @@ -22,7 +22,7 @@ class TestSpider: def test_base_spider(self): spider = self.spider_class("example.com") assert spider.name == "example.com" - assert spider.start_urls == [] # pylint: disable=use-implicit-booleaness-not-comparison + assert spider.start_urls == [] def test_spider_args(self): """``__init__`` method arguments are assigned to spider attributes""" diff --git a/tests/test_utils_python.py b/tests/test_utils_python.py index e8fe45749..2e8047e2d 100644 --- a/tests/test_utils_python.py +++ b/tests/test_utils_python.py @@ -175,7 +175,7 @@ def test_get_func_args(): assert get_func_args(partial_f2) == ["a", "c"] assert get_func_args(partial_f3) == ["c"] assert get_func_args(cal) == ["a", "b", "c"] - assert get_func_args(object) == [] # pylint: disable=use-implicit-booleaness-not-comparison + assert get_func_args(object) == [] assert get_func_args(str.split, stripself=True) == ["sep", "maxsplit"] assert get_func_args(" ".join, stripself=True) == ["iterable"] From 7b3f88f8abcbf45682140ae35456810b49065ccf Mon Sep 17 00:00:00 2001 From: Adrian Date: Fri, 19 Jun 2026 15:28:32 +0200 Subject: [PATCH 203/248] Improve test coverage for pqueues.py (#7640) --- tests/test_pqueues.py | 52 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 51 insertions(+), 1 deletion(-) diff --git a/tests/test_pqueues.py b/tests/test_pqueues.py index 7be9241b9..6c6a6584a 100644 --- a/tests/test_pqueues.py +++ b/tests/test_pqueues.py @@ -8,7 +8,7 @@ from scrapy.core.downloader import Downloader from scrapy.http.request import Request from scrapy.pqueues import DownloaderAwarePriorityQueue, ScrapyPriorityQueue from scrapy.spiders import Spider -from scrapy.squeues import FifoMemoryQueue +from scrapy.squeues import FifoMemoryQueue, PickleFifoDiskQueue from scrapy.utils.misc import build_from_crawler, load_object from scrapy.utils.test import get_crawler from tests.test_scheduler import MockDownloader @@ -76,6 +76,29 @@ class TestPriorityQueue: assert queue.pop().url == req3.url assert not queue.close() + def test_init_prios_with_start_queue(self): + temp_dir = tempfile.mkdtemp() + queue = ScrapyPriorityQueue.from_crawler( + self.crawler, + PickleFifoDiskQueue, + temp_dir, + start_queue_cls=PickleFifoDiskQueue, + ) + req = Request("https://example.org/", meta={"is_start_request": True}) + queue.push(req) + startprios = queue.close() + + queue2 = ScrapyPriorityQueue.from_crawler( + self.crawler, + PickleFifoDiskQueue, + temp_dir, + startprios, + start_queue_cls=PickleFifoDiskQueue, + ) + assert len(queue2) == 1 + assert queue2.pop().url == req.url + queue2.close() + def test_queue_push_pop_priorities(self): temp_dir = tempfile.mkdtemp() queue = ScrapyPriorityQueue.from_crawler( @@ -207,6 +230,33 @@ class TestDownloaderAwarePriorityQueue: assert slots == ["slot-a", "slot-b", "slot-c", "slot-a"] + def test_pop_prefers_slot_with_fewer_active_downloads(self): + downloader = self.queue._downloader_interface.downloader + + req_a = Request("https://example.org/a") + req_a.meta[Downloader.DOWNLOAD_SLOT] = "slot-a" + req_b = Request("https://example.org/b") + req_b.meta[Downloader.DOWNLOAD_SLOT] = "slot-b" + req_c = Request("https://example.org/c") + req_c.meta[Downloader.DOWNLOAD_SLOT] = "slot-c" + + for req in (req_a, req_b, req_c): + self.queue.push(req) + + downloader.increment("slot-a") + downloader.increment("slot-c") + + popped = self.queue.pop() + assert popped.url == req_b.url + + def test_contains(self): + req = Request("https://example.org/") + req.meta[Downloader.DOWNLOAD_SLOT] = "example-slot" + assert "example-slot" not in self.queue + self.queue.push(req) + assert "example-slot" in self.queue + assert "other-slot" not in self.queue + @pytest.mark.parametrize( ("input_", "output"), From d2842a205c7986070f77b1f4033504a0fd95e5fe Mon Sep 17 00:00:00 2001 From: Adrian Date: Fri, 19 Jun 2026 16:59:25 +0200 Subject: [PATCH 204/248] Improve coverage statscollectors (#7641) --- scrapy/__main__.py | 1 + tests/test_stats.py | 9 ++++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/scrapy/__main__.py b/scrapy/__main__.py index 697b9b1e9..17b1aa538 100644 --- a/scrapy/__main__.py +++ b/scrapy/__main__.py @@ -1,3 +1,4 @@ +# pragma: no file cover from scrapy.cmdline import execute if __name__ == "__main__": diff --git a/tests/test_stats.py b/tests/test_stats.py index 27af18bb1..6e6aa0cc4 100644 --- a/tests/test_stats.py +++ b/tests/test_stats.py @@ -94,8 +94,13 @@ class TestStatsCollector: assert stats.get_value("test2") == 35 stats.min_value("test4", 7) assert stats.get_value("test4") == 7 + stats.set_stats({"replaced": "stats"}) + assert stats.get_stats() == {"replaced": "stats"} + stats.clear_stats() + assert stats.get_stats() == {} - def test_dummy_collector(self, crawler: Crawler) -> None: + def test_dummy_collector(self) -> None: + crawler = get_crawler(Spider, {"STATS_DUMP": False}) stats = DummyStatsCollector(crawler) assert stats.get_stats() == {} assert stats.get_value("anything") is None @@ -104,9 +109,11 @@ class TestStatsCollector: stats.inc_value("v1") stats.max_value("v2", 100) stats.min_value("v3", 100) + stats.set_stats({"key": "val"}) stats.open_spider() stats.set_value("test", "value") assert stats.get_stats() == {} + stats.close_spider() def test_deprecated_spider_arg(self, crawler: Crawler, spider: Spider) -> None: stats = StatsCollector(crawler) From 6393858c7e45ed5393fe98b570505f04855664c1 Mon Sep 17 00:00:00 2001 From: Adrian Date: Fri, 19 Jun 2026 20:26:54 +0200 Subject: [PATCH 205/248] Improve coverage resolver (#7642) * Improve test coverage for resolver.py * Make the Twisted code more readable --- scrapy/resolver.py | 2 +- tests/test_resolver.py | 55 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 tests/test_resolver.py diff --git a/scrapy/resolver.py b/scrapy/resolver.py index f5f00ab0f..270a7fbf5 100644 --- a/scrapy/resolver.py +++ b/scrapy/resolver.py @@ -75,7 +75,7 @@ class HostResolution: def __init__(self, name: str): self.name: str = name - def cancel(self) -> None: + def cancel(self) -> None: # pragma: no cover raise NotImplementedError diff --git a/tests/test_resolver.py b/tests/test_resolver.py new file mode 100644 index 000000000..83fc8693b --- /dev/null +++ b/tests/test_resolver.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from unittest.mock import Mock + +import pytest + +from scrapy.resolver import CachingHostnameResolver, CachingThreadedResolver, dnscache +from scrapy.utils.defer import maybe_deferred_to_future +from scrapy.utils.test import get_crawler +from tests.utils.decorators import coroutine_test + + +@pytest.fixture(autouse=True) +def reset_dnscache(): + original_limit = dnscache.limit + dnscache.clear() + yield + dnscache.clear() + dnscache.limit = original_limit + + +def test_caching_threaded_resolver_dnscache_disabled(): + crawler = get_crawler(settings_dict={"DNSCACHE_ENABLED": False}) + CachingThreadedResolver.from_crawler(crawler, Mock()) + assert dnscache.limit == 0 + + +@coroutine_test +async def test_caching_threaded_resolver_getHostByName_cache_hit(): + resolver = CachingThreadedResolver(Mock(), cache_size=10, timeout=5.0) + dnscache["example.com"] = "1.2.3.4" + + result = await maybe_deferred_to_future(resolver.getHostByName("example.com")) + assert result == "1.2.3.4" + + +def test_caching_hostname_resolver_dnscache_disabled(): + crawler = get_crawler(settings_dict={"DNSCACHE_ENABLED": False}) + CachingHostnameResolver.from_crawler(crawler, Mock()) + assert dnscache.limit == 0 + + +def test_caching_hostname_resolver_no_addresses_not_cached(): + def fake_resolve(receiver, *_): + receiver.resolutionBegan(Mock()) + receiver.resolutionComplete() + return receiver + + reactor = Mock() + reactor.nameResolver.resolveHostName.side_effect = fake_resolve + + resolver = CachingHostnameResolver(reactor, cache_size=10) + resolver.resolveHostName(Mock(), "example.com") + + assert "example.com" not in dnscache From c9f952c2584f490cd2e5c843980212abc67c2971 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Sat, 20 Jun 2026 00:04:34 +0500 Subject: [PATCH 206/248] Refactor and improve catching warnings in tests. (#7643) --- scrapy/spidermiddlewares/referer.py | 6 +- scrapy/spiders/crawl.py | 2 + tests/test_crawler.py | 5 +- tests/test_dupefilters.py | 13 +- tests/test_http_request.py | 6 +- tests/test_http_request_json.py | 41 +++-- tests/test_pipeline_files.py | 23 ++- tests/test_pipeline_media.py | 32 ++-- tests/test_scheduler.py | 7 +- tests/test_scrapy__getattr__.py | 21 +-- tests/test_settings/__init__.py | 14 +- tests/test_spider_crawl.py | 24 +-- tests/test_spiderloader/__init__.py | 105 +++++-------- tests/test_spidermiddleware_referer.py | 64 ++++---- tests/test_utils_curl.py | 8 +- tests/test_utils_datatypes.py | 15 +- tests/test_utils_deprecate.py | 148 ++++++++---------- ...t_return_with_argument_inside_generator.py | 84 +++------- tests/test_utils_sitemap.py | 11 +- 19 files changed, 275 insertions(+), 354 deletions(-) diff --git a/scrapy/spidermiddlewares/referer.py b/scrapy/spidermiddlewares/referer.py index 6c5acf0de..2c9452174 100644 --- a/scrapy/spidermiddlewares/referer.py +++ b/scrapy/spidermiddlewares/referer.py @@ -11,7 +11,7 @@ from typing import TYPE_CHECKING, cast from urllib.parse import urlparse from warnings import warn -from scrapy.exceptions import NotConfigured +from scrapy.exceptions import NotConfigured, ScrapyDeprecationWarning from scrapy.http import Request, Response from scrapy.spidermiddlewares.base import BaseSpiderMiddleware from scrapy.utils.misc import load_object @@ -349,7 +349,7 @@ class RefererMiddleware(BaseSpiderMiddleware): response = kwargs.pop("resp_or_url") warn( "Passing 'resp_or_url' is deprecated, use 'response' instead.", - DeprecationWarning, + ScrapyDeprecationWarning, stacklevel=2, ) if response is None: @@ -360,7 +360,7 @@ class RefererMiddleware(BaseSpiderMiddleware): warn( "Passing a response URL to RefererMiddleware.policy() instead " "of a Response object is deprecated.", - DeprecationWarning, + ScrapyDeprecationWarning, stacklevel=2, ) allow_import_path = True diff --git a/scrapy/spiders/crawl.py b/scrapy/spiders/crawl.py index d2da31f35..373c0b8b5 100644 --- a/scrapy/spiders/crawl.py +++ b/scrapy/spiders/crawl.py @@ -110,6 +110,7 @@ class CrawlSpider(Spider): "deprecated: it will be removed in future Scrapy releases. " "Please override the CrawlSpider.parse_with_rules method " "instead.", + ScrapyDeprecationWarning, stacklevel=2, ) @@ -199,6 +200,7 @@ class CrawlSpider(Spider): "The CrawlSpider._parse_response method is deprecated: " "it will be removed in future Scrapy releases. " "Please use the CrawlSpider.parse_with_rules method instead.", + ScrapyDeprecationWarning, stacklevel=2, ) return self.parse_with_rules(response, callback, cb_kwargs, follow) diff --git a/tests/test_crawler.py b/tests/test_crawler.py index 31d195c4b..c646aea4b 100644 --- a/tests/test_crawler.py +++ b/tests/test_crawler.py @@ -3,7 +3,6 @@ from __future__ import annotations import asyncio import logging import re -import warnings from pathlib import Path from typing import Any, ClassVar @@ -88,9 +87,7 @@ class TestCrawler(TestBaseCrawler): self.assertOptionIsDefault(crawler.settings, "RETRY_ENABLED") def test_crawler_accepts_None(self) -> None: - with warnings.catch_warnings(): - warnings.simplefilter("ignore", ScrapyDeprecationWarning) - crawler = Crawler(DefaultSpider) + crawler = Crawler(DefaultSpider) self.assertOptionIsDefault(crawler.settings, "RETRY_ENABLED") def test_crawler_rejects_spider_objects(self) -> None: diff --git a/tests/test_dupefilters.py b/tests/test_dupefilters.py index 412a59fcd..5d79691b2 100644 --- a/tests/test_dupefilters.py +++ b/tests/test_dupefilters.py @@ -3,8 +3,8 @@ import shutil import sys import tempfile from pathlib import Path -from warnings import catch_warnings +import pytest from testfixtures import LogCapture from scrapy.core.scheduler import Scheduler @@ -260,11 +260,8 @@ class TestBaseDupeFilter: dupefilter = _get_dupefilter( settings={"DUPEFILTER_CLASS": BaseDupeFilter}, ) - with catch_warnings(record=True) as warning_list: + with pytest.warns( + ScrapyDeprecationWarning, + match=r"Calling BaseDupeFilter\.log\(\) is deprecated.", + ): dupefilter.log(None, None) - assert len(warning_list) == 1 - assert ( - str(warning_list[0].message) - == "Calling BaseDupeFilter.log() is deprecated." - ) - assert warning_list[0].category == ScrapyDeprecationWarning diff --git a/tests/test_http_request.py b/tests/test_http_request.py index fd494504d..e22689d49 100644 --- a/tests/test_http_request.py +++ b/tests/test_http_request.py @@ -460,11 +460,13 @@ class TestRequest: def test_from_curl_ignore_unknown_options(self): # By default: it works and ignores the unknown options: --foo and -z with warnings.catch_warnings(): # avoid warning when executing tests - warnings.simplefilter("ignore") + warnings.filterwarnings( + "ignore", category=UserWarning, message="Unrecognized options:" + ) r = self.request_class.from_curl( 'curl -X DELETE "http://example.org" --foo -z', ) - assert r.method == "DELETE" + assert r.method == "DELETE" # If `ignore_unknown_options` is set to `False` it raises an error with # the unknown options: --foo and -z diff --git a/tests/test_http_request_json.py b/tests/test_http_request_json.py index fcfe78365..65022afe1 100644 --- a/tests/test_http_request_json.py +++ b/tests/test_http_request_json.py @@ -4,6 +4,8 @@ import json import warnings from unittest import mock +import pytest + from scrapy.http import JsonRequest from scrapy.utils.python import to_bytes from tests.test_http_request import TestRequest @@ -63,40 +65,40 @@ class TestJsonRequest(TestRequest): data = { "name": "value", } - with warnings.catch_warnings(record=True) as _warnings: + with pytest.warns(UserWarning, match="data will be ignored"): r5 = self.request_class(url="http://www.example.com/", body=body, data=data) - assert r5.body == body - assert r5.method == "GET" - assert len(_warnings) == 1 - assert "data will be ignored" in str(_warnings[0].message) + assert r5.body == body + assert r5.method == "GET" def test_empty_body_data(self): """passing any body value and data should result a warning""" data = { "name": "value", } - with warnings.catch_warnings(record=True) as _warnings: + with pytest.warns(UserWarning, match="data will be ignored"): r6 = self.request_class(url="http://www.example.com/", body=b"", data=data) - assert r6.body == b"" - assert r6.method == "GET" - assert len(_warnings) == 1 - assert "data will be ignored" in str(_warnings[0].message) + assert r6.body == b"" + assert r6.method == "GET" def test_body_none_data(self): data = { "name": "value", } - with warnings.catch_warnings(record=True) as _warnings: + with warnings.catch_warnings(): + warnings.filterwarnings( + "error", category=UserWarning, message="Both body and data passed" + ) r7 = self.request_class(url="http://www.example.com/", body=None, data=data) - assert r7.body == to_bytes(json.dumps(data)) - assert r7.method == "POST" - assert len(_warnings) == 0 + assert r7.body == to_bytes(json.dumps(data)) + assert r7.method == "POST" def test_body_data_none(self): - with warnings.catch_warnings(record=True) as _warnings: + with warnings.catch_warnings(): + warnings.filterwarnings( + "error", category=UserWarning, message="Both body and data passed" + ) r8 = self.request_class(url="http://www.example.com/", body=None, data=None) - assert r8.method == "GET" - assert len(_warnings) == 0 + assert r8.method == "GET" def test_dumps_sort_keys(self): """Test that sort_keys=True is passed to json.dumps by default""" @@ -183,8 +185,5 @@ class TestJsonRequest(TestRequest): } r1 = self.request_class(url="http://www.example.com/", data=data1, body=body1) - with warnings.catch_warnings(record=True) as _warnings: + with pytest.warns(UserWarning, match="data will be ignored"): r1.replace(data=data2, body=body2) - assert "Both body and data passed. data will be ignored" in str( - _warnings[0].message - ) diff --git a/tests/test_pipeline_files.py b/tests/test_pipeline_files.py index c6a41fa6e..3829a35f5 100644 --- a/tests/test_pipeline_files.py +++ b/tests/test_pipeline_files.py @@ -2,7 +2,6 @@ import dataclasses import os import random import time -import warnings from abc import ABC, abstractmethod from datetime import datetime from ftplib import FTP @@ -786,12 +785,10 @@ class TestBuildFromCrawler: class Pipeline(FilesPipeline): pass - with warnings.catch_warnings(record=True) as w: - pipe = Pipeline.from_crawler(self.crawler) - assert pipe.crawler == self.crawler - assert pipe._fingerprinter - assert len(w) == 0 - assert pipe.store + pipe = Pipeline.from_crawler(self.crawler) + assert pipe.crawler == self.crawler + assert pipe._fingerprinter + assert pipe.store def test_has_from_crawler_and_init(self): class Pipeline(FilesPipeline): @@ -805,13 +802,11 @@ class TestBuildFromCrawler: o._from_crawler_called = True return o - with warnings.catch_warnings(record=True) as w: - pipe = Pipeline.from_crawler(self.crawler) - assert pipe.crawler == self.crawler - assert pipe._fingerprinter - assert len(w) == 0 - assert pipe.store - assert pipe._from_crawler_called + pipe = Pipeline.from_crawler(self.crawler) + assert pipe.crawler == self.crawler + assert pipe._fingerprinter + assert pipe.store + assert pipe._from_crawler_called @pytest.mark.parametrize("store", [None, ""]) diff --git a/tests/test_pipeline_media.py b/tests/test_pipeline_media.py index da1bfa317..8ac949da7 100644 --- a/tests/test_pipeline_media.py +++ b/tests/test_pipeline_media.py @@ -1,6 +1,5 @@ from __future__ import annotations -import warnings from unittest.mock import MagicMock import pytest @@ -425,11 +424,9 @@ class TestBuildFromCrawler: class Pipeline(UserDefinedPipeline): pass - with warnings.catch_warnings(record=True) as w: - pipe = Pipeline.from_crawler(self.crawler) - assert pipe.crawler == self.crawler - assert pipe._fingerprinter - assert len(w) == 0 + pipe = Pipeline.from_crawler(self.crawler) + assert pipe.crawler == self.crawler + assert pipe._fingerprinter def test_has_from_crawler_and_init(self): class Pipeline(UserDefinedPipeline): @@ -447,13 +444,11 @@ class TestBuildFromCrawler: o._from_crawler_called = True return o - with warnings.catch_warnings(record=True) as w: - pipe = Pipeline.from_crawler(self.crawler) - assert pipe.crawler == self.crawler - assert pipe._fingerprinter - assert len(w) == 0 - assert pipe._from_crawler_called - assert pipe._init_called + pipe = Pipeline.from_crawler(self.crawler) + assert pipe.crawler == self.crawler + assert pipe._fingerprinter + assert pipe._from_crawler_called + assert pipe._init_called def test_has_from_crawler(self): class Pipeline(UserDefinedPipeline): @@ -467,13 +462,10 @@ class TestBuildFromCrawler: o.store_uri = settings["FILES_STORE"] return o - with warnings.catch_warnings(record=True) as w: - pipe = Pipeline.from_crawler(self.crawler) - # this and the next assert will fail as MediaPipeline.from_crawler() wasn't called - assert pipe.crawler == self.crawler - assert pipe._fingerprinter - assert len(w) == 0 - assert pipe._from_crawler_called + pipe = Pipeline.from_crawler(self.crawler) + assert pipe.crawler == self.crawler + assert pipe._fingerprinter + assert pipe._from_crawler_called class MediaFailedFailurePipeline(MockedMediaPipeline): diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index eb88baf01..8637de41e 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -12,6 +12,7 @@ import pytest from scrapy.core.downloader import Downloader from scrapy.core.scheduler import BaseScheduler, Scheduler from scrapy.crawler import Crawler +from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.http import Request from scrapy.spiders import Spider from scrapy.utils.defer import ensure_awaitable @@ -396,7 +397,11 @@ class TestIncompatibility: def test_incompatibility(self): with warnings.catch_warnings(): - warnings.filterwarnings("ignore") + warnings.filterwarnings( + "ignore", + category=ScrapyDeprecationWarning, + message="The CONCURRENT_REQUESTS_PER_IP setting is deprecated", + ) with pytest.raises( ValueError, match="does not support CONCURRENT_REQUESTS_PER_IP" ): diff --git a/tests/test_scrapy__getattr__.py b/tests/test_scrapy__getattr__.py index 4c365859b..4647e1041 100644 --- a/tests/test_scrapy__getattr__.py +++ b/tests/test_scrapy__getattr__.py @@ -1,15 +1,18 @@ -import warnings +from __future__ import annotations + +import pytest + +from scrapy.exceptions import ScrapyDeprecationWarning -def test_deprecated_concurrent_requests_per_ip_attribute(): - with warnings.catch_warnings(record=True) as warns: +def test_deprecated_concurrent_requests_per_ip_attribute() -> None: + with pytest.warns( + ScrapyDeprecationWarning, + match=r"scrapy\.settings\.default_settings\.CONCURRENT_REQUESTS_PER_IP attribute is deprecated", + ): from scrapy.settings.default_settings import ( # noqa: PLC0415 CONCURRENT_REQUESTS_PER_IP, ) - assert CONCURRENT_REQUESTS_PER_IP is not None - assert isinstance(CONCURRENT_REQUESTS_PER_IP, int) - assert ( - "The scrapy.settings.default_settings.CONCURRENT_REQUESTS_PER_IP attribute is deprecated, use scrapy.settings.default_settings.CONCURRENT_REQUESTS_PER_DOMAIN instead." - in warns[0].message.args - ) + assert CONCURRENT_REQUESTS_PER_IP is not None + assert isinstance(CONCURRENT_REQUESTS_PER_IP, int) diff --git a/tests/test_settings/__init__.py b/tests/test_settings/__init__.py index 9599ed6c9..908bb417c 100644 --- a/tests/test_settings/__init__.py +++ b/tests/test_settings/__init__.py @@ -2,12 +2,12 @@ # (too many false positives) import logging -import warnings from unittest import mock import pytest from scrapy.core.downloader.handlers.file import FileDownloadHandler +from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.settings import ( SETTINGS_PRIORITIES, BaseSettings, @@ -711,15 +711,13 @@ def test_remove_from_list(before, name, item, after): def test_deprecated_concurrent_requests_per_ip_setting(): - with warnings.catch_warnings(record=True) as warns: - settings = Settings({"CONCURRENT_REQUESTS_PER_IP": 1}) + settings = Settings({"CONCURRENT_REQUESTS_PER_IP": 1}) + with pytest.warns( + ScrapyDeprecationWarning, + match="The CONCURRENT_REQUESTS_PER_IP setting is deprecated", + ): settings.get("CONCURRENT_REQUESTS_PER_IP") - assert ( - str(warns[0].message) - == "The CONCURRENT_REQUESTS_PER_IP setting is deprecated, use CONCURRENT_REQUESTS_PER_DOMAIN instead." - ) - class Component1: pass diff --git a/tests/test_spider_crawl.py b/tests/test_spider_crawl.py index c97934b74..2eeae110b 100644 --- a/tests/test_spider_crawl.py +++ b/tests/test_spider_crawl.py @@ -8,6 +8,7 @@ import pytest from testfixtures import LogCapture from w3lib.url import safe_url_string +from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.http import HtmlResponse, Request, TextResponse from scrapy.linkextractors import LinkExtractor from scrapy.spiders import CrawlSpider, Rule, Spider @@ -264,13 +265,16 @@ class TestCrawlSpider(TestSpider): start_urls = "https://www.example.com" _follow_links = False - with warnings.catch_warnings(record=True) as w: + with warnings.catch_warnings(): + warnings.simplefilter("error", category=ScrapyDeprecationWarning) spider = _CrawlSpider() - assert len(w) == 0 + with pytest.warns( + ScrapyDeprecationWarning, + match=r"CrawlSpider\._parse_response method is deprecated", + ): spider._parse_response( TextResponse(spider.start_urls, body=b""), None, None ) - assert len(w) == 1 def test_parse_response_override(self): class _CrawlSpider(CrawlSpider): @@ -281,26 +285,28 @@ class TestCrawlSpider(TestSpider): start_urls = "https://www.example.com" _follow_links = False - with warnings.catch_warnings(record=True) as w: - assert len(w) == 0 + with pytest.warns( + ScrapyDeprecationWarning, + match=r"CrawlSpider\._parse_response method, which the", + ): spider = _CrawlSpider() - assert len(w) == 1 + with warnings.catch_warnings(): + warnings.simplefilter("error", category=ScrapyDeprecationWarning) spider._parse_response( TextResponse(spider.start_urls, body=b""), None, None ) - assert len(w) == 1 def test_parse_with_rules(self): class _CrawlSpider(CrawlSpider): name = "test" start_urls = "https://www.example.com" - with warnings.catch_warnings(record=True) as w: + with warnings.catch_warnings(): + warnings.simplefilter("error", category=ScrapyDeprecationWarning) spider = _CrawlSpider() spider.parse_with_rules( TextResponse(spider.start_urls, body=b""), None, None ) - assert len(w) == 0 class TestDeprecation: diff --git a/tests/test_spiderloader/__init__.py b/tests/test_spiderloader/__init__.py index de27ad519..d85942f90 100644 --- a/tests/test_spiderloader/__init__.py +++ b/tests/test_spiderloader/__init__.py @@ -1,7 +1,6 @@ import contextlib import shutil import sys -import warnings from pathlib import Path from unittest import mock @@ -137,50 +136,38 @@ class TestSpiderLoader: SpiderLoader.from_settings(settings) def test_bad_spider_modules_warning(self): - with warnings.catch_warnings(record=True) as w: - module = "tests.test_spiderloader.test_spiders.doesnotexist" - settings = Settings( - {"SPIDER_MODULES": [module], "SPIDER_LOADER_WARN_ONLY": True} - ) + module = "tests.test_spiderloader.test_spiders.doesnotexist" + settings = Settings( + {"SPIDER_MODULES": [module], "SPIDER_LOADER_WARN_ONLY": True} + ) + with pytest.warns(RuntimeWarning, match="Could not load spiders from module"): spider_loader = SpiderLoader.from_settings(settings) - if str(w[0].message).startswith("_SixMetaPathImporter"): - # needed on 3.10 because of https://github.com/benjaminp/six/issues/349, - # at least until all six versions we can import (including botocore.vendored.six) - # are updated to 1.16.0+ - w.pop(0) - assert "Could not load spiders from module" in str(w[0].message) - spiders = spider_loader.list() - assert not spiders + spiders = spider_loader.list() + assert not spiders def test_syntax_error_exception(self): module = "tests.test_spiderloader.test_spiders.spider1" + settings = Settings({"SPIDER_MODULES": [module]}) with mock.patch.object(SpiderLoader, "_load_spiders") as m: m.side_effect = SyntaxError - settings = Settings({"SPIDER_MODULES": [module]}) with pytest.raises(SyntaxError): SpiderLoader.from_settings(settings) def test_syntax_error_warning(self): - with ( - warnings.catch_warnings(record=True) as w, - mock.patch.object(SpiderLoader, "_load_spiders") as m, - ): + module = "tests.test_spiderloader.test_spiders.spider1" + settings = Settings( + {"SPIDER_MODULES": [module], "SPIDER_LOADER_WARN_ONLY": True} + ) + with mock.patch.object(SpiderLoader, "_load_spiders") as m: m.side_effect = SyntaxError - module = "tests.test_spiderloader.test_spiders.spider1" - settings = Settings( - {"SPIDER_MODULES": [module], "SPIDER_LOADER_WARN_ONLY": True} - ) - spider_loader = SpiderLoader.from_settings(settings) - if str(w[0].message).startswith("_SixMetaPathImporter"): - # needed on 3.10 because of https://github.com/benjaminp/six/issues/349, - # at least until all six versions we can import (including botocore.vendored.six) - # are updated to 1.16.0+ - w.pop(0) - assert "Could not load spiders from module" in str(w[0].message) + with pytest.warns( + RuntimeWarning, match="Could not load spiders from module" + ): + spider_loader = SpiderLoader.from_settings(settings) - spiders = spider_loader.list() - assert not spiders + spiders = spider_loader.list() + assert not spiders class TestDuplicateSpiderNameLoader: @@ -190,21 +177,17 @@ class TestDuplicateSpiderNameLoader: # copy 1 spider module so as to have duplicate spider name shutil.copyfile(spiders_dir / "spider3.py", spiders_dir / "spider3dupe.py") - with warnings.catch_warnings(record=True) as w: + msg = r"""There are several spiders with the same name: + + Spider3 named 'spider3' \(in test_spiders_xxx\.spider3\) + + Spider3 named 'spider3' \(in test_spiders_xxx\.spider3dupe\) + + This can cause unexpected behavior\.""" + with pytest.warns(UserWarning, match=msg): spider_loader = SpiderLoader.from_settings(settings) - - assert len(w) == 1 - msg = str(w[0].message) - assert "several spiders with the same name" in msg - assert "'spider3'" in msg - assert msg.count("'spider3'") == 2 - - assert "'spider1'" not in msg - assert "'spider2'" not in msg - assert "'spider4'" not in msg - - spiders = set(spider_loader.list()) - assert spiders == {"spider1", "spider2", "spider3", "spider4"} + spiders = set(spider_loader.list()) + assert spiders == {"spider1", "spider2", "spider3", "spider4"} def test_multiple_dupename_warning(self, spider_loader_env): settings, spiders_dir = spider_loader_env @@ -213,23 +196,21 @@ class TestDuplicateSpiderNameLoader: shutil.copyfile(spiders_dir / "spider1.py", spiders_dir / "spider1dupe.py") shutil.copyfile(spiders_dir / "spider2.py", spiders_dir / "spider2dupe.py") - with warnings.catch_warnings(record=True) as w: + msg = r"""There are several spiders with the same name: + + Spider1 named 'spider1' \(in test_spiders_xxx\.spider1\) + + Spider1 named 'spider1' \(in test_spiders_xxx\.spider1dupe\) + + Spider2 named 'spider2' \(in test_spiders_xxx\.spider2\) + + Spider2 named 'spider2' \(in test_spiders_xxx\.spider2dupe\) + + This can cause unexpected behavior\.""" + with pytest.warns(UserWarning, match=msg): spider_loader = SpiderLoader.from_settings(settings) - - assert len(w) == 1 - msg = str(w[0].message) - assert "several spiders with the same name" in msg - assert "'spider1'" in msg - assert msg.count("'spider1'") == 2 - - assert "'spider2'" in msg - assert msg.count("'spider2'") == 2 - - assert "'spider3'" not in msg - assert "'spider4'" not in msg - - spiders = set(spider_loader.list()) - assert spiders == {"spider1", "spider2", "spider3", "spider4"} + spiders = set(spider_loader.list()) + assert spiders == {"spider1", "spider2", "spider3", "spider4"} class CustomSpiderLoader(SpiderLoader): diff --git a/tests/test_spidermiddleware_referer.py b/tests/test_spidermiddleware_referer.py index a9089419a..b3a434c3f 100644 --- a/tests/test_spidermiddleware_referer.py +++ b/tests/test_spidermiddleware_referer.py @@ -6,6 +6,7 @@ from urllib.parse import urlparse import pytest +from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.http import Request, Response from scrapy.settings import Settings from scrapy.spidermiddlewares.referer import ( @@ -842,13 +843,14 @@ class TestRequestMetaSettingFallback: response = Response(origin, headers=response_headers) request = Request(target, meta=request_meta) - with warnings.catch_warnings(record=True) as w: + if check_warning: + with pytest.warns( + RuntimeWarning, match="Could not load referrer policy" + ): + policy = mw.policy(response, request) + else: policy = mw.policy(response, request) - assert isinstance(policy, policy_class) - - if check_warning: - assert len(w) == 1 - assert w[0].category is RuntimeWarning, w[0].message + assert isinstance(policy, policy_class) class TestSettingsPolicyByName: @@ -973,49 +975,39 @@ class TestPolicyMethodResponseParamRename: self.response = Response("http://www.example.com") def test_pos_string(self): - with warnings.catch_warnings(record=True) as w: + with pytest.warns( + ScrapyDeprecationWarning, + match=r"Passing a response URL to RefererMiddleware\.policy\(\)", + ): self.mw.policy("http://old.com", self.request) - found = False - for warning in w: - if "Passing a response URL" in str(warning.message): - found = True - break - assert found def test_pos_response(self): - with warnings.catch_warnings(record=True) as w: + with warnings.catch_warnings(): + warnings.filterwarnings( + "error", + category=ScrapyDeprecationWarning, + message=r"Passing 'resp_or_url' is deprecated", + ) self.mw.policy(self.response, self.request) - for warning in w: - assert "resp_or_url" not in str(warning.message) def test_key_resp_or_url(self): - with warnings.catch_warnings(record=True) as w: + with pytest.warns( + ScrapyDeprecationWarning, match=r"Passing 'resp_or_url' is deprecated" + ): self.mw.policy(resp_or_url=self.response, request=self.request) - found = False - for warning in w: - if "Passing 'resp_or_url' is deprecated, use 'response' instead" in str( - warning.message - ): - found = True - break - assert found def test_key_response(self): - with warnings.catch_warnings(record=True) as w: + with warnings.catch_warnings(): + warnings.filterwarnings( + "error", + category=ScrapyDeprecationWarning, + message=r"Passing 'resp_or_url' is deprecated", + ) self.mw.policy(response=self.response, request=self.request) - for warning in w: - assert "resp_or_url" not in str(warning.message) def test_key_response_string(self): - with warnings.catch_warnings(record=True) as w: - warnings.simplefilter("always") + with pytest.warns(ScrapyDeprecationWarning, match="Passing a response URL"): self.mw.policy(response="http://old.com", request=self.request) - found = False - for warning in w: - if "Passing a response URL" in str(warning.message): - found = True - break - assert found def test_both_resp_or_url_and_response(self): with pytest.raises( diff --git a/tests/test_utils_curl.py b/tests/test_utils_curl.py index b1532ca77..0627a4b93 100644 --- a/tests/test_utils_curl.py +++ b/tests/test_utils_curl.py @@ -213,10 +213,12 @@ class TestCurlToRequestKwargs: def test_ignore_unknown_options(self): # case 1: ignore_unknown_options=True: + curl_command = "curl --bar --baz http://www.example.com" + expected_result = {"method": "GET", "url": "http://www.example.com"} with warnings.catch_warnings(): # avoid warning when executing tests - warnings.simplefilter("ignore") - curl_command = "curl --bar --baz http://www.example.com" - expected_result = {"method": "GET", "url": "http://www.example.com"} + warnings.filterwarnings( + "ignore", category=UserWarning, message="Unrecognized options:" + ) assert curl_to_request_kwargs(curl_command) == expected_result # case 2: ignore_unknown_options=False (raise exception): diff --git a/tests/test_utils_datatypes.py b/tests/test_utils_datatypes.py index f573993a1..ba6b82503 100644 --- a/tests/test_utils_datatypes.py +++ b/tests/test_utils_datatypes.py @@ -1,5 +1,4 @@ import copy -import warnings from abc import ABC, abstractmethod from collections.abc import Iterator, Mapping, MutableMapping from typing import Any @@ -227,18 +226,12 @@ class TestCaselessDict(TestCaseInsensitiveDictBase): dict_class = CaselessDict def test_deprecation_message(self): - with warnings.catch_warnings(record=True) as caught: - warnings.filterwarnings("always", category=ScrapyDeprecationWarning) + with pytest.warns( + ScrapyDeprecationWarning, + match=r"scrapy.utils.datatypes.CaselessDict is deprecated", + ): self.dict_class({"foo": "bar"}) - assert len(caught) == 1 - assert issubclass(caught[0].category, ScrapyDeprecationWarning) - assert ( - str(caught[0].message) - == "scrapy.utils.datatypes.CaselessDict is deprecated," - " please use scrapy.utils.datatypes.CaseInsensitiveDict instead" - ) - class TestSequenceExclude: def test_list(self): diff --git a/tests/test_utils_deprecate.py b/tests/test_utils_deprecate.py index 5ea6f678e..0706fec99 100644 --- a/tests/test_utils_deprecate.py +++ b/tests/test_utils_deprecate.py @@ -1,7 +1,6 @@ import inspect import warnings from unittest import mock -from warnings import WarningMessage import pytest @@ -22,34 +21,26 @@ class NewName(SomeBaseClass): class TestWarnWhenSubclassed: - def _mywarnings(self, w: list[WarningMessage]) -> list[WarningMessage]: - return [x for x in w if x.category is MyWarning] - def test_no_warning_on_definition(self): - with warnings.catch_warnings(record=True) as w: + with warnings.catch_warnings(): + warnings.simplefilter("error", category=ScrapyDeprecationWarning) create_deprecated_class("Deprecated", NewName) - w = self._mywarnings(w) - assert w == [] - def test_subclassing_warning_message(self): + msg = ( + r"tests\.test_utils_deprecate\.UserClass inherits from " + r"deprecated class tests\.test_utils_deprecate\.Deprecated, " + r"please inherit from tests\.test_utils_deprecate\.NewName." + r" \(warning only on first subclass, there may be others\)" + ) Deprecated = create_deprecated_class( "Deprecated", NewName, warn_category=MyWarning ) - - with warnings.catch_warnings(record=True) as w: + with pytest.warns(MyWarning, match=msg) as w: class UserClass(Deprecated): pass - w = self._mywarnings(w) - assert len(w) == 1 - assert ( - str(w[0].message) == "tests.test_utils_deprecate.UserClass inherits from " - "deprecated class tests.test_utils_deprecate.Deprecated, " - "please inherit from tests.test_utils_deprecate.NewName." - " (warning only on first subclass, there may be others)" - ) assert w[0].lineno == inspect.getsourcelines(UserClass)[1] def test_custom_class_paths(self): @@ -61,62 +52,77 @@ class TestWarnWhenSubclassed: warn_category=MyWarning, ) - with warnings.catch_warnings(record=True) as w: + with pytest.warns( + MyWarning, + match=r"UserClass inherits from deprecated class bar\.OldClass, please inherit from foo\.NewClass", + ): class UserClass(Deprecated): pass + with pytest.warns( + MyWarning, + match=r"bar\.OldClass is deprecated, instantiate foo\.NewClass instead", + ): _ = Deprecated() - w = self._mywarnings(w) - assert len(w) == 2 - assert "foo.NewClass" in str(w[0].message) - assert "bar.OldClass" in str(w[0].message) - assert "foo.NewClass" in str(w[1].message) - assert "bar.OldClass" in str(w[1].message) - def test_subclassing_warns_only_on_direct_children(self): Deprecated = create_deprecated_class( "Deprecated", NewName, warn_once=False, warn_category=MyWarning ) - with warnings.catch_warnings(record=True) as w: + with pytest.warns( + MyWarning, + match="UserClass inherits from deprecated class", + ): class UserClass(Deprecated): pass + with warnings.catch_warnings(): + warnings.simplefilter("error", MyWarning) + class NoWarnOnMe(UserClass): pass - w = self._mywarnings(w) - assert len(w) == 1 - assert "UserClass" in str(w[0].message) - def test_subclassing_warns_once_by_default(self): Deprecated = create_deprecated_class( "Deprecated", NewName, warn_category=MyWarning ) - with warnings.catch_warnings(record=True) as w: + with pytest.warns( + MyWarning, + match="UserClass inherits from deprecated class", + ): class UserClass(Deprecated): pass + with warnings.catch_warnings(): + warnings.simplefilter("error", MyWarning) + class FooClass(Deprecated): pass class BarClass(Deprecated): pass - w = self._mywarnings(w) - assert len(w) == 1 - assert "UserClass" in str(w[0].message) - def test_warning_on_instance(self): Deprecated = create_deprecated_class( "Deprecated", NewName, warn_category=MyWarning ) + with pytest.warns(MyWarning) as w: + _, lineno = Deprecated(), inspect.getlineno(inspect.currentframe()) + + w = [x for x in w if x.category is MyWarning] + assert len(w) == 1 + assert ( + str(w[0].message) == "tests.test_utils_deprecate.Deprecated is deprecated, " + "instantiate tests.test_utils_deprecate.NewName instead." + ) + assert w[0].lineno == lineno + # ignore subclassing warnings with warnings.catch_warnings(): warnings.simplefilter("ignore", MyWarning) @@ -124,29 +130,20 @@ class TestWarnWhenSubclassed: class UserClass(Deprecated): pass - with warnings.catch_warnings(record=True) as w: - _, lineno = Deprecated(), inspect.getlineno(inspect.currentframe()) - _ = UserClass() # subclass instances don't warn - - w = self._mywarnings(w) - assert len(w) == 1 - assert ( - str(w[0].message) == "tests.test_utils_deprecate.Deprecated is deprecated, " - "instantiate tests.test_utils_deprecate.NewName instead." - ) - assert w[0].lineno == lineno + with warnings.catch_warnings(): + warnings.simplefilter("error", MyWarning) + UserClass() # subclass instances don't warn def test_warning_auto_message(self): - with warnings.catch_warnings(record=True) as w: - Deprecated = create_deprecated_class("Deprecated", NewName) + Deprecated = create_deprecated_class("Deprecated", NewName) + with pytest.warns( + ScrapyDeprecationWarning, + match=r"UserClass2 inherits from deprecated class tests\.test_utils_deprecate\.Deprecated, please inherit from tests\.test_utils_deprecate\.NewName", + ): class UserClass2(Deprecated): pass - msg = str(w[0].message) - assert "tests.test_utils_deprecate.NewName" in msg - assert "tests.test_utils_deprecate.Deprecated" in msg - def test_issubclass(self): with warnings.catch_warnings(): warnings.simplefilter("ignore", ScrapyDeprecationWarning) @@ -222,8 +219,8 @@ class TestWarnWhenSubclassed: create_deprecated_class("Deprecated", New) def test_deprecate_subclass_of_deprecated_class(self): - with warnings.catch_warnings(record=True) as w: - warnings.simplefilter("always") + with warnings.catch_warnings(): + warnings.simplefilter("error", MyWarning) Deprecated = create_deprecated_class( "Deprecated", NewName, warn_category=MyWarning ) @@ -234,33 +231,26 @@ class TestWarnWhenSubclassed: warn_category=MyWarning, ) - w = self._mywarnings(w) - assert len(w) == 0, [str(warning) for warning in w] - - with warnings.catch_warnings(record=True) as w: + with pytest.warns( + MyWarning, + match=r"AlsoDeprecated is deprecated, instantiate foo\.Bar instead", + ): AlsoDeprecated() + with pytest.warns( + MyWarning, + match=r"UserClass inherits from deprecated class tests\.test_utils_deprecate\.AlsoDeprecated, please inherit from foo\.Bar", + ): + class UserClass(AlsoDeprecated): pass - w = self._mywarnings(w) - assert len(w) == 2 - assert "AlsoDeprecated" in str(w[0].message) - assert "foo.Bar" in str(w[0].message) - assert "AlsoDeprecated" in str(w[1].message) - assert "foo.Bar" in str(w[1].message) - def test_inspect_stack(self): with ( mock.patch("inspect.stack", side_effect=IndexError), - warnings.catch_warnings(record=True) as w, + pytest.warns(UserWarning, match="Error detecting parent module"), ): - DeprecatedName = create_deprecated_class("DeprecatedName", NewName) - - class SubClass(DeprecatedName): - pass - - assert "Error detecting parent module" in str(w[0].message) + create_deprecated_class("DeprecatedName", NewName) @mock.patch( @@ -272,12 +262,12 @@ class TestWarnWhenSubclassed: ) class TestUpdateClassPath: def test_old_path_gets_fixed(self): - with warnings.catch_warnings(record=True) as w: + with pytest.warns( + ScrapyDeprecationWarning, + match="`scrapy.contrib.debug.Debug` class is deprecated, use `scrapy.extensions.debug.Debug` instead", + ): output = update_classpath("scrapy.contrib.debug.Debug") assert output == "scrapy.extensions.debug.Debug" - assert len(w) == 1 - assert "scrapy.contrib.debug.Debug" in str(w[0].message) - assert "scrapy.extensions.debug.Debug" in str(w[0].message) def test_sorted_replacement(self): with warnings.catch_warnings(): @@ -286,10 +276,10 @@ class TestUpdateClassPath: assert output == "scrapy.pipelines.Pipeline" def test_unmatched_path_stays_the_same(self): - with warnings.catch_warnings(record=True) as w: + with warnings.catch_warnings(): + warnings.simplefilter("error", ScrapyDeprecationWarning) output = update_classpath("scrapy.unmatched.Path") assert output == "scrapy.unmatched.Path" - assert len(w) == 0 def test_returns_nonstring(self): for notastring in [None, True, [1, 2, 3], object()]: diff --git a/tests/test_utils_misc/test_return_with_argument_inside_generator.py b/tests/test_utils_misc/test_return_with_argument_inside_generator.py index 3783416b9..1acc3aac2 100644 --- a/tests/test_utils_misc/test_return_with_argument_inside_generator.py +++ b/tests/test_utils_misc/test_return_with_argument_inside_generator.py @@ -93,29 +93,27 @@ https://example.org assert is_generator_with_return_value(h1) assert is_generator_with_return_value(i1) - with warnings.catch_warnings(record=True) as w: + with pytest.warns( + UserWarning, + match='The "MockSpider.top_level_return_something" method is a generator', + ): warn_on_generator_with_return_value(mock_spider, top_level_return_something) - assert len(w) == 1 - assert ( - 'The "MockSpider.top_level_return_something" method is a generator' - in str(w[0].message) - ) - with warnings.catch_warnings(record=True) as w: + with pytest.warns( + UserWarning, match='The "MockSpider.f1" method is a generator' + ): warn_on_generator_with_return_value(mock_spider, f1) - assert len(w) == 1 - assert 'The "MockSpider.f1" method is a generator' in str(w[0].message) - with warnings.catch_warnings(record=True) as w: + with pytest.warns( + UserWarning, match='The "MockSpider.g1" method is a generator' + ): warn_on_generator_with_return_value(mock_spider, g1) - assert len(w) == 1 - assert 'The "MockSpider.g1" method is a generator' in str(w[0].message) - with warnings.catch_warnings(record=True) as w: + with pytest.warns( + UserWarning, match='The "MockSpider.h1" method is a generator' + ): warn_on_generator_with_return_value(mock_spider, h1) - assert len(w) == 1 - assert 'The "MockSpider.h1" method is a generator' in str(w[0].message) - with warnings.catch_warnings(record=True) as w: + with pytest.warns( + UserWarning, match='The "MockSpider.i1" method is a generator' + ): warn_on_generator_with_return_value(mock_spider, i1) - assert len(w) == 1 - assert 'The "MockSpider.i1" method is a generator' in str(w[0].message) def test_generators_return_none(self, mock_spider): def f2(): @@ -160,32 +158,18 @@ https://example.org assert not is_generator_with_return_value(k2) # not recursive assert not is_generator_with_return_value(l2) - with warnings.catch_warnings(record=True) as w: + with warnings.catch_warnings(): + warnings.simplefilter("error", UserWarning) warn_on_generator_with_return_value(mock_spider, top_level_return_none) - assert len(w) == 0 - with warnings.catch_warnings(record=True) as w: warn_on_generator_with_return_value(mock_spider, f2) - assert len(w) == 0 - with warnings.catch_warnings(record=True) as w: warn_on_generator_with_return_value(mock_spider, g2) - assert len(w) == 0 - with warnings.catch_warnings(record=True) as w: warn_on_generator_with_return_value(mock_spider, h2) - assert len(w) == 0 - with warnings.catch_warnings(record=True) as w: warn_on_generator_with_return_value(mock_spider, i2) - assert len(w) == 0 - with warnings.catch_warnings(record=True) as w: warn_on_generator_with_return_value(mock_spider, j2) - assert len(w) == 0 - with warnings.catch_warnings(record=True) as w: warn_on_generator_with_return_value(mock_spider, k2) - assert len(w) == 0 - with warnings.catch_warnings(record=True) as w: warn_on_generator_with_return_value(mock_spider, l2) - assert len(w) == 0 - def test_generators_return_none_with_decorator(self, mock_spider): # noqa: PLR0915 + def test_generators_return_none_with_decorator(self, mock_spider): def decorator(func): def inner_func(): func() @@ -241,39 +225,23 @@ https://example.org assert not is_generator_with_return_value(k3) # not recursive assert not is_generator_with_return_value(l3) - with warnings.catch_warnings(record=True) as w: + with warnings.catch_warnings(): + warnings.simplefilter("error", UserWarning) warn_on_generator_with_return_value(mock_spider, top_level_return_none) - assert len(w) == 0 - with warnings.catch_warnings(record=True) as w: warn_on_generator_with_return_value(mock_spider, f3) - assert len(w) == 0 - with warnings.catch_warnings(record=True) as w: warn_on_generator_with_return_value(mock_spider, g3) - assert len(w) == 0 - with warnings.catch_warnings(record=True) as w: warn_on_generator_with_return_value(mock_spider, h3) - assert len(w) == 0 - with warnings.catch_warnings(record=True) as w: warn_on_generator_with_return_value(mock_spider, i3) - assert len(w) == 0 - with warnings.catch_warnings(record=True) as w: warn_on_generator_with_return_value(mock_spider, j3) - assert len(w) == 0 - with warnings.catch_warnings(record=True) as w: warn_on_generator_with_return_value(mock_spider, k3) - assert len(w) == 0 - with warnings.catch_warnings(record=True) as w: warn_on_generator_with_return_value(mock_spider, l3) - assert len(w) == 0 @mock.patch( "scrapy.utils.misc.is_generator_with_return_value", new=_indentation_error ) def test_indentation_error(self, mock_spider): - with warnings.catch_warnings(record=True) as w: + with pytest.warns(UserWarning, match="Unable to determine"): warn_on_generator_with_return_value(mock_spider, top_level_return_none) - assert len(w) == 1 - assert "Unable to determine" in str(w[0].message) def test_partial(self): def cb(arg1, arg2): @@ -300,13 +268,11 @@ https://example.org yield 1 return "value" - with warnings.catch_warnings(record=True) as w: + with warnings.catch_warnings(): + warnings.simplefilter("error", UserWarning) warn_on_generator_with_return_value(spider, gen_with_return) - assert len(w) == 0 spider.settings.settings_dict["WARN_ON_GENERATOR_RETURN_VALUE"] = True - with warnings.catch_warnings(record=True) as w: + with pytest.warns(UserWarning, match="is a generator"): warn_on_generator_with_return_value(spider, gen_with_return) - assert len(w) == 1 - assert "is a generator" in str(w[0].message) diff --git a/tests/test_utils_sitemap.py b/tests/test_utils_sitemap.py index f2bcd7541..3599c4824 100644 --- a/tests/test_utils_sitemap.py +++ b/tests/test_utils_sitemap.py @@ -1,5 +1,6 @@ -import warnings +import pytest +from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.utils.sitemap import Sitemap, sitemap_urls_from_robots @@ -204,7 +205,10 @@ Disallow: /forum/search/ Disallow: /forum/active/ """ - with warnings.catch_warnings(record=True) as w: + with pytest.warns( + ScrapyDeprecationWarning, + match="Passing `str` type as `robots_text` is deprecated", + ): assert list( sitemap_urls_from_robots(robots, base_url="http://example.com") ) == [ @@ -213,9 +217,6 @@ Disallow: /forum/active/ "http://example.com/sitemap-uppercase.xml", "http://example.com/sitemap-relative-url.xml", ] - assert "Passing `str` type as `robots_text` is deprecated, use `bytes`" in str( - w[0].message - ) def test_sitemap_blanklines(): From 75f05d4e80b82705f54b2fe6b2948c463c7eb36b Mon Sep 17 00:00:00 2001 From: "Shashank S. Khasare" Date: Mon, 22 Jun 2026 12:20:11 +0530 Subject: [PATCH 207/248] Add test coverage for scrapy.utils.decorators (#7645) --- tests/test_utils_decorators.py | 107 +++++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 tests/test_utils_decorators.py diff --git a/tests/test_utils_decorators.py b/tests/test_utils_decorators.py new file mode 100644 index 000000000..9743e1a50 --- /dev/null +++ b/tests/test_utils_decorators.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +import warnings + +import pytest +from twisted.internet.defer import Deferred + +from scrapy.exceptions import ScrapyDeprecationWarning +from scrapy.utils.decorators import _warn_spider_arg, deprecated, inthread +from scrapy.utils.defer import maybe_deferred_to_future +from tests.utils.decorators import coroutine_test + + +class TestDeprecated: + def test_warns_and_still_calls(self): + @deprecated() + def add(a, b): + return a + b + + with pytest.warns( + ScrapyDeprecationWarning, match=r"Call to deprecated function add\." + ): + result = add(2, 3) + + assert result == 5 + + def test_use_instead_in_message(self): + @deprecated(use_instead="other_function") + def old(): + return None + + with pytest.warns( + ScrapyDeprecationWarning, + match=r"Call to deprecated function old\. Use other_function instead\.", + ): + old() + + def test_applied_without_parentheses(self): + @deprecated + def square(x): + return x * x + + with pytest.warns( + ScrapyDeprecationWarning, match=r"Call to deprecated function square\." + ) as record: + result = square(4) + + assert result == 16 + # No "Use ... instead." part when applied directly to the function. + assert "instead" not in str(record[0].message) + + +class TestInthread: + @coroutine_test + async def test_returns_deferred_with_result(self): + @inthread + def multiply(a, b): + return a * b + + deferred = multiply(6, 7) + assert isinstance(deferred, Deferred) + assert await maybe_deferred_to_future(deferred) == 42 + + +class TestWarnSpiderArg: + def test_sync_warns_with_spider_arg(self): + @_warn_spider_arg + def parse(response, spider=None): + return response + + with pytest.warns( + ScrapyDeprecationWarning, match=r"Passing a 'spider' argument" + ): + assert parse("response", spider="spider") == "response" + + def test_sync_no_warning_without_spider_arg(self): + @_warn_spider_arg + def parse(response, spider=None): + return response + + with warnings.catch_warnings(): + warnings.simplefilter("error", category=ScrapyDeprecationWarning) + assert parse("response") == "response" + + @coroutine_test + async def test_async_warns_with_spider_arg(self): + @_warn_spider_arg + async def parse(response, spider=None): + return response + + with pytest.warns( + ScrapyDeprecationWarning, match=r"Passing a 'spider' argument" + ): + assert await parse("response", spider="spider") == "response" + + @coroutine_test + async def test_asyncgen_warns_with_spider_arg(self): + @_warn_spider_arg + async def parse(response, spider=None): + yield response + + with pytest.warns( + ScrapyDeprecationWarning, match=r"Passing a 'spider' argument" + ): + results = [item async for item in parse("response", spider="spider")] + + assert results == ["response"] From b6596de317acb16c1a0de0a77496640f7542364b Mon Sep 17 00:00:00 2001 From: Adrian Date: Mon, 22 Jun 2026 17:10:10 +0200 Subject: [PATCH 208/248] Do not ignore CrawlerProcess settings (#7647) * Do not ignore CrawlerProcess settings * Update test_crawlerrunner_accepts_crawler --- scrapy/crawler.py | 8 ++++++-- tests/test_crawl.py | 4 ++-- tests/test_crawler.py | 36 ++++++++++++++++++++++++++++++++++++ 3 files changed, 44 insertions(+), 4 deletions(-) diff --git a/scrapy/crawler.py b/scrapy/crawler.py index 8e3ae7879..9828fe6f5 100644 --- a/scrapy/crawler.py +++ b/scrapy/crawler.py @@ -363,9 +363,12 @@ class CrawlerRunnerBase(ABC): """ Return a :class:`~scrapy.crawler.Crawler` object. - * If ``crawler_or_spidercls`` is a Crawler, it is returned as-is. + * If ``crawler_or_spidercls`` is a Crawler, the runner's settings are + merged into it as defaults: for each setting, the runner's value + is applied only if the Crawler does not already have that setting at + an equal or higher priority. The Crawler is then returned. * If ``crawler_or_spidercls`` is a Spider subclass, a new Crawler - is constructed for it. + is constructed for it using this runner's settings. * If ``crawler_or_spidercls`` is a string, this function finds a spider with this name in a Scrapy project (using spider loader), then creates a Crawler instance for it. @@ -376,6 +379,7 @@ class CrawlerRunnerBase(ABC): "it must be a spider class (or a Crawler object)" ) if isinstance(crawler_or_spidercls, Crawler): + crawler_or_spidercls.settings.update(self.settings) return crawler_or_spidercls return self._create_crawler(crawler_or_spidercls) diff --git a/tests/test_crawl.py b/tests/test_crawl.py index 66b15bfdd..be6c80429 100644 --- a/tests/test_crawl.py +++ b/tests/test_crawl.py @@ -14,7 +14,7 @@ from twisted.internet.ssl import Certificate from twisted.python.failure import Failure from scrapy import Spider, signals -from scrapy.crawler import AsyncCrawlerRunner, CrawlerRunner +from scrapy.crawler import AsyncCrawlerRunner, Crawler, CrawlerRunner from scrapy.exceptions import CloseSpider, ScrapyDeprecationWarning, StopDownload from scrapy.http import Request from scrapy.http.response import Response @@ -432,7 +432,7 @@ with multiples lines async def test_crawlerrunner_accepts_crawler( self, caplog: pytest.LogCaptureFixture, mockserver: MockServer ) -> None: - crawler = get_crawler(SimpleSpider) + crawler = Crawler(SimpleSpider, get_reactor_settings()) runner = CrawlerRunner() with caplog.at_level(logging.DEBUG): await maybe_deferred_to_future( diff --git a/tests/test_crawler.py b/tests/test_crawler.py index c646aea4b..0cddfd0ed 100644 --- a/tests/test_crawler.py +++ b/tests/test_crawler.py @@ -651,6 +651,42 @@ class TestAsyncCrawlerProcess(TestBaseCrawler): self.assertOptionIsDefault(runner.settings, "RETRY_ENABLED") +@pytest.mark.parametrize("runner_cls", [AsyncCrawlerRunner, CrawlerRunner]) +def test_runner_settings_applied_to_crawler_instance( + runner_cls: type[CrawlerRunnerBase], +) -> None: + runner = runner_cls({"FOO": "runner"}) + crawler = Crawler(DefaultSpider) + result = runner.create_crawler(crawler) + assert result is crawler + assert result.settings["FOO"] == "runner" + + +@pytest.mark.parametrize("runner_cls", [AsyncCrawlerRunner, CrawlerRunner]) +def test_spider_custom_settings_override_runner( + runner_cls: type[CrawlerRunnerBase], +) -> None: + class MySpider(DefaultSpider): + custom_settings = {"FOO": "spider"} + + runner = runner_cls({"FOO": "runner"}) + crawler = Crawler(MySpider) + runner.create_crawler(crawler) + assert crawler.settings["FOO"] == "spider" + + +def test_create_crawler_instance_consistent_with_spider_class() -> None: + runner = AsyncCrawlerRunner({"FOO": "runner"}) + + crawler_from_class = runner.create_crawler(DefaultSpider) + + pre_built = Crawler(DefaultSpider) + runner.create_crawler(pre_built) + + assert crawler_from_class.settings["FOO"] == "runner" + assert pre_built.settings["FOO"] == "runner" + + class ExceptionSpider(scrapy.Spider): name = "exception" From 7499d17e281cd214a70538ea92c05b14d8459dde Mon Sep 17 00:00:00 2001 From: Adrian Date: Mon, 22 Jun 2026 17:10:38 +0200 Subject: [PATCH 209/248] Improve test coverage for responsetypes.py (#7646) * Improve test coverage for responsetypes.py * Solve typing issues --- scrapy/responsetypes.py | 7 +------ tests/test_responsetypes.py | 3 +++ 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/scrapy/responsetypes.py b/scrapy/responsetypes.py index cd62f02af..29e9b6bb7 100644 --- a/scrapy/responsetypes.py +++ b/scrapy/responsetypes.py @@ -40,18 +40,13 @@ class ResponseTypes: self.classes: dict[str, type[Response]] = {} self.mimetypes: MimeTypes = MimeTypes() mimedata = get_data("scrapy", "mime.types") - if not mimedata: - raise ValueError( - "The mime.types file is not found in the Scrapy installation" - ) + assert mimedata is not None self.mimetypes.readfp(StringIO(mimedata.decode("utf8"))) for mimetype, cls in self.CLASSES.items(): self.classes[mimetype] = load_object(cls) def from_mimetype(self, mimetype: str) -> type[Response]: """Return the most appropriate Response class for the given mimetype""" - if mimetype is None: - return Response if mimetype in self.classes: return self.classes[mimetype] basetype = f"{mimetype.split('/', maxsplit=1)[0]}/*" diff --git a/tests/test_responsetypes.py b/tests/test_responsetypes.py index 5b04c7436..42ab29267 100644 --- a/tests/test_responsetypes.py +++ b/tests/test_responsetypes.py @@ -40,6 +40,9 @@ class TestResponseTypes: retcls = responsetypes.from_content_disposition(source) assert retcls is cls, f"{source} ==> {retcls} != {cls}" + def test_from_content_disposition_no_filename(self): + assert responsetypes.from_content_disposition(b"attachment") is Response + def test_from_content_type(self): mappings = [ ("text/html; charset=UTF-8", HtmlResponse), From f605defefc5cb8e7a969fba7da66c2087868c104 Mon Sep 17 00:00:00 2001 From: Adrian Date: Mon, 22 Jun 2026 17:12:16 +0200 Subject: [PATCH 210/248] Document scrapy-lint, remove start_url check (#7627) * Document scrapy-lint, remove start_url check * Remove the offsite URL check in favor of scrapy-lint --- .pre-commit-config.yaml | 2 +- docs/conf.py | 1 + docs/requirements.in | 2 +- docs/requirements.txt | 2 +- docs/topics/practices.rst | 8 ++++++++ scrapy/downloadermiddlewares/offsite.py | 18 +----------------- scrapy/spiders/__init__.py | 6 ------ tests/test_downloadermiddleware_offsite.py | 10 ++-------- tests/test_spider_crawl.py | 15 --------------- tox.ini | 2 +- 10 files changed, 16 insertions(+), 50 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6b9ef3c04..754bd6f9c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -27,6 +27,6 @@ repos: hooks: - id: sphinx-lint - repo: https://github.com/scrapy/sphinx-scrapy - rev: 0.8.6 + rev: 0.8.8 hooks: - id: sphinx-scrapy diff --git a/docs/conf.py b/docs/conf.py index 99d5df7da..b950c4ee2 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -158,6 +158,7 @@ scrapy_intersphinx_enable = [ "itemloaders", "parsel", "pytest", + "scrapy-lint", "sphinx", "tox", "twisted", diff --git a/docs/requirements.in b/docs/requirements.in index 140791641..a1f3a7468 100644 --- a/docs/requirements.in +++ b/docs/requirements.in @@ -5,4 +5,4 @@ sphinx sphinx-notfound-page sphinx-rtd-theme sphinx-rtd-dark-mode -sphinx-scrapy @ git+https://github.com/scrapy/sphinx-scrapy.git@0.8.6 +sphinx-scrapy @ git+https://github.com/scrapy/sphinx-scrapy.git@0.8.8 diff --git a/docs/requirements.txt b/docs/requirements.txt index 9c93dacd0..a5cbad302 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -153,7 +153,7 @@ sphinx-rtd-theme==3.1.0 # via # -r docs/requirements.in # sphinx-rtd-dark-mode -sphinx-scrapy @ git+https://github.com/scrapy/sphinx-scrapy.git@b1d55db4d16a5425fc68576d63519bbfe26dd9c0 +sphinx-scrapy @ git+https://github.com/scrapy/sphinx-scrapy.git@c0b2ac815afc3cb8857d575cecb5d55c05e6b737 # via -r docs/requirements.in sphinx-sitemap==2.9.0 # via sphinx-scrapy diff --git a/docs/topics/practices.rst b/docs/topics/practices.rst index b3c58d6d3..8a04f7ada 100644 --- a/docs/topics/practices.rst +++ b/docs/topics/practices.rst @@ -440,6 +440,14 @@ Here are some tips to keep in mind when dealing with these kinds of sites: If you are still unable to prevent your bot getting banned, consider contacting `commercial support`_. +.. _static-analysis: + +Static analysis +=============== + +Consider using :doc:`scrapy-lint `, a linter for Scrapy +projects that detects common mistakes and anti-patterns. + .. _Tor project: https://www.torproject.org/ .. _commercial support: https://www.scrapy.org/companies .. _ProxyMesh: https://proxymesh.com/ diff --git a/scrapy/downloadermiddlewares/offsite.py b/scrapy/downloadermiddlewares/offsite.py index 10f19bacc..e03dc040e 100644 --- a/scrapy/downloadermiddlewares/offsite.py +++ b/scrapy/downloadermiddlewares/offsite.py @@ -2,7 +2,6 @@ from __future__ import annotations import logging import re -import warnings from typing import TYPE_CHECKING from scrapy import Request, Spider, signals @@ -75,25 +74,10 @@ class OffsiteMiddleware: allowed_domains = getattr(spider, "allowed_domains", None) if not allowed_domains: return re.compile("") # allow all by default - url_pattern = re.compile(r"^https?://.*$") - port_pattern = re.compile(r":\d+$") domains = [] for domain in allowed_domains: if domain is None: continue - if url_pattern.match(domain): - message = ( - "allowed_domains accepts only domains, not URLs. " - f"Ignoring URL entry {domain} in allowed_domains." - ) - warnings.warn(message, stacklevel=2) - elif port_pattern.search(domain): - message = ( - "allowed_domains accepts only domains without ports. " - f"Ignoring entry {domain} in allowed_domains." - ) - warnings.warn(message, stacklevel=2) - else: - domains.append(re.escape(domain)) + domains.append(re.escape(domain)) regex = rf"^(.*\.)?({'|'.join(domains)})$" return re.compile(regex) diff --git a/scrapy/spiders/__init__.py b/scrapy/spiders/__init__.py index 6e8c67adb..de527d04f 100644 --- a/scrapy/spiders/__init__.py +++ b/scrapy/spiders/__init__.py @@ -125,12 +125,6 @@ class Spider(object_ref): .. seealso:: :ref:`start-requests` """ - if not self.start_urls and hasattr(self, "start_url"): - raise AttributeError( - "Crawling could not start: 'start_urls' not found " - "or empty (but found 'start_url' attribute instead, " - "did you miss an 's'?)" - ) for url in self.start_urls: yield Request(url, dont_filter=True) diff --git a/tests/test_downloadermiddleware_offsite.py b/tests/test_downloadermiddleware_offsite.py index dc0a31a76..edfb15d10 100644 --- a/tests/test_downloadermiddleware_offsite.py +++ b/tests/test_downloadermiddleware_offsite.py @@ -1,5 +1,3 @@ -import warnings - import pytest from scrapy import Request, Spider @@ -120,9 +118,7 @@ def test_process_request_invalid_domains(): allowed_domains = ["a.example", None, "http:////b.example", "//c.example"] crawler.spider = crawler._create_spider(name="a", allowed_domains=allowed_domains) mw = OffsiteMiddleware.from_crawler(crawler) - with warnings.catch_warnings(): - warnings.simplefilter("ignore", UserWarning) - mw.spider_opened(crawler.spider) + mw.spider_opened(crawler.spider) request = Request("https://a.example") assert mw.process_request(request) is None for letter in ("b", "c"): @@ -210,9 +206,7 @@ def test_request_scheduled_invalid_domains(): allowed_domains = ["a.example", None, "http:////b.example", "//c.example"] crawler.spider = crawler._create_spider(name="a", allowed_domains=allowed_domains) mw = OffsiteMiddleware.from_crawler(crawler) - with warnings.catch_warnings(): - warnings.simplefilter("ignore", UserWarning) - mw.spider_opened(crawler.spider) + mw.spider_opened(crawler.spider) request = Request("https://a.example") assert mw.request_scheduled(request, crawler.spider) is None for letter in ("b", "c"): diff --git a/tests/test_spider_crawl.py b/tests/test_spider_crawl.py index 2eeae110b..63010e195 100644 --- a/tests/test_spider_crawl.py +++ b/tests/test_spider_crawl.py @@ -2,10 +2,8 @@ from __future__ import annotations import re import warnings -from logging import ERROR import pytest -from testfixtures import LogCapture from w3lib.url import safe_url_string from scrapy.exceptions import ScrapyDeprecationWarning @@ -14,7 +12,6 @@ from scrapy.linkextractors import LinkExtractor from scrapy.spiders import CrawlSpider, Rule, Spider from scrapy.utils.test import get_crawler from tests.test_spider import TestSpider -from tests.utils.decorators import inline_callbacks_test class TestCrawlSpider(TestSpider): @@ -247,18 +244,6 @@ class TestCrawlSpider(TestSpider): assert hasattr(spider, "_follow_links") assert not spider._follow_links - @inline_callbacks_test - def test_start_url(self): - class TestSpider(self.spider_class): - name = "test" - start_url = "https://www.example.com" - - crawler = get_crawler(TestSpider) - with LogCapture("scrapy.core.engine", propagate=False, level=ERROR) as log: - yield crawler.crawl() - assert "Error while reading start items and requests" in str(log) - assert "did you miss an 's'?" in str(log) - def test_parse_response_use(self): class _CrawlSpider(CrawlSpider): name = "test" diff --git a/tox.ini b/tox.ini index 44e240e03..2fa7b455d 100644 --- a/tox.ini +++ b/tox.ini @@ -5,7 +5,7 @@ [tox] requires = - sphinx-scrapy[tox] @ git+https://github.com/scrapy/sphinx-scrapy.git@0.8.6 + sphinx-scrapy[tox] @ git+https://github.com/scrapy/sphinx-scrapy.git@0.8.8 envlist = pre-commit pylint From f5a62a293f82bb3a7d4be1357ae8392d61f56021 Mon Sep 17 00:00:00 2001 From: tanishqtayade Date: Tue, 23 Jun 2026 12:34:01 +0530 Subject: [PATCH 211/248] Fix cell-var-from-loop bug in _send_catch_log_deferred (#7649) --- scrapy/utils/signal.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/scrapy/utils/signal.py b/scrapy/utils/signal.py index ee391a49e..919f67240 100644 --- a/scrapy/utils/signal.py +++ b/scrapy/utils/signal.py @@ -125,13 +125,11 @@ def _send_catch_log_deferred( **named, ) d.addErrback(logerror, receiver) - # TODO https://pylint.readthedocs.io/en/latest/user_guide/messages/warning/cell-var-from-loop.html + d2: Deferred[tuple[TypingAny, TypingAny]] = d.addBoth( - lambda result: ( - receiver, # pylint: disable=cell-var-from-loop # noqa: B023 - result, - ) + lambda result, recv: (recv, result), receiver ) + dfds.append(d2) results = yield DeferredList(dfds) From fb3455304d9e9f5332a97edbe2ea0a77a874e974 Mon Sep 17 00:00:00 2001 From: smellslikeml Date: Tue, 23 Jun 2026 00:04:19 -0700 Subject: [PATCH 212/248] Add content-based image filtering example (#4954) --- docs/topics/media-pipeline.rst | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/docs/topics/media-pipeline.rst b/docs/topics/media-pipeline.rst index b542c6c05..c67d2d627 100644 --- a/docs/topics/media-pipeline.rst +++ b/docs/topics/media-pipeline.rst @@ -774,4 +774,28 @@ To enable your custom media pipeline component you must add its class import pat ITEM_PIPELINES = {"myproject.pipelines.MyImagesPipeline": 300} +Content-based image filtering pipeline +-------------------------------------- + +This example overrides ``get_images()`` to filter images using a classifier, +such as a TensorFlow_ model. Override ``is_valid_image()`` with your +classification logic: + +.. code-block:: python + + from scrapy.pipelines.images import ImagesPipeline, ImageException + + + class ImageClassifierPipeline(ImagesPipeline): + def is_valid_image(self, image): + raise NotImplementedError + + def get_images(self, response, request, info, *, item=None): + for path, image, buf in super().get_images(response, request, info, item=item): + if not self.is_valid_image(image): + raise ImageException("Image does not match criteria") + yield path, image, buf + + .. _MD5 hash: https://en.wikipedia.org/wiki/MD5 +.. _TensorFlow: https://tensorflow.org From b78ab3d6c8715d488ac657e0d09d0cf7bc6abc42 Mon Sep 17 00:00:00 2001 From: Adrian Date: Tue, 23 Jun 2026 11:47:21 +0200 Subject: [PATCH 213/248] Improve test coverage for settings/ (#7654) --- tests/test_settings/__init__.py | 59 +++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/tests/test_settings/__init__.py b/tests/test_settings/__init__.py index 908bb417c..3282b0591 100644 --- a/tests/test_settings/__init__.py +++ b/tests/test_settings/__init__.py @@ -539,6 +539,56 @@ class TestBaseSettings: msg = caplog.records[0].message assert "tests.test_settings.Component1" in msg + def test_getdictorlist(self): + settings = BaseSettings() + + # No value and no default → {} + assert settings.getdictorlist("MISSING") == {} + + # String: valid JSON dict + settings.set("S_DICT_STR", '{"key": "val"}') + assert settings.getdictorlist("S_DICT_STR") == {"key": "val"} + + # String: valid JSON list + settings.set("S_LIST_STR", '["a", "b"]') + assert settings.getdictorlist("S_LIST_STR") == ["a", "b"] + + # String: invalid JSON → comma-split fallback + settings.set("S_CSV", "a,b,c") + assert settings.getdictorlist("S_CSV") == ["a", "b", "c"] + + # String: valid JSON but not dict or list → ValueError caught → comma-split + settings.set("S_JSON_NUMBER", "123") + assert settings.getdictorlist("S_JSON_NUMBER") == ["123"] + + # Tuple → list + settings.set("S_TUPLE", ("x", "y")) + assert settings.getdictorlist("S_TUPLE") == ["x", "y"] + + # Unsupported type → raises ValueError + settings.set("S_INT", 42) + with pytest.raises(ValueError, match="must be a dict, list, tuple, or string"): + settings.getdictorlist("S_INT") + + # Dict value → deepcopy returned + settings.set("S_DICT", {"key": "val"}) + assert settings.getdictorlist("S_DICT") == {"key": "val"} + + # List value → deepcopy returned + settings.set("S_LIST", ["a", "b"]) + assert settings.getdictorlist("S_LIST") == ["a", "b"] + + def test_repr_pretty_(self): + settings = BaseSettings({"key": "value"}) + mock_p = mock.Mock() + + settings._repr_pretty_(mock_p, cycle=False) + assert mock_p.text.call_count == 1 + + mock_p.reset_mock() + settings._repr_pretty_(mock_p, cycle=True) + mock_p.text.assert_called_once_with(repr(settings)) + def test_getwithbase_invalid_setting_name(self): settings = BaseSettings() with pytest.raises( @@ -710,6 +760,15 @@ def test_remove_from_list(before, name, item, after): assert settings.getpriority(name) == expected_settings.getpriority(name) +def test_deprecated_dns_resolver_setting(): + settings = Settings() + with pytest.warns( + ScrapyDeprecationWarning, + match="The DNS_RESOLVER setting is deprecated", + ): + settings.get("DNS_RESOLVER") + + def test_deprecated_concurrent_requests_per_ip_setting(): settings = Settings({"CONCURRENT_REQUESTS_PER_IP": 1}) with pytest.warns( From dd4549e6f9a92f4844b552aad49ab4431f73514f Mon Sep 17 00:00:00 2001 From: Adrian Date: Wed, 24 Jun 2026 22:48:28 +0200 Subject: [PATCH 214/248] Improve test coverage for downloader middlewares (#7655) * Improve test coverage for downloader middlewares * Improve coverage further --- .../downloadermiddlewares/httpcompression.py | 90 ++++++++++--------- tests/test_downloadermiddleware_cookies.py | 14 +++ ...st_downloadermiddleware_downloadtimeout.py | 6 ++ tests/test_downloadermiddleware_httpcache.py | 1 + ...st_downloadermiddleware_httpcompression.py | 18 +++- tests/test_downloadermiddleware_httpproxy.py | 6 ++ tests/test_downloadermiddleware_offsite.py | 18 ++++ tests/test_downloadermiddleware_redirect.py | 17 ++++ ...wnloadermiddleware_redirect_metarefresh.py | 7 ++ tests/test_downloadermiddleware_stats.py | 14 ++- 10 files changed, 146 insertions(+), 45 deletions(-) diff --git a/scrapy/downloadermiddlewares/httpcompression.py b/scrapy/downloadermiddlewares/httpcompression.py index 414c3d8a3..2b1721ced 100644 --- a/scrapy/downloadermiddlewares/httpcompression.py +++ b/scrapy/downloadermiddlewares/httpcompression.py @@ -6,7 +6,7 @@ from logging import getLogger from typing import TYPE_CHECKING, Any from scrapy import Request, Spider, signals -from scrapy.exceptions import IgnoreRequest, NotConfigured +from scrapy.exceptions import IgnoreRequest, NotConfigured, ScrapyDeprecationWarning from scrapy.http import Response, TextResponse from scrapy.responsetypes import responsetypes from scrapy.utils._compression import ( @@ -70,6 +70,12 @@ class HttpCompressionMiddleware: crawler: Crawler | None = None, ): if not crawler: + warnings.warn( + "Instantiating HttpCompressionMiddleware without a 'crawler' " + "argument is deprecated.", + category=ScrapyDeprecationWarning, + stacklevel=2, + ) self.stats = stats self._max_size = 1073741824 self._warn_size = 33554432 @@ -108,49 +114,47 @@ class HttpCompressionMiddleware: ) -> Request | Response: if request.method == "HEAD": return response - if isinstance(response, Response): - content_encoding = response.headers.getlist("Content-Encoding") - if content_encoding: - max_size = request.meta.get("download_maxsize", self._max_size) - warn_size = request.meta.get("download_warnsize", self._warn_size) - try: - decoded_body, content_encoding = self._handle_encoding( - response.body, content_encoding, max_size - ) - except _DecompressionMaxSizeExceeded as e: - raise IgnoreRequest( - f"Ignored response {response} because its body " - f"({len(response.body)} B compressed, " - f"{e.decompressed_size} B decompressed so far) exceeded " - f"DOWNLOAD_MAXSIZE ({max_size} B) during decompression." - ) from e - if len(response.body) < warn_size <= len(decoded_body): - logger.warning( - f"{response} body size after decompression " - f"({len(decoded_body)} B) is larger than the " - f"download warning size ({warn_size} B)." - ) - if content_encoding: - self._warn_unknown_encoding(response, content_encoding) - response.headers["Content-Encoding"] = content_encoding - if self.stats: - self.stats.inc_value( - "httpcompression/response_bytes", - len(decoded_body), - ) - self.stats.inc_value("httpcompression/response_count") - respcls = responsetypes.from_args( - headers=response.headers, url=response.url, body=decoded_body + content_encoding = response.headers.getlist("Content-Encoding") + if content_encoding: + max_size = request.meta.get("download_maxsize", self._max_size) + warn_size = request.meta.get("download_warnsize", self._warn_size) + try: + decoded_body, content_encoding = self._handle_encoding( + response.body, content_encoding, max_size ) - kwargs: dict[str, Any] = {"body": decoded_body} - if issubclass(respcls, TextResponse): - # force recalculating the encoding until we make sure the - # responsetypes guessing is reliable - kwargs["encoding"] = None - response = response.replace(cls=respcls, **kwargs) - if not content_encoding: - del response.headers["Content-Encoding"] - + except _DecompressionMaxSizeExceeded as e: + raise IgnoreRequest( + f"Ignored response {response} because its body " + f"({len(response.body)} B compressed, " + f"{e.decompressed_size} B decompressed so far) exceeded " + f"DOWNLOAD_MAXSIZE ({max_size} B) during decompression." + ) from e + if len(response.body) < warn_size <= len(decoded_body): + logger.warning( + f"{response} body size after decompression " + f"({len(decoded_body)} B) is larger than the " + f"download warning size ({warn_size} B)." + ) + if content_encoding: + self._warn_unknown_encoding(response, content_encoding) + response.headers["Content-Encoding"] = content_encoding + if self.stats: + self.stats.inc_value( + "httpcompression/response_bytes", + len(decoded_body), + ) + self.stats.inc_value("httpcompression/response_count") + respcls = responsetypes.from_args( + headers=response.headers, url=response.url, body=decoded_body + ) + kwargs: dict[str, Any] = {"body": decoded_body} + if issubclass(respcls, TextResponse): + # force recalculating the encoding until we make sure the + # responsetypes guessing is reliable + kwargs["encoding"] = None + response = response.replace(cls=respcls, **kwargs) + if not content_encoding: + del response.headers["Content-Encoding"] return response def _handle_encoding( diff --git a/tests/test_downloadermiddleware_cookies.py b/tests/test_downloadermiddleware_cookies.py index 225562644..f79591020 100644 --- a/tests/test_downloadermiddleware_cookies.py +++ b/tests/test_downloadermiddleware_cookies.py @@ -131,6 +131,20 @@ class TestCookiesMiddleware: ), ) + def test_debug_no_cookies(self): + crawler = get_crawler(settings_dict={"COOKIES_DEBUG": True}) + mw = CookiesMiddleware.from_crawler(crawler) + with LogCapture( + "scrapy.downloadermiddlewares.cookies", + propagate=False, + level=logging.DEBUG, + ) as log: + req = Request("http://scrapytest.org/") + res = Response("http://scrapytest.org/") # no Set-Cookie header + mw.process_response(req, res) + mw.process_request(req) # no cookies to send either + log.check() # no log output since cl is empty in both cases + def test_setting_disabled_cookies_debug(self): crawler = get_crawler(settings_dict={"COOKIES_DEBUG": False}) mw = CookiesMiddleware.from_crawler(crawler) diff --git a/tests/test_downloadermiddleware_downloadtimeout.py b/tests/test_downloadermiddleware_downloadtimeout.py index c744d259c..e6b17960e 100644 --- a/tests/test_downloadermiddleware_downloadtimeout.py +++ b/tests/test_downloadermiddleware_downloadtimeout.py @@ -35,3 +35,9 @@ class TestDownloadTimeoutMiddleware: req.meta["download_timeout"] = 1 assert mw.process_request(req) is None assert req.meta.get("download_timeout") == 1 + + def test_zero_download_timeout(self): + req, spider, mw = self.get_request_spider_mw({"DOWNLOAD_TIMEOUT": 0}) + mw.spider_opened(spider) + assert mw.process_request(req) is None + assert req.meta.get("download_timeout") is None diff --git a/tests/test_downloadermiddleware_httpcache.py b/tests/test_downloadermiddleware_httpcache.py index e5d726764..6c86d7adf 100644 --- a/tests/test_downloadermiddleware_httpcache.py +++ b/tests/test_downloadermiddleware_httpcache.py @@ -135,6 +135,7 @@ class PolicyTestMixin: def test_dont_cache(self): with self._middleware() as mw: self.request.meta["dont_cache"] = True + assert mw.process_request(self.request) is None mw.process_response(self.request, self.response) assert mw.storage.retrieve_response(mw.crawler.spider, self.request) is None diff --git a/tests/test_downloadermiddleware_httpcompression.py b/tests/test_downloadermiddleware_httpcompression.py index 30caa094f..5c4085657 100644 --- a/tests/test_downloadermiddleware_httpcompression.py +++ b/tests/test_downloadermiddleware_httpcompression.py @@ -11,7 +11,7 @@ from scrapy.downloadermiddlewares.httpcompression import ( ACCEPTED_ENCODINGS, HttpCompressionMiddleware, ) -from scrapy.exceptions import IgnoreRequest, NotConfigured +from scrapy.exceptions import IgnoreRequest, NotConfigured, ScrapyDeprecationWarning from scrapy.http import HtmlResponse, Request, Response from scrapy.responsetypes import responsetypes from scrapy.spiders import Spider @@ -124,6 +124,22 @@ class TestHttpCompression: HttpCompressionMiddleware, ) + def test_no_crawler_constructor(self): + with pytest.warns(ScrapyDeprecationWarning, match="HttpCompressionMiddleware"): + mw = HttpCompressionMiddleware() + buf = BytesIO() + with GzipFile(fileobj=buf, mode="wb") as f: + f.write(b"hello") + body = buf.getvalue() + request = Request("http://scrapytest.org") + response = Response( + "http://scrapytest.org", + body=body, + headers={"Content-Encoding": "gzip"}, + ) + newresponse = mw.process_response(request, response) + assert newresponse.body == b"hello" + def test_process_request(self): request = Request("http://scrapytest.org") assert "Accept-Encoding" not in request.headers diff --git a/tests/test_downloadermiddleware_httpproxy.py b/tests/test_downloadermiddleware_httpproxy.py index a2d421e39..7ed848764 100644 --- a/tests/test_downloadermiddleware_httpproxy.py +++ b/tests/test_downloadermiddleware_httpproxy.py @@ -373,6 +373,12 @@ class TestHttpProxyMiddleware: assert "proxy" not in request.meta assert b"Proxy-Authorization" not in request.headers + def test_proxy_unparseable_url_clears_meta(self): + middleware = HttpProxyMiddleware() + request = Request("http://example.com", meta={"proxy": "//"}) + assert middleware.process_request(request) is None + assert request.meta["proxy"] is None + def test_proxy_authentication_header_disabled_proxy(self): middleware = HttpProxyMiddleware() request = Request( diff --git a/tests/test_downloadermiddleware_offsite.py b/tests/test_downloadermiddleware_offsite.py index edfb15d10..c0b8dc4dd 100644 --- a/tests/test_downloadermiddleware_offsite.py +++ b/tests/test_downloadermiddleware_offsite.py @@ -213,3 +213,21 @@ def test_request_scheduled_invalid_domains(): request = Request(f"https://{letter}.example") with pytest.raises(IgnoreRequest): mw.request_scheduled(request, crawler.spider) + + +def test_repeated_offsite_domain(): + crawler = get_crawler(Spider) + crawler.spider = crawler._create_spider(name="a", allowed_domains=["example.com"]) + mw = OffsiteMiddleware.from_crawler(crawler) + mw.spider_opened(crawler.spider) + req1 = Request("http://other.org/1") + req2 = Request("http://other.org/2") + with pytest.raises(IgnoreRequest): + mw.process_request(req1) + assert "other.org" in mw.domains_seen + assert crawler.stats.get_value("offsite/domains") == 1 + assert crawler.stats.get_value("offsite/filtered") == 1 + with pytest.raises(IgnoreRequest): + mw.process_request(req2) + assert crawler.stats.get_value("offsite/domains") == 1 # not incremented again + assert crawler.stats.get_value("offsite/filtered") == 2 diff --git a/tests/test_downloadermiddleware_redirect.py b/tests/test_downloadermiddleware_redirect.py index 42a25cd5b..1da7bbf3e 100644 --- a/tests/test_downloadermiddleware_redirect.py +++ b/tests/test_downloadermiddleware_redirect.py @@ -4,6 +4,7 @@ from unittest.mock import MagicMock import pytest from scrapy.downloadermiddlewares.redirect import RedirectMiddleware +from scrapy.exceptions import NotConfigured from scrapy.http import Request, Response from scrapy.spidermiddlewares.referer import ( POLICY_NO_REFERRER, @@ -265,6 +266,16 @@ class TestRedirectMiddleware(Base.Test): assert isinstance(req2, Request) assert req2.url == "http://www.example.com/redirected#frag" + def test_redirect_target_has_fragment(self): + url = "http://www.example.com/302#original" + url2 = "http://www.example.com/redirected#target" + req = Request(url) + rsp = Response(url, headers={"Location": url2}, status=302) + + req2 = self.mw.process_response(req, rsp) + assert isinstance(req2, Request) + assert req2.url == "http://www.example.com/redirected#target" + def test_redirect_302_head(self): url = "http://www.example.com/302" url2 = "http://www.example.com/redirected2" @@ -458,3 +469,9 @@ def test_warning_subclass(caplog): assert ( "(if defined in your code base) to override the handle_referer() method" ) in caplog.text + + +def test_not_configured(): + crawler = get_crawler(DefaultSpider, {"REDIRECT_ENABLED": False}) + with pytest.raises(NotConfigured): + RedirectMiddleware.from_crawler(crawler) diff --git a/tests/test_downloadermiddleware_redirect_metarefresh.py b/tests/test_downloadermiddleware_redirect_metarefresh.py index 416fbc2aa..d849cc8fb 100644 --- a/tests/test_downloadermiddleware_redirect_metarefresh.py +++ b/tests/test_downloadermiddleware_redirect_metarefresh.py @@ -7,6 +7,7 @@ from unittest.mock import MagicMock import pytest from scrapy.downloadermiddlewares.redirect import MetaRefreshMiddleware +from scrapy.exceptions import NotConfigured from scrapy.http import HtmlResponse, Request, Response from scrapy.spiders import Spider from scrapy.utils.misc import build_from_crawler @@ -157,3 +158,9 @@ def test_warning_meta_refresh_middleware(caplog): "replace scrapy.downloadermiddlewares.redirect.MetaRefreshMiddleware " "with a subclass that overrides the handle_referer() method" ) in caplog.text + + +def test_not_configured(): + crawler = get_crawler(Spider, {"METAREFRESH_ENABLED": False}) + with pytest.raises(NotConfigured): + MetaRefreshMiddleware.from_crawler(crawler) diff --git a/tests/test_downloadermiddleware_stats.py b/tests/test_downloadermiddleware_stats.py index cf7b614c4..5609360a7 100644 --- a/tests/test_downloadermiddleware_stats.py +++ b/tests/test_downloadermiddleware_stats.py @@ -1,4 +1,7 @@ -from scrapy.downloadermiddlewares.stats import DownloaderStats +import pytest + +from scrapy.downloadermiddlewares.stats import DownloaderStats, get_header_size +from scrapy.exceptions import NotConfigured from scrapy.http import Request, Response from scrapy.spiders import Spider from scrapy.utils.test import get_crawler @@ -39,5 +42,14 @@ class TestDownloaderStats: 1, ) + def test_from_crawler_not_configured(self): + crawler = get_crawler(Spider, {"DOWNLOADER_STATS": False}) + with pytest.raises(NotConfigured): + DownloaderStats.from_crawler(crawler) + def teardown_method(self): self.crawler.stats.close_spider() + + +def test_get_header_size_non_list_value(): + assert get_header_size({"Content-Type": "text/html"}) == 0 From 65e8954a0689b65ba8ae187a3045e8d4ed00b84f Mon Sep 17 00:00:00 2001 From: Adrian Date: Thu, 25 Jun 2026 12:35:25 +0200 Subject: [PATCH 215/248] Improve test coverage for spider middlewares (#7664) --- scrapy/spidermiddlewares/referer.py | 6 ++++ tests/test_spidermiddleware_base.py | 5 +++ tests/test_spidermiddleware_depth.py | 25 ++++++++++++++ tests/test_spidermiddleware_referer.py | 43 +++++++++++++++++++++++- tests/test_spidermiddleware_start.py | 12 ++++++- tests/test_spidermiddleware_urllength.py | 8 +++++ 6 files changed, 97 insertions(+), 2 deletions(-) diff --git a/scrapy/spidermiddlewares/referer.py b/scrapy/spidermiddlewares/referer.py index 2c9452174..264f685c1 100644 --- a/scrapy/spidermiddlewares/referer.py +++ b/scrapy/spidermiddlewares/referer.py @@ -308,6 +308,12 @@ class RefererMiddleware(BaseSpiderMiddleware): # Reference: https://www.w3.org/TR/referrer-policy/#referrer-policy-empty-string self.policies[""] = NoReferrerWhenDowngradePolicy if settings is None: + warn( + "Instantiating RefererMiddleware without a 'settings' argument is " + "deprecated.", + ScrapyDeprecationWarning, + stacklevel=2, + ) return setting_policies = settings.getdict("REFERRER_POLICIES") for policy_name, policy_class_import_path in setting_policies.items(): diff --git a/tests/test_spidermiddleware_base.py b/tests/test_spidermiddleware_base.py index 70326f3f1..4de57fda9 100644 --- a/tests/test_spidermiddleware_base.py +++ b/tests/test_spidermiddleware_base.py @@ -56,6 +56,11 @@ async def test_processed_request(crawler: Crawler) -> None: spider_output = [test_req1, {"foo": "bar"}, test_req2, test_req3] for processed in [ list(mw.process_spider_output(Response("data:,"), spider_output)), + await collect_asyncgen( + mw.process_spider_output_async( + Response("data:,"), as_async_generator(spider_output) + ) + ), await collect_asyncgen(mw.process_start(as_async_generator(spider_output))), ]: assert len(processed) == 3 diff --git a/tests/test_spidermiddleware_depth.py b/tests/test_spidermiddleware_depth.py index 00e547305..307ed2440 100644 --- a/tests/test_spidermiddleware_depth.py +++ b/tests/test_spidermiddleware_depth.py @@ -7,6 +7,7 @@ import pytest from scrapy.http import Request, Response from scrapy.spidermiddlewares.depth import DepthMiddleware from scrapy.spiders import Spider +from scrapy.utils.misc import build_from_crawler from scrapy.utils.test import get_crawler if TYPE_CHECKING: @@ -55,3 +56,27 @@ def test_process_spider_output(mw: DepthMiddleware, stats: StatsCollector) -> No rdm = stats.get_value("request_depth_max") assert rdm == 1 + + +def test_priority_and_non_verbose_stats() -> None: + crawler = get_crawler( + Spider, + {"DEPTH_LIMIT": 0, "DEPTH_STATS_VERBOSE": False, "DEPTH_PRIORITY": 10}, + ) + assert crawler.stats is not None + crawler.stats.open_spider() + try: + mw = build_from_crawler(DepthMiddleware, crawler) + resp = Response("http://toscrape.com") + resp.request = Request("http://toscrape.com") + resp.request.meta["depth"] = 2 + out = list(mw.process_spider_output(resp, [Request("http://toscrape.com")])) + assert len(out) == 1 + # priority is decremented by depth * DEPTH_PRIORITY + assert out[0].priority == -30 + assert out[0].meta["depth"] == 3 + # non-verbose stats don't track per-depth counts but still track the max + assert crawler.stats.get_value("request_depth_count/3") is None + assert crawler.stats.get_value("request_depth_max") == 3 + finally: + crawler.stats.close_spider() diff --git a/tests/test_spidermiddleware_referer.py b/tests/test_spidermiddleware_referer.py index b3a434c3f..32e83f990 100644 --- a/tests/test_spidermiddleware_referer.py +++ b/tests/test_spidermiddleware_referer.py @@ -6,7 +6,7 @@ from urllib.parse import urlparse import pytest -from scrapy.exceptions import ScrapyDeprecationWarning +from scrapy.exceptions import NotConfigured, ScrapyDeprecationWarning from scrapy.http import Request, Response from scrapy.settings import Settings from scrapy.spidermiddlewares.referer import ( @@ -1017,6 +1017,14 @@ class TestPolicyMethodResponseParamRename: response=self.response, resp_or_url=self.response, request=self.request ) + def test_missing_response(self): + with pytest.raises(TypeError, match="Missing required argument: 'response'"): + self.mw.policy(request=self.request) + + def test_missing_request(self): + with pytest.raises(TypeError, match="Missing required argument: 'request'"): + self.mw.policy(response=self.response) + @coroutine_test async def test_response_policy_only_supports_policy_names(): @@ -1115,3 +1123,36 @@ async def test_referer_policies_setting(): ] assert len(output) == 1 assert output[0].headers == {b"Referer": [b"https://python.org/"]} + + +class TestReferrerPolicyHelpers: + def test_origin_referrer_local_scheme(self): + # A local scheme yields no referrer. + assert UnsafeUrlPolicy().origin_referrer("data:,foo") is None + + def test_strip_url_empty(self): + assert UnsafeUrlPolicy().strip_url("") is None + + def test_potentially_trustworthy_data_scheme(self): + assert UnsafeUrlPolicy().potentially_trustworthy("data:,foo") is False + + +def test_default_policy(): + crawler = get_crawler() + mw = build_from_crawler(RefererMiddleware, crawler) + assert mw.default_policy is DefaultReferrerPolicy + + +def test_no_settings_constructor(): + with pytest.warns( + ScrapyDeprecationWarning, + match="Instantiating RefererMiddleware without a 'settings' argument", + ): + mw = RefererMiddleware() + assert mw.default_policy is DefaultReferrerPolicy + + +def test_not_configured_when_disabled(): + crawler = get_crawler(settings_dict={"REFERER_ENABLED": False}) + with pytest.raises(NotConfigured): + build_from_crawler(RefererMiddleware, crawler) diff --git a/tests/test_spidermiddleware_start.py b/tests/test_spidermiddleware_start.py index 26397d37c..def3a3df3 100644 --- a/tests/test_spidermiddleware_start.py +++ b/tests/test_spidermiddleware_start.py @@ -1,4 +1,4 @@ -from scrapy.http import Request +from scrapy.http import Request, Response from scrapy.spidermiddlewares.start import StartSpiderMiddleware from scrapy.spiders import Spider from scrapy.utils.misc import build_from_crawler @@ -23,3 +23,13 @@ class TestMiddleware: async for request in mw.process_start(start()) ] assert result == [True, True, False, "foo"] + + def test_spider_output_not_marked(self): + # Requests from a non-None response (spider output) are not flagged. + crawler = get_crawler(Spider) + mw = build_from_crawler(StartSpiderMiddleware, crawler) + response = Response("data:,") + request = Request("data:,1") + out = list(mw.process_spider_output(response, [request])) + assert out == [request] + assert "is_start_request" not in request.meta diff --git a/tests/test_spidermiddleware_urllength.py b/tests/test_spidermiddleware_urllength.py index 750ae3b07..1ed3a5637 100644 --- a/tests/test_spidermiddleware_urllength.py +++ b/tests/test_spidermiddleware_urllength.py @@ -5,9 +5,11 @@ from typing import TYPE_CHECKING import pytest +from scrapy.exceptions import NotConfigured from scrapy.http import Request, Response from scrapy.spidermiddlewares.urllength import UrlLengthMiddleware from scrapy.spiders import Spider +from scrapy.utils.misc import build_from_crawler from scrapy.utils.test import get_crawler if TYPE_CHECKING: @@ -46,6 +48,12 @@ def test_middleware_works(mw: UrlLengthMiddleware) -> None: assert process_spider_output(mw) == [short_url_req] +def test_not_configured_without_limit() -> None: + crawler = get_crawler(Spider, {"URLLENGTH_LIMIT": 0}) + with pytest.raises(NotConfigured): + build_from_crawler(UrlLengthMiddleware, crawler) + + def test_logging( stats: StatsCollector, mw: UrlLengthMiddleware, caplog: pytest.LogCaptureFixture ) -> None: From c690eac770a9fb803cdf0003ac3dc73e409146dc Mon Sep 17 00:00:00 2001 From: Adrian Date: Thu, 25 Jun 2026 13:34:37 +0200 Subject: [PATCH 216/248] =?UTF-8?q?Document=20=E2=80=9Clogging=20settings?= =?UTF-8?q?=E2=80=9D=20as=20special=20settings=20(#7668)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/topics/practices.rst | 8 ++--- docs/topics/settings.rst | 64 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 67 insertions(+), 5 deletions(-) diff --git a/docs/topics/practices.rst b/docs/topics/practices.rst index 8a04f7ada..e7faf48c4 100644 --- a/docs/topics/practices.rst +++ b/docs/topics/practices.rst @@ -347,10 +347,10 @@ finishes before starting the next one: install_reactor("twisted.internet.asyncioreactor.AsyncioSelectorReactor") react(deferred_f_from_coro_f(crawl)) -.. note:: When running multiple spiders in the same process, :ref:`reactor - settings ` should not have a different value per spider. - Also, :ref:`pre-crawler settings ` cannot be defined - per spider. +.. note:: When running multiple spiders in the same process, :ref:`logging + settings ` and :ref:`reactor settings ` + should not have a different value per spider, and :ref:`pre-crawler + settings ` cannot be defined per spider. .. seealso:: :ref:`run-from-script`. diff --git a/docs/topics/settings.rst b/docs/topics/settings.rst index 455985a56..c452f88d1 100644 --- a/docs/topics/settings.rst +++ b/docs/topics/settings.rst @@ -361,6 +361,31 @@ All of these settings, except for :setting:`ASYNCIO_EVENT_LOOP`, are only used when the Twisted reactor is used, i.e. when :setting:`TWISTED_REACTOR_ENABLED` is ``True``. +.. _logging-settings: + +Logging settings +---------------- + +**Logging settings** are settings that configure the global root logging +handler installed by :func:`~scrapy.utils.log.configure_logging`. + +These settings can be defined from a spider. However, because only 1 root +logging handler is active per process, these settings cannot use a different +value per spider when :ref:`running multiple spiders in the same process +`. + +These settings are: + +- :setting:`LOG_DATEFORMAT` +- :setting:`LOG_ENABLED` +- :setting:`LOG_ENCODING` +- :setting:`LOG_FILE` +- :setting:`LOG_FILE_APPEND` +- :setting:`LOG_FORMAT` +- :setting:`LOG_LEVEL` +- :setting:`LOG_SHORT_NAMES` +- :setting:`LOG_STDOUT` + .. _topics-settings-ref: Built-in settings reference @@ -481,6 +506,8 @@ Note that the event loop class must inherit from :class:`asyncio.AbstractEventLo :func:`asyncio.set_event_loop`, which will set the specified event loop as the current loop for the current OS thread. +.. note:: This is a :ref:`reactor setting `. + .. setting:: BOT_NAME BOT_NAME @@ -662,6 +689,8 @@ Whether to enable DNS in-memory cache. :setting:`TWISTED_REACTOR_ENABLED` is ``False``, and may have no effect either when :setting:`DNS_RESOLVER` is set to a different resolver. +.. note:: This is a :ref:`reactor setting `. + .. setting:: DNSCACHE_SIZE DNSCACHE_SIZE @@ -671,6 +700,8 @@ Default: ``10000`` DNS in-memory cache size, see :setting:`DNSCACHE_ENABLED`. +.. note:: This is a :ref:`reactor setting `. + .. setting:: TWISTED_DNS_RESOLVER TWISTED_DNS_RESOLVER @@ -688,6 +719,8 @@ take the :setting:`DNS_TIMEOUT` setting into account. .. note:: This setting has no effect when :setting:`TWISTED_REACTOR_ENABLED` is ``False``. +.. note:: This is a :ref:`reactor setting `. + .. setting:: DNS_TIMEOUT DNS_TIMEOUT @@ -703,6 +736,8 @@ Timeout for processing of DNS queries in seconds. Float is supported. :setting:`TWISTED_REACTOR_ENABLED` is ``False``, and may have no effect either when :setting:`DNS_RESOLVER` is set to a different resolver. +.. note:: This is a :ref:`reactor setting `. + .. setting:: DOWNLOADER DOWNLOADER @@ -1442,6 +1477,8 @@ Default: ``True`` Whether to enable logging. +.. note:: This is a :ref:`logging setting `. + .. setting:: LOG_ENCODING LOG_ENCODING @@ -1451,6 +1488,8 @@ Default: ``'utf-8'`` The encoding to use for logging. +.. note:: This is a :ref:`logging setting `. + .. setting:: LOG_FILE LOG_FILE @@ -1460,6 +1499,8 @@ Default: ``None`` File name to use for logging output. If ``None``, standard error will be used. +.. note:: This is a :ref:`logging setting `. + .. setting:: LOG_FILE_APPEND LOG_FILE_APPEND @@ -1470,6 +1511,8 @@ Default: ``True`` If ``False``, the log file specified with :setting:`LOG_FILE` will be overwritten (discarding the output from previous runs, if any). +.. note:: This is a :ref:`logging setting `. + .. setting:: LOG_FORMAT LOG_FORMAT @@ -1481,6 +1524,8 @@ String for formatting log messages. Refer to the :ref:`Python logging documentation ` for the whole list of available placeholders. +.. note:: This is a :ref:`logging setting `. + .. setting:: LOG_DATEFORMAT LOG_DATEFORMAT @@ -1493,6 +1538,8 @@ in :setting:`LOG_FORMAT`. Refer to the :ref:`Python datetime documentation ` for the whole list of available directives. +.. note:: This is a :ref:`logging setting `. + .. setting:: LOG_FORMATTER LOG_FORMATTER @@ -1512,6 +1559,8 @@ Default: ``'DEBUG'`` Minimum level to log. Available levels are: CRITICAL, ERROR, WARNING, INFO, DEBUG. For more info see :ref:`topics-logging`. +.. note:: This is a :ref:`logging setting `. + .. setting:: LOG_STDOUT LOG_STDOUT @@ -1523,6 +1572,8 @@ If ``True``, all standard output (and error) of your process will be redirected to the log. For example if you ``print('hello')`` it will appear in the Scrapy log. +.. note:: This is a :ref:`logging setting `. + .. setting:: LOG_SHORT_NAMES LOG_SHORT_NAMES @@ -1533,6 +1584,8 @@ Default: ``False`` If ``True``, the logs will just contain the root path. If it is set to ``False`` then it displays the component responsible for the log output +.. note:: This is a :ref:`logging setting `. + .. setting:: LOG_VERSIONS LOG_VERSIONS @@ -1695,6 +1748,8 @@ multi-purpose thread pool used by various Scrapy components. Threaded DNS Resolver, BlockingFeedStorage, S3FilesStore just to name a few. Increase this value if you're experiencing problems with insufficient blocking IO. +.. note:: This is a :ref:`reactor setting `. + .. setting:: REDIRECT_PRIORITY_ADJUST REDIRECT_PRIORITY_ADJUST @@ -1921,6 +1976,8 @@ Default: ``'scrapy.spiderloader.SpiderLoader'`` The class that will be used for loading spiders, which must implement the :ref:`topics-api-spiderloader`. +.. note:: This is a :ref:`pre-crawler setting `. + .. setting:: SPIDER_LOADER_WARN_ONLY SPIDER_LOADER_WARN_ONLY @@ -1933,6 +1990,8 @@ it will fail loudly if there is any ``ImportError`` or ``SyntaxError`` exception But you can choose to silence this exception and turn it into a simple warning by setting ``SPIDER_LOADER_WARN_ONLY = True``. +.. note:: This is a :ref:`pre-crawler setting `. + .. setting:: SPIDER_MIDDLEWARES SPIDER_MIDDLEWARES @@ -1978,6 +2037,8 @@ Example: SPIDER_MODULES = ["mybot.spiders_prod", "mybot.spiders_dev"] +.. note:: This is a :ref:`pre-crawler setting `. + .. setting:: STATS_CLASS STATS_CLASS @@ -2049,7 +2110,7 @@ stopped) will not apply. This mode is currently experimental and may not be suitable for production use. It may also not be supported by 3rd-party code. See :ref:`asyncio-without-reactor` for more information about this mode. -.. note:: This setting can't be set :ref:`per-spider `. +.. note:: This is a :ref:`pre-crawler setting `. .. versionadded:: 2.15.0 @@ -2156,6 +2217,7 @@ current platform. For additional information, see :doc:`core/howto/choosing-reactor`. +.. note:: This is a :ref:`reactor setting `. .. setting:: URLLENGTH_LIMIT From 74e6b61071c9fee372da4342604cd35510d03718 Mon Sep 17 00:00:00 2001 From: Adrian Date: Thu, 25 Jun 2026 13:44:51 +0200 Subject: [PATCH 217/248] Make DOWNLOADER_CLIENT_TLS_CIPHERS=None enable Twisted defaults (#7665) * Make DOWNLOADER_CLIENT_TLS_CIPHERS=None enable Twisted defaults * Remove pointless comment * Remove unnecessary mypy comments --- docs/topics/settings.rst | 3 ++ scrapy/core/downloader/contextfactory.py | 11 +++-- scrapy/core/downloader/tls.py | 12 +++--- tests/test_core_downloader.py | 52 +++++++++++++++++++++++- 4 files changed, 66 insertions(+), 12 deletions(-) diff --git a/docs/topics/settings.rst b/docs/topics/settings.rst index c452f88d1..b92733ede 100644 --- a/docs/topics/settings.rst +++ b/docs/topics/settings.rst @@ -763,6 +763,9 @@ necessary to access certain HTTPS websites: for example, you may need to use ``'DEFAULT:!DH'`` for a website with weak DH parameters or enable a specific cipher that is not included in ``DEFAULT`` if a website requires it. +Set this setting to ``None`` to use the default ciphers of the underlying TLS +implementation. + .. _OpenSSL cipher list format: https://docs.openssl.org/master/man1/openssl-ciphers/#cipher-list-format .. note:: diff --git a/scrapy/core/downloader/contextfactory.py b/scrapy/core/downloader/contextfactory.py index 40f09ffb2..c1796c723 100644 --- a/scrapy/core/downloader/contextfactory.py +++ b/scrapy/core/downloader/contextfactory.py @@ -17,7 +17,6 @@ from zope.interface.verify import verifyObject from scrapy.core.downloader.tls import ( _TWISTED_VERSION_MAP, - DEFAULT_CIPHERS, _openssl_methods, _ScrapyClientTLSOptions, _ScrapyClientTLSOptions26, @@ -68,11 +67,11 @@ class _ScrapyClientContextFactory(BrowserLikePolicyForHTTPS): self.tls_min_version: TLSVersion | None = tls_min_version self.tls_max_version: TLSVersion | None = tls_max_version self.tls_verbose_logging: bool = tls_verbose_logging # unused - self.tls_ciphers: AcceptableCiphers - if tls_ciphers: - self.tls_ciphers = AcceptableCiphers.fromOpenSSLCipherString(tls_ciphers) - else: - self.tls_ciphers = DEFAULT_CIPHERS + self.tls_ciphers: AcceptableCiphers | None = ( + AcceptableCiphers.fromOpenSSLCipherString(tls_ciphers) + if tls_ciphers + else None + ) self._verify_certificates = verify_certificates @classmethod diff --git a/scrapy/core/downloader/tls.py b/scrapy/core/downloader/tls.py index 3b903b39d..c5cf1156d 100644 --- a/scrapy/core/downloader/tls.py +++ b/scrapy/core/downloader/tls.py @@ -43,6 +43,13 @@ _openssl_methods: dict[str, int] = { def __getattr__(name: str) -> Any: + if name == "DEFAULT_CIPHERS": + warnings.warn( + "scrapy.core.downloader.tls.DEFAULT_CIPHERS is deprecated.", + ScrapyDeprecationWarning, + stacklevel=2, + ) + return AcceptableCiphers.fromOpenSSLCipherString("DEFAULT") deprecated = { "METHOD_TLS": "TLS", "METHOD_TLSv10": "TLSv1.0", @@ -177,8 +184,3 @@ class _ScrapyClientTLSOptions26(ClientTLSOptions): return True return verifyCallback - - -DEFAULT_CIPHERS: AcceptableCiphers = AcceptableCiphers.fromOpenSSLCipherString( - "DEFAULT" -) diff --git a/tests/test_core_downloader.py b/tests/test_core_downloader.py index e348bfb7f..fdd5edc27 100644 --- a/tests/test_core_downloader.py +++ b/tests/test_core_downloader.py @@ -8,7 +8,7 @@ import pytest from pytest_twisted import async_yield_fixture from twisted.internet.protocol import Factory from twisted.internet.protocol import Protocol as TxProtocol -from twisted.internet.ssl import optionsForClientTLS +from twisted.internet.ssl import AcceptableCiphers, optionsForClientTLS from twisted.protocols.tls import TLSMemoryBIOFactory, TLSMemoryBIOProtocol from twisted.web import server, static from twisted.web.client import Agent, BrowserLikePolicyForHTTPS, readBody @@ -180,6 +180,49 @@ class TestContextFactory(TestContextFactoryBase): assert options & 0x4 # OP_LEGACY_SERVER_CONNECT +class TestContextFactoryCiphers(TestContextFactoryBase): + async def _assert_factory_works( + self, server_url: str, client_context_factory: _ScrapyClientContextFactory + ) -> None: + s = "0123456789" * 10 + body = await self.get_page( + server_url + "payload", client_context_factory, body=s + ) + assert body == to_bytes(s) + + def test_default(self) -> None: + """The default 'DEFAULT' value is passed to Twisted as is.""" + crawler = get_crawler() + factory = build_from_crawler(_ScrapyClientContextFactory, crawler) + assert factory.tls_ciphers is not None + # OpenSSLAcceptableCiphers has no __eq__, so compare the parsed ciphers. + assert ( + factory.tls_ciphers._ciphers + == AcceptableCiphers.fromOpenSSLCipherString("DEFAULT")._ciphers + ) + assert factory._get_cert_options_kwargs()["acceptableCiphers"] is not None + + def test_custom(self) -> None: + crawler = get_crawler( + settings_dict={"DOWNLOADER_CLIENT_TLS_CIPHERS": "CAMELLIA256-SHA"} + ) + factory = build_from_crawler(_ScrapyClientContextFactory, crawler) + assert factory.tls_ciphers is not None + assert ( + factory.tls_ciphers._ciphers + == AcceptableCiphers.fromOpenSSLCipherString("CAMELLIA256-SHA")._ciphers + ) + + @coroutine_test + async def test_none(self, server_url: str) -> None: + """A None value enables the Twisted default ciphers.""" + crawler = get_crawler(settings_dict={"DOWNLOADER_CLIENT_TLS_CIPHERS": None}) + factory = build_from_crawler(_ScrapyClientContextFactory, crawler) + assert factory.tls_ciphers is None + assert factory._get_cert_options_kwargs()["acceptableCiphers"] is None + await self._assert_factory_works(server_url, factory) + + class TestContextFactoryTLSMethod(TestContextFactoryBase): async def _assert_factory_works( self, server_url: str, client_context_factory: _ScrapyClientContextFactory @@ -278,3 +321,10 @@ def test_deprecated_tls_module_names() -> None: match="scrapy.core.downloader.tls.openssl_methods is deprecated", ): assert isinstance(tls.openssl_methods, dict) + with pytest.warns( + ScrapyDeprecationWarning, + match="scrapy.core.downloader.tls.DEFAULT_CIPHERS is deprecated", + ): + assert tls.DEFAULT_CIPHERS._ciphers == ( + AcceptableCiphers.fromOpenSSLCipherString("DEFAULT")._ciphers + ) From d8d7de2339b3aeabdc144fa7d0d11cae3ecfd1bb Mon Sep 17 00:00:00 2001 From: Sriniketh24 Date: Fri, 26 Jun 2026 07:56:59 +0530 Subject: [PATCH 218/248] Fix FTPDownloadHandler not closing FTP connection after download (#7667) --- scrapy/core/downloader/handlers/ftp.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/scrapy/core/downloader/handlers/ftp.py b/scrapy/core/downloader/handlers/ftp.py index 6258067c1..9064aa53a 100644 --- a/scrapy/core/downloader/handlers/ftp.py +++ b/scrapy/core/downloader/handlers/ftp.py @@ -119,7 +119,10 @@ class FTPDownloadHandler(BaseDownloadHandler): httpcode = self.CODE_MAPPING.get(ftpcode, self.CODE_MAPPING["default"]) return Response(url=request.url, status=httpcode, body=message.encode()) raise - protocol.close() + finally: + protocol.close() + assert client.transport + client.transport.loseConnection() headers = {"local filename": protocol.filename or b"", "size": protocol.size} body = protocol.filename or protocol.body.read() respcls = responsetypes.from_args(url=request.url, body=body) From 0007676e8d5210f8d0d05ffb12610ca780a3c5ea Mon Sep 17 00:00:00 2001 From: Adrian Date: Fri, 26 Jun 2026 09:21:12 +0200 Subject: [PATCH 219/248] Improve test coverage for http/ (#7672) * Improve test coverage for http/ * Use isinstance() instead of type() --- tests/test_http_cookies.py | 53 +++++++++++++++++++++++++++++++- tests/test_http_headers.py | 1 + tests/test_http_request.py | 10 ++++++ tests/test_http_request_form.py | 3 ++ tests/test_http_response.py | 5 +++ tests/test_http_response_text.py | 7 +++++ 6 files changed, 78 insertions(+), 1 deletion(-) diff --git a/tests/test_http_cookies.py b/tests/test_http_cookies.py index 660b76d08..ce9296764 100644 --- a/tests/test_http_cookies.py +++ b/tests/test_http_cookies.py @@ -1,8 +1,59 @@ +from http.cookiejar import DefaultCookiePolicy + from scrapy.http import Request, Response -from scrapy.http.cookies import WrappedRequest, WrappedResponse +from scrapy.http.cookies import CookieJar, WrappedRequest, WrappedResponse from scrapy.utils.httpobj import urlparse_cached +class TestCookieJar: + def setup_method(self): + self.jar = CookieJar() + self.request = Request("http://example.com/") + self.response = Response( + "http://example.com/", + headers={"Set-Cookie": "name=value; Domain=example.com; Path=/"}, + ) + + def test_extract_cookies(self): + assert len(self.jar) == 0 + self.jar.extract_cookies(self.response, self.request) + assert len(self.jar) == 1 + cookie = next(iter(self.jar)) + assert cookie.name == "name" + assert cookie.value == "value" + assert ".example.com" in self.jar._cookies + + def test_make_cookies_and_set_cookie(self): + cookies = self.jar.make_cookies(self.response, self.request) + assert len(cookies) == 1 + jar = CookieJar() + for cookie in cookies: + jar.set_cookie(cookie) + assert len(jar) == 1 + + def test_clear(self): + self.jar.extract_cookies(self.response, self.request) + assert len(self.jar) == 1 + self.jar.clear() + assert len(self.jar) == 0 + + def test_clear_session_cookies(self): + self.jar.extract_cookies(self.response, self.request) + assert len(self.jar) == 1 + self.jar.clear_session_cookies() + assert len(self.jar) == 0 + + def test_set_policy(self): + policy = DefaultCookiePolicy() + self.jar.set_policy(policy) + assert self.jar.jar._policy is policy + + def test_check_expired_frequency(self): + jar = CookieJar(check_expired_frequency=1) + jar.add_cookie_header(self.request) + assert jar.processed == 1 + + class TestWrappedRequest: def setup_method(self): self.request = Request( diff --git a/tests/test_http_headers.py b/tests/test_http_headers.py index 243aa6afe..aff3562e3 100644 --- a/tests/test_http_headers.py +++ b/tests/test_http_headers.py @@ -140,6 +140,7 @@ class TestHeaders: h1["foo"] = "bar" h1["foo"] = None h1.setdefault("foo", "bar") + assert h1["foo"] is None assert h1.get("foo") is None assert h1.getlist("foo") == [] diff --git a/tests/test_http_request.py b/tests/test_http_request.py index e22689d49..1941b826f 100644 --- a/tests/test_http_request.py +++ b/tests/test_http_request.py @@ -24,6 +24,10 @@ class TestRequest: with pytest.raises(TypeError): self.request_class(123) + # priority argument must be an integer + with pytest.raises(TypeError, match="Request priority not an integer"): + self.request_class("http://www.example.com", priority="1") + r = self.request_class("http://www.example.com") assert isinstance(r.url, str) assert r.url == "http://www.example.com" @@ -274,6 +278,12 @@ class TestRequest: assert r4.meta == {} assert r4.dont_filter is False + # the cls argument allows changing the resulting class + custom_request_cls = type("CustomRequest", (self.request_class,), {}) + r5 = r1.replace(cls=custom_request_cls) + assert isinstance(r5, custom_request_cls) + assert r5.url == r1.url + def test_method_always_str(self): r = self.request_class("http://www.example.com", method="POST") assert isinstance(r.method, str) diff --git a/tests/test_http_request_form.py b/tests/test_http_request_form.py index a4f87d50d..c667e48a1 100644 --- a/tests/test_http_request_form.py +++ b/tests/test_http_request_form.py @@ -294,6 +294,9 @@ class TestFormRequest(TestRequest): assert request.method == "GET" request = FormRequest.from_response(response, method="POST") assert request.method == "POST" + # an explicit method=None skips form-method normalization + request = FormRequest.from_response(response, method=None) + assert request.method == "NONE" def test_from_response_override_url(self): response = _buildresponse( diff --git a/tests/test_http_response.py b/tests/test_http_response.py index 079c547c7..a8ea4920e 100644 --- a/tests/test_http_response.py +++ b/tests/test_http_response.py @@ -254,6 +254,11 @@ class TestResponse: with pytest.raises(ValueError, match="url can't be None"): r.follow(None) + def test_follow_None_encoding(self): + r = self.response_class("http://example.com") + with pytest.raises(ValueError, match="encoding can't be None"): + r.follow("foo", encoding=None) + @pytest.mark.xfail( not W3LIB_STRIPS_URLS, reason="https://github.com/scrapy/w3lib/pull/207", diff --git a/tests/test_http_response_text.py b/tests/test_http_response_text.py index 4b3fa2302..507fd1864 100644 --- a/tests/test_http_response_text.py +++ b/tests/test_http_response_text.py @@ -14,6 +14,13 @@ from tests.test_http_response import TestResponse class TestTextResponse(TestResponse): response_class = TextResponse + def test_follow_None_encoding(self): + # unlike the base Response, TextResponse.follow() falls back to the + # response encoding when encoding is None instead of raising + r = self.response_class("http://example.com", body=b"hello", encoding="cp1252") + req = r.follow("foo", encoding=None) + assert req.encoding == "cp1252" + def test_replace(self): super().test_replace() r1 = self.response_class( From 0ccddb4f61b27dd493fd65329d039b88a3333e7b Mon Sep 17 00:00:00 2001 From: Adrian Date: Fri, 26 Jun 2026 11:47:30 +0200 Subject: [PATCH 220/248] Improve test coverage for contracts (#7677) --- tests/test_contracts.py | 180 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 180 insertions(+) diff --git a/tests/test_contracts.py b/tests/test_contracts.py index e80945b93..698044de7 100644 --- a/tests/test_contracts.py +++ b/tests/test_contracts.py @@ -133,6 +133,29 @@ class DemoSpider(Spider): """ return DemoItem(url=response.url) + def returns_request_range_fail(self, response): + """method which returns fewer requests than the expected range + @url http://scrapy.org + @returns requests 2 3 + """ + return Request("http://scrapy.org", callback=self.returns_item) + + def yields_item_and_request(self, response): + """yields one item and one request + @url http://scrapy.org + @returns items 1 1 + @scrapes name url + """ + yield DemoItem(name="test", url=response.url) + yield Request("http://scrapy.org", callback=self.returns_item) + + async def returns_async_gen(self, response): + """async generator callback + @url http://scrapy.org + @returns items 1 1 + """ + yield DemoItem(url=response.url) + def returns_dict_fail(self, response): """method which returns item @url http://scrapy.org @@ -437,6 +460,48 @@ class TestContractsManager: request.callback(response) self.should_error() + def test_returns_invalid_argument_count(self): + spider = DemoSpider() + with pytest.raises(ValueError, match="expected 1, 2 or 3, got 0"): + ReturnsContract(spider.returns_item) + with pytest.raises(ValueError, match="expected 1, 2 or 3, got 4"): + ReturnsContract(spider.returns_item, "items", "1", "2", "3") + + def test_returns_default_bounds(self): + spider = DemoSpider() + contract = ReturnsContract(spider.returns_item, "items") + assert contract.min_bound == 1 + assert contract.max_bound == float("inf") + + def test_returns_range_fail(self): + spider = DemoSpider() + response = ResponseMock() + + request = self.conman.from_method( + spider.returns_request_range_fail, self.results + ) + request.callback(response) + self.should_fail() + assert "expected 2..3" in self.results.failures[-1][-1] + + def test_returns_and_scrapes_ignore_other_types(self): + spider = DemoSpider() + response = ResponseMock() + + # @returns and @scrapes only count matching output objects and skip + # the request that is also yielded. + request = self.conman.from_method(spider.yields_item_and_request, self.results) + request.callback(response) + self.should_succeed() + + def test_testcase_str(self): + spider = DemoSpider() + contract = UrlContract(spider.returns_request, "http://scrapy.org") + assert ( + str(contract.testcase_pre) + == "[demo_spider] returns_request (@url pre-hook)" + ) + def test_scrapes(self): spider = DemoSpider() response = ResponseMock() @@ -570,6 +635,41 @@ class CustomFailContractPostProcess(Contract): raise KeyboardInterrupt("Post-process exception") +class PreProcessSuccessContract(Contract): + name = "pre_success" + + def pre_process(self, response): + return + + +class PreProcessAssertionFailContract(Contract): + name = "pre_assertion_fail" + + def pre_process(self, response): + raise AssertionError("pre-process assertion") + + +class PreProcessErrorContract(Contract): + name = "pre_error" + + def pre_process(self, response): + raise ValueError("pre-process error") + + +class PostProcessSuccessContract(Contract): + name = "post_success" + + def post_process(self, output): + return + + +class PostProcessErrorContract(Contract): + name = "post_error" + + def post_process(self, output): + raise ValueError("post-process error") + + class TestCustomContractPrePostProcess: def setup_method(self): self.results = TextTestResult(stream=None, descriptions=False, verbosity=0) @@ -601,3 +701,83 @@ class TestCustomContractPrePostProcess: assert not self.results.failures assert not self.results.errors + + def test_pre_hook_success(self): + spider = DemoSpider() + response = ResponseMock() + contract = PreProcessSuccessContract(spider.returns_request) + conman = ContractsManager([UrlContract, ReturnsContract, contract]) + + request = conman.from_method(spider.returns_request, self.results) + contract.add_pre_hook(request, self.results) + request.callback(response, **request.cb_kwargs) + + assert not self.results.failures + assert not self.results.errors + + def test_pre_hook_assertion_failure(self): + spider = DemoSpider() + response = ResponseMock() + contract = PreProcessAssertionFailContract(spider.returns_request) + conman = ContractsManager([UrlContract, ReturnsContract, contract]) + + request = conman.from_method(spider.returns_request, self.results) + contract.add_pre_hook(request, self.results) + request.callback(response, **request.cb_kwargs) + + assert self.results.failures + assert not self.results.errors + + def test_pre_hook_error(self): + spider = DemoSpider() + response = ResponseMock() + contract = PreProcessErrorContract(spider.returns_request) + conman = ContractsManager([UrlContract, ReturnsContract, contract]) + + request = conman.from_method(spider.returns_request, self.results) + contract.add_pre_hook(request, self.results) + request.callback(response, **request.cb_kwargs) + + assert self.results.errors + + def test_pre_hook_async_callback(self): + spider = DemoSpider() + response = ResponseMock() + contract = PreProcessSuccessContract(spider.returns_request_async) + request = Request("http://scrapy.org", callback=spider.returns_request_async) + contract.add_pre_hook(request, self.results) + + with pytest.raises(TypeError, match="async callbacks"): + request.callback(response) + + def test_pre_hook_async_generator(self): + spider = DemoSpider() + response = ResponseMock() + contract = PreProcessSuccessContract(spider.returns_async_gen) + request = Request("http://scrapy.org", callback=spider.returns_async_gen) + contract.add_pre_hook(request, self.results) + + with pytest.raises(TypeError, match="async callbacks"): + request.callback(response) + + def test_post_hook_async_generator(self): + spider = DemoSpider() + response = ResponseMock() + contract = PostProcessSuccessContract(spider.returns_async_gen) + request = Request("http://scrapy.org", callback=spider.returns_async_gen) + contract.add_post_hook(request, self.results) + + with pytest.raises(TypeError, match="async callbacks"): + request.callback(response) + + def test_post_hook_error(self): + spider = DemoSpider() + response = ResponseMock() + contract = PostProcessErrorContract(spider.returns_request) + conman = ContractsManager([UrlContract, ReturnsContract, contract]) + + request = conman.from_method(spider.returns_request, self.results) + contract.add_post_hook(request, self.results) + request.callback(response, **request.cb_kwargs) + + assert self.results.errors From 7ab404c72546a9f0ef5bf4a6ee54512d3c1d6851 Mon Sep 17 00:00:00 2001 From: Adrian Date: Fri, 26 Jun 2026 11:58:37 +0200 Subject: [PATCH 221/248] Add a documentation page about security (#7678) --- docs/index.rst | 5 + docs/topics/download-handlers.rst | 4 + docs/topics/downloader-middleware.rst | 2 + docs/topics/security.rst | 207 ++++++++++++++++++++++++++ docs/topics/settings.rst | 16 ++ docs/topics/spider-middleware.rst | 2 + docs/topics/telnetconsole.rst | 4 + 7 files changed, 240 insertions(+) create mode 100644 docs/topics/security.rst diff --git a/docs/index.rst b/docs/index.rst index a46a2ad9f..8e8624a22 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -151,6 +151,7 @@ Solving specific problems topics/debug topics/contracts topics/practices + topics/security topics/broad-crawls topics/developer-tools topics/dynamic-content @@ -175,6 +176,10 @@ Solving specific problems :doc:`topics/practices` Get familiar with some Scrapy common practices. +:doc:`topics/security` + Understand the security implications of Scrapy defaults and how to harden + them. + :doc:`topics/broad-crawls` Tune Scrapy for crawling a lot domains in parallel. diff --git a/docs/topics/download-handlers.rst b/docs/topics/download-handlers.rst index 888bfaf08..c901e01e4 100644 --- a/docs/topics/download-handlers.rst +++ b/docs/topics/download-handlers.rst @@ -39,6 +39,10 @@ for additional schemes and to replace or disable default ones: "sftp": "my.download_handlers.SftpHandler", } +.. seealso:: :ref:`security-unencrypted-protocols` and + :ref:`security-local-resources`, for the security implications of the + default ``http``, ``ftp``, ``file`` and ``data`` handlers. + Replacing HTTP(S) download handlers ----------------------------------- diff --git a/docs/topics/downloader-middleware.rst b/docs/topics/downloader-middleware.rst index 0c1af5276..5649453b1 100644 --- a/docs/topics/downloader-middleware.rst +++ b/docs/topics/downloader-middleware.rst @@ -379,6 +379,8 @@ that this risks leaking credentials to unrelated domains. This setting must be explicitly configured whenever :setting:`HTTPAUTH_USER` or :setting:`HTTPAUTH_PASS` is set. +.. seealso:: :ref:`security-credential-leakage` + .. _Basic access authentication: https://en.wikipedia.org/wiki/Basic_access_authentication diff --git a/docs/topics/security.rst b/docs/topics/security.rst new file mode 100644 index 000000000..2ca270045 --- /dev/null +++ b/docs/topics/security.rst @@ -0,0 +1,207 @@ +.. _security: + +======== +Security +======== + +Scrapy defaults are optimized for web scraping, not for the security posture +that you might expect from software that handles untrusted input or runs in a +shared or exposed environment. Some common security practices are unnecessary +for many scraping use cases, and a few can even prevent valid ones (for +example, sites that you must scrape may use misconfigured TLS certificates or +serve content over unencrypted protocols). + +This page highlights the Scrapy defaults that have security implications, so +that you can make an informed decision about whether to keep them, and explains +how to harden them along with the trade-offs involved. + +.. note:: + + None of the options below are silver bullets. Which of them make sense + depends on your threat model: whether the URLs you crawl come from trusted + sources, whether the machine running Scrapy is exposed to a network you do + not control, whether the data you handle is sensitive, and so on. + +.. _security-untrusted-responses: + +Treat responses as untrusted input +================================== + +Regardless of any setting, remember that response data comes from servers you +do not control, even when you trust the site you are crawling, as responses may +be tampered with in transit or the server itself may be compromised. + +Never pass response data to functions that can execute code or otherwise act on +their input in an unsafe way, such as :func:`eval`, :func:`exec`, or +:func:`pickle.loads`, and be careful when writing response data to paths +derived from the response itself. + +TLS connections +=============== + +.. _security-certificate-verification: + +Certificate verification +------------------------ + +By default Scrapy does **not** verify the TLS certificate of HTTPS servers, as +controlled by the :setting:`DOWNLOAD_VERIFY_CERTIFICATES` setting (default: +``False``). + +This default favors reach over security: many sites that are otherwise fine to +scrape have expired, self-signed, or otherwise invalid certificates, and +verifying certificates would make requests to them fail. + +If the integrity of the connection matters to you (for example, to detect +man-in-the-middle attacks), set: + +.. code-block:: python + + DOWNLOAD_VERIFY_CERTIFICATES = True + +* **Pro:** requests to servers with invalid or untrusted certificates fail + instead of silently succeeding, protecting you from some man-in-the-middle + attacks. + +* **Con:** you can no longer scrape sites with misconfigured certificates + without re-disabling verification for them. + +.. _security-tls-protocols-ciphers: + +Protocol versions and ciphers +----------------------------- + +You can restrict the TLS protocol versions that Scrapy accepts through the +:setting:`DOWNLOAD_TLS_MIN_VERSION` and :setting:`DOWNLOAD_TLS_MAX_VERSION` +settings, e.g. to reject obsolete protocol versions. + +By default Scrapy uses the OpenSSL ``DEFAULT`` cipher list +(:setting:`DOWNLOADER_CLIENT_TLS_CIPHERS`), which favors compatibility and still +allows some older, weaker ciphers. Set it to ``None`` to instead use the curated +cipher list of the underlying TLS implementation (Twisted), which excludes weak +ciphers: + +.. code-block:: python + + DOWNLOADER_CLIENT_TLS_CIPHERS = None + +* **Pro:** connections that would negotiate a weak cipher fail instead of + succeeding. + +* **Con:** you can no longer connect to servers that only support the excluded + ciphers. + +.. _security-unencrypted-protocols: + +Unencrypted protocols +===================== + +By default Scrapy enables download handlers for unencrypted protocols, namely +``http://`` and ``ftp://`` (see :setting:`DOWNLOAD_HANDLERS_BASE`). Data sent +and received over these protocols, including any credentials, travels in plain +text and can be read or modified by anyone on the network path. + +If you only crawl over encrypted protocols, you can disable the unencrypted +ones so that no request can accidentally be sent unencrypted: + +.. code-block:: python + + DOWNLOAD_HANDLERS = { + "http": None, + "ftp": None, + } + +* **Pro:** a misconfigured or maliciously-redirected request cannot leak data + over an unencrypted connection, as such requests fail instead. + +* **Con:** you can no longer crawl resources that are only available over those + protocols. + +Note that disabling the ``http`` handler also prevents plain-HTTP requests that +result from following an ``http://`` redirect or link, which is often the point +of disabling it. + +.. _security-local-resources: + +Local and non-network resources +=============================== + +By default Scrapy enables download handlers for the ``file://`` and ``data:`` +schemes (see :setting:`DOWNLOAD_HANDLERS_BASE`). The ``file://`` handler reads +arbitrary files from the local filesystem, limited only by the permissions of +the process running Scrapy. + +This is convenient (for example, to parse a local HTML file), but it is a risk +if any of the URLs you schedule come from an untrusted source: a crafted +``file:///etc/passwd`` URL could read local files. + +If you do not need them, disable these handlers: + +.. code-block:: python + + DOWNLOAD_HANDLERS = { + "file": None, + "data": None, + } + +* **Pro:** crawled URLs cannot be used to read local files or inline data. + +* **Con:** you can no longer fetch ``file://`` or ``data:`` URLs. + +More generally, if you crawl URLs from untrusted sources, consider validating +their schemes (and, where applicable, their hosts) before scheduling requests, +to avoid server-side request forgery (SSRF) and similar issues. + +.. _security-telnet: + +Telnet console +============== + +Scrapy enables the :ref:`telnet console ` by default +(:setting:`TELNETCONSOLE_ENABLED`). The telnet console is a Python shell +running inside the Scrapy process, so anyone who can connect to it can run +arbitrary code in that process. + +By default the console binds to ``127.0.0.1`` (:setting:`TELNETCONSOLE_HOST`) +and is protected by a username (:setting:`TELNETCONSOLE_USERNAME`, default +``scrapy``) and an automatically generated password +(:setting:`TELNETCONSOLE_PASSWORD`), so it is only reachable from the local +machine. + +.. warning:: + + Telnet does not provide any transport-layer security, so the + username/password authentication does not protect the credentials or the + session from anyone able to observe the traffic. Never expose the telnet + console over an untrusted network by changing :setting:`TELNETCONSOLE_HOST` + to a non-local address. + +If you do not use the telnet console, disable it entirely: + +.. code-block:: python + + TELNETCONSOLE_ENABLED = False + +* **Pro:** removes a local code-execution surface and one less listening port. + +* **Con:** you can no longer :ref:`inspect and control a running crawler + ` through it. + +.. _security-credential-leakage: + +Credential leakage across domains +================================= + +Some Scrapy features attach credentials or other sensitive headers to requests, +and a crawl that spans multiple domains can leak them to unintended hosts: + +* HTTP authentication credentials set through + :class:`~scrapy.downloadermiddlewares.httpauth.HttpAuthMiddleware` are only + sent to the domain set in :setting:`HTTPAUTH_DOMAIN`. Leave this set to the + intended domain rather than ``None`` so that credentials are not sent to + every domain you crawl. + +* The ``Referer`` header may disclose the URLs you crawl to other sites. The + default :setting:`REFERRER_POLICY` already avoids sending the referrer from + HTTPS to HTTP, but you can tighten it further (for example, to + ``same-origin`` or ``no-referrer``) if needed. diff --git a/docs/topics/settings.rst b/docs/topics/settings.rst index b92733ede..11251a92c 100644 --- a/docs/topics/settings.rst +++ b/docs/topics/settings.rst @@ -774,6 +774,8 @@ implementation. handler `, so it's not guaranteed to be supported by all 3rd-party handlers. +.. seealso:: :ref:`security-tls-protocols-ciphers` + .. setting:: DOWNLOAD_TLS_MAX_VERSION DOWNLOAD_TLS_MAX_VERSION @@ -807,6 +809,8 @@ modern environments. by all 3rd-party handlers. Additionally, the set of supported TLS versions depends on the TLS implementation being used by the handler. +.. seealso:: :ref:`security-tls-protocols-ciphers` + .. setting:: DOWNLOAD_TLS_MIN_VERSION DOWNLOAD_TLS_MIN_VERSION @@ -819,6 +823,8 @@ be used by Scrapy. See :setting:`DOWNLOAD_TLS_MAX_VERSION` for the details and limitations. +.. seealso:: :ref:`security-tls-protocols-ciphers` + .. setting:: DOWNLOADER_CLIENT_TLS_VERBOSE_LOGGING DOWNLOADER_CLIENT_TLS_VERBOSE_LOGGING @@ -979,6 +985,9 @@ enabled in your project. See :setting:`DOWNLOAD_HANDLERS_BASE` for example format. +.. seealso:: :ref:`security-unencrypted-protocols` and + :ref:`security-local-resources` + .. setting:: DOWNLOAD_HANDLERS_BASE DOWNLOAD_HANDLERS_BASE @@ -1026,6 +1035,9 @@ handler (without replacement), place this in your ``settings.py``: "ftp": None, } +.. seealso:: :ref:`security-unencrypted-protocols` and + :ref:`security-local-resources` + .. setting:: DOWNLOAD_SLOTS @@ -1184,6 +1196,8 @@ when making a request and abort the request if the verification fails. certificate problems are logged when this setting is set to ``False``) depends on its implementation. +.. seealso:: :ref:`security-certificate-verification` + .. setting:: DUPEFILTER_CLASS DUPEFILTER_CLASS @@ -2074,6 +2088,8 @@ Default: ``True`` (``False`` when :setting:`TWISTED_REACTOR_ENABLED` is ``False` A boolean which specifies if the :ref:`telnet console ` will be enabled (provided its extension is also enabled). +.. seealso:: :ref:`security-telnet` + .. setting:: TEMPLATES_DIR TEMPLATES_DIR diff --git a/docs/topics/spider-middleware.rst b/docs/topics/spider-middleware.rst index 99bbdf292..e2cd0f986 100644 --- a/docs/topics/spider-middleware.rst +++ b/docs/topics/spider-middleware.rst @@ -355,6 +355,8 @@ Default: ``'scrapy.spidermiddlewares.referer.DefaultReferrerPolicy'`` using the special ``"referrer_policy"`` :ref:`Request.meta ` key, with the same acceptable values as for the ``REFERRER_POLICY`` setting. +.. seealso:: :ref:`security-credential-leakage` + Acceptable values for REFERRER_POLICY ************************************* diff --git a/docs/topics/telnetconsole.rst b/docs/topics/telnetconsole.rst index e274edc9b..a30258112 100644 --- a/docs/topics/telnetconsole.rst +++ b/docs/topics/telnetconsole.rst @@ -29,6 +29,8 @@ disable it if you want. For more information about the extension itself see .. note:: This feature is not supported when :setting:`TWISTED_REACTOR_ENABLED` is ``False``. +.. seealso:: :ref:`security-telnet` + .. highlight:: none How to access the telnet console @@ -190,6 +192,8 @@ Default: ``'127.0.0.1'`` The interface the telnet console should listen on +.. seealso:: :ref:`security-telnet` + .. setting:: TELNETCONSOLE_USERNAME From 1b940a75ac20401b1e944a65c8901569c06e091b Mon Sep 17 00:00:00 2001 From: Adrian Date: Fri, 26 Jun 2026 13:18:22 +0200 Subject: [PATCH 222/248] Improve the item pipeline docs (#7676) --- docs/topics/item-pipeline.rst | 111 ++++++++++++++++++ .../project/module/pipelines.py.tmpl | 2 +- 2 files changed, 112 insertions(+), 1 deletion(-) diff --git a/docs/topics/item-pipeline.rst b/docs/topics/item-pipeline.rst index 9a53f88fb..f1fa463d8 100644 --- a/docs/topics/item-pipeline.rst +++ b/docs/topics/item-pipeline.rst @@ -57,6 +57,8 @@ Any of these methods may be defined as a coroutine function (``async def``). Item pipeline example ===================== +.. _price-pipeline-example: + Price validation and dropping items with no prices -------------------------------------------------- @@ -246,6 +248,8 @@ returns multiples items with the same id: return item +.. _activating-item-pipeline: + Activating an Item Pipeline component ===================================== @@ -262,3 +266,110 @@ To activate an Item Pipeline component you must add its class to the The integer values you assign to classes in this setting determine the order in which they run: items go through from lower valued to higher valued classes. It's customary to define these numbers in the 0-1000 range. + +A complete example +================== + +The examples above show item pipeline components on their own. In a project, a +pipeline is one of four pieces that work together: the :ref:`item +` your spider produces, the :ref:`spider ` that +yields it, the pipeline that processes it, and the :setting:`ITEM_PIPELINES` +setting that enables the pipeline. + +The following example wires those pieces together to validate the price of +books scraped from `books.toscrape.com`_, reusing the ``PricePipeline`` from +:ref:`price-pipeline-example` above. + +Define the item in ``myproject/items.py``: + +.. code-block:: python + + from dataclasses import dataclass + + + @dataclass + class BookItem: + title: str + price: float + +Yield instances of that item from your spider, e.g. in +``myproject/spiders/books.py``: + +.. skip: next +.. code-block:: python + + import scrapy + + from myproject.items import BookItem + + + class BooksSpider(scrapy.Spider): + name = "books" + start_urls = ["https://books.toscrape.com/"] + + def parse(self, response): + for book in response.css("article.product_pod"): + yield BookItem( + title=book.css("h3 a::attr(title)").get(), + price=float(book.css("p.price_color::text").re_first(r"[\d.]+")), + ) + +Put the ``PricePipeline`` shown earlier in ``myproject/pipelines.py``, and +enable it in ``myproject/settings.py``: + +.. code-block:: python + + ITEM_PIPELINES = { + "myproject.pipelines.PricePipeline": 300, + } + +With these pieces in place, every ``BookItem`` that ``BooksSpider`` yields +passes through ``PricePipeline`` before it reaches the :ref:`feed exports +` or any other output. + +.. _books.toscrape.com: https://books.toscrape.com/ + + +Common pitfalls +=============== + +The pipeline does not run +------------------------- + +A pipeline component only runs if its class is listed in the +:setting:`ITEM_PIPELINES` setting, normally in your project's +:file:`settings.py` file (see :ref:`activating-item-pipeline`). Adding it to +the spider or elsewhere has no effect. + +To confirm that Scrapy loaded your pipeline, look for a line like this near the +start of the crawl log:: + + [scrapy.middleware] INFO: Enabled item pipelines: + ['myproject.pipelines.PricePipeline'] + +If your pipeline is missing from that list, check that its import path matches +the :setting:`ITEM_PIPELINES` entry, and that the setting is not being +overridden, for example by :attr:`~scrapy.Spider.custom_settings` or by a +redefinition of :setting:`ITEM_PIPELINES` in :file:`settings.py`. + +The item is not returned +------------------------ + +:meth:`process_item` must return the item (or raise +:exc:`~scrapy.exceptions.DropItem`). A common mistake is to modify the item but +forget to return it: + +.. code-block:: python + + def process_item(self, item): + ItemAdapter(item)["price"] *= 1.15 + # Bug: returns None, so the next component gets None instead of the item. + +Return the item so that the next component, and the rest of Scrapy, can keep +processing it: + +.. code-block:: python + + def process_item(self, item): + ItemAdapter(item)["price"] *= 1.15 + return item diff --git a/scrapy/templates/project/module/pipelines.py.tmpl b/scrapy/templates/project/module/pipelines.py.tmpl index e845f43e9..a6494b391 100644 --- a/scrapy/templates/project/module/pipelines.py.tmpl +++ b/scrapy/templates/project/module/pipelines.py.tmpl @@ -9,5 +9,5 @@ from itemadapter import ItemAdapter class ${ProjectName}Pipeline: - def process_item(self, item, spider): + def process_item(self, item): return item From edc353c975d2c7fdef480bcaad27c4ff6602682d Mon Sep 17 00:00:00 2001 From: Adrian Date: Fri, 26 Jun 2026 16:40:36 +0200 Subject: [PATCH 223/248] Improve test coverage for shell/ (#7680) * Improve test coverage for shell/ * Address test issues * Make the shell config test more reliable on Windows --- tests/test_command_shell.py | 196 +++++++++++++++++++++++++++++++++++- 1 file changed, 195 insertions(+), 1 deletion(-) diff --git a/tests/test_command_shell.py b/tests/test_command_shell.py index 1585835cc..1c109a333 100644 --- a/tests/test_command_shell.py +++ b/tests/test_command_shell.py @@ -1,19 +1,28 @@ from __future__ import annotations +import importlib.util import os +import signal import sys from io import BytesIO from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any +from unittest.mock import AsyncMock, MagicMock, patch import pytest from pexpect.popen_spawn import PopenSpawn +from scrapy import Spider +from scrapy.http import Request, Response +from scrapy.shell import Shell, inspect_response from scrapy.utils.reactor import _asyncio_reactor_path +from scrapy.utils.test import get_crawler from tests import NON_EXISTING_RESOLVABLE, tests_datadir from tests.utils.cmdline import proc +from tests.utils.decorators import coroutine_test if TYPE_CHECKING: + from scrapy.crawler import Crawler from tests.mockserver.http import MockServer @@ -139,6 +148,19 @@ class TestShellCommand: ) assert ret == 0, err + def test_shelp(self) -> None: + ret, out, _ = proc("shell", "-c", "shelp()") + assert ret == 0, out + assert "Available Scrapy objects" in out + + def test_fetch_request_with_callbacks(self, mockserver: MockServer) -> None: + url = mockserver.url("/text") + code = ( + f"fetch(scrapy.Request('{url}', callback=lambda r: r, errback=lambda f: f))" + ) + ret, out, _ = proc("shell", "-c", code) + assert ret == 0, out + class TestInteractiveShell: def test_fetch(self, mockserver: MockServer) -> None: @@ -161,3 +183,175 @@ class TestInteractiveShell: p.wait() # type: ignore[no-untyped-call] logfile.seek(0) assert "Traceback" not in logfile.read().decode() + + @staticmethod + def _isolate_config(env: dict[str, str], config_home: Path) -> None: + """Point every scrapy.cfg location (see + :func:`scrapy.utils.conf.get_sources`) at ``config_home``. + + ``XDG_CONFIG_HOME`` is read by Scrapy on all platforms, while + ``~/.scrapy.cfg`` goes through :func:`os.path.expanduser`, which uses + ``HOME`` on POSIX and ``USERPROFILE`` on Windows. The working directory + stays at the repository root (no scrapy.cfg) so subprocess coverage data + is still collected there. + """ + env.pop("SCRAPY_PYTHON_SHELL", None) + env["HOME"] = str(config_home) + env["USERPROFILE"] = str(config_home) + env["XDG_CONFIG_HOME"] = str(config_home) + + def _run_interactive_shell(self, env: dict[str, str]) -> str: + args = (sys.executable, "-m", "scrapy.cmdline", "shell") + logfile = BytesIO() + p = PopenSpawn(args, env=env, timeout=5) + p.logfile_read = logfile + p.expect_exact("Available Scrapy objects") + p.sendeof() + p.wait() # type: ignore[no-untyped-call] + logfile.seek(0) + return logfile.read().decode() + + @pytest.mark.skipif( + importlib.util.find_spec("IPython") is None, + reason="Without IPython installed, shell=python and the default both " + "select the standard Python shell, so the setting has no observable effect.", + ) + def test_shell_from_cfg(self, tmp_path: Path) -> None: + config_home = tmp_path / "config" + config_home.mkdir() + (config_home / "scrapy.cfg").write_text("[settings]\nshell = python\n") + env = os.environ.copy() + self._isolate_config(env, config_home) + args = (sys.executable, "-m", "scrapy.cmdline", "shell") + logfile = BytesIO() + p = PopenSpawn(args, env=env, timeout=10) + p.logfile_read = logfile + p.expect_exact("Available Scrapy objects") + # The standard Python shell never imports IPython, whereas the IPython + # shell (the default when installed) does; this confirms the configured + # shell=python was honored, regardless of platform-specific prompts. + p.sendline("import sys; print('IPYMODULE', 'IPython' in sys.modules)") + p.expect_exact("IPYMODULE False") + p.sendeof() + p.wait() # type: ignore[no-untyped-call] + logfile.seek(0) + assert "Traceback" not in logfile.read().decode() + + def test_shell_default_shells(self, tmp_path: Path) -> None: + config_home = tmp_path / "config" + config_home.mkdir() + env = os.environ.copy() + self._isolate_config(env, config_home) + assert "Traceback" not in self._run_interactive_shell(env) + + +@pytest.fixture +def restore_sigint(): + """Shell.start() installs SIG_IGN as the SIGINT handler; restore it.""" + handler = signal.getsignal(signal.SIGINT) + try: + yield + finally: + signal.signal(signal.SIGINT, handler) + + +def _no_reactor_crawler(monkeypatch: pytest.MonkeyPatch) -> Crawler: + """Return a crawler that reports ``TWISTED_REACTOR_ENABLED=False``. + + A genuine no-reactor crawler cannot be built while a Twisted reactor is + installed (as it is during the test run), so we build a normal crawler and + make its settings report the reactor as disabled, which is all the shell + code looks at. + """ + crawler = get_crawler() + real_getbool = crawler.settings.getbool + + def fake_getbool(name: str, *args: Any, **kwargs: Any) -> bool: + if name == "TWISTED_REACTOR_ENABLED": + return False + return real_getbool(name, *args, **kwargs) + + monkeypatch.setattr(crawler.settings, "getbool", fake_getbool) + return crawler + + +@pytest.mark.requires_reactor +class TestShell: + """Tests for :class:`~scrapy.shell.Shell` paths with no ``scrapy shell`` + command-line route: those reached through + :func:`scrapy.shell.inspect_response` (called from spider callbacks) or only + through direct API use, hence not covered by the subprocess tests above. + """ + + def test_populate_vars_fetch_not_available(self) -> None: + shell = Shell(get_crawler()) + shell._inthread = False + shell.populate_vars() + assert "fetch" not in shell.vars + + def test_get_help_fetch_not_available(self) -> None: + shell = Shell(get_crawler()) + shell._inthread = False + shell.populate_vars() + help_text = shell.get_help() + assert "fetch(url" not in help_text + assert "shelp()" in help_text + + def test_start_with_request(self, restore_sigint: None) -> None: + shell = Shell(get_crawler(), code="1") + shell.fetch = MagicMock() # type: ignore[method-assign] + request = Request("data:,") + shell.start(request=request) + shell.fetch.assert_called_once_with(request, None) + + def test_start_with_response( + self, restore_sigint: None, capsys: pytest.CaptureFixture[str] + ) -> None: + shell = Shell(get_crawler(), code="response.url") + request = Request("data:,") + response = Response("data:,", request=request) + shell.start(response=response) + assert "data:," in capsys.readouterr().out + assert shell.vars["response"] is response + assert shell.vars["request"] is request + + @patch("scrapy.shell.start_python_console") + def test_inspect_response( + self, mock_console: MagicMock, restore_sigint: None + ) -> None: + crawler = get_crawler() + spider = crawler._create_spider() + response = Response("data:,", request=Request("data:,")) + sigint_handler = signal.getsignal(signal.SIGINT) + inspect_response(response, spider) + mock_console.assert_called_once() + assert signal.getsignal(signal.SIGINT) is sigint_handler + + @coroutine_test + async def test_open_spider_explicit_spider(self) -> None: + crawler = get_crawler() + crawler.engine = MagicMock() + crawler.engine.open_spider_async = AsyncMock() + shell = Shell(crawler) + spider = Spider("test") + await shell._open_spider(spider) + assert shell.spider is spider + assert crawler.spider is spider + crawler.engine.open_spider_async.assert_called_once_with(close_if_idle=False) + + +@pytest.mark.only_asyncio +class TestShellNoReactor: + @coroutine_test + @patch("scrapy.shell.start_python_console") + async def test_inspect_response_no_reactor( + self, + mock_console: MagicMock, + restore_sigint: None, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + crawler = _no_reactor_crawler(monkeypatch) + spider = crawler._create_spider() + response = Response("data:,", request=Request("data:,")) + inspect_response(response, spider) + mock_console.assert_called_once() From cf5607f8bca45fa7c86b053b599a457542641ec1 Mon Sep 17 00:00:00 2001 From: Adrian Date: Fri, 26 Jun 2026 17:35:46 +0200 Subject: [PATCH 224/248] Undeprecated the basic FormRequest API (#7671) --- docs/topics/request-response.rst | 5 +++ scrapy/http/__init__.py | 17 +-------- scrapy/http/request/form.py | 63 ++++++++++++++++++++++++++++---- tests/test_http_request_form.py | 28 +++++++++++++- 4 files changed, 89 insertions(+), 24 deletions(-) diff --git a/docs/topics/request-response.rst b/docs/topics/request-response.rst index 700238fe9..5e70a4f98 100644 --- a/docs/topics/request-response.rst +++ b/docs/topics/request-response.rst @@ -911,6 +911,11 @@ Request subclasses Here is the list of built-in :class:`~scrapy.Request` subclasses. You can also subclass it to implement your own custom functionality. +FormRequest +----------- + +.. autoclass:: scrapy.FormRequest + JsonRequest ----------- diff --git a/scrapy/http/__init__.py b/scrapy/http/__init__.py index e20e894ae..0e5c2b53b 100644 --- a/scrapy/http/__init__.py +++ b/scrapy/http/__init__.py @@ -5,11 +5,9 @@ Use this module (instead of the more specific ones) when importing Headers, Request and Response outside this module. """ -from warnings import catch_warnings, filterwarnings - -from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.http.headers import Headers from scrapy.http.request import Request +from scrapy.http.request.form import FormRequest from scrapy.http.request.json_request import JsonRequest from scrapy.http.request.rpc import XmlRpcRequest from scrapy.http.response import Response @@ -17,19 +15,6 @@ from scrapy.http.response.html import HtmlResponse from scrapy.http.response.json import JsonResponse from scrapy.http.response.text import TextResponse from scrapy.http.response.xml import XmlResponse -from scrapy.utils.deprecate import create_deprecated_class - -with catch_warnings(): - filterwarnings("ignore", category=ScrapyDeprecationWarning) - - from scrapy.http.request.form import FormRequest as _FormRequest - - FormRequest = create_deprecated_class( - name="FormRequest", - new_class=_FormRequest, - subclass_warn_message="{cls} inherits from deprecated class {old}, use the form2request library instead.", - instance_warn_message="{cls} is deprecated, use the form2request library instead.", - ) __all__ = [ "FormRequest", diff --git a/scrapy/http/request/form.py b/scrapy/http/request/form.py index 4da595b22..f1a8dbf3b 100644 --- a/scrapy/http/request/form.py +++ b/scrapy/http/request/form.py @@ -32,19 +32,61 @@ if TYPE_CHECKING: from scrapy.http.response.text import TextResponse -warn( - "The entire scrapy.http.request.form module is deprecated. Use the " - "form2request library instead.", - ScrapyDeprecationWarning, - stacklevel=2, -) - FormdataVType: TypeAlias = str | Iterable[str] FormdataKVType: TypeAlias = tuple[str, FormdataVType] FormdataType: TypeAlias = dict[str, FormdataVType] | list[FormdataKVType] | None class FormRequest(Request): + """A :class:`~scrapy.Request` subclass with a ``formdata`` parameter that + url-encodes the given data and assigns it to the request, which makes it + convenient to send arbitrary form data via HTTP POST or GET without an HTML + ```` element to parse. + + .. note:: To build a request from an HTML ```` element found in a + response, use :doc:`form2request ` instead. See + :ref:`form`. + + The remaining arguments are the same as for the :class:`~scrapy.Request` + class and are not documented here. + + :param formdata: a dictionary (or iterable of (key, value) tuples) + containing HTML form data which will be url-encoded. If + :attr:`~scrapy.Request.method` is not given and ``formdata`` is + provided, the method is set to ``"POST"`` and the data is assigned to + the request body; if the method is ``"GET"``, the data is added to the + URL query string instead. + :type formdata: dict or collections.abc.Iterable + + To send data via HTTP POST, simulating an HTML form submission, return a + :class:`~scrapy.FormRequest` object from your spider: + + .. skip: next + .. code-block:: python + + return [ + FormRequest( + url="http://www.example.com/post/action", + formdata={"name": "John Doe", "age": "27"}, + callback=self.after_post, + ) + ] + + To send the data in the URL query string instead, use the ``GET`` method: + + .. skip: next + .. code-block:: python + + return [ + FormRequest( + url="http://www.example.com/search", + method="GET", + formdata={"q": "keyword", "page": "1"}, + callback=self.parse_results, + ) + ] + """ + __slots__ = () valid_form_methods: ClassVar[list[str]] = ["GET", "POST"] @@ -84,6 +126,13 @@ class FormRequest(Request): formcss: str | None = None, **kwargs: Any, ) -> Self: + warn( + "FormRequest.from_response() is deprecated. Use the form2request " + "library instead.", + ScrapyDeprecationWarning, + stacklevel=2, + ) + kwargs.setdefault("encoding", response.encoding) if formcss is not None: diff --git a/tests/test_http_request_form.py b/tests/test_http_request_form.py index c667e48a1..af86b35c0 100644 --- a/tests/test_http_request_form.py +++ b/tests/test_http_request_form.py @@ -1,10 +1,12 @@ from __future__ import annotations import re +import warnings from urllib.parse import parse_qs, unquote_to_bytes import pytest +from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.http import FormRequest, HtmlResponse from scrapy.utils.httpobj import urlparse_cached from scrapy.utils.python import to_unicode @@ -26,15 +28,39 @@ def _qs(req, encoding="utf-8", to_unicode=False): return parse_qs(uqs, True) +# FormRequest.from_response() is deprecated in favor of form2request, so the +# many tests below that exercise it ignore the resulting deprecation warning. @pytest.mark.filterwarnings("ignore::scrapy.exceptions.ScrapyDeprecationWarning") class TestFormRequest(TestRequest): - request_class = FormRequest # type: ignore[assignment] + request_class = FormRequest def assertQueryEqual(self, first, second, msg=None): first = to_unicode(first).split("&") second = to_unicode(second).split("&") assert sorted(first) == sorted(second), msg + def test_init_not_deprecated(self): + # Building a request directly from form data is not deprecated. + with warnings.catch_warnings(): + warnings.simplefilter("error", ScrapyDeprecationWarning) + self.request_class( + "http://www.example.com", formdata={"a": "1"}, method="POST" + ) + self.request_class( + "http://www.example.com", method="GET", formdata={"a": "1"} + ) + + def test_from_response_deprecated(self): + response = _buildresponse( + """ + + """ + ) + with pytest.warns( + ScrapyDeprecationWarning, match=r"FormRequest\.from_response\(\)" + ): + self.request_class.from_response(response) + def test_empty_formdata(self): r1 = self.request_class("http://www.example.com", formdata={}) assert r1.body == b"" From 4b40d2d06a05174b693958145152559c94ebe487 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Fri, 26 Jun 2026 23:05:08 +0500 Subject: [PATCH 225/248] Close various garbage collectible resources (mostly in tests) (#7644) * Set TELNETCONSOLE_ENABLED=False in get_crawler(). * Make _get_console_and_portal() a context manager. * Allow PYTHONTRACEMALLOC in tox. * Close the event loop in test_custom_asyncio_loop_enabled_false(). * Close the handler in _uninstall_scrapy_root_handler(). * Close queues in test_squeues.py. * Close empty queues in init_prios(). * Close PopenSpawn stdin and stdout explicitly. * Silence the unclosed socket warning. * Implement more methods in CustomStatsCollector. * Link the Twisted issue. * Close the connection on download_maxsize. * Fix typing. * Add a comment about CustomStatsCollector. * Restore the coverage. * Properly close fixture queues. * More robust file closing in feed storages. * Close the file in TestMarshalItemExporter.test_nonstring_types_item(). * Cleanup temporary file handling in test_exporters.py. * Close the file in test_stats_file_failed(). * Use an explicit TextIOWrapper in XmlItemExporter. --- pyproject.toml | 2 + scrapy/core/downloader/handlers/http11.py | 3 +- scrapy/exporters.py | 11 ++- scrapy/extensions/feedexport.py | 30 +++---- scrapy/pqueues.py | 4 + scrapy/utils/ftp.py | 4 +- scrapy/utils/log.py | 9 ++- scrapy/utils/test.py | 1 + tests/test_command_runspider.py | 1 + tests/test_command_shell.py | 4 + tests/test_crawler_subprocess.py | 8 ++ tests/test_exporters.py | 16 +++- tests/test_extension_telnet.py | 95 ++++++++++++++--------- tests/test_feedexport.py | 8 +- tests/test_squeues.py | 9 +++ tests/test_squeues_request.py | 26 +++++-- tests/test_stats.py | 10 +++ tests/test_utils_asyncio.py | 9 +++ tox.ini | 1 + 19 files changed, 183 insertions(+), 68 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 6b0c561f8..b8d9067f9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -274,6 +274,8 @@ markers = [ ] filterwarnings = [ "ignore::DeprecationWarning:twisted.web.static", + # Twisted doesn't close failed sockets after CannotListenError: https://github.com/twisted/twisted/issues/6108 + "ignore:Exception ignored in. warnsize: diff --git a/scrapy/exporters.py b/scrapy/exporters.py index e18f1e6ed..c3540d724 100644 --- a/scrapy/exporters.py +++ b/scrapy/exporters.py @@ -171,7 +171,15 @@ class XmlItemExporter(BaseItemExporter): super().__init__(**kwargs) if not self.encoding: self.encoding = "utf-8" - self.xg = XMLGenerator(file, encoding=self.encoding) + # copied from xml.sax.saxutils._gettextwriter() + self.stream = TextIOWrapper( + file, + encoding=self.encoding, + errors="xmlcharrefreplace", + newline="\n", + write_through=True, + ) + self.xg = XMLGenerator(self.stream, encoding=self.encoding) def _beautify_newline(self, new_item: bool = False) -> None: if self.indent is not None and (self.indent > 0 or new_item): @@ -199,6 +207,7 @@ class XmlItemExporter(BaseItemExporter): def finish_exporting(self) -> None: self.xg.endElement(self.root_element) self.xg.endDocument() + self.stream.detach() # Avoid closing the wrapped file. def _export_xml_field(self, name: str, serialized_value: Any, depth: int) -> None: self._beautify_indent(depth=depth) diff --git a/scrapy/extensions/feedexport.py b/scrapy/extensions/feedexport.py index 8029f85c9..fa708a2e1 100644 --- a/scrapy/extensions/feedexport.py +++ b/scrapy/extensions/feedexport.py @@ -250,20 +250,22 @@ class S3FeedStorage(BlockingFeedStorage): def _store_in_thread(self, file: IO[bytes]) -> None: file.seek(0) - if self.acl: - self.s3_client.upload_fileobj( - Bucket=self.bucketname, - Key=self.keyname, - Fileobj=file, - ExtraArgs={"ACL": self.acl}, - ) - else: - self.s3_client.upload_fileobj( - Bucket=self.bucketname, - Key=self.keyname, - Fileobj=file, - ) - file.close() + try: + if self.acl: + self.s3_client.upload_fileobj( + Bucket=self.bucketname, + Key=self.keyname, + Fileobj=file, + ExtraArgs={"ACL": self.acl}, + ) + else: + self.s3_client.upload_fileobj( + Bucket=self.bucketname, + Key=self.keyname, + Fileobj=file, + ) + finally: + file.close() class GCSFeedStorage(BlockingFeedStorage): diff --git a/scrapy/pqueues.py b/scrapy/pqueues.py index 0ad0b5d78..2efc43d4b 100644 --- a/scrapy/pqueues.py +++ b/scrapy/pqueues.py @@ -141,10 +141,14 @@ class ScrapyPriorityQueue: q = self.qfactory(priority) if q: self.queues[priority] = q + else: + q.close() if self._start_queue_cls: q = self._sqfactory(priority) if q: self._start_queues[priority] = q + else: + q.close() self.curprio = min(startprios) diff --git a/scrapy/utils/ftp.py b/scrapy/utils/ftp.py index 152f3374e..a3e7a4306 100644 --- a/scrapy/utils/ftp.py +++ b/scrapy/utils/ftp.py @@ -1,4 +1,5 @@ import posixpath +from contextlib import closing from ftplib import FTP, error_perm from posixpath import dirname from typing import IO @@ -32,7 +33,7 @@ def ftp_store_file( """Opens a FTP connection with passed credentials,sets current directory to the directory extracted from given path, then uploads the file to server """ - with FTP() as ftp: + with FTP() as ftp, closing(file): ftp.connect(host, port) ftp.login(username, password) if use_active_mode: @@ -42,4 +43,3 @@ def ftp_store_file( ftp_makedirs_cwd(ftp, dirname) command = "STOR" if overwrite else "APPE" ftp.storbinary(f"{command} {filename}", file) - file.close() diff --git a/scrapy/utils/log.py b/scrapy/utils/log.py index 09b67805d..aa77e692a 100644 --- a/scrapy/utils/log.py +++ b/scrapy/utils/log.py @@ -149,11 +149,12 @@ def install_scrapy_root_handler(settings: Settings) -> None: def _uninstall_scrapy_root_handler() -> None: global _scrapy_root_handler # noqa: PLW0603 - if ( - _scrapy_root_handler is not None - and _scrapy_root_handler in logging.root.handlers - ): + if _scrapy_root_handler is None: + return + + if _scrapy_root_handler in logging.root.handlers: logging.root.removeHandler(_scrapy_root_handler) + _scrapy_root_handler.close() _scrapy_root_handler = None diff --git a/scrapy/utils/test.py b/scrapy/utils/test.py index b4e20c3c6..90ed70262 100644 --- a/scrapy/utils/test.py +++ b/scrapy/utils/test.py @@ -69,6 +69,7 @@ def get_crawler( # When needed, useful settings can be added here, e.g. ones that prevent # deprecation warnings. settings: dict[str, Any] = { + "TELNETCONSOLE_ENABLED": False, **get_reactor_settings(), **(settings_dict or {}), } diff --git a/tests/test_command_runspider.py b/tests/test_command_runspider.py index b1455611e..9dabbc942 100644 --- a/tests/test_command_runspider.py +++ b/tests/test_command_runspider.py @@ -212,6 +212,7 @@ class MySpider(scrapy.Spider): f"Using asyncio event loop: {loop.__module__}.{loop.__class__.__name__}" in log ) + loop.close() def test_no_reactor(self, tmp_path: Path) -> None: log = self.get_log( diff --git a/tests/test_command_shell.py b/tests/test_command_shell.py index 1c109a333..f24200f53 100644 --- a/tests/test_command_shell.py +++ b/tests/test_command_shell.py @@ -181,6 +181,10 @@ class TestInteractiveShell: p.expect_exact("HtmlResponse") p.sendeof() p.wait() # type: ignore[no-untyped-call] + if p.proc.stdin: + p.proc.stdin.close() + if p.proc.stdout: + p.proc.stdout.close() logfile.seek(0) assert "Traceback" not in logfile.read().decode() diff --git a/tests/test_crawler_subprocess.py b/tests/test_crawler_subprocess.py index 146d94ecd..e3f9ac161 100644 --- a/tests/test_crawler_subprocess.py +++ b/tests/test_crawler_subprocess.py @@ -215,6 +215,10 @@ class TestCrawlerProcessSubprocessBase(ScriptRunnerMixin): p.expect_exact("shutting down gracefully") p.expect_exact("Spider closed (shutdown)") p.wait() # type: ignore[no-untyped-call] + if p.proc.stdin: + p.proc.stdin.close() + if p.proc.stdout: + p.proc.stdout.close() def test_shutdown_graceful(self) -> None: self._test_shutdown_graceful() @@ -232,6 +236,10 @@ class TestCrawlerProcessSubprocessBase(ScriptRunnerMixin): p.kill(sig) p.expect_exact("forcing unclean shutdown") p.wait() # type: ignore[no-untyped-call] + if p.proc.stdin: + p.proc.stdin.close() + if p.proc.stdout: + p.proc.stdout.close() @coroutine_test async def test_shutdown_forced(self) -> None: diff --git a/tests/test_exporters.py b/tests/test_exporters.py index 2fded613d..877e0708b 100644 --- a/tests/test_exporters.py +++ b/tests/test_exporters.py @@ -3,7 +3,6 @@ import json import marshal import pickle import re -import tempfile from abc import ABC, abstractmethod from datetime import datetime from io import BytesIO @@ -242,7 +241,6 @@ class TestPickleItemExporterDataclass(TestPickleItemExporter): class TestMarshalItemExporter(TestBaseItemExporter): def _get_exporter(self, **kwargs): - self.output = tempfile.TemporaryFile() return MarshalItemExporter(self.output, **kwargs) def _check_output(self): @@ -252,7 +250,7 @@ class TestMarshalItemExporter(TestBaseItemExporter): def test_nonstring_types_item(self): item = self._get_nonstring_types_item() item.pop("time") # datetime is not marshallable - fp = tempfile.TemporaryFile() + fp = BytesIO() ie = MarshalItemExporter(fp) ie.start_exporting() ie.export_item(item) @@ -260,6 +258,7 @@ class TestMarshalItemExporter(TestBaseItemExporter): del ie # See the first “del self.ie” in this file for context. fp.seek(0) assert marshal.load(fp) == item + fp.close() class TestMarshalItemExporterDataclass(TestMarshalItemExporter): @@ -269,7 +268,11 @@ class TestMarshalItemExporterDataclass(TestMarshalItemExporter): class TestCsvItemExporter(TestBaseItemExporter): def _get_exporter(self, **kwargs): - self.output = tempfile.TemporaryFile() + # We need a fresh instance for each exporter, because + # CsvItemExporter.stream.__del__() closes the underlying file + # (CsvItemExporter.finish_exporting() calls detach() but not all tests + # call it). + self.output = BytesIO() return CsvItemExporter(self.output, **kwargs) def assertCsvEqual(self, first, second, msg=None): @@ -389,6 +392,11 @@ class TestCsvItemExporterDataclass(TestCsvItemExporter): class TestXmlItemExporter(TestBaseItemExporter): def _get_exporter(self, **kwargs): + # We need a fresh instance for each exporter, because + # XmlItemExporter.stream.__del__() closes the underlying file + # (XmlItemExporter.finish_exporting() calls detach() but not all tests + # call it). + self.output = BytesIO() return XmlItemExporter(self.output, **kwargs) def assertXmlEquivalent(self, first, second, msg=None): diff --git a/tests/test_extension_telnet.py b/tests/test_extension_telnet.py index 3f9135867..20c801558 100644 --- a/tests/test_extension_telnet.py +++ b/tests/test_extension_telnet.py @@ -1,61 +1,86 @@ +from __future__ import annotations + +from contextlib import contextmanager +from typing import TYPE_CHECKING, Any + import pytest from twisted.conch.telnet import ITelnetProtocol from twisted.cred import credentials from scrapy.extensions.telnet import TelnetConsole +from scrapy.utils.defer import maybe_deferred_to_future from scrapy.utils.test import get_crawler -from tests.utils.decorators import inline_callbacks_test +from tests.utils.decorators import coroutine_test + +if TYPE_CHECKING: + from collections.abc import Generator + + from scrapy.crawler import Crawler pytestmark = pytest.mark.requires_reactor # TelnetConsole requires a reactor -class TestTelnetExtension: - def _get_console_and_portal(self, settings=None): - crawler = get_crawler(settings_dict=settings) - console = TelnetConsole(crawler) +def _get_crawler(settings_dict: dict[str, Any] | None = None) -> Crawler: + settings = { + "TELNETCONSOLE_ENABLED": True, + **(settings_dict or {}), + } + return get_crawler(settings_dict=settings) - # This function has some side effects we don't need for this test - console._get_telnet_vars = dict - console.start_listening() - protocol = console.protocol() - portal = protocol.protocolArgs[0] +@contextmanager +def _get_console_and_portal( + settings: dict[str, Any] | None = None, +) -> Generator[tuple[TelnetConsole, Any]]: + crawler = _get_crawler(settings_dict=settings) + console = TelnetConsole(crawler) - return console, portal + # This function has some side effects we don't need for this test + console._get_telnet_vars = dict # type: ignore[method-assign] - @inline_callbacks_test - def test_bad_credentials(self): - console, portal = self._get_console_and_portal() + console.start_listening() + protocol = console.protocol() + portal = protocol.protocolArgs[0] + + try: + yield console, portal + finally: + console.stop_listening() + + +@coroutine_test +async def test_bad_credentials() -> None: + with _get_console_and_portal() as (_, portal): creds = credentials.UsernamePassword(b"username", b"password") d = portal.login(creds, None, ITelnetProtocol) with pytest.raises(ValueError, match="Invalid credentials"): - yield d - console.stop_listening() + await maybe_deferred_to_future(d) - @inline_callbacks_test - def test_good_credentials(self): - console, portal = self._get_console_and_portal() + +@coroutine_test +async def test_good_credentials() -> None: + with _get_console_and_portal() as (console, portal): creds = credentials.UsernamePassword( console.username.encode("utf8"), console.password.encode("utf8") ) d = portal.login(creds, None, ITelnetProtocol) - yield d - console.stop_listening() + await maybe_deferred_to_future(d) - @inline_callbacks_test - def test_custom_credentials(self): - settings = { - "TELNETCONSOLE_USERNAME": "user", - "TELNETCONSOLE_PASSWORD": "pass", - } - console, portal = self._get_console_and_portal(settings=settings) + +@coroutine_test +async def test_custom_credentials() -> None: + settings = { + "TELNETCONSOLE_USERNAME": "user", + "TELNETCONSOLE_PASSWORD": "pass", + } + with _get_console_and_portal(settings=settings) as (_, portal): creds = credentials.UsernamePassword(b"user", b"pass") d = portal.login(creds, None, ITelnetProtocol) - yield d - console.stop_listening() + await maybe_deferred_to_future(d) - def test_invalid_reversed_portrange(self): - settings = {"TELNETCONSOLE_PORT": [2, 1]} - console = TelnetConsole(get_crawler(settings_dict=settings)) - with pytest.raises(ValueError, match=r"invalid portrange: \[2, 1\]"): - console.start_listening() + +def test_invalid_reversed_portrange() -> None: + settings = {"TELNETCONSOLE_PORT": [2, 1]} + console = TelnetConsole(_get_crawler(settings_dict=settings)) + with pytest.raises(ValueError, match=r"invalid portrange: \[2, 1\]"): + console.start_listening() diff --git a/tests/test_feedexport.py b/tests/test_feedexport.py index c1d6f04eb..d7ea60cc8 100644 --- a/tests/test_feedexport.py +++ b/tests/test_feedexport.py @@ -87,6 +87,7 @@ class DummyBlockingFeedStorage(BlockingFeedStorage): class FailingBlockingFeedStorage(DummyBlockingFeedStorage): def _store_in_thread(self, file): + file.close() raise OSError("Cannot store") @@ -477,9 +478,14 @@ class TestFeedExport(TestFeedExportBase): }, } crawler = get_crawler(ItemSpider, settings) + + def store(file: IO[bytes]) -> None: + file.close() + raise KeyError("foo") + with mock.patch( "scrapy.extensions.feedexport.FileFeedStorage.store", - side_effect=KeyError("foo"), + side_effect=store, ): yield crawler.crawl(mockserver=self.mockserver) assert "feedexport/failed_count/FileFeedStorage" in crawler.stats.get_stats() diff --git a/tests/test_squeues.py b/tests/test_squeues.py index ddc12766c..8544602af 100644 --- a/tests/test_squeues.py +++ b/tests/test_squeues.py @@ -42,6 +42,7 @@ def nonserializable_object_test(self): ValueError, match=r"unmarshallable object|can't pickle Selector objects" ): q.push(sel) + q.close() class FifoDiskQueueTestMixin: @@ -53,6 +54,7 @@ class FifoDiskQueueTestMixin: assert q.pop() == "a" assert q.pop() == 123 assert q.pop() == {"a": "dict"} + q.close() test_nonserializable_object = nonserializable_object_test @@ -93,6 +95,7 @@ class PickleFifoDiskQueueTest(t.FifoDiskQueueTest, FifoDiskQueueTestMixin): i2 = q.pop() assert isinstance(i2, MyItem) assert i == i2 + q.close() def test_serialize_loader(self): q = self.queue() @@ -102,6 +105,7 @@ class PickleFifoDiskQueueTest(t.FifoDiskQueueTest, FifoDiskQueueTestMixin): assert isinstance(loader2, MyLoader) assert loader2.default_item_class is MyItem assert loader2.name_out("x") == "xx" + q.close() def test_serialize_request_recursive(self): q = self.queue() @@ -112,6 +116,7 @@ class PickleFifoDiskQueueTest(t.FifoDiskQueueTest, FifoDiskQueueTestMixin): assert isinstance(r2, Request) assert r.url == r2.url assert r2.meta["request"] is r2 + q.close() def test_non_pickable_object(self): q = self.queue() @@ -158,6 +163,7 @@ class LifoDiskQueueTestMixin: assert q.pop() == {"a": "dict"} assert q.pop() == 123 assert q.pop() == "a" + q.close() test_nonserializable_object = nonserializable_object_test @@ -178,6 +184,7 @@ class PickleLifoDiskQueueTest(t.LifoDiskQueueTest, LifoDiskQueueTestMixin): i2 = q.pop() assert isinstance(i2, MyItem) assert i == i2 + q.close() def test_serialize_loader(self): q = self.queue() @@ -187,6 +194,7 @@ class PickleLifoDiskQueueTest(t.LifoDiskQueueTest, LifoDiskQueueTestMixin): assert isinstance(loader2, MyLoader) assert loader2.default_item_class is MyItem assert loader2.name_out("x") == "xx" + q.close() def test_serialize_request_recursive(self): q = self.queue() @@ -197,3 +205,4 @@ class PickleLifoDiskQueueTest(t.LifoDiskQueueTest, LifoDiskQueueTestMixin): assert isinstance(r2, Request) assert r.url == r2.url assert r2.meta["request"] is r2 + q.close() diff --git a/tests/test_squeues_request.py b/tests/test_squeues_request.py index 847a76ab6..c779b005f 100644 --- a/tests/test_squeues_request.py +++ b/tests/test_squeues_request.py @@ -70,7 +70,6 @@ class TestRequestQueueBase(ABC): if test_peek: assert q.peek() is None assert q.pop() is None - q.close() @pytest.mark.parametrize("test_peek", [True, False]) def test_order(self, q: queuelib.queue.BaseQueue, test_peek: bool): @@ -108,7 +107,6 @@ class TestRequestQueueBase(ABC): if test_peek: assert q.peek() is None assert q.pop() is None - q.close() class TestPickleFifoDiskQueueRequest(TestRequestQueueBase): @@ -116,9 +114,13 @@ class TestPickleFifoDiskQueueRequest(TestRequestQueueBase): @pytest.fixture def q(self, crawler, tmp_path): - return PickleFifoDiskQueue.from_crawler( + queue = PickleFifoDiskQueue.from_crawler( crawler=crawler, key=str(tmp_path / "pickle" / "fifo") ) + try: + yield queue + finally: + queue.close() class TestPickleLifoDiskQueueRequest(TestRequestQueueBase): @@ -126,9 +128,13 @@ class TestPickleLifoDiskQueueRequest(TestRequestQueueBase): @pytest.fixture def q(self, crawler, tmp_path): - return PickleLifoDiskQueue.from_crawler( + queue = PickleLifoDiskQueue.from_crawler( crawler=crawler, key=str(tmp_path / "pickle" / "lifo") ) + try: + yield queue + finally: + queue.close() class TestMarshalFifoDiskQueueRequest(TestRequestQueueBase): @@ -136,9 +142,13 @@ class TestMarshalFifoDiskQueueRequest(TestRequestQueueBase): @pytest.fixture def q(self, crawler, tmp_path): - return MarshalFifoDiskQueue.from_crawler( + queue = MarshalFifoDiskQueue.from_crawler( crawler=crawler, key=str(tmp_path / "marshal" / "fifo") ) + try: + yield queue + finally: + queue.close() class TestMarshalLifoDiskQueueRequest(TestRequestQueueBase): @@ -146,9 +156,13 @@ class TestMarshalLifoDiskQueueRequest(TestRequestQueueBase): @pytest.fixture def q(self, crawler, tmp_path): - return MarshalLifoDiskQueue.from_crawler( + queue = MarshalLifoDiskQueue.from_crawler( crawler=crawler, key=str(tmp_path / "marshal" / "lifo") ) + try: + yield queue + finally: + queue.close() class TestFifoMemoryQueueRequest(TestRequestQueueBase): diff --git a/tests/test_stats.py b/tests/test_stats.py index 6e6aa0cc4..05f609fda 100644 --- a/tests/test_stats.py +++ b/tests/test_stats.py @@ -131,6 +131,7 @@ class TestStatsCollector: @coroutine_test async def test_deprecated_spider_arg_custom_collector(self) -> None: + # the class reimplements many methods because those are called during the test crawl class CustomStatsCollector: def __init__(self, crawler): self._stats = {} @@ -141,10 +142,19 @@ class TestStatsCollector: def get_stats(self, spider=None): return self._stats + def get_value(self, key, default=None, spider=None): + return self._stats.get(key, default) + + def set_value(self, key, value, spider=None): + self._stats[key] = value + def inc_value(self, key, count=1, start=0, spider=None): d = self._stats d[key] = d.setdefault(key, start) + count + def max_value(self, key, value, spider=None) -> None: + self._stats[key] = max(self._stats.setdefault(key, value), value) + def close_spider(self, spider, reason): pass diff --git a/tests/test_utils_asyncio.py b/tests/test_utils_asyncio.py index 5532b4a31..a198d1c09 100644 --- a/tests/test_utils_asyncio.py +++ b/tests/test_utils_asyncio.py @@ -149,3 +149,12 @@ class TestAsyncioLoopingCall: with pytest.raises(TypeError): looping_call.start(0.1) assert not looping_call.running + + @coroutine_test + async def test_looping_function_raises( + self, caplog: pytest.LogCaptureFixture + ) -> None: + looping_call = AsyncioLoopingCall(lambda: 1 / 0) + looping_call.start(0.1) + assert not looping_call.running + assert "Error calling the AsyncioLoopingCall function" in caplog.text diff --git a/tox.ini b/tox.ini index 2fa7b455d..5017d7636 100644 --- a/tox.ini +++ b/tox.ini @@ -53,6 +53,7 @@ deps = {[test-requirements]deps} pytest >= 8.4.1 # https://github.com/pytest-dev/pytest/pull/13502 passenv = + PYTHONTRACEMALLOC PYTEST_ADDOPTS S3_TEST_FILE_URI AWS_ACCESS_KEY_ID From 185d6b9a20b7d0e77f4c60435d17e5072bd4d704 Mon Sep 17 00:00:00 2001 From: greymoth Date: Sat, 27 Jun 2026 04:53:32 +0900 Subject: [PATCH 226/248] Fix request_to_curl() corrupting dict cookies with bytes keys/values (#7675) * Fix request_to_curl() corrupting dict cookies with bytes keys/values PR #7603 made the list-cookie branch of request_to_curl() bytes-safe via _cookie_value_to_unicode(), but left the sibling dict-cookie branch using raw f-string interpolation. A dict cookie with bytes keys/values (a supported and common form, e.g. Request(url, cookies={b"k": b"v"})) was rendered as --cookie 'b'k'=b'v'' instead of --cookie 'k=v', producing a broken curl command. Route the dict branch through the same _cookie_value_to_unicode() helper, mirroring the list branch. Add a regression test. Co-Authored-By: Claude Opus 4.8 (1M context) * fix: apply _cookie_value_to_unicode to list-branch cookie key/value --------- Co-authored-by: Claude Opus 4.8 (1M context) --- scrapy/utils/request.py | 7 +++++-- tests/test_utils_request.py | 26 ++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/scrapy/utils/request.py b/scrapy/utils/request.py index 398403d90..4a85526c0 100644 --- a/scrapy/utils/request.py +++ b/scrapy/utils/request.py @@ -204,13 +204,16 @@ def request_to_curl(request: Request) -> str: cookies = "" if request.cookies: if isinstance(request.cookies, dict): - cookie = "; ".join(f"{k}={v}" for k, v in request.cookies.items()) + cookie = "; ".join( + f"{_cookie_value_to_unicode(k)}={_cookie_value_to_unicode(v)}" + for k, v in request.cookies.items() + ) cookies = f"--cookie '{cookie}'" elif isinstance(request.cookies, list): cookie = "; ".join( f"{_cookie_value_to_unicode(c['name'])}={_cookie_value_to_unicode(c['value'])}" if "name" in c and "value" in c - else f"{next(iter(c.keys()))}={next(iter(c.values()))}" + else f"{_cookie_value_to_unicode(next(iter(c.keys())))}={_cookie_value_to_unicode(next(iter(c.values())))}" for c in request.cookies ) cookies = f"--cookie '{cookie}'" diff --git a/tests/test_utils_request.py b/tests/test_utils_request.py index e4967d4e7..63926e9d2 100644 --- a/tests/test_utils_request.py +++ b/tests/test_utils_request.py @@ -401,6 +401,19 @@ class TestRequestToCurl: ) self._test_request(request_object, expected_curl_command) + def test_cookies_dict_bytes(self): + request_object = Request( + "https://www.httpbin.org/post", + method="POST", + cookies={b"foo": b"bar"}, + body=json.dumps({"foo": "bar"}), + ) + expected_curl_command = ( + "curl -X POST https://www.httpbin.org/post" + " --data-raw '{\"foo\": \"bar\"}' --cookie 'foo=bar'" + ) + self._test_request(request_object, expected_curl_command) + def test_cookies_list(self): request_object = Request( "https://www.httpbin.org/post", @@ -455,3 +468,16 @@ class TestRequestToCurl: " --data-raw '{\"foo\": \"bar\"}' --cookie 'foo=1'" ) self._test_request(request_object, expected_curl_command) + + def test_cookies_list_bytes_nonstandard_key(self): + request_object = Request( + "https://www.httpbin.org/post", + method="POST", + cookies=[{b"foo": b"bar"}], + body=json.dumps({"foo": "bar"}), + ) + expected_curl_command = ( + "curl -X POST https://www.httpbin.org/post" + " --data-raw '{\"foo\": \"bar\"}' --cookie 'foo=bar'" + ) + self._test_request(request_object, expected_curl_command) From 9559cbee1e7344d9746b2e2164d10a9d6575f9d1 Mon Sep 17 00:00:00 2001 From: Adrian Date: Mon, 29 Jun 2026 14:21:20 +0200 Subject: [PATCH 227/248] Solve timing issues with HTTP cache tests? (#7692) --- tests/test_downloadermiddleware_httpcache.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/tests/test_downloadermiddleware_httpcache.py b/tests/test_downloadermiddleware_httpcache.py index 6c86d7adf..9d5d6874e 100644 --- a/tests/test_downloadermiddleware_httpcache.py +++ b/tests/test_downloadermiddleware_httpcache.py @@ -6,6 +6,7 @@ import tempfile import time from contextlib import contextmanager from typing import TYPE_CHECKING, Any +from unittest import mock import pytest @@ -47,7 +48,6 @@ class TestBase: settings = { "HTTPCACHE_ENABLED": True, "HTTPCACHE_DIR": self.tmpdir, - "HTTPCACHE_EXPIRATION_SECS": 1, "HTTPCACHE_IGNORE_HTTP_CODES": [], "HTTPCACHE_POLICY": self.policy_class, "HTTPCACHE_STORAGE": self.storage_class, @@ -94,7 +94,7 @@ class StorageTestMixin: """Mixin containing storage-specific test methods.""" def test_storage(self): - with self._storage() as (storage, crawler): + with self._storage(HTTPCACHE_EXPIRATION_SECS=1) as (storage, crawler): request2 = self.request.copy() assert storage.retrieve_response(crawler.spider, request2) is None @@ -103,15 +103,17 @@ class StorageTestMixin: assert isinstance(response2, HtmlResponse) # content-type header self.assertEqualResponse(self.response, response2) - time.sleep(2) # wait for cache to expire - assert storage.retrieve_response(crawler.spider, request2) is None + expired = time.time() + storage.expiration_secs + 1 + with mock.patch("scrapy.extensions.httpcache.time", return_value=expired): + assert storage.retrieve_response(crawler.spider, request2) is None def test_storage_never_expire(self): with self._storage(HTTPCACHE_EXPIRATION_SECS=0) as (storage, crawler): assert storage.retrieve_response(crawler.spider, self.request) is None storage.store_response(crawler.spider, self.request, self.response) - time.sleep(0.5) # give the chance to expire - assert storage.retrieve_response(crawler.spider, self.request) + future = time.time() + 10**6 + with mock.patch("scrapy.extensions.httpcache.time", return_value=future): + assert storage.retrieve_response(crawler.spider, self.request) def test_storage_no_content_type_header(self): """Test that the response body is used to get the right response class From 4b2b56f3848111d55151ccfefddfc6b3d84d1302 Mon Sep 17 00:00:00 2001 From: Javier <114426455+javidiazz@users.noreply.github.com> Date: Mon, 29 Jun 2026 15:34:12 +0200 Subject: [PATCH 228/248] Update on Request objects (#7286) --- docs/topics/request-response.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/topics/request-response.rst b/docs/topics/request-response.rst index 5e70a4f98..f2fcfb0b5 100644 --- a/docs/topics/request-response.rst +++ b/docs/topics/request-response.rst @@ -63,7 +63,7 @@ Request objects .. invisible-code-block: python - from scrapy.http import Request + from scrapy import Request 1. Using a dict: From 6591cb756c16bdb0ce85425071fea84a7cddaf53 Mon Sep 17 00:00:00 2001 From: tanishqtayade Date: Mon, 29 Jun 2026 22:07:06 +0530 Subject: [PATCH 229/248] Fix to LocalCache with limit=0 still stores items rather than disabling the cache (#7663) * Fix cell-var-from-loop in _send_catch_log_deferred Replace lambda capturing receiver by reference with a default argument to capture it by value, fixing a potential bug where all deferred callbacks could reference the last receiver in the loop instead of their respective receivers. Removes the pylint disable comment and TODO that were suppressing this issue. * chore: trigger CI rerun for mypy network error * Fix mypy error: pass receiver via addBoth args instead of lambda default * style: apply pre-commit ruff formatting * fix: LocalCache with limit=0 incorrectly stores items When limit=0 is passed to LocalCache (e.g. when DNSCACHE_ENABLED=False), the condition 'if self.limit' evaluates to False due to Python's truthiness rules, causing items to be stored despite the cache being disabled. This leads to an unbounded memory leak during long crawls when DNS caching is explicitly disabled. Fix changes the condition to 'if self.limit is not None' and adds an early return when limit=0 to correctly handle the disabled cache case. * test: add resolver-level regression tests for DNSCACHE_ENABLED=False Add two regression tests that verify DNS results are not stored in dnscache when DNSCACHE_ENABLED=False (cache_size=0): - test_caching_hostname_resolver_dnscache_disabled_rejects_storage: verifies _CachingResolutionReceiver does not write to dnscache when CachingHostnameResolver is initialized with cache_size=0 - test_caching_threaded_resolver_dnscache_disabled_rejects_storage: verifies dnscache rejects storage at the LocalCache level when limit=0 * test: drop misleading threaded resolver test it was writing directly to dnscache, not actually going through CachingThreadedResolver at all. the threaded resolver already has its own if dnscache.limit: guard so the bug doesnt affect it anyway. keeping only the hostname resolver test which covers the actual bug path through _CachingResolutionReceiver * test: clean up comments in resolver test * test: remove unnecessary comment --- scrapy/utils/datatypes.py | 4 +++- tests/test_resolver.py | 18 ++++++++++++++++++ tests/test_utils_datatypes.py | 10 ++++++++++ 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/scrapy/utils/datatypes.py b/scrapy/utils/datatypes.py index 4e65c062e..dd0e062d0 100644 --- a/scrapy/utils/datatypes.py +++ b/scrapy/utils/datatypes.py @@ -152,7 +152,9 @@ class LocalCache(OrderedDict[_KT, _VT]): self.limit: int | None = limit def __setitem__(self, key: _KT, value: _VT) -> None: - if self.limit: + if self.limit is not None: + if self.limit == 0: + return while len(self) >= self.limit: self.popitem(last=False) super().__setitem__(key, value) diff --git a/tests/test_resolver.py b/tests/test_resolver.py index 83fc8693b..7cca45ed1 100644 --- a/tests/test_resolver.py +++ b/tests/test_resolver.py @@ -53,3 +53,21 @@ def test_caching_hostname_resolver_no_addresses_not_cached(): resolver.resolveHostName(Mock(), "example.com") assert "example.com" not in dnscache + + +def test_caching_hostname_resolver_dnscache_disabled_rejects_storage(): + + def fake_resolve(receiver, *_): + receiver.resolutionBegan(Mock()) + receiver.addressResolved(Mock()) + receiver.resolutionComplete() + return receiver + + reactor = Mock() + reactor.nameResolver.resolveHostName.side_effect = fake_resolve + + resolver = CachingHostnameResolver(reactor, cache_size=0) + resolver.resolveHostName(Mock(), "example.com") + + assert "example.com" not in dnscache + assert len(dnscache) == 0 diff --git a/tests/test_utils_datatypes.py b/tests/test_utils_datatypes.py index ba6b82503..fe1f60c7d 100644 --- a/tests/test_utils_datatypes.py +++ b/tests/test_utils_datatypes.py @@ -309,6 +309,16 @@ class TestLocalCache: assert str(x) in cache assert cache[str(x)] == x + def test_cache_with_zero_limit(self): + cache = LocalCache(limit=0) + cache["a"] = 1 + cache["b"] = 2 + cache["c"] = 3 + assert len(cache) == 0 + assert "a" not in cache + assert "b" not in cache + assert "c" not in cache + class TestLocalWeakReferencedCache: def test_cache_with_limit(self): From 52147017b43baff1f9b4668e46ab11341aa8f400 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Mon, 29 Jun 2026 21:49:09 +0500 Subject: [PATCH 230/248] Cleanup cookie handling in request_to_curl() (#7684) * Adjust CookiesT. * Drop list of plain cookie dicts support from request_to_curl(). * Extract _decode_cookie(). * Unify logging. * Extract _to_verbose_cookies(). * Sync and type hint _cookie_to_set_cookie_value() in tests. * Add tests for bytes in Request.cookies. * Type hint assertCookieValEqual(). --- scrapy/downloadermiddlewares/cookies.py | 32 ++------- scrapy/http/request/__init__.py | 2 +- scrapy/utils/request.py | 73 +++++++++++++------ tests/test_downloadermiddleware_cookies.py | 81 +++++++++++++--------- tests/test_utils_request.py | 44 +++--------- 5 files changed, 116 insertions(+), 116 deletions(-) diff --git a/scrapy/downloadermiddlewares/cookies.py b/scrapy/downloadermiddlewares/cookies.py index cd8c2abca..34d071fd5 100644 --- a/scrapy/downloadermiddlewares/cookies.py +++ b/scrapy/downloadermiddlewares/cookies.py @@ -12,6 +12,7 @@ from scrapy.http.cookies import CookieJar from scrapy.utils.decorators import _warn_spider_arg from scrapy.utils.httpobj import urlparse_cached from scrapy.utils.python import to_unicode +from scrapy.utils.request import _decode_cookie, _to_verbose_cookies if TYPE_CHECKING: from collections.abc import Iterable, Sequence @@ -134,29 +135,10 @@ class CookiesMiddleware: Given a dict consisting of cookie components, return its string representation. Decode from bytes if necessary. """ - decoded = {} + decoded = _decode_cookie(cookie, request) + if decoded is None: + return None flags = set() - for key in ("name", "value", "path", "domain"): - value = cookie.get(key) - if value is None: - if key in {"name", "value"}: - msg = f"Invalid cookie found in request {request}: {cookie} ('{key}' is missing)" - logger.warning(msg) - return None - continue - if isinstance(value, (bool, float, int, str)): - decoded[key] = str(value) - else: - assert isinstance(value, bytes) - try: - decoded[key] = value.decode("utf8") - except UnicodeDecodeError: - logger.warning( - "Non UTF-8 encoded cookie found in request %s: %s", - request, - cookie, - ) - decoded[key] = value.decode("latin1", errors="replace") for flag in ("secure",): value = cookie.get(flag, _UNSET) if value is _UNSET or not value: @@ -177,11 +159,7 @@ class CookiesMiddleware: """ if not request.cookies: return () - cookies: Iterable[VerboseCookie] - if isinstance(request.cookies, dict): - cookies = tuple({"name": k, "value": v} for k, v in request.cookies.items()) - else: - cookies = request.cookies + cookies: Iterable[VerboseCookie] = _to_verbose_cookies(request.cookies) for cookie in cookies: cookie.setdefault("secure", urlparse_cached(request).scheme == "https") formatted = filter(None, (self._format_cookie(c, request) for c in cookies)) diff --git a/scrapy/http/request/__init__.py b/scrapy/http/request/__init__.py index 7db648a45..73c2e7dd4 100644 --- a/scrapy/http/request/__init__.py +++ b/scrapy/http/request/__init__.py @@ -51,7 +51,7 @@ class VerboseCookie(TypedDict): secure: NotRequired[bool] -CookiesT: TypeAlias = dict[str, str] | list[VerboseCookie] +CookiesT: TypeAlias = dict[str | bytes, str | bytes] | list[VerboseCookie] RequestTypeVar = TypeVar("RequestTypeVar", bound="Request") diff --git a/scrapy/utils/request.py b/scrapy/utils/request.py index 4a85526c0..b2fd9a834 100644 --- a/scrapy/utils/request.py +++ b/scrapy/utils/request.py @@ -7,6 +7,7 @@ from __future__ import annotations import hashlib import json +import logging from typing import TYPE_CHECKING, Any, Protocol from urllib.parse import urlunparse from weakref import WeakKeyDictionary @@ -25,6 +26,9 @@ if TYPE_CHECKING: from typing_extensions import Self from scrapy.crawler import Crawler + from scrapy.http.request import CookiesT, VerboseCookie + +logger = logging.getLogger(__name__) _fingerprint_cache: WeakKeyDictionary[ @@ -179,17 +183,52 @@ def _get_method(obj: Any, name: Any) -> Any: raise ValueError(f"Method {name!r} not found in: {obj}") from None -def _cookie_value_to_unicode(value: str | bytes | float) -> str: - if isinstance(value, bytes): - return value.decode() - return str(value) +def _to_verbose_cookies(cookies: CookiesT) -> list[VerboseCookie]: + """Return a list of verbose cookies from ``request.cookies``. + + The list of dicts form is returned as is, the dict one is converted first. + """ + if isinstance(cookies, dict): + return [{"name": k, "value": v} for k, v in cookies.items()] + return cookies + + +def _decode_cookie(cookie: VerboseCookie, request: Request) -> dict[str, str] | None: + """Return a dict with non-flag verbose cookie values converted to strings. + + ``name``, ``value``, ``path``, ``domain`` are included, ``secure`` isn't. + """ + + decoded = {} + for key in ("name", "value", "path", "domain"): + value = cookie.get(key) + if value is None: + if key in {"name", "value"}: + logger.warning( + f"Invalid cookie found in request {request}:" + f" {cookie} ('{key}' is missing)" + ) + return None + continue + if isinstance(value, (bool, float, int, str)): + decoded[key] = str(value) + else: + assert isinstance(value, bytes) + try: + decoded[key] = value.decode("utf8") + except UnicodeDecodeError: + logger.warning( + f"Non UTF-8 encoded cookie found in request {request}: {cookie}", + ) + decoded[key] = value.decode("latin1", errors="replace") + return decoded def request_to_curl(request: Request) -> str: """ Converts a :class:`~scrapy.Request` object to a curl command. - :param :class:`~scrapy.Request`: Request object to be converted + :param request: Request object to be converted :return: string containing the curl command """ method = request.method @@ -201,22 +240,14 @@ def request_to_curl(request: Request) -> str: ) url = request.url - cookies = "" - if request.cookies: - if isinstance(request.cookies, dict): - cookie = "; ".join( - f"{_cookie_value_to_unicode(k)}={_cookie_value_to_unicode(v)}" - for k, v in request.cookies.items() - ) - cookies = f"--cookie '{cookie}'" - elif isinstance(request.cookies, list): - cookie = "; ".join( - f"{_cookie_value_to_unicode(c['name'])}={_cookie_value_to_unicode(c['value'])}" - if "name" in c and "value" in c - else f"{_cookie_value_to_unicode(next(iter(c.keys())))}={_cookie_value_to_unicode(next(iter(c.values())))}" - for c in request.cookies - ) - cookies = f"--cookie '{cookie}'" + + cookie_list: list[VerboseCookie] = _to_verbose_cookies(request.cookies) + pairs = [ + f"{decoded['name']}={decoded['value']}" + for c in cookie_list + if (decoded := _decode_cookie(c, request)) is not None + ] + cookies = f"--cookie '{'; '.join(pairs)}'" if pairs else "" curl_cmd = f"curl -X {method} {url} {data} {headers} {cookies}".strip() return " ".join(curl_cmd.split()) diff --git a/tests/test_downloadermiddleware_cookies.py b/tests/test_downloadermiddleware_cookies.py index f79591020..b4419dc67 100644 --- a/tests/test_downloadermiddleware_cookies.py +++ b/tests/test_downloadermiddleware_cookies.py @@ -1,4 +1,5 @@ import logging +from collections.abc import Iterable import pytest from testfixtures import LogCapture @@ -8,30 +9,34 @@ from scrapy.downloadermiddlewares.defaultheaders import DefaultHeadersMiddleware from scrapy.downloadermiddlewares.redirect import RedirectMiddleware from scrapy.exceptions import NotConfigured from scrapy.http import Request, Response +from scrapy.http.request import CookiesT, VerboseCookie from scrapy.utils.python import to_bytes +from scrapy.utils.request import _to_verbose_cookies from scrapy.utils.spider import DefaultSpider from scrapy.utils.test import get_crawler UNSET = object() -def _cookie_to_set_cookie_value(cookie): +def _cookie_to_set_cookie_value(cookie: VerboseCookie) -> str | None: """Given a cookie defined as a dictionary with name and value keys, and optional path and domain keys, return the equivalent string that can be associated to a ``Set-Cookie`` header.""" decoded = {} for key in ("name", "value", "path", "domain"): - if cookie.get(key) is None: - if key in ("name", "value"): + value = cookie.get(key) + if value is None: + if key in {"name", "value"}: return None continue - if isinstance(cookie[key], (bool, float, int, str)): - decoded[key] = str(cookie[key]) + if isinstance(value, (bool, float, int, str)): + decoded[key] = str(value) else: + assert isinstance(value, bytes) try: - decoded[key] = cookie[key].decode("utf8") + decoded[key] = value.decode("utf8") except UnicodeDecodeError: - decoded[key] = cookie[key].decode("latin1", errors="replace") + decoded[key] = value.decode("latin1", errors="replace") cookie_str = f"{decoded.pop('name')}={decoded.pop('value')}" for key, value in decoded.items(): # path, domain @@ -39,24 +44,30 @@ def _cookie_to_set_cookie_value(cookie): return cookie_str -def _cookies_to_set_cookie_list(cookies): +def _cookies_to_set_cookie_list(cookies: CookiesT) -> Iterable[str]: """Given a group of cookie defined either as a dictionary or as a list of dictionaries (i.e. in a format supported by the cookies parameter of Request), return the equivalent list of strings that can be associated to a ``Set-Cookie`` header.""" if not cookies: return [] - if isinstance(cookies, dict): - cookies = ({"name": k, "value": v} for k, v in cookies.items()) - return filter(None, (_cookie_to_set_cookie_value(cookie) for cookie in cookies)) + return filter( + None, + ( + _cookie_to_set_cookie_value(cookie) + for cookie in _to_verbose_cookies(cookies) + ), + ) class TestCookiesMiddleware: - def assertCookieValEqual(self, first, second, msg=None): - def split_cookies(cookies): + @staticmethod + def assertCookieValEqual(first: bytes | str | None, second: bytes | str) -> None: + def split_cookies(cookies: bytes | str) -> list[bytes]: return sorted([s.strip() for s in to_bytes(cookies).split(b";")]) - assert split_cookies(first) == split_cookies(second), msg + assert first is not None + assert split_cookies(first) == split_cookies(second) def setup_method(self): crawler = get_crawler(DefaultSpider) @@ -372,21 +383,25 @@ class TestCookiesMiddleware: assert self.mw.process_request(req3) is None self.assertCookieValEqual(req3.headers["Cookie"], "a=new; c=d; e=f") - def test_request_cookies_encoding(self): - # 1) UTF8-encoded bytes - req1 = Request("http://example.org", cookies={"a": "á".encode()}) - assert self.mw.process_request(req1) is None - self.assertCookieValEqual(req1.headers["Cookie"], b"a=\xc3\xa1") - - # 2) Non UTF8-encoded bytes - req2 = Request("http://example.org", cookies={"a": "á".encode("latin1")}) - assert self.mw.process_request(req2) is None - self.assertCookieValEqual(req2.headers["Cookie"], b"a=\xc3\xa1") - - # 3) String - req3 = Request("http://example.org", cookies={"a": "á"}) - assert self.mw.process_request(req3) is None - self.assertCookieValEqual(req3.headers["Cookie"], b"a=\xc3\xa1") + @pytest.mark.parametrize( + "cookies", + [ + # UTF8-encoded bytes + {"a": "á".encode()}, + # non UTF8-encoded bytes + {"a": "á".encode("latin1")}, + # string + {"a": "á"}, + # key as bytes + {b"a": "á"}, + # key and value as bytes + {b"a": "á".encode()}, + ], + ) + def test_request_cookies_encoding(self, cookies: CookiesT) -> None: + req = Request("http://example.org", cookies=cookies) + assert self.mw.process_request(req) is None + self.assertCookieValEqual(req.headers["Cookie"], b"a=\xc3\xa1") @pytest.mark.xfail(reason="Cookie header is not currently being processed") def test_request_headers_cookie_encoding(self): @@ -410,7 +425,7 @@ class TestCookiesMiddleware: Invalid cookies are logged as warnings and discarded """ with LogCapture( - "scrapy.downloadermiddlewares.cookies", + "scrapy.utils.request", propagate=False, level=logging.INFO, ) as lc: @@ -425,19 +440,19 @@ class TestCookiesMiddleware: assert self.mw.process_request(req3) is None lc.check( ( - "scrapy.downloadermiddlewares.cookies", + "scrapy.utils.request", "WARNING", "Invalid cookie found in request :" " {'value': 'bar', 'secure': False} ('name' is missing)", ), ( - "scrapy.downloadermiddlewares.cookies", + "scrapy.utils.request", "WARNING", "Invalid cookie found in request :" " {'name': 'foo', 'secure': False} ('value' is missing)", ), ( - "scrapy.downloadermiddlewares.cookies", + "scrapy.utils.request", "WARNING", "Invalid cookie found in request :" " {'name': 'foo', 'value': None, 'secure': False} ('value' is missing)", diff --git a/tests/test_utils_request.py b/tests/test_utils_request.py index 63926e9d2..1642a932b 100644 --- a/tests/test_utils_request.py +++ b/tests/test_utils_request.py @@ -354,16 +354,18 @@ class TestCustomRequestFingerprinter: class TestRequestToCurl: - def _test_request(self, request_object, expected_curl_command): + def _test_request( + self, request_object: Request, expected_curl_command: str + ) -> None: curl_command = request_to_curl(request_object) assert curl_command == expected_curl_command - def test_get(self): + def test_get(self) -> None: request_object = Request("https://www.example.com") expected_curl_command = "curl -X GET https://www.example.com" self._test_request(request_object, expected_curl_command) - def test_post(self): + def test_post(self) -> None: request_object = Request( "https://www.httpbin.org/post", method="POST", @@ -374,7 +376,7 @@ class TestRequestToCurl: ) self._test_request(request_object, expected_curl_command) - def test_headers(self): + def test_headers(self) -> None: request_object = Request( "https://www.httpbin.org/post", method="POST", @@ -388,7 +390,7 @@ class TestRequestToCurl: ) self._test_request(request_object, expected_curl_command) - def test_cookies_dict(self): + def test_cookies_dict(self) -> None: request_object = Request( "https://www.httpbin.org/post", method="POST", @@ -401,7 +403,7 @@ class TestRequestToCurl: ) self._test_request(request_object, expected_curl_command) - def test_cookies_dict_bytes(self): + def test_cookies_dict_bytes(self) -> None: request_object = Request( "https://www.httpbin.org/post", method="POST", @@ -414,20 +416,7 @@ class TestRequestToCurl: ) self._test_request(request_object, expected_curl_command) - def test_cookies_list(self): - request_object = Request( - "https://www.httpbin.org/post", - method="POST", - cookies=[{"foo": "bar"}], - body=json.dumps({"foo": "bar"}), - ) - expected_curl_command = ( - "curl -X POST https://www.httpbin.org/post" - " --data-raw '{\"foo\": \"bar\"}' --cookie 'foo=bar'" - ) - self._test_request(request_object, expected_curl_command) - - def test_cookies_list_verbose(self): + def test_cookies_list_verbose(self) -> None: request_object = Request( "https://www.httpbin.org/post", method="POST", @@ -448,7 +437,7 @@ class TestRequestToCurl: ) self._test_request(request_object, expected_curl_command) - def test_cookies_list_verbose_non_string_value(self): + def test_cookies_list_verbose_non_string_value(self) -> None: request_object = Request( "https://www.httpbin.org/post", method="POST", @@ -468,16 +457,3 @@ class TestRequestToCurl: " --data-raw '{\"foo\": \"bar\"}' --cookie 'foo=1'" ) self._test_request(request_object, expected_curl_command) - - def test_cookies_list_bytes_nonstandard_key(self): - request_object = Request( - "https://www.httpbin.org/post", - method="POST", - cookies=[{b"foo": b"bar"}], - body=json.dumps({"foo": "bar"}), - ) - expected_curl_command = ( - "curl -X POST https://www.httpbin.org/post" - " --data-raw '{\"foo\": \"bar\"}' --cookie 'foo=bar'" - ) - self._test_request(request_object, expected_curl_command) From 6ad8a043cae3d4e0db906788cb60a4c8a3fd7d91 Mon Sep 17 00:00:00 2001 From: Adrian Date: Tue, 30 Jun 2026 12:05:42 +0200 Subject: [PATCH 231/248] Fix genspider --editor (#7683) --- scrapy/commands/edit.py | 23 +++++++++++++-- scrapy/commands/genspider.py | 12 +++++--- tests/test_command_genspider.py | 50 +++++++++++++++++++++++++++++++++ tests/test_commands.py | 28 ++++++++++++++++++ 4 files changed, 106 insertions(+), 7 deletions(-) diff --git a/scrapy/commands/edit.py b/scrapy/commands/edit.py index cd7c57f28..fa75cb09c 100644 --- a/scrapy/commands/edit.py +++ b/scrapy/commands/edit.py @@ -1,12 +1,29 @@ -import argparse +from __future__ import annotations + import os +import shlex +import subprocess import sys -from typing import Any, ClassVar +from pathlib import Path +from typing import TYPE_CHECKING, Any, ClassVar from scrapy.commands import ScrapyCommand from scrapy.exceptions import UsageError from scrapy.spiderloader import get_spider_loader +if TYPE_CHECKING: + import argparse + + +def _edit_file(editor: str, file_path: str | os.PathLike[str]) -> int: + """Open ``file_path`` with ``editor`` and return the editor exit code. + + ``editor`` may include arguments (e.g. ``"code -w"``); it is split with + :func:`shlex.split` and the file is passed as a separate argument, so no + shell is involved. + """ + return subprocess.call([*shlex.split(editor), os.fspath(file_path)]) # noqa: S603 + class Command(ScrapyCommand): requires_project = True @@ -45,4 +62,4 @@ class Command(ScrapyCommand): sfile = sys.modules[spidercls.__module__].__file__ assert sfile sfile = sfile.replace(".pyc", ".py") - self.exitcode = os.system(f'{editor} "{sfile}"') # noqa: S605 + self.exitcode = _edit_file(editor, Path(sfile)) diff --git a/scrapy/commands/genspider.py b/scrapy/commands/genspider.py index cc8624fa1..4277232c3 100644 --- a/scrapy/commands/genspider.py +++ b/scrapy/commands/genspider.py @@ -1,6 +1,5 @@ from __future__ import annotations -import os import shutil import string from importlib import import_module @@ -10,12 +9,14 @@ from urllib.parse import urlparse import scrapy from scrapy.commands import ScrapyCommand +from scrapy.commands.edit import _edit_file from scrapy.exceptions import UsageError from scrapy.spiderloader import get_spider_loader from scrapy.utils.template import render_templatefile, string_camelcase if TYPE_CHECKING: import argparse + import os def sanitize_module_name(module_name: str) -> str: @@ -118,9 +119,11 @@ class Command(ScrapyCommand): template_file = self._find_template(opts.template) if template_file: - self._genspider(module, name, url, opts.template, template_file) + spider_file = self._genspider( + module, name, url, opts.template, template_file + ) if opts.edit: - self.exitcode = os.system(f'scrapy edit "{name}"') # noqa: S605 + self.exitcode = _edit_file(self.settings["EDITOR"], spider_file) def _generate_template_variables( self, @@ -148,7 +151,7 @@ class Command(ScrapyCommand): url: str, template_name: str, template_file: str | os.PathLike[str], - ) -> None: + ) -> Path: """Generate the spider module, based on the given template""" assert self.settings is not None tvars = self._generate_template_variables(module, name, url, template_name) @@ -168,6 +171,7 @@ class Command(ScrapyCommand): ) if spiders_module: print(f"in module:\n {spiders_module.__name__}.{module}") + return Path(spider_file) def _find_template(self, template: str) -> Path | None: template_file = Path(self.templates_dir, f"{template}.tmpl") diff --git a/tests/test_command_genspider.py b/tests/test_command_genspider.py index 67e3eb50a..94db14c30 100644 --- a/tests/test_command_genspider.py +++ b/tests/test_command_genspider.py @@ -1,6 +1,7 @@ from __future__ import annotations import re +import sys from pathlib import Path import pytest @@ -9,6 +10,13 @@ from tests.test_commands import TestProjectBase from tests.utils.cmdline import call, proc +def write_recording_editor(editor: Path) -> None: + """Create an executable editor script that writes the path it is asked to + open (its last argument) into the file given as its first argument.""" + editor.write_text('#!/bin/sh\nprintf "%s" "$2" > "$1"\n', encoding="utf-8") + editor.chmod(0o755) + + def find_in_file(filename: Path, regex: str) -> re.Match[str] | None: """Find first pattern occurrence in file""" pattern = re.compile(regex) @@ -63,6 +71,28 @@ class TestGenspiderCommand(TestProjectBase): assert call("genspider", "--dump=basic", cwd=proj_path) == 0 assert call("genspider", "-d", "basic", cwd=proj_path) == 0 + @pytest.mark.skipif( + sys.platform == "win32", reason="requires a POSIX shell editor script" + ) + def test_edit(self, proj_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + spider = proj_path / self.project_name / "spiders" / "example2.py" + edited = proj_path / "edited.txt" + editor = proj_path / "fake-editor.sh" + write_recording_editor(editor) + # The extra argument exercises shlex-splitting of the EDITOR value. + monkeypatch.setenv("EDITOR", f"{editor} {edited}") + + returncode, _, err = proc( + "genspider", "--edit", "example2", "example2.com", cwd=proj_path + ) + + assert returncode == 0, err + assert "ModuleNotFoundError" not in err + assert spider.exists() + assert (proj_path / edited.read_text(encoding="utf-8")).resolve() == ( + spider.resolve() + ) + def test_same_name_as_project(self, proj_path: Path) -> None: assert call("genspider", self.project_name, cwd=proj_path) == 2 assert not ( @@ -168,6 +198,26 @@ class TestGenspiderStandaloneCommand: call("genspider", "example", "example.com", cwd=tmp_path) assert Path(tmp_path, "example.py").exists() + @pytest.mark.skipif( + sys.platform == "win32", reason="requires a POSIX shell editor script" + ) + def test_edit(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + spider = tmp_path / "example.py" + edited = tmp_path / "edited.txt" + editor = tmp_path / "fake-editor.sh" + write_recording_editor(editor) + monkeypatch.setenv("EDITOR", f"{editor} {edited}") + + returncode, _, err = proc( + "genspider", "--edit", "example", "example.com", cwd=tmp_path + ) + + assert returncode == 0, err + assert spider.exists() + assert (tmp_path / edited.read_text(encoding="utf-8")).resolve() == ( + spider.resolve() + ) + @pytest.mark.parametrize("force", [True, False]) def test_same_name_as_existing_file(self, force: bool, tmp_path: Path) -> None: file_name = "example" diff --git a/tests/test_commands.py b/tests/test_commands.py index d7b7a9ff2..9f81e8602 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -2,6 +2,7 @@ from __future__ import annotations import argparse import json +import sys from io import StringIO from shutil import copytree from typing import TYPE_CHECKING @@ -420,6 +421,33 @@ class TestViewCommand: assert "URL using the Scrapy downloader and show its" in command.long_desc() +class TestEditCommand(TestProjectBase): + @pytest.mark.skipif( + sys.platform == "win32", reason="requires a POSIX shell editor script" + ) + def test_edit(self, proj_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + spider = proj_path / self.project_name / "spiders" / "example.py" + edited = proj_path / "edited.txt" + editor = proj_path / "fake-editor.sh" + # Records the file it is asked to open ($2) into the file given as $1. + editor.write_text('#!/bin/sh\nprintf "%s" "$2" > "$1"\n', encoding="utf-8") + editor.chmod(0o755) + monkeypatch.setenv("EDITOR", f"{editor} {edited}") + + assert call("genspider", "example", "example.com", cwd=proj_path) == 0 + returncode, _, err = proc("edit", "example", cwd=proj_path) + + assert returncode == 0, err + assert (proj_path / edited.read_text(encoding="utf-8")).resolve() == ( + spider.resolve() + ) + + def test_edit_spider_not_found(self, proj_path: Path) -> None: + returncode, _, err = proc("edit", "nonexistent", cwd=proj_path) + assert returncode == 1 + assert "Spider not found: nonexistent" in err + + class TestHelpMessage(TestProjectBase): @pytest.mark.parametrize( "command", From deb7e2861e616bbfefb96433bd72e0d50055cd0e Mon Sep 17 00:00:00 2001 From: Gaurav Yadav Date: Tue, 30 Jun 2026 18:19:47 +0530 Subject: [PATCH 232/248] Fix _get_tag_name() crash for non-string elem.tag (#7686) (#7687) * Fix _get_tag_name() crash for non-string elem.tag (#7686) * test: improve non-string tag test accuracy and add direct unit test * test: use minimal payload for non-string tag test * test: address Adrian+syncrain PR feedback - remove first docstring line (Adrian: unnecessary) - replace weak isinstance assert with no-op call - keep Cython function mention (Adrian: wording is great) * test: replace silent call with assert results == [] per Adrian * chore: drop accidental pyproject.toml and uv.lock changes --- scrapy/utils/sitemap.py | 9 +++++---- tests/test_utils_sitemap.py | 11 +++++++++++ 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/scrapy/utils/sitemap.py b/scrapy/utils/sitemap.py index 1520a4ff0..03b7bf3b1 100644 --- a/scrapy/utils/sitemap.py +++ b/scrapy/utils/sitemap.py @@ -97,10 +97,11 @@ class Sitemap: @staticmethod def _get_tag_name(elem: lxml.etree._Element) -> str: - if TYPE_CHECKING: - assert isinstance(elem.tag, str) - _, _, localname = elem.tag.partition("}") - return localname or elem.tag + tag = elem.tag + if not isinstance(tag, str): + return "" + _, _, localname = tag.partition("}") + return localname or tag def sitemap_urls_from_robots( diff --git a/tests/test_utils_sitemap.py b/tests/test_utils_sitemap.py index 3599c4824..ac57e1739 100644 --- a/tests/test_utils_sitemap.py +++ b/tests/test_utils_sitemap.py @@ -311,3 +311,14 @@ def test_xml_entity_expansion(): """ ) assert list(s) == [{"loc": "http://127.0.0.1:8000/"}] + + +def test_sitemap_non_string_tag(): + """With recover=True and resolve_entities=False, libxml2 >= 2.14.6 (used + by lxml >= 6.1.1) preserves undeclared entity reference nodes whose + .tag is a non-string ``Cython function`` object instead of a ``str``. + _get_tag_name must handle this gracefully instead of raising + AttributeError. + """ + results = list(Sitemap(b"&k;")) + assert results == [] From 00098cb596d0d3957236ba1ba193b09172696bc5 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Tue, 30 Jun 2026 18:27:32 +0500 Subject: [PATCH 233/248] Assorted docstring fixes (#7698) * Smaller fixes. * Add more code blocks in docstrings. * Queue stuff. * Response stuff. * Round 2. * Address feedback. --- scrapy/addons.py | 2 +- scrapy/commands/__init__.py | 2 +- scrapy/core/downloader/contextfactory.py | 9 +++- scrapy/core/downloader/handlers/ftp.py | 6 +-- scrapy/core/downloader/handlers/http11.py | 3 +- scrapy/core/http2/agent.py | 6 +-- scrapy/core/http2/protocol.py | 4 +- scrapy/core/scheduler.py | 12 ++--- scrapy/core/scraper.py | 4 +- scrapy/crawler.py | 12 ++--- .../downloadermiddlewares/httpcompression.py | 2 +- scrapy/downloadermiddlewares/redirect.py | 7 ++- scrapy/downloadermiddlewares/retry.py | 8 ++- scrapy/exceptions.py | 2 +- scrapy/extensions/logstats.py | 2 +- scrapy/extensions/throttle.py | 2 +- scrapy/http/cookies.py | 6 +-- scrapy/http/response/html.py | 5 +- scrapy/http/response/xml.py | 5 +- scrapy/item.py | 2 +- scrapy/link.py | 4 +- scrapy/logformatter.py | 24 +++++---- scrapy/mail.py | 2 - scrapy/pipelines/__init__.py | 2 +- scrapy/pipelines/files.py | 2 +- scrapy/pipelines/images.py | 2 +- scrapy/pqueues.py | 7 +-- scrapy/settings/__init__.py | 11 ++-- scrapy/shell.py | 5 +- scrapy/spidermiddlewares/base.py | 8 +-- scrapy/spidermiddlewares/httperror.py | 2 +- scrapy/spidermiddlewares/referer.py | 2 +- scrapy/spiders/feed.py | 29 ++++++----- scrapy/utils/datatypes.py | 2 - scrapy/utils/defer.py | 52 +++++++++++-------- scrapy/utils/deprecate.py | 15 +++--- scrapy/utils/log.py | 3 +- scrapy/utils/response.py | 2 +- scrapy/utils/signal.py | 7 ++- scrapy/utils/trackref.py | 4 +- scrapy/utils/url.py | 4 +- 41 files changed, 151 insertions(+), 139 deletions(-) diff --git a/scrapy/addons.py b/scrapy/addons.py index 2e12f8c8a..470be47c1 100644 --- a/scrapy/addons.py +++ b/scrapy/addons.py @@ -64,7 +64,7 @@ class AddonManager: :param settings: The :class:`~scrapy.settings.BaseSettings` object from \ which to read the early add-on configuration - :type settings: :class:`~scrapy.settings.Settings` + :type settings: :class:`~scrapy.settings.BaseSettings` """ for clspath in build_component_list(settings["ADDONS"]): addoncls = load_object(clspath) diff --git a/scrapy/commands/__init__.py b/scrapy/commands/__init__.py index 598e8060e..d9c919db9 100644 --- a/scrapy/commands/__init__.py +++ b/scrapy/commands/__init__.py @@ -73,7 +73,7 @@ class ScrapyCommand(ABC): def long_desc(self) -> str: """A long description of the command. Return short description when not available. It cannot contain newlines since contents will be formatted - by optparser which removes newlines and wraps text. + by argparse which removes newlines and wraps text. """ return self.short_desc() diff --git a/scrapy/core/downloader/contextfactory.py b/scrapy/core/downloader/contextfactory.py index c1796c723..a934cbbc7 100644 --- a/scrapy/core/downloader/contextfactory.py +++ b/scrapy/core/downloader/contextfactory.py @@ -47,8 +47,13 @@ class _ScrapyClientContextFactory(BrowserLikePolicyForHTTPS): instance. The purpose of this custom class is to provide a ``creatorForNetloc()`` - method that returns a ``_ScrapyClientTLSOptions`` instance configured based - on TLS settings provided to the factory. + method that returns: + + - a ``_ScrapyClientTLSOptions26`` or ``_ScrapyClientTLSOptions`` instance + configured based on TLS settings provided to the factory (when the + certificate verification is disabled); + - a result of ``optionsForClientTLS()`` called with those TLS settings + (when the certificate verification is enabled). """ def __init__( diff --git a/scrapy/core/downloader/handlers/ftp.py b/scrapy/core/downloader/handlers/ftp.py index 9064aa53a..07ff4a74e 100644 --- a/scrapy/core/downloader/handlers/ftp.py +++ b/scrapy/core/downloader/handlers/ftp.py @@ -2,9 +2,9 @@ An asynchronous FTP file download handler for scrapy which somehow emulates an http response. FTP connection parameters are passed using the request meta field: -- ftp_user (required) -- ftp_password (required) -- ftp_passive (by default, enabled) sets FTP connection passive mode +- ftp_user (optional, falls back to FTP_USER) +- ftp_password (optional, falls back to FTP_PASSWORD) +- ftp_passive (optional, falls back to FTP_PASSIVE_MODE) sets FTP connection passive mode - ftp_local_filename - If not given, file data will come in the response.body, as a normal scrapy Response, which will imply that the entire file will be on memory. diff --git a/scrapy/core/downloader/handlers/http11.py b/scrapy/core/downloader/handlers/http11.py index 1fc504c59..17dca5acb 100644 --- a/scrapy/core/downloader/handlers/http11.py +++ b/scrapy/core/downloader/handlers/http11.py @@ -104,7 +104,6 @@ class HTTP11DownloadHandler(BaseHttpDownloadHandler): self._disconnect_timeout: int = 1 async def download_request(self, request: Request) -> Response: - """Return a deferred for the HTTP download""" if hasattr(self._crawler.spider, "download_maxsize"): # pragma: no cover warn_on_deprecated_spider_attribute("download_maxsize", "DOWNLOAD_MAXSIZE") if hasattr(self._crawler.spider, "download_warnsize"): # pragma: no cover @@ -283,7 +282,7 @@ def _tunnel_request_data( class _TunnelingAgent(Agent): - """An agent that uses a L{TunnelingTCP4ClientEndpoint} to make HTTPS + """An agent that uses a ``_TunnelingTCP4ClientEndpoint`` to make HTTPS downloads. It may look strange that we have chosen to subclass Agent and not ProxyAgent but consider that after the tunnel is opened the proxy is transparent to the client; thus the agent should behave like there is no diff --git a/scrapy/core/http2/agent.py b/scrapy/core/http2/agent.py index 7137a0f2b..aa55e29a0 100644 --- a/scrapy/core/http2/agent.py +++ b/scrapy/core/http2/agent.py @@ -114,11 +114,7 @@ class H2ConnectionPool: d.errback(ResponseFailed(errors)) def close_connections(self) -> None: - """Close all the HTTP/2 connections and remove them from pool - - Returns: - Deferred that fires when all connections have been closed - """ + """Close all the HTTP/2 connections and remove them from pool.""" for conn in self._connections.values(): assert conn.transport is not None # typing conn.transport.abortConnection() diff --git a/scrapy/core/http2/protocol.py b/scrapy/core/http2/protocol.py index 39703f976..7136e829e 100644 --- a/scrapy/core/http2/protocol.py +++ b/scrapy/core/http2/protocol.py @@ -101,7 +101,7 @@ class H2ClientProtocol(Protocol, TimeoutMixin): uri is used to verify that incoming client requests have correct base URL. settings -- Scrapy project settings - conn_lost_deferred -- Deferred fires with the reason: Failure to notify + conn_lost_deferred -- Deferred that fires with the list of underlying exceptions to notify that connection was lost tls_verbose_logging -- Whether to log TLS details """ @@ -375,7 +375,7 @@ class H2ClientProtocol(Protocol, TimeoutMixin): def _handle_events(self, events: list[Event]) -> None: """Private method which acts as a bridge between the events - received from the HTTP/2 data and IH2EventsHandler + received from the HTTP/2 data and the handlers in this class. Arguments: events -- A list of events that the remote peer triggered by sending data diff --git a/scrapy/core/scheduler.py b/scrapy/core/scheduler.py index 7217da942..82e31b90b 100644 --- a/scrapy/core/scheduler.py +++ b/scrapy/core/scheduler.py @@ -131,10 +131,10 @@ class Scheduler(BaseScheduler): (:setting:`SCHEDULER_PRIORITY_QUEUE`) that sort requests by :attr:`~scrapy.http.Request.priority`. - By default, a single, memory-based priority queue is used for all requests. - When using :setting:`JOBDIR`, a disk-based priority queue is also created, + By default, memory-based priority queues are used for all requests. + When using :setting:`JOBDIR`, disk-based priority queues are also created, and only unserializable requests are stored in the memory-based priority - queue. For a given priority value, requests in memory take precedence over + queues. For a given priority value, requests in memory take precedence over requests in disk. Each priority queue stores requests in separate internal queues, one per @@ -209,8 +209,8 @@ class Scheduler(BaseScheduler): ------------------------- While pending requests are below the configured values of - :setting:`CONCURRENT_REQUESTS`, :setting:`CONCURRENT_REQUESTS_PER_DOMAIN` - or :setting:`CONCURRENT_REQUESTS_PER_IP`, those requests are sent + :setting:`CONCURRENT_REQUESTS` or + :setting:`CONCURRENT_REQUESTS_PER_DOMAIN`, those requests are sent concurrently. As a result, the first few requests of a crawl may not follow the desired @@ -342,7 +342,7 @@ class Scheduler(BaseScheduler): def open(self, spider: Spider) -> Deferred[None] | None: """ (1) initialize the memory queue - (2) initialize the disk queue if the ``jobdir`` attribute is a valid directory + (2) initialize the disk queue if the ``jobdir`` argument wasn't empty (3) return the result of the dupefilter's ``open`` method """ self.spider: Spider = spider diff --git a/scrapy/core/scraper.py b/scrapy/core/scraper.py index 466ce656d..58e37ce5e 100644 --- a/scrapy/core/scraper.py +++ b/scrapy/core/scraper.py @@ -441,7 +441,7 @@ class Scraper: self, output: Any, response: Response | Failure ) -> Deferred[None]: """Process each Request/Item (given in the output parameter) returned - from the given spider. + from the spider. Items are sent to the item pipelines, requests are scheduled. """ @@ -451,7 +451,7 @@ class Scraper: self, output: Any, response: Response | Failure ) -> None: """Process each Request/Item (given in the output parameter) returned - from the given spider. + from the spider. Items are sent to the item pipelines, requests are scheduled. """ diff --git a/scrapy/crawler.py b/scrapy/crawler.py index 9828fe6f5..c72752915 100644 --- a/scrapy/crawler.py +++ b/scrapy/crawler.py @@ -531,8 +531,8 @@ class AsyncCrawlerRunner(CrawlerRunnerBase): """ Run a crawler with the provided arguments. - It will call the given Crawler's :meth:`~Crawler.crawl` method, while - keeping track of it so it can be stopped later. + It will call the given Crawler's :meth:`~Crawler.crawl_async` method, + while keeping track of it so it can be stopped later. If ``crawler_or_spidercls`` isn't a :class:`~scrapy.crawler.Crawler` instance, this method will try to create one using this parameter as @@ -773,7 +773,7 @@ class CrawlerProcess(CrawlerProcessBase, CrawlerRunner): """ This method starts a :mod:`~twisted.internet.reactor`, adjusts its pool size to :setting:`REACTOR_THREADPOOL_MAXSIZE`, and installs a DNS - resolver based on :setting:`DNSCACHE_ENABLED`. + resolver based on :setting:`TWISTED_DNS_RESOLVER`. If ``stop_after_crawl`` is True, the reactor will be stopped after all crawlers have finished, using :meth:`join`. @@ -875,10 +875,10 @@ class AsyncCrawlerProcess(CrawlerProcessBase, AsyncCrawlerRunner): When using a reactor it adjusts its pool size to :setting:`REACTOR_THREADPOOL_MAXSIZE` and installs a DNS resolver based - on :setting:`DNSCACHE_ENABLED`. + on :setting:`TWISTED_DNS_RESOLVER`. - If ``stop_after_crawl`` is True, the reactor will be stopped after all - crawlers have finished, using :meth:`join`. + If ``stop_after_crawl`` is True, the reactor/event loop will be stopped + after all crawlers have finished, using :meth:`join`. :param bool stop_after_crawl: stop or not the reactor when all crawlers have finished diff --git a/scrapy/downloadermiddlewares/httpcompression.py b/scrapy/downloadermiddlewares/httpcompression.py index 2b1721ced..6ca04a50e 100644 --- a/scrapy/downloadermiddlewares/httpcompression.py +++ b/scrapy/downloadermiddlewares/httpcompression.py @@ -60,7 +60,7 @@ else: class HttpCompressionMiddleware: - """This middleware allows compressed (gzip, deflate) traffic to be + """This middleware allows compressed (gzip, deflate etc.) traffic to be sent/received from websites""" def __init__( diff --git a/scrapy/downloadermiddlewares/redirect.py b/scrapy/downloadermiddlewares/redirect.py index 821f41699..45af5c67a 100644 --- a/scrapy/downloadermiddlewares/redirect.py +++ b/scrapy/downloadermiddlewares/redirect.py @@ -196,10 +196,7 @@ class BaseRedirectMiddleware: class RedirectMiddleware(BaseRedirectMiddleware): - """ - Handle redirection of requests based on response status - and meta-refresh html tag. - """ + """Handle redirection of requests based on response status.""" @_warn_spider_arg def process_response( @@ -251,6 +248,8 @@ class RedirectMiddleware(BaseRedirectMiddleware): class MetaRefreshMiddleware(BaseRedirectMiddleware): + """Handle redirection of requests based on meta-refresh html tag.""" + enabled_setting = "METAREFRESH_ENABLED" def __init__(self, settings: BaseSettings): diff --git a/scrapy/downloadermiddlewares/retry.py b/scrapy/downloadermiddlewares/retry.py index 5f125cae4..a0a0b60a2 100644 --- a/scrapy/downloadermiddlewares/retry.py +++ b/scrapy/downloadermiddlewares/retry.py @@ -5,9 +5,6 @@ problems such as a connection timeout or HTTP 500 error. You can change the behaviour of this middleware by modifying the scraping settings: RETRY_TIMES - how many times to retry a failed page RETRY_HTTP_CODES - which HTTP response codes to retry - -Failed pages are collected on the scraping process and rescheduled at the end, -once the spider has finished crawling all regular (non-failed) pages. """ from __future__ import annotations @@ -70,8 +67,9 @@ def get_retry_request( and :ref:`stats `, and to provide extra logging context (see :func:`logging.debug`). - *reason* is a string or an :class:`Exception` object that indicates the - reason why the request needs to be retried. It is used to name retry stats. + *reason* is a string, an :class:`Exception` subclass or an + :class:`Exception` object that indicates the reason why the request needs + to be retried. It is used to name retry stats. *max_retry_times* is a number that determines the maximum number of times that *request* can be retried. If not specified or ``None``, the number is diff --git a/scrapy/exceptions.py b/scrapy/exceptions.py index 204132973..5330eab48 100644 --- a/scrapy/exceptions.py +++ b/scrapy/exceptions.py @@ -115,7 +115,7 @@ class UsageError(Exception): class ScrapyDeprecationWarning(Warning): """Warning category for deprecated features, since the default - DeprecationWarning is silenced on Python 2.7+ + :exc:`DeprecationWarning` is silenced. """ diff --git a/scrapy/extensions/logstats.py b/scrapy/extensions/logstats.py index 3d7674905..6c94d947e 100644 --- a/scrapy/extensions/logstats.py +++ b/scrapy/extensions/logstats.py @@ -22,7 +22,7 @@ logger = logging.getLogger(__name__) class LogStats: """Log basic scraping stats periodically like: - * RPM - Requests per Minute + * RPM - Responses per Minute * IPM - Items per Minute """ diff --git a/scrapy/extensions/throttle.py b/scrapy/extensions/throttle.py index cdb0671ae..542ff1cdc 100644 --- a/scrapy/extensions/throttle.py +++ b/scrapy/extensions/throttle.py @@ -116,7 +116,7 @@ class AutoThrottle: # It works better with problematic sites. new_delay = max(target_delay, new_delay) - # Make sure self.mindelay <= new_delay <= self.max_delay + # Make sure self.mindelay <= new_delay <= self.maxdelay new_delay = min(max(self.mindelay, new_delay), self.maxdelay) # Dont adjust delay if response status != 200 and new delay is smaller diff --git a/scrapy/http/cookies.py b/scrapy/http/cookies.py index 599f20947..8edeae01c 100644 --- a/scrapy/http/cookies.py +++ b/scrapy/http/cookies.py @@ -136,9 +136,9 @@ class _DummyLock: class WrappedRequest: - """Wraps a scrapy Request class with methods defined by urllib2.Request class to interact with CookieJar class - - see http://docs.python.org/library/urllib2.html#urllib2.Request + """Wraps a :class:`scrapy.Request` class with methods defined by + the :class:`urllib.request.Request` class to interact with + the :class:`http.cookiejar.CookieJar` class. """ def __init__(self, request: Request): diff --git a/scrapy/http/response/html.py b/scrapy/http/response/html.py index 70c08c11d..6d3a9ee12 100644 --- a/scrapy/http/response/html.py +++ b/scrapy/http/response/html.py @@ -1,6 +1,7 @@ """ -This module implements the HtmlResponse class which adds encoding -discovering through HTML encoding declarations to the TextResponse class. +This module implements the :class:`HtmlResponse` class which is used as a +content type marker by :class:`~scrapy.selector.Selector` and can be used in +``isinstance()`` checks. See documentation in docs/topics/request-response.rst """ diff --git a/scrapy/http/response/xml.py b/scrapy/http/response/xml.py index 6d9c4cb73..847fb2b3c 100644 --- a/scrapy/http/response/xml.py +++ b/scrapy/http/response/xml.py @@ -1,6 +1,7 @@ """ -This module implements the XmlResponse class which adds encoding -discovering through XML encoding declarations to the TextResponse class. +This module implements the :class:`XmlResponse` class which is used as a +content type marker by :class:`~scrapy.selector.Selector` and can be used in +``isinstance()`` checks. See documentation in docs/topics/request-response.rst """ diff --git a/scrapy/item.py b/scrapy/item.py index 1cc0ae584..d5adc1efb 100644 --- a/scrapy/item.py +++ b/scrapy/item.py @@ -1,7 +1,7 @@ """ Scrapy Item -See documentation in docs/topics/item.rst +See documentation in docs/topics/items.rst """ from __future__ import annotations diff --git a/scrapy/link.py b/scrapy/link.py index 046630403..8211faccd 100644 --- a/scrapy/link.py +++ b/scrapy/link.py @@ -9,7 +9,9 @@ its documentation in: docs/topics/link-extractors.rst class Link: """Link objects represent an extracted link by the LinkExtractor. - Using the anchor tag sample below to illustrate the parameters:: + Using the anchor tag sample below to illustrate the parameters: + + .. code-block:: html Dont follow this one diff --git a/scrapy/logformatter.py b/scrapy/logformatter.py index a50064e08..bfb2d5dff 100644 --- a/scrapy/logformatter.py +++ b/scrapy/logformatter.py @@ -58,18 +58,20 @@ class LogFormatter: logging an action the method must return ``None``. Here is an example on how to create a custom log formatter to lower the severity level of - the log message when an item is dropped from the pipeline:: + the log message when an item is dropped from the pipeline: - class PoliteLogFormatter(logformatter.LogFormatter): - def dropped(self, item, exception, response, spider): - return { - 'level': logging.INFO, # lowering the level from logging.WARNING - 'msg': "Dropped: %(exception)s" + os.linesep + "%(item)s", - 'args': { - 'exception': exception, - 'item': item, - } - } + .. code-block:: python + + class PoliteLogFormatter(logformatter.LogFormatter): + def dropped(self, item, exception, response, spider): + return { + "level": logging.INFO, # lowering the level from logging.WARNING + "msg": "Dropped: %(exception)s" + os.linesep + "%(item)s", + "args": { + "exception": exception, + "item": item, + }, + } """ def crawled( diff --git a/scrapy/mail.py b/scrapy/mail.py index fbd11ad1d..97123e63c 100644 --- a/scrapy/mail.py +++ b/scrapy/mail.py @@ -1,7 +1,5 @@ """ Mail sending helpers - -See documentation in docs/topics/email.rst """ from __future__ import annotations diff --git a/scrapy/pipelines/__init__.py b/scrapy/pipelines/__init__.py index 84fb5f85e..383c461e6 100644 --- a/scrapy/pipelines/__init__.py +++ b/scrapy/pipelines/__init__.py @@ -1,7 +1,7 @@ """ Item pipeline -See documentation in docs/item-pipeline.rst +See documentation in docs/topics/item-pipeline.rst """ from __future__ import annotations diff --git a/scrapy/pipelines/files.py b/scrapy/pipelines/files.py index 0066fd38f..44c422430 100644 --- a/scrapy/pipelines/files.py +++ b/scrapy/pipelines/files.py @@ -423,7 +423,7 @@ class FTPFilesStore: class FilesPipeline(MediaPipeline): - """Abstract pipeline that implement the file downloading + """Pipeline that implements file downloading. This pipeline tries to minimize network transfers and file processing, doing stat of the files and determining if file is new, up-to-date or diff --git a/scrapy/pipelines/images.py b/scrapy/pipelines/images.py index 762b0fdf1..79b6c4f27 100644 --- a/scrapy/pipelines/images.py +++ b/scrapy/pipelines/images.py @@ -47,7 +47,7 @@ class ImageException(FileException): class ImagesPipeline(FilesPipeline): - """Abstract pipeline that implement the image thumbnail generation logic""" + """Pipeline that implements the handling logic specific to images.""" MEDIA_NAME: str = "image" diff --git a/scrapy/pqueues.py b/scrapy/pqueues.py index 2efc43d4b..41411ceaf 100644 --- a/scrapy/pqueues.py +++ b/scrapy/pqueues.py @@ -92,9 +92,10 @@ class ScrapyPriorityQueue: - The :data:`~scrapy.Request.priority` of the request. For each combination of the above seen, this class creates an instance of - *downstream_queue_cls* with *key* set to a subdirectory of the persistence - directory, named as the request priority (e.g. ``1``), with an ``s`` suffix - in case of a start request (e.g. ``1s``). + *downstream_queue_cls* (or *start_queue_cls* for start requests if it was + passed) with *key* set to a subdirectory of the persistence directory, + named as the negated request priority (e.g. ``-1``), with an ``s`` suffix + in case of a start request (e.g. ``-1s``). """ @classmethod diff --git a/scrapy/settings/__init__.py b/scrapy/settings/__init__.py index be298e9b1..932463ba9 100644 --- a/scrapy/settings/__init__.py +++ b/scrapy/settings/__init__.py @@ -454,9 +454,10 @@ class BaseSettings(MutableMapping[str, Any]): """ Store a key/value attribute with a given priority. - Settings should be populated *before* configuring the Crawler object - (through the :meth:`~scrapy.crawler.Crawler.configure` method), - otherwise they won't have any effect. + Settings should be populated *before* the Crawler object applies them + (in the :meth:`~scrapy.crawler.Crawler.crawl_async` or + :meth:`~scrapy.crawler.Crawler.crawl` method), otherwise they won't + have any effect. :param name: the setting name :type name: str @@ -613,7 +614,7 @@ class BaseSettings(MutableMapping[str, Any]): """ Make a deep copy of current settings. - This method returns a new instance of the :class:`Settings` class, + This method returns a new instance of this class, populated with the same values and their priorities. Modifications to the new object won't be reflected on the original @@ -658,7 +659,7 @@ class BaseSettings(MutableMapping[str, Any]): Make a copy of current settings and convert to a dict. This method returns a new dict populated with the same values - and their priorities as the current settings. + as the current settings. Modifications to the returned dict won't be reflected on the original settings. diff --git a/scrapy/shell.py b/scrapy/shell.py index 44dcd880e..dfea00c46 100644 --- a/scrapy/shell.py +++ b/scrapy/shell.py @@ -75,7 +75,7 @@ if TYPE_CHECKING: # running event loop. # # Side note: it should be possible to remove _request_deferred() by using -# engine.download_async() instead of engine.schedule(), losing the usual stuff +# engine.download_async() instead of engine.crawl(), losing the usual stuff # like spider middlewares (none of which should be important). # # Other architecture problems: @@ -188,7 +188,8 @@ class Shell: async def _schedule(self, request: Request, spider: Spider | None) -> Response: """Send the request to the engine, wait for the result. - Runs in the reactor thread. + Runs in the reactor thread when using the reactor, or in the asyncio + event loop thread otherwise. """ if not self.spider: await self._open_spider(spider) diff --git a/scrapy/spidermiddlewares/base.py b/scrapy/spidermiddlewares/base.py index e09f2d10e..6c62dccb8 100644 --- a/scrapy/spidermiddlewares/base.py +++ b/scrapy/spidermiddlewares/base.py @@ -75,7 +75,7 @@ class BaseSpiderMiddleware: ) -> Request | None: """Return a processed request from the spider output. - This method is called with a single request from the start seeds or the + This method is called with a single request from ``start()`` or the spider output. It should return the same or a different request, or ``None`` to ignore it. @@ -84,7 +84,7 @@ class BaseSpiderMiddleware: :param response: the response being processed :type response: :class:`~scrapy.http.Response` object or ``None`` for - start seeds + start requests :return: the processed request or ``None`` """ @@ -93,7 +93,7 @@ class BaseSpiderMiddleware: def get_processed_item(self, item: Any, response: Response | None) -> Any: """Return a processed item from the spider output. - This method is called with a single item from the start seeds or the + This method is called with a single item from ``start()`` or the spider output. It should return the same or a different item, or ``None`` to ignore it. @@ -102,7 +102,7 @@ class BaseSpiderMiddleware: :param response: the response being processed :type response: :class:`~scrapy.http.Response` object or ``None`` for - start seeds + start items :return: the processed item or ``None`` """ diff --git a/scrapy/spidermiddlewares/httperror.py b/scrapy/spidermiddlewares/httperror.py index 94b6dfbb5..156b73e7e 100644 --- a/scrapy/spidermiddlewares/httperror.py +++ b/scrapy/spidermiddlewares/httperror.py @@ -28,7 +28,7 @@ logger = logging.getLogger(__name__) class HttpError(IgnoreRequest): - """A non-200 response was filtered""" + """A non-2xx response was filtered""" def __init__(self, response: Response, *args: Any, **kwargs: Any): self.response = response diff --git a/scrapy/spidermiddlewares/referer.py b/scrapy/spidermiddlewares/referer.py index 264f685c1..1305874d5 100644 --- a/scrapy/spidermiddlewares/referer.py +++ b/scrapy/spidermiddlewares/referer.py @@ -92,7 +92,7 @@ class ReferrerPolicy(ABC): ) def origin(self, url: str) -> str | None: - """Return serialized origin (scheme, host, path) for a request or response URL.""" + """Return serialized origin (scheme, host, port) for a request or response URL.""" return self.strip_url(url, origin_only=True) def potentially_trustworthy(self, url: str) -> bool: diff --git a/scrapy/spiders/feed.py b/scrapy/spiders/feed.py index 395183613..925f31ede 100644 --- a/scrapy/spiders/feed.py +++ b/scrapy/spiders/feed.py @@ -54,17 +54,21 @@ class XMLFeedSpider(Spider): return response def parse_node(self, response: Response, selector: Selector) -> Any: - """This method must be overridden with your custom spider functionality""" + """This method is called for the nodes matching the provided tag name + (itertag). Receives the response and an Selector for each node. + + This method must return either an item, a request, or a list + containing any of them. + + This method must be overridden with your custom spider functionality. + """ if hasattr(self, "parse_item"): # backward compatibility return self.parse_item(response, selector) raise NotImplementedError def parse_nodes(self, response: Response, nodes: Iterable[Selector]) -> Any: """This method is called for the nodes matching the provided tag name - (itertag). Receives the response and an Selector for each node. - Overriding this method is mandatory. Otherwise, you spider won't work. - This method must return either an item, a request, or a list - containing any of them. + (itertag). Receives the response and an iterable of Selectors. """ for selector in nodes: @@ -113,6 +117,9 @@ class CSVFeedSpider(Spider): It receives a CSV file in a response; iterates through each of its rows, and calls parse_row with a dict containing each field's data. + This spider also gives the opportunity to override adapt_response and + process_results methods for pre and post-processing purposes. + You can set some options regarding the CSV file, such as the delimiter, quotechar and the file's headers. """ @@ -136,16 +143,14 @@ class CSVFeedSpider(Spider): return response def parse_row(self, response: Response, row: dict[str, str]) -> Any: - """This method must be overridden with your custom spider functionality""" + """Receives a response and a dict (representing each row) with a key for + each provided (or detected) header of the CSV file. + + This method must be overridden with your custom spider functionality. + """ raise NotImplementedError def parse_rows(self, response: Response) -> Any: - """Receives a response and a dict (representing each row) with a key for - each provided (or detected) header of the CSV file. This spider also - gives the opportunity to override adapt_response and - process_results methods for pre and post-processing purposes. - """ - for row in csviter( response, self.delimiter, self.headers, quotechar=self.quotechar ): diff --git a/scrapy/utils/datatypes.py b/scrapy/utils/datatypes.py index dd0e062d0..c020ff4b9 100644 --- a/scrapy/utils/datatypes.py +++ b/scrapy/utils/datatypes.py @@ -1,8 +1,6 @@ """ This module contains data types used by Scrapy which are not included in the Python Standard Library. - -This module must not depend on any module outside the Standard Library. """ from __future__ import annotations diff --git a/scrapy/utils/defer.py b/scrapy/utils/defer.py index 29a34d4ef..d0259b634 100644 --- a/scrapy/utils/defer.py +++ b/scrapy/utils/defer.py @@ -103,7 +103,7 @@ async def _defer_sleep_async() -> None: def defer_result(result: Any) -> Deferred[Any]: # pragma: no cover warnings.warn( "scrapy.utils.defer.defer_result() is deprecated, use" - " twisted.internet.defer.success() and twisted.internet.defer.fail()," + " twisted.internet.defer.succeed() and twisted.internet.defer.fail()," " plus an explicit sleep if needed, or explicit reactor.callLater().", category=ScrapyDeprecationWarning, stacklevel=2, @@ -469,22 +469,22 @@ def _maybeDeferred_coro( def deferred_to_future(d: Deferred[_T]) -> Future[_T]: """Return an :class:`asyncio.Future` object that wraps *d*. - This function requires - :class:`~twisted.internet.asyncioreactor.AsyncioSelectorReactor` to be - installed. + This function requires an installed asyncio reactor or a running asyncio + event loop, see :ref:`using-asyncio`. - When :ref:`using the asyncio reactor `, you cannot await - on :class:`~twisted.internet.defer.Deferred` objects from :ref:`Scrapy - callables defined as coroutines `, you can only await on - ``Future`` objects. Wrapping ``Deferred`` objects into ``Future`` objects - allows you to wait on them:: + In this state you cannot await on :class:`~twisted.internet.defer.Deferred` + objects from :ref:`Scrapy callables defined as coroutines + `, you can only await on ``Future`` objects. Wrapping + ``Deferred`` objects into ``Future`` objects allows you to wait on them: + + .. code-block:: python class MySpider(Spider): ... + async def parse(self, response): - additional_request = scrapy.Request('https://example.org/price') - deferred = self.crawler.engine.download(additional_request) - additional_response = await deferred_to_future(deferred) + deferred = some_dfd_helper() + result = await deferred_to_future(deferred) .. versionchanged:: 2.14 This function no longer installs an asyncio loop if called before the @@ -492,7 +492,10 @@ def deferred_to_future(d: Deferred[_T]) -> Future[_T]: in this case. """ if not is_asyncio_available(): - raise RuntimeError("deferred_to_future() requires AsyncioSelectorReactor.") + raise RuntimeError( + "deferred_to_future() requires an installed asyncio reactor" + " or a running asyncio event loop." + ) return d.asFuture(asyncio.get_event_loop()) @@ -501,23 +504,26 @@ def maybe_deferred_to_future(d: Deferred[_T]) -> Deferred[_T] | Future[_T]: defined as a coroutine `. What you can await in Scrapy callables defined as coroutines depends on the - value of :setting:`TWISTED_REACTOR`: + value of :setting:`TWISTED_REACTOR` and :setting:`TWISTED_REACTOR_ENABLED`: - - When :ref:`using the asyncio reactor `, you can only - await on :class:`asyncio.Future` objects. + - When :ref:`using the asyncio reactor `, or :ref:`not + using a reactor at all `, you can only await + on :class:`asyncio.Future` objects. - - When not using the asyncio reactor, you can only await on - :class:`~twisted.internet.defer.Deferred` objects. + - When :ref:`using a non-asyncio reactor `, you can only + await on :class:`~twisted.internet.defer.Deferred` objects. - If you want to write code that uses ``Deferred`` objects but works with any - reactor, use this function on all ``Deferred`` objects:: + If you want to write code that uses ``Deferred`` objects but works in both + of these states, use this function on all ``Deferred`` objects: + + .. code-block:: python class MySpider(Spider): ... + async def parse(self, response): - additional_request = scrapy.Request('https://example.org/price') - deferred = self.crawler.engine.download(additional_request) - additional_response = await maybe_deferred_to_future(deferred) + deferred = some_dfd_helper() + result = await maybe_deferred_to_future(deferred) """ if not is_asyncio_available(): return d diff --git a/scrapy/utils/deprecate.py b/scrapy/utils/deprecate.py index 359f819d7..4fd50fdad 100644 --- a/scrapy/utils/deprecate.py +++ b/scrapy/utils/deprecate.py @@ -43,15 +43,18 @@ def create_deprecated_class( It can be used to rename a base class in a library. For example, if we have - class OldName(SomeClass): - # ... + .. code-block:: python - and we want to rename it to NewName, we can do the following:: + class OldName(SomeClass): ... - class NewName(SomeClass): - # ... + and we want to rename it to NewName, we can do the following: - OldName = create_deprecated_class('OldName', NewName) + .. code-block:: python + + class NewName(SomeClass): ... + + + OldName = create_deprecated_class("OldName", NewName) Then, if user class inherits from OldName, warning is issued. Also, if some code uses ``issubclass(sub, OldName)`` or ``isinstance(sub(), OldName)`` diff --git a/scrapy/utils/log.py b/scrapy/utils/log.py index aa77e692a..7645b235e 100644 --- a/scrapy/utils/log.py +++ b/scrapy/utils/log.py @@ -248,8 +248,7 @@ def logformatter_adapter( ) -> tuple[int, str, dict[str, Any] | tuple[Any, ...]]: """ Helper that takes the dictionary output from the methods in LogFormatter - and adapts it into a tuple of positional arguments for logger.log calls, - handling backward compatibility as well. + and adapts it into a tuple of positional arguments for logger.log calls. """ level = logkws.get("level", logging.INFO) diff --git a/scrapy/utils/response.py b/scrapy/utils/response.py index c068d9b1e..7747a7b9b 100644 --- a/scrapy/utils/response.py +++ b/scrapy/utils/response.py @@ -88,7 +88,7 @@ def open_in_browser( def parse_details(self, response): - if "item name" not in response.body: + if "item name" not in response.text: open_in_browser(response) """ # circular imports diff --git a/scrapy/utils/signal.py b/scrapy/utils/signal.py index 919f67240..eca95e225 100644 --- a/scrapy/utils/signal.py +++ b/scrapy/utils/signal.py @@ -39,7 +39,7 @@ def send_catch_log( *arguments: TypingAny, **named: TypingAny, ) -> list[tuple[TypingAny, TypingAny]]: - """Like ``pydispatcher.robust.sendRobust()`` but it also logs errors and returns + """Like ``pydispatch.robust.sendRobust()`` but it also logs errors and returns Failures instead of exceptions. """ dont_log = named.pop("dont_log", ()) @@ -172,9 +172,8 @@ async def _send_catch_log_asyncio( Returns a coroutine that completes once all signal handlers have finished. - This function requires - :class:`~twisted.internet.asyncioreactor.AsyncioSelectorReactor` to be - installed. + This function requires an installed asyncio reactor or a running asyncio + event loop. .. versionadded:: 2.14 """ diff --git a/scrapy/utils/trackref.py b/scrapy/utils/trackref.py index 87df10a02..22f9eadd0 100644 --- a/scrapy/utils/trackref.py +++ b/scrapy/utils/trackref.py @@ -4,9 +4,7 @@ references to live object instances. If you want live objects for a particular class to be tracked, you only have to subclass from object_ref (instead of object). -About performance: This library has a minimal performance impact when enabled, -and no performance penalty at all when disabled (as object_ref becomes just an -alias to object in that case). +This library has a minimal performance impact. .. note:: PyPy uses a tracing garbage collector, so objects may remain in the ``live_refs`` longer than expected, even after they diff --git a/scrapy/utils/url.py b/scrapy/utils/url.py index 4d2bbdda2..f67853ece 100644 --- a/scrapy/utils/url.py +++ b/scrapy/utils/url.py @@ -117,8 +117,8 @@ def strip_url( - ``strip_credentials`` removes "user:password@" - ``strip_default_port`` removes ":80" (resp. ":443", ":21") from http:// (resp. https://, ftp://) URLs - - ``origin_only`` replaces path component with "/", also dropping - query and fragment components ; it also strips credentials + - ``origin_only`` replaces the path component with "/", also dropping + the query component; it also strips credentials - ``strip_fragment`` drops any #fragment component """ From a6d6a48aa600d1b4c3deb6409c17d0d7f55db312 Mon Sep 17 00:00:00 2001 From: Adrian Date: Tue, 30 Jun 2026 16:26:22 +0200 Subject: [PATCH 234/248] Keep Item fields in definition order (#7694) --- scrapy/item.py | 37 ++++++++++++++++++++++++++++++------- tests/test_feedexport.py | 2 +- tests/test_item.py | 24 ++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 8 deletions(-) diff --git a/scrapy/item.py b/scrapy/item.py index d5adc1efb..4d99ea79d 100644 --- a/scrapy/item.py +++ b/scrapy/item.py @@ -25,6 +25,25 @@ class Field(dict[str, Any]): """Container of field metadata""" +def _ordered_field_names(cls: type) -> list[str]: + """Return the names of the :class:`Field` attributes of *cls* in definition + order. + + Fields declared in base classes come first, ordered from the topmost base + to the most derived class. Within each class, fields keep their definition + order. A field redefined in a subclass keeps the position of its first + definition. + """ + names: list[str] = [] + seen: set[str] = set() + for base in reversed(cls.__mro__): + for name, value in vars(base).items(): + if isinstance(value, Field) and name not in seen: + seen.add(name) + names.append(name) + return names + + class ItemMeta(ABCMeta): """Metaclass_ of :class:`Item` that handles field definitions. @@ -39,13 +58,9 @@ class ItemMeta(ABCMeta): _class = super().__new__(mcs, "x_" + class_name, new_bases, attrs) fields = getattr(_class, "fields", {}) - new_attrs = {} - for n in dir(_class): - v = getattr(_class, n) - if isinstance(v, Field): - fields[n] = v - elif n in attrs: - new_attrs[n] = attrs[n] + for n in _ordered_field_names(_class): + fields[n] = getattr(_class, n) + new_attrs = {n: v for n, v in attrs.items() if not isinstance(v, Field)} new_attrs["fields"] = fields new_attrs["_class"] = _class @@ -80,6 +95,14 @@ class Item(MutableMapping[str, Any], object_ref, metaclass=ItemMeta): #: those populated. The keys are the field names and the values are the #: :class:`Field` objects used in the :ref:`Item declaration #: `. + #: + #: Fields are kept in definition order: fields declared in base classes + #: come first, followed by fields declared in subclasses, and a field + #: redefined in a subclass keeps the position of its first definition. + #: + #: .. versionchanged:: VERSION + #: Fields are now returned in definition order rather than alphabetical + #: order. fields: dict[str, Field] def __init__(self, *args: Any, **kwargs: Any): diff --git a/tests/test_feedexport.py b/tests/test_feedexport.py index d7ea60cc8..7d751f188 100644 --- a/tests/test_feedexport.py +++ b/tests/test_feedexport.py @@ -746,7 +746,7 @@ class TestFeedExport(TestFeedExportBase): ] formats = { - "csv": b"baz,egg,foo\r\n,spam1,bar1\r\n", + "csv": b"foo,egg,baz\r\nbar1,spam1,\r\n", "json": b'[\n{"hello": "world2", "foo": "bar2"}\n]', "jsonlines": ( b'{"foo": "bar1", "egg": "spam1"}\n{"hello": "world2", "foo": "bar2"}\n' diff --git a/tests/test_item.py b/tests/test_item.py index 34b054e12..7b4c2e918 100644 --- a/tests/test_item.py +++ b/tests/test_item.py @@ -142,6 +142,30 @@ class TestItem: self.assertSortedEqual(list(item.keys()), ["new"]) self.assertSortedEqual(list(item.values()), ["New"]) + def test_fields_order(self): + class TestItem(Item): + name = Field() + keys = Field() + values = Field() + + assert list(TestItem.fields) == ["name", "keys", "values"] + + def test_fields_order_inheritance(self): + class ParentItem(Item): + name = Field() + keys = Field() + values = Field() + + class TestItem(ParentItem): + extra = Field() + keys = Field(serializer=str) + + # Inherited fields come first, in their definition order, followed by + # the fields newly defined in the subclass. A redefined field keeps the + # position of its first definition while taking the new metadata. + assert list(TestItem.fields) == ["name", "keys", "values", "extra"] + assert TestItem.fields["keys"] == {"serializer": str} + def test_metaclass_inheritance(self): class ParentItem(Item): name = Field() From fc5216f15611e40d795f5ab566acff8f9ca0af1e Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Wed, 1 Jul 2026 11:50:32 +0500 Subject: [PATCH 235/248] Clarify/cleanup Selector.type (#7704) --- scrapy/selector/unified.py | 47 ++++++++++++++------------------------ 1 file changed, 17 insertions(+), 30 deletions(-) diff --git a/scrapy/selector/unified.py b/scrapy/selector/unified.py index 99b22aca9..f6334c32c 100644 --- a/scrapy/selector/unified.py +++ b/scrapy/selector/unified.py @@ -1,10 +1,6 @@ -""" -XPath selectors based on lxml -""" - from __future__ import annotations -from typing import Any +from typing import Any, Literal from parsel import Selector as _ParselSelector @@ -18,13 +14,10 @@ __all__ = ["Selector", "SelectorList"] _NOT_SET = object() -def _st(response: TextResponse | None, st: str | None) -> str: - if st is None: - return "xml" if isinstance(response, XmlResponse) else "html" - return st +SelectorType = Literal["html", "xml", "json", "text"] -def _response_from_text(text: str | bytes, st: str | None) -> TextResponse: +def _response_from_text(text: str | bytes, st: SelectorType | None) -> TextResponse: rt: type[TextResponse] = XmlResponse if st == "xml" else HtmlResponse return rt(url="about:blank", encoding="utf-8", body=to_bytes(text, "utf-8")) @@ -49,23 +42,16 @@ class Selector(_ParselSelector, object_ref): ``response`` isn't available. Using ``text`` and ``response`` together is undefined behavior. - ``type`` defines the selector type, it can be ``"html"``, ``"xml"``, ``"json"`` - or ``None`` (default). + ``type`` defines the selector type, it can be ``"html"``, ``"xml"``, + ``"json"``, ``"text"`` or ``None`` (default). It's passed to + :class:`parsel.Selector` and its meaning is defined there. However, when + ``type`` is ``None``, it is set to ``"xml"`` for an + :class:`~scrapy.http.XmlResponse` and to ``"html"`` otherwise before + passing it to :class:`parsel.Selector`. - If ``type`` is ``None``, the selector automatically chooses the best type - based on ``response`` type (see below), or defaults to ``"html"`` in case it - is used together with ``text``. - - If ``type`` is ``None`` and a ``response`` is passed, the selector type is - inferred from the response type as follows: - - * ``"html"`` for :class:`~scrapy.http.HtmlResponse` type - * ``"xml"`` for :class:`~scrapy.http.XmlResponse` type - * ``"json"`` for :class:`~scrapy.http.TextResponse` type - * ``"html"`` for anything else - - Otherwise, if ``type`` is set, the selector type will be forced and no - detection will occur. + .. note:: JSON selector support requires ``parsel`` 1.8.0 or higher. With + older versions setting ``type`` to ``"json"`` or ``"text"`` is not + supported. """ __slots__ = ["response"] @@ -75,7 +61,7 @@ class Selector(_ParselSelector, object_ref): self, response: TextResponse | None = None, text: str | None = None, - type: str | None = None, # noqa: A002 + type: SelectorType | None = None, # noqa: A002 root: Any | None = _NOT_SET, **kwargs: Any, ): @@ -84,10 +70,11 @@ class Selector(_ParselSelector, object_ref): f"{self.__class__.__name__}.__init__() received both response and text" ) - st = _st(response, type) + if type is None: + type = "xml" if isinstance(response, XmlResponse) else "html" # noqa: A001 if text is not None: - response = _response_from_text(text, st) + response = _response_from_text(text, type) if response is not None: text = response.text @@ -98,4 +85,4 @@ class Selector(_ParselSelector, object_ref): if root is not _NOT_SET: kwargs["root"] = root - super().__init__(text=text, type=st, **kwargs) + super().__init__(text=text, type=type, **kwargs) From 361f689df785959a59cf939b9efaefc72079f037 Mon Sep 17 00:00:00 2001 From: Adrian Date: Wed, 1 Jul 2026 08:53:07 +0200 Subject: [PATCH 236/248] Improve test coverage for crawler.py (#7682) * Improve test coverage for crawler.py * Silence mypy warnings * Improve test coverage for crawler.py --- ...yncio_enabled_reactor_same_loop_default.py | 31 ++++ .../dns_resolver_deprecated.py | 31 ++++ .../reactorless_sleeping.py | 2 +- tests/AsyncCrawlerProcess/sleeping.py | 2 +- .../CrawlerProcess/dns_resolver_deprecated.py | 31 ++++ tests/CrawlerProcess/sleeping.py | 2 +- tests/test_crawler.py | 163 +++++++++++++++++- tests/test_crawler_subprocess.py | 30 +++- 8 files changed, 286 insertions(+), 6 deletions(-) create mode 100644 tests/AsyncCrawlerProcess/asyncio_enabled_reactor_same_loop_default.py create mode 100644 tests/AsyncCrawlerProcess/dns_resolver_deprecated.py create mode 100644 tests/CrawlerProcess/dns_resolver_deprecated.py diff --git a/tests/AsyncCrawlerProcess/asyncio_enabled_reactor_same_loop_default.py b/tests/AsyncCrawlerProcess/asyncio_enabled_reactor_same_loop_default.py new file mode 100644 index 000000000..c519e123b --- /dev/null +++ b/tests/AsyncCrawlerProcess/asyncio_enabled_reactor_same_loop_default.py @@ -0,0 +1,31 @@ +import asyncio +import sys + +from twisted.internet import asyncioreactor + +import scrapy +from scrapy.crawler import AsyncCrawlerProcess + +if sys.platform == "win32": + asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) +loop = asyncio.SelectorEventLoop() +asyncio.set_event_loop(loop) +asyncioreactor.install(loop) + + +class NoRequestsSpider(scrapy.Spider): + name = "no_request" + + async def start(self): + return + yield + + +process = AsyncCrawlerProcess( + settings={ + "TWISTED_REACTOR": "twisted.internet.asyncioreactor.AsyncioSelectorReactor", + "ASYNCIO_EVENT_LOOP": "asyncio.SelectorEventLoop", + } +) +process.crawl(NoRequestsSpider) +process.start() diff --git a/tests/AsyncCrawlerProcess/dns_resolver_deprecated.py b/tests/AsyncCrawlerProcess/dns_resolver_deprecated.py new file mode 100644 index 000000000..8c8df96bb --- /dev/null +++ b/tests/AsyncCrawlerProcess/dns_resolver_deprecated.py @@ -0,0 +1,31 @@ +import sys + +import scrapy +from scrapy.crawler import AsyncCrawlerProcess +from scrapy.settings import Settings + + +class NoRequestsSpider(scrapy.Spider): + name = "no_request" + + async def start(self): + return + yield + + +settings = Settings() +# The deprecated DNS_RESOLVER setting, set above its default priority so that +# AsyncCrawlerProcess._setup_reactor() emits the deprecation warning. +settings.set("DNS_RESOLVER", "scrapy.resolver.CachingThreadedResolver", priority=10) +if len(sys.argv) > 1 and sys.argv[1] == "twisted-wins": + # TWISTED_DNS_RESOLVER at a higher priority takes precedence over the + # deprecated DNS_RESOLVER setting. + settings.set( + "TWISTED_DNS_RESOLVER", + "scrapy.resolver.CachingThreadedResolver", + priority=20, + ) + +process = AsyncCrawlerProcess(settings) +process.crawl(NoRequestsSpider) +process.start() diff --git a/tests/AsyncCrawlerProcess/reactorless_sleeping.py b/tests/AsyncCrawlerProcess/reactorless_sleeping.py index 12101d221..0d11a0996 100644 --- a/tests/AsyncCrawlerProcess/reactorless_sleeping.py +++ b/tests/AsyncCrawlerProcess/reactorless_sleeping.py @@ -17,4 +17,4 @@ class SleepingSpider(scrapy.Spider): process = AsyncCrawlerProcess(settings={"TWISTED_REACTOR_ENABLED": False}) process.crawl(SleepingSpider) -process.start() +process.start(stop_after_crawl="--no-stop" not in sys.argv) diff --git a/tests/AsyncCrawlerProcess/sleeping.py b/tests/AsyncCrawlerProcess/sleeping.py index 88caf5032..dad6a3f20 100644 --- a/tests/AsyncCrawlerProcess/sleeping.py +++ b/tests/AsyncCrawlerProcess/sleeping.py @@ -17,4 +17,4 @@ class SleepingSpider(scrapy.Spider): process = AsyncCrawlerProcess(settings={}) process.crawl(SleepingSpider) -process.start() +process.start(stop_after_crawl="--no-stop" not in sys.argv) diff --git a/tests/CrawlerProcess/dns_resolver_deprecated.py b/tests/CrawlerProcess/dns_resolver_deprecated.py new file mode 100644 index 000000000..b8cd24325 --- /dev/null +++ b/tests/CrawlerProcess/dns_resolver_deprecated.py @@ -0,0 +1,31 @@ +import sys + +import scrapy +from scrapy.crawler import CrawlerProcess +from scrapy.settings import Settings + + +class NoRequestsSpider(scrapy.Spider): + name = "no_request" + + async def start(self): + return + yield + + +settings = Settings() +# The deprecated DNS_RESOLVER setting, set above its default priority so that +# CrawlerProcess._setup_reactor() emits the deprecation warning. +settings.set("DNS_RESOLVER", "scrapy.resolver.CachingThreadedResolver", priority=10) +if len(sys.argv) > 1 and sys.argv[1] == "twisted-wins": + # TWISTED_DNS_RESOLVER at a higher priority takes precedence over the + # deprecated DNS_RESOLVER setting. + settings.set( + "TWISTED_DNS_RESOLVER", + "scrapy.resolver.CachingThreadedResolver", + priority=20, + ) + +process = CrawlerProcess(settings) +process.crawl(NoRequestsSpider) +process.start() diff --git a/tests/CrawlerProcess/sleeping.py b/tests/CrawlerProcess/sleeping.py index cb8f869e1..a577b1909 100644 --- a/tests/CrawlerProcess/sleeping.py +++ b/tests/CrawlerProcess/sleeping.py @@ -23,4 +23,4 @@ class SleepingSpider(scrapy.Spider): process = CrawlerProcess(settings={}) process.crawl(SleepingSpider) -process.start() +process.start(stop_after_crawl="--no-stop" not in sys.argv) diff --git a/tests/test_crawler.py b/tests/test_crawler.py index 0cddfd0ed..adac32df1 100644 --- a/tests/test_crawler.py +++ b/tests/test_crawler.py @@ -3,8 +3,11 @@ from __future__ import annotations import asyncio import logging import re +import signal +import threading from pathlib import Path -from typing import Any, ClassVar +from typing import TYPE_CHECKING, Any, ClassVar +from unittest.mock import MagicMock import pytest from zope.interface.exceptions import MultipleInvalid @@ -32,6 +35,9 @@ from scrapy.utils.spider import DefaultSpider from scrapy.utils.test import get_crawler, get_reactor_settings from tests.utils.decorators import coroutine_test +if TYPE_CHECKING: + from collections.abc import Callable + BASE_SETTINGS: dict[str, Any] = {} @@ -651,6 +657,145 @@ class TestAsyncCrawlerProcess(TestBaseCrawler): self.assertOptionIsDefault(runner.settings, "RETRY_ENABLED") +class TestAsyncCrawlerProcessReactorlessHelpers: + """Unit tests for the reactorless shutdown helpers of AsyncCrawlerProcess. + + These cover defensive branches that guard against shutdown races and that + are not reachable through a full process run. + """ + + @staticmethod + def _bare_process( + monkeypatch: pytest.MonkeyPatch, + ) -> tuple[AsyncCrawlerProcess, list[Any]]: + # AsyncCrawlerProcess.__init__ has global side effects (it installs a + # reactor import hook and an asyncio event loop), so build a bare + # instance and set only the attributes these helpers read. The shutdown + # handlers installed by these helpers are recorded for assertions + # instead of touching the real process-wide signal handlers. + installed_handlers: list[Any] = [] + monkeypatch.setattr( + "scrapy.crawler.install_shutdown_handlers", + lambda handler, *args, **kwargs: installed_handlers.append(handler), + ) + return AsyncCrawlerProcess.__new__(AsyncCrawlerProcess), installed_handlers + + @staticmethod + def _run_in_thread(target: Callable[[], None]) -> None: + # Run target in a dedicated thread so its event loop is not nested + # inside the event loop that may already be running the test session. + thread = threading.Thread(target=target) + thread.start() + thread.join() + + def test_signal_shutdown_reactorless_without_loop( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + process, installed_handlers = self._bare_process(monkeypatch) + process._reactorless_loop = None + # No loop to schedule the shutdown task on, so it returns early, but it + # must still escalate the handler so a second signal forces a kill. + process._signal_shutdown_reactorless(signal.SIGINT, None) + assert installed_handlers == [process._signal_kill_reactorless] + + def test_signal_kill_reactorless_without_loop( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + process, installed_handlers = self._bare_process(monkeypatch) + process._reactorless_loop = None + process._reactorless_main_task = None + # No loop to cancel the main task on, so it returns early, but it must + # still ignore any further signals. + process._signal_kill_reactorless(signal.SIGINT, None) + assert installed_handlers == [signal.SIG_IGN] + + def test_signal_kill_reactorless_without_main_task( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + process, installed_handlers = self._bare_process(monkeypatch) + loop = MagicMock() + process._reactorless_loop = loop + process._reactorless_main_task = None + # No main task to cancel, so nothing is scheduled on the loop. + process._signal_kill_reactorless(signal.SIGINT, None) + assert installed_handlers == [signal.SIG_IGN] + loop.call_soon_threadsafe.assert_not_called() + + def test_shutdown_graceful_reactorless_main_task_already_done( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + process, _ = self._bare_process(monkeypatch) + process._stop_after_crawl = False + + async def noop() -> None: + return None + + monkeypatch.setattr(process, "stop", noop) + monkeypatch.setattr(process, "join", noop) + + def run() -> None: + loop = asyncio.new_event_loop() + try: + main_task: asyncio.Future[None] = loop.create_future() + main_task.set_result(None) + process._reactorless_main_task = main_task + # The main task is already done, so it is not cancelled. + loop.run_until_complete(process._shutdown_graceful_reactorless()) + assert not main_task.cancelled() + finally: + loop.close() + + self._run_in_thread(run) + + def test_create_shutdown_task_closed_loop( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + process, _ = self._bare_process(monkeypatch) + loop = asyncio.new_event_loop() + loop.close() + process._reactorless_loop = loop + process._stop_after_crawl = True + # create_task() raises RuntimeError on a closed loop; the coroutine + # must be closed instead of leaking. + process._create_shutdown_task() + + def test_cancel_all_tasks_logs_task_exception(self) -> None: + contexts: list[dict[str, Any]] = [] + task_was_cancelled: list[bool] = [] + + def run() -> None: + loop = asyncio.new_event_loop() + loop.set_exception_handler(lambda _loop, context: contexts.append(context)) + + async def fail_on_cancel() -> None: + try: + await asyncio.sleep(10) + except asyncio.CancelledError: + raise RuntimeError("boom") + + try: + task = loop.create_task(fail_on_cancel()) + # Let the task start and suspend on the sleep so the + # cancellation is raised inside its body and turned into a + # RuntimeError rather than cancelling the task cleanly. + loop.run_until_complete(asyncio.sleep(0)) + AsyncCrawlerProcess._cancel_all_tasks(loop) + task_was_cancelled.append(task.cancelled()) + finally: + loop.close() + + self._run_in_thread(run) + + # The task raised instead of being cancelled, so its exception is + # reported to the loop exception handler. + assert task_was_cancelled == [False] + assert any( + context.get("message") + == "unhandled exception during AsyncCrawlerProcess shutdown" + for context in contexts + ) + + @pytest.mark.parametrize("runner_cls", [AsyncCrawlerRunner, CrawlerRunner]) def test_runner_settings_applied_to_crawler_instance( runner_cls: type[CrawlerRunnerBase], @@ -687,6 +832,22 @@ def test_create_crawler_instance_consistent_with_spider_class() -> None: assert pre_built.settings["FOO"] == "runner" +@pytest.mark.parametrize("runner_cls", [AsyncCrawlerRunner, CrawlerRunner]) +def test_create_crawler_rejects_spider_object( + runner_cls: type[CrawlerRunnerBase], +) -> None: + runner = runner_cls() + with pytest.raises(ValueError, match="cannot be a spider object"): + runner.create_crawler(DefaultSpider()) # type: ignore[arg-type] + + +@pytest.mark.parametrize("runner_cls", [AsyncCrawlerRunner, CrawlerRunner]) +def test_crawl_rejects_spider_object(runner_cls: type[CrawlerRunnerBase]) -> None: + runner = runner_cls() + with pytest.raises(ValueError, match="cannot be a spider object"): + runner.crawl(DefaultSpider()) # type: ignore[arg-type] + + class ExceptionSpider(scrapy.Spider): name = "exception" diff --git a/tests/test_crawler_subprocess.py b/tests/test_crawler_subprocess.py index e3f9ac161..018a2b31b 100644 --- a/tests/test_crawler_subprocess.py +++ b/tests/test_crawler_subprocess.py @@ -126,6 +126,16 @@ class TestCrawlerProcessSubprocessBase(ScriptRunnerMixin): assert "TimeoutError" not in log assert "scrapy.exceptions.CannotResolveHostError" not in log + def test_dns_resolver_deprecated(self) -> None: + log = self.run_script("dns_resolver_deprecated.py") + assert "Spider closed (finished)" in log + assert "The DNS_RESOLVER setting is deprecated" in log + + def test_dns_resolver_deprecated_twisted_dns_resolver(self) -> None: + log = self.run_script("dns_resolver_deprecated.py", "twisted-wins") + assert "Spider closed (finished)" in log + assert "The DNS_RESOLVER setting is deprecated" in log + def test_twisted_reactor_asyncio(self) -> None: log = self.run_script("twisted_reactor_asyncio.py") assert "Spider closed (finished)" in log @@ -205,9 +215,11 @@ class TestCrawlerProcessSubprocessBase(ScriptRunnerMixin): assert "Spider closed (finished)" in log assert "The value of FOO is 42" in log - def _test_shutdown_graceful(self, script: str = "sleeping.py") -> None: + def _test_shutdown_graceful( + self, script: str = "sleeping.py", *extra_args: str + ) -> None: sig = signal.SIGINT if sys.platform != "win32" else signal.SIGBREAK # type: ignore[attr-defined] - args = self.get_script_args(script, "3") + args = self.get_script_args(script, "3", *extra_args) p = PopenSpawn(args, timeout=5, env=get_script_run_env()) p.expect_exact("Spider opened") p.expect_exact("Crawled (200)") @@ -245,6 +257,9 @@ class TestCrawlerProcessSubprocessBase(ScriptRunnerMixin): async def test_shutdown_forced(self) -> None: await self._test_shutdown_forced() + def test_shutdown_graceful_no_stop(self) -> None: + self._test_shutdown_graceful("sleeping.py", "--no-stop") + class TestCrawlerProcessSubprocess(TestCrawlerProcessSubprocessBase): @property @@ -429,6 +444,17 @@ class TestAsyncCrawlerProcessSubprocess(TestCrawlerProcessSubprocessBase): async def test_shutdown_forced(self) -> None: await self._test_shutdown_forced("reactorless_sleeping.py") + def test_shutdown_graceful_reactorless_no_stop(self) -> None: + self._test_shutdown_graceful("reactorless_sleeping.py", "--no-stop") + + def test_asyncio_enabled_reactor_same_loop_default(self) -> None: + log = self.run_script("asyncio_enabled_reactor_same_loop_default.py") + assert "Spider closed (finished)" in log + assert ( + "Using reactor: twisted.internet.asyncioreactor.AsyncioSelectorReactor" + in log + ) + class TestCrawlerRunnerSubprocessBase(ScriptRunnerMixin): """Common tests between CrawlerRunner and AsyncCrawlerRunner, From 870803b7fb1ed56c296eb6b51d0212f0460dfdda Mon Sep 17 00:00:00 2001 From: Fat-Coder-CN Date: Wed, 1 Jul 2026 15:16:01 +0800 Subject: [PATCH 237/248] fix-utf16-response-test-on-big-endian-systems (#7508) --- tests/test_http_response_text.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_http_response_text.py b/tests/test_http_response_text.py index 507fd1864..5ef89fe4a 100644 --- a/tests/test_http_response_text.py +++ b/tests/test_http_response_text.py @@ -163,12 +163,13 @@ class TestTextResponse(TestResponse): def test_utf16(self): """Test utf-16 because UnicodeDammit is known to have problems with""" + body = b"\xff\xfeh\x00i\x00" r = self.response_class( "http://www.example.com", - body=b"\xff\xfeh\x00i\x00", + body=body, encoding="utf-16", ) - self._assert_response_values(r, "utf-16", "hi") + self._assert_response_values(r, "utf-16", body) def test_invalid_utf8_encoded_body_with_valid_utf8_BOM(self): r6 = self.response_class( From dd10cb8e9a982fe3d311078d6e1207596e272717 Mon Sep 17 00:00:00 2001 From: Adrian Date: Sun, 5 Jul 2026 13:46:22 +0200 Subject: [PATCH 238/248] LxmlLinkExtractor: add deny_attrs and deny_tags (#7679) --- docs/topics/link-extractors.rst | 103 +--------------------- scrapy/linkextractors/lxmlhtml.py | 142 +++++++++++++++++++++++++++++- tests/test_linkextractors.py | 53 +++++++++++ 3 files changed, 194 insertions(+), 104 deletions(-) diff --git a/docs/topics/link-extractors.rst b/docs/topics/link-extractors.rst index 613e175da..3fc896507 100644 --- a/docs/topics/link-extractors.rst +++ b/docs/topics/link-extractors.rst @@ -47,108 +47,7 @@ LxmlLinkExtractor :synopsis: lxml's HTMLParser-based link extractors -.. class:: LxmlLinkExtractor(allow=(), deny=(), allow_domains=(), deny_domains=(), deny_extensions=None, restrict_xpaths=(), restrict_css=(), tags=('a', 'area'), attrs=('href',), canonicalize=False, unique=True, process_value=None, strip=True) - - LxmlLinkExtractor is the recommended link extractor with handy filtering - options. It is implemented using lxml's robust HTMLParser. - - :param allow: a single regular expression (or list of regular expressions) - that the (absolute) urls must match in order to be extracted. If not - given (or empty), it will match all links. - :type allow: str or list - - :param deny: a single regular expression (or list of regular expressions) - that the (absolute) urls must match in order to be excluded (i.e. not - extracted). It has precedence over the ``allow`` parameter. If not - given (or empty) it won't exclude any links. - :type deny: str or list - - :param allow_domains: a single value or a list of string containing - domains which will be considered for extracting the links - :type allow_domains: str or list - - :param deny_domains: a single value or a list of strings containing - domains which won't be considered for extracting the links - :type deny_domains: str or list - - :param deny_extensions: a single value or list of strings containing - extensions that should be ignored when extracting links. - If not given, it will default to - :data:`scrapy.linkextractors.IGNORED_EXTENSIONS`. - - :type deny_extensions: list - - :param restrict_xpaths: is an XPath (or list of XPath's) which defines - regions inside the response where links should be extracted from. - If given, only the text selected by those XPath will be scanned for - links. - :type restrict_xpaths: str or list - - :param restrict_css: a CSS selector (or list of selectors) which defines - regions inside the response where links should be extracted from. - Has the same behaviour as ``restrict_xpaths``. - :type restrict_css: str or list - - :param restrict_text: a single regular expression (or list of regular expressions) - that the link's text must match in order to be extracted. If not - given (or empty), it will match all links. If a list of regular expressions is - given, the link will be extracted if it matches at least one. - :type restrict_text: str or list - - :param tags: a tag or a list of tags to consider when extracting links. - Defaults to ``('a', 'area')``. - :type tags: str or list - - :param attrs: an attribute or list of attributes which should be considered when looking - for links to extract (only for those tags specified in the ``tags`` - parameter). Defaults to ``('href',)`` - :type attrs: list - - :param canonicalize: canonicalize each extracted url (using - w3lib.url.canonicalize_url). Defaults to ``False``. - Note that canonicalize_url is meant for duplicate checking; - it can change the URL visible at server side, so the response can be - different for requests with canonicalized and raw URLs. If you're - using LinkExtractor to follow links it is more robust to - keep the default ``canonicalize=False``. - :type canonicalize: bool - - :param unique: whether duplicate filtering should be applied to extracted - links. - :type unique: bool - - :param process_value: a function which receives each value extracted from - the tag and attributes scanned and can modify the value and return a - new one, or return ``None`` to ignore the link altogether. If not - given, ``process_value`` defaults to ``lambda x: x``. - - .. highlight:: html - - For example, to extract links from this code:: - - Link text - - .. highlight:: python - - You can use the following function in ``process_value``: - - .. code-block:: python - - def process_value(value): - m = re.search(r"javascript:goToPage\('(.*?)'", value) - if m: - return m.group(1) - - :type process_value: collections.abc.Callable - - :param strip: whether to strip whitespaces from extracted attributes. - According to HTML5 standard, leading and trailing whitespaces - must be stripped from ``href`` attributes of ````, ```` - and many other elements, ``src`` attribute of ````, ``