From 9d92d16510b8c82d529abf1662bce3e16ee293ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Tue, 27 May 2025 08:32:24 +0200 Subject: [PATCH 001/459] Prioritize other requests over start requests --- scrapy/core/scheduler.py | 4 ++-- scrapy/pqueues.py | 30 +++++++++++++++++------------- tests/test_engine_loop.py | 20 ++++++++++---------- 3 files changed, 29 insertions(+), 25 deletions(-) diff --git a/scrapy/core/scheduler.py b/scrapy/core/scheduler.py index 57d27b7cf..9ac447289 100644 --- a/scrapy/core/scheduler.py +++ b/scrapy/core/scheduler.py @@ -173,8 +173,8 @@ class Scheduler(BaseScheduler): :ref:`Start requests ` are sent in the order they are yielded from :meth:`~scrapy.Spider.start`, and given the same - :attr:`~scrapy.http.Request.priority`, start requests take precedence over - other requests. + :attr:`~scrapy.http.Request.priority`, other requests take precedence over + start requests. You can set :setting:`SCHEDULER_START_MEMORY_QUEUE` and :setting:`SCHEDULER_START_DISK_QUEUE` to ``None`` to handle start requests diff --git a/scrapy/pqueues.py b/scrapy/pqueues.py index e6c6b8bf1..34b235d83 100644 --- a/scrapy/pqueues.py +++ b/scrapy/pqueues.py @@ -160,28 +160,32 @@ class ScrapyPriorityQueue: def pop(self) -> Request | None: while self.curprio is not None: - if self._start_queues: - try: - q = self._start_queues[self.curprio] - except KeyError: - pass - else: - m = q.pop() - if not q: - del self._start_queues[self.curprio] - q.close() - return m try: q = self.queues[self.curprio] except KeyError: - self._update_curprio() + pass else: m = q.pop() if not q: del self.queues[self.curprio] q.close() - self._update_curprio() + if not self._start_queues: + self._update_curprio() return m + if self._start_queues: + try: + q = self._start_queues[self.curprio] + except KeyError: + self._update_curprio() + else: + m = q.pop() + if not q: + del self._start_queues[self.curprio] + q.close() + self._update_curprio() + return m + else: + self._update_curprio() return None def _update_curprio(self) -> None: diff --git a/tests/test_engine_loop.py b/tests/test_engine_loop.py index 90af10f0e..c7dbc82d4 100644 --- a/tests/test_engine_loop.py +++ b/tests/test_engine_loop.py @@ -189,9 +189,9 @@ class RequestSendOrderTestCase(TestCase): @deferred_f_from_coro_f async def test_default(self): - """By default, start requests take priority over callback requests and + """By default, callback requests take priority over start requests and are sent in order. Priority matters, but given the same priority, a - start request takes precedence.""" + callback request takes precedence.""" nums = [1, 2, 3, 4, 5, 6] response_seconds = 0 download_slots = 1 @@ -207,13 +207,13 @@ class RequestSendOrderTestCase(TestCase): yield _request(1) for request in ( - _request(4, priority=1), - _request(6), + _request(2, priority=1), + _request(5), ): spider.crawler.engine._slot.scheduler.enqueue_request(request) - yield _request(5) - yield _request(2, priority=1) + yield _request(6) yield _request(3, priority=1) + yield _request(4, priority=1) def parse(spider, response): return @@ -249,13 +249,13 @@ class RequestSendOrderTestCase(TestCase): yield _request(1) for request in ( - _request(4, priority=1), - _request(6), + _request(2, priority=1), + _request(5), ): spider.crawler.engine._slot.scheduler.enqueue_request(request) - yield _request(5) + yield _request(6) + yield _request(4, priority=1) yield _request(3, priority=1) - yield _request(2, priority=1) def parse(spider, response): return From 05529f3017a6327ba4553c2af422f2e5f02c7d43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Tue, 27 May 2025 08:44:18 +0200 Subject: [PATCH 002/459] Release notes for Scrapy 2.13.1 --- docs/news.rst | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/docs/news.rst b/docs/news.rst index cf1c35893..eb5370b6e 100644 --- a/docs/news.rst +++ b/docs/news.rst @@ -3,6 +3,22 @@ Release notes ============= +.. _release-2.13.1: + +Scrapy 2.13.1 (unreleased) +-------------------------- + +- Give callback requests precedence over start requests when priority values + are the same. + + This makes changes from 2.13.0 to start request handling more intuitive and + backward compatible. For scenarios where all requests have the same + priorities, in 2.13.0 all start requests were sent before the first + callback request. In 2.13.1, same as in 2.12 and lower, start requests are + only sent when there are not enough pending callback requests to reach + concurrency limits. + + .. _release-2.13.0: Scrapy 2.13.0 (2025-05-08) From f28be27423d720a59dcd7194df26c935fcc7e416 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Tue, 13 May 2025 12:22:28 +0400 Subject: [PATCH 003/459] Add a deepwiki badge, update other badges. (#6793) --- README.rst | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/README.rst b/README.rst index cf7c6043c..29488d825 100644 --- a/README.rst +++ b/README.rst @@ -17,19 +17,14 @@ Scrapy :target: https://github.com/scrapy/scrapy/actions?query=workflow%3AUbuntu :alt: Ubuntu -.. .. image:: https://github.com/scrapy/scrapy/workflows/macOS/badge.svg - .. :target: https://github.com/scrapy/scrapy/actions?query=workflow%3AmacOS - .. :alt: macOS - +.. image:: https://github.com/scrapy/scrapy/workflows/macOS/badge.svg + :target: https://github.com/scrapy/scrapy/actions?query=workflow%3AmacOS + :alt: macOS .. image:: https://github.com/scrapy/scrapy/workflows/Windows/badge.svg :target: https://github.com/scrapy/scrapy/actions?query=workflow%3AWindows :alt: Windows -.. image:: https://img.shields.io/badge/wheel-yes-brightgreen.svg - :target: https://pypi.org/pypi/Scrapy - :alt: Wheel Status - .. image:: https://img.shields.io/codecov/c/github/scrapy/scrapy/master.svg :target: https://codecov.io/github/scrapy/scrapy?branch=master :alt: Coverage report @@ -38,6 +33,10 @@ Scrapy :target: https://anaconda.org/conda-forge/scrapy :alt: Conda Version +.. image:: https://deepwiki.com/badge.svg + :target: https://deepwiki.com/scrapy/scrapy + :alt: Ask DeepWiki + Overview ======== From 43087fe1df5b3209bcc65dabd1831067d1a29711 Mon Sep 17 00:00:00 2001 From: Keval Sakhiya <37344767+kevalsakhiya@users.noreply.github.com> Date: Tue, 20 May 2025 11:21:31 +0530 Subject: [PATCH 004/459] Fix typo in documentation and code: 'needs_backoff' -> 'needs_backout' (#6815) Corrected the typo in the code and documentation where 'needs_backoff' was incorrectly used instead of 'needs_backout'. --- docs/topics/spiders.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/topics/spiders.rst b/docs/topics/spiders.rst index 891c4da05..8240d5d4b 100644 --- a/docs/topics/spiders.rst +++ b/docs/topics/spiders.rst @@ -388,7 +388,7 @@ its iteration whenever there are scheduled requests: async def start(self): async for item_or_request in super().start(): - if self.crawler.engine.needs_backoff(): + if self.crawler.engine.needs_backout(): await self.crawler.signals.wait_for(signals.scheduler_empty) yield item_or_request From 06dec081254e19950eb00fcb6979fe8b7c342ee8 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Fri, 23 May 2025 14:06:33 +0500 Subject: [PATCH 005/459] Fix a wrong versionadded usage. (#6822) --- docs/topics/coroutines.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/topics/coroutines.rst b/docs/topics/coroutines.rst index 00812ed7f..2c0df5e0f 100644 --- a/docs/topics/coroutines.rst +++ b/docs/topics/coroutines.rst @@ -21,7 +21,7 @@ hence use coroutine syntax (e.g. ``await``, ``async for``, ``async with``): - The :meth:`~scrapy.spiders.Spider.start` spider method, which *must* be defined as an :term:`asynchronous generator`. - .. versionadded: 2.13 + .. versionadded:: 2.13 - :class:`~scrapy.Request` callbacks. From 597320856776c2b6e44fc3a45a97d6524c64d464 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Tue, 27 May 2025 00:31:28 +0500 Subject: [PATCH 006/459] Make the release notes work better on PyPI. (#6826) --- pyproject.toml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 85fba0f92..47707e061 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,8 +58,7 @@ Homepage = "https://scrapy.org/" Documentation = "https://docs.scrapy.org/" Source = "https://github.com/scrapy/scrapy" Tracker = "https://github.com/scrapy/scrapy/issues" -Changelog = "https://github.com/scrapy/scrapy/commits/master/" -releasenotes = "https://docs.scrapy.org/en/latest/news.html" +"Release notes" = "https://docs.scrapy.org/en/latest/news.html" [project.scripts] scrapy = "scrapy.cmdline:execute" From e3f82afaf1ab12ac8f5915dfb0b926391bc81f52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Tue, 27 May 2025 10:01:00 +0200 Subject: [PATCH 007/459] Add a test for ScrapyPriorityQueue pop order --- tests/test_pqueues.py | 54 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/tests/test_pqueues.py b/tests/test_pqueues.py index d5c710ed2..b65f1b7e7 100644 --- a/tests/test_pqueues.py +++ b/tests/test_pqueues.py @@ -7,6 +7,7 @@ from scrapy.http.request import Request from scrapy.pqueues import DownloaderAwarePriorityQueue, ScrapyPriorityQueue 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 @@ -155,3 +156,56 @@ class TestDownloaderAwarePriorityQueue: assert self.queue.peek().url == req3.url assert self.queue.pop().url == req3.url assert self.queue.peek() is None + + +@pytest.mark.parametrize( + ("input", "output"), + [ + # By default, start requests are FIFO, other requests are LIFO. + ([{}, {}], [2, 1]), + ([{"start": True}, {"start": True}], [1, 2]), + # Priority matters. + ([{"priority": 1}, {"start": True}], [1, 2]), + ([{}, {"start": True, "priority": 1}], [2, 1]), + # For the same priority, start requests pop last. + ([{}, {"start": True}], [1, 2]), + ([{"start": True}, {}], [2, 1]), + ], +) +def test_pop_order(input, output): + def make_url(index): + return f"https://toscrape.com/{index}" + + def make_request(index, data): + meta = {} + if data.get("start", False): + meta["is_start_request"] = True + return Request( + url=make_url(index), + priority=data.get("priority", 0), + meta=meta, + ) + + input_requests = [ + make_request(index, data) for index, data in enumerate(input, start=1) + ] + expected_output_urls = [make_url(index) for index in output] + + crawler = get_crawler(Spider) + settings = crawler.settings + queue = build_from_crawler( + ScrapyPriorityQueue, + crawler, + downstream_queue_cls=load_object(settings["SCHEDULER_MEMORY_QUEUE"]), + key="", + start_queue_cls=load_object(settings["SCHEDULER_START_MEMORY_QUEUE"]), + ) + + for request in input_requests: + queue.push(request) + + actual_output_urls = [] + while request := queue.pop(): + actual_output_urls.append(request.url) + + assert actual_output_urls == expected_output_urls From 0cdb971f636fc75ed08ed13642adfa04fcc235ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Wed, 28 May 2025 10:06:04 +0200 Subject: [PATCH 008/459] Complete the release notes --- docs/news.rst | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/docs/news.rst b/docs/news.rst index eb5370b6e..76e195feb 100644 --- a/docs/news.rst +++ b/docs/news.rst @@ -18,6 +18,21 @@ Scrapy 2.13.1 (unreleased) only sent when there are not enough pending callback requests to reach concurrency limits. + (:issue:`6828`) + +- Added a deepwiki_ badge to the README. (:issue:`6793`) + + .. _deepwiki: https://deepwiki.com/scrapy/scrapy + +- Fixed a typo in the code example of :ref:`start-requests-lazy`. + (:issue:`6812`, :issue:`6815`) + +- Fixed a typo in the :ref:`coroutine-support` section of the documentation. + (:issue:`6822`) + +- Made this page more prominently listed in PyPI project links. + (:issue:`6826`) + .. _release-2.13.0: From 08ee88456f850f247117aa602f7b066356b66419 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Wed, 28 May 2025 10:12:37 +0200 Subject: [PATCH 009/459] bumpversion: set the release date automatically --- pyproject.toml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 47707e061..3b8174afe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -120,6 +120,12 @@ commit = true tag = true tag_name = "{new_version}" +[[tool.bumpversion.files]] +filename = "docs/news.rst" +search = "\\(unreleased\\)$" +replace = "({now:%Y-%m-%d})" +regex = true + [[tool.bumpversion.files]] filename = "scrapy/VERSION" From 7fe7f1734aba5625f8c9c405bb92ef1da18cd983 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Wed, 28 May 2025 10:12:46 +0200 Subject: [PATCH 010/459] =?UTF-8?q?Bump=20version:=202.13.0=20=E2=86=92=20?= =?UTF-8?q?2.13.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 76e195feb..ef3b549e7 100644 --- a/docs/news.rst +++ b/docs/news.rst @@ -5,7 +5,7 @@ Release notes .. _release-2.13.1: -Scrapy 2.13.1 (unreleased) +Scrapy 2.13.1 (2025-05-28) -------------------------- - Give callback requests precedence over start requests when priority values diff --git a/pyproject.toml b/pyproject.toml index 3b8174afe..68c1e07bb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -115,7 +115,7 @@ module = "twisted" implicit_reexport = true [tool.bumpversion] -current_version = "2.13.0" +current_version = "2.13.1" commit = true tag = true tag_name = "{new_version}" diff --git a/scrapy/VERSION b/scrapy/VERSION index fb2c0766b..94f15e9cc 100644 --- a/scrapy/VERSION +++ b/scrapy/VERSION @@ -1 +1 @@ -2.13.0 +2.13.1 From 8ae418df44b7a107a4abe1a718721742d7e33fc0 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Mon, 2 Jun 2025 19:02:08 +0500 Subject: [PATCH 011/459] Rewrite download handler tests to coroutines. (#6846) --- .../test_downloader_handler_twisted_http10.py | 10 +- .../test_downloader_handler_twisted_http2.py | 112 +++-- tests/test_downloader_handlers.py | 123 +++--- tests/test_downloader_handlers_http_base.py | 410 +++++++++--------- 4 files changed, 329 insertions(+), 326 deletions(-) diff --git a/tests/test_downloader_handler_twisted_http10.py b/tests/test_downloader_handler_twisted_http10.py index 807c8c4cb..bc306aa07 100644 --- a/tests/test_downloader_handler_twisted_http10.py +++ b/tests/test_downloader_handler_twisted_http10.py @@ -9,6 +9,7 @@ import pytest from scrapy.core.downloader.handlers.http10 import HTTP10DownloadHandler from scrapy.http import Request from scrapy.spiders import Spider +from scrapy.utils.defer import deferred_f_from_coro_f from tests.test_downloader_handlers_http_base import TestHttpBase, TestHttpProxyBase if TYPE_CHECKING: @@ -25,12 +26,11 @@ class HTTP10DownloadHandlerMixin: class TestHttp10(HTTP10DownloadHandlerMixin, TestHttpBase): """HTTP 1.0 test case""" - def test_protocol(self): + @deferred_f_from_coro_f + async def test_protocol(self): request = Request(self.getURL("host"), method="GET") - d = self.download_request(request, Spider("foo")) - d.addCallback(lambda r: r.protocol) - d.addCallback(self.assertEqual, "HTTP/1.0") - return d + response = await self.download_request(request, Spider("foo")) + assert response.protocol == "HTTP/1.0" class TestHttps10(TestHttp10): diff --git a/tests/test_downloader_handler_twisted_http2.py b/tests/test_downloader_handler_twisted_http2.py index 159f403d0..e058cedae 100644 --- a/tests/test_downloader_handler_twisted_http2.py +++ b/tests/test_downloader_handler_twisted_http2.py @@ -15,6 +15,10 @@ 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.mockserver import ssl_context_factory @@ -50,15 +54,14 @@ class H2DownloadHandlerMixin: class TestHttps2(H2DownloadHandlerMixin, TestHttps11Base): HTTP2_DATALOSS_SKIP_REASON = "Content-Length mismatch raises InvalidBodyLengthError" - def test_protocol(self): + @deferred_f_from_coro_f + async def test_protocol(self): request = Request(self.getURL("host"), method="GET") - d = self.download_request(request, Spider("foo")) - d.addCallback(lambda r: r.protocol) - d.addCallback(self.assertEqual, "h2") - return d + response = await self.download_request(request, Spider("foo")) + assert response.protocol == "h2" - @defer.inlineCallbacks - def test_download_with_maxsize_very_large_file(self): + @deferred_f_from_coro_f + async def test_download_with_maxsize_very_large_file(self): from twisted.internet import reactor with mock.patch("scrapy.core.http2.stream.logger") as logger: @@ -67,8 +70,10 @@ class TestHttps2(H2DownloadHandlerMixin, TestHttps11Base): def check(logger): logger.error.assert_called_once_with(mock.ANY) - d = self.download_request(request, Spider("foo", download_maxsize=1500)) - yield self.assertFailure(d, defer.CancelledError, error.ConnectionAborted) + with pytest.raises((defer.CancelledError, error.ConnectionAborted)): + await self.download_request( + request, Spider("foo", download_maxsize=1500) + ) # 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 @@ -76,13 +81,13 @@ class TestHttps2(H2DownloadHandlerMixin, TestHttps11Base): d = defer.Deferred() d.addCallback(check) reactor.callLater(0.1, d.callback, logger) - yield d + await maybe_deferred_to_future(d) - @defer.inlineCallbacks - def test_unsupported_scheme(self): + @deferred_f_from_coro_f + async def test_unsupported_scheme(self): request = Request("ftp://unsupported.scheme") - d = self.download_request(request, Spider("foo")) - yield self.assertFailure(d, SchemeNotSupported) + with pytest.raises(SchemeNotSupported): + await self.download_request(request, Spider("foo")) def test_download_broken_content_cause_data_loss(self, url="broken"): pytest.skip(self.HTTP2_DATALOSS_SKIP_REASON) @@ -102,70 +107,60 @@ class TestHttps2(H2DownloadHandlerMixin, TestHttps11Base): def test_download_broken_chunked_content_allow_data_loss_via_setting(self): pytest.skip(self.HTTP2_DATALOSS_SKIP_REASON) - def test_concurrent_requests_same_domain(self): + @deferred_f_from_coro_f + async def test_concurrent_requests_same_domain(self): spider = Spider("foo") request1 = Request(self.getURL("file")) - d1 = self.download_request(request1, spider) - d1.addCallback(lambda r: r.body) - d1.addCallback(self.assertEqual, b"0123456789") + response1 = await self.download_request(request1, spider) + assert response1.body == b"0123456789" request2 = Request(self.getURL("echo"), method="POST") - d2 = self.download_request(request2, spider) - d2.addCallback(lambda r: r.headers["Content-Length"]) - d2.addCallback(self.assertEqual, b"79") - - return defer.DeferredList([d1, d2]) + response2 = await self.download_request(request2, spider) + assert response2.headers["Content-Length"] == b"79" @pytest.mark.xfail(reason="https://github.com/python-hyper/h2/issues/1247") - def test_connect_request(self): + @deferred_f_from_coro_f + async def test_connect_request(self): request = Request(self.getURL("file"), method="CONNECT") - d = self.download_request(request, Spider("foo")) - d.addCallback(lambda r: r.body) - d.addCallback(self.assertEqual, b"") - return d + response = await self.download_request(request, Spider("foo")) + assert response.body == b"" - def test_custom_content_length_good(self): + @deferred_f_from_coro_f + async def test_custom_content_length_good(self): request = Request(self.getURL("contentlength")) custom_content_length = str(len(request.body)) request.headers["Content-Length"] = custom_content_length - d = self.download_request(request, Spider("foo")) - d.addCallback(lambda r: r.text) - d.addCallback(self.assertEqual, custom_content_length) - return d + response = await self.download_request(request, Spider("foo")) + assert response.text == custom_content_length - def test_custom_content_length_bad(self): + @deferred_f_from_coro_f + async def test_custom_content_length_bad(self): request = Request(self.getURL("contentlength")) actual_content_length = str(len(request.body)) bad_content_length = str(len(request.body) + 1) request.headers["Content-Length"] = bad_content_length - log = LogCapture() - d = self.download_request(request, Spider("foo")) - d.addCallback(lambda r: r.text) - d.addCallback(self.assertEqual, actual_content_length) - d.addCallback( - lambda _: log.check_present( - ( - "scrapy.core.http2.stream", - "WARNING", - f"Ignoring bad Content-Length header " - f"{bad_content_length!r} of request {request}, sending " - f"{actual_content_length!r} instead", - ) + with LogCapture() as log: + response = await self.download_request(request, Spider("foo")) + assert response.text == actual_content_length + log.check_present( + ( + "scrapy.core.http2.stream", + "WARNING", + f"Ignoring bad Content-Length header " + f"{bad_content_length!r} of request {request}, sending " + f"{actual_content_length!r} instead", ) ) - d.addCallback(lambda _: log.uninstall()) - return d - def test_duplicate_header(self): + @deferred_f_from_coro_f + async def test_duplicate_header(self): request = Request(self.getURL("echo")) header, value1, value2 = "Custom-Header", "foo", "bar" request.headers.appendlist(header, value1) request.headers.appendlist(header, value2) - d = self.download_request(request, Spider("foo")) - d.addCallback(lambda r: json.loads(r.text)["headers"][header]) - d.addCallback(self.assertEqual, [value1, value2]) - return d + response = await self.download_request(request, Spider("foo")) + assert json.loads(response.text)["headers"][header] == [value1, value2] class Https2WrongHostnameTestCase(H2DownloadHandlerMixin, TestHttpsWrongHostnameBase): @@ -222,12 +217,13 @@ class Https2ProxyTestCase(H2DownloadHandlerMixin, TestHttpProxyBase): self.download_handler = build_from_crawler( self.download_handler_cls, get_crawler() ) - self.download_request = self.download_handler.download_request def getURL(self, path): return f"{self.scheme}://{self.host}:{self.portno}/{path}" - @defer.inlineCallbacks - def test_download_with_proxy_https_timeout(self): + @deferred_f_from_coro_f + async def test_download_with_proxy_https_timeout(self): with pytest.raises(NotImplementedError): - yield super().test_download_with_proxy_https_timeout() + await maybe_deferred_to_future( + super().test_download_with_proxy_https_timeout() + ) diff --git a/tests/test_downloader_handlers.py b/tests/test_downloader_handlers.py index dacadb075..09cdbaf35 100644 --- a/tests/test_downloader_handlers.py +++ b/tests/test_downloader_handlers.py @@ -22,10 +22,14 @@ from scrapy.core.downloader.handlers.file import FileDownloadHandler from scrapy.core.downloader.handlers.ftp import FTPDownloadHandler from scrapy.core.downloader.handlers.s3 import S3DownloadHandler from scrapy.exceptions import NotConfigured -from scrapy.http import HtmlResponse, Request +from scrapy.http import HtmlResponse, Request, Response from scrapy.http.response.text import TextResponse from scrapy.responsetypes import responsetypes 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.python import to_bytes from scrapy.utils.test import get_crawler @@ -95,28 +99,33 @@ class TestFile(unittest.TestCase): # 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") - handler = build_from_crawler(FileDownloadHandler, get_crawler()) - self.download_request = handler.download_request + self.download_handler = build_from_crawler(FileDownloadHandler, get_crawler()) def tearDown(self): os.close(self.fd) Path(self.tmpname).unlink() - def test_download(self): - def _test(response): - assert response.url == request.url - assert response.status == 200 - assert response.body == b"0123456789" - assert response.protocol is None + async def download_request(self, request: Request, spider: Spider) -> Response: + return await maybe_deferred_to_future( + self.download_handler.download_request(request, spider) + ) + @deferred_f_from_coro_f + async def test_download(self): request = Request(path_to_file_uri(self.tmpname)) assert request.url.upper().endswith("%5E") - return self.download_request(request, Spider("foo")).addCallback(_test) + response = await self.download_request(request, Spider("foo")) + assert response.url == request.url + assert response.status == 200 + assert response.body == b"0123456789" + assert response.protocol is None - def test_non_existent(self): + @deferred_f_from_coro_f + async def test_non_existent(self): request = Request(path_to_file_uri(mkdtemp())) - d = self.download_request(request, Spider("foo")) - return self.assertFailure(d, OSError) + # the specific exception differs between platforms + with pytest.raises(OSError): # noqa: PT011 + await self.download_request(request, Spider("foo")) class HttpDownloadHandlerMock: @@ -479,69 +488,65 @@ class TestDataURI(unittest.TestCase): def setUp(self): crawler = get_crawler() self.download_handler = build_from_crawler(DataURIDownloadHandler, crawler) - self.download_request = self.download_handler.download_request self.spider = Spider("foo") - def test_response_attrs(self): + async def download_request(self, request: Request, spider: Spider) -> Response: + return await maybe_deferred_to_future( + self.download_handler.download_request(request, spider) + ) + + @deferred_f_from_coro_f + async def test_response_attrs(self): uri = "data:,A%20brief%20note" - - def _test(response): - assert response.url == uri - assert not response.headers - request = Request(uri) - return self.download_request(request, self.spider).addCallback(_test) - - def test_default_mediatype_encoding(self): - def _test(response): - assert response.text == "A brief note" - assert type(response) is responsetypes.from_mimetype("text/plain") # pylint: disable=unidiomatic-typecheck - assert response.encoding == "US-ASCII" + response = await self.download_request(request, self.spider) + assert response.url == uri + assert not response.headers + @deferred_f_from_coro_f + async def test_default_mediatype_encoding(self): request = Request("data:,A%20brief%20note") - return self.download_request(request, self.spider).addCallback(_test) - - def test_default_mediatype(self): - def _test(response): - assert response.text == "\u038e\u03a3\u038e" - assert type(response) is responsetypes.from_mimetype("text/plain") # pylint: disable=unidiomatic-typecheck - assert response.encoding == "iso-8859-7" + response = await self.download_request(request, self.spider) + assert response.text == "A brief note" + assert type(response) is responsetypes.from_mimetype("text/plain") # pylint: disable=unidiomatic-typecheck + assert response.encoding == "US-ASCII" + @deferred_f_from_coro_f + async def test_default_mediatype(self): request = Request("data:;charset=iso-8859-7,%be%d3%be") - return self.download_request(request, self.spider).addCallback(_test) - - def test_text_charset(self): - def _test(response): - assert response.text == "\u038e\u03a3\u038e" - assert response.body == b"\xbe\xd3\xbe" - assert response.encoding == "iso-8859-7" + response = await self.download_request(request, self.spider) + assert response.text == "\u038e\u03a3\u038e" + assert type(response) is responsetypes.from_mimetype("text/plain") # pylint: disable=unidiomatic-typecheck + assert response.encoding == "iso-8859-7" + @deferred_f_from_coro_f + async def test_text_charset(self): request = Request("data:text/plain;charset=iso-8859-7,%be%d3%be") - return self.download_request(request, self.spider).addCallback(_test) - - def test_mediatype_parameters(self): - def _test(response): - assert response.text == "\u038e\u03a3\u038e" - assert type(response) is responsetypes.from_mimetype("text/plain") # pylint: disable=unidiomatic-typecheck - assert response.encoding == "utf-8" + response = await self.download_request(request, self.spider) + assert response.text == "\u038e\u03a3\u038e" + assert response.body == b"\xbe\xd3\xbe" + assert response.encoding == "iso-8859-7" + @deferred_f_from_coro_f + async def test_mediatype_parameters(self): request = Request( "data:text/plain;foo=%22foo;bar%5C%22%22;" "charset=utf-8;bar=%22foo;%5C%22 foo ;/,%22" ",%CE%8E%CE%A3%CE%8E" ) - return self.download_request(request, self.spider).addCallback(_test) - - def test_base64(self): - def _test(response): - assert response.text == "Hello, world." + response = await self.download_request(request, self.spider) + assert response.text == "\u038e\u03a3\u038e" + assert type(response) is responsetypes.from_mimetype("text/plain") # pylint: disable=unidiomatic-typecheck + assert response.encoding == "utf-8" + @deferred_f_from_coro_f + async def test_base64(self): request = Request("data:text/plain;base64,SGVsbG8sIHdvcmxkLg%3D%3D") - return self.download_request(request, self.spider).addCallback(_test) - - def test_protocol(self): - def _test(response): - assert response.protocol is None + response = await self.download_request(request, self.spider) + assert response.text == "Hello, world." + @deferred_f_from_coro_f + async def test_protocol(self): request = Request("data:,") - return self.download_request(request, self.spider).addCallback(_test) + response = await self.download_request(request, self.spider) + assert response.protocol is None diff --git a/tests/test_downloader_handlers_http_base.py b/tests/test_downloader_handlers_http_base.py index 5eaf66966..14e12a3e6 100644 --- a/tests/test_downloader_handlers_http_base.py +++ b/tests/test_downloader_handlers_http_base.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json import shutil import sys from abc import ABC, abstractmethod @@ -13,14 +14,20 @@ from unittest import mock import pytest from testfixtures import LogCapture from twisted.internet import defer, error +from twisted.internet.defer import maybeDeferred from twisted.protocols.policies import WrappingFactory from twisted.trial import unittest from twisted.web import resource, server, static, util from twisted.web._newclient import ResponseFailed from twisted.web.http import _DataLoss -from scrapy.http import Headers, HtmlResponse, Request, TextResponse +from scrapy.http import Headers, HtmlResponse, Request, Response, TextResponse from scrapy.spiders import Spider +from scrapy.utils.defer import ( + deferred_f_from_coro_f, + deferred_from_coro, + maybe_deferred_to_future, +) from scrapy.utils.misc import build_from_crawler from scrapy.utils.python import to_bytes from scrapy.utils.test import get_crawler @@ -178,7 +185,6 @@ class TestHttpBase(unittest.TestCase, ABC): self.download_handler = build_from_crawler( self.download_handler_cls, get_crawler() ) - self.download_request = self.download_handler.download_request @defer.inlineCallbacks def tearDown(self): @@ -190,36 +196,37 @@ class TestHttpBase(unittest.TestCase, ABC): def getURL(self, path): return f"{self.scheme}://{self.host}:{self.portno}/{path}" - def test_download(self): + async def download_request(self, request: Request, spider: Spider) -> Response: + return await maybe_deferred_to_future( + self.download_handler.download_request(request, spider) + ) + + @deferred_f_from_coro_f + async def test_download(self): request = Request(self.getURL("file")) - d = self.download_request(request, Spider("foo")) - d.addCallback(lambda r: r.body) - d.addCallback(self.assertEqual, b"0123456789") - return d + response = await self.download_request(request, Spider("foo")) + assert response.body == b"0123456789" - def test_download_head(self): + @deferred_f_from_coro_f + async def test_download_head(self): request = Request(self.getURL("file"), method="HEAD") - d = self.download_request(request, Spider("foo")) - d.addCallback(lambda r: r.body) - d.addCallback(self.assertEqual, b"") - return d + response = await self.download_request(request, Spider("foo")) + assert response.body == b"" - def test_redirect_status(self): + @deferred_f_from_coro_f + async def test_redirect_status(self): request = Request(self.getURL("redirect")) - d = self.download_request(request, Spider("foo")) - d.addCallback(lambda r: r.status) - d.addCallback(self.assertEqual, 302) - return d + response = await self.download_request(request, Spider("foo")) + assert response.status == 302 - def test_redirect_status_head(self): + @deferred_f_from_coro_f + async def test_redirect_status_head(self): request = Request(self.getURL("redirect"), method="HEAD") - d = self.download_request(request, Spider("foo")) - d.addCallback(lambda r: r.status) - d.addCallback(self.assertEqual, 302) - return d + response = await self.download_request(request, Spider("foo")) + assert response.status == 302 - @defer.inlineCallbacks - def test_timeout_download_from_spider_nodata_rcvd(self): + @deferred_f_from_coro_f + async def test_timeout_download_from_spider_nodata_rcvd(self): if self.reactor_pytest != "default" and sys.platform == "win32": # https://twistedmatrix.com/trac/ticket/10279 raise unittest.SkipTest( @@ -230,11 +237,12 @@ class TestHttpBase(unittest.TestCase, ABC): spider = Spider("foo") meta = {"download_timeout": 0.5} request = Request(self.getURL("wait"), meta=meta) - d = self.download_request(request, spider) - yield self.assertFailure(d, defer.TimeoutError, error.TimeoutError) + d = deferred_from_coro(self.download_request(request, spider)) + with pytest.raises((defer.TimeoutError, error.TimeoutError)): + await maybe_deferred_to_future(d) - @defer.inlineCallbacks - def test_timeout_download_from_spider_server_hangs(self): + @deferred_f_from_coro_f + async def test_timeout_download_from_spider_server_hangs(self): if self.reactor_pytest != "default" and sys.platform == "win32": # https://twistedmatrix.com/trac/ticket/10279 raise unittest.SkipTest( @@ -244,28 +252,27 @@ class TestHttpBase(unittest.TestCase, ABC): spider = Spider("foo") meta = {"download_timeout": 0.5} request = Request(self.getURL("hang-after-headers"), meta=meta) - d = self.download_request(request, spider) - yield self.assertFailure(d, defer.TimeoutError, error.TimeoutError) - - def test_host_header_not_in_request_headers(self): - def _test(response): - assert response.body == to_bytes(f"{self.host}:{self.portno}") - assert not request.headers + d = deferred_from_coro(self.download_request(request, spider)) + with pytest.raises((defer.TimeoutError, error.TimeoutError)): + await maybe_deferred_to_future(d) + @deferred_f_from_coro_f + async def test_host_header_not_in_request_headers(self): request = Request(self.getURL("host")) - return self.download_request(request, Spider("foo")).addCallback(_test) + response = await self.download_request(request, Spider("foo")) + assert response.body == to_bytes(f"{self.host}:{self.portno}") + assert not request.headers - def test_host_header_seted_in_request_headers(self): + @deferred_f_from_coro_f + async def test_host_header_set_in_request_headers(self): host = self.host + ":" + str(self.portno) - - def _test(response): - assert response.body == host.encode() - assert request.headers.get("Host") == host.encode() - request = Request(self.getURL("host"), headers={"Host": host}) - return self.download_request(request, Spider("foo")).addCallback(_test) + response = await self.download_request(request, Spider("foo")) + assert response.body == host.encode() + assert request.headers.get("Host") == host.encode() - def test_content_length_zero_bodyless_post_request_headers(self): + @deferred_f_from_coro_f + async def test_content_length_zero_bodyless_post_request_headers(self): """Tests if "Content-Length: 0" is sent for bodyless POST requests. This is not strictly required by HTTP RFCs but can cause trouble @@ -276,105 +283,93 @@ class TestHttpBase(unittest.TestCase, ABC): https://github.com/kennethreitz/requests/issues/405 https://bugs.python.org/issue14721 """ - - def _test(response): - assert response.body == b"0" - request = Request(self.getURL("contentlength"), method="POST") - return self.download_request(request, Spider("foo")).addCallback(_test) - - def test_content_length_zero_bodyless_post_only_one(self): - def _test(response): - import json - - headers = Headers(json.loads(response.text)["headers"]) - contentlengths = headers.getlist("Content-Length") - assert len(contentlengths) == 1 - assert contentlengths == [b"0"] + response = await self.download_request(request, Spider("foo")) + assert response.body == b"0" + @deferred_f_from_coro_f + async def test_content_length_zero_bodyless_post_only_one(self): request = Request(self.getURL("echo"), method="POST") - return self.download_request(request, Spider("foo")).addCallback(_test) + response = await self.download_request(request, Spider("foo")) + headers = Headers(json.loads(response.text)["headers"]) + contentlengths = headers.getlist("Content-Length") + assert len(contentlengths) == 1 + assert contentlengths == [b"0"] - def test_payload(self): + @deferred_f_from_coro_f + async def test_payload(self): body = b"1" * 100 # PayloadResource requires body length to be 100 request = Request(self.getURL("payload"), method="POST", body=body) - d = self.download_request(request, Spider("foo")) - d.addCallback(lambda r: r.body) - d.addCallback(self.assertEqual, body) - return d + response = await self.download_request(request, Spider("foo")) + assert response.body == body - def test_response_header_content_length(self): + @deferred_f_from_coro_f + async def test_response_header_content_length(self): request = Request(self.getURL("file"), method=b"GET") - d = self.download_request(request, Spider("foo")) - d.addCallback(lambda r: r.headers[b"content-length"]) - d.addCallback(self.assertEqual, b"159") - return d - - def _test_response_class(self, filename, body, response_class): - def _test(response): - assert type(response) is response_class # pylint: disable=unidiomatic-typecheck + response = await self.download_request(request, Spider("foo")) + assert response.headers[b"content-length"] == b"159" + async def _test_response_class( + self, filename: str, body: bytes, response_class: type[Response] + ) -> None: request = Request(self.getURL(filename), body=body) - return self.download_request(request, Spider("foo")).addCallback(_test) + response = await self.download_request(request, Spider("foo")) + assert type(response) is response_class # pylint: disable=unidiomatic-typecheck - def test_response_class_from_url(self): - return self._test_response_class("foo.html", b"", HtmlResponse) + @deferred_f_from_coro_f + async def test_response_class_from_url(self): + await self._test_response_class("foo.html", b"", HtmlResponse) - def test_response_class_from_body(self): - return self._test_response_class( + @deferred_f_from_coro_f + async def test_response_class_from_body(self): + await self._test_response_class( "foo", b"\n.", HtmlResponse, ) - def test_get_duplicate_header(self): - def _test(response): - assert response.headers.getlist(b"Set-Cookie") == [b"a=b", b"c=d"] - + @deferred_f_from_coro_f + async def test_get_duplicate_header(self): request = Request(self.getURL("duplicate-header")) - return self.download_request(request, Spider("foo")).addCallback(_test) + response = await self.download_request(request, Spider("foo")) + assert response.headers.getlist(b"Set-Cookie") == [b"a=b", b"c=d"] class TestHttp11Base(TestHttpBase): """HTTP 1.1 test case""" - def test_download_without_maxsize_limit(self): + @deferred_f_from_coro_f + async def test_download_without_maxsize_limit(self): request = Request(self.getURL("file")) - d = self.download_request(request, Spider("foo")) - d.addCallback(lambda r: r.body) - d.addCallback(self.assertEqual, b"0123456789") - return d + response = await self.download_request(request, Spider("foo")) + assert response.body == b"0123456789" - def test_response_class_choosing_request(self): + @deferred_f_from_coro_f + async def test_response_class_choosing_request(self): """Tests choosing of correct response type in case of Content-Type is empty but body contains text. """ body = b"Some plain text\ndata with tabs\t and null bytes\0" - - def _test_type(response): - assert type(response) is TextResponse # pylint: disable=unidiomatic-typecheck - request = Request(self.getURL("nocontenttype"), body=body) - d = self.download_request(request, Spider("foo")) - d.addCallback(_test_type) - return d + response = await self.download_request(request, Spider("foo")) + assert type(response) is TextResponse # pylint: disable=unidiomatic-typecheck - @defer.inlineCallbacks - def test_download_with_maxsize(self): + @deferred_f_from_coro_f + async def test_download_with_maxsize(self): request = Request(self.getURL("file")) # 10 is minimal size for this request and the limit is only counted on # response body. (regardless of headers) - d = self.download_request(request, Spider("foo", download_maxsize=10)) - d.addCallback(lambda r: r.body) - d.addCallback(self.assertEqual, b"0123456789") - yield d + response = await self.download_request( + request, Spider("foo", download_maxsize=10) + ) + assert response.body == b"0123456789" - d = self.download_request(request, Spider("foo", download_maxsize=9)) - yield self.assertFailure(d, defer.CancelledError, error.ConnectionAborted) + with pytest.raises((defer.CancelledError, error.ConnectionAborted)): + await self.download_request(request, Spider("foo", download_maxsize=9)) - @defer.inlineCallbacks - def test_download_with_maxsize_very_large_file(self): + @deferred_f_from_coro_f + async def test_download_with_maxsize_very_large_file(self): from twisted.internet import reactor # TODO: the logger check is specific to scrapy.core.downloader.handlers.http11 @@ -384,8 +379,10 @@ class TestHttp11Base(TestHttpBase): def check(logger): logger.warning.assert_called_once_with(mock.ANY, mock.ANY) - d = self.download_request(request, Spider("foo", download_maxsize=1500)) - yield self.assertFailure(d, defer.CancelledError, error.ConnectionAborted) + with pytest.raises((defer.CancelledError, error.ConnectionAborted)): + await self.download_request( + request, Spider("foo", download_maxsize=1500) + ) # 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 @@ -393,84 +390,81 @@ class TestHttp11Base(TestHttpBase): d = defer.Deferred() d.addCallback(check) reactor.callLater(0.1, d.callback, logger) - yield d + await maybe_deferred_to_future(d) - @defer.inlineCallbacks - def test_download_with_maxsize_per_req(self): + @deferred_f_from_coro_f + async def test_download_with_maxsize_per_req(self): meta = {"download_maxsize": 2} request = Request(self.getURL("file"), meta=meta) - d = self.download_request(request, Spider("foo")) - yield self.assertFailure(d, defer.CancelledError, error.ConnectionAborted) + with pytest.raises((defer.CancelledError, error.ConnectionAborted)): + await self.download_request(request, Spider("foo")) - @defer.inlineCallbacks - def test_download_with_small_maxsize_per_spider(self): + @deferred_f_from_coro_f + async def test_download_with_small_maxsize_per_spider(self): request = Request(self.getURL("file")) - d = self.download_request(request, Spider("foo", download_maxsize=2)) - yield self.assertFailure(d, defer.CancelledError, error.ConnectionAborted) + with pytest.raises((defer.CancelledError, error.ConnectionAborted)): + await self.download_request(request, Spider("foo", download_maxsize=2)) - def test_download_with_large_maxsize_per_spider(self): + @deferred_f_from_coro_f + async def test_download_with_large_maxsize_per_spider(self): request = Request(self.getURL("file")) - d = self.download_request(request, Spider("foo", download_maxsize=100)) - d.addCallback(lambda r: r.body) - d.addCallback(self.assertEqual, b"0123456789") - return d + response = await self.download_request( + request, Spider("foo", download_maxsize=100) + ) + assert response.body == b"0123456789" - def test_download_chunked_content(self): + @deferred_f_from_coro_f + async def test_download_chunked_content(self): request = Request(self.getURL("chunked")) - d = self.download_request(request, Spider("foo")) - d.addCallback(lambda r: r.body) - d.addCallback(self.assertEqual, b"chunked content\n") - return d + response = await self.download_request(request, Spider("foo")) + assert response.body == b"chunked content\n" - def test_download_broken_content_cause_data_loss(self, url="broken"): + @deferred_f_from_coro_f + async def test_download_broken_content_cause_data_loss( + self, url: str = "broken" + ) -> None: # TODO: this one checks for Twisted-specific exceptions request = Request(self.getURL(url)) - d = self.download_request(request, Spider("foo")) - - def checkDataLoss(failure): - if failure.check(ResponseFailed) and any( - r.check(_DataLoss) for r in failure.value.reasons - ): - return None - return failure - - d.addCallback(lambda _: self.fail("No DataLoss exception")) - d.addErrback(checkDataLoss) - return d + with pytest.raises(ResponseFailed) as exc_info: + await self.download_request(request, Spider("foo")) + assert any(r.check(_DataLoss) for r in exc_info.value.reasons) def test_download_broken_chunked_content_cause_data_loss(self): return self.test_download_broken_content_cause_data_loss("broken-chunked") - def test_download_broken_content_allow_data_loss(self, url="broken"): + @deferred_f_from_coro_f + async def test_download_broken_content_allow_data_loss( + self, url: str = "broken" + ) -> None: request = Request(self.getURL(url), meta={"download_fail_on_dataloss": False}) - d = self.download_request(request, Spider("foo")) - d.addCallback(lambda r: r.flags) - d.addCallback(self.assertEqual, ["dataloss"]) - return d + response = await self.download_request(request, Spider("foo")) + assert response.flags == ["dataloss"] def test_download_broken_chunked_content_allow_data_loss(self): return self.test_download_broken_content_allow_data_loss("broken-chunked") - def test_download_broken_content_allow_data_loss_via_setting(self, url="broken"): + @deferred_f_from_coro_f + async def test_download_broken_content_allow_data_loss_via_setting( + self, url: str = "broken" + ) -> None: crawler = get_crawler(settings_dict={"DOWNLOAD_FAIL_ON_DATALOSS": False}) download_handler = build_from_crawler(self.download_handler_cls, crawler) request = Request(self.getURL(url)) - d = download_handler.download_request(request, Spider("foo")) - d.addCallback(lambda r: r.flags) - d.addCallback(self.assertEqual, ["dataloss"]) - return d + response = await maybe_deferred_to_future( + download_handler.download_request(request, Spider("foo")) + ) + assert response.flags == ["dataloss"] def test_download_broken_chunked_content_allow_data_loss_via_setting(self): return self.test_download_broken_content_allow_data_loss_via_setting( "broken-chunked" ) - def test_protocol(self): + @deferred_f_from_coro_f + async def test_protocol(self): request = Request(self.getURL("host"), method="GET") - d = self.download_request(request, Spider("foo")) - d.addCallback(lambda r: r.protocol) - d.addCallback(self.assertEqual, "HTTP/1.1") - return d + response = await self.download_request(request, Spider("foo")) + assert response.protocol == "HTTP/1.1" class TestHttps11Base(TestHttp11Base): @@ -481,8 +475,8 @@ class TestHttps11Base(TestHttp11Base): 'subject "/C=IE/O=Scrapy/CN=localhost"' ) - @defer.inlineCallbacks - def test_tls_logging(self): + @deferred_f_from_coro_f + async def test_tls_logging(self): crawler = get_crawler( settings_dict={"DOWNLOADER_CLIENT_TLS_VERBOSE_LOGGING": True} ) @@ -490,15 +484,15 @@ class TestHttps11Base(TestHttp11Base): try: with LogCapture() as log_capture: request = Request(self.getURL("file")) - d = download_handler.download_request(request, Spider("foo")) - d.addCallback(lambda r: r.body) - d.addCallback(self.assertEqual, b"0123456789") - yield d + response = await maybe_deferred_to_future( + download_handler.download_request(request, Spider("foo")) + ) + assert response.body == b"0123456789" log_capture.check_present( ("scrapy.core.downloader.tls", "DEBUG", self.tls_log_message) ) finally: - yield download_handler.close() + await maybe_deferred_to_future(maybeDeferred(download_handler.close)) class TestSimpleHttpsBase(unittest.TestCase, ABC): @@ -536,7 +530,6 @@ class TestSimpleHttpsBase(unittest.TestCase, ABC): settings_dict = None crawler = get_crawler(settings_dict=settings_dict) self.download_handler = build_from_crawler(self.download_handler_cls, crawler) - self.download_request = self.download_handler.download_request @defer.inlineCallbacks def tearDown(self): @@ -548,12 +541,16 @@ class TestSimpleHttpsBase(unittest.TestCase, ABC): def getURL(self, path): return f"https://{self.host}:{self.portno}/{path}" - def test_download(self): + async def download_request(self, request: Request, spider: Spider) -> Response: + return await maybe_deferred_to_future( + self.download_handler.download_request(request, spider) + ) + + @deferred_f_from_coro_f + async def test_download(self): request = Request(self.getURL("file")) - d = self.download_request(request, Spider("foo")) - d.addCallback(lambda r: r.body) - d.addCallback(self.assertEqual, b"0123456789") - return d + response = await self.download_request(request, Spider("foo")) + assert response.body == b"0123456789" class TestHttpsWrongHostnameBase(TestSimpleHttpsBase): @@ -604,25 +601,29 @@ class TestHttpMockServerBase(unittest.TestCase, ABC): def tearDownClass(cls): cls.mockserver.__exit__(None, None, None) - @defer.inlineCallbacks - def test_download_with_content_length(self): + @deferred_f_from_coro_f + async def test_download_with_content_length(self): crawler = get_crawler(SingleRequestSpider, self.settings_dict) # http://localhost:8998/partial set Content-Length to 1024, use download_maxsize= 1000 to avoid # download it - yield crawler.crawl( - seed=Request( - url=self.mockserver.url("/partial", is_secure=self.is_secure), - meta={"download_maxsize": 1000}, + await maybe_deferred_to_future( + crawler.crawl( + seed=Request( + url=self.mockserver.url("/partial", is_secure=self.is_secure), + meta={"download_maxsize": 1000}, + ) ) ) failure = crawler.spider.meta["failure"] assert isinstance(failure.value, defer.CancelledError) - @defer.inlineCallbacks - def test_download(self): + @deferred_f_from_coro_f + async def test_download(self): crawler = get_crawler(SingleRequestSpider, self.settings_dict) - yield crawler.crawl( - seed=Request(url=self.mockserver.url("", is_secure=self.is_secure)) + await maybe_deferred_to_future( + crawler.crawl( + seed=Request(url=self.mockserver.url("", is_secure=self.is_secure)) + ) ) failure = crawler.spider.meta.get("failure") assert failure is None @@ -663,7 +664,6 @@ class TestHttpProxyBase(unittest.TestCase, ABC): self.download_handler = build_from_crawler( self.download_handler_cls, get_crawler() ) - self.download_request = self.download_handler.download_request @defer.inlineCallbacks def tearDown(self): @@ -674,42 +674,44 @@ class TestHttpProxyBase(unittest.TestCase, ABC): def getURL(self, path): return f"http://127.0.0.1:{self.portno}/{path}" - def test_download_with_proxy(self): - def _test(response): - assert response.status == 200 - assert response.url == request.url - assert response.body == self.expected_http_proxy_request_body + async def download_request(self, request: Request, spider: Spider) -> Response: + return await maybe_deferred_to_future( + self.download_handler.download_request(request, spider) + ) + @deferred_f_from_coro_f + async def test_download_with_proxy(self): http_proxy = self.getURL("") request = Request("http://example.com", meta={"proxy": http_proxy}) - return self.download_request(request, Spider("foo")).addCallback(_test) - - def test_download_without_proxy(self): - def _test(response): - assert response.status == 200 - assert response.url == request.url - assert response.body == b"/path/to/resource" + response = await self.download_request(request, Spider("foo")) + 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): request = Request(self.getURL("path/to/resource")) - return self.download_request(request, Spider("foo")).addCallback(_test) + response = await self.download_request(request, Spider("foo")) + assert response.status == 200 + assert response.url == request.url + assert response.body == b"/path/to/resource" - @defer.inlineCallbacks - def test_download_with_proxy_https_timeout(self): + @deferred_f_from_coro_f + async def test_download_with_proxy_https_timeout(self): if NON_EXISTING_RESOLVABLE: pytest.skip("Non-existing hosts are resolvable") http_proxy = self.getURL("") domain = "https://no-such-domain.nosuch" request = Request(domain, meta={"proxy": http_proxy, "download_timeout": 0.2}) - d = self.download_request(request, Spider("foo")) - timeout = yield self.assertFailure(d, error.TimeoutError) - assert domain in timeout.osError - - def test_download_with_proxy_without_http_scheme(self): - def _test(response): - assert response.status == 200 - assert response.url == request.url - assert response.body == self.expected_http_proxy_request_body + with pytest.raises(error.TimeoutError) as exc_info: + await self.download_request(request, Spider("foo")) + assert domain in exc_info.value.osError + @deferred_f_from_coro_f + async def test_download_with_proxy_without_http_scheme(self): http_proxy = self.getURL("").replace("http://", "") request = Request("http://example.com", meta={"proxy": http_proxy}) - return self.download_request(request, Spider("foo")).addCallback(_test) + response = await self.download_request(request, Spider("foo")) + assert response.status == 200 + assert response.url == request.url + assert response.body == self.expected_http_proxy_request_body From 9cc23641ccc988d1f623902397496db5d9fe499b Mon Sep 17 00:00:00 2001 From: Rodrigosnrocha <83819959+Rodrigosnrocha@users.noreply.github.com> Date: Mon, 2 Jun 2025 17:00:17 +0200 Subject: [PATCH 012/459] Deprecate _parse_response and implement parse_with_rules (#6804) --- docs/conf.py | 1 - pyproject.toml | 3 +++ scrapy/spiders/crawl.py | 32 +++++++++++++++++++++++++++--- tests/test_spider.py | 44 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 76 insertions(+), 4 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 1167ce050..493a62976 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -3,7 +3,6 @@ # For the full list of built-in configuration values, see the documentation: # https://www.sphinx-doc.org/en/master/usage/configuration.html -# pylint: disable=import-error import os import sys from collections.abc import Sequence diff --git a/pyproject.toml b/pyproject.toml index 871da8020..02ab7858d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -161,6 +161,9 @@ extension-pkg-allow-list=[ enable = [ "useless-suppression", ] +# Make INFO checks like useless-suppression also cause pylint to return a +# non-zero exit code. +fail-on = "I" disable = [ # Ones we want to ignore "attribute-defined-outside-init", diff --git a/scrapy/spiders/crawl.py b/scrapy/spiders/crawl.py index 171d8479c..f44f70e40 100644 --- a/scrapy/spiders/crawl.py +++ b/scrapy/spiders/crawl.py @@ -8,6 +8,7 @@ See documentation in docs/topics/spiders.rst from __future__ import annotations import copy +import warnings from collections.abc import AsyncIterator, Awaitable, Callable from typing import TYPE_CHECKING, Any, Optional, TypeVar, cast @@ -18,6 +19,8 @@ from scrapy.link import Link from scrapy.linkextractors import LinkExtractor from scrapy.spiders import Spider from scrapy.utils.asyncgen import collect_asyncgen +from scrapy.utils.deprecate import method_is_overridden +from scrapy.utils.python import global_object_name from scrapy.utils.spider import iterate_spider_output if TYPE_CHECKING: @@ -95,9 +98,17 @@ class CrawlSpider(Spider): def __init__(self, *a: Any, **kw: Any): super().__init__(*a, **kw) self._compile_rules() + if method_is_overridden(self.__class__, CrawlSpider, "_parse_response"): + warnings.warn( + f"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." + ) def _parse(self, response: Response, **kwargs: Any) -> Any: - return self._parse_response( + return self.parse_with_rules( response=response, callback=self.parse_start_url, cb_kwargs=kwargs, @@ -137,7 +148,7 @@ class CrawlSpider(Spider): def _callback(self, response: Response, **cb_kwargs: Any) -> Any: rule = self._rules[cast(int, response.meta["rule"])] - return self._parse_response( + return self.parse_with_rules( response, cast("CallbackT", rule.callback), {**rule.cb_kwargs, **cb_kwargs}, @@ -150,7 +161,7 @@ class CrawlSpider(Spider): failure, cast(Callable[[Failure], Any], rule.errback) ) - async def _parse_response( + async def parse_with_rules( self, response: Response, callback: CallbackT | None, @@ -171,6 +182,21 @@ class CrawlSpider(Spider): for request_or_item in self._requests_to_follow(response): yield request_or_item + def _parse_response( + self, + response: Response, + callback: CallbackT | None, + cb_kwargs: dict[str, Any], + follow: bool = True, + ) -> AsyncIterator[Any]: + warnings.warn( + "The CrawlSpider._parse_response method is deprecated: " + "it will be removed in future Scrapy releases. " + "Please use the CrawlSpider.parse_with_rules method instead.", + stacklevel=2, + ) + return self.parse_with_rules(response, callback, cb_kwargs, follow) + def _handle_failure( self, failure: Failure, errback: Callable[[Failure], Any] | None ) -> Iterable[Any]: diff --git a/tests/test_spider.py b/tests/test_spider.py index b4aa649a3..4e4a99638 100644 --- a/tests/test_spider.py +++ b/tests/test_spider.py @@ -476,6 +476,50 @@ class TestCrawlSpider(TestSpider): 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 From d400aa3e2d8c5e3d1a7d8a483fb1a5a6f1c66d50 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Tue, 3 Jun 2025 14:19:15 +0500 Subject: [PATCH 013/459] Add _parallel_asyncio(). (#6852) --- scrapy/core/scraper.py | 30 ++++++++++-- scrapy/utils/asyncio.py | 54 +++++++++++++++++++++ tests/test_utils_asyncio.py | 93 ++++++++++++++++++++++++++++++++++++- tests/test_utils_defer.py | 49 +++++++++++++++++-- 4 files changed, 217 insertions(+), 9 deletions(-) diff --git a/scrapy/core/scraper.py b/scrapy/core/scraper.py index 9fc1d20ed..9fd68bce5 100644 --- a/scrapy/core/scraper.py +++ b/scrapy/core/scraper.py @@ -21,6 +21,7 @@ from scrapy.exceptions import ( ScrapyDeprecationWarning, ) from scrapy.http import Request, Response +from scrapy.utils.asyncio import _parallel_asyncio, is_asyncio_available from scrapy.utils.defer import ( _defer_sleep, aiter_errback, @@ -328,11 +329,21 @@ class Scraper: response: Response, ) -> None: """Pass items/requests produced by a callback to ``_process_spidermw_output()`` in parallel.""" + it: Iterable[_T] | AsyncIterator[_T] + if is_asyncio_available(): + if isinstance(result, AsyncIterator): + it = aiter_errback(result, self.handle_spider_error, request, response) + else: + it = iter_errback(result, self.handle_spider_error, request, response) + await _parallel_asyncio( + it, self.concurrent_items, self._process_spidermw_output_async, response + ) + return if isinstance(result, AsyncIterator): - ait = aiter_errback(result, self.handle_spider_error, request, response) + it = aiter_errback(result, self.handle_spider_error, request, response) await maybe_deferred_to_future( parallel_async( - ait, + it, self.concurrent_items, self._process_spidermw_output, response, @@ -349,8 +360,19 @@ class Scraper: ) ) - @deferred_f_from_coro_f - async def _process_spidermw_output(self, output: Any, response: Response) -> None: + def _process_spidermw_output( + self, output: Any, response: Response + ) -> Deferred[None]: + """Process each Request/Item (given in the output parameter) returned + from the given spider. + + Items are sent to the item pipelines, requests are scheduled. + """ + return deferred_from_coro(self._process_spidermw_output_async(output, response)) + + async def _process_spidermw_output_async( + self, output: Any, response: Response + ) -> None: """Process each Request/Item (given in the output parameter) returned from the given spider. diff --git a/scrapy/utils/asyncio.py b/scrapy/utils/asyncio.py index 4469369fa..a3f27bcc9 100644 --- a/scrapy/utils/asyncio.py +++ b/scrapy/utils/asyncio.py @@ -1,7 +1,23 @@ """Utilities related to asyncio and its support in Scrapy.""" +from __future__ import annotations + +import asyncio +from typing import TYPE_CHECKING, Any, TypeVar + +from scrapy.utils.asyncgen import as_async_generator from scrapy.utils.reactor import is_asyncio_reactor_installed, is_reactor_installed +if TYPE_CHECKING: + from collections.abc import AsyncIterator, Callable, Coroutine, Iterable + + # typing.Concatenate and typing.ParamSpec require Python 3.10 + from typing_extensions import Concatenate, ParamSpec + + _P = ParamSpec("_P") + +_T = TypeVar("_T") + def is_asyncio_available() -> bool: """Check if it's possible to call asyncio code that relies on the asyncio event loop. @@ -36,3 +52,41 @@ def is_asyncio_available() -> bool: ) return is_asyncio_reactor_installed() + + +async def _parallel_asyncio( + iterable: Iterable[_T] | AsyncIterator[_T], + count: int, + callable: Callable[Concatenate[_T, _P], Coroutine[Any, Any, None]], + *args: _P.args, + **kwargs: _P.kwargs, +) -> None: + """Execute a callable over the objects in the given iterable, in parallel, + using no more than ``count`` concurrent calls. + + This function is only used in + :meth:`scrapy.core.scraper.Scraper.handle_spider_output_async` and so it + assumes that neither *callable* nor iterating *iterable* will raise an + exception. + """ + queue: asyncio.Queue[_T | None] = asyncio.Queue() + + async def worker() -> None: + while True: + item = await queue.get() + if item is None: + break + try: + await callable(item, *args, **kwargs) + finally: + queue.task_done() + + async def fill_queue() -> None: + async for item in as_async_generator(iterable): + await queue.put(item) + for _ in range(count): + await queue.put(None) + + fill_task = asyncio.create_task(fill_queue()) + work_tasks = [asyncio.create_task(worker()) for _ in range(count)] + await asyncio.wait([fill_task, *work_tasks]) diff --git a/tests/test_utils_asyncio.py b/tests/test_utils_asyncio.py index fe44748f9..6c47965a3 100644 --- a/tests/test_utils_asyncio.py +++ b/tests/test_utils_asyncio.py @@ -1,6 +1,18 @@ -import pytest +from __future__ import annotations -from scrapy.utils.asyncio import is_asyncio_available +import asyncio +import random +from typing import TYPE_CHECKING + +import pytest +from twisted.trial import unittest + +from scrapy.utils.asyncgen import as_async_generator +from scrapy.utils.asyncio import _parallel_asyncio, is_asyncio_available +from scrapy.utils.defer import deferred_f_from_coro_f + +if TYPE_CHECKING: + from collections.abc import AsyncGenerator @pytest.mark.usefixtures("reactor_pytest") @@ -8,3 +20,80 @@ class TestAsyncio: def test_is_asyncio_available(self): # the result should depend only on the pytest --reactor argument assert is_asyncio_available() == (self.reactor_pytest != "default") + + +@pytest.mark.only_asyncio +class TestParallelAsyncio(unittest.TestCase): + """Test for scrapy.utils.asyncio.parallel_asyncio(), based on tests.test_utils_defer.TestParallelAsync.""" + + CONCURRENT_ITEMS = 50 + + @staticmethod + async def callable(o: int, results: list[int]) -> None: + if random.random() < 0.4: + # simulate async processing + await asyncio.sleep(random.random() / 8) + # simulate trivial sync processing + results.append(o) + + async def callable_wrapped( + self, + o: int, + results: list[int], + parallel_count: list[int], + max_parallel_count: list[int], + ) -> None: + parallel_count[0] += 1 + max_parallel_count[0] = max(max_parallel_count[0], parallel_count[0]) + await self.callable(o, results) + assert parallel_count[0] > 0, parallel_count[0] + parallel_count[0] -= 1 + + @staticmethod + def get_async_iterable(length: int) -> AsyncGenerator[int, None]: + # simulate a simple callback without delays between results + return as_async_generator(range(length)) + + @staticmethod + async def get_async_iterable_with_delays(length: int) -> AsyncGenerator[int, None]: + # simulate a callback with delays between some of the results + for i in range(length): + if random.random() < 0.1: + await asyncio.sleep(random.random() / 20) + yield i + + @deferred_f_from_coro_f + async def test_simple(self): + for length in [20, 50, 100]: + parallel_count = [0] + max_parallel_count = [0] + results = [] + ait = self.get_async_iterable(length) + await _parallel_asyncio( + ait, + self.CONCURRENT_ITEMS, + self.callable_wrapped, + results, + parallel_count, + max_parallel_count, + ) + assert list(range(length)) == sorted(results) + assert max_parallel_count[0] <= self.CONCURRENT_ITEMS + + @deferred_f_from_coro_f + async def test_delays(self): + for length in [20, 50, 100]: + parallel_count = [0] + max_parallel_count = [0] + results = [] + ait = self.get_async_iterable_with_delays(length) + await _parallel_asyncio( + ait, + self.CONCURRENT_ITEMS, + self.callable_wrapped, + results, + parallel_count, + max_parallel_count, + ) + assert list(range(length)) == sorted(results) + assert max_parallel_count[0] <= self.CONCURRENT_ITEMS diff --git a/tests/test_utils_defer.py b/tests/test_utils_defer.py index c565c1c4e..98962f74c 100644 --- a/tests/test_utils_defer.py +++ b/tests/test_utils_defer.py @@ -164,7 +164,7 @@ class TestAsyncDefTestsuite(unittest.TestCase): raise RuntimeError("This is expected to be raised") -class TestAsyncCooperator(unittest.TestCase): +class TestParallelAsync(unittest.TestCase): """This tests _AsyncCooperatorAdapter by testing parallel_async which is its only usage. parallel_async is called with the results of a callback (so an iterable of items, requests and None, @@ -194,6 +194,27 @@ class TestAsyncCooperator(unittest.TestCase): results.append(o) return None + def callable_wrapped( + self, + o: int, + results: list[int], + parallel_count: list[int], + max_parallel_count: list[int], + ) -> Deferred[None] | None: + parallel_count[0] += 1 + max_parallel_count[0] = max(max_parallel_count[0], parallel_count[0]) + dfd = self.callable(o, results) + + def decrement(_: Any = None) -> None: + assert parallel_count[0] > 0, parallel_count[0] + parallel_count[0] -= 1 + + if dfd is not None: + dfd.addBoth(decrement) + else: + decrement() + return dfd + @staticmethod def get_async_iterable(length: int) -> AsyncGenerator[int, None]: # simulate a simple callback without delays between results @@ -215,20 +236,42 @@ class TestAsyncCooperator(unittest.TestCase): @inlineCallbacks def test_simple(self): for length in [20, 50, 100]: + parallel_count = [0] + max_parallel_count = [0] results = [] ait = self.get_async_iterable(length) - dl = parallel_async(ait, self.CONCURRENT_ITEMS, self.callable, results) + dl = parallel_async( + ait, + self.CONCURRENT_ITEMS, + self.callable_wrapped, + results, + parallel_count, + max_parallel_count, + ) yield dl assert list(range(length)) == sorted(results) + assert parallel_count[0] == 0 + assert max_parallel_count[0] <= self.CONCURRENT_ITEMS, max_parallel_count[0] @inlineCallbacks def test_delays(self): for length in [20, 50, 100]: + parallel_count = [0] + max_parallel_count = [0] results = [] ait = self.get_async_iterable_with_delays(length) - dl = parallel_async(ait, self.CONCURRENT_ITEMS, self.callable, results) + dl = parallel_async( + ait, + self.CONCURRENT_ITEMS, + self.callable_wrapped, + results, + parallel_count, + max_parallel_count, + ) yield dl assert list(range(length)) == sorted(results) + assert parallel_count[0] == 0 + assert max_parallel_count[0] <= self.CONCURRENT_ITEMS, max_parallel_count[0] class TestDeferredFromCoro(unittest.TestCase): From 3aa5e757871c6288f0a1e7031ad74b48d6321f5c Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Tue, 3 Jun 2025 17:47:52 +0500 Subject: [PATCH 014/459] Use AsyncCrawlerProcess in commands. (#6845) * Use AsyncCrawlerProcess in commands. * Ignore coverage of abstract methods. * Address feedback. --- docs/news.rst | 12 ++ docs/topics/commands.rst | 38 +++++ docs/topics/settings.rst | 20 +++ pyproject.toml | 6 +- scrapy/cmdline.py | 10 +- scrapy/commands/__init__.py | 4 +- scrapy/commands/crawl.py | 20 +-- scrapy/commands/fetch.py | 2 - scrapy/commands/genspider.py | 1 - scrapy/commands/runspider.py | 1 - scrapy/commands/settings.py | 1 - scrapy/commands/shell.py | 1 - scrapy/commands/startproject.py | 1 - scrapy/crawler.py | 21 ++- scrapy/settings/default_settings.py | 2 + tests/test_command_crawl.py | 25 ++++ tests/test_command_runspider.py | 17 ++- tests/test_command_shell.py | 4 +- tests/test_commands.py | 216 ++++++++++++++++++++++++++++ tests/test_utils_defer.py | 6 +- 20 files changed, 370 insertions(+), 38 deletions(-) diff --git a/docs/news.rst b/docs/news.rst index ef3b549e7..d3e6c6774 100644 --- a/docs/news.rst +++ b/docs/news.rst @@ -3,6 +3,18 @@ Release notes ============= +Scrapy VERSION (unreleased) +--------------------------- + +Backward-incompatible changes +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +- If you set the :setting:`TWISTED_REACTOR` setting to a :ref:`non-asyncio + value ` at the :ref:`spider level `, you + 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. + .. _release-2.13.1: Scrapy 2.13.1 (2025-05-28) diff --git a/docs/topics/commands.rst b/docs/topics/commands.rst index 6ffb8ae93..4994fe1d6 100644 --- a/docs/topics/commands.rst +++ b/docs/topics/commands.rst @@ -587,6 +587,44 @@ bench Run a quick benchmark test. :ref:`benchmarking`. +.. _topics-commands-crawlerprocess: + +Commands that run a crawl +========================= + +Many commands need to run a crawl of some kind, running either a user-provided +spider or a special internal one: + +* :command:`bench` +* :command:`check` +* :command:`crawl` +* :command:`fetch` +* :command:`parse` +* :command:`runspider` +* :command:`shell` +* :command:`view` + +They use an internal instance of :class:`scrapy.crawler.AsyncCrawlerProcess` or +:class:`scrapy.crawler.CrawlerProcess` for this. In most cases this detail +shouldn't matter to the user running the command, but when the user :ref:`needs +a non-default Twisted reactor `, it may be important. + +Scrapy decides which of these two classes to use based on the value of the +:setting:`TWISTED_REACTOR` setting. If the setting value is the default one +(``'twisted.internet.asyncioreactor.AsyncioSelectorReactor'``), +:class:`~scrapy.crawler.AsyncCrawlerProcess` will be used, otherwise +:class:`~scrapy.crawler.CrawlerProcess` will be used. The :ref:`spider settings +` are not taken into account when doing this, as they are +loaded after this decision is made. This may cause an error if the +project-level setting is set to :ref:`the asyncio reactor ` +(:ref:`explicitly ` or :ref:`by using the Scrapy default +`) and :ref:`the setting of the spider being run +` is set to :ref:`a different one `, because +:class:`~scrapy.crawler.AsyncCrawlerProcess` only supports the asyncio reactor. +In this case you should set the :setting:`FORCE_CRAWLER_PROCESS` setting to +``True`` (at the project level or via the command line) so that Scrapy uses +:class:`~scrapy.crawler.CrawlerProcess` which supports all reactors. + Custom project commands ======================= diff --git a/docs/topics/settings.rst b/docs/topics/settings.rst index 65f2e5ebd..2a1be5f88 100644 --- a/docs/topics/settings.rst +++ b/docs/topics/settings.rst @@ -1263,6 +1263,26 @@ 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 `_. +.. setting:: FORCE_CRAWLER_PROCESS + +FORCE_CRAWLER_PROCESS +--------------------- + +Default: ``False`` + +If ``False``, :ref:`Scrapy commands that need a CrawlerProcess +` will decide between using +:class:`scrapy.crawler.AsyncCrawlerProcess` and +:class:`scrapy.crawler.CrawlerProcess` based on the value of the +:setting:`TWISTED_REACTOR` setting, but ignoring its value in :ref:`per-spider +settings `. + +If ``True``, these commands will always use +:class:`~scrapy.crawler.CrawlerProcess`. + +Set this to ``True`` if you want to set :setting:`TWISTED_REACTOR` to a +non-default value in :ref:`per-spider settings `. + .. setting:: FTP_PASSIVE_MODE FTP_PASSIVE_MODE diff --git a/pyproject.toml b/pyproject.toml index 02ab7858d..8ec0c1056 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -147,8 +147,10 @@ source = [ ] [tool.coverage.report] -# https://github.com/nedbat/coveragepy/issues/831#issuecomment-517778185 -exclude_lines = ["pragma: no cover", "if TYPE_CHECKING:"] +exclude_also = [ + "if TYPE_CHECKING:", + "@(abc\\.)?abstractmethod", +] [tool.pylint.MASTER] persistent = "no" diff --git a/scrapy/cmdline.py b/scrapy/cmdline.py index 81e507a4e..3d448532b 100644 --- a/scrapy/cmdline.py +++ b/scrapy/cmdline.py @@ -10,11 +10,12 @@ from typing import TYPE_CHECKING import scrapy from scrapy.commands import BaseRunSpiderCommand, ScrapyCommand, ScrapyHelpFormatter -from scrapy.crawler import CrawlerProcess +from scrapy.crawler import AsyncCrawlerProcess, CrawlerProcess from scrapy.exceptions import UsageError from scrapy.utils.misc import walk_modules 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 if TYPE_CHECKING: from collections.abc import Callable, Iterable @@ -202,7 +203,12 @@ 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: - cmd.crawler_process = CrawlerProcess(settings) + if settings[ + "TWISTED_REACTOR" + ] == _asyncio_reactor_path and not settings.getbool("FORCE_CRAWLER_PROCESS"): + cmd.crawler_process = AsyncCrawlerProcess(settings) + else: + cmd.crawler_process = CrawlerProcess(settings) _run_print_help(parser, _run_command, cmd, args, opts) sys.exit(cmd.exitcode) diff --git a/scrapy/commands/__init__.py b/scrapy/commands/__init__.py index 2818ead77..4ce070e6e 100644 --- a/scrapy/commands/__init__.py +++ b/scrapy/commands/__init__.py @@ -18,14 +18,14 @@ from scrapy.utils.conf import arglist_to_dict, feed_process_params_from_cli if TYPE_CHECKING: from collections.abc import Iterable - from scrapy.crawler import Crawler, CrawlerProcess + from scrapy.crawler import Crawler, CrawlerProcessBase from scrapy.settings import Settings class ScrapyCommand: requires_project: bool = False requires_crawler_process: bool = True - crawler_process: CrawlerProcess | None = None # set in scrapy.cmdline + 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] = {} diff --git a/scrapy/commands/crawl.py b/scrapy/commands/crawl.py index 184bd5ca4..866ba9f6b 100644 --- a/scrapy/commands/crawl.py +++ b/scrapy/commands/crawl.py @@ -1,8 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING, cast - -from twisted.python.failure import Failure +from typing import TYPE_CHECKING from scrapy.commands import BaseRunSpiderCommand from scrapy.exceptions import UsageError @@ -30,17 +28,7 @@ class Command(BaseRunSpiderCommand): spname = args[0] assert self.crawler_process - crawl_defer = self.crawler_process.crawl(spname, **opts.spargs) - - if getattr(crawl_defer, "result", None) is not None and issubclass( - cast(Failure, crawl_defer.result).type, Exception - ): + self.crawler_process.crawl(spname, **opts.spargs) + self.crawler_process.start() + if self.crawler_process.bootstrap_failed: self.exitcode = 1 - else: - self.crawler_process.start() - - if self.crawler_process.bootstrap_failed or ( - hasattr(self.crawler_process, "has_exception") - and self.crawler_process.has_exception - ): - self.exitcode = 1 diff --git a/scrapy/commands/fetch.py b/scrapy/commands/fetch.py index ef6e13de2..e5eedffb5 100644 --- a/scrapy/commands/fetch.py +++ b/scrapy/commands/fetch.py @@ -18,8 +18,6 @@ if TYPE_CHECKING: class Command(ScrapyCommand): - requires_project = False - def syntax(self) -> str: return "[options] " diff --git a/scrapy/commands/genspider.py b/scrapy/commands/genspider.py index c4abfc4c9..0e90c3188 100644 --- a/scrapy/commands/genspider.py +++ b/scrapy/commands/genspider.py @@ -46,7 +46,6 @@ def verify_url_scheme(url: str) -> str: class Command(ScrapyCommand): - requires_project = False requires_crawler_process = False default_settings = {"LOG_ENABLED": False} diff --git a/scrapy/commands/runspider.py b/scrapy/commands/runspider.py index 3e826456e..eeb1303e2 100644 --- a/scrapy/commands/runspider.py +++ b/scrapy/commands/runspider.py @@ -30,7 +30,6 @@ def _import_file(filepath: str | PathLike[str]) -> ModuleType: class Command(BaseRunSpiderCommand): - requires_project = False default_settings = {"SPIDER_LOADER_CLASS": DummySpiderLoader} def syntax(self) -> str: diff --git a/scrapy/commands/settings.py b/scrapy/commands/settings.py index e63031f2d..704cc500d 100644 --- a/scrapy/commands/settings.py +++ b/scrapy/commands/settings.py @@ -6,7 +6,6 @@ from scrapy.settings import BaseSettings class Command(ScrapyCommand): - requires_project = False requires_crawler_process = False default_settings = {"LOG_ENABLED": False} diff --git a/scrapy/commands/shell.py b/scrapy/commands/shell.py index 9dabfcd9c..eedaeb263 100644 --- a/scrapy/commands/shell.py +++ b/scrapy/commands/shell.py @@ -22,7 +22,6 @@ if TYPE_CHECKING: class Command(ScrapyCommand): - requires_project = False default_settings = { "DUPEFILTER_CLASS": "scrapy.dupefilters.BaseDupeFilter", "KEEP_ALIVE": True, diff --git a/scrapy/commands/startproject.py b/scrapy/commands/startproject.py index 323979193..8f4427580 100644 --- a/scrapy/commands/startproject.py +++ b/scrapy/commands/startproject.py @@ -33,7 +33,6 @@ def _make_writable(path: Path) -> None: class Command(ScrapyCommand): - requires_project = False requires_crawler_process = False default_settings = {"LOG_ENABLED": False} diff --git a/scrapy/crawler.py b/scrapy/crawler.py index 8e3223a5c..d6fb9972e 100644 --- a/scrapy/crawler.py +++ b/scrapy/crawler.py @@ -5,6 +5,7 @@ import contextlib import logging import pprint import signal +from abc import ABC, abstractmethod from typing import TYPE_CHECKING, Any, TypeVar from twisted.internet.defer import ( @@ -42,7 +43,7 @@ from scrapy.utils.reactor import ( ) if TYPE_CHECKING: - from collections.abc import Generator, Iterable + from collections.abc import Awaitable, Generator, Iterable from scrapy.logformatter import LogFormatter from scrapy.statscollectors import StatsCollector @@ -321,7 +322,7 @@ class Crawler: return self._get_component(cls, self.engine.scraper.spidermw.middlewares) -class CrawlerRunnerBase: +class CrawlerRunnerBase(ABC): def __init__(self, settings: dict[str, Any] | Settings | None = None): if isinstance(settings, dict) or settings is None: settings = Settings(settings) @@ -364,6 +365,15 @@ class CrawlerRunnerBase: spidercls = self.spider_loader.load(spidercls) return Crawler(spidercls, self.settings) + @abstractmethod + def crawl( + self, + crawler_or_spidercls: type[Spider] | str | Crawler, + *args: Any, + **kwargs: Any, + ) -> Awaitable[None]: + raise NotImplementedError + class CrawlerRunner(CrawlerRunnerBase): """ @@ -560,6 +570,12 @@ class CrawlerProcessBase(CrawlerRunnerBase): configure_logging(self.settings, install_root_handler) log_scrapy_info(self.settings) + @abstractmethod + def start( + self, stop_after_crawl: bool = True, install_signal_handlers: bool = True + ) -> None: + raise NotImplementedError + def _signal_shutdown(self, signum: int, _: Any) -> None: from twisted.internet import reactor @@ -597,6 +613,7 @@ class CrawlerProcessBase(CrawlerRunnerBase): "after", "startup", install_shutdown_handlers, self._signal_shutdown ) + @abstractmethod def _stop_dfd(self) -> Deferred[Any]: raise NotImplementedError diff --git a/scrapy/settings/default_settings.py b/scrapy/settings/default_settings.py index 01443fa17..4a27017a6 100644 --- a/scrapy/settings/default_settings.py +++ b/scrapy/settings/default_settings.py @@ -179,6 +179,8 @@ FEED_STORAGE_S3_ACL = "" FILES_STORE_S3_ACL = "private" FILES_STORE_GCS_ACL = "" +FORCE_CRAWLER_PROCESS = False + FTP_USER = "anonymous" FTP_PASSWORD = "guest" # noqa: S105 FTP_PASSIVE_MODE = True diff --git a/tests/test_command_crawl.py b/tests/test_command_crawl.py index 3d5e17977..0ab0659b2 100644 --- a/tests/test_command_crawl.py +++ b/tests/test_command_crawl.py @@ -30,6 +30,11 @@ class MySpider(scrapy.Spider): """ log = self.get_log(spider_code) assert "[myspider] DEBUG: It works!" in log + assert ( + "Using reactor: twisted.internet.asyncioreactor.AsyncioSelectorReactor" + in log + ) + assert "Spider closed (finished)" in log def test_output(self): spider_code = """ @@ -91,3 +96,23 @@ class MySpider(scrapy.Spider): assert ( "error: Please use only one of -o/--output and -O/--overwrite-output" in log ) + + def test_default_reactor(self): + 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, args=("-s", "TWISTED_REACTOR=")) + assert "[myspider] DEBUG: It works!" in log + assert ( + "Using reactor: twisted.internet.asyncioreactor.AsyncioSelectorReactor" + not in log + ) + assert "Spider closed (finished)" in log diff --git a/tests/test_command_runspider.py b/tests/test_command_runspider.py index 664de16f8..c57c09249 100644 --- a/tests/test_command_runspider.py +++ b/tests/test_command_runspider.py @@ -65,8 +65,10 @@ class BadSpider(scrapy.Spider): def test_runspider(self): log = self.get_log(self.debug_log_spider) assert "DEBUG: It Works!" in log - assert "INFO: Spider opened" in log - assert "INFO: Closing spider (finished)" in log + assert ( + "Using reactor: twisted.internet.asyncioreactor.AsyncioSelectorReactor" + in log + ) assert "INFO: Spider closed (finished)" in log def test_run_fail_spider(self): @@ -88,6 +90,17 @@ class BadSpider(scrapy.Spider): assert "DEBUG: It Works!" not in log assert "INFO: Spider opened" in log + def test_runspider_default_reactor(self): + log = self.get_log(self.debug_log_spider, args=("-s", "TWISTED_REACTOR=")) + assert "DEBUG: It Works!" in log + assert ( + "Using reactor: twisted.internet.asyncioreactor.AsyncioSelectorReactor" + not in log + ) + assert "INFO: Spider opened" in log + assert "INFO: Closing spider (finished)" in log + assert "INFO: Spider closed (finished)" in log + def test_runspider_dnscache_disabled(self): # see https://github.com/scrapy/scrapy/issues/2811 # The spider below should not be able to connect to localhost:12345, diff --git a/tests/test_command_shell.py b/tests/test_command_shell.py index 0f45a7ee8..8041e7cb1 100644 --- a/tests/test_command_shell.py +++ b/tests/test_command_shell.py @@ -7,6 +7,7 @@ from pexpect.popen_spawn import PopenSpawn from twisted.internet import defer from twisted.trial import unittest +from scrapy.utils.reactor import _asyncio_reactor_path from tests import NON_EXISTING_RESOLVABLE, tests_datadir from tests.mockserver import MockServer from tests.utils.testproc import ProcessTest @@ -132,10 +133,9 @@ class TestShellCommand(ProcessTest, SiteTest, unittest.TestCase): @defer.inlineCallbacks def test_shell_fetch_async(self): - reactor_path = "twisted.internet.asyncioreactor.AsyncioSelectorReactor" url = self.url("/html") code = f"fetch('{url}')" - args = ["-c", code, "--set", f"TWISTED_REACTOR={reactor_path}"] + args = ["-c", code, "--set", f"TWISTED_REACTOR={_asyncio_reactor_path}"] _, _, err = yield self.execute(args, check_code=True) assert b"RuntimeError: There is no current event loop in thread" not in err diff --git a/tests/test_commands.py b/tests/test_commands.py index 6e59f561d..8ca5d51e5 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -20,6 +20,7 @@ from scrapy.cmdline import _pop_command_name, _print_unknown_command_msg from scrapy.commands import ScrapyCommand, ScrapyHelpFormatter, view from scrapy.settings import Settings from scrapy.utils.python import to_unicode +from scrapy.utils.reactor import _asyncio_reactor_path from scrapy.utils.test import get_testenv if TYPE_CHECKING: @@ -124,6 +125,221 @@ class TestCommandBase(TestProjectBase): self.env["SCRAPY_SETTINGS_MODULE"] = f"{self.project_name}.settings" +class TestCommandCrawlerProcess(TestCommandBase): + """Test that the command uses the expected kind of *CrawlerProcess + and produces expected errors when needed.""" + + name = "crawltest" + + NORMAL_MSG = "Type of self.crawler_process: " + ASYNC_MSG = ( + "Type of self.crawler_process: " + ) + + def setUp(self): + super().setUp() + (self.cwd / self.project_name / "commands").mkdir(exist_ok=True) + (self.cwd / self.project_name / "commands" / "__init__.py").touch() + (self.cwd / self.project_name / "commands" / f"{self.name}.py").write_text(""" +from scrapy.commands.crawl import Command + +class CrawlerProcessCrawlCommand(Command): + requires_project = True + + def run(self, args, opts): + print(f"Type of self.crawler_process: {type(self.crawler_process)}") + super().run(args, opts) +""") + + self._append_settings(f"COMMANDS_MODULE = '{self.project_name}.commands'\n") + + (self.cwd / self.project_name / "spiders" / "sp.py").write_text(""" +import scrapy + +class MySpider(scrapy.Spider): + name = 'sp' + + custom_settings = {} + + async def start(self): + self.logger.debug('It works!') + return + yield +""") + + (self.cwd / self.project_name / "spiders" / "aiosp.py").write_text(""" +import asyncio + +import scrapy + +class MySpider(scrapy.Spider): + name = 'aiosp' + + custom_settings = {} + + async def start(self): + await asyncio.sleep(0.01) + self.logger.debug('It works!') + return + yield +""") + + def _append_settings(self, text: str) -> None: + """Add text to the end of the project settings.py.""" + with (self.cwd / self.project_name / "settings.py").open( + "a", encoding="utf-8" + ) as f: + f.write(text) + + def _replace_custom_settings(self, spider_name: str, text: str) -> None: + """Replace custom_settings in the given spider file with the given text.""" + spider_path = self.cwd / self.project_name / "spiders" / f"{spider_name}.py" + with spider_path.open("r+", encoding="utf-8") as f: + content = f.read() + content = content.replace( + "custom_settings = {}", f"custom_settings = {text}" + ) + f.seek(0) + f.write(content) + f.truncate() + + def _assert_spider_works(self, msg: str, *args: str) -> None: + """The command uses the expected *CrawlerProcess, the spider works.""" + _, out, err = self.proc(self.name, *args) + assert msg in out, out + assert "It works!" in err, err + assert "Spider closed (finished)" in err, err + + def _assert_spider_asyncio_fail(self, msg: str, *args: str) -> None: + """The command uses the expected *CrawlerProcess, the spider fails to use asyncio.""" + _, out, err = self.proc(self.name, *args) + assert msg in out, out + assert "no running event loop" in err, err + + def test_project_settings(self): + """The reactor is set via the project default settings (to the asyncio value). + + AsyncCrawlerProcess, the asyncio reactor, both spiders work.""" + for spider in ["sp", "aiosp"]: + self._assert_spider_works(self.ASYNC_MSG, spider) + + def test_cmdline_asyncio(self): + """The reactor is set via the command line to the asyncio value. + AsyncCrawlerProcess, the asyncio reactor, both spiders work.""" + for spider in ["sp", "aiosp"]: + self._assert_spider_works( + self.ASYNC_MSG, spider, "-s", f"TWISTED_REACTOR={_asyncio_reactor_path}" + ) + + def test_project_settings_explicit_asyncio(self): + """The reactor explicitly is set via the project settings to the asyncio value. + + AsyncCrawlerProcess, the asyncio reactor, both spiders work.""" + self._append_settings(f"TWISTED_REACTOR = '{_asyncio_reactor_path}'\n") + + for spider in ["sp", "aiosp"]: + self._assert_spider_works(self.ASYNC_MSG, spider) + + def test_cmdline_empty(self): + """The reactor is set via the command line to the empty value. + + CrawlerProcess, the default reactor, only the normal spider works.""" + self._assert_spider_works(self.NORMAL_MSG, "sp", "-s", "TWISTED_REACTOR=") + self._assert_spider_asyncio_fail( + self.NORMAL_MSG, "aiosp", "-s", "TWISTED_REACTOR=" + ) + + def test_project_settings_empty(self): + """The reactor is set via the project settings to the empty value. + + CrawlerProcess, the default reactor, only the normal spider works.""" + self._append_settings("TWISTED_REACTOR = None\n") + + self._assert_spider_works(self.NORMAL_MSG, "sp") + self._assert_spider_asyncio_fail( + self.NORMAL_MSG, "aiosp", "-s", "TWISTED_REACTOR=" + ) + + def test_spider_settings_asyncio(self): + """The reactor is set via the spider settings to the asyncio value. + + AsyncCrawlerProcess, the asyncio reactor, both spiders work.""" + for spider in ["sp", "aiosp"]: + self._replace_custom_settings( + spider, f"{{'TWISTED_REACTOR': '{_asyncio_reactor_path}'}}" + ) + self._assert_spider_works(self.ASYNC_MSG, spider) + + def test_spider_settings_asyncio_cmdline_empty(self): + """The reactor is set via the spider settings to the asyncio value + and via command line to the empty value. The command line value takes + precedence so the spider settings don't matter. + + CrawlerProcess, the default reactor, only the normal spider works.""" + for spider in ["sp", "aiosp"]: + self._replace_custom_settings( + spider, f"{{'TWISTED_REACTOR': '{_asyncio_reactor_path}'}}" + ) + + self._assert_spider_works(self.NORMAL_MSG, "sp", "-s", "TWISTED_REACTOR=") + self._assert_spider_asyncio_fail( + self.NORMAL_MSG, "aiosp", "-s", "TWISTED_REACTOR=" + ) + + def test_project_empty_spider_settings_asyncio(self): + """The reactor is set via the project settings to the empty value + and via the spider settings to the asyncio value. CrawlerProcess is + chosen based on the project settings, but the asyncio reactor is chosen + based on the spider settings. + + CrawlerProcess, the asyncio reactor, both spiders work.""" + self._append_settings("TWISTED_REACTOR = None\n") + for spider in ["sp", "aiosp"]: + self._replace_custom_settings( + spider, f"{{'TWISTED_REACTOR': '{_asyncio_reactor_path}'}}" + ) + self._assert_spider_works(self.NORMAL_MSG, spider) + + def test_project_asyncio_spider_settings_select(self): + """The reactor is set via the project settings to the asyncio value + and via the spider settings to the select value. AsyncCrawlerProcess + is chosen based on the project settings, and the conflicting reactor + setting in the spider settings causes an exception. + + AsyncCrawlerProcess, the asyncio reactor, both spiders produce a + mismatched reactor exception.""" + self._append_settings(f"TWISTED_REACTOR = '{_asyncio_reactor_path}'\n") + for spider in ["sp", "aiosp"]: + self._replace_custom_settings( + spider, + "{'TWISTED_REACTOR': 'twisted.internet.selectreactor.SelectReactor'}", + ) + _, out, err = self.proc(self.name, spider) + assert self.ASYNC_MSG in out, out + assert ( + "The installed reactor (twisted.internet.asyncioreactor.AsyncioSelectorReactor)" + " does not match the requested one" + " (twisted.internet.selectreactor.SelectReactor)" + ) in err, err + + def test_project_asyncio_spider_settings_select_forced(self): + """The reactor is set via the project settings to the asyncio value + and via the spider settings to the select value, CrawlerProcess is + forced via the project settings. The reactor is chosen based on the + spider settings. + + CrawlerProcess, the select reactor, only the normal spider works.""" + self._append_settings("FORCE_CRAWLER_PROCESS = True\n") + for spider in ["sp", "aiosp"]: + self._replace_custom_settings( + spider, + "{'TWISTED_REACTOR': 'twisted.internet.selectreactor.SelectReactor'}", + ) + + self._assert_spider_works(self.NORMAL_MSG, "sp") + self._assert_spider_asyncio_fail(self.NORMAL_MSG, "aiosp") + + class TestMiscCommands(TestCommandBase): def test_list(self): assert self.call("list") == 0 diff --git a/tests/test_utils_defer.py b/tests/test_utils_defer.py index 98962f74c..372213319 100644 --- a/tests/test_utils_defer.py +++ b/tests/test_utils_defer.py @@ -299,7 +299,7 @@ class TestDeferredFromCoro(unittest.TestCase): @inlineCallbacks def test_coroutine_asyncio(self): async def coroutine() -> int: - await asyncio.sleep(0) + await asyncio.sleep(0.01) return 42 result = deferred_from_coro(coroutine()) @@ -379,7 +379,7 @@ class TestDeferredToFuture(unittest.TestCase): @deferred_f_from_coro_f async def test_wrapped_coroutine_asyncio(self): async def c_f() -> int: - await asyncio.sleep(0) + await asyncio.sleep(0.01) return 42 d = deferred_from_coro(c_f()) @@ -414,7 +414,7 @@ class TestMaybeDeferredToFutureAsyncio(unittest.TestCase): @deferred_f_from_coro_f async def test_wrapped_coroutine_asyncio(self): async def c_f() -> int: - await asyncio.sleep(0) + await asyncio.sleep(0.01) return 42 d = deferred_from_coro(c_f()) From 8fb8d2c6b8a83181ef6564fbc9afa9cfeea2bd05 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Tue, 3 Jun 2025 23:21:33 +0500 Subject: [PATCH 015/459] Add AsyncioLoopingCall. (#6855) --- scrapy/core/downloader/__init__.py | 8 ++- scrapy/core/engine.py | 11 ++- scrapy/extensions/closespider.py | 5 +- scrapy/extensions/logstats.py | 12 ++-- scrapy/extensions/memusage.py | 16 +++-- scrapy/extensions/periodic_log.py | 14 ++-- scrapy/utils/asyncio.py | 106 ++++++++++++++++++++++++++++- tests/test_utils_asyncio.py | 48 ++++++++++++- 8 files changed, 195 insertions(+), 25 deletions(-) diff --git a/scrapy/core/downloader/__init__.py b/scrapy/core/downloader/__init__.py index 5468398aa..501c669ce 100644 --- a/scrapy/core/downloader/__init__.py +++ b/scrapy/core/downloader/__init__.py @@ -7,7 +7,6 @@ from datetime import datetime from time import time from typing import TYPE_CHECKING, Any, cast -from twisted.internet import task from twisted.internet.defer import Deferred, inlineCallbacks from scrapy import Request, Spider, signals @@ -15,6 +14,7 @@ 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, create_looping_call from scrapy.utils.defer import ( deferred_from_coro, maybe_deferred_to_future, @@ -25,6 +25,8 @@ from scrapy.utils.httpobj import urlparse_cached if TYPE_CHECKING: from collections.abc import Generator + from twisted.internet.task import LoopingCall + from scrapy.crawler import Crawler from scrapy.http import Response from scrapy.settings import BaseSettings @@ -111,7 +113,9 @@ class Downloader: self.middleware: DownloaderMiddlewareManager = ( DownloaderMiddlewareManager.from_crawler(crawler) ) - self._slot_gc_loop: task.LoopingCall = task.LoopingCall(self._slot_gc) + self._slot_gc_loop: AsyncioLoopingCall | LoopingCall = create_looping_call( + self._slot_gc + ) self._slot_gc_loop.start(60) self.per_slot_settings: dict[str, dict[str, Any]] = self.settings.getdict( "DOWNLOAD_SLOTS", {} diff --git a/scrapy/core/engine.py b/scrapy/core/engine.py index 721c81d81..d9361a674 100644 --- a/scrapy/core/engine.py +++ b/scrapy/core/engine.py @@ -13,13 +13,16 @@ from traceback import format_exc from typing import TYPE_CHECKING, Any, cast from twisted.internet.defer import Deferred, inlineCallbacks, succeed -from twisted.internet.task import LoopingCall from twisted.python.failure import Failure from scrapy import signals from scrapy.core.scraper import Scraper from scrapy.exceptions import CloseSpider, DontCloseSpider, IgnoreRequest from scrapy.http import Request, Response +from scrapy.utils.asyncio import ( + AsyncioLoopingCall, + create_looping_call, +) from scrapy.utils.defer import ( deferred_f_from_coro_f, deferred_from_coro, @@ -32,6 +35,8 @@ from scrapy.utils.reactor import CallLaterOnce if TYPE_CHECKING: from collections.abc import AsyncIterator, Callable, Generator + from twisted.internet.task import LoopingCall + from scrapy.core.downloader import Downloader from scrapy.core.scheduler import BaseScheduler from scrapy.crawler import Crawler @@ -56,7 +61,9 @@ class _Slot: self.close_if_idle: bool = close_if_idle self.nextcall: CallLaterOnce[None] = nextcall self.scheduler: BaseScheduler = scheduler - self.heartbeat: LoopingCall = LoopingCall(nextcall.schedule) + self.heartbeat: AsyncioLoopingCall | LoopingCall = create_looping_call( + nextcall.schedule + ) def add_request(self, request: Request) -> None: self.inprogress.add(request) diff --git a/scrapy/extensions/closespider.py b/scrapy/extensions/closespider.py index dff8bc97e..a649a86e2 100644 --- a/scrapy/extensions/closespider.py +++ b/scrapy/extensions/closespider.py @@ -12,6 +12,7 @@ from typing import TYPE_CHECKING, Any from scrapy import Request, Spider, signals from scrapy.exceptions import NotConfigured +from scrapy.utils.asyncio import create_looping_call if TYPE_CHECKING: from twisted.python.failure import Failure @@ -118,9 +119,7 @@ class CloseSpider: task_no_item.stop() def spider_opened_no_item(self, spider: Spider) -> None: - from twisted.internet import task - - self.task_no_item = task.LoopingCall(self._count_items_produced, spider) + self.task_no_item = create_looping_call(self._count_items_produced, spider) self.task_no_item.start(self.timeout_no_item, now=False) logger.info( diff --git a/scrapy/extensions/logstats.py b/scrapy/extensions/logstats.py index f2e1f57b8..387cfddb3 100644 --- a/scrapy/extensions/logstats.py +++ b/scrapy/extensions/logstats.py @@ -3,12 +3,16 @@ from __future__ import annotations import logging from typing import TYPE_CHECKING -from twisted.internet import task - from scrapy import Spider, signals from scrapy.exceptions import NotConfigured +from scrapy.utils.asyncio import ( + AsyncioLoopingCall, + create_looping_call, +) if TYPE_CHECKING: + from twisted.internet.task import LoopingCall + # typing.Self requires Python 3.11 from typing_extensions import Self @@ -29,7 +33,7 @@ class LogStats: self.stats: StatsCollector = stats self.interval: float = interval self.multiplier: float = 60.0 / self.interval - self.task: task.LoopingCall | None = None + self.task: AsyncioLoopingCall | LoopingCall | None = None @classmethod def from_crawler(cls, crawler: Crawler) -> Self: @@ -46,7 +50,7 @@ class LogStats: self.pagesprev: int = 0 self.itemsprev: int = 0 - self.task = task.LoopingCall(self.log, spider) + self.task = create_looping_call(self.log, spider) self.task.start(self.interval) def log(self, spider: Spider) -> None: diff --git a/scrapy/extensions/memusage.py b/scrapy/extensions/memusage.py index d7f810107..2ef322f1a 100644 --- a/scrapy/extensions/memusage.py +++ b/scrapy/extensions/memusage.py @@ -13,14 +13,18 @@ from importlib import import_module from pprint import pformat from typing import TYPE_CHECKING -from twisted.internet import task - from scrapy import signals from scrapy.exceptions import NotConfigured from scrapy.mail import MailSender +from scrapy.utils.asyncio import ( + AsyncioLoopingCall, + create_looping_call, +) from scrapy.utils.engine import get_engine_status if TYPE_CHECKING: + from twisted.internet.task import LoopingCall + # typing.Self requires Python 3.11 from typing_extensions import Self @@ -66,16 +70,16 @@ class MemoryUsage: def engine_started(self) -> None: assert self.crawler.stats self.crawler.stats.set_value("memusage/startup", self.get_virtual_size()) - self.tasks: list[task.LoopingCall] = [] - tsk = task.LoopingCall(self.update) + self.tasks: list[AsyncioLoopingCall | LoopingCall] = [] + tsk = create_looping_call(self.update) self.tasks.append(tsk) tsk.start(self.check_interval, now=True) if self.limit: - tsk = task.LoopingCall(self._check_limit) + tsk = create_looping_call(self._check_limit) self.tasks.append(tsk) tsk.start(self.check_interval, now=True) if self.warning: - tsk = task.LoopingCall(self._check_warning) + tsk = create_looping_call(self._check_warning) self.tasks.append(tsk) tsk.start(self.check_interval, now=True) diff --git a/scrapy/extensions/periodic_log.py b/scrapy/extensions/periodic_log.py index f97577442..9158482fa 100644 --- a/scrapy/extensions/periodic_log.py +++ b/scrapy/extensions/periodic_log.py @@ -4,16 +4,20 @@ import logging from datetime import datetime, timezone from typing import TYPE_CHECKING, Any -from twisted.internet import task - from scrapy import Spider, signals from scrapy.exceptions import NotConfigured +from scrapy.utils.asyncio import ( + AsyncioLoopingCall, + create_looping_call, +) from scrapy.utils.serialize import ScrapyJSONEncoder if TYPE_CHECKING: - # typing.Self requires Python 3.11 from json import JSONEncoder + from twisted.internet.task import LoopingCall + + # typing.Self requires Python 3.11 from typing_extensions import Self from scrapy.crawler import Crawler @@ -37,7 +41,7 @@ class PeriodicLog: self.stats: StatsCollector = stats self.interval: float = interval self.multiplier: float = 60.0 / self.interval - self.task: task.LoopingCall | None = None + 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", []) @@ -97,7 +101,7 @@ class PeriodicLog: self.delta_prev: dict[str, int | float] = {} self.stats_prev: dict[str, int | float] = {} - self.task = task.LoopingCall(self.log) + self.task = create_looping_call(self.log) self.task.start(self.interval) def log(self) -> None: diff --git a/scrapy/utils/asyncio.py b/scrapy/utils/asyncio.py index a3f27bcc9..cae2dc033 100644 --- a/scrapy/utils/asyncio.py +++ b/scrapy/utils/asyncio.py @@ -3,22 +3,30 @@ from __future__ import annotations import asyncio +import logging +import time +from collections.abc import AsyncIterator, Callable, Coroutine, Iterable from typing import TYPE_CHECKING, Any, TypeVar +from twisted.internet.defer import Deferred +from twisted.internet.task import LoopingCall + from scrapy.utils.asyncgen import as_async_generator from scrapy.utils.reactor import is_asyncio_reactor_installed, is_reactor_installed if TYPE_CHECKING: - from collections.abc import AsyncIterator, Callable, Coroutine, Iterable - # typing.Concatenate and typing.ParamSpec require Python 3.10 from typing_extensions import Concatenate, ParamSpec _P = ParamSpec("_P") + _T = TypeVar("_T") +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. @@ -90,3 +98,97 @@ async def _parallel_asyncio( fill_task = asyncio.create_task(fill_queue()) work_tasks = [asyncio.create_task(worker()) for _ in range(count)] await asyncio.wait([fill_task, *work_tasks]) + + +class AsyncioLoopingCall: + """A simple implementation of a periodic call using asyncio, keeping + some API and behavior compatibility with the Twisted ``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``. + """ + + def __init__(self, func: Callable[_P, _T], *args: _P.args, **kwargs: _P.kwargs): + self._func: Callable[_P, _T] = func + self._args: tuple[Any, ...] = args + self._kwargs: dict[str, Any] = kwargs + self._task: asyncio.Task | None = None + self.interval: float | None = None + self._start_time: float | None = None + + @property + def running(self) -> bool: + return self._start_time is not None + + def start(self, interval: float, now: bool = True) -> None: + """Start calling the function every *interval* seconds. + + :param interval: The interval in seconds between calls. + :type interval: float + + :param now: If ``True``, also call the function immediately. + :type now: bool + """ + if self.running: + raise RuntimeError("AsyncioLoopingCall already running") + + if interval <= 0: + raise ValueError("Interval must be greater than 0") + + self.interval = interval + self._start_time = time.time() + if now: + self._call() + loop = asyncio.get_event_loop() + self._task = loop.create_task(self._loop()) + + def _to_sleep(self) -> float: + """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() + running_for = now - self._start_time + return self.interval - (running_for % self.interval) + + async def _loop(self) -> None: + """Run an infinite loop that calls the function periodically.""" + while self.running: + await asyncio.sleep(self._to_sleep()) + self._call() + + def stop(self) -> None: + """Stop the periodic calls.""" + self.interval = self._start_time = None + if self._task is not None: + self._task.cancel() + self._task = None + + def _call(self) -> None: + """Execute the function.""" + try: + result = self._func(*self._args, **self._kwargs) + except Exception: + logger.exception("Error calling the AsyncioLoopingCall function") + self.stop() + else: + if isinstance(result, (Coroutine, Deferred)): + self.stop() + raise TypeError( + "The AsyncioLoopingCall function must not return a coroutine or a Deferred" + ) + + +def create_looping_call( + func: Callable[_P, _T], *args: _P.args, **kwargs: _P.kwargs +) -> 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. + """ + if is_asyncio_available(): + return AsyncioLoopingCall(func, *args, **kwargs) + return LoopingCall(func, *args, **kwargs) diff --git a/tests/test_utils_asyncio.py b/tests/test_utils_asyncio.py index 6c47965a3..a6e52eb26 100644 --- a/tests/test_utils_asyncio.py +++ b/tests/test_utils_asyncio.py @@ -3,12 +3,18 @@ from __future__ import annotations import asyncio import random from typing import TYPE_CHECKING +from unittest import mock import pytest +from twisted.internet.defer import Deferred from twisted.trial import unittest from scrapy.utils.asyncgen import as_async_generator -from scrapy.utils.asyncio import _parallel_asyncio, is_asyncio_available +from scrapy.utils.asyncio import ( + AsyncioLoopingCall, + _parallel_asyncio, + is_asyncio_available, +) from scrapy.utils.defer import deferred_f_from_coro_f if TYPE_CHECKING: @@ -97,3 +103,43 @@ class TestParallelAsyncio(unittest.TestCase): ) assert list(range(length)) == sorted(results) assert max_parallel_count[0] <= self.CONCURRENT_ITEMS + + +@pytest.mark.only_asyncio +class TestAsyncioLoopingCall: + def test_looping_call(self): + func = mock.MagicMock() + looping_call = AsyncioLoopingCall(func) + looping_call.start(1, now=False) + assert looping_call.running + looping_call.stop() + assert not looping_call.running + assert not func.called + + 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): + 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): + looping_call = AsyncioLoopingCall(lambda: None) + with pytest.raises(ValueError, match="Interval must be greater than 0"): + looping_call.start(0) + with pytest.raises(ValueError, match="Interval must be greater than 0"): + looping_call.start(-1) + assert not looping_call.running + + def test_looping_call_bad_function(self): + looping_call = AsyncioLoopingCall(Deferred) + with pytest.raises(TypeError): + looping_call.start(0.1) + assert not looping_call.running From c6698b9fe8d3e1f27f2982b484ba69486a8cbc3d Mon Sep 17 00:00:00 2001 From: Mehraz Hossain Rumman <59512321+MehrazRumman@users.noreply.github.com> Date: Wed, 4 Jun 2025 17:02:29 +0600 Subject: [PATCH 016/459] fixing settings order (#6849) * fixing issue #6838 * Reorder some more settings. * Clarify the header. --------- Co-authored-by: Andrey Rakhmatullin --- scrapy/settings/default_settings.py | 146 ++++++++++++++-------------- 1 file changed, 71 insertions(+), 75 deletions(-) diff --git a/scrapy/settings/default_settings.py b/scrapy/settings/default_settings.py index 4a27017a6..7cd470f11 100644 --- a/scrapy/settings/default_settings.py +++ b/scrapy/settings/default_settings.py @@ -1,16 +1,16 @@ -""" -This module contains the default values for all settings used by Scrapy. +"""This module contains the default values for all settings used by Scrapy. For more information about these settings you can read the settings documentation in docs/topics/settings.rst Scrapy developers, if you add a setting here remember to: -* add it in alphabetical order +* add it in alphabetical order, with the exception that enabling flags and + other high-level settings for a group should come first in their group + and pairs like host/port and user/password should be in the usual order * group similar settings without leaving blank lines * add its documentation to the available settings documentation (docs/topics/settings.rst) - """ import sys @@ -31,10 +31,10 @@ AUTOTHROTTLE_TARGET_CONCURRENCY = 1.0 BOT_NAME = "scrapybot" -CLOSESPIDER_TIMEOUT = 0 -CLOSESPIDER_PAGECOUNT = 0 -CLOSESPIDER_ITEMCOUNT = 0 CLOSESPIDER_ERRORCOUNT = 0 +CLOSESPIDER_ITEMCOUNT = 0 +CLOSESPIDER_PAGECOUNT = 0 +CLOSESPIDER_TIMEOUT = 0 COMMANDS_MODULE = "" @@ -59,8 +59,8 @@ DEFAULT_REQUEST_HEADERS = { } DEPTH_LIMIT = 0 -DEPTH_STATS_VERBOSE = False DEPTH_PRIORITY = 0 +DEPTH_STATS_VERBOSE = False DNSCACHE_ENABLED = True DNSCACHE_SIZE = 10000 @@ -69,6 +69,8 @@ DNS_TIMEOUT = 60 DOWNLOAD_DELAY = 0 +DOWNLOAD_FAIL_ON_DATALOSS = True + DOWNLOAD_HANDLERS = {} DOWNLOAD_HANDLERS_BASE = { "data": "scrapy.core.downloader.handlers.datauri.DataURIDownloadHandler", @@ -79,18 +81,13 @@ DOWNLOAD_HANDLERS_BASE = { "ftp": "scrapy.core.downloader.handlers.ftp.FTPDownloadHandler", } -DOWNLOAD_TIMEOUT = 180 # 3mins - DOWNLOAD_MAXSIZE = 1024 * 1024 * 1024 # 1024m DOWNLOAD_WARNSIZE = 32 * 1024 * 1024 # 32m -DOWNLOAD_FAIL_ON_DATALOSS = True +DOWNLOAD_TIMEOUT = 180 # 3mins DOWNLOADER = "scrapy.core.downloader.Downloader" -DOWNLOADER_HTTPCLIENTFACTORY = ( - "scrapy.core.downloader.webclient.ScrapyHTTPClientFactory" -) DOWNLOADER_CLIENTCONTEXTFACTORY = ( "scrapy.core.downloader.contextfactory.ScrapyClientContextFactory" ) @@ -99,8 +96,11 @@ DOWNLOADER_CLIENT_TLS_CIPHERS = "DEFAULT" DOWNLOADER_CLIENT_TLS_METHOD = "TLS" DOWNLOADER_CLIENT_TLS_VERBOSE_LOGGING = False -DOWNLOADER_MIDDLEWARES = {} +DOWNLOADER_HTTPCLIENTFACTORY = ( + "scrapy.core.downloader.webclient.ScrapyHTTPClientFactory" +) +DOWNLOADER_MIDDLEWARES = {} DOWNLOADER_MIDDLEWARES_BASE = { # Engine side "scrapy.downloadermiddlewares.offsite.OffsiteMiddleware": 50, @@ -130,7 +130,6 @@ if sys.platform == "win32": EDITOR = "%s -m idlelib.idle" EXTENSIONS = {} - EXTENSIONS_BASE = { "scrapy.extensions.corestats.CoreStats": 0, "scrapy.extensions.telnet.TelnetConsole": 0, @@ -143,22 +142,11 @@ EXTENSIONS_BASE = { "scrapy.extensions.throttle.AutoThrottle": 0, } -FEED_TEMPDIR = None FEEDS = {} -FEED_URI_PARAMS = None # a function to extend uri arguments -FEED_STORE_EMPTY = True +FEED_EXPORT_BATCH_ITEM_COUNT = 0 FEED_EXPORT_ENCODING = None FEED_EXPORT_FIELDS = None -FEED_STORAGES = {} -FEED_STORAGES_BASE = { - "": "scrapy.extensions.feedexport.FileFeedStorage", - "file": "scrapy.extensions.feedexport.FileFeedStorage", - "ftp": "scrapy.extensions.feedexport.FTPFeedStorage", - "gs": "scrapy.extensions.feedexport.GCSFeedStorage", - "s3": "scrapy.extensions.feedexport.S3FeedStorage", - "stdout": "scrapy.extensions.feedexport.StdoutFeedStorage", -} -FEED_EXPORT_BATCH_ITEM_COUNT = 0 +FEED_EXPORT_INDENT = 0 FEED_EXPORTERS = {} FEED_EXPORTERS_BASE = { "json": "scrapy.exporters.JsonItemExporter", @@ -170,59 +158,69 @@ FEED_EXPORTERS_BASE = { "marshal": "scrapy.exporters.MarshalItemExporter", "pickle": "scrapy.exporters.PickleItemExporter", } -FEED_EXPORT_INDENT = 0 - +FEED_STORE_EMPTY = True +FEED_STORAGES = {} +FEED_STORAGES_BASE = { + "": "scrapy.extensions.feedexport.FileFeedStorage", + "file": "scrapy.extensions.feedexport.FileFeedStorage", + "ftp": "scrapy.extensions.feedexport.FTPFeedStorage", + "gs": "scrapy.extensions.feedexport.GCSFeedStorage", + "s3": "scrapy.extensions.feedexport.S3FeedStorage", + "stdout": "scrapy.extensions.feedexport.StdoutFeedStorage", +} FEED_STORAGE_FTP_ACTIVE = False FEED_STORAGE_GCS_ACL = "" FEED_STORAGE_S3_ACL = "" +FEED_TEMPDIR = None +FEED_URI_PARAMS = None # a function to extend uri arguments -FILES_STORE_S3_ACL = "private" FILES_STORE_GCS_ACL = "" +FILES_STORE_S3_ACL = "private" FORCE_CRAWLER_PROCESS = False +FTP_PASSIVE_MODE = True FTP_USER = "anonymous" FTP_PASSWORD = "guest" # noqa: S105 -FTP_PASSIVE_MODE = True GCS_PROJECT_ID = None HTTPCACHE_ENABLED = False -HTTPCACHE_DIR = "httpcache" -HTTPCACHE_IGNORE_MISSING = False -HTTPCACHE_STORAGE = "scrapy.extensions.httpcache.FilesystemCacheStorage" -HTTPCACHE_EXPIRATION_SECS = 0 HTTPCACHE_ALWAYS_STORE = False -HTTPCACHE_IGNORE_HTTP_CODES = [] -HTTPCACHE_IGNORE_SCHEMES = ["file"] -HTTPCACHE_IGNORE_RESPONSE_CACHE_CONTROLS = [] HTTPCACHE_DBM_MODULE = "dbm" -HTTPCACHE_POLICY = "scrapy.extensions.httpcache.DummyPolicy" +HTTPCACHE_DIR = "httpcache" +HTTPCACHE_EXPIRATION_SECS = 0 HTTPCACHE_GZIP = False +HTTPCACHE_IGNORE_HTTP_CODES = [] +HTTPCACHE_IGNORE_MISSING = False +HTTPCACHE_IGNORE_RESPONSE_CACHE_CONTROLS = [] +HTTPCACHE_IGNORE_SCHEMES = ["file"] +HTTPCACHE_POLICY = "scrapy.extensions.httpcache.DummyPolicy" +HTTPCACHE_STORAGE = "scrapy.extensions.httpcache.FilesystemCacheStorage" HTTPPROXY_ENABLED = True HTTPPROXY_AUTH_ENCODING = "latin-1" -IMAGES_STORE_S3_ACL = "private" IMAGES_STORE_GCS_ACL = "" - -ITEM_PROCESSOR = "scrapy.pipelines.ItemPipelineManager" +IMAGES_STORE_S3_ACL = "private" ITEM_PIPELINES = {} ITEM_PIPELINES_BASE = {} +ITEM_PROCESSOR = "scrapy.pipelines.ItemPipelineManager" + JOBDIR = None LOG_ENABLED = True -LOG_ENCODING = "utf-8" -LOG_FORMATTER = "scrapy.logformatter.LogFormatter" -LOG_FORMAT = "%(asctime)s [%(name)s] %(levelname)s: %(message)s" LOG_DATEFORMAT = "%Y-%m-%d %H:%M:%S" -LOG_STDOUT = False -LOG_LEVEL = "DEBUG" +LOG_ENCODING = "utf-8" LOG_FILE = None LOG_FILE_APPEND = True +LOG_FORMAT = "%(asctime)s [%(name)s] %(levelname)s: %(message)s" +LOG_FORMATTER = "scrapy.logformatter.LogFormatter" +LOG_LEVEL = "DEBUG" LOG_SHORT_NAMES = False +LOG_STDOUT = False LOG_VERSIONS = [ "lxml", "libxml2", @@ -236,21 +234,19 @@ LOG_VERSIONS = [ "Platform", ] -SCHEDULER_DEBUG = False - LOGSTATS_INTERVAL = 60.0 +MAIL_FROM = "scrapy@localhost" MAIL_HOST = "localhost" MAIL_PORT = 25 -MAIL_FROM = "scrapy@localhost" -MAIL_PASS = None MAIL_USER = None +MAIL_PASS = None MEMDEBUG_ENABLED = False # enable memory debugging MEMDEBUG_NOTIFY = [] # send memory debugging report by mail at engine shutdown -MEMUSAGE_CHECK_INTERVAL_SECONDS = 60.0 MEMUSAGE_ENABLED = True +MEMUSAGE_CHECK_INTERVAL_SECONDS = 60.0 MEMUSAGE_LIMIT_MB = 0 MEMUSAGE_NOTIFY_MAIL = [] MEMUSAGE_WARNING_MB = 0 @@ -280,9 +276,6 @@ REQUEST_FINGERPRINTER_CLASS = "scrapy.utils.request.RequestFingerprinter" REQUEST_FINGERPRINTER_IMPLEMENTATION = "SENTINEL" RETRY_ENABLED = True -RETRY_TIMES = 2 # initial response + 2 retries = 3 requests -RETRY_HTTP_CODES = [500, 502, 503, 504, 522, 524, 408, 429] -RETRY_PRIORITY_ADJUST = -1 RETRY_EXCEPTIONS = [ "twisted.internet.defer.TimeoutError", "twisted.internet.error.TimeoutError", @@ -298,12 +291,16 @@ RETRY_EXCEPTIONS = [ OSError, "scrapy.core.downloader.handlers.http11.TunnelError", ] +RETRY_HTTP_CODES = [500, 502, 503, 504, 522, 524, 408, 429] +RETRY_PRIORITY_ADJUST = -1 +RETRY_TIMES = 2 # initial response + 2 retries = 3 requests ROBOTSTXT_OBEY = False ROBOTSTXT_PARSER = "scrapy.robotstxt.ProtegoRobotParser" ROBOTSTXT_USER_AGENT = None 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" @@ -312,11 +309,19 @@ SCHEDULER_START_MEMORY_QUEUE = "scrapy.squeues.FifoMemoryQueue" SCRAPER_SLOT_MAX_ACTIVE_SIZE = 5000000 +SPIDER_CONTRACTS = {} +SPIDER_CONTRACTS_BASE = { + "scrapy.contracts.default.UrlContract": 1, + "scrapy.contracts.default.CallbackKeywordArgumentsContract": 1, + "scrapy.contracts.default.MetadataContract": 1, + "scrapy.contracts.default.ReturnsContract": 2, + "scrapy.contracts.default.ScrapesContract": 3, +} + SPIDER_LOADER_CLASS = "scrapy.spiderloader.SpiderLoader" SPIDER_LOADER_WARN_ONLY = False SPIDER_MIDDLEWARES = {} - SPIDER_MIDDLEWARES_BASE = { # Engine side "scrapy.spidermiddlewares.start.StartSpiderMiddleware": 25, @@ -334,27 +339,18 @@ STATS_DUMP = True STATSMAILER_RCPTS = [] +TELNETCONSOLE_ENABLED = 1 +TELNETCONSOLE_HOST = "127.0.0.1" +TELNETCONSOLE_PORT = [6023, 6073] +TELNETCONSOLE_USERNAME = "scrapy" +TELNETCONSOLE_PASSWORD = None + TEMPLATES_DIR = str((Path(__file__).parent / ".." / "templates").resolve()) +TWISTED_REACTOR = "twisted.internet.asyncioreactor.AsyncioSelectorReactor" + URLLENGTH_LIMIT = 2083 USER_AGENT = f"Scrapy/{import_module('scrapy').__version__} (+https://scrapy.org)" -TELNETCONSOLE_ENABLED = 1 -TELNETCONSOLE_PORT = [6023, 6073] -TELNETCONSOLE_HOST = "127.0.0.1" -TELNETCONSOLE_USERNAME = "scrapy" -TELNETCONSOLE_PASSWORD = None - -TWISTED_REACTOR = "twisted.internet.asyncioreactor.AsyncioSelectorReactor" - -SPIDER_CONTRACTS = {} -SPIDER_CONTRACTS_BASE = { - "scrapy.contracts.default.UrlContract": 1, - "scrapy.contracts.default.CallbackKeywordArgumentsContract": 1, - "scrapy.contracts.default.MetadataContract": 1, - "scrapy.contracts.default.ReturnsContract": 2, - "scrapy.contracts.default.ScrapesContract": 3, -} - WARN_ON_GENERATOR_RETURN_VALUE = True From 5902aab25ce2ef0b26e158e0455ee6f0846636bb Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Wed, 4 Jun 2025 20:37:36 +0500 Subject: [PATCH 017/459] Add the call_later() wrapper. (#6858) --- scrapy/core/downloader/__init__.py | 25 +++++++----- scrapy/extensions/closespider.py | 33 ++++++++++------ scrapy/utils/asyncio.py | 63 +++++++++++++++++++++++++++++- scrapy/utils/reactor.py | 13 +++--- 4 files changed, 107 insertions(+), 27 deletions(-) diff --git a/scrapy/core/downloader/__init__.py b/scrapy/core/downloader/__init__.py index 501c669ce..9293d7b78 100644 --- a/scrapy/core/downloader/__init__.py +++ b/scrapy/core/downloader/__init__.py @@ -14,7 +14,12 @@ 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, create_looping_call +from scrapy.utils.asyncio import ( + AsyncioLoopingCall, + CallLaterResult, + call_later, + create_looping_call, +) from scrapy.utils.defer import ( deferred_from_coro, maybe_deferred_to_future, @@ -50,7 +55,7 @@ class Slot: self.queue: deque[tuple[Request, Deferred[Response]]] = deque() self.transferring: set[Request] = set() self.lastseen: float = 0 - self.latercall = None + self.latercall: CallLaterResult | None = None def free_transfer_slots(self) -> int: return self.concurrency - len(self.transferring) @@ -61,8 +66,9 @@ class Slot: return self.delay def close(self) -> None: - if self.latercall and self.latercall.active(): + if self.latercall: self.latercall.cancel() + self.latercall = None def __repr__(self) -> str: cls_name = self.__class__.__name__ @@ -191,9 +197,8 @@ class Downloader: slot.active.remove(request) def _process_queue(self, spider: Spider, slot: Slot) -> None: - from twisted.internet import reactor - - if slot.latercall and slot.latercall.active(): + if slot.latercall: + # block processing until slot.latercall is called return # Delay queue processing if a download_delay is configured @@ -202,9 +207,7 @@ class Downloader: if delay: penalty = delay - now + slot.lastseen if penalty > 0: - slot.latercall = reactor.callLater( - penalty, self._process_queue, spider, slot - ) + slot.latercall = call_later(penalty, self._latercall, spider, slot) return # Process enqueued requests if there are free slots to transfer for this slot @@ -218,6 +221,10 @@ class Downloader: self._process_queue(spider, slot) break + def _latercall(self, spider: Spider, slot: Slot) -> None: + slot.latercall = None + self._process_queue(spider, slot) + async def _download(self, slot: Slot, request: Request, spider: Spider) -> Response: # The order is very important for the following logic. Do not change! slot.transferring.add(request) diff --git a/scrapy/extensions/closespider.py b/scrapy/extensions/closespider.py index a649a86e2..b4c6c73a0 100644 --- a/scrapy/extensions/closespider.py +++ b/scrapy/extensions/closespider.py @@ -12,9 +12,15 @@ from typing import TYPE_CHECKING, Any from scrapy import Request, Spider, signals from scrapy.exceptions import NotConfigured -from scrapy.utils.asyncio import create_looping_call +from scrapy.utils.asyncio import ( + AsyncioLoopingCall, + CallLaterResult, + call_later, + create_looping_call, +) if TYPE_CHECKING: + from twisted.internet.task import LoopingCall from twisted.python.failure import Failure # typing.Self requires Python 3.11 @@ -31,6 +37,12 @@ class CloseSpider: def __init__(self, crawler: Crawler): self.crawler: Crawler = crawler + # for CLOSESPIDER_TIMEOUT + self.task: CallLaterResult | None = None + + # for CLOSESPIDER_TIMEOUT_NO_ITEM + self.task_no_item: AsyncioLoopingCall | LoopingCall | None = None + self.close_on: dict[str, Any] = { "timeout": crawler.settings.getfloat("CLOSESPIDER_TIMEOUT"), "itemcount": crawler.settings.getint("CLOSESPIDER_ITEMCOUNT"), @@ -92,14 +104,12 @@ class CloseSpider: self.crawler.engine.close_spider(spider, "closespider_pagecount_no_item") def spider_opened(self, spider: Spider) -> None: - from twisted.internet import reactor - assert self.crawler.engine - self.task = reactor.callLater( + self.task = call_later( self.close_on["timeout"], self.crawler.engine.close_spider, spider, - reason="closespider_timeout", + "closespider_timeout", ) def item_scraped(self, item: Any, spider: Spider) -> None: @@ -110,13 +120,14 @@ class CloseSpider: self.crawler.engine.close_spider(spider, "closespider_itemcount") def spider_closed(self, spider: Spider) -> None: - task = getattr(self, "task", None) - if task and task.active(): - task.cancel() + if self.task: + self.task.cancel() + self.task = None - task_no_item = getattr(self, "task_no_item", None) - if task_no_item and task_no_item.running: - task_no_item.stop() + if self.task_no_item: + if self.task_no_item.running: + self.task_no_item.stop() + self.task_no_item = None def spider_opened_no_item(self, spider: Spider) -> None: self.task_no_item = create_looping_call(self._count_items_produced, spider) diff --git a/scrapy/utils/asyncio.py b/scrapy/utils/asyncio.py index cae2dc033..8c5b843cb 100644 --- a/scrapy/utils/asyncio.py +++ b/scrapy/utils/asyncio.py @@ -15,10 +15,14 @@ from scrapy.utils.asyncgen import as_async_generator from scrapy.utils.reactor import is_asyncio_reactor_installed, is_reactor_installed if TYPE_CHECKING: + from twisted.internet.base import DelayedCall + # typing.Concatenate and typing.ParamSpec require Python 3.10 - from typing_extensions import Concatenate, ParamSpec + # typing.Self, typing.TypeVarTuple and typing.Unpack require Python 3.11 + from typing_extensions import Concatenate, ParamSpec, Self, TypeVarTuple, Unpack _P = ParamSpec("_P") + _Ts = TypeVarTuple("_Ts") _T = TypeVar("_T") @@ -192,3 +196,60 @@ def create_looping_call( if is_asyncio_available(): return AsyncioLoopingCall(func, *args, **kwargs) return LoopingCall(func, *args, **kwargs) + + +def call_later( + delay: float, func: Callable[[Unpack[_Ts]], object], *args: Unpack[_Ts] +) -> 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. + """ + if is_asyncio_available(): + loop = asyncio.get_event_loop() + return CallLaterResult.from_asyncio(loop.call_later(delay, func, *args)) + + from twisted.internet import reactor + + return CallLaterResult.from_twisted(reactor.callLater(delay, func, *args)) + + +class CallLaterResult: + """An universal result for :func:`call_later`, wrapping either + :class:`asyncio.TimerHandle` or :class:`twisted.internet.base.DelayedCall`. + + The provided API is close to the :class:`asyncio.TimerHandle` one: there is + no ``active()`` (as there is no such public API in + :class:`asyncio.TimerHandle`) but ``cancel()`` can be called on already + called or cancelled instances. + """ + + _timer_handle: asyncio.TimerHandle | None = None + _delayed_call: DelayedCall | None = None + + @classmethod + def from_asyncio(cls, timer_handle: asyncio.TimerHandle) -> Self: + """Create a CallLaterResult from an asyncio TimerHandle.""" + o = cls() + o._timer_handle = timer_handle + return o + + @classmethod + def from_twisted(cls, delayed_call: DelayedCall) -> Self: + """Create a CallLaterResult from a Twisted DelayedCall.""" + o = cls() + o._delayed_call = delayed_call + return o + + def cancel(self) -> None: + """Cancel the underlying delayed call. + + Does nothing if the delayed call was already called or cancelled. + """ + if self._timer_handle: + self._timer_handle.cancel() + self._timer_handle = None + elif self._delayed_call and self._delayed_call.active(): + self._delayed_call.cancel() + self._delayed_call = None diff --git a/scrapy/utils/reactor.py b/scrapy/utils/reactor.py index 2fb1e0ce7..76f42392b 100644 --- a/scrapy/utils/reactor.py +++ b/scrapy/utils/reactor.py @@ -16,13 +16,14 @@ if TYPE_CHECKING: from asyncio import AbstractEventLoop, AbstractEventLoopPolicy from collections.abc import Callable - from twisted.internet.base import DelayedCall from twisted.internet.protocol import ServerFactory from twisted.internet.tcp import Port # typing.ParamSpec requires Python 3.10 from typing_extensions import ParamSpec + from scrapy.utils.asyncio import CallLaterResult + _P = ParamSpec("_P") _T = TypeVar("_T") @@ -55,27 +56,27 @@ class CallLaterOnce(Generic[_T]): self._func: Callable[_P, _T] = func self._a: tuple[Any, ...] = a self._kw: dict[str, Any] = kw - self._call: DelayedCall | None = None + self._call: CallLaterResult | None = None self._deferreds: list[Deferred] = [] def schedule(self, delay: float = 0) -> None: - from twisted.internet import reactor + from scrapy.utils.asyncio import call_later if self._call is None: - self._call = reactor.callLater(delay, self) + self._call = call_later(delay, self) def cancel(self) -> None: if self._call: self._call.cancel() def __call__(self) -> _T: - from twisted.internet import reactor + from scrapy.utils.asyncio import call_later self._call = None result = self._func(*self._a, **self._kw) for d in self._deferreds: - reactor.callLater(0, d.callback, None) + call_later(0, d.callback, None) self._deferreds = [] return result From d602f13e8cd22154936ded9c9356e28fe3be4cd4 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Thu, 5 Jun 2025 18:02:31 +0500 Subject: [PATCH 018/459] Fix a regression in errback result handling. (#6863) --- scrapy/core/scraper.py | 19 ++++++++----- scrapy/logformatter.py | 4 +-- tests/test_crawl.py | 61 +++++++++++++++++++++++++++++++++++++++++- 3 files changed, 74 insertions(+), 10 deletions(-) diff --git a/scrapy/core/scraper.py b/scrapy/core/scraper.py index 9fd68bce5..1f0d57c63 100644 --- a/scrapy/core/scraper.py +++ b/scrapy/core/scraper.py @@ -189,6 +189,7 @@ class Scraper: ) assert self.crawler.spider + output: Iterable[Any] | AsyncIterator[Any] if isinstance(result, Response): try: # call the spider middlewares and the request callback with the response @@ -203,7 +204,7 @@ class Scraper: try: # call the request errback with the downloader error - await self.call_spider_async(result, request) + output = await self.call_spider_async(result, request) except Exception as spider_exc: # the errback didn't silence the exception if not result.check(IgnoreRequest): @@ -218,6 +219,8 @@ class Scraper: if spider_exc is not result.value: # the errback raised a different exception, handle it self.handle_spider_error(Failure(), request, result) + else: + await self.handle_spider_output_async(output, request, result) def call_spider( self, result: Response | Failure, request: Request, spider: Spider | None = None @@ -308,7 +311,7 @@ class Scraper: self, result: Iterable[_T] | AsyncIterator[_T], request: Request, - response: Response, + response: Response | Failure, spider: Spider | None = None, ) -> Deferred[None]: """Pass items/requests produced by a callback to ``_process_spidermw_output()`` in parallel.""" @@ -326,7 +329,7 @@ class Scraper: self, result: Iterable[_T] | AsyncIterator[_T], request: Request, - response: Response, + response: Response | Failure, ) -> None: """Pass items/requests produced by a callback to ``_process_spidermw_output()`` in parallel.""" it: Iterable[_T] | AsyncIterator[_T] @@ -361,7 +364,7 @@ class Scraper: ) def _process_spidermw_output( - self, output: Any, response: Response + self, output: Any, response: Response | Failure ) -> Deferred[None]: """Process each Request/Item (given in the output parameter) returned from the given spider. @@ -371,7 +374,7 @@ class Scraper: return deferred_from_coro(self._process_spidermw_output_async(output, response)) async def _process_spidermw_output_async( - self, output: Any, response: Response + self, output: Any, response: Response | Failure ) -> None: """Process each Request/Item (given in the output parameter) returned from the given spider. @@ -385,7 +388,9 @@ class Scraper: if output is not None: await self.start_itemproc_async(output, response=response) - def start_itemproc(self, item: Any, *, response: Response | None) -> Deferred[None]: + def start_itemproc( + self, item: Any, *, response: Response | Failure | None + ) -> Deferred[None]: """Send *item* to the item pipelines for processing. *response* is the source of the item data. If the item does not come @@ -394,7 +399,7 @@ class Scraper: return deferred_from_coro(self.start_itemproc_async(item, response=response)) async def start_itemproc_async( - self, item: Any, *, response: Response | None + self, item: Any, *, response: Response | Failure | None ) -> None: """Send *item* to the item pipelines for processing. diff --git a/scrapy/logformatter.py b/scrapy/logformatter.py index 4f08918ae..e81a9ec93 100644 --- a/scrapy/logformatter.py +++ b/scrapy/logformatter.py @@ -116,7 +116,7 @@ class LogFormatter: self, item: Any, exception: BaseException, - response: Response | None, + response: Response | Failure | None, spider: Spider, ) -> LogFormatterResult: """Logs a message when an item is dropped while it is passing through the item pipeline.""" @@ -137,7 +137,7 @@ class LogFormatter: self, item: Any, exception: BaseException, - response: Response | None, + response: Response | Failure | None, spider: Spider, ) -> LogFormatterResult: """Logs a message when an item causes an error while it is passing diff --git a/tests/test_crawl.py b/tests/test_crawl.py index b90706027..8289b2243 100644 --- a/tests/test_crawl.py +++ b/tests/test_crawl.py @@ -5,6 +5,7 @@ import logging import unittest from ipaddress import IPv4Address from socket import gethostbyname +from typing import Any from urllib.parse import urlparse import pytest @@ -419,6 +420,8 @@ with multiples lines class TestCrawlSpider(TestCase): + mockserver: MockServer + @classmethod def setUpClass(cls): cls.mockserver = MockServer() @@ -756,6 +759,34 @@ class TestCrawlSpider(TestCase): ) assert "Spider error processing" in str(log) + @defer.inlineCallbacks + def test_spider_errback_item(self): + 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 + ) + 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) + + @defer.inlineCallbacks + def test_spider_errback_request(self): + def eb(failure: Failure) -> Request: + return Request(self.mockserver.url("/")) + + crawler = get_crawler(SingleRequestSpider) + with LogCapture() as log: + yield crawler.crawl( + seed=self.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) + @defer.inlineCallbacks def test_spider_errback_downloader_error(self): failures = [] @@ -774,7 +805,7 @@ class TestCrawlSpider(TestCase): assert "Spider error processing" not in str(log) @defer.inlineCallbacks - def test_spider_errback_exception_downloader_error(self): + def test_spider_errback_downloader_error_exception(self): def eb(failure: Failure) -> None: raise ValueError("foo") @@ -786,6 +817,34 @@ class TestCrawlSpider(TestCase): assert "Error downloading" in str(log) assert "Spider error processing" in str(log) + @defer.inlineCallbacks + def test_spider_errback_downloader_error_item(self): + 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 + ) + 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) + + @defer.inlineCallbacks + def test_spider_errback_downloader_error_request(self): + def eb(failure: Failure) -> Request: + return Request(self.mockserver.url("/")) + + crawler = get_crawler(SingleRequestSpider) + with LogCapture() as log: + yield crawler.crawl( + seed=self.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) + @defer.inlineCallbacks def test_raise_closespider(self): def cb(response): From 105c0afb6ee12a5d1664b311582caa90bcc6c6bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Wed, 28 May 2025 10:54:36 +0200 Subject: [PATCH 019/459] Feature the new logo in the README (#6831) --- README.rst | 22 ++++++++++------------ docs/_static/logo.svg | 1 + 2 files changed, 11 insertions(+), 12 deletions(-) create mode 100644 docs/_static/logo.svg diff --git a/README.rst b/README.rst index 29488d825..30001e4b0 100644 --- a/README.rst +++ b/README.rst @@ -1,9 +1,10 @@ -.. image:: https://scrapy.org/img/scrapylogo.png - :target: https://scrapy.org/ +.. raw:: html -====== -Scrapy -====== +

+ + Scrapy + +

.. image:: https://img.shields.io/pypi/v/Scrapy.svg :target: https://pypi.org/pypi/Scrapy @@ -37,13 +38,10 @@ Scrapy :target: https://deepwiki.com/scrapy/scrapy :alt: Ask DeepWiki - -Overview -======== - -Scrapy is a BSD-licensed fast high-level web crawling and web scraping framework, used to -crawl websites and extract structured data from their pages. It can be used for -a wide range of purposes, from data mining to monitoring and automated testing. +Scrapy is a BSD-licensed fast high-level web crawling and web scraping +framework, used to crawl websites and extract structured data from their pages. +It can be used for a wide range of purposes, from data mining to monitoring and +automated testing. Scrapy is maintained by Zyte_ (formerly Scrapinghub) and `many other contributors`_. diff --git a/docs/_static/logo.svg b/docs/_static/logo.svg new file mode 100644 index 000000000..04b2d18a7 --- /dev/null +++ b/docs/_static/logo.svg @@ -0,0 +1 @@ + From b8cd079014f0e31c609d5fd7fd5f52b89283b1c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Wed, 28 May 2025 11:35:18 +0200 Subject: [PATCH 020/459] Shorten the README and remove broken links to scrapy.org (#6833) --- README.rst | 68 ++++++------------------------------------- docs/contributing.rst | 9 ++++-- 2 files changed, 16 insertions(+), 61 deletions(-) diff --git a/README.rst b/README.rst index 30001e4b0..5dc994570 100644 --- a/README.rst +++ b/README.rst @@ -38,74 +38,24 @@ :target: https://deepwiki.com/scrapy/scrapy :alt: Ask DeepWiki -Scrapy is a BSD-licensed fast high-level web crawling and web scraping -framework, used to crawl websites and extract structured data from their pages. -It can be used for a wide range of purposes, from data mining to monitoring and -automated testing. - -Scrapy is maintained by Zyte_ (formerly Scrapinghub) and `many other -contributors`_. +Scrapy_ is a web scraping framework to extract structured data from websites. +It is cross-platform, and requires Python 3.9+. It is maintained by Zyte_ +(formerly Scrapinghub) and `many other contributors`_. .. _many other contributors: https://github.com/scrapy/scrapy/graphs/contributors +.. _Scrapy: https://scrapy.org/ .. _Zyte: https://www.zyte.com/ -Check the Scrapy homepage at https://scrapy.org for more information, -including a list of features. - - -Requirements -============ - -* Python 3.9+ -* Works on Linux, Windows, macOS, BSD - -Install -======= - -The quick way: +Install with: .. code:: bash pip install scrapy -See the install section in the documentation at -https://docs.scrapy.org/en/latest/intro/install.html for more details. +And follow the documentation_ to learn how to use it. -Documentation -============= +.. _documentation: https://docs.scrapy.org/en/latest/ -Documentation is available online at https://docs.scrapy.org/ and in the ``docs`` -directory. +If you wish to contribute, see Contributing_. -Releases -======== - -You can check https://docs.scrapy.org/en/latest/news.html for the release notes. - -Community (blog, twitter, mail list, IRC) -========================================= - -See https://scrapy.org/community/ for details. - -Contributing -============ - -See https://docs.scrapy.org/en/master/contributing.html for details. - -Code of Conduct ---------------- - -Please note that this project is released with a Contributor `Code of Conduct `_. - -By participating in this project you agree to abide by its terms. -Please report unacceptable behavior to opensource@zyte.com. - -Companies using Scrapy -====================== - -See https://scrapy.org/companies/ for a list. - -Commercial Support -================== - -See https://scrapy.org/support/ for details. +.. _Contributing: https://docs.scrapy.org/en/master/contributing.html diff --git a/docs/contributing.rst b/docs/contributing.rst index f5c1c74b8..0172887d6 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -6,8 +6,13 @@ Contributing to Scrapy .. important:: - Double check that you are reading the most recent version of this document at - https://docs.scrapy.org/en/master/contributing.html + Double check that you are reading the most recent version of this document + at https://docs.scrapy.org/en/master/contributing.html + + By participating in this project you agree to abide by the terms of our + `Code of Conduct + `_. Please + report unacceptable behavior to opensource@zyte.com. There are many ways to contribute to Scrapy. Here are some of them: From 3d382aa650735827647093dfb157d5bd2f15efc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Fri, 30 May 2025 09:33:17 +0200 Subject: [PATCH 021/459] Avoid raw HTML in the README (#6839) --- README.rst | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/README.rst b/README.rst index 5dc994570..536dec7f0 100644 --- a/README.rst +++ b/README.rst @@ -1,40 +1,41 @@ -.. raw:: html +|logo| -

- - Scrapy - -

+.. |logo| image:: https://raw.githubusercontent.com/scrapy/scrapy/master/docs/_static/logo.svg + :target: https://scrapy.org + :alt: Scrapy + :width: 480px -.. image:: https://img.shields.io/pypi/v/Scrapy.svg +|version| |python_version| |ubuntu| |macos| |windows| |coverage| |conda| |deepwiki| + +.. |version| image:: https://img.shields.io/pypi/v/Scrapy.svg :target: https://pypi.org/pypi/Scrapy :alt: PyPI Version -.. image:: https://img.shields.io/pypi/pyversions/Scrapy.svg +.. |python_version| image:: https://img.shields.io/pypi/pyversions/Scrapy.svg :target: https://pypi.org/pypi/Scrapy :alt: Supported Python Versions -.. image:: https://github.com/scrapy/scrapy/workflows/Ubuntu/badge.svg +.. |ubuntu| image:: https://github.com/scrapy/scrapy/workflows/Ubuntu/badge.svg :target: https://github.com/scrapy/scrapy/actions?query=workflow%3AUbuntu :alt: Ubuntu -.. image:: https://github.com/scrapy/scrapy/workflows/macOS/badge.svg +.. |macos| image:: https://github.com/scrapy/scrapy/workflows/macOS/badge.svg :target: https://github.com/scrapy/scrapy/actions?query=workflow%3AmacOS :alt: macOS -.. image:: https://github.com/scrapy/scrapy/workflows/Windows/badge.svg +.. |windows| image:: https://github.com/scrapy/scrapy/workflows/Windows/badge.svg :target: https://github.com/scrapy/scrapy/actions?query=workflow%3AWindows :alt: Windows -.. image:: https://img.shields.io/codecov/c/github/scrapy/scrapy/master.svg +.. |coverage| image:: https://img.shields.io/codecov/c/github/scrapy/scrapy/master.svg :target: https://codecov.io/github/scrapy/scrapy?branch=master :alt: Coverage report -.. image:: https://anaconda.org/conda-forge/scrapy/badges/version.svg +.. |conda| image:: https://anaconda.org/conda-forge/scrapy/badges/version.svg :target: https://anaconda.org/conda-forge/scrapy :alt: Conda Version -.. image:: https://deepwiki.com/badge.svg +.. |deepwiki| image:: https://deepwiki.com/badge.svg :target: https://deepwiki.com/scrapy/scrapy :alt: Ask DeepWiki From 54474ceb0d1467d90bf047415d1c1f135263d983 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Thu, 5 Jun 2025 18:02:31 +0500 Subject: [PATCH 022/459] Fix a regression in errback result handling. (#6863) --- scrapy/core/scraper.py | 17 ++++++++---- scrapy/logformatter.py | 4 +-- tests/test_crawl.py | 61 +++++++++++++++++++++++++++++++++++++++++- 3 files changed, 74 insertions(+), 8 deletions(-) diff --git a/scrapy/core/scraper.py b/scrapy/core/scraper.py index 9378f2651..975344103 100644 --- a/scrapy/core/scraper.py +++ b/scrapy/core/scraper.py @@ -188,6 +188,7 @@ class Scraper: ) assert self.crawler.spider + output: Iterable[Any] | AsyncIterator[Any] if isinstance(result, Response): try: # call the spider middlewares and the request callback with the response @@ -204,7 +205,7 @@ class Scraper: try: # call the request errback with the downloader error - await self.call_spider_async(result, request) + output = await self.call_spider_async(result, request) except Exception as spider_exc: # the errback didn't silence the exception if not result.check(IgnoreRequest): @@ -219,6 +220,8 @@ class Scraper: if spider_exc is not result.value: # the errback raised a different exception, handle it self.handle_spider_error(Failure(), request, result) + else: + await self.handle_spider_output_async(output, request, result) def call_spider( self, result: Response | Failure, request: Request, spider: Spider | None = None @@ -309,7 +312,7 @@ class Scraper: self, result: Iterable[_T] | AsyncIterator[_T], request: Request, - response: Response, + response: Response | Failure, spider: Spider | None = None, ) -> Deferred[None]: """Pass items/requests produced by a callback to ``_process_spidermw_output()`` in parallel.""" @@ -327,7 +330,7 @@ class Scraper: self, result: Iterable[_T] | AsyncIterator[_T], request: Request, - response: Response, + response: Response | Failure, ) -> None: """Pass items/requests produced by a callback to ``_process_spidermw_output()`` in parallel.""" if isinstance(result, AsyncIterator): @@ -352,7 +355,9 @@ class Scraper: ) @deferred_f_from_coro_f - async def _process_spidermw_output(self, output: Any, response: Response) -> None: + async def _process_spidermw_output( + self, output: Any, response: Response | Failure + ) -> None: """Process each Request/Item (given in the output parameter) returned from the given spider. @@ -368,7 +373,9 @@ class Scraper: ) @deferred_f_from_coro_f - async def start_itemproc(self, item: Any, *, response: Response | None) -> None: + async def start_itemproc( + self, item: Any, *, response: Response | Failure | None + ) -> None: """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/logformatter.py b/scrapy/logformatter.py index 4f08918ae..e81a9ec93 100644 --- a/scrapy/logformatter.py +++ b/scrapy/logformatter.py @@ -116,7 +116,7 @@ class LogFormatter: self, item: Any, exception: BaseException, - response: Response | None, + response: Response | Failure | None, spider: Spider, ) -> LogFormatterResult: """Logs a message when an item is dropped while it is passing through the item pipeline.""" @@ -137,7 +137,7 @@ class LogFormatter: self, item: Any, exception: BaseException, - response: Response | None, + response: Response | Failure | None, spider: Spider, ) -> LogFormatterResult: """Logs a message when an item causes an error while it is passing diff --git a/tests/test_crawl.py b/tests/test_crawl.py index b90706027..8289b2243 100644 --- a/tests/test_crawl.py +++ b/tests/test_crawl.py @@ -5,6 +5,7 @@ import logging import unittest from ipaddress import IPv4Address from socket import gethostbyname +from typing import Any from urllib.parse import urlparse import pytest @@ -419,6 +420,8 @@ with multiples lines class TestCrawlSpider(TestCase): + mockserver: MockServer + @classmethod def setUpClass(cls): cls.mockserver = MockServer() @@ -756,6 +759,34 @@ class TestCrawlSpider(TestCase): ) assert "Spider error processing" in str(log) + @defer.inlineCallbacks + def test_spider_errback_item(self): + 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 + ) + 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) + + @defer.inlineCallbacks + def test_spider_errback_request(self): + def eb(failure: Failure) -> Request: + return Request(self.mockserver.url("/")) + + crawler = get_crawler(SingleRequestSpider) + with LogCapture() as log: + yield crawler.crawl( + seed=self.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) + @defer.inlineCallbacks def test_spider_errback_downloader_error(self): failures = [] @@ -774,7 +805,7 @@ class TestCrawlSpider(TestCase): assert "Spider error processing" not in str(log) @defer.inlineCallbacks - def test_spider_errback_exception_downloader_error(self): + def test_spider_errback_downloader_error_exception(self): def eb(failure: Failure) -> None: raise ValueError("foo") @@ -786,6 +817,34 @@ class TestCrawlSpider(TestCase): assert "Error downloading" in str(log) assert "Spider error processing" in str(log) + @defer.inlineCallbacks + def test_spider_errback_downloader_error_item(self): + 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 + ) + 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) + + @defer.inlineCallbacks + def test_spider_errback_downloader_error_request(self): + def eb(failure: Failure) -> Request: + return Request(self.mockserver.url("/")) + + crawler = get_crawler(SingleRequestSpider) + with LogCapture() as log: + yield crawler.crawl( + seed=self.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) + @defer.inlineCallbacks def test_raise_closespider(self): def cb(response): From b20995c9d8dd00618ae71d0f64cdc53f6d669cf2 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Fri, 6 Jun 2025 13:16:48 +0500 Subject: [PATCH 023/459] Silence a typing error. --- 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 975344103..ac720e03f 100644 --- a/scrapy/core/scraper.py +++ b/scrapy/core/scraper.py @@ -193,7 +193,7 @@ class Scraper: try: # call the spider middlewares and the request callback with the response output = await maybe_deferred_to_future( - self.spidermw.scrape_response( + self.spidermw.scrape_response( # type: ignore[arg-type] self.call_spider, result, request, self.crawler.spider ) ) From d99234a33f02f7dd5fb06d167ec78266b7f4dfeb Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Fri, 6 Jun 2025 14:54:21 +0500 Subject: [PATCH 024/459] Install the reactor explicitly in CrawlerRunner examples. (#6865) --- docs/topics/practices.rst | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/docs/topics/practices.rst b/docs/topics/practices.rst index db91cd073..b3c881b81 100644 --- a/docs/topics/practices.rst +++ b/docs/topics/practices.rst @@ -95,6 +95,7 @@ reactor after ``MySpider`` has finished running. import scrapy from scrapy.crawler import CrawlerRunner from scrapy.utils.log import configure_logging + from scrapy.utils.reactor import install_reactor class MySpider(scrapy.Spider): @@ -102,6 +103,7 @@ reactor after ``MySpider`` has finished running. ... + install_reactor("twisted.internet.asyncioreactor.AsyncioSelectorReactor") configure_logging({"LOG_FORMAT": "%(levelname)s: %(message)s"}) runner = CrawlerRunner() @@ -112,26 +114,26 @@ reactor after ``MySpider`` has finished running. d.addBoth(lambda _: reactor.stop()) reactor.run() # the script will block here until the crawling is finished -Same example but using a non-default reactor, it's only necessary call -``install_reactor`` if you are using ``CrawlerRunner`` since ``CrawlerProcess`` already does this automatically. +Same example but using a different reactor. .. code-block:: python import scrapy from scrapy.crawler import CrawlerRunner from scrapy.utils.log import configure_logging + from scrapy.utils.reactor import install_reactor class MySpider(scrapy.Spider): + custom_settings = { + "TWISTED_REACTOR": "twisted.internet.epollreactor.EPollReactor", + } # Your spider definition ... + install_reactor("twisted.internet.epollreactor.EPollReactor") configure_logging({"LOG_FORMAT": "%(levelname)s: %(message)s"}) - - from scrapy.utils.reactor import install_reactor - - install_reactor("twisted.internet.asyncioreactor.AsyncioSelectorReactor") runner = CrawlerRunner() d = runner.crawl(MySpider) @@ -184,6 +186,7 @@ Same example using :class:`~scrapy.crawler.CrawlerRunner`: from scrapy.crawler import CrawlerRunner from scrapy.utils.log import configure_logging from scrapy.utils.project import get_project_settings + from scrapy.utils.reactor import install_reactor class MySpider1(scrapy.Spider): @@ -196,6 +199,7 @@ Same example using :class:`~scrapy.crawler.CrawlerRunner`: ... + install_reactor("twisted.internet.asyncioreactor.AsyncioSelectorReactor") configure_logging() settings = get_project_settings() runner = CrawlerRunner(settings) @@ -217,6 +221,7 @@ Same example but running the spiders sequentially by chaining the deferreds: from scrapy.crawler import CrawlerRunner from scrapy.utils.log import configure_logging from scrapy.utils.project import get_project_settings + from scrapy.utils.reactor import install_reactor class MySpider1(scrapy.Spider): @@ -229,6 +234,7 @@ Same example but running the spiders sequentially by chaining the deferreds: ... + install_reactor("twisted.internet.asyncioreactor.AsyncioSelectorReactor") settings = get_project_settings() configure_logging(settings) runner = CrawlerRunner(settings) From 405d9bc8a247cfec4d698310c425112c456f134f Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Fri, 6 Jun 2025 15:59:49 +0500 Subject: [PATCH 025/459] More docs for the is_asyncio_reactor_installed() behavior change. (#6866) --- docs/news.rst | 9 +++++++++ scrapy/utils/reactor.py | 5 +++++ scrapy/utils/test.py | 7 ++++++- 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/docs/news.rst b/docs/news.rst index ef3b549e7..8b1d51674 100644 --- a/docs/news.rst +++ b/docs/news.rst @@ -126,6 +126,15 @@ Backward-incompatible changes also enforced for start requests. (:issue:`6777`) +- Calling :func:`scrapy.utils.reactor.is_asyncio_reactor_installed` without + an installed reactor now raises an exception instead of installing a + reactor. This shouldn't affect normal Scrapy use cases, but it may affect + 3rd-party test suites that use Scrapy internals such as + :class:`~scrapy.crawler.Crawler` and don't install a reactor explicitly. If + you are affected by this change, you most likely need to install the + reactor before running Scrapy code that expects it to be installed. + (:issue:`6732`, :issue:`6735`) + - The ``from_settings()`` method of :class:`~scrapy.spidermiddlewares.urllength.UrlLengthMiddleware`, deprecated in Scrapy 2.12.0, is removed earlier than the usual deprecation diff --git a/scrapy/utils/reactor.py b/scrapy/utils/reactor.py index 9c2754394..1b179f988 100644 --- a/scrapy/utils/reactor.py +++ b/scrapy/utils/reactor.py @@ -202,6 +202,11 @@ def is_asyncio_reactor_installed() -> bool: """Check whether the installed reactor is :class:`~twisted.internet.asyncioreactor.AsyncioSelectorReactor`. Raise a :exc:`RuntimeError` if no reactor is installed. + + .. versionchanged:: 2.13 + In earlier Scrapy versions this function silently installed the default + reactor if there was no reactor installed. Now it raises an exception to + prevent silent problems in this case. """ if not is_reactor_installed(): raise RuntimeError( diff --git a/scrapy/utils/test.py b/scrapy/utils/test.py index 2da526cd8..4a732bd72 100644 --- a/scrapy/utils/test.py +++ b/scrapy/utils/test.py @@ -18,7 +18,7 @@ from twisted.trial.unittest import SkipTest 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 +from scrapy.utils.reactor import is_asyncio_reactor_installed, is_reactor_installed from scrapy.utils.spider import DefaultSpider if TYPE_CHECKING: @@ -117,6 +117,11 @@ def get_reactor_settings() -> dict[str, Any]: settings, so tests that run the crawler in the current process may need to pass a correct ``"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 From 657e6cb2b57d52005740e92543c1270dbaf61ded Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Fri, 6 Jun 2025 16:02:15 +0500 Subject: [PATCH 026/459] Don't try to close ExecutionEngine.downloader when it doesn't exist. (#6867) --- scrapy/core/engine.py | 3 ++- tests/test_engine.py | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/scrapy/core/engine.py b/scrapy/core/engine.py index d9361a674..fe635dc82 100644 --- a/scrapy/core/engine.py +++ b/scrapy/core/engine.py @@ -174,7 +174,8 @@ class ExecutionEngine: return self.close_spider( self.spider, reason="shutdown" ) # will also close downloader - self.downloader.close() + if hasattr(self, "downloader"): + self.downloader.close() return succeed(None) def pause(self) -> None: diff --git a/tests/test_engine.py b/tests/test_engine.py index 1f79a081d..9f618437c 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -22,6 +22,7 @@ from unittest.mock import Mock from urllib.parse import urlparse import attr +import pytest from itemadapter import ItemAdapter from pydispatch import dispatcher from twisted.internet import defer @@ -433,6 +434,19 @@ class TestEngine(TestEngineBase): e = ExecutionEngine(get_crawler(MySpider), lambda _: None) yield e.close() + def test_close_without_downloader(self): + class CustomException(Exception): + pass + + class BadDownloader: + def __init__(self, crawler): + raise CustomException + + with pytest.raises(CustomException): + ExecutionEngine( + get_crawler(MySpider, {"DOWNLOADER": BadDownloader}), lambda _: None + ) + @defer.inlineCallbacks def test_start_already_running_exception(self): e = ExecutionEngine(get_crawler(MySpider), lambda _: None) From d329eedfefd9a1fa7006e6d0a214e9d5e01a8e0c Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Fri, 6 Jun 2025 16:02:15 +0500 Subject: [PATCH 027/459] Don't try to close ExecutionEngine.downloader when it doesn't exist. (#6867) --- scrapy/core/engine.py | 3 ++- tests/test_engine.py | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/scrapy/core/engine.py b/scrapy/core/engine.py index 7f5dd0405..0df9ad2b2 100644 --- a/scrapy/core/engine.py +++ b/scrapy/core/engine.py @@ -170,7 +170,8 @@ class ExecutionEngine: return self.close_spider( self.spider, reason="shutdown" ) # will also close downloader - self.downloader.close() + if hasattr(self, "downloader"): + self.downloader.close() return succeed(None) def pause(self) -> None: diff --git a/tests/test_engine.py b/tests/test_engine.py index b60b510b2..b2e436425 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -22,6 +22,7 @@ from unittest.mock import Mock from urllib.parse import urlparse import attr +import pytest from itemadapter import ItemAdapter from pydispatch import dispatcher from twisted.internet import defer, reactor @@ -431,6 +432,19 @@ class TestEngine(TestEngineBase): e = ExecutionEngine(get_crawler(MySpider), lambda _: None) yield e.close() + def test_close_without_downloader(self): + class CustomException(Exception): + pass + + class BadDownloader: + def __init__(self, crawler): + raise CustomException + + with pytest.raises(CustomException): + ExecutionEngine( + get_crawler(MySpider, {"DOWNLOADER": BadDownloader}), lambda _: None + ) + @defer.inlineCallbacks def test_start_already_running_exception(self): e = ExecutionEngine(get_crawler(MySpider), lambda _: None) From 744edb9ba9e293ddccfcfa03e0aef0a7c0da14b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Fri, 6 Jun 2025 16:09:51 +0200 Subject: [PATCH 028/459] Make scrapy fetch work with scrapy-poet (#6872) --- scrapy/commands/fetch.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scrapy/commands/fetch.py b/scrapy/commands/fetch.py index ef6e13de2..0aaff6c25 100644 --- a/scrapy/commands/fetch.py +++ b/scrapy/commands/fetch.py @@ -1,6 +1,7 @@ from __future__ import annotations import sys +from argparse import Namespace # noqa: TC003 from typing import TYPE_CHECKING from w3lib.url import is_url @@ -12,7 +13,7 @@ from scrapy.utils.datatypes import SequenceExclude from scrapy.utils.spider import DefaultSpider, spidercls_for_request if TYPE_CHECKING: - from argparse import ArgumentParser, Namespace + from argparse import ArgumentParser from scrapy import Spider From d8251332845d48d2418f0055c27686cee04b5b9a Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Sat, 7 Jun 2025 01:59:09 +0500 Subject: [PATCH 029/459] Reduce deps on unittest, unify inlineCallbacks imports in tests. (#6873) --- tests/test_closespider.py | 16 +- tests/test_command_fetch.py | 10 +- tests/test_command_parse.py | 42 ++-- tests/test_command_runspider.py | 5 +- tests/test_command_shell.py | 39 ++-- tests/test_command_version.py | 6 +- tests/test_contracts.py | 4 +- tests/test_crawl.py | 135 +++++++------ tests/test_dependencies.py | 12 -- tests/test_downloader_handlers.py | 2 +- tests/test_downloader_handlers_http_base.py | 12 +- ...st_downloadermiddleware_httpcompression.py | 35 ++-- tests/test_downloadermiddleware_robotstxt.py | 8 +- tests/test_downloaderslotssettings.py | 4 +- tests/test_engine.py | 24 +-- tests/test_engine_stop_download_bytes.py | 4 +- tests/test_engine_stop_download_headers.py | 4 +- tests/test_exporters.py | 3 +- tests/test_extension_periodic_log.py | 3 +- tests/test_extension_telnet.py | 12 +- tests/test_feedexport.py | 179 +++++++++--------- tests/test_http2_client_protocol.py | 4 +- tests/test_logformatter.py | 6 +- tests/test_pipeline_crawl.py | 12 +- tests/test_pipeline_files.py | 22 +-- tests/test_pipelines.py | 13 +- 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 | 6 +- tests/test_scheduler_base.py | 5 +- tests/test_signals.py | 4 +- tests/test_spidermiddleware.py | 15 +- tests/test_spidermiddleware_httperror.py | 8 +- tests/test_spidermiddleware_output_chain.py | 26 +-- tests/test_squeues_request.py | 13 +- tests/test_utils_log.py | 13 +- tests/test_utils_signal.py | 3 +- tests/test_utils_trackref.py | 4 +- tests/test_utils_url.py | 3 +- tests/test_webclient.py | 35 ++-- 42 files changed, 380 insertions(+), 409 deletions(-) diff --git a/tests/test_closespider.py b/tests/test_closespider.py index 4a17b254b..c6ec690a1 100644 --- a/tests/test_closespider.py +++ b/tests/test_closespider.py @@ -1,4 +1,4 @@ -from twisted.internet import defer +from twisted.internet.defer import inlineCallbacks from twisted.trial.unittest import TestCase from scrapy.utils.test import get_crawler @@ -22,7 +22,7 @@ class TestCloseSpider(TestCase): def tearDownClass(cls): cls.mockserver.__exit__(None, None, None) - @defer.inlineCallbacks + @inlineCallbacks def test_closespider_itemcount(self): close_on = 5 crawler = get_crawler(ItemSpider, {"CLOSESPIDER_ITEMCOUNT": close_on}) @@ -32,7 +32,7 @@ class TestCloseSpider(TestCase): itemcount = crawler.stats.get_value("item_scraped_count") assert itemcount >= close_on - @defer.inlineCallbacks + @inlineCallbacks def test_closespider_pagecount(self): close_on = 5 crawler = get_crawler(FollowAllSpider, {"CLOSESPIDER_PAGECOUNT": close_on}) @@ -42,7 +42,7 @@ class TestCloseSpider(TestCase): pagecount = crawler.stats.get_value("response_received_count") assert pagecount >= close_on - @defer.inlineCallbacks + @inlineCallbacks def test_closespider_pagecount_no_item(self): close_on = 5 max_items = 5 @@ -62,7 +62,7 @@ class TestCloseSpider(TestCase): itemcount = crawler.stats.get_value("item_scraped_count") assert pagecount <= close_on + itemcount - @defer.inlineCallbacks + @inlineCallbacks def test_closespider_pagecount_no_item_with_pagecount(self): close_on_pagecount_no_item = 5 close_on_pagecount = 20 @@ -79,7 +79,7 @@ class TestCloseSpider(TestCase): pagecount = crawler.stats.get_value("response_received_count") assert pagecount < close_on_pagecount - @defer.inlineCallbacks + @inlineCallbacks def test_closespider_errorcount(self): close_on = 5 crawler = get_crawler(ErrorSpider, {"CLOSESPIDER_ERRORCOUNT": close_on}) @@ -91,7 +91,7 @@ class TestCloseSpider(TestCase): assert crawler.stats.get_value("spider_exceptions/count") >= close_on assert errorcount >= close_on - @defer.inlineCallbacks + @inlineCallbacks def test_closespider_timeout(self): close_on = 0.1 crawler = get_crawler(FollowAllSpider, {"CLOSESPIDER_TIMEOUT": close_on}) @@ -101,7 +101,7 @@ class TestCloseSpider(TestCase): total_seconds = crawler.stats.get_value("elapsed_time_seconds") assert total_seconds >= close_on - @defer.inlineCallbacks + @inlineCallbacks def test_closespider_timeout_no_item(self): timeout = 1 crawler = get_crawler(SlowSpider, {"CLOSESPIDER_TIMEOUT_NO_ITEM": timeout}) diff --git a/tests/test_command_fetch.py b/tests/test_command_fetch.py index a31cada85..89f664336 100644 --- a/tests/test_command_fetch.py +++ b/tests/test_command_fetch.py @@ -1,4 +1,4 @@ -from twisted.internet import defer +from twisted.internet.defer import inlineCallbacks from twisted.trial import unittest from tests.utils.testproc import ProcessTest @@ -8,17 +8,17 @@ from tests.utils.testsite import SiteTest class TestFetchCommand(ProcessTest, SiteTest, unittest.TestCase): command = "fetch" - @defer.inlineCallbacks + @inlineCallbacks def test_output(self): _, out, _ = yield self.execute([self.url("/text")]) assert out.strip() == b"Works" - @defer.inlineCallbacks + @inlineCallbacks def test_redirect_default(self): _, out, _ = yield self.execute([self.url("/redirect")]) assert out.strip() == b"Redirected here" - @defer.inlineCallbacks + @inlineCallbacks def test_redirect_disabled(self): _, out, err = yield self.execute( ["--no-redirect", self.url("/redirect-no-meta-refresh")] @@ -27,7 +27,7 @@ class TestFetchCommand(ProcessTest, SiteTest, unittest.TestCase): assert b"downloader/response_status_count/302" in err, err assert b"downloader/response_status_count/200" not in err, err - @defer.inlineCallbacks + @inlineCallbacks def test_headers(self): _, out, _ = yield self.execute([self.url("/text"), "--headers"]) out = out.replace(b"\r", b"") # required on win32 diff --git a/tests/test_command_parse.py b/tests/test_command_parse.py index 9e66d319c..6681aba17 100644 --- a/tests/test_command_parse.py +++ b/tests/test_command_parse.py @@ -3,7 +3,7 @@ import os import re from pathlib import Path -from twisted.internet import defer +from twisted.internet.defer import inlineCallbacks from scrapy.commands import parse from scrapy.settings import Settings @@ -171,7 +171,7 @@ ITEM_PIPELINES = {{'{self.project_name}.pipelines.MyPipeline': 1}} """ ) - @defer.inlineCallbacks + @inlineCallbacks def test_spider_arguments(self): _, _, stderr = yield self.execute( [ @@ -187,7 +187,7 @@ ITEM_PIPELINES = {{'{self.project_name}.pipelines.MyPipeline': 1}} ) assert "DEBUG: It Works!" in _textmode(stderr) - @defer.inlineCallbacks + @inlineCallbacks def test_request_with_meta(self): raw_json_string = '{"foo" : "baz"}' _, _, stderr = yield self.execute( @@ -218,7 +218,7 @@ ITEM_PIPELINES = {{'{self.project_name}.pipelines.MyPipeline': 1}} ) assert "DEBUG: It Works!" in _textmode(stderr) - @defer.inlineCallbacks + @inlineCallbacks def test_request_with_cb_kwargs(self): raw_json_string = '{"foo" : "bar", "key": "value"}' _, _, stderr = yield self.execute( @@ -239,7 +239,7 @@ ITEM_PIPELINES = {{'{self.project_name}.pipelines.MyPipeline': 1}} "DEBUG: request.callback signature: (response, foo=None, key=None)" in log ) - @defer.inlineCallbacks + @inlineCallbacks def test_request_without_meta(self): _, _, stderr = yield self.execute( [ @@ -253,7 +253,7 @@ ITEM_PIPELINES = {{'{self.project_name}.pipelines.MyPipeline': 1}} ) assert "DEBUG: It Works!" in _textmode(stderr) - @defer.inlineCallbacks + @inlineCallbacks def test_pipelines(self): _, _, stderr = yield self.execute( [ @@ -268,7 +268,7 @@ ITEM_PIPELINES = {{'{self.project_name}.pipelines.MyPipeline': 1}} ) assert "INFO: It Works!" in _textmode(stderr) - @defer.inlineCallbacks + @inlineCallbacks def test_async_def_asyncio_parse_items_list(self): status, out, stderr = yield self.execute( [ @@ -283,7 +283,7 @@ ITEM_PIPELINES = {{'{self.project_name}.pipelines.MyPipeline': 1}} assert "{'id': 1}" in _textmode(out) assert "{'id': 2}" in _textmode(out) - @defer.inlineCallbacks + @inlineCallbacks def test_async_def_asyncio_parse_items_single_element(self): status, out, stderr = yield self.execute( [ @@ -297,7 +297,7 @@ ITEM_PIPELINES = {{'{self.project_name}.pipelines.MyPipeline': 1}} assert "INFO: Got response 200" in _textmode(stderr) assert "{'foo': 42}" in _textmode(out) - @defer.inlineCallbacks + @inlineCallbacks def test_async_def_asyncgen_parse_loop(self): status, out, stderr = yield self.execute( [ @@ -312,7 +312,7 @@ ITEM_PIPELINES = {{'{self.project_name}.pipelines.MyPipeline': 1}} for i in range(10): assert f"{{'foo': {i}}}" in _textmode(out) - @defer.inlineCallbacks + @inlineCallbacks def test_async_def_asyncgen_parse_exc(self): status, out, stderr = yield self.execute( [ @@ -327,7 +327,7 @@ ITEM_PIPELINES = {{'{self.project_name}.pipelines.MyPipeline': 1}} for i in range(7): assert f"{{'foo': {i}}}" in _textmode(out) - @defer.inlineCallbacks + @inlineCallbacks def test_async_def_asyncio_parse(self): _, _, stderr = yield self.execute( [ @@ -340,21 +340,21 @@ ITEM_PIPELINES = {{'{self.project_name}.pipelines.MyPipeline': 1}} ) assert "DEBUG: Got response 200" in _textmode(stderr) - @defer.inlineCallbacks + @inlineCallbacks def test_parse_items(self): status, out, stderr = yield self.execute( ["--spider", self.spider_name, "-c", "parse", self.url("/html")] ) assert "[{}, {'foo': 'bar'}]" in _textmode(out) - @defer.inlineCallbacks + @inlineCallbacks def test_parse_items_no_callback_passed(self): status, out, stderr = yield self.execute( ["--spider", self.spider_name, self.url("/html")] ) assert "[{}, {'foo': 'bar'}]" in _textmode(out) - @defer.inlineCallbacks + @inlineCallbacks def test_wrong_callback_passed(self): status, out, stderr = yield self.execute( ["--spider", self.spider_name, "-c", "dummy", self.url("/html")] @@ -362,7 +362,7 @@ ITEM_PIPELINES = {{'{self.project_name}.pipelines.MyPipeline': 1}} assert re.search(r"# Scraped Items -+\n\[\]", _textmode(out)) assert "Cannot find callback" in _textmode(stderr) - @defer.inlineCallbacks + @inlineCallbacks def test_crawlspider_matching_rule_callback_set(self): """If a rule matches the URL, use it's defined callback.""" status, out, stderr = yield self.execute( @@ -370,7 +370,7 @@ ITEM_PIPELINES = {{'{self.project_name}.pipelines.MyPipeline': 1}} ) assert "[{}, {'foo': 'bar'}]" in _textmode(out) - @defer.inlineCallbacks + @inlineCallbacks def test_crawlspider_matching_rule_default_callback(self): """If a rule match but it has no callback set, use the 'parse' callback.""" status, out, stderr = yield self.execute( @@ -378,7 +378,7 @@ ITEM_PIPELINES = {{'{self.project_name}.pipelines.MyPipeline': 1}} ) assert "[{}, {'nomatch': 'default'}]" in _textmode(out) - @defer.inlineCallbacks + @inlineCallbacks def test_spider_with_no_rules_attribute(self): """Using -r with a spider with no rule should not produce items.""" status, out, stderr = yield self.execute( @@ -387,14 +387,14 @@ ITEM_PIPELINES = {{'{self.project_name}.pipelines.MyPipeline': 1}} assert re.search(r"# Scraped Items -+\n\[\]", _textmode(out)) assert "No CrawlSpider rules found" in _textmode(stderr) - @defer.inlineCallbacks + @inlineCallbacks def test_crawlspider_missing_callback(self): status, out, stderr = yield self.execute( ["--spider", "badcrawl" + self.spider_name, "-r", self.url("/html")] ) assert re.search(r"# Scraped Items -+\n\[\]", _textmode(out)) - @defer.inlineCallbacks + @inlineCallbacks def test_crawlspider_no_matching_rule(self): """The requested URL has no matching rule, so no items should be scraped""" status, out, stderr = yield self.execute( @@ -403,12 +403,12 @@ ITEM_PIPELINES = {{'{self.project_name}.pipelines.MyPipeline': 1}} assert re.search(r"# Scraped Items -+\n\[\]", _textmode(out)) assert "Cannot find a rule that matches" in _textmode(stderr) - @defer.inlineCallbacks + @inlineCallbacks def test_crawlspider_not_exists_with_not_matched_url(self): status, out, stderr = yield self.execute([self.url("/invalid_url")]) assert status == 0 - @defer.inlineCallbacks + @inlineCallbacks def test_output_flag(self): """Checks if a file was created successfully having correct format containing correct data in it. diff --git a/tests/test_command_runspider.py b/tests/test_command_runspider.py index c57c09249..7f8d9fb61 100644 --- a/tests/test_command_runspider.py +++ b/tests/test_command_runspider.py @@ -10,7 +10,6 @@ from typing import TYPE_CHECKING from unittest import skipIf import pytest -from twisted.trial import unittest from tests.test_commands import TestCommandBase from tests.test_crawler import ExceptionSpider, NoRequestsSpider @@ -376,7 +375,7 @@ class TestWindowsRunSpiderCommand(TestRunSpiderCommand): def setUp(self): if platform.system() != "Windows": - raise unittest.SkipTest("Windows required for .pyw files") + pytest.skip("Windows required for .pyw files") return super().setUp() def test_start_errors(self): @@ -385,4 +384,4 @@ class TestWindowsRunSpiderCommand(TestRunSpiderCommand): assert "badspider.pyw" in log def test_runspider_unable_to_load(self): - raise unittest.SkipTest("Already Tested in 'RunSpiderCommandTest' ") + pytest.skip("Already Tested in 'RunSpiderCommandTest' ") diff --git a/tests/test_command_shell.py b/tests/test_command_shell.py index 8041e7cb1..d9f17d76b 100644 --- a/tests/test_command_shell.py +++ b/tests/test_command_shell.py @@ -3,8 +3,9 @@ import sys from io import BytesIO from pathlib import Path +import pytest from pexpect.popen_spawn import PopenSpawn -from twisted.internet import defer +from twisted.internet.defer import inlineCallbacks from twisted.trial import unittest from scrapy.utils.reactor import _asyncio_reactor_path @@ -17,52 +18,52 @@ from tests.utils.testsite import SiteTest class TestShellCommand(ProcessTest, SiteTest, unittest.TestCase): command = "shell" - @defer.inlineCallbacks + @inlineCallbacks def test_empty(self): _, out, _ = yield self.execute(["-c", "item"]) assert b"{}" in out - @defer.inlineCallbacks + @inlineCallbacks def test_response_body(self): _, out, _ = yield self.execute([self.url("/text"), "-c", "response.body"]) assert b"Works" in out - @defer.inlineCallbacks + @inlineCallbacks def test_response_type_text(self): _, out, _ = yield self.execute([self.url("/text"), "-c", "type(response)"]) assert b"TextResponse" in out - @defer.inlineCallbacks + @inlineCallbacks def test_response_type_html(self): _, out, _ = yield self.execute([self.url("/html"), "-c", "type(response)"]) assert b"HtmlResponse" in out - @defer.inlineCallbacks + @inlineCallbacks def test_response_selector_html(self): xpath = "response.xpath(\"//p[@class='one']/text()\").get()" _, out, _ = yield self.execute([self.url("/html"), "-c", xpath]) assert out.strip() == b"Works" - @defer.inlineCallbacks + @inlineCallbacks def test_response_encoding_gb18030(self): _, out, _ = yield self.execute( [self.url("/enc-gb18030"), "-c", "response.encoding"] ) assert out.strip() == b"gb18030" - @defer.inlineCallbacks + @inlineCallbacks def test_redirect(self): _, out, _ = yield self.execute([self.url("/redirect"), "-c", "response.url"]) assert out.strip().endswith(b"/redirected") - @defer.inlineCallbacks + @inlineCallbacks def test_redirect_follow_302(self): _, out, _ = yield self.execute( [self.url("/redirect-no-meta-refresh"), "-c", "response.status"] ) assert out.strip().endswith(b"200") - @defer.inlineCallbacks + @inlineCallbacks def test_redirect_not_follow_302(self): _, out, _ = yield self.execute( [ @@ -74,7 +75,7 @@ class TestShellCommand(ProcessTest, SiteTest, unittest.TestCase): ) assert out.strip().endswith(b"302") - @defer.inlineCallbacks + @inlineCallbacks def test_fetch_redirect_follow_302(self): """Test that calling ``fetch(url)`` follows HTTP redirects by default.""" url = self.url("/redirect-no-meta-refresh") @@ -84,7 +85,7 @@ class TestShellCommand(ProcessTest, SiteTest, unittest.TestCase): assert b"Redirecting (302)" in errout assert b"Crawled (200)" in errout - @defer.inlineCallbacks + @inlineCallbacks def test_fetch_redirect_not_follow_302(self): """Test that calling ``fetch(url, redirect=False)`` disables automatic redirects.""" url = self.url("/redirect-no-meta-refresh") @@ -93,27 +94,27 @@ class TestShellCommand(ProcessTest, SiteTest, unittest.TestCase): assert errcode == 0, out assert b"Crawled (302)" in errout - @defer.inlineCallbacks + @inlineCallbacks def test_request_replace(self): url = self.url("/text") code = f"fetch('{url}') or fetch(response.request.replace(method='POST'))" errcode, out, _ = yield self.execute(["-c", code]) assert errcode == 0, out - @defer.inlineCallbacks + @inlineCallbacks def test_scrapy_import(self): url = self.url("/text") code = f"fetch(scrapy.Request('{url}'))" errcode, out, _ = yield self.execute(["-c", code]) assert errcode == 0, out - @defer.inlineCallbacks + @inlineCallbacks def test_local_file(self): filepath = Path(tests_datadir, "test_site", "index.html") _, out, _ = yield self.execute([str(filepath), "-c", "item"]) assert b"{}" in out - @defer.inlineCallbacks + @inlineCallbacks def test_local_nofile(self): filepath = "file:///tests/sample_data/test_site/nothinghere.html" errcode, out, err = yield self.execute( @@ -122,16 +123,16 @@ class TestShellCommand(ProcessTest, SiteTest, unittest.TestCase): assert errcode == 1, out or err assert b"No such file or directory" in err - @defer.inlineCallbacks + @inlineCallbacks def test_dns_failures(self): if NON_EXISTING_RESOLVABLE: - raise unittest.SkipTest("Non-existing hosts are resolvable") + pytest.skip("Non-existing hosts are resolvable") url = "www.somedomainthatdoesntexi.st" errcode, out, err = yield self.execute([url, "-c", "item"], check_code=False) assert errcode == 1, out or err assert b"DNS lookup failed" in err - @defer.inlineCallbacks + @inlineCallbacks def test_shell_fetch_async(self): url = self.url("/html") code = f"fetch('{url}')" diff --git a/tests/test_command_version.py b/tests/test_command_version.py index a61a6a32b..87dfb16df 100644 --- a/tests/test_command_version.py +++ b/tests/test_command_version.py @@ -1,6 +1,6 @@ import sys -from twisted.internet import defer +from twisted.internet.defer import inlineCallbacks from twisted.trial import unittest import scrapy @@ -10,13 +10,13 @@ from tests.utils.testproc import ProcessTest class TestVersionCommand(ProcessTest, unittest.TestCase): command = "version" - @defer.inlineCallbacks + @inlineCallbacks def test_output(self): encoding = sys.stdout.encoding or "utf-8" _, out, _ = yield self.execute([]) assert out.strip().decode(encoding) == f"Scrapy {scrapy.__version__}" - @defer.inlineCallbacks + @inlineCallbacks def test_verbose_output(self): encoding = sys.stdout.encoding or "utf-8" _, out, _ = yield self.execute(["-v"]) diff --git a/tests/test_contracts.py b/tests/test_contracts.py index 26b16a1d4..ad3efa042 100644 --- a/tests/test_contracts.py +++ b/tests/test_contracts.py @@ -1,7 +1,7 @@ from unittest import TextTestResult import pytest -from twisted.internet import defer +from twisted.internet.defer import inlineCallbacks from twisted.python import failure from twisted.trial import unittest @@ -502,7 +502,7 @@ class TestContractsManager(unittest.TestCase): assert not self.results.failures assert self.results.errors - @defer.inlineCallbacks + @inlineCallbacks def test_same_url(self): class TestSameUrlSpider(Spider): name = "test_same_url" diff --git a/tests/test_crawl.py b/tests/test_crawl.py index 8289b2243..4c1f6216b 100644 --- a/tests/test_crawl.py +++ b/tests/test_crawl.py @@ -2,7 +2,6 @@ from __future__ import annotations import json import logging -import unittest from ipaddress import IPv4Address from socket import gethostbyname from typing import Any @@ -10,7 +9,7 @@ from urllib.parse import urlparse import pytest from testfixtures import LogCapture -from twisted.internet import defer +from twisted.internet.defer import inlineCallbacks from twisted.internet.ssl import Certificate from twisted.python.failure import Failure from twisted.trial.unittest import TestCase @@ -67,21 +66,21 @@ class TestCrawl(TestCase): def tearDownClass(cls): cls.mockserver.__exit__(None, None, None) - @defer.inlineCallbacks + @inlineCallbacks 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 - @defer.inlineCallbacks + @inlineCallbacks def test_fixed_delay(self): yield self._test_delay(total=3, delay=0.2) - @defer.inlineCallbacks + @inlineCallbacks def test_randomized_delay(self): yield self._test_delay(total=3, delay=0.1, randomize=True) - @defer.inlineCallbacks + @inlineCallbacks def _test_delay(self, total, delay, randomize=False): crawl_kwargs = { "maxlatency": delay * 2, @@ -110,7 +109,7 @@ class TestCrawl(TestCase): average = total_time / (len(times) - 1) assert average <= delay / tolerance, "test total or delay values are too small" - @defer.inlineCallbacks + @inlineCallbacks def test_timeout_success(self): crawler = get_crawler(DelaySpider) yield crawler.crawl(n=0.5, mockserver=self.mockserver) @@ -118,7 +117,7 @@ class TestCrawl(TestCase): assert crawler.spider.t2 > 0 assert crawler.spider.t2 > crawler.spider.t1 - @defer.inlineCallbacks + @inlineCallbacks def test_timeout_failure(self): crawler = get_crawler(DelaySpider, {"DOWNLOAD_TIMEOUT": 0.35}) yield crawler.crawl(n=0.5, mockserver=self.mockserver) @@ -135,7 +134,7 @@ class TestCrawl(TestCase): assert crawler.spider.t2_err > 0 assert crawler.spider.t2_err > crawler.spider.t1 - @defer.inlineCallbacks + @inlineCallbacks def test_retry_503(self): crawler = get_crawler(SimpleSpider) with LogCapture() as log: @@ -144,7 +143,7 @@ class TestCrawl(TestCase): ) self._assert_retried(log) - @defer.inlineCallbacks + @inlineCallbacks def test_retry_conn_failed(self): crawler = get_crawler(SimpleSpider) with LogCapture() as log: @@ -153,10 +152,10 @@ class TestCrawl(TestCase): ) self._assert_retried(log) - @defer.inlineCallbacks + @inlineCallbacks def test_retry_dns_error(self): if NON_EXISTING_RESOLVABLE: - raise unittest.SkipTest("Non-existing hosts are resolvable") + pytest.skip("Non-existing hosts are resolvable") crawler = get_crawler(SimpleSpider) with LogCapture() as log: # try to fetch the homepage of a nonexistent domain @@ -165,7 +164,7 @@ class TestCrawl(TestCase): ) self._assert_retried(log) - @defer.inlineCallbacks + @inlineCallbacks def test_start_bug_before_yield(self): with LogCapture("scrapy", level=logging.ERROR) as log: crawler = get_crawler(BrokenStartSpider) @@ -176,7 +175,7 @@ class TestCrawl(TestCase): assert record.exc_info is not None assert record.exc_info[0] is ZeroDivisionError - @defer.inlineCallbacks + @inlineCallbacks def test_start_bug_yielding(self): with LogCapture("scrapy", level=logging.ERROR) as log: crawler = get_crawler(BrokenStartSpider) @@ -187,7 +186,7 @@ class TestCrawl(TestCase): assert record.exc_info is not None assert record.exc_info[0] is ZeroDivisionError - @defer.inlineCallbacks + @inlineCallbacks def test_start_items(self): items = [] @@ -202,7 +201,7 @@ class TestCrawl(TestCase): assert len(log.records) == 0 assert items == [{"name": "test item"}] - @defer.inlineCallbacks + @inlineCallbacks 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 @@ -223,7 +222,7 @@ class TestCrawl(TestCase): assert len(items) == 3 assert not any(isinstance(item, Request) for item in items) - @defer.inlineCallbacks + @inlineCallbacks def test_start_dupes(self): settings = {"CONCURRENT_REQUESTS": 1} crawler = get_crawler(DuplicateStartSpider, settings) @@ -241,7 +240,7 @@ class TestCrawl(TestCase): ) assert crawler.spider.visited == 3 - @defer.inlineCallbacks + @inlineCallbacks 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" @@ -275,7 +274,7 @@ with multiples lines ) assert str(log).count("Got response 200") == 1 - @defer.inlineCallbacks + @inlineCallbacks def test_retry_conn_lost(self): # connection lost after receiving data crawler = get_crawler(SimpleSpider) @@ -285,7 +284,7 @@ with multiples lines ) self._assert_retried(log) - @defer.inlineCallbacks + @inlineCallbacks def test_retry_conn_aborted(self): # connection lost before receiving data crawler = get_crawler(SimpleSpider) @@ -299,7 +298,7 @@ with multiples lines assert str(log).count("Retrying") == 2 assert str(log).count("Gave up retrying") == 1 - @defer.inlineCallbacks + @inlineCallbacks 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) @@ -327,7 +326,7 @@ with multiples lines echo3 = json.loads(to_unicode(crawler.spider.meta["responses"][3].body)) assert echo3["headers"].get("Referer") == ["http://example.com"] - @defer.inlineCallbacks + @inlineCallbacks def test_engine_status(self): from scrapy.utils.engine import get_engine_status @@ -345,7 +344,7 @@ with multiples lines assert s["engine.spider.name"] == crawler.spider.name assert s["len(engine.scraper.slot.active)"] == 1 - @defer.inlineCallbacks + @inlineCallbacks def test_format_engine_status(self): from scrapy.utils.engine import format_engine_status @@ -370,7 +369,7 @@ with multiples lines assert s["engine.spider.name"] == crawler.spider.name assert s["len(engine.scraper.slot.active)"] == "1" - @defer.inlineCallbacks + @inlineCallbacks def test_open_spider_error_on_faulty_pipeline(self): settings = { "ITEM_PIPELINES": { @@ -378,15 +377,13 @@ with multiples lines } } crawler = get_crawler(SimpleSpider, settings) - yield self.assertFailure( - crawler.crawl( + with pytest.raises(ZeroDivisionError): + yield crawler.crawl( self.mockserver.url("/status?n=200"), mockserver=self.mockserver - ), - ZeroDivisionError, - ) + ) assert not crawler.crawling - @defer.inlineCallbacks + @inlineCallbacks def test_crawlerrunner_accepts_crawler(self): crawler = get_crawler(SimpleSpider) runner = CrawlerRunner() @@ -398,7 +395,7 @@ with multiples lines ) assert "Got response 200" in str(log) - @defer.inlineCallbacks + @inlineCallbacks def test_crawl_multiple(self): runner = CrawlerRunner(get_reactor_settings()) runner.crawl( @@ -431,7 +428,7 @@ class TestCrawlSpider(TestCase): def tearDownClass(cls): cls.mockserver.__exit__(None, None, None) - @defer.inlineCallbacks + @inlineCallbacks def _run_spider(self, spider_cls): items = [] @@ -446,7 +443,7 @@ class TestCrawlSpider(TestCase): ) return log, items, crawler.stats - @defer.inlineCallbacks + @inlineCallbacks def test_crawlspider_with_parse(self): crawler = get_crawler(CrawlSpiderWithParseMethod) with LogCapture() as log: @@ -456,7 +453,7 @@ class TestCrawlSpider(TestCase): assert "[parse] status 201 (foo: None)" in str(log) assert "[parse] status 202 (foo: bar)" in str(log) - @defer.inlineCallbacks + @inlineCallbacks def test_crawlspider_with_async_callback(self): crawler = get_crawler(CrawlSpiderWithAsyncCallback) with LogCapture() as log: @@ -466,7 +463,7 @@ class TestCrawlSpider(TestCase): assert "[parse_async] status 201 (foo: None)" in str(log) assert "[parse_async] status 202 (foo: bar)" in str(log) - @defer.inlineCallbacks + @inlineCallbacks def test_crawlspider_with_async_generator_callback(self): crawler = get_crawler(CrawlSpiderWithAsyncGeneratorCallback) with LogCapture() as log: @@ -476,7 +473,7 @@ class TestCrawlSpider(TestCase): assert "[parse_async_gen] status 201 (foo: None)" in str(log) assert "[parse_async_gen] status 202 (foo: bar)" in str(log) - @defer.inlineCallbacks + @inlineCallbacks def test_crawlspider_with_errback(self): crawler = get_crawler(CrawlSpiderWithErrback) with LogCapture() as log: @@ -489,7 +486,7 @@ class TestCrawlSpider(TestCase): assert "[errback] status 500" in str(log) assert "[errback] status 501" in str(log) - @defer.inlineCallbacks + @inlineCallbacks def test_crawlspider_process_request_cb_kwargs(self): crawler = get_crawler(CrawlSpiderWithProcessRequestCallbackKeywordArguments) with LogCapture() as log: @@ -499,7 +496,7 @@ class TestCrawlSpider(TestCase): assert "[parse] status 201 (foo: process_request)" in str(log) assert "[parse] status 202 (foo: bar)" in str(log) - @defer.inlineCallbacks + @inlineCallbacks def test_async_def_parse(self): crawler = get_crawler(AsyncDefSpider) with LogCapture() as log: @@ -509,7 +506,7 @@ class TestCrawlSpider(TestCase): assert "Got response 200" in str(log) @pytest.mark.only_asyncio - @defer.inlineCallbacks + @inlineCallbacks def test_async_def_asyncio_parse(self): crawler = get_crawler( AsyncDefAsyncioSpider, @@ -524,7 +521,7 @@ class TestCrawlSpider(TestCase): assert "Got response 200" in str(log) @pytest.mark.only_asyncio - @defer.inlineCallbacks + @inlineCallbacks def test_async_def_asyncio_parse_items_list(self): log, items, _ = yield self._run_spider(AsyncDefAsyncioReturnSpider) assert "Got response 200" in str(log) @@ -532,7 +529,7 @@ class TestCrawlSpider(TestCase): assert {"id": 2} in items @pytest.mark.only_asyncio - @defer.inlineCallbacks + @inlineCallbacks def test_async_def_asyncio_parse_items_single_element(self): items = [] @@ -549,7 +546,7 @@ class TestCrawlSpider(TestCase): assert {"foo": 42} in items @pytest.mark.only_asyncio - @defer.inlineCallbacks + @inlineCallbacks def test_async_def_asyncgen_parse(self): log, _, stats = yield self._run_spider(AsyncDefAsyncioGenSpider) assert "Got response 200" in str(log) @@ -557,7 +554,7 @@ class TestCrawlSpider(TestCase): assert itemcount == 1 @pytest.mark.only_asyncio - @defer.inlineCallbacks + @inlineCallbacks def test_async_def_asyncgen_parse_loop(self): log, items, stats = yield self._run_spider(AsyncDefAsyncioGenLoopSpider) assert "Got response 200" in str(log) @@ -567,7 +564,7 @@ class TestCrawlSpider(TestCase): assert {"foo": i} in items @pytest.mark.only_asyncio - @defer.inlineCallbacks + @inlineCallbacks def test_async_def_asyncgen_parse_exc(self): log, items, stats = yield self._run_spider(AsyncDefAsyncioGenExcSpider) log = str(log) @@ -579,7 +576,7 @@ class TestCrawlSpider(TestCase): assert {"foo": i} in items @pytest.mark.only_asyncio - @defer.inlineCallbacks + @inlineCallbacks def test_async_def_asyncgen_parse_complex(self): _, items, stats = yield self._run_spider(AsyncDefAsyncioGenComplexSpider) itemcount = stats.get_value("item_scraped_count") @@ -591,37 +588,37 @@ class TestCrawlSpider(TestCase): assert {"index2": i} in items @pytest.mark.only_asyncio - @defer.inlineCallbacks + @inlineCallbacks def test_async_def_asyncio_parse_reqs_list(self): log, *_ = yield 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 - @defer.inlineCallbacks + @inlineCallbacks def test_async_def_deferred_direct(self): _, items, _ = yield self._run_spider(AsyncDefDeferredDirectSpider) assert items == [{"code": 200}] @pytest.mark.only_asyncio - @defer.inlineCallbacks + @inlineCallbacks def test_async_def_deferred_wrapped(self): log, items, _ = yield self._run_spider(AsyncDefDeferredWrappedSpider) assert items == [{"code": 200}] - @defer.inlineCallbacks + @inlineCallbacks def test_async_def_deferred_maybe_wrapped(self): _, items, _ = yield self._run_spider(AsyncDefDeferredMaybeWrappedSpider) assert items == [{"code": 200}] - @defer.inlineCallbacks + @inlineCallbacks def test_response_ssl_certificate_none(self): crawler = get_crawler(SingleRequestSpider) url = self.mockserver.url("/echo?body=test", is_secure=False) yield crawler.crawl(seed=url, mockserver=self.mockserver) assert crawler.spider.meta["responses"][0].certificate is None - @defer.inlineCallbacks + @inlineCallbacks def test_response_ssl_certificate(self): crawler = get_crawler(SingleRequestSpider) url = self.mockserver.url("/echo?body=test", is_secure=True) @@ -634,7 +631,7 @@ class TestCrawlSpider(TestCase): @pytest.mark.xfail( reason="Responses with no body return early and contain no certificate" ) - @defer.inlineCallbacks + @inlineCallbacks def test_response_ssl_certificate_empty_response(self): crawler = get_crawler(SingleRequestSpider) url = self.mockserver.url("/status?n=200", is_secure=True) @@ -644,7 +641,7 @@ class TestCrawlSpider(TestCase): assert cert.getSubject().commonName == b"localhost" assert cert.getIssuer().commonName == b"localhost" - @defer.inlineCallbacks + @inlineCallbacks def test_dns_server_ip_address_none(self): crawler = get_crawler(SingleRequestSpider) url = self.mockserver.url("/status?n=200") @@ -652,7 +649,7 @@ class TestCrawlSpider(TestCase): ip_address = crawler.spider.meta["responses"][0].ip_address assert ip_address is None - @defer.inlineCallbacks + @inlineCallbacks def test_dns_server_ip_address(self): crawler = get_crawler(SingleRequestSpider) url = self.mockserver.url("/echo?body=test") @@ -662,7 +659,7 @@ class TestCrawlSpider(TestCase): assert isinstance(ip_address, IPv4Address) assert str(ip_address) == gethostbyname(expected_netloc) - @defer.inlineCallbacks + @inlineCallbacks def test_bytes_received_stop_download_callback(self): crawler = get_crawler(BytesReceivedCallbackSpider) yield crawler.crawl(mockserver=self.mockserver) @@ -676,7 +673,7 @@ class TestCrawlSpider(TestCase): < crawler.spider.full_response_length ) - @defer.inlineCallbacks + @inlineCallbacks def test_bytes_received_stop_download_errback(self): crawler = get_crawler(BytesReceivedErrbackSpider) yield crawler.crawl(mockserver=self.mockserver) @@ -692,7 +689,7 @@ class TestCrawlSpider(TestCase): < crawler.spider.full_response_length ) - @defer.inlineCallbacks + @inlineCallbacks def test_headers_received_stop_download_callback(self): crawler = get_crawler(HeadersReceivedCallbackSpider) yield crawler.crawl(mockserver=self.mockserver) @@ -702,7 +699,7 @@ class TestCrawlSpider(TestCase): "headers_received" ) - @defer.inlineCallbacks + @inlineCallbacks def test_headers_received_stop_download_errback(self): crawler = get_crawler(HeadersReceivedErrbackSpider) yield crawler.crawl(mockserver=self.mockserver) @@ -714,7 +711,7 @@ class TestCrawlSpider(TestCase): "failure" ].value.response.headers == crawler.spider.meta.get("headers_received") - @defer.inlineCallbacks + @inlineCallbacks def test_spider_errback(self): failures = [] @@ -731,7 +728,7 @@ class TestCrawlSpider(TestCase): assert "HTTP status code is not handled or not allowed" in str(log) assert "Spider error processing" not in str(log) - @defer.inlineCallbacks + @inlineCallbacks def test_spider_errback_silence(self): failures = [] @@ -747,7 +744,7 @@ class TestCrawlSpider(TestCase): assert "HTTP status code is not handled or not allowed" not in str(log) assert "Spider error processing" not in str(log) - @defer.inlineCallbacks + @inlineCallbacks def test_spider_errback_exception(self): def eb(failure: Failure) -> None: raise ValueError("foo") @@ -759,7 +756,7 @@ class TestCrawlSpider(TestCase): ) assert "Spider error processing" in str(log) - @defer.inlineCallbacks + @inlineCallbacks def test_spider_errback_item(self): def eb(failure: Failure) -> Any: return {"foo": "bar"} @@ -773,7 +770,7 @@ class TestCrawlSpider(TestCase): assert "Spider error processing" not in str(log) assert "'item_scraped_count': 1" in str(log) - @defer.inlineCallbacks + @inlineCallbacks def test_spider_errback_request(self): def eb(failure: Failure) -> Request: return Request(self.mockserver.url("/")) @@ -787,7 +784,7 @@ class TestCrawlSpider(TestCase): assert "Spider error processing" not in str(log) assert "Crawled (200)" in str(log) - @defer.inlineCallbacks + @inlineCallbacks def test_spider_errback_downloader_error(self): failures = [] @@ -804,7 +801,7 @@ class TestCrawlSpider(TestCase): assert "Error downloading" in str(log) assert "Spider error processing" not in str(log) - @defer.inlineCallbacks + @inlineCallbacks def test_spider_errback_downloader_error_exception(self): def eb(failure: Failure) -> None: raise ValueError("foo") @@ -817,7 +814,7 @@ class TestCrawlSpider(TestCase): assert "Error downloading" in str(log) assert "Spider error processing" in str(log) - @defer.inlineCallbacks + @inlineCallbacks def test_spider_errback_downloader_error_item(self): def eb(failure: Failure) -> Any: return {"foo": "bar"} @@ -831,7 +828,7 @@ class TestCrawlSpider(TestCase): assert "Spider error processing" not in str(log) assert "'item_scraped_count': 1" in str(log) - @defer.inlineCallbacks + @inlineCallbacks def test_spider_errback_downloader_error_request(self): def eb(failure: Failure) -> Request: return Request(self.mockserver.url("/")) @@ -845,7 +842,7 @@ class TestCrawlSpider(TestCase): assert "Spider error processing" not in str(log) assert "Crawled (200)" in str(log) - @defer.inlineCallbacks + @inlineCallbacks def test_raise_closespider(self): def cb(response): raise CloseSpider @@ -856,7 +853,7 @@ class TestCrawlSpider(TestCase): assert "Closing spider (cancelled)" in str(log) assert "Spider error processing" not in str(log) - @defer.inlineCallbacks + @inlineCallbacks def test_raise_closespider_reason(self): def cb(response): raise CloseSpider("my_reason") diff --git a/tests/test_dependencies.py b/tests/test_dependencies.py index c2df67c66..4436efd9b 100644 --- a/tests/test_dependencies.py +++ b/tests/test_dependencies.py @@ -1,25 +1,13 @@ import os import re from configparser import ConfigParser -from importlib import import_module from pathlib import Path import pytest from twisted import version as twisted_version -from twisted.trial import unittest class TestScrapyUtils: - def test_required_openssl_version(self): - try: - module = import_module("OpenSSL") - except ImportError: - raise unittest.SkipTest("OpenSSL is not available") - - if hasattr(module, "__version__"): - installed_version = [int(x) for x in module.__version__.split(".")[:2]] - assert installed_version >= [0, 6], "OpenSSL >= 0.6 required" - def test_pinned_twisted_version(self): """When running tests within a Tox environment with pinned dependencies, make sure that the version of Twisted is the pinned diff --git a/tests/test_downloader_handlers.py b/tests/test_downloader_handlers.py index 09cdbaf35..2c8e96040 100644 --- a/tests/test_downloader_handlers.py +++ b/tests/test_downloader_handlers.py @@ -440,7 +440,7 @@ class TestFTPBase(unittest.TestCase): class TestFTP(TestFTPBase): def test_invalid_credentials(self): if self.reactor_pytest != "default" and sys.platform == "win32": - raise unittest.SkipTest( + pytest.skip( "This test produces DirtyReactorAggregateError on Windows with asyncio" ) from twisted.protocols.ftp import ConnectionLost diff --git a/tests/test_downloader_handlers_http_base.py b/tests/test_downloader_handlers_http_base.py index 14e12a3e6..9b2c49fd4 100644 --- a/tests/test_downloader_handlers_http_base.py +++ b/tests/test_downloader_handlers_http_base.py @@ -14,7 +14,7 @@ from unittest import mock import pytest from testfixtures import LogCapture from twisted.internet import defer, error -from twisted.internet.defer import maybeDeferred +from twisted.internet.defer import inlineCallbacks, maybeDeferred from twisted.protocols.policies import WrappingFactory from twisted.trial import unittest from twisted.web import resource, server, static, util @@ -186,7 +186,7 @@ class TestHttpBase(unittest.TestCase, ABC): self.download_handler_cls, get_crawler() ) - @defer.inlineCallbacks + @inlineCallbacks def tearDown(self): yield self.port.stopListening() if hasattr(self.download_handler, "close"): @@ -229,7 +229,7 @@ class TestHttpBase(unittest.TestCase, ABC): async def test_timeout_download_from_spider_nodata_rcvd(self): if self.reactor_pytest != "default" and sys.platform == "win32": # https://twistedmatrix.com/trac/ticket/10279 - raise unittest.SkipTest( + pytest.skip( "This test produces DirtyReactorAggregateError on Windows with asyncio" ) @@ -245,7 +245,7 @@ class TestHttpBase(unittest.TestCase, ABC): async def test_timeout_download_from_spider_server_hangs(self): if self.reactor_pytest != "default" and sys.platform == "win32": # https://twistedmatrix.com/trac/ticket/10279 - raise unittest.SkipTest( + pytest.skip( "This test produces DirtyReactorAggregateError on Windows with asyncio" ) # client connects, server send headers and some body bytes but hangs @@ -531,7 +531,7 @@ class TestSimpleHttpsBase(unittest.TestCase, ABC): crawler = get_crawler(settings_dict=settings_dict) self.download_handler = build_from_crawler(self.download_handler_cls, crawler) - @defer.inlineCallbacks + @inlineCallbacks def tearDown(self): yield self.port.stopListening() if hasattr(self.download_handler, "close"): @@ -665,7 +665,7 @@ class TestHttpProxyBase(unittest.TestCase, ABC): self.download_handler_cls, get_crawler() ) - @defer.inlineCallbacks + @inlineCallbacks def tearDown(self): yield self.port.stopListening() if hasattr(self.download_handler, "close"): diff --git a/tests/test_downloadermiddleware_httpcompression.py b/tests/test_downloadermiddleware_httpcompression.py index e7427c5ac..3c26b242f 100644 --- a/tests/test_downloadermiddleware_httpcompression.py +++ b/tests/test_downloadermiddleware_httpcompression.py @@ -2,7 +2,6 @@ from gzip import GzipFile from io import BytesIO from logging import WARNING from pathlib import Path -from unittest import SkipTest import pytest from testfixtures import LogCapture @@ -130,7 +129,7 @@ class TestHttpCompression: except ImportError: import brotlicffi # noqa: F401 except ImportError: - raise SkipTest("no brotli") + pytest.skip("no brotli") response = self._getresponse("br") request = response.request assert response.headers["Content-Encoding"] == b"br" @@ -146,11 +145,11 @@ class TestHttpCompression: try: import brotli # noqa: F401 - raise SkipTest("Requires not having brotli support") + pytest.skip("Requires not having brotli support") except ImportError: import brotlicffi # noqa: F401 - raise SkipTest("Requires not having brotli support") + pytest.skip("Requires not having brotli support") except ImportError: pass response = self._getresponse("br") @@ -180,7 +179,7 @@ class TestHttpCompression: try: import zstandard # noqa: F401 except ImportError: - raise SkipTest("no zstd support (zstandard)") + pytest.skip("no zstd support (zstandard)") raw_content = None for check_key in FORMAT: if not check_key.startswith("zstd-"): @@ -201,7 +200,7 @@ class TestHttpCompression: try: import zstandard # noqa: F401 - raise SkipTest("Requires not having zstandard support") + pytest.skip("Requires not having zstandard support") except ImportError: pass response = self._getresponse("zstd-static-content-size") @@ -520,7 +519,7 @@ class TestHttpCompression: except ImportError: import brotlicffi # noqa: F401 except ImportError: - raise SkipTest("no brotli") + pytest.skip("no brotli") self._test_compression_bomb_setting("br") def test_compression_bomb_setting_deflate(self): @@ -533,7 +532,7 @@ class TestHttpCompression: try: import zstandard # noqa: F401 except ImportError: - raise SkipTest("no zstd support (zstandard)") + pytest.skip("no zstd support (zstandard)") self._test_compression_bomb_setting("zstd") def _test_compression_bomb_spider_attr(self, compression_id): @@ -556,7 +555,7 @@ class TestHttpCompression: except ImportError: import brotlicffi # noqa: F401 except ImportError: - raise SkipTest("no brotli") + pytest.skip("no brotli") self._test_compression_bomb_spider_attr("br") def test_compression_bomb_spider_attr_deflate(self): @@ -569,7 +568,7 @@ class TestHttpCompression: try: import zstandard # noqa: F401 except ImportError: - raise SkipTest("no zstd support (zstandard)") + pytest.skip("no zstd support (zstandard)") self._test_compression_bomb_spider_attr("zstd") def _test_compression_bomb_request_meta(self, compression_id): @@ -590,7 +589,7 @@ class TestHttpCompression: except ImportError: import brotlicffi # noqa: F401 except ImportError: - raise SkipTest("no brotli") + pytest.skip("no brotli") self._test_compression_bomb_request_meta("br") def test_compression_bomb_request_meta_deflate(self): @@ -603,7 +602,7 @@ class TestHttpCompression: try: import zstandard # noqa: F401 except ImportError: - raise SkipTest("no zstd support (zstandard)") + pytest.skip("no zstd support (zstandard)") self._test_compression_bomb_request_meta("zstd") def _test_download_warnsize_setting(self, compression_id): @@ -639,7 +638,7 @@ class TestHttpCompression: except ImportError: import brotlicffi # noqa: F401 except ImportError: - raise SkipTest("no brotli") + pytest.skip("no brotli") self._test_download_warnsize_setting("br") def test_download_warnsize_setting_deflate(self): @@ -652,7 +651,7 @@ class TestHttpCompression: try: import zstandard # noqa: F401 except ImportError: - raise SkipTest("no zstd support (zstandard)") + pytest.skip("no zstd support (zstandard)") self._test_download_warnsize_setting("zstd") def _test_download_warnsize_spider_attr(self, compression_id): @@ -690,7 +689,7 @@ class TestHttpCompression: except ImportError: import brotlicffi # noqa: F401 except ImportError: - raise SkipTest("no brotli") + pytest.skip("no brotli") self._test_download_warnsize_spider_attr("br") def test_download_warnsize_spider_attr_deflate(self): @@ -703,7 +702,7 @@ class TestHttpCompression: try: import zstandard # noqa: F401 except ImportError: - raise SkipTest("no zstd support (zstandard)") + pytest.skip("no zstd support (zstandard)") self._test_download_warnsize_spider_attr("zstd") def _test_download_warnsize_request_meta(self, compression_id): @@ -739,7 +738,7 @@ class TestHttpCompression: except ImportError: import brotlicffi # noqa: F401 except ImportError: - raise SkipTest("no brotli") + pytest.skip("no brotli") self._test_download_warnsize_request_meta("br") def test_download_warnsize_request_meta_deflate(self): @@ -752,5 +751,5 @@ class TestHttpCompression: try: import zstandard # noqa: F401 except ImportError: - raise SkipTest("no zstd support (zstandard)") + pytest.skip("no zstd support (zstandard)") self._test_download_warnsize_request_meta("zstd") diff --git a/tests/test_downloadermiddleware_robotstxt.py b/tests/test_downloadermiddleware_robotstxt.py index 04800896c..146b0057e 100644 --- a/tests/test_downloadermiddleware_robotstxt.py +++ b/tests/test_downloadermiddleware_robotstxt.py @@ -235,12 +235,10 @@ Disallow: /some/randome/page.html self, request: Request, middleware: RobotsTxtMiddleware ) -> None: spider = None # not actually used - await maybe_deferred_to_future( - self.assertFailure( - middleware.process_request(request, spider), # type: ignore[arg-type] - IgnoreRequest, + with pytest.raises(IgnoreRequest): + await maybe_deferred_to_future( + maybeDeferred(middleware.process_request, request, spider) # type: ignore[call-overload] ) - ) def assertRobotsTxtRequested(self, base_url: str) -> None: calls = self.crawler.engine.download.call_args_list diff --git a/tests/test_downloaderslotssettings.py b/tests/test_downloaderslotssettings.py index 78c83ea83..9b7c09448 100644 --- a/tests/test_downloaderslotssettings.py +++ b/tests/test_downloaderslotssettings.py @@ -1,6 +1,6 @@ import time -from twisted.internet import defer +from twisted.internet.defer import inlineCallbacks from twisted.trial.unittest import TestCase from scrapy import Request @@ -62,7 +62,7 @@ class CrawlTestCase(TestCase): def setUp(self): self.runner = CrawlerRunner() - @defer.inlineCallbacks + @inlineCallbacks def test_delay(self): crawler = get_crawler(DownloaderSlotsSettingsTestSpider) yield crawler.crawl(mockserver=self.mockserver) diff --git a/tests/test_engine.py b/tests/test_engine.py index 9f618437c..e181a36cf 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -26,6 +26,7 @@ import pytest from itemadapter import ItemAdapter from pydispatch import dispatcher from twisted.internet import defer +from twisted.internet.defer import inlineCallbacks from twisted.trial import unittest from twisted.web import server, static, util @@ -390,7 +391,7 @@ class TestEngineBase(unittest.TestCase): class TestEngine(TestEngineBase): - @defer.inlineCallbacks + @inlineCallbacks def test_crawler(self): for spider in ( MySpider, @@ -407,20 +408,20 @@ class TestEngine(TestEngineBase): self._assert_signals_caught(run) self._assert_bytes_received(run) - @defer.inlineCallbacks + @inlineCallbacks def test_crawler_dupefilter(self): run = CrawlerRun(DupeFilterSpider) yield run.run() self._assert_scheduled_requests(run, count=8) self._assert_dropped_requests(run) - @defer.inlineCallbacks + @inlineCallbacks def test_crawler_itemerror(self): run = CrawlerRun(ItemZeroDivisionErrorSpider) yield run.run() self._assert_items_error(run) - @defer.inlineCallbacks + @inlineCallbacks def test_crawler_change_close_reason_on_idle(self): run = CrawlerRun(ChangeCloseReasonSpider) yield run.run() @@ -429,7 +430,7 @@ class TestEngine(TestEngineBase): "reason": "custom_reason", } == run.signals_caught[signals.spider_closed] - @defer.inlineCallbacks + @inlineCallbacks def test_close_downloader(self): e = ExecutionEngine(get_crawler(MySpider), lambda _: None) yield e.close() @@ -447,19 +448,14 @@ class TestEngine(TestEngineBase): get_crawler(MySpider, {"DOWNLOADER": BadDownloader}), lambda _: None ) - @defer.inlineCallbacks + @inlineCallbacks def test_start_already_running_exception(self): e = ExecutionEngine(get_crawler(MySpider), lambda _: None) yield e.open_spider(MySpider(), []) e.start() - - def cb(exc: BaseException) -> None: - assert str(exc), "Engine already running" - - try: - yield self.assertFailure(e.start(), RuntimeError).addBoth(cb) - finally: - yield e.stop() + with pytest.raises(RuntimeError, match="Engine already running"): + yield e.start() + yield e.stop() def test_short_timeout(self): args = ( diff --git a/tests/test_engine_stop_download_bytes.py b/tests/test_engine_stop_download_bytes.py index f09b0e091..2662e45e1 100644 --- a/tests/test_engine_stop_download_bytes.py +++ b/tests/test_engine_stop_download_bytes.py @@ -1,5 +1,5 @@ from testfixtures import LogCapture -from twisted.internet import defer +from twisted.internet.defer import inlineCallbacks from scrapy.exceptions import StopDownload from tests.test_engine import ( @@ -19,7 +19,7 @@ class BytesReceivedCrawlerRun(CrawlerRun): class TestBytesReceivedEngine(TestEngineBase): - @defer.inlineCallbacks + @inlineCallbacks def test_crawler(self): for spider in ( MySpider, diff --git a/tests/test_engine_stop_download_headers.py b/tests/test_engine_stop_download_headers.py index dbb0ea0d2..142715927 100644 --- a/tests/test_engine_stop_download_headers.py +++ b/tests/test_engine_stop_download_headers.py @@ -1,5 +1,5 @@ from testfixtures import LogCapture -from twisted.internet import defer +from twisted.internet.defer import inlineCallbacks from scrapy.exceptions import StopDownload from tests.test_engine import ( @@ -19,7 +19,7 @@ class HeadersReceivedCrawlerRun(CrawlerRun): class TestHeadersReceivedEngine(TestEngineBase): - @defer.inlineCallbacks + @inlineCallbacks def test_crawler(self): for spider in ( MySpider, diff --git a/tests/test_exporters.py b/tests/test_exporters.py index f55cb6c97..05e8865bc 100644 --- a/tests/test_exporters.py +++ b/tests/test_exporters.py @@ -4,7 +4,6 @@ import marshal import pickle import re import tempfile -import unittest from datetime import datetime from io import BytesIO from typing import Any @@ -662,7 +661,7 @@ class TestCustomExporterItem: def setup_method(self): if self.item_class is None: - raise unittest.SkipTest("item class is None") + pytest.skip("item class is None") def test_exporter_custom_serializer(self): class CustomItemExporter(BaseItemExporter): diff --git a/tests/test_extension_periodic_log.py b/tests/test_extension_periodic_log.py index 85bd42857..b86f3c7f2 100644 --- a/tests/test_extension_periodic_log.py +++ b/tests/test_extension_periodic_log.py @@ -1,7 +1,6 @@ from __future__ import annotations import datetime -import unittest from typing import Any, Callable from scrapy.extensions.periodic_log import PeriodicLog @@ -66,7 +65,7 @@ def extension(settings: dict[str, Any] | None = None) -> CustomPeriodicLog: return CustomPeriodicLog.from_crawler(crawler) -class TestPeriodicLog(unittest.TestCase): +class TestPeriodicLog: def test_extension_enabled(self): # Expected that settings for this extension loaded successfully # And on certain conditions - extension raising NotConfigured diff --git a/tests/test_extension_telnet.py b/tests/test_extension_telnet.py index 8c897c223..2ac4d7830 100644 --- a/tests/test_extension_telnet.py +++ b/tests/test_extension_telnet.py @@ -1,6 +1,7 @@ +import pytest from twisted.conch.telnet import ITelnetProtocol from twisted.cred import credentials -from twisted.internet import defer +from twisted.internet.defer import inlineCallbacks from twisted.trial import unittest from scrapy.extensions.telnet import TelnetConsole @@ -21,15 +22,16 @@ class TelnetExtensionTest(unittest.TestCase): return console, portal - @defer.inlineCallbacks + @inlineCallbacks def test_bad_credentials(self): console, portal = self._get_console_and_portal() creds = credentials.UsernamePassword(b"username", b"password") d = portal.login(creds, None, ITelnetProtocol) - yield self.assertFailure(d, ValueError) + with pytest.raises(ValueError, match="Invalid credentials"): + yield d console.stop_listening() - @defer.inlineCallbacks + @inlineCallbacks def test_good_credentials(self): console, portal = self._get_console_and_portal() creds = credentials.UsernamePassword( @@ -39,7 +41,7 @@ class TelnetExtensionTest(unittest.TestCase): yield d console.stop_listening() - @defer.inlineCallbacks + @inlineCallbacks def test_custom_credentials(self): settings = { "TELNETCONSOLE_USERNAME": "user", diff --git a/tests/test_feedexport.py b/tests/test_feedexport.py index 44cd10ec3..cdf03ca76 100644 --- a/tests/test_feedexport.py +++ b/tests/test_feedexport.py @@ -26,6 +26,7 @@ import lxml.etree import pytest from testfixtures import LogCapture from twisted.internet import defer +from twisted.internet.defer import inlineCallbacks from twisted.trial import unittest from w3lib.url import file_uri_to_path, path_to_file_uri from zope.interface import implementer @@ -131,7 +132,7 @@ class TestFileFeedStorage(unittest.TestCase): FileFeedStorage(str(path), feed_options={"overwrite": True}), path ) - @defer.inlineCallbacks + @inlineCallbacks def _assert_stores(self, storage, path: Path, expected_content=b"content"): spider = scrapy.Spider("default") file = storage.open(spider) @@ -172,7 +173,7 @@ class TestFTPFeedStorage(unittest.TestCase): finally: path.unlink() - @defer.inlineCallbacks + @inlineCallbacks def test_append(self): with MockFTPServer() as ftp_server: filename = "file" @@ -182,7 +183,7 @@ class TestFTPFeedStorage(unittest.TestCase): yield self._store(url, b"bar", feed_options=feed_options) self._assert_stored(ftp_server.path / filename, b"foobar") - @defer.inlineCallbacks + @inlineCallbacks def test_overwrite(self): with MockFTPServer() as ftp_server: filename = "file" @@ -191,7 +192,7 @@ class TestFTPFeedStorage(unittest.TestCase): yield self._store(url, b"bar") self._assert_stored(ftp_server.path / filename, b"bar") - @defer.inlineCallbacks + @inlineCallbacks def test_append_active_mode(self): with MockFTPServer() as ftp_server: settings = {"FEED_STORAGE_FTP_ACTIVE": True} @@ -202,7 +203,7 @@ class TestFTPFeedStorage(unittest.TestCase): yield self._store(url, b"bar", feed_options=feed_options, settings=settings) self._assert_stored(ftp_server.path / filename, b"foobar") - @defer.inlineCallbacks + @inlineCallbacks def test_overwrite_active_mode(self): with MockFTPServer() as ftp_server: settings = {"FEED_STORAGE_FTP_ACTIVE": True} @@ -290,7 +291,7 @@ class TestS3FeedStorage(unittest.TestCase): assert storage.access_key == "uri_key" assert storage.secret_key == "uri_secret" - @defer.inlineCallbacks + @inlineCallbacks def test_store(self): settings = { "AWS_ACCESS_KEY_ID": "access_key", @@ -431,7 +432,7 @@ class TestS3FeedStorage(unittest.TestCase): assert storage.region_name == region_name assert storage.s3_client._client_config.region_name == region_name - @defer.inlineCallbacks + @inlineCallbacks def test_store_without_acl(self): storage = S3FeedStorage( "s3://mybucket/export.csv", @@ -451,7 +452,7 @@ class TestS3FeedStorage(unittest.TestCase): ) assert acl is None - @defer.inlineCallbacks + @inlineCallbacks def test_store_with_acl(self): storage = S3FeedStorage( "s3://mybucket/export.csv", "access_key", "secret_key", "custom-acl" @@ -489,7 +490,7 @@ class TestGCSFeedStorage(unittest.TestCase): try: from google.cloud.storage import Client # noqa: F401 except ImportError: - raise unittest.SkipTest("GCSFeedStorage requires google-cloud-storage") + pytest.skip("GCSFeedStorage requires google-cloud-storage") settings = {"GCS_PROJECT_ID": "123", "FEED_STORAGE_GCS_ACL": "publicRead"} crawler = get_crawler(settings_dict=settings) @@ -503,7 +504,7 @@ class TestGCSFeedStorage(unittest.TestCase): try: from google.cloud.storage import Client # noqa: F401 except ImportError: - raise unittest.SkipTest("GCSFeedStorage requires google-cloud-storage") + pytest.skip("GCSFeedStorage requires google-cloud-storage") settings = {"GCS_PROJECT_ID": "123", "FEED_STORAGE_GCS_ACL": ""} crawler = get_crawler(settings_dict=settings) @@ -515,12 +516,12 @@ class TestGCSFeedStorage(unittest.TestCase): storage = GCSFeedStorage.from_crawler(crawler, "gs://mybucket/export.csv") assert storage.acl is None - @defer.inlineCallbacks + @inlineCallbacks def test_store(self): try: from google.cloud.storage import Client # noqa: F401 except ImportError: - raise unittest.SkipTest("GCSFeedStorage requires google-cloud-storage") + pytest.skip("GCSFeedStorage requires google-cloud-storage") uri = "gs://mybucket/export.csv" project_id = "myproject-123" @@ -556,7 +557,7 @@ class TestGCSFeedStorage(unittest.TestCase): class TestStdoutFeedStorage(unittest.TestCase): - @defer.inlineCallbacks + @inlineCallbacks def test_store(self): out = BytesIO() storage = StdoutFeedStorage("stdout:", _stdout=out) @@ -669,7 +670,7 @@ class TestFeedExportBase(ABC, unittest.TestCase): def tearDown(self): shutil.rmtree(self.temp_dir, ignore_errors=True) - @defer.inlineCallbacks + @inlineCallbacks def exported_data(self, items, settings): """ Return exported data which a spider yielding ``items`` would return. @@ -684,7 +685,7 @@ class TestFeedExportBase(ABC, unittest.TestCase): data = yield self.run_and_export(TestSpider, settings) return data - @defer.inlineCallbacks + @inlineCallbacks def exported_no_data(self, settings): """ Return exported data which a spider yielding no ``items`` would return. @@ -699,7 +700,7 @@ class TestFeedExportBase(ABC, unittest.TestCase): data = yield self.run_and_export(TestSpider, settings) return data - @defer.inlineCallbacks + @inlineCallbacks def assertExported(self, items, header, rows, settings=None): yield self.assertExportedCsv(items, header, rows, settings) yield self.assertExportedJsonLines(items, rows, settings) @@ -770,7 +771,7 @@ class ExceptionJsonItemExporter(JsonItemExporter): class TestFeedExport(TestFeedExportBase): - @defer.inlineCallbacks + @inlineCallbacks def run_and_export(self, spider_cls, settings): """Run spider with specified settings; return exported data.""" @@ -800,7 +801,7 @@ class TestFeedExport(TestFeedExportBase): return content - @defer.inlineCallbacks + @inlineCallbacks def assertExportedCsv(self, items, header, rows, settings=None): settings = settings or {} settings.update( @@ -815,7 +816,7 @@ class TestFeedExport(TestFeedExportBase): assert reader.fieldnames == list(header) assert rows == list(reader) - @defer.inlineCallbacks + @inlineCallbacks def assertExportedJsonLines(self, items, rows, settings=None): settings = settings or {} settings.update( @@ -830,7 +831,7 @@ class TestFeedExport(TestFeedExportBase): rows = [{k: v for k, v in row.items() if v} for row in rows] assert rows == parsed - @defer.inlineCallbacks + @inlineCallbacks def assertExportedXml(self, items, rows, settings=None): settings = settings or {} settings.update( @@ -846,7 +847,7 @@ class TestFeedExport(TestFeedExportBase): got_rows = [{e.tag: e.text for e in it} for it in root.findall("item")] assert rows == got_rows - @defer.inlineCallbacks + @inlineCallbacks def assertExportedMultiple(self, items, rows, settings=None): settings = settings or {} settings.update( @@ -867,7 +868,7 @@ class TestFeedExport(TestFeedExportBase): json_rows = json.loads(to_unicode(data["json"])) assert rows == json_rows - @defer.inlineCallbacks + @inlineCallbacks def assertExportedPickle(self, items, rows, settings=None): settings = settings or {} settings.update( @@ -884,7 +885,7 @@ class TestFeedExport(TestFeedExportBase): result = self._load_until_eof(data["pickle"], load_func=pickle.load) assert result == expected - @defer.inlineCallbacks + @inlineCallbacks def assertExportedMarshal(self, items, rows, settings=None): settings = settings or {} settings.update( @@ -901,7 +902,7 @@ class TestFeedExport(TestFeedExportBase): result = self._load_until_eof(data["marshal"], load_func=marshal.load) assert result == expected - @defer.inlineCallbacks + @inlineCallbacks def test_stats_file_success(self): settings = { "FEEDS": { @@ -915,7 +916,7 @@ class TestFeedExport(TestFeedExportBase): assert "feedexport/success_count/FileFeedStorage" in crawler.stats.get_stats() assert crawler.stats.get_value("feedexport/success_count/FileFeedStorage") == 1 - @defer.inlineCallbacks + @inlineCallbacks def test_stats_file_failed(self): settings = { "FEEDS": { @@ -933,7 +934,7 @@ class TestFeedExport(TestFeedExportBase): assert "feedexport/failed_count/FileFeedStorage" in crawler.stats.get_stats() assert crawler.stats.get_value("feedexport/failed_count/FileFeedStorage") == 1 - @defer.inlineCallbacks + @inlineCallbacks def test_stats_multiple_file(self): settings = { "FEEDS": { @@ -955,7 +956,7 @@ class TestFeedExport(TestFeedExportBase): crawler.stats.get_value("feedexport/success_count/StdoutFeedStorage") == 1 ) - @defer.inlineCallbacks + @inlineCallbacks def test_export_items(self): # feed exporters use field names from Item items = [ @@ -969,7 +970,7 @@ class TestFeedExport(TestFeedExportBase): header = self.MyItem.fields.keys() yield self.assertExported(items, header, rows) - @defer.inlineCallbacks + @inlineCallbacks def test_export_no_items_not_store_empty(self): for fmt in ("json", "jsonlines", "xml", "csv"): settings = { @@ -981,7 +982,7 @@ class TestFeedExport(TestFeedExportBase): data = yield self.exported_no_data(settings) assert data[fmt] is None - @defer.inlineCallbacks + @inlineCallbacks def test_start_finish_exporting_items(self): items = [ self.MyItem({"foo": "bar1", "egg": "spam1"}), @@ -1001,7 +1002,7 @@ class TestFeedExport(TestFeedExportBase): assert not listener.start_without_finish assert not listener.finish_without_start - @defer.inlineCallbacks + @inlineCallbacks def test_start_finish_exporting_no_items(self): items = [] settings = { @@ -1019,7 +1020,7 @@ class TestFeedExport(TestFeedExportBase): assert not listener.start_without_finish assert not listener.finish_without_start - @defer.inlineCallbacks + @inlineCallbacks def test_start_finish_exporting_items_exception(self): items = [ self.MyItem({"foo": "bar1", "egg": "spam1"}), @@ -1040,7 +1041,7 @@ class TestFeedExport(TestFeedExportBase): assert not listener.start_without_finish assert not listener.finish_without_start - @defer.inlineCallbacks + @inlineCallbacks def test_start_finish_exporting_no_items_exception(self): items = [] settings = { @@ -1059,7 +1060,7 @@ class TestFeedExport(TestFeedExportBase): assert not listener.start_without_finish assert not listener.finish_without_start - @defer.inlineCallbacks + @inlineCallbacks def test_export_no_items_store_empty(self): formats = ( ("json", b"[]"), @@ -1079,7 +1080,7 @@ class TestFeedExport(TestFeedExportBase): data = yield self.exported_no_data(settings) assert expctd == data[fmt] - @defer.inlineCallbacks + @inlineCallbacks def test_export_no_items_multiple_feeds(self): """Make sure that `storage.store` is called for every feed.""" settings = { @@ -1097,7 +1098,7 @@ class TestFeedExport(TestFeedExportBase): assert str(log).count("Storage.store is called") == 0 - @defer.inlineCallbacks + @inlineCallbacks def test_export_multiple_item_classes(self): items = [ self.MyItem({"foo": "bar1", "egg": "spam1"}), @@ -1119,7 +1120,7 @@ class TestFeedExport(TestFeedExportBase): yield self.assertExportedCsv(items, header, rows_csv) yield self.assertExportedJsonLines(items, rows_jl) - @defer.inlineCallbacks + @inlineCallbacks def test_export_items_empty_field_list(self): # FEED_EXPORT_FIELDS==[] means the same as default None items = [{"foo": "bar"}] @@ -1129,7 +1130,7 @@ class TestFeedExport(TestFeedExportBase): yield self.assertExportedCsv(items, header, rows) yield self.assertExportedJsonLines(items, rows, settings) - @defer.inlineCallbacks + @inlineCallbacks def test_export_items_field_list(self): items = [{"foo": "bar"}] header = ["foo", "baz"] @@ -1137,7 +1138,7 @@ class TestFeedExport(TestFeedExportBase): settings = {"FEED_EXPORT_FIELDS": header} yield self.assertExported(items, header, rows, settings=settings) - @defer.inlineCallbacks + @inlineCallbacks def test_export_items_comma_separated_field_list(self): items = [{"foo": "bar"}] header = ["foo", "baz"] @@ -1145,7 +1146,7 @@ class TestFeedExport(TestFeedExportBase): settings = {"FEED_EXPORT_FIELDS": ",".join(header)} yield self.assertExported(items, header, rows, settings=settings) - @defer.inlineCallbacks + @inlineCallbacks def test_export_items_json_field_list(self): items = [{"foo": "bar"}] header = ["foo", "baz"] @@ -1153,7 +1154,7 @@ class TestFeedExport(TestFeedExportBase): settings = {"FEED_EXPORT_FIELDS": json.dumps(header)} yield self.assertExported(items, header, rows, settings=settings) - @defer.inlineCallbacks + @inlineCallbacks def test_export_items_field_names(self): items = [{"foo": "bar"}] header = {"foo": "Foo"} @@ -1161,7 +1162,7 @@ class TestFeedExport(TestFeedExportBase): settings = {"FEED_EXPORT_FIELDS": header} yield self.assertExported(items, list(header.values()), rows, settings=settings) - @defer.inlineCallbacks + @inlineCallbacks def test_export_items_dict_field_names(self): items = [{"foo": "bar"}] header = { @@ -1172,7 +1173,7 @@ class TestFeedExport(TestFeedExportBase): settings = {"FEED_EXPORT_FIELDS": header} yield self.assertExported(items, ["Baz", "Foo"], rows, settings=settings) - @defer.inlineCallbacks + @inlineCallbacks def test_export_items_json_field_names(self): items = [{"foo": "bar"}] header = {"foo": "Foo"} @@ -1180,7 +1181,7 @@ class TestFeedExport(TestFeedExportBase): settings = {"FEED_EXPORT_FIELDS": json.dumps(header)} yield self.assertExported(items, list(header.values()), rows, settings=settings) - @defer.inlineCallbacks + @inlineCallbacks def test_export_based_on_item_classes(self): items = [ self.MyItem({"foo": "bar1", "egg": "spam1"}), @@ -1226,7 +1227,7 @@ class TestFeedExport(TestFeedExportBase): for fmt, expected in formats.items(): assert data[fmt] == expected - @defer.inlineCallbacks + @inlineCallbacks def test_export_based_on_custom_filters(self): items = [ self.MyItem({"foo": "bar1", "egg": "spam1"}), @@ -1285,7 +1286,7 @@ class TestFeedExport(TestFeedExportBase): for fmt, expected in formats.items(): assert data[fmt] == expected - @defer.inlineCallbacks + @inlineCallbacks 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. @@ -1298,7 +1299,7 @@ class TestFeedExport(TestFeedExportBase): yield self.assertExportedCsv(items, ["foo", "egg"], rows_csv) yield self.assertExportedJsonLines(items, rows_jl) - @defer.inlineCallbacks + @inlineCallbacks def test_export_tuple(self): items = [ {"foo": "bar1", "egg": "spam1"}, @@ -1309,7 +1310,7 @@ class TestFeedExport(TestFeedExportBase): rows = [{"foo": "bar1", "baz": ""}, {"foo": "bar2", "baz": "quux"}] yield self.assertExported(items, ["foo", "baz"], rows, settings=settings) - @defer.inlineCallbacks + @inlineCallbacks 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. @@ -1335,7 +1336,7 @@ class TestFeedExport(TestFeedExportBase): rows = [{"egg": "spam1", "baz": ""}, {"egg": "spam2", "baz": "quux2"}] yield self.assertExported(items, ["egg", "baz"], rows, settings=settings) - @defer.inlineCallbacks + @inlineCallbacks def test_export_encoding(self): items = [{"foo": "Test\xd6"}] @@ -1380,7 +1381,7 @@ class TestFeedExport(TestFeedExportBase): data = yield self.exported_data(items, settings) assert data[fmt] == expected - @defer.inlineCallbacks + @inlineCallbacks def test_export_multiple_configs(self): items = [{"foo": "FOO", "bar": "BAR"}] @@ -1420,7 +1421,7 @@ class TestFeedExport(TestFeedExportBase): for fmt, expected in formats.items(): assert data[fmt] == expected - @defer.inlineCallbacks + @inlineCallbacks def test_export_indentation(self): items = [ {"foo": ["bar"]}, @@ -1576,7 +1577,7 @@ class TestFeedExport(TestFeedExportBase): data = yield self.exported_data(items, settings) assert data[row["format"]] == row["expected"] - @defer.inlineCallbacks + @inlineCallbacks def test_init_exporters_storages_with_crawler(self): settings = { "FEED_EXPORTERS": {"csv": FromCrawlerCsvItemExporter}, @@ -1589,7 +1590,7 @@ class TestFeedExport(TestFeedExportBase): assert FromCrawlerCsvItemExporter.init_with_crawler assert FromCrawlerFileFeedStorage.init_with_crawler - @defer.inlineCallbacks + @inlineCallbacks def test_str_uri(self): settings = { "FEED_STORE_EMPTY": True, @@ -1598,7 +1599,7 @@ class TestFeedExport(TestFeedExportBase): data = yield self.exported_no_data(settings) assert data["csv"] == b"" - @defer.inlineCallbacks + @inlineCallbacks def test_multiple_feeds_success_logs_blocking_feed_storage(self): settings = { "FEEDS": { @@ -1619,7 +1620,7 @@ class TestFeedExport(TestFeedExportBase): for fmt in ["json", "xml", "csv"]: assert f"Stored {fmt} feed (2 items)" in str(log) - @defer.inlineCallbacks + @inlineCallbacks def test_multiple_feeds_failing_logs_blocking_feed_storage(self): settings = { "FEEDS": { @@ -1640,7 +1641,7 @@ class TestFeedExport(TestFeedExportBase): for fmt in ["json", "xml", "csv"]: assert f"Error storing {fmt} feed (2 items)" in str(log) - @defer.inlineCallbacks + @inlineCallbacks def test_extend_kwargs(self): items = [{"foo": "FOO", "bar": "BAR"}] @@ -1677,7 +1678,7 @@ class TestFeedExport(TestFeedExportBase): data = yield self.exported_data(items, settings) assert data[feed_options["format"]] == row["expected"] - @defer.inlineCallbacks + @inlineCallbacks def test_storage_file_no_postprocessing(self): @implementer(IFeedStorage) class Storage: @@ -1699,7 +1700,7 @@ class TestFeedExport(TestFeedExportBase): yield self.exported_no_data(settings) assert Storage.open_file is Storage.store_file - @defer.inlineCallbacks + @inlineCallbacks def test_storage_file_postprocessing(self): @implementer(IFeedStorage) class Storage: @@ -1752,7 +1753,7 @@ class TestFeedPostProcessedExports(TestFeedExportBase): def _named_tempfile(self, name) -> str: return str(Path(self.temp_dir, name)) - @defer.inlineCallbacks + @inlineCallbacks def run_and_export(self, spider_cls, settings): """Run spider with specified settings; return exported data with filename.""" @@ -1796,7 +1797,7 @@ class TestFeedPostProcessedExports(TestFeedExportBase): data_stream.seek(0) return data_stream.read() - @defer.inlineCallbacks + @inlineCallbacks def test_gzip_plugin(self): filename = self._named_tempfile("gzip_file") @@ -1815,7 +1816,7 @@ class TestFeedPostProcessedExports(TestFeedExportBase): except OSError: pytest.fail("Received invalid gzip data.") - @defer.inlineCallbacks + @inlineCallbacks def test_gzip_plugin_compresslevel(self): filename_to_compressed = { self._named_tempfile("compresslevel_0"): self.get_gzip_compressed( @@ -1852,7 +1853,7 @@ class TestFeedPostProcessedExports(TestFeedExportBase): assert compressed == data[filename] assert result == self.expected - @defer.inlineCallbacks + @inlineCallbacks def test_gzip_plugin_mtime(self): filename_to_compressed = { self._named_tempfile("mtime_123"): self.get_gzip_compressed( @@ -1887,7 +1888,7 @@ class TestFeedPostProcessedExports(TestFeedExportBase): assert compressed == data[filename] assert result == self.expected - @defer.inlineCallbacks + @inlineCallbacks def test_gzip_plugin_filename(self): filename_to_compressed = { self._named_tempfile("filename_FILE1"): self.get_gzip_compressed( @@ -1922,7 +1923,7 @@ class TestFeedPostProcessedExports(TestFeedExportBase): assert compressed == data[filename] assert result == self.expected - @defer.inlineCallbacks + @inlineCallbacks def test_lzma_plugin(self): filename = self._named_tempfile("lzma_file") @@ -1941,7 +1942,7 @@ class TestFeedPostProcessedExports(TestFeedExportBase): except lzma.LZMAError: pytest.fail("Received invalid lzma data.") - @defer.inlineCallbacks + @inlineCallbacks def test_lzma_plugin_format(self): filename_to_compressed = { self._named_tempfile("format_FORMAT_XZ"): lzma.compress( @@ -1974,7 +1975,7 @@ class TestFeedPostProcessedExports(TestFeedExportBase): assert compressed == data[filename] assert result == self.expected - @defer.inlineCallbacks + @inlineCallbacks def test_lzma_plugin_check(self): filename_to_compressed = { self._named_tempfile("check_CHECK_NONE"): lzma.compress( @@ -2007,7 +2008,7 @@ class TestFeedPostProcessedExports(TestFeedExportBase): assert compressed == data[filename] assert result == self.expected - @defer.inlineCallbacks + @inlineCallbacks def test_lzma_plugin_preset(self): filename_to_compressed = { self._named_tempfile("preset_PRESET_0"): lzma.compress( @@ -2040,11 +2041,11 @@ class TestFeedPostProcessedExports(TestFeedExportBase): assert compressed == data[filename] assert result == self.expected - @defer.inlineCallbacks + @inlineCallbacks def test_lzma_plugin_filters(self): if "PyPy" in sys.version: # https://foss.heptapod.net/pypy/pypy/-/issues/3527 - raise unittest.SkipTest("lzma filters doesn't work in PyPy") + pytest.skip("lzma filters doesn't work in PyPy") filters = [{"id": lzma.FILTER_LZMA2}] compressed = lzma.compress(self.expected, filters=filters) @@ -2065,7 +2066,7 @@ class TestFeedPostProcessedExports(TestFeedExportBase): result = lzma.decompress(data[filename]) assert result == self.expected - @defer.inlineCallbacks + @inlineCallbacks def test_bz2_plugin(self): filename = self._named_tempfile("bz2_file") @@ -2084,7 +2085,7 @@ class TestFeedPostProcessedExports(TestFeedExportBase): except OSError: pytest.fail("Received invalid bz2 data.") - @defer.inlineCallbacks + @inlineCallbacks def test_bz2_plugin_compresslevel(self): filename_to_compressed = { self._named_tempfile("compresslevel_1"): bz2.compress( @@ -2117,7 +2118,7 @@ class TestFeedPostProcessedExports(TestFeedExportBase): assert compressed == data[filename] assert result == self.expected - @defer.inlineCallbacks + @inlineCallbacks def test_custom_plugin(self): filename = self._named_tempfile("csv_file") @@ -2133,7 +2134,7 @@ class TestFeedPostProcessedExports(TestFeedExportBase): data = yield self.exported_data(self.items, settings) assert data[filename] == self.expected - @defer.inlineCallbacks + @inlineCallbacks def test_custom_plugin_with_parameter(self): expected = b"foo\r\n\nbar\r\n\n" filename = self._named_tempfile("newline") @@ -2151,7 +2152,7 @@ class TestFeedPostProcessedExports(TestFeedExportBase): data = yield self.exported_data(self.items, settings) assert data[filename] == expected - @defer.inlineCallbacks + @inlineCallbacks def test_custom_plugin_with_compression(self): expected = b"foo\r\n\nbar\r\n\n" @@ -2196,7 +2197,7 @@ class TestFeedPostProcessedExports(TestFeedExportBase): result = decompressor(data[filename]) assert result == expected - @defer.inlineCallbacks + @inlineCallbacks def test_exports_compatibility_with_postproc(self): import marshal import pickle @@ -2254,7 +2255,7 @@ class TestFeedPostProcessedExports(TestFeedExportBase): class TestBatchDeliveries(TestFeedExportBase): _file_mark = "_%(batch_time)s_#%(batch_id)02d_" - @defer.inlineCallbacks + @inlineCallbacks def run_and_export(self, spider_cls, settings): """Run spider with specified settings; return exported data.""" @@ -2276,7 +2277,7 @@ class TestBatchDeliveries(TestFeedExportBase): content[feed["format"]].append(file.read_bytes()) return content - @defer.inlineCallbacks + @inlineCallbacks def assertExportedJsonLines(self, items, rows, settings=None): settings = settings or {} settings.update( @@ -2298,7 +2299,7 @@ class TestBatchDeliveries(TestFeedExportBase): expected_batch, rows = rows[:batch_size], rows[batch_size:] assert got_batch == expected_batch - @defer.inlineCallbacks + @inlineCallbacks def assertExportedCsv(self, items, header, rows, settings=None): settings = settings or {} settings.update( @@ -2318,7 +2319,7 @@ class TestBatchDeliveries(TestFeedExportBase): expected_batch, rows = rows[:batch_size], rows[batch_size:] assert list(got_batch) == expected_batch - @defer.inlineCallbacks + @inlineCallbacks def assertExportedXml(self, items, rows, settings=None): settings = settings or {} settings.update( @@ -2339,7 +2340,7 @@ class TestBatchDeliveries(TestFeedExportBase): expected_batch, rows = rows[:batch_size], rows[batch_size:] assert got_batch == expected_batch - @defer.inlineCallbacks + @inlineCallbacks def assertExportedMultiple(self, items, rows, settings=None): settings = settings or {} settings.update( @@ -2371,7 +2372,7 @@ class TestBatchDeliveries(TestFeedExportBase): expected_batch, json_rows = json_rows[:batch_size], json_rows[batch_size:] assert got_batch == expected_batch - @defer.inlineCallbacks + @inlineCallbacks def assertExportedPickle(self, items, rows, settings=None): settings = settings or {} settings.update( @@ -2393,7 +2394,7 @@ class TestBatchDeliveries(TestFeedExportBase): expected_batch, rows = rows[:batch_size], rows[batch_size:] assert got_batch == expected_batch - @defer.inlineCallbacks + @inlineCallbacks def assertExportedMarshal(self, items, rows, settings=None): settings = settings or {} settings.update( @@ -2415,7 +2416,7 @@ class TestBatchDeliveries(TestFeedExportBase): expected_batch, rows = rows[:batch_size], rows[batch_size:] assert got_batch == expected_batch - @defer.inlineCallbacks + @inlineCallbacks def test_export_items(self): """Test partial deliveries in all supported formats""" items = [ @@ -2444,7 +2445,7 @@ class TestBatchDeliveries(TestFeedExportBase): with pytest.raises(NotConfigured): FeedExporter(crawler) - @defer.inlineCallbacks + @inlineCallbacks def test_export_no_items_not_store_empty(self): for fmt in ("json", "jsonlines", "xml", "csv"): settings = { @@ -2460,7 +2461,7 @@ class TestBatchDeliveries(TestFeedExportBase): data = dict(data) assert len(data[fmt]) == 0 - @defer.inlineCallbacks + @inlineCallbacks def test_export_no_items_store_empty(self): formats = ( ("json", b"[]"), @@ -2484,7 +2485,7 @@ class TestBatchDeliveries(TestFeedExportBase): data = dict(data) assert data[fmt][0] == expctd - @defer.inlineCallbacks + @inlineCallbacks def test_export_multiple_configs(self): items = [ {"foo": "FOO", "bar": "BAR"}, @@ -2540,7 +2541,7 @@ class TestBatchDeliveries(TestFeedExportBase): for expected_batch, got_batch in zip(expected, data[fmt]): assert got_batch == expected_batch - @defer.inlineCallbacks + @inlineCallbacks def test_batch_item_count_feeds_setting(self): items = [{"foo": "FOO"}, {"foo": "FOO1"}] formats = { @@ -2564,7 +2565,7 @@ class TestBatchDeliveries(TestFeedExportBase): for expected_batch, got_batch in zip(expected, data[fmt]): assert got_batch == expected_batch - @defer.inlineCallbacks + @inlineCallbacks def test_batch_path_differ(self): """ Test that the name of all batch files differ from each other. @@ -2586,7 +2587,7 @@ class TestBatchDeliveries(TestFeedExportBase): data = yield self.exported_data(items, settings) assert len(items) == len(data["json"]) - @defer.inlineCallbacks + @inlineCallbacks def test_stats_batch_file_success(self): settings = { "FEEDS": { @@ -2604,7 +2605,7 @@ class TestBatchDeliveries(TestFeedExportBase): assert crawler.stats.get_value("feedexport/success_count/FileFeedStorage") == 12 @pytest.mark.requires_boto3 - @defer.inlineCallbacks + @inlineCallbacks def test_s3_export(self): bucket = "mybucket" items = [ diff --git a/tests/test_http2_client_protocol.py b/tests/test_http2_client_protocol.py index 0605c2438..ef1806cc0 100644 --- a/tests/test_http2_client_protocol.py +++ b/tests/test_http2_client_protocol.py @@ -9,7 +9,7 @@ from ipaddress import IPv4Address from pathlib import Path from tempfile import mkdtemp from typing import TYPE_CHECKING -from unittest import mock, skipIf +from unittest import mock from urllib.parse import urlencode import pytest @@ -183,7 +183,7 @@ def get_client_certificate( return PrivateCertificate.loadPEM(pem) -@skipIf(not H2_ENABLED, "HTTP/2 support in Twisted is not enabled") +@pytest.mark.skipif(not H2_ENABLED, reason="HTTP/2 support in Twisted is not enabled") class TestHttps2ClientProtocol(TestCase): scheme = "https" key_file = Path(__file__).parent / "keys" / "localhost.key" diff --git a/tests/test_logformatter.py b/tests/test_logformatter.py index 3c9f97631..047f8c610 100644 --- a/tests/test_logformatter.py +++ b/tests/test_logformatter.py @@ -2,7 +2,7 @@ import logging import pytest from testfixtures import LogCapture -from twisted.internet import defer +from twisted.internet.defer import inlineCallbacks from twisted.python.failure import Failure from twisted.trial.unittest import TestCase @@ -272,7 +272,7 @@ class TestShowOrSkipMessages(TestCase): }, } - @defer.inlineCallbacks + @inlineCallbacks def test_show_messages(self): crawler = get_crawler(ItemSpider, self.base_settings) with LogCapture() as lc: @@ -281,7 +281,7 @@ class TestShowOrSkipMessages(TestCase): assert "Crawled (200) Date: Sat, 7 Jun 2025 09:17:36 +0200 Subject: [PATCH 030/459] Remove the old artwork (#6874) --- artwork/README.rst | 20 -------------------- artwork/qlassik.zip | Bin 120204 -> 0 bytes artwork/scrapy-blog-logo.xcf | Bin 52428 -> 0 bytes artwork/scrapy-logo.jpg | Bin 23398 -> 0 bytes 4 files changed, 20 deletions(-) delete mode 100644 artwork/README.rst delete mode 100644 artwork/qlassik.zip delete mode 100644 artwork/scrapy-blog-logo.xcf delete mode 100644 artwork/scrapy-logo.jpg diff --git a/artwork/README.rst b/artwork/README.rst deleted file mode 100644 index c1880ef6c..000000000 --- a/artwork/README.rst +++ /dev/null @@ -1,20 +0,0 @@ -============== -Scrapy artwork -============== - -This folder contains the Scrapy artwork resources such as logos and fonts. - -scrapy-logo.jpg ---------------- - -The main Scrapy logo, in JPEG format. - -qlassik.zip ------------ - -The font used for the Scrapy logo. Homepage: https://www.dafont.com/qlassik.font - -scrapy-blog.logo.xcf --------------------- - -The logo used in the Scrapy blog, in Gimp format. diff --git a/artwork/qlassik.zip b/artwork/qlassik.zip deleted file mode 100644 index 2885c06ef4bab2fd9027bf748bd5ad2a69eb857f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 120204 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 N6Lgg{VDiuFe*lPvsYCz( diff --git a/artwork/scrapy-blog-logo.xcf b/artwork/scrapy-blog-logo.xcf deleted file mode 100644 index 320102604f4511d26094cd4c964ae767151d880c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 52428 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 Date: Sat, 7 Jun 2025 09:17:36 +0200 Subject: [PATCH 031/459] Remove the old artwork (#6874) --- artwork/README.rst | 20 -------------------- artwork/qlassik.zip | Bin 120204 -> 0 bytes artwork/scrapy-blog-logo.xcf | Bin 52428 -> 0 bytes artwork/scrapy-logo.jpg | Bin 23398 -> 0 bytes 4 files changed, 20 deletions(-) delete mode 100644 artwork/README.rst delete mode 100644 artwork/qlassik.zip delete mode 100644 artwork/scrapy-blog-logo.xcf delete mode 100644 artwork/scrapy-logo.jpg diff --git a/artwork/README.rst b/artwork/README.rst deleted file mode 100644 index c1880ef6c..000000000 --- a/artwork/README.rst +++ /dev/null @@ -1,20 +0,0 @@ -============== -Scrapy artwork -============== - -This folder contains the Scrapy artwork resources such as logos and fonts. - -scrapy-logo.jpg ---------------- - -The main Scrapy logo, in JPEG format. - -qlassik.zip ------------ - -The font used for the Scrapy logo. Homepage: https://www.dafont.com/qlassik.font - -scrapy-blog.logo.xcf --------------------- - -The logo used in the Scrapy blog, in Gimp format. diff --git a/artwork/qlassik.zip b/artwork/qlassik.zip deleted file mode 100644 index 2885c06ef4bab2fd9027bf748bd5ad2a69eb857f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 120204 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 N6Lgg{VDiuFe*lPvsYCz( diff --git a/artwork/scrapy-blog-logo.xcf b/artwork/scrapy-blog-logo.xcf deleted file mode 100644 index 320102604f4511d26094cd4c964ae767151d880c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 52428 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 Date: Mon, 9 Jun 2025 12:53:42 +0500 Subject: [PATCH 032/459] Pin older Twisted in tests, update type hints. (#6882) --- scrapy/core/downloader/handlers/http11.py | 19 +++++++++++-------- scrapy/utils/test.py | 4 ++-- tests/test_core_downloader.py | 14 +++++++++++--- tox.ini | 1 + 4 files changed, 25 insertions(+), 13 deletions(-) diff --git a/scrapy/core/downloader/handlers/http11.py b/scrapy/core/downloader/handlers/http11.py index 74a6e54ee..54fef48b6 100644 --- a/scrapy/core/downloader/handlers/http11.py +++ b/scrapy/core/downloader/handlers/http11.py @@ -8,7 +8,7 @@ import re from contextlib import suppress from io import BytesIO from time import time -from typing import TYPE_CHECKING, Any, TypedDict, TypeVar +from typing import TYPE_CHECKING, Any, TypedDict, TypeVar, cast from urllib.parse import urldefrag, urlparse from twisted.internet import ssl @@ -27,7 +27,7 @@ from twisted.web.client import ( from twisted.web.client import Response as TxResponse from twisted.web.http import PotentialDataLoss, _DataLoss from twisted.web.http_headers import Headers as TxHeaders -from twisted.web.iweb import UNKNOWN_LENGTH, IBodyProducer, IPolicyForHTTPS +from twisted.web.iweb import UNKNOWN_LENGTH, IBodyProducer, IPolicyForHTTPS, IResponse from zope.interface import implementer from scrapy import Request, Spider, signals @@ -286,11 +286,11 @@ class TunnelingAgent(Agent): key: Any, endpoint: TCP4ClientEndpoint, method: bytes, - parsedURI: bytes, + parsedURI: URI, headers: TxHeaders | None, bodyProducer: IBodyProducer | None, requestPath: bytes, - ) -> Deferred[TxResponse]: + ) -> Deferred[IResponse]: # proxy host and port are required for HTTP pool `key` # otherwise, same remote host connection request could reuse # a cached tunneled connection to a different proxy @@ -329,14 +329,14 @@ class ScrapyProxyAgent(Agent): uri: bytes, headers: TxHeaders | None = None, bodyProducer: IBodyProducer | None = None, - ) -> Deferred[TxResponse]: + ) -> Deferred[IResponse]: """ Issue a new request via the configured proxy. """ # Cache *all* connections under the same key, since we are only # connecting to a single destination, the proxy: return self._requestWithEndpoint( - key=("http-proxy", self._proxyURI.host, self._proxyURI.port), + key=(b"http-proxy", self._proxyURI.host, self._proxyURI.port), endpoint=self._getEndpoint(self._proxyURI), method=method, parsedURI=URI.fromBytes(uri), @@ -426,8 +426,11 @@ class ScrapyAgent: headers.removeHeader(b"Proxy-Authorization") bodyproducer = _RequestBodyProducer(request.body) if request.body else None start_time = time() - d: Deferred[TxResponse] = agent.request( - method, to_bytes(url, encoding="ascii"), headers, bodyproducer + d: Deferred[IResponse] = agent.request( + method, + to_bytes(url, encoding="ascii"), + headers, + cast(IBodyProducer, bodyproducer), ) # set download latency d.addCallback(self._cb_latency, request, start_time) diff --git a/scrapy/utils/test.py b/scrapy/utils/test.py index 2da526cd8..94b1a1fc7 100644 --- a/scrapy/utils/test.py +++ b/scrapy/utils/test.py @@ -10,7 +10,7 @@ import warnings from importlib import import_module from pathlib import Path from posixpath import split -from typing import TYPE_CHECKING, Any, TypeVar +from typing import TYPE_CHECKING, Any, TypeVar, cast from unittest import TestCase, mock from twisted.trial.unittest import SkipTest @@ -211,4 +211,4 @@ def get_web_client_agent_req(url: str) -> Deferred[TxResponse]: from twisted.web.client import Agent # imports twisted.internet.reactor agent = Agent(reactor) - return agent.request(b"GET", url.encode("utf-8")) + return cast("Deferred[TxResponse]", agent.request(b"GET", url.encode("utf-8"))) diff --git a/tests/test_core_downloader.py b/tests/test_core_downloader.py index ef77f7843..464320602 100644 --- a/tests/test_core_downloader.py +++ b/tests/test_core_downloader.py @@ -4,7 +4,7 @@ import shutil import warnings from pathlib import Path from tempfile import mkdtemp -from typing import Any +from typing import Any, cast import OpenSSL.SSL import pytest @@ -14,6 +14,7 @@ from twisted.trial import unittest from twisted.web import server, static from twisted.web.client import Agent, BrowserLikePolicyForHTTPS, readBody from twisted.web.client import Response as TxResponse +from twisted.web.iweb import IBodyProducer from scrapy.core.downloader import Slot from scrapy.core.downloader.contextfactory import ( @@ -76,8 +77,15 @@ class TestContextFactoryBase(unittest.TestCase): agent = Agent(reactor, contextFactory=client_context_factory) body_producer = _RequestBodyProducer(body.encode()) if body else None - response: TxResponse = await maybe_deferred_to_future( - agent.request(b"GET", url.encode(), bodyProducer=body_producer) + response: TxResponse = cast( + TxResponse, + await maybe_deferred_to_future( + agent.request( + b"GET", + url.encode(), + bodyProducer=cast(IBodyProducer, body_producer), + ) + ), ) with warnings.catch_warnings(): # https://github.com/twisted/twisted/issues/8227 diff --git a/tox.ini b/tox.ini index 92cfc3794..5680d98d1 100644 --- a/tox.ini +++ b/tox.ini @@ -20,6 +20,7 @@ deps = sybil >= 1.3.0 # https://github.com/cjw296/sybil/issues/20#issuecomment-605433422 testfixtures pywin32; sys_platform == "win32" + Twisted < 25.5.0 # https://github.com/twisted/twisted/issues/12467 [testenv] deps = From 24a827c72e9f5b35ddcd12ccce2ce7c6611d2845 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Mon, 9 Jun 2025 12:53:42 +0500 Subject: [PATCH 033/459] Pin older Twisted in tests, update type hints. (#6882) --- scrapy/core/downloader/handlers/http11.py | 19 +++++++++++-------- scrapy/utils/test.py | 4 ++-- tests/test_core_downloader.py | 14 +++++++++++--- tox.ini | 1 + 4 files changed, 25 insertions(+), 13 deletions(-) diff --git a/scrapy/core/downloader/handlers/http11.py b/scrapy/core/downloader/handlers/http11.py index 74a6e54ee..54fef48b6 100644 --- a/scrapy/core/downloader/handlers/http11.py +++ b/scrapy/core/downloader/handlers/http11.py @@ -8,7 +8,7 @@ import re from contextlib import suppress from io import BytesIO from time import time -from typing import TYPE_CHECKING, Any, TypedDict, TypeVar +from typing import TYPE_CHECKING, Any, TypedDict, TypeVar, cast from urllib.parse import urldefrag, urlparse from twisted.internet import ssl @@ -27,7 +27,7 @@ from twisted.web.client import ( from twisted.web.client import Response as TxResponse from twisted.web.http import PotentialDataLoss, _DataLoss from twisted.web.http_headers import Headers as TxHeaders -from twisted.web.iweb import UNKNOWN_LENGTH, IBodyProducer, IPolicyForHTTPS +from twisted.web.iweb import UNKNOWN_LENGTH, IBodyProducer, IPolicyForHTTPS, IResponse from zope.interface import implementer from scrapy import Request, Spider, signals @@ -286,11 +286,11 @@ class TunnelingAgent(Agent): key: Any, endpoint: TCP4ClientEndpoint, method: bytes, - parsedURI: bytes, + parsedURI: URI, headers: TxHeaders | None, bodyProducer: IBodyProducer | None, requestPath: bytes, - ) -> Deferred[TxResponse]: + ) -> Deferred[IResponse]: # proxy host and port are required for HTTP pool `key` # otherwise, same remote host connection request could reuse # a cached tunneled connection to a different proxy @@ -329,14 +329,14 @@ class ScrapyProxyAgent(Agent): uri: bytes, headers: TxHeaders | None = None, bodyProducer: IBodyProducer | None = None, - ) -> Deferred[TxResponse]: + ) -> Deferred[IResponse]: """ Issue a new request via the configured proxy. """ # Cache *all* connections under the same key, since we are only # connecting to a single destination, the proxy: return self._requestWithEndpoint( - key=("http-proxy", self._proxyURI.host, self._proxyURI.port), + key=(b"http-proxy", self._proxyURI.host, self._proxyURI.port), endpoint=self._getEndpoint(self._proxyURI), method=method, parsedURI=URI.fromBytes(uri), @@ -426,8 +426,11 @@ class ScrapyAgent: headers.removeHeader(b"Proxy-Authorization") bodyproducer = _RequestBodyProducer(request.body) if request.body else None start_time = time() - d: Deferred[TxResponse] = agent.request( - method, to_bytes(url, encoding="ascii"), headers, bodyproducer + d: Deferred[IResponse] = agent.request( + method, + to_bytes(url, encoding="ascii"), + headers, + cast(IBodyProducer, bodyproducer), ) # set download latency d.addCallback(self._cb_latency, request, start_time) diff --git a/scrapy/utils/test.py b/scrapy/utils/test.py index 4a732bd72..3780ad23e 100644 --- a/scrapy/utils/test.py +++ b/scrapy/utils/test.py @@ -10,7 +10,7 @@ import warnings from importlib import import_module from pathlib import Path from posixpath import split -from typing import TYPE_CHECKING, Any, TypeVar +from typing import TYPE_CHECKING, Any, TypeVar, cast from unittest import TestCase, mock from twisted.trial.unittest import SkipTest @@ -216,4 +216,4 @@ def get_web_client_agent_req(url: str) -> Deferred[TxResponse]: from twisted.web.client import Agent # imports twisted.internet.reactor agent = Agent(reactor) - return agent.request(b"GET", url.encode("utf-8")) + return cast("Deferred[TxResponse]", agent.request(b"GET", url.encode("utf-8"))) diff --git a/tests/test_core_downloader.py b/tests/test_core_downloader.py index 1bffd69ed..668a2cd1b 100644 --- a/tests/test_core_downloader.py +++ b/tests/test_core_downloader.py @@ -4,7 +4,7 @@ import shutil import warnings from pathlib import Path from tempfile import mkdtemp -from typing import Any +from typing import Any, cast import OpenSSL.SSL import pytest @@ -15,6 +15,7 @@ from twisted.trial import unittest from twisted.web import server, static from twisted.web.client import Agent, BrowserLikePolicyForHTTPS, readBody from twisted.web.client import Response as TxResponse +from twisted.web.iweb import IBodyProducer from scrapy.core.downloader import Slot from scrapy.core.downloader.contextfactory import ( @@ -73,8 +74,15 @@ class TestContextFactoryBase(unittest.TestCase): ) -> bytes: agent = Agent(reactor, contextFactory=client_context_factory) body_producer = _RequestBodyProducer(body.encode()) if body else None - response: TxResponse = await maybe_deferred_to_future( - agent.request(b"GET", url.encode(), bodyProducer=body_producer) + response: TxResponse = cast( + TxResponse, + await maybe_deferred_to_future( + agent.request( + b"GET", + url.encode(), + bodyProducer=cast(IBodyProducer, body_producer), + ) + ), ) with warnings.catch_warnings(): # https://github.com/twisted/twisted/issues/8227 diff --git a/tox.ini b/tox.ini index 92cfc3794..5680d98d1 100644 --- a/tox.ini +++ b/tox.ini @@ -20,6 +20,7 @@ deps = sybil >= 1.3.0 # https://github.com/cjw296/sybil/issues/20#issuecomment-605433422 testfixtures pywin32; sys_platform == "win32" + Twisted < 25.5.0 # https://github.com/twisted/twisted/issues/12467 [testenv] deps = From 6b5a4a64173fc051063f01c05925519e45dbbfdd Mon Sep 17 00:00:00 2001 From: nakanoh Date: Mon, 9 Jun 2025 19:07:01 +0900 Subject: [PATCH 034/459] Minor improvement in cmdline.py (#6875) --- scrapy/cmdline.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/scrapy/cmdline.py b/scrapy/cmdline.py index 3d448532b..2b0204071 100644 --- a/scrapy/cmdline.py +++ b/scrapy/cmdline.py @@ -32,8 +32,9 @@ class ScrapyArgumentParser(argparse.ArgumentParser): def _parse_optional( self, arg_string: str ) -> tuple[argparse.Action | None, str, str | None] | None: - # if starts with -: it means that is a parameter not a argument - if arg_string[:2] == "-:": + # Support something like ‘-o -:json’, where ‘-:json’ is a value for + # ‘-o’, not another parameter. + if arg_string.startswith("-:"): return None return super()._parse_optional(arg_string) From 7400868ad5b80f6c91fd69dea476aa8f59ce9081 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Mon, 9 Jun 2025 15:19:02 +0500 Subject: [PATCH 035/459] Release notes for 2.13.2. (#6868) * Release notes for 2.13.2. * Update release notes. --- docs/news.rst | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/docs/news.rst b/docs/news.rst index 8b1d51674..8e7d80e26 100644 --- a/docs/news.rst +++ b/docs/news.rst @@ -3,6 +3,52 @@ Release notes ============= +.. _release-2.13.2: + +Scrapy 2.13.2 (unreleased) +-------------------------- + +- Fixed a bug introduced in Scrapy 2.13.0 that caused results of request + errbacks to be ignored when the errback was called because of a downloader + error. + (:issue:`6861`, :issue:`6863`) + +- Added a note about the behavior change of + :func:`scrapy.utils.reactor.is_asyncio_reactor_installed` to its docs and + to the "Backward-incompatible changes" section of :ref:`the Scrapy 2.13.0 + release notes `. + (:issue:`6866`) + +- Improved the message in the exception raised by + :func:`scrapy.utils.test.get_reactor_settings` when there is no reactor + installed. + (:issue:`6866`) + +- Updated the :class:`scrapy.crawler.CrawlerRunner` examples in + :ref:`topics-practices` to install the reactor explicitly, to fix + reactor-related errors with Scrapy 2.13.0 and later. + (:issue:`6865`) + +- Fixed ``scrapy fetch`` not working with scrapy-poet_. + (:issue:`6872`) + +- Fixed an exception produced by :class:`scrapy.core.engine.ExecutionEngine` + when it's closed before being fully initialized. + (:issue:`6857`, :issue:`6867`) + +- Improved the README, updated the Scrapy logo in it. + (:issue:`6831`, :issue:`6833`, :issue:`6839`) + +- Restricted the Twisted version used in tests to below 25.5.0, as some tests + fail with 25.5.0. + (:issue:`6878`, :issue:`6882`) + +- Updated type hints for Twisted 25.5.0 changes. + (:issue:`6882`) + +- Removed the old artwork. + (:issue:`6874`) + .. _release-2.13.1: Scrapy 2.13.1 (2025-05-28) From c6740604a405c51e92bedce8617b9151cd9766c4 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Mon, 9 Jun 2025 15:21:06 +0500 Subject: [PATCH 036/459] =?UTF-8?q?Bump=20version:=202.13.1=20=E2=86=92=20?= =?UTF-8?q?2.13.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 8e7d80e26..b2fe78bc6 100644 --- a/docs/news.rst +++ b/docs/news.rst @@ -5,7 +5,7 @@ Release notes .. _release-2.13.2: -Scrapy 2.13.2 (unreleased) +Scrapy 2.13.2 (2025-06-09) -------------------------- - Fixed a bug introduced in Scrapy 2.13.0 that caused results of request diff --git a/pyproject.toml b/pyproject.toml index 68c1e07bb..d6aebf514 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -115,7 +115,7 @@ module = "twisted" implicit_reexport = true [tool.bumpversion] -current_version = "2.13.1" +current_version = "2.13.2" commit = true tag = true tag_name = "{new_version}" diff --git a/scrapy/VERSION b/scrapy/VERSION index 94f15e9cc..0e83a9a9c 100644 --- a/scrapy/VERSION +++ b/scrapy/VERSION @@ -1 +1 @@ -2.13.1 +2.13.2 From ac956f8595354fde80bf64b3eaf95a22b3433f98 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Tue, 10 Jun 2025 13:02:27 +0500 Subject: [PATCH 037/459] Replace most of the @inlineCallbacks test helpers. (#6883) --- tests/test_crawl.py | 101 ++-- tests/test_feedexport.py | 619 +++++++++++--------- tests/test_spidermiddleware.py | 219 +++---- tests/test_spidermiddleware_output_chain.py | 75 +-- 4 files changed, 565 insertions(+), 449 deletions(-) diff --git a/tests/test_crawl.py b/tests/test_crawl.py index 4c1f6216b..42f9899f9 100644 --- a/tests/test_crawl.py +++ b/tests/test_crawl.py @@ -4,7 +4,7 @@ import json import logging from ipaddress import IPv4Address from socket import gethostbyname -from typing import Any +from typing import TYPE_CHECKING, Any from urllib.parse import urlparse import pytest @@ -14,11 +14,12 @@ from twisted.internet.ssl import Certificate from twisted.python.failure import Failure from twisted.trial.unittest import TestCase -from scrapy import signals +from scrapy import Spider, signals from scrapy.crawler import CrawlerRunner from scrapy.exceptions import CloseSpider, 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.python import to_unicode from scrapy.utils.test import get_crawler, get_reactor_settings from tests import NON_EXISTING_RESOLVABLE @@ -55,8 +56,13 @@ from tests.spiders import ( StartItemSpider, ) +if TYPE_CHECKING: + from scrapy.statscollectors import StatsCollector + class TestCrawl(TestCase): + mockserver: MockServer + @classmethod def setUpClass(cls): cls.mockserver = MockServer() @@ -72,16 +78,17 @@ class TestCrawl(TestCase): yield crawler.crawl(mockserver=self.mockserver) assert len(crawler.spider.urls_visited) == 11 # 10 + start_url - @inlineCallbacks - def test_fixed_delay(self): - yield self._test_delay(total=3, delay=0.2) + @deferred_f_from_coro_f + async def test_fixed_delay(self): + await self._test_delay(total=3, delay=0.2) - @inlineCallbacks - def test_randomized_delay(self): - yield self._test_delay(total=3, delay=0.1, randomize=True) + @deferred_f_from_coro_f + async def test_randomized_delay(self): + await self._test_delay(total=3, delay=0.1, randomize=True) - @inlineCallbacks - def _test_delay(self, total, delay, randomize=False): + async def _test_delay( + self, total: int, delay: float, randomize: bool = False + ) -> None: crawl_kwargs = { "maxlatency": delay * 2, "mockserver": self.mockserver, @@ -91,7 +98,9 @@ class TestCrawl(TestCase): settings = {"DOWNLOAD_DELAY": delay, "RANDOMIZE_DOWNLOAD_DELAY": randomize} crawler = get_crawler(FollowAllSpider, settings) - yield crawler.crawl(**crawl_kwargs) + await maybe_deferred_to_future(crawler.crawl(**crawl_kwargs)) + assert crawler.spider + assert isinstance(crawler.spider, FollowAllSpider) times = crawler.spider.times total_time = times[-1] - times[0] average = total_time / (len(times) - 1) @@ -103,7 +112,9 @@ class TestCrawl(TestCase): # code above to have any meaning. settings["DOWNLOAD_DELAY"] = 0 crawler = get_crawler(FollowAllSpider, settings) - yield crawler.crawl(**crawl_kwargs) + await maybe_deferred_to_future(crawler.crawl(**crawl_kwargs)) + assert crawler.spider + assert isinstance(crawler.spider, FollowAllSpider) times = crawler.spider.times total_time = times[-1] - times[0] average = total_time / (len(times) - 1) @@ -428,8 +439,9 @@ class TestCrawlSpider(TestCase): def tearDownClass(cls): cls.mockserver.__exit__(None, None, None) - @inlineCallbacks - def _run_spider(self, spider_cls): + async def _run_spider( + self, spider_cls: type[Spider] + ) -> tuple[LogCapture, list[Any], StatsCollector]: items = [] def _on_item_scraped(item): @@ -438,9 +450,12 @@ class TestCrawlSpider(TestCase): crawler = get_crawler(spider_cls) 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 + await maybe_deferred_to_future( + crawler.crawl( + self.mockserver.url("/status?n=200"), mockserver=self.mockserver + ) ) + assert crawler.stats return log, items, crawler.stats @inlineCallbacks @@ -521,9 +536,9 @@ class TestCrawlSpider(TestCase): assert "Got response 200" in str(log) @pytest.mark.only_asyncio - @inlineCallbacks - def test_async_def_asyncio_parse_items_list(self): - log, items, _ = yield self._run_spider(AsyncDefAsyncioReturnSpider) + @deferred_f_from_coro_f + async def test_async_def_asyncio_parse_items_list(self): + log, items, _ = await self._run_spider(AsyncDefAsyncioReturnSpider) assert "Got response 200" in str(log) assert {"id": 1} in items assert {"id": 2} in items @@ -546,17 +561,17 @@ class TestCrawlSpider(TestCase): assert {"foo": 42} in items @pytest.mark.only_asyncio - @inlineCallbacks - def test_async_def_asyncgen_parse(self): - log, _, stats = yield self._run_spider(AsyncDefAsyncioGenSpider) + @deferred_f_from_coro_f + async def test_async_def_asyncgen_parse(self): + log, _, stats = await self._run_spider(AsyncDefAsyncioGenSpider) assert "Got response 200" in str(log) itemcount = stats.get_value("item_scraped_count") assert itemcount == 1 @pytest.mark.only_asyncio - @inlineCallbacks - def test_async_def_asyncgen_parse_loop(self): - log, items, stats = yield self._run_spider(AsyncDefAsyncioGenLoopSpider) + @deferred_f_from_coro_f + async def test_async_def_asyncgen_parse_loop(self): + log, items, stats = await self._run_spider(AsyncDefAsyncioGenLoopSpider) assert "Got response 200" in str(log) itemcount = stats.get_value("item_scraped_count") assert itemcount == 10 @@ -564,9 +579,9 @@ class TestCrawlSpider(TestCase): assert {"foo": i} in items @pytest.mark.only_asyncio - @inlineCallbacks - def test_async_def_asyncgen_parse_exc(self): - log, items, stats = yield self._run_spider(AsyncDefAsyncioGenExcSpider) + @deferred_f_from_coro_f + 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 @@ -576,9 +591,9 @@ class TestCrawlSpider(TestCase): assert {"foo": i} in items @pytest.mark.only_asyncio - @inlineCallbacks - def test_async_def_asyncgen_parse_complex(self): - _, items, stats = yield self._run_spider(AsyncDefAsyncioGenComplexSpider) + @deferred_f_from_coro_f + async def test_async_def_asyncgen_parse_complex(self): + _, items, stats = await self._run_spider(AsyncDefAsyncioGenComplexSpider) itemcount = stats.get_value("item_scraped_count") assert itemcount == 156 # some random items @@ -588,27 +603,27 @@ class TestCrawlSpider(TestCase): assert {"index2": i} in items @pytest.mark.only_asyncio - @inlineCallbacks - def test_async_def_asyncio_parse_reqs_list(self): - log, *_ = yield self._run_spider(AsyncDefAsyncioReqsReturnSpider) + @deferred_f_from_coro_f + 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 - @inlineCallbacks - def test_async_def_deferred_direct(self): - _, items, _ = yield self._run_spider(AsyncDefDeferredDirectSpider) + @deferred_f_from_coro_f + async def test_async_def_deferred_direct(self): + _, items, _ = await self._run_spider(AsyncDefDeferredDirectSpider) assert items == [{"code": 200}] @pytest.mark.only_asyncio - @inlineCallbacks - def test_async_def_deferred_wrapped(self): - log, items, _ = yield self._run_spider(AsyncDefDeferredWrappedSpider) + @deferred_f_from_coro_f + async def test_async_def_deferred_wrapped(self): + log, items, _ = await self._run_spider(AsyncDefDeferredWrappedSpider) assert items == [{"code": 200}] - @inlineCallbacks - def test_async_def_deferred_maybe_wrapped(self): - _, items, _ = yield self._run_spider(AsyncDefDeferredMaybeWrappedSpider) + @deferred_f_from_coro_f + async def test_async_def_deferred_maybe_wrapped(self): + _, items, _ = await self._run_spider(AsyncDefDeferredMaybeWrappedSpider) assert items == [{"code": 200}] @inlineCallbacks diff --git a/tests/test_feedexport.py b/tests/test_feedexport.py index cdf03ca76..262c0b434 100644 --- a/tests/test_feedexport.py +++ b/tests/test_feedexport.py @@ -33,7 +33,7 @@ from zope.interface import implementer from zope.interface.verify import verifyObject import scrapy -from scrapy import signals +from scrapy import Spider, signals from scrapy.exceptions import NotConfigured, ScrapyDeprecationWarning from scrapy.exporters import CsvItemExporter, JsonItemExporter from scrapy.extensions.feedexport import ( @@ -48,12 +48,14 @@ 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.python import to_unicode from scrapy.utils.test import get_crawler from tests.mockserver import MockFTPServer, MockServer from tests.spiders import ItemSpider if TYPE_CHECKING: + from collections.abc import Iterable from os import PathLike @@ -89,24 +91,25 @@ def mock_google_cloud_storage() -> tuple[Any, Any, Any]: return (client_mock, bucket_mock, blob_mock) +# TODO: replace self.mktemp() and drop the unittest.TestCase base class TestFileFeedStorage(unittest.TestCase): def test_store_file_uri(self): path = Path(self.mktemp()).resolve() uri = path_to_file_uri(str(path)) - return self._assert_stores(FileFeedStorage(uri), path) + self._assert_stores(FileFeedStorage(uri), path) def test_store_file_uri_makedirs(self): path = Path(self.mktemp()).resolve() / "more" / "paths" / "file.txt" uri = path_to_file_uri(str(path)) - return self._assert_stores(FileFeedStorage(uri), path) + self._assert_stores(FileFeedStorage(uri), path) def test_store_direct_path(self): path = Path(self.mktemp()).resolve() - return self._assert_stores(FileFeedStorage(str(path)), path) + self._assert_stores(FileFeedStorage(str(path)), path) def test_store_direct_path_relative(self): path = Path(self.mktemp()) - return self._assert_stores(FileFeedStorage(str(path)), path) + self._assert_stores(FileFeedStorage(str(path)), path) def test_interface(self): path = self.mktemp() @@ -124,20 +127,21 @@ class TestFileFeedStorage(unittest.TestCase): def test_append(self): path = self._store() - return self._assert_stores(FileFeedStorage(str(path)), path, b"contentcontent") + self._assert_stores(FileFeedStorage(str(path)), path, b"contentcontent") def test_overwrite(self): path = self._store({"overwrite": True}) - return self._assert_stores( + self._assert_stores( FileFeedStorage(str(path), feed_options={"overwrite": True}), path ) - @inlineCallbacks - def _assert_stores(self, storage, path: Path, expected_content=b"content"): + def _assert_stores( + self, storage: FileFeedStorage, path: Path, expected_content: bytes = b"content" + ) -> None: spider = scrapy.Spider("default") file = storage.open(spider) file.write(b"content") - yield storage.store(file) + storage.store(file) assert path.exists() try: assert path.read_bytes() == expected_content @@ -153,7 +157,7 @@ class TestFTPFeedStorage(unittest.TestCase): crawler = get_crawler(settings_dict=settings) return TestSpider.from_crawler(crawler) - def _store(self, uri, content, feed_options=None, settings=None): + async def _store(self, uri, content, feed_options=None, settings=None): crawler = get_crawler(settings_dict=settings or {}) storage = FTPFeedStorage.from_crawler( crawler, @@ -164,7 +168,7 @@ class TestFTPFeedStorage(unittest.TestCase): spider = self.get_test_spider() file = storage.open(spider) file.write(content) - return storage.store(file) + await maybe_deferred_to_future(storage.store(file)) def _assert_stored(self, path: Path, content): assert path.exists() @@ -173,44 +177,44 @@ class TestFTPFeedStorage(unittest.TestCase): finally: path.unlink() - @inlineCallbacks - def test_append(self): + @deferred_f_from_coro_f + async def test_append(self): with MockFTPServer() as ftp_server: filename = "file" url = ftp_server.url(filename) feed_options = {"overwrite": False} - yield self._store(url, b"foo", feed_options=feed_options) - yield self._store(url, b"bar", feed_options=feed_options) + 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") - @inlineCallbacks - def test_overwrite(self): + @deferred_f_from_coro_f + async def test_overwrite(self): with MockFTPServer() as ftp_server: filename = "file" url = ftp_server.url(filename) - yield self._store(url, b"foo") - yield self._store(url, b"bar") + await self._store(url, b"foo") + await self._store(url, b"bar") self._assert_stored(ftp_server.path / filename, b"bar") - @inlineCallbacks - def test_append_active_mode(self): + @deferred_f_from_coro_f + 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} - yield self._store(url, b"foo", feed_options=feed_options, settings=settings) - yield self._store(url, b"bar", feed_options=feed_options, settings=settings) + 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") - @inlineCallbacks - def test_overwrite_active_mode(self): + @deferred_f_from_coro_f + 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) - yield self._store(url, b"foo", settings=settings) - yield self._store(url, b"bar", settings=settings) + 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): @@ -291,8 +295,8 @@ class TestS3FeedStorage(unittest.TestCase): assert storage.access_key == "uri_key" assert storage.secret_key == "uri_secret" - @inlineCallbacks - def test_store(self): + @deferred_f_from_coro_f + async def test_store(self): settings = { "AWS_ACCESS_KEY_ID": "access_key", "AWS_SECRET_ACCESS_KEY": "secret_key", @@ -306,7 +310,7 @@ class TestS3FeedStorage(unittest.TestCase): file = mock.MagicMock() storage.s3_client = mock.MagicMock() - yield storage.store(file) + await maybe_deferred_to_future(storage.store(file)) assert storage.s3_client.upload_fileobj.call_args == mock.call( Bucket=bucket, Key=key, Fileobj=file ) @@ -432,8 +436,8 @@ class TestS3FeedStorage(unittest.TestCase): assert storage.region_name == region_name assert storage.s3_client._client_config.region_name == region_name - @inlineCallbacks - def test_store_without_acl(self): + @deferred_f_from_coro_f + async def test_store_without_acl(self): storage = S3FeedStorage( "s3://mybucket/export.csv", "access_key", @@ -444,7 +448,7 @@ class TestS3FeedStorage(unittest.TestCase): assert storage.acl is None storage.s3_client = mock.MagicMock() - yield storage.store(BytesIO(b"test file")) + await maybe_deferred_to_future(storage.store(BytesIO(b"test file"))) acl = ( storage.s3_client.upload_fileobj.call_args[1] .get("ExtraArgs", {}) @@ -452,8 +456,8 @@ class TestS3FeedStorage(unittest.TestCase): ) assert acl is None - @inlineCallbacks - def test_store_with_acl(self): + @deferred_f_from_coro_f + async def test_store_with_acl(self): storage = S3FeedStorage( "s3://mybucket/export.csv", "access_key", "secret_key", "custom-acl" ) @@ -462,7 +466,7 @@ class TestS3FeedStorage(unittest.TestCase): assert storage.acl == "custom-acl" storage.s3_client = mock.MagicMock() - yield storage.store(BytesIO(b"test file")) + 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" @@ -516,8 +520,8 @@ class TestGCSFeedStorage(unittest.TestCase): storage = GCSFeedStorage.from_crawler(crawler, "gs://mybucket/export.csv") assert storage.acl is None - @inlineCallbacks - def test_store(self): + @deferred_f_from_coro_f + async def test_store(self): try: from google.cloud.storage import Client # noqa: F401 except ImportError: @@ -532,7 +536,7 @@ class TestGCSFeedStorage(unittest.TestCase): f = mock.Mock() storage = GCSFeedStorage(uri, project_id, acl) - yield storage.store(f) + await maybe_deferred_to_future(storage.store(f)) f.seek.assert_called_once_with(0) m.assert_called_once_with(project=project_id) @@ -556,14 +560,13 @@ class TestGCSFeedStorage(unittest.TestCase): assert "GCS does not support appending to files" in str(log) -class TestStdoutFeedStorage(unittest.TestCase): - @inlineCallbacks +class TestStdoutFeedStorage: def test_store(self): out = BytesIO() storage = StdoutFeedStorage("stdout:", _stdout=out) file = storage.open(scrapy.Spider("default")) file.write(b"content") - yield storage.store(file) + storage.store(file) assert out.getvalue() == b"content" def test_overwrite_default(self): @@ -641,6 +644,8 @@ class LogOnStoreFileStorage: class TestFeedExportBase(ABC, unittest.TestCase): + mockserver: MockServer + class MyItem(scrapy.Item): foo = scrapy.Field() egg = scrapy.Field() @@ -670,8 +675,9 @@ class TestFeedExportBase(ABC, unittest.TestCase): def tearDown(self): shutil.rmtree(self.temp_dir, ignore_errors=True) - @inlineCallbacks - def exported_data(self, items, settings): + async def exported_data( + self, items: Iterable[Any], settings: dict[str, Any] + ) -> dict[str, Any]: """ Return exported data which a spider yielding ``items`` would return. """ @@ -682,11 +688,9 @@ class TestFeedExportBase(ABC, unittest.TestCase): def parse(self, response): yield from items - data = yield self.run_and_export(TestSpider, settings) - return data + return await self.run_and_export(TestSpider, settings) - @inlineCallbacks - def exported_no_data(self, settings): + async def exported_no_data(self, settings: dict[str, Any]) -> dict[str, Any]: """ Return exported data which a spider yielding no ``items`` would return. """ @@ -697,20 +701,75 @@ class TestFeedExportBase(ABC, unittest.TestCase): def parse(self, response): pass - data = yield self.run_and_export(TestSpider, settings) - return data + return await self.run_and_export(TestSpider, settings) - @inlineCallbacks - def assertExported(self, items, header, rows, settings=None): - yield self.assertExportedCsv(items, header, rows, settings) - yield self.assertExportedJsonLines(items, rows, settings) - yield self.assertExportedXml(items, rows, settings) - yield self.assertExportedPickle(items, rows, settings) - yield self.assertExportedMarshal(items, rows, settings) - yield self.assertExportedMultiple(items, rows, settings) + async def assertExported( + self, + items: Iterable[Any], + header: Iterable[str], + rows: Iterable[dict[str, Any]], + settings: dict[str, Any] | None = None, + ) -> None: + await self.assertExportedCsv(items, header, rows, settings) + await self.assertExportedJsonLines(items, rows, settings) + await self.assertExportedXml(items, rows, settings) + await self.assertExportedPickle(items, rows, settings) + await self.assertExportedMarshal(items, rows, settings) + await self.assertExportedMultiple(items, rows, settings) + + async def assertExportedCsv( + self, + items: Iterable[Any], + header: Iterable[str], + rows: Iterable[dict[str, Any]], + settings: dict[str, Any] | None = None, + ) -> None: + pass + + async def assertExportedJsonLines( + self, + items: Iterable[Any], + rows: Iterable[dict[str, Any]], + settings: dict[str, Any] | None = None, + ) -> None: + pass + + async def assertExportedXml( + self, + items: Iterable[Any], + rows: Iterable[dict[str, Any]], + settings: dict[str, Any] | None = None, + ) -> None: + pass + + async def assertExportedMultiple( + self, + items: Iterable[Any], + rows: Iterable[dict[str, Any]], + settings: dict[str, Any] | None = None, + ) -> None: + pass + + async def assertExportedPickle( + self, + items: Iterable[Any], + rows: Iterable[dict[str, Any]], + settings: dict[str, Any] | None = None, + ) -> None: + pass + + async def assertExportedMarshal( + self, + items: Iterable[Any], + rows: Iterable[dict[str, Any]], + settings: dict[str, Any] | None = None, + ) -> None: + pass @abstractmethod - def run_and_export(self, spider_cls, settings): + async def run_and_export( + self, spider_cls: type[Spider], settings: dict[str, Any] + ) -> dict[str, Any]: pass def _load_until_eof(self, data, load_func): @@ -771,8 +830,9 @@ class ExceptionJsonItemExporter(JsonItemExporter): class TestFeedExport(TestFeedExportBase): - @inlineCallbacks - def run_and_export(self, spider_cls, settings): + async def run_and_export( + self, spider_cls: type[Spider], settings: dict[str, Any] + ) -> dict[str, Any]: """Run spider with specified settings; return exported data.""" FEEDS = settings.get("FEEDS") or {} @@ -781,11 +841,11 @@ class TestFeedExport(TestFeedExportBase): for file_path, feed_options in FEEDS.items() } - content = {} + content: dict[str, Any] = {} try: spider_cls.start_urls = [self.mockserver.url("/")] crawler = get_crawler(spider_cls, settings) - yield crawler.crawl() + await maybe_deferred_to_future(crawler.crawl()) for file_path, feed_options in FEEDS.items(): content[feed_options["format"]] = ( @@ -801,8 +861,13 @@ class TestFeedExport(TestFeedExportBase): return content - @inlineCallbacks - def assertExportedCsv(self, items, header, rows, settings=None): + async def assertExportedCsv( + self, + items: Iterable[Any], + header: Iterable[str], + rows: Iterable[dict[str, Any]], + settings: dict[str, Any] | None = None, + ) -> None: settings = settings or {} settings.update( { @@ -811,13 +876,17 @@ class TestFeedExport(TestFeedExportBase): }, } ) - data = yield self.exported_data(items, settings) + data = await self.exported_data(items, settings) reader = csv.DictReader(to_unicode(data["csv"]).splitlines()) assert reader.fieldnames == list(header) assert rows == list(reader) - @inlineCallbacks - def assertExportedJsonLines(self, items, rows, settings=None): + async def assertExportedJsonLines( + self, + items: Iterable[Any], + rows: Iterable[dict[str, Any]], + settings: dict[str, Any] | None = None, + ) -> None: settings = settings or {} settings.update( { @@ -826,13 +895,17 @@ class TestFeedExport(TestFeedExportBase): }, } ) - data = yield self.exported_data(items, settings) + data = await self.exported_data(items, settings) parsed = [json.loads(to_unicode(line)) for line in data["jl"].splitlines()] rows = [{k: v for k, v in row.items() if v} for row in rows] assert rows == parsed - @inlineCallbacks - def assertExportedXml(self, items, rows, settings=None): + async def assertExportedXml( + self, + items: Iterable[Any], + rows: Iterable[dict[str, Any]], + settings: dict[str, Any] | None = None, + ) -> None: settings = settings or {} settings.update( { @@ -841,14 +914,18 @@ class TestFeedExport(TestFeedExportBase): }, } ) - data = yield self.exported_data(items, settings) + data = await self.exported_data(items, settings) rows = [{k: v for k, v in row.items() if v} for row in rows] root = lxml.etree.fromstring(data["xml"]) got_rows = [{e.tag: e.text for e in it} for it in root.findall("item")] assert rows == got_rows - @inlineCallbacks - def assertExportedMultiple(self, items, rows, settings=None): + async def assertExportedMultiple( + self, + items: Iterable[Any], + rows: Iterable[dict[str, Any]], + settings: dict[str, Any] | None = None, + ) -> None: settings = settings or {} settings.update( { @@ -858,7 +935,7 @@ class TestFeedExport(TestFeedExportBase): }, } ) - data = yield self.exported_data(items, settings) + data = await self.exported_data(items, settings) rows = [{k: v for k, v in row.items() if v} for row in rows] # XML root = lxml.etree.fromstring(data["xml"]) @@ -868,8 +945,12 @@ class TestFeedExport(TestFeedExportBase): json_rows = json.loads(to_unicode(data["json"])) assert rows == json_rows - @inlineCallbacks - def assertExportedPickle(self, items, rows, settings=None): + async def assertExportedPickle( + self, + items: Iterable[Any], + rows: Iterable[dict[str, Any]], + settings: dict[str, Any] | None = None, + ) -> None: settings = settings or {} settings.update( { @@ -878,15 +959,19 @@ class TestFeedExport(TestFeedExportBase): }, } ) - data = yield self.exported_data(items, settings) + data = await self.exported_data(items, settings) expected = [{k: v for k, v in row.items() if v} for row in rows] import pickle result = self._load_until_eof(data["pickle"], load_func=pickle.load) assert result == expected - @inlineCallbacks - def assertExportedMarshal(self, items, rows, settings=None): + async def assertExportedMarshal( + self, + items: Iterable[Any], + rows: Iterable[dict[str, Any]], + settings: dict[str, Any] | None = None, + ) -> None: settings = settings or {} settings.update( { @@ -895,7 +980,7 @@ class TestFeedExport(TestFeedExportBase): }, } ) - data = yield self.exported_data(items, settings) + data = await self.exported_data(items, settings) expected = [{k: v for k, v in row.items() if v} for row in rows] import marshal @@ -956,8 +1041,8 @@ class TestFeedExport(TestFeedExportBase): crawler.stats.get_value("feedexport/success_count/StdoutFeedStorage") == 1 ) - @inlineCallbacks - def test_export_items(self): + @deferred_f_from_coro_f + async def test_export_items(self): # feed exporters use field names from Item items = [ self.MyItem({"foo": "bar1", "egg": "spam1"}), @@ -968,10 +1053,10 @@ class TestFeedExport(TestFeedExportBase): {"egg": "spam2", "foo": "bar2", "baz": "quux2"}, ] header = self.MyItem.fields.keys() - yield self.assertExported(items, header, rows) + await self.assertExported(items, header, rows) - @inlineCallbacks - def test_export_no_items_not_store_empty(self): + @deferred_f_from_coro_f + async def test_export_no_items_not_store_empty(self): for fmt in ("json", "jsonlines", "xml", "csv"): settings = { "FEEDS": { @@ -979,11 +1064,11 @@ class TestFeedExport(TestFeedExportBase): }, "FEED_STORE_EMPTY": False, } - data = yield self.exported_no_data(settings) + data = await self.exported_no_data(settings) assert data[fmt] is None - @inlineCallbacks - def test_start_finish_exporting_items(self): + @deferred_f_from_coro_f + async def test_start_finish_exporting_items(self): items = [ self.MyItem({"foo": "bar1", "egg": "spam1"}), ] @@ -998,12 +1083,12 @@ class TestFeedExport(TestFeedExportBase): InstrumentedFeedSlot.subscribe__listener(listener) with mock.patch("scrapy.extensions.feedexport.FeedSlot", InstrumentedFeedSlot): - _ = yield self.exported_data(items, settings) + await self.exported_data(items, settings) assert not listener.start_without_finish assert not listener.finish_without_start - @inlineCallbacks - def test_start_finish_exporting_no_items(self): + @deferred_f_from_coro_f + async def test_start_finish_exporting_no_items(self): items = [] settings = { "FEEDS": { @@ -1016,12 +1101,12 @@ class TestFeedExport(TestFeedExportBase): InstrumentedFeedSlot.subscribe__listener(listener) with mock.patch("scrapy.extensions.feedexport.FeedSlot", InstrumentedFeedSlot): - _ = yield self.exported_data(items, settings) + await self.exported_data(items, settings) assert not listener.start_without_finish assert not listener.finish_without_start - @inlineCallbacks - def test_start_finish_exporting_items_exception(self): + @deferred_f_from_coro_f + async def test_start_finish_exporting_items_exception(self): items = [ self.MyItem({"foo": "bar1", "egg": "spam1"}), ] @@ -1037,12 +1122,12 @@ class TestFeedExport(TestFeedExportBase): InstrumentedFeedSlot.subscribe__listener(listener) with mock.patch("scrapy.extensions.feedexport.FeedSlot", InstrumentedFeedSlot): - _ = yield self.exported_data(items, settings) + await self.exported_data(items, settings) assert not listener.start_without_finish assert not listener.finish_without_start - @inlineCallbacks - def test_start_finish_exporting_no_items_exception(self): + @deferred_f_from_coro_f + async def test_start_finish_exporting_no_items_exception(self): items = [] settings = { "FEEDS": { @@ -1056,12 +1141,12 @@ class TestFeedExport(TestFeedExportBase): InstrumentedFeedSlot.subscribe__listener(listener) with mock.patch("scrapy.extensions.feedexport.FeedSlot", InstrumentedFeedSlot): - _ = yield self.exported_data(items, settings) + await self.exported_data(items, settings) assert not listener.start_without_finish assert not listener.finish_without_start - @inlineCallbacks - def test_export_no_items_store_empty(self): + @deferred_f_from_coro_f + async def test_export_no_items_store_empty(self): formats = ( ("json", b"[]"), ("jsonlines", b""), @@ -1077,11 +1162,11 @@ class TestFeedExport(TestFeedExportBase): "FEED_STORE_EMPTY": True, "FEED_EXPORT_INDENT": None, } - data = yield self.exported_no_data(settings) + data = await self.exported_no_data(settings) assert expctd == data[fmt] - @inlineCallbacks - def test_export_no_items_multiple_feeds(self): + @deferred_f_from_coro_f + async def test_export_no_items_multiple_feeds(self): """Make sure that `storage.store` is called for every feed.""" settings = { "FEEDS": { @@ -1094,12 +1179,12 @@ class TestFeedExport(TestFeedExportBase): } with LogCapture() as log: - yield self.exported_no_data(settings) + await self.exported_no_data(settings) assert str(log).count("Storage.store is called") == 0 - @inlineCallbacks - def test_export_multiple_item_classes(self): + @deferred_f_from_coro_f + async def test_export_multiple_item_classes(self): items = [ self.MyItem({"foo": "bar1", "egg": "spam1"}), self.MyItem2({"hello": "world2", "foo": "bar2"}), @@ -1117,53 +1202,53 @@ class TestFeedExport(TestFeedExportBase): {"egg": "spam4", "foo": "", "baz": ""}, ] rows_jl = [dict(row) for row in items] - yield self.assertExportedCsv(items, header, rows_csv) - yield self.assertExportedJsonLines(items, rows_jl) + await self.assertExportedCsv(items, header, rows_csv) + await self.assertExportedJsonLines(items, rows_jl) - @inlineCallbacks - def test_export_items_empty_field_list(self): + @deferred_f_from_coro_f + async def test_export_items_empty_field_list(self): # FEED_EXPORT_FIELDS==[] means the same as default None items = [{"foo": "bar"}] header = ["foo"] rows = [{"foo": "bar"}] settings = {"FEED_EXPORT_FIELDS": []} - yield self.assertExportedCsv(items, header, rows) - yield self.assertExportedJsonLines(items, rows, settings) + await self.assertExportedCsv(items, header, rows) + await self.assertExportedJsonLines(items, rows, settings) - @inlineCallbacks - def test_export_items_field_list(self): + @deferred_f_from_coro_f + async def test_export_items_field_list(self): items = [{"foo": "bar"}] header = ["foo", "baz"] rows = [{"foo": "bar", "baz": ""}] settings = {"FEED_EXPORT_FIELDS": header} - yield self.assertExported(items, header, rows, settings=settings) + await self.assertExported(items, header, rows, settings=settings) - @inlineCallbacks - def test_export_items_comma_separated_field_list(self): + @deferred_f_from_coro_f + async def test_export_items_comma_separated_field_list(self): items = [{"foo": "bar"}] header = ["foo", "baz"] rows = [{"foo": "bar", "baz": ""}] settings = {"FEED_EXPORT_FIELDS": ",".join(header)} - yield self.assertExported(items, header, rows, settings=settings) + await self.assertExported(items, header, rows, settings=settings) - @inlineCallbacks - def test_export_items_json_field_list(self): + @deferred_f_from_coro_f + async def test_export_items_json_field_list(self): items = [{"foo": "bar"}] header = ["foo", "baz"] rows = [{"foo": "bar", "baz": ""}] settings = {"FEED_EXPORT_FIELDS": json.dumps(header)} - yield self.assertExported(items, header, rows, settings=settings) + await self.assertExported(items, header, rows, settings=settings) - @inlineCallbacks - def test_export_items_field_names(self): + @deferred_f_from_coro_f + async def test_export_items_field_names(self): items = [{"foo": "bar"}] header = {"foo": "Foo"} rows = [{"Foo": "bar"}] settings = {"FEED_EXPORT_FIELDS": header} - yield self.assertExported(items, list(header.values()), rows, settings=settings) + await self.assertExported(items, list(header.values()), rows, settings=settings) - @inlineCallbacks - def test_export_items_dict_field_names(self): + @deferred_f_from_coro_f + async def test_export_items_dict_field_names(self): items = [{"foo": "bar"}] header = { "baz": "Baz", @@ -1171,18 +1256,18 @@ class TestFeedExport(TestFeedExportBase): } rows = [{"Baz": "", "Foo": "bar"}] settings = {"FEED_EXPORT_FIELDS": header} - yield self.assertExported(items, ["Baz", "Foo"], rows, settings=settings) + await self.assertExported(items, ["Baz", "Foo"], rows, settings=settings) - @inlineCallbacks - def test_export_items_json_field_names(self): + @deferred_f_from_coro_f + async def test_export_items_json_field_names(self): items = [{"foo": "bar"}] header = {"foo": "Foo"} rows = [{"Foo": "bar"}] settings = {"FEED_EXPORT_FIELDS": json.dumps(header)} - yield self.assertExported(items, list(header.values()), rows, settings=settings) + await self.assertExported(items, list(header.values()), rows, settings=settings) - @inlineCallbacks - def test_export_based_on_item_classes(self): + @deferred_f_from_coro_f + async def test_export_based_on_item_classes(self): items = [ self.MyItem({"foo": "bar1", "egg": "spam1"}), self.MyItem2({"hello": "world2", "foo": "bar2"}), @@ -1223,12 +1308,12 @@ class TestFeedExport(TestFeedExportBase): }, } - data = yield self.exported_data(items, settings) + data = await self.exported_data(items, settings) for fmt, expected in formats.items(): assert data[fmt] == expected - @inlineCallbacks - def test_export_based_on_custom_filters(self): + @deferred_f_from_coro_f + async def test_export_based_on_custom_filters(self): items = [ self.MyItem({"foo": "bar1", "egg": "spam1"}), self.MyItem2({"hello": "world2", "foo": "bar2"}), @@ -1282,12 +1367,12 @@ class TestFeedExport(TestFeedExportBase): }, } - data = yield self.exported_data(items, settings) + data = await self.exported_data(items, settings) for fmt, expected in formats.items(): assert data[fmt] == expected - @inlineCallbacks - def test_export_dicts(self): + @deferred_f_from_coro_f + 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. items = [ @@ -1296,11 +1381,11 @@ class TestFeedExport(TestFeedExportBase): ] rows_csv = [{"egg": "spam", "foo": "bar"}, {"egg": "spam", "foo": "bar"}] rows_jl = items - yield self.assertExportedCsv(items, ["foo", "egg"], rows_csv) - yield self.assertExportedJsonLines(items, rows_jl) + await self.assertExportedCsv(items, ["foo", "egg"], rows_csv) + await self.assertExportedJsonLines(items, rows_jl) - @inlineCallbacks - def test_export_tuple(self): + @deferred_f_from_coro_f + async def test_export_tuple(self): items = [ {"foo": "bar1", "egg": "spam1"}, {"foo": "bar2", "egg": "spam2", "baz": "quux"}, @@ -1308,10 +1393,10 @@ class TestFeedExport(TestFeedExportBase): settings = {"FEED_EXPORT_FIELDS": ("foo", "baz")} rows = [{"foo": "bar1", "baz": ""}, {"foo": "bar2", "baz": "quux"}] - yield self.assertExported(items, ["foo", "baz"], rows, settings=settings) + await self.assertExported(items, ["foo", "baz"], rows, settings=settings) - @inlineCallbacks - def test_export_feed_export_fields(self): + @deferred_f_from_coro_f + 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. @@ -1327,17 +1412,17 @@ class TestFeedExport(TestFeedExportBase): {"egg": "spam1", "foo": "bar1", "baz": ""}, {"egg": "spam2", "foo": "bar2", "baz": "quux2"}, ] - yield self.assertExported( + await self.assertExported( items, ["foo", "baz", "egg"], rows, settings=settings ) # export a subset of columns settings = {"FEED_EXPORT_FIELDS": "egg,baz"} rows = [{"egg": "spam1", "baz": ""}, {"egg": "spam2", "baz": "quux2"}] - yield self.assertExported(items, ["egg", "baz"], rows, settings=settings) + await self.assertExported(items, ["egg", "baz"], rows, settings=settings) - @inlineCallbacks - def test_export_encoding(self): + @deferred_f_from_coro_f + async def test_export_encoding(self): items = [{"foo": "Test\xd6"}] formats = { @@ -1357,7 +1442,7 @@ class TestFeedExport(TestFeedExportBase): }, "FEED_EXPORT_INDENT": None, } - data = yield self.exported_data(items, settings) + data = await self.exported_data(items, settings) assert data[fmt] == expected formats = { @@ -1378,11 +1463,11 @@ class TestFeedExport(TestFeedExportBase): "FEED_EXPORT_INDENT": None, "FEED_EXPORT_ENCODING": "latin-1", } - data = yield self.exported_data(items, settings) + data = await self.exported_data(items, settings) assert data[fmt] == expected - @inlineCallbacks - def test_export_multiple_configs(self): + @deferred_f_from_coro_f + async def test_export_multiple_configs(self): items = [{"foo": "FOO", "bar": "BAR"}] formats = { @@ -1417,12 +1502,12 @@ class TestFeedExport(TestFeedExportBase): }, } - data = yield self.exported_data(items, settings) + data = await self.exported_data(items, settings) for fmt, expected in formats.items(): assert data[fmt] == expected - @inlineCallbacks - def test_export_indentation(self): + @deferred_f_from_coro_f + async def test_export_indentation(self): items = [ {"foo": ["bar"]}, {"key": "value"}, @@ -1574,11 +1659,11 @@ class TestFeedExport(TestFeedExportBase): }, }, } - data = yield self.exported_data(items, settings) + data = await self.exported_data(items, settings) assert data[row["format"]] == row["expected"] - @inlineCallbacks - def test_init_exporters_storages_with_crawler(self): + @deferred_f_from_coro_f + async def test_init_exporters_storages_with_crawler(self): settings = { "FEED_EXPORTERS": {"csv": FromCrawlerCsvItemExporter}, "FEED_STORAGES": {"file": FromCrawlerFileFeedStorage}, @@ -1586,21 +1671,21 @@ class TestFeedExport(TestFeedExportBase): self._random_temp_filename(): {"format": "csv"}, }, } - yield self.exported_data(items=[], settings=settings) + await self.exported_data(items=[], settings=settings) assert FromCrawlerCsvItemExporter.init_with_crawler assert FromCrawlerFileFeedStorage.init_with_crawler - @inlineCallbacks - def test_str_uri(self): + @deferred_f_from_coro_f + async def test_str_uri(self): settings = { "FEED_STORE_EMPTY": True, "FEEDS": {str(self._random_temp_filename()): {"format": "csv"}}, } - data = yield self.exported_no_data(settings) + data = await self.exported_no_data(settings) assert data["csv"] == b"" - @inlineCallbacks - def test_multiple_feeds_success_logs_blocking_feed_storage(self): + @deferred_f_from_coro_f + async def test_multiple_feeds_success_logs_blocking_feed_storage(self): settings = { "FEEDS": { self._random_temp_filename(): {"format": "json"}, @@ -1614,14 +1699,14 @@ class TestFeedExport(TestFeedExportBase): {"foo": "bar2", "baz": "quux"}, ] with LogCapture() as log: - yield self.exported_data(items, settings) + await self.exported_data(items, settings) print(log) for fmt in ["json", "xml", "csv"]: assert f"Stored {fmt} feed (2 items)" in str(log) - @inlineCallbacks - def test_multiple_feeds_failing_logs_blocking_feed_storage(self): + @deferred_f_from_coro_f + async def test_multiple_feeds_failing_logs_blocking_feed_storage(self): settings = { "FEEDS": { self._random_temp_filename(): {"format": "json"}, @@ -1635,14 +1720,14 @@ class TestFeedExport(TestFeedExportBase): {"foo": "bar2", "baz": "quux"}, ] with LogCapture() as log: - yield self.exported_data(items, settings) + await self.exported_data(items, settings) print(log) for fmt in ["json", "xml", "csv"]: assert f"Error storing {fmt} feed (2 items)" in str(log) - @inlineCallbacks - def test_extend_kwargs(self): + @deferred_f_from_coro_f + async def test_extend_kwargs(self): items = [{"foo": "FOO", "bar": "BAR"}] expected_with_title_csv = b"foo,bar\r\nFOO,BAR\r\n" @@ -1675,11 +1760,11 @@ class TestFeedExport(TestFeedExportBase): "FEED_EXPORT_INDENT": None, } - data = yield self.exported_data(items, settings) + data = await self.exported_data(items, settings) assert data[feed_options["format"]] == row["expected"] - @inlineCallbacks - def test_storage_file_no_postprocessing(self): + @deferred_f_from_coro_f + async def test_storage_file_no_postprocessing(self): @implementer(IFeedStorage) class Storage: def __init__(self, uri, *, feed_options=None): @@ -1697,11 +1782,11 @@ class TestFeedExport(TestFeedExportBase): "FEEDS": {self._random_temp_filename(): {"format": "jsonlines"}}, "FEED_STORAGES": {"file": Storage}, } - yield self.exported_no_data(settings) + await self.exported_no_data(settings) assert Storage.open_file is Storage.store_file - @inlineCallbacks - def test_storage_file_postprocessing(self): + @deferred_f_from_coro_f + async def test_storage_file_postprocessing(self): @implementer(IFeedStorage) class Storage: def __init__(self, uri, *, feed_options=None): @@ -1727,7 +1812,7 @@ class TestFeedExport(TestFeedExportBase): }, "FEED_STORAGES": {"file": Storage}, } - yield self.exported_no_data(settings) + await self.exported_no_data(settings) assert Storage.open_file is Storage.store_file assert not Storage.file_was_closed @@ -1753,8 +1838,9 @@ class TestFeedPostProcessedExports(TestFeedExportBase): def _named_tempfile(self, name) -> str: return str(Path(self.temp_dir, name)) - @inlineCallbacks - def run_and_export(self, spider_cls, settings): + 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 {} @@ -1763,11 +1849,11 @@ class TestFeedPostProcessedExports(TestFeedExportBase): for file_path, feed_options in FEEDS.items() } - content = {} + content: dict[str, bytes | None] = {} try: spider_cls.start_urls = [self.mockserver.url("/")] crawler = get_crawler(spider_cls, settings) - yield crawler.crawl() + await maybe_deferred_to_future(crawler.crawl()) for file_path in FEEDS: content[str(file_path)] = ( @@ -1797,8 +1883,8 @@ class TestFeedPostProcessedExports(TestFeedExportBase): data_stream.seek(0) return data_stream.read() - @inlineCallbacks - def test_gzip_plugin(self): + @deferred_f_from_coro_f + async def test_gzip_plugin(self): filename = self._named_tempfile("gzip_file") settings = { @@ -1810,14 +1896,14 @@ class TestFeedPostProcessedExports(TestFeedExportBase): }, } - data = yield self.exported_data(self.items, settings) + data = await self.exported_data(self.items, settings) try: gzip.decompress(data[filename]) except OSError: pytest.fail("Received invalid gzip data.") - @inlineCallbacks - def test_gzip_plugin_compresslevel(self): + @deferred_f_from_coro_f + async def test_gzip_plugin_compresslevel(self): filename_to_compressed = { self._named_tempfile("compresslevel_0"): self.get_gzip_compressed( self.expected, compresslevel=0 @@ -1846,15 +1932,15 @@ class TestFeedPostProcessedExports(TestFeedExportBase): }, } - data = yield self.exported_data(self.items, settings) + 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 - @inlineCallbacks - def test_gzip_plugin_mtime(self): + @deferred_f_from_coro_f + async def test_gzip_plugin_mtime(self): filename_to_compressed = { self._named_tempfile("mtime_123"): self.get_gzip_compressed( self.expected, mtime=123 @@ -1881,15 +1967,15 @@ class TestFeedPostProcessedExports(TestFeedExportBase): }, } - data = yield self.exported_data(self.items, settings) + 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 - @inlineCallbacks - def test_gzip_plugin_filename(self): + @deferred_f_from_coro_f + async def test_gzip_plugin_filename(self): filename_to_compressed = { self._named_tempfile("filename_FILE1"): self.get_gzip_compressed( self.expected, filename="FILE1" @@ -1916,15 +2002,15 @@ class TestFeedPostProcessedExports(TestFeedExportBase): }, } - data = yield self.exported_data(self.items, settings) + 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 - @inlineCallbacks - def test_lzma_plugin(self): + @deferred_f_from_coro_f + async def test_lzma_plugin(self): filename = self._named_tempfile("lzma_file") settings = { @@ -1936,14 +2022,14 @@ class TestFeedPostProcessedExports(TestFeedExportBase): }, } - data = yield self.exported_data(self.items, settings) + data = await self.exported_data(self.items, settings) try: lzma.decompress(data[filename]) except lzma.LZMAError: pytest.fail("Received invalid lzma data.") - @inlineCallbacks - def test_lzma_plugin_format(self): + @deferred_f_from_coro_f + async def test_lzma_plugin_format(self): filename_to_compressed = { self._named_tempfile("format_FORMAT_XZ"): lzma.compress( self.expected, format=lzma.FORMAT_XZ @@ -1968,15 +2054,15 @@ class TestFeedPostProcessedExports(TestFeedExportBase): }, } - data = yield self.exported_data(self.items, settings) + 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 - @inlineCallbacks - def test_lzma_plugin_check(self): + @deferred_f_from_coro_f + async def test_lzma_plugin_check(self): filename_to_compressed = { self._named_tempfile("check_CHECK_NONE"): lzma.compress( self.expected, check=lzma.CHECK_NONE @@ -2001,15 +2087,15 @@ class TestFeedPostProcessedExports(TestFeedExportBase): }, } - data = yield self.exported_data(self.items, settings) + 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 - @inlineCallbacks - def test_lzma_plugin_preset(self): + @deferred_f_from_coro_f + async def test_lzma_plugin_preset(self): filename_to_compressed = { self._named_tempfile("preset_PRESET_0"): lzma.compress( self.expected, preset=0 @@ -2034,15 +2120,15 @@ class TestFeedPostProcessedExports(TestFeedExportBase): }, } - data = yield self.exported_data(self.items, settings) + 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 - @inlineCallbacks - def test_lzma_plugin_filters(self): + @deferred_f_from_coro_f + 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") @@ -2061,13 +2147,13 @@ class TestFeedPostProcessedExports(TestFeedExportBase): }, } - data = yield self.exported_data(self.items, settings) + data = await self.exported_data(self.items, settings) assert compressed == data[filename] result = lzma.decompress(data[filename]) assert result == self.expected - @inlineCallbacks - def test_bz2_plugin(self): + @deferred_f_from_coro_f + async def test_bz2_plugin(self): filename = self._named_tempfile("bz2_file") settings = { @@ -2079,14 +2165,14 @@ class TestFeedPostProcessedExports(TestFeedExportBase): }, } - data = yield self.exported_data(self.items, settings) + data = await self.exported_data(self.items, settings) try: bz2.decompress(data[filename]) except OSError: pytest.fail("Received invalid bz2 data.") - @inlineCallbacks - def test_bz2_plugin_compresslevel(self): + @deferred_f_from_coro_f + async def test_bz2_plugin_compresslevel(self): filename_to_compressed = { self._named_tempfile("compresslevel_1"): bz2.compress( self.expected, compresslevel=1 @@ -2111,15 +2197,15 @@ class TestFeedPostProcessedExports(TestFeedExportBase): }, } - data = yield self.exported_data(self.items, settings) + 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 - @inlineCallbacks - def test_custom_plugin(self): + @deferred_f_from_coro_f + async def test_custom_plugin(self): filename = self._named_tempfile("csv_file") settings = { @@ -2131,11 +2217,11 @@ class TestFeedPostProcessedExports(TestFeedExportBase): }, } - data = yield self.exported_data(self.items, settings) + data = await self.exported_data(self.items, settings) assert data[filename] == self.expected - @inlineCallbacks - def test_custom_plugin_with_parameter(self): + @deferred_f_from_coro_f + async def test_custom_plugin_with_parameter(self): expected = b"foo\r\n\nbar\r\n\n" filename = self._named_tempfile("newline") @@ -2149,11 +2235,11 @@ class TestFeedPostProcessedExports(TestFeedExportBase): }, } - data = yield self.exported_data(self.items, settings) + data = await self.exported_data(self.items, settings) assert data[filename] == expected - @inlineCallbacks - def test_custom_plugin_with_compression(self): + @deferred_f_from_coro_f + async def test_custom_plugin_with_compression(self): expected = b"foo\r\n\nbar\r\n\n" filename_to_decompressor = { @@ -2191,14 +2277,14 @@ class TestFeedPostProcessedExports(TestFeedExportBase): }, } - data = yield self.exported_data(self.items, settings) + data = await self.exported_data(self.items, settings) for filename, decompressor in filename_to_decompressor.items(): result = decompressor(data[filename]) assert result == expected - @inlineCallbacks - def test_exports_compatibility_with_postproc(self): + @deferred_f_from_coro_f + async def test_exports_compatibility_with_postproc(self): import marshal import pickle @@ -2240,7 +2326,7 @@ class TestFeedPostProcessedExports(TestFeedExportBase): }, } - data = yield self.exported_data(self.items, settings) + data = await self.exported_data(self.items, settings) for filename, result in data.items(): if "pickle" in filename: @@ -2255,18 +2341,19 @@ class TestFeedPostProcessedExports(TestFeedExportBase): class TestBatchDeliveries(TestFeedExportBase): _file_mark = "_%(batch_time)s_#%(batch_id)02d_" - @inlineCallbacks - def run_and_export(self, spider_cls, settings): + 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(list) + content: defaultdict[str, list[bytes]] = defaultdict(list) spider_cls.start_urls = [self.mockserver.url("/")] crawler = get_crawler(spider_cls, settings) - yield crawler.crawl() + await maybe_deferred_to_future(crawler.crawl()) for path, feed in FEEDS.items(): dir_name = Path(path).parent @@ -2277,8 +2364,7 @@ class TestBatchDeliveries(TestFeedExportBase): content[feed["format"]].append(file.read_bytes()) return content - @inlineCallbacks - def assertExportedJsonLines(self, items, rows, settings=None): + async def assertExportedJsonLines(self, items, rows, settings=None): settings = settings or {} settings.update( { @@ -2291,7 +2377,7 @@ class TestBatchDeliveries(TestFeedExportBase): ) 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 = yield self.exported_data(items, settings) + 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() @@ -2299,8 +2385,7 @@ class TestBatchDeliveries(TestFeedExportBase): expected_batch, rows = rows[:batch_size], rows[batch_size:] assert got_batch == expected_batch - @inlineCallbacks - def assertExportedCsv(self, items, header, rows, settings=None): + async def assertExportedCsv(self, items, header, rows, settings=None): settings = settings or {} settings.update( { @@ -2312,15 +2397,14 @@ class TestBatchDeliveries(TestFeedExportBase): } ) batch_size = Settings(settings).getint("FEED_EXPORT_BATCH_ITEM_COUNT") - data = yield self.exported_data(items, settings) + 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 - @inlineCallbacks - def assertExportedXml(self, items, rows, settings=None): + async def assertExportedXml(self, items, rows, settings=None): settings = settings or {} settings.update( { @@ -2333,15 +2417,14 @@ class TestBatchDeliveries(TestFeedExportBase): ) 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 = yield self.exported_data(items, settings) + 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 - @inlineCallbacks - def assertExportedMultiple(self, items, rows, settings=None): + async def assertExportedMultiple(self, items, rows, settings=None): settings = settings or {} settings.update( { @@ -2357,7 +2440,7 @@ class TestBatchDeliveries(TestFeedExportBase): ) 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 = yield self.exported_data(items, settings) + data = await self.exported_data(items, settings) # XML xml_rows = rows.copy() for batch in data["xml"]: @@ -2372,8 +2455,7 @@ class TestBatchDeliveries(TestFeedExportBase): expected_batch, json_rows = json_rows[:batch_size], json_rows[batch_size:] assert got_batch == expected_batch - @inlineCallbacks - def assertExportedPickle(self, items, rows, settings=None): + async def assertExportedPickle(self, items, rows, settings=None): settings = settings or {} settings.update( { @@ -2386,7 +2468,7 @@ class TestBatchDeliveries(TestFeedExportBase): ) 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 = yield self.exported_data(items, settings) + data = await self.exported_data(items, settings) import pickle for batch in data["pickle"]: @@ -2394,8 +2476,7 @@ class TestBatchDeliveries(TestFeedExportBase): expected_batch, rows = rows[:batch_size], rows[batch_size:] assert got_batch == expected_batch - @inlineCallbacks - def assertExportedMarshal(self, items, rows, settings=None): + async def assertExportedMarshal(self, items, rows, settings=None): settings = settings or {} settings.update( { @@ -2408,7 +2489,7 @@ class TestBatchDeliveries(TestFeedExportBase): ) 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 = yield self.exported_data(items, settings) + data = await self.exported_data(items, settings) import marshal for batch in data["marshal"]: @@ -2416,8 +2497,8 @@ class TestBatchDeliveries(TestFeedExportBase): expected_batch, rows = rows[:batch_size], rows[batch_size:] assert got_batch == expected_batch - @inlineCallbacks - def test_export_items(self): + @deferred_f_from_coro_f + async def test_export_items(self): """Test partial deliveries in all supported formats""" items = [ self.MyItem({"foo": "bar1", "egg": "spam1"}), @@ -2431,7 +2512,7 @@ class TestBatchDeliveries(TestFeedExportBase): ] settings = {"FEED_EXPORT_BATCH_ITEM_COUNT": 2} header = self.MyItem.fields.keys() - yield self.assertExported(items, header, rows, settings=settings) + 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""" @@ -2445,8 +2526,8 @@ class TestBatchDeliveries(TestFeedExportBase): with pytest.raises(NotConfigured): FeedExporter(crawler) - @inlineCallbacks - def test_export_no_items_not_store_empty(self): + @deferred_f_from_coro_f + async def test_export_no_items_not_store_empty(self): for fmt in ("json", "jsonlines", "xml", "csv"): settings = { "FEEDS": { @@ -2457,12 +2538,12 @@ class TestBatchDeliveries(TestFeedExportBase): "FEED_EXPORT_BATCH_ITEM_COUNT": 1, "FEED_STORE_EMPTY": False, } - data = yield self.exported_no_data(settings) + data = await self.exported_no_data(settings) data = dict(data) assert len(data[fmt]) == 0 - @inlineCallbacks - def test_export_no_items_store_empty(self): + @deferred_f_from_coro_f + async def test_export_no_items_store_empty(self): formats = ( ("json", b"[]"), ("jsonlines", b""), @@ -2481,12 +2562,12 @@ class TestBatchDeliveries(TestFeedExportBase): "FEED_EXPORT_INDENT": None, "FEED_EXPORT_BATCH_ITEM_COUNT": 1, } - data = yield self.exported_no_data(settings) + data = await self.exported_no_data(settings) data = dict(data) assert data[fmt][0] == expctd - @inlineCallbacks - def test_export_multiple_configs(self): + @deferred_f_from_coro_f + async def test_export_multiple_configs(self): items = [ {"foo": "FOO", "bar": "BAR"}, {"foo": "FOO1", "bar": "BAR1"}, @@ -2536,13 +2617,13 @@ class TestBatchDeliveries(TestFeedExportBase): }, "FEED_EXPORT_BATCH_ITEM_COUNT": 1, } - data = yield self.exported_data(items, settings) + data = await self.exported_data(items, settings) for fmt, expected in formats.items(): for expected_batch, got_batch in zip(expected, data[fmt]): assert got_batch == expected_batch - @inlineCallbacks - def test_batch_item_count_feeds_setting(self): + @deferred_f_from_coro_f + async def test_batch_item_count_feeds_setting(self): items = [{"foo": "FOO"}, {"foo": "FOO1"}] formats = { "json": [ @@ -2560,13 +2641,13 @@ class TestBatchDeliveries(TestFeedExportBase): }, }, } - data = yield self.exported_data(items, settings) + data = await self.exported_data(items, settings) for fmt, expected in formats.items(): for expected_batch, got_batch in zip(expected, data[fmt]): assert got_batch == expected_batch - @inlineCallbacks - def test_batch_path_differ(self): + @deferred_f_from_coro_f + 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. @@ -2584,7 +2665,7 @@ class TestBatchDeliveries(TestFeedExportBase): }, "FEED_EXPORT_BATCH_ITEM_COUNT": 1, } - data = yield self.exported_data(items, settings) + data = await self.exported_data(items, settings) assert len(items) == len(data["json"]) @inlineCallbacks diff --git a/tests/test_spidermiddleware.py b/tests/test_spidermiddleware.py index 6ebaa19ce..28ffbe767 100644 --- a/tests/test_spidermiddleware.py +++ b/tests/test_spidermiddleware.py @@ -8,7 +8,6 @@ from unittest import mock import pytest from testfixtures import LogCapture from twisted.internet import defer -from twisted.internet.defer import inlineCallbacks from twisted.trial.unittest import TestCase from scrapy.core.spidermw import SpiderMiddlewareManager @@ -18,7 +17,6 @@ from scrapy.spiders import Spider from scrapy.utils.asyncgen import collect_asyncgen from scrapy.utils.defer import ( deferred_f_from_coro_f, - deferred_from_coro, maybe_deferred_to_future, ) from scrapy.utils.test import get_crawler @@ -130,25 +128,22 @@ class TestBaseAsyncSpiderMiddleware(TestSpiderMiddleware): yield {"foo": 2} yield {"foo": 3} - @inlineCallbacks - def _get_middleware_result(self, *mw_classes, start_index: int | None = None): + async def _get_middleware_result(self, *mw_classes, start_index: int | None = None): setting = self._construct_mw_setting(*mw_classes, start_index=start_index) self.crawler = get_crawler( Spider, {"SPIDER_MIDDLEWARES_BASE": {}, "SPIDER_MIDDLEWARES": setting} ) self.spider = self.crawler._create_spider("foo") self.mwman = SpiderMiddlewareManager.from_crawler(self.crawler) - result = yield self.mwman.scrape_response( + return await self.mwman.scrape_response_async( self._scrape_func, self.response, self.request, self.spider ) - return result - @inlineCallbacks - def _test_simple_base( + async def _test_simple_base( self, *mw_classes, downgrade: bool = False, start_index: int | None = None ): with LogCapture() as log: - result = yield self._get_middleware_result( + result = await self._get_middleware_result( *mw_classes, start_index=start_index ) assert isinstance(result, Iterable) @@ -160,16 +155,15 @@ class TestBaseAsyncSpiderMiddleware(TestSpiderMiddleware): ProcessSpiderOutputSimpleMiddleware in mw_classes ) - @inlineCallbacks - def _test_asyncgen_base( + async def _test_asyncgen_base( self, *mw_classes, downgrade: bool = False, start_index: int | None = None ): with LogCapture() as log: - result = yield self._get_middleware_result( + result = await self._get_middleware_result( *mw_classes, start_index=start_index ) assert isinstance(result, AsyncIterator) - result_list = yield deferred_from_coro(collect_asyncgen(result)) + 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 @@ -222,41 +216,50 @@ class TestProcessSpiderOutputSimple(TestBaseAsyncSpiderMiddleware): MW_ASYNCGEN = ProcessSpiderOutputAsyncGenMiddleware MW_UNIVERSAL = ProcessSpiderOutputUniversalMiddleware - def test_simple(self): + @deferred_f_from_coro_f + async def test_simple(self): """Simple mw""" - return self._test_simple_base(self.MW_SIMPLE) + await self._test_simple_base(self.MW_SIMPLE) - def test_asyncgen(self): + @deferred_f_from_coro_f + async def test_asyncgen(self): """Asyncgen mw; upgrade""" - return self._test_asyncgen_base(self.MW_ASYNCGEN) + await self._test_asyncgen_base(self.MW_ASYNCGEN) - def test_simple_asyncgen(self): + @deferred_f_from_coro_f + async def test_simple_asyncgen(self): """Simple mw -> asyncgen mw; upgrade""" - return self._test_asyncgen_base(self.MW_ASYNCGEN, self.MW_SIMPLE) + await self._test_asyncgen_base(self.MW_ASYNCGEN, self.MW_SIMPLE) - def test_asyncgen_simple(self): + @deferred_f_from_coro_f + async def test_asyncgen_simple(self): """Asyncgen mw -> simple mw; upgrade then downgrade""" - return self._test_simple_base(self.MW_SIMPLE, self.MW_ASYNCGEN, downgrade=True) + await self._test_simple_base(self.MW_SIMPLE, self.MW_ASYNCGEN, downgrade=True) - def test_universal(self): + @deferred_f_from_coro_f + async def test_universal(self): """Universal mw""" - return self._test_simple_base(self.MW_UNIVERSAL) + await self._test_simple_base(self.MW_UNIVERSAL) - def test_universal_simple(self): + @deferred_f_from_coro_f + async def test_universal_simple(self): """Universal mw -> simple mw""" - return self._test_simple_base(self.MW_SIMPLE, self.MW_UNIVERSAL) + await self._test_simple_base(self.MW_SIMPLE, self.MW_UNIVERSAL) - def test_simple_universal(self): + @deferred_f_from_coro_f + async def test_simple_universal(self): """Simple mw -> universal mw""" - return self._test_simple_base(self.MW_UNIVERSAL, self.MW_SIMPLE) + await self._test_simple_base(self.MW_UNIVERSAL, self.MW_SIMPLE) - def test_universal_asyncgen(self): + @deferred_f_from_coro_f + async def test_universal_asyncgen(self): """Universal mw -> asyncgen mw; upgrade""" - return self._test_asyncgen_base(self.MW_ASYNCGEN, self.MW_UNIVERSAL) + await self._test_asyncgen_base(self.MW_ASYNCGEN, self.MW_UNIVERSAL) - def test_asyncgen_universal(self): + @deferred_f_from_coro_f + async def test_asyncgen_universal(self): """Asyncgen mw -> universal mw; upgrade""" - return self._test_asyncgen_base(self.MW_UNIVERSAL, self.MW_ASYNCGEN) + await self._test_asyncgen_base(self.MW_UNIVERSAL, self.MW_ASYNCGEN) class TestProcessSpiderOutputAsyncGen(TestProcessSpiderOutputSimple): @@ -266,27 +269,30 @@ class TestProcessSpiderOutputAsyncGen(TestProcessSpiderOutputSimple): for item in super()._scrape_func(): yield item - def test_simple(self): + @deferred_f_from_coro_f + async def test_simple(self): """Simple mw; downgrade""" - return self._test_simple_base(self.MW_SIMPLE, downgrade=True) + await self._test_simple_base(self.MW_SIMPLE, downgrade=True) - def test_simple_asyncgen(self): + @deferred_f_from_coro_f + async def test_simple_asyncgen(self): """Simple mw -> asyncgen mw; downgrade then upgrade""" - return self._test_asyncgen_base( - self.MW_ASYNCGEN, self.MW_SIMPLE, downgrade=True - ) + await self._test_asyncgen_base(self.MW_ASYNCGEN, self.MW_SIMPLE, downgrade=True) - def test_universal(self): + @deferred_f_from_coro_f + async def test_universal(self): """Universal mw""" - return self._test_asyncgen_base(self.MW_UNIVERSAL) + await self._test_asyncgen_base(self.MW_UNIVERSAL) - def test_universal_simple(self): + @deferred_f_from_coro_f + async def test_universal_simple(self): """Universal mw -> simple mw; downgrade""" - return self._test_simple_base(self.MW_SIMPLE, self.MW_UNIVERSAL, downgrade=True) + await self._test_simple_base(self.MW_SIMPLE, self.MW_UNIVERSAL, downgrade=True) - def test_simple_universal(self): + @deferred_f_from_coro_f + async def test_simple_universal(self): """Simple mw -> universal mw; downgrade""" - return self._test_simple_base(self.MW_UNIVERSAL, self.MW_SIMPLE, downgrade=True) + await self._test_simple_base(self.MW_UNIVERSAL, self.MW_SIMPLE, downgrade=True) class ProcessSpiderOutputNonIterableMiddleware: @@ -300,25 +306,21 @@ class ProcessSpiderOutputCoroutineMiddleware: class TestProcessSpiderOutputInvalidResult(TestBaseAsyncSpiderMiddleware): - @inlineCallbacks - def test_non_iterable(self): + @deferred_f_from_coro_f + async def test_non_iterable(self): with pytest.raises( _InvalidOutput, match=r"\.process_spider_output must return an iterable, got ", ): - yield self._get_middleware_result( - ProcessSpiderOutputNonIterableMiddleware, - ) + await self._get_middleware_result(ProcessSpiderOutputNonIterableMiddleware) - @inlineCallbacks - def test_coroutine(self): + @deferred_f_from_coro_f + async def test_coroutine(self): with pytest.raises( _InvalidOutput, match=r"\.process_spider_output must be an asynchronous generator", ): - yield self._get_middleware_result( - ProcessSpiderOutputCoroutineMiddleware, - ) + await self._get_middleware_result(ProcessSpiderOutputCoroutineMiddleware) class ProcessStartSimpleMiddleware: @@ -445,39 +447,44 @@ class TestBuiltinMiddlewareSimple(TestBaseAsyncSpiderMiddleware): MW_ASYNCGEN = ProcessSpiderOutputAsyncGenMiddleware MW_UNIVERSAL = ProcessSpiderOutputUniversalMiddleware - @inlineCallbacks - def _get_middleware_result(self, *mw_classes, start_index: int | None = None): + async def _get_middleware_result(self, *mw_classes, start_index: int | None = None): setting = self._construct_mw_setting(*mw_classes, start_index=start_index) self.crawler = get_crawler(Spider, {"SPIDER_MIDDLEWARES": setting}) self.spider = self.crawler._create_spider("foo") self.mwman = SpiderMiddlewareManager.from_crawler(self.crawler) - result = yield self.mwman.scrape_response( + return await self.mwman.scrape_response_async( self._scrape_func, self.response, self.request, self.spider ) - return result - def test_just_builtin(self): - return self._test_simple_base() + @deferred_f_from_coro_f + async def test_just_builtin(self): + await self._test_simple_base() - def test_builtin_simple(self): - return self._test_simple_base(self.MW_SIMPLE, start_index=1000) + @deferred_f_from_coro_f + async def test_builtin_simple(self): + await self._test_simple_base(self.MW_SIMPLE, start_index=1000) - def test_builtin_async(self): + @deferred_f_from_coro_f + async def test_builtin_async(self): """Upgrade""" - return self._test_asyncgen_base(self.MW_ASYNCGEN, start_index=1000) + await self._test_asyncgen_base(self.MW_ASYNCGEN, start_index=1000) - def test_builtin_universal(self): - return self._test_simple_base(self.MW_UNIVERSAL, start_index=1000) + @deferred_f_from_coro_f + async def test_builtin_universal(self): + await self._test_simple_base(self.MW_UNIVERSAL, start_index=1000) - def test_simple_builtin(self): - return self._test_simple_base(self.MW_SIMPLE) + @deferred_f_from_coro_f + async def test_simple_builtin(self): + await self._test_simple_base(self.MW_SIMPLE) - def test_async_builtin(self): + @deferred_f_from_coro_f + async def test_async_builtin(self): """Upgrade""" - return self._test_asyncgen_base(self.MW_ASYNCGEN) + await self._test_asyncgen_base(self.MW_ASYNCGEN) - def test_universal_builtin(self): - return self._test_simple_base(self.MW_UNIVERSAL) + @deferred_f_from_coro_f + async def test_universal_builtin(self): + await self._test_simple_base(self.MW_UNIVERSAL) class TestBuiltinMiddlewareAsyncGen(TestBuiltinMiddlewareSimple): @@ -485,28 +492,35 @@ class TestBuiltinMiddlewareAsyncGen(TestBuiltinMiddlewareSimple): for item in super()._scrape_func(): yield item - def test_just_builtin(self): - return self._test_asyncgen_base() + @deferred_f_from_coro_f + async def test_just_builtin(self): + await self._test_asyncgen_base() - def test_builtin_simple(self): + @deferred_f_from_coro_f + async def test_builtin_simple(self): """Downgrade""" - return self._test_simple_base(self.MW_SIMPLE, downgrade=True, start_index=1000) + await self._test_simple_base(self.MW_SIMPLE, downgrade=True, start_index=1000) - def test_builtin_async(self): - return self._test_asyncgen_base(self.MW_ASYNCGEN, start_index=1000) + @deferred_f_from_coro_f + async def test_builtin_async(self): + await self._test_asyncgen_base(self.MW_ASYNCGEN, start_index=1000) - def test_builtin_universal(self): - return self._test_asyncgen_base(self.MW_UNIVERSAL, start_index=1000) + @deferred_f_from_coro_f + async def test_builtin_universal(self): + await self._test_asyncgen_base(self.MW_UNIVERSAL, start_index=1000) - def test_simple_builtin(self): + @deferred_f_from_coro_f + async def test_simple_builtin(self): """Downgrade""" - return self._test_simple_base(self.MW_SIMPLE, downgrade=True) + await self._test_simple_base(self.MW_SIMPLE, downgrade=True) - def test_async_builtin(self): - return self._test_asyncgen_base(self.MW_ASYNCGEN) + @deferred_f_from_coro_f + async def test_async_builtin(self): + await self._test_asyncgen_base(self.MW_ASYNCGEN) - def test_universal_builtin(self): - return self._test_asyncgen_base(self.MW_UNIVERSAL) + @deferred_f_from_coro_f + async def test_universal_builtin(self): + await self._test_asyncgen_base(self.MW_UNIVERSAL) class TestProcessSpiderException(TestBaseAsyncSpiderMiddleware): @@ -520,33 +534,38 @@ class TestProcessSpiderException(TestBaseAsyncSpiderMiddleware): def _scrape_func(self, *args, **kwargs): 1 / 0 - @inlineCallbacks - def _test_asyncgen_nodowngrade(self, *mw_classes): + async def _test_asyncgen_nodowngrade(self, *mw_classes): with pytest.raises( _InvalidOutput, match="Async iterable returned from .+ cannot be downgraded" ): - yield self._get_middleware_result(*mw_classes) + await self._get_middleware_result(*mw_classes) - def test_exc_simple(self): + @deferred_f_from_coro_f + async def test_exc_simple(self): """Simple exc mw""" - return self._test_simple_base(self.MW_EXC_SIMPLE) + await self._test_simple_base(self.MW_EXC_SIMPLE) - def test_exc_async(self): + @deferred_f_from_coro_f + async def test_exc_async(self): """Async exc mw""" - return self._test_asyncgen_base(self.MW_EXC_ASYNCGEN) + await self._test_asyncgen_base(self.MW_EXC_ASYNCGEN) - def test_exc_simple_simple(self): + @deferred_f_from_coro_f + async def test_exc_simple_simple(self): """Simple exc mw -> simple output mw""" - return self._test_simple_base(self.MW_SIMPLE, self.MW_EXC_SIMPLE) + await self._test_simple_base(self.MW_SIMPLE, self.MW_EXC_SIMPLE) - def test_exc_async_async(self): + @deferred_f_from_coro_f + async def test_exc_async_async(self): """Async exc mw -> async output mw""" - return self._test_asyncgen_base(self.MW_ASYNCGEN, self.MW_EXC_ASYNCGEN) + await self._test_asyncgen_base(self.MW_ASYNCGEN, self.MW_EXC_ASYNCGEN) - def test_exc_simple_async(self): + @deferred_f_from_coro_f + async def test_exc_simple_async(self): """Simple exc mw -> async output mw; upgrade""" - return self._test_asyncgen_base(self.MW_ASYNCGEN, self.MW_EXC_SIMPLE) + await self._test_asyncgen_base(self.MW_ASYNCGEN, self.MW_EXC_SIMPLE) - def test_exc_async_simple(self): + @deferred_f_from_coro_f + async def test_exc_async_simple(self): """Async exc mw -> simple output mw; cannot work as downgrading is not supported""" - return self._test_asyncgen_nodowngrade(self.MW_SIMPLE, self.MW_EXC_ASYNCGEN) + await self._test_asyncgen_nodowngrade(self.MW_SIMPLE, self.MW_EXC_ASYNCGEN) diff --git a/tests/test_spidermiddleware_output_chain.py b/tests/test_spidermiddleware_output_chain.py index 62ec1a624..60464d696 100644 --- a/tests/test_spidermiddleware_output_chain.py +++ b/tests/test_spidermiddleware_output_chain.py @@ -1,8 +1,8 @@ from testfixtures import LogCapture -from twisted.internet.defer import inlineCallbacks from twisted.trial.unittest import TestCase from scrapy import Request, Spider +from scrapy.utils.defer import deferred_f_from_coro_f, maybe_deferred_to_future from scrapy.utils.test import get_crawler from tests.mockserver import MockServer @@ -299,6 +299,8 @@ class NotGeneratorOutputChainSpider(Spider): # ================================================================================ class TestSpiderMiddleware(TestCase): + mockserver: MockServer + @classmethod def setUpClass(cls): cls.mockserver = MockServer() @@ -308,53 +310,52 @@ class TestSpiderMiddleware(TestCase): def tearDownClass(cls): cls.mockserver.__exit__(None, None, None) - @inlineCallbacks - def crawl_log(self, spider): + async def crawl_log(self, spider: type[Spider]) -> LogCapture: crawler = get_crawler(spider) with LogCapture() as log: - yield crawler.crawl(mockserver=self.mockserver) + await maybe_deferred_to_future(crawler.crawl(mockserver=self.mockserver)) return log - @inlineCallbacks - def test_recovery(self): + @deferred_f_from_coro_f + async def test_recovery(self): """ (0) Recover from an exception in a spider callback. The final item count should be 3 (one yielded from the callback method before the exception is raised, one directly from the recovery middleware and one from the spider when processing the request that was enqueued from the recovery middleware) """ - log = yield self.crawl_log(RecoverySpider) + log = await self.crawl_log(RecoverySpider) assert "Middleware: TabError exception caught" in str(log) assert str(log).count("Middleware: TabError exception caught") == 1 assert "'item_scraped_count': 3" in str(log) - @inlineCallbacks - def test_recovery_asyncgen(self): + @deferred_f_from_coro_f + async def test_recovery_asyncgen(self): """ Same as test_recovery but with an async callback. """ - log = yield self.crawl_log(RecoveryAsyncGenSpider) + log = await self.crawl_log(RecoveryAsyncGenSpider) assert "Middleware: TabError exception caught" in str(log) assert str(log).count("Middleware: TabError exception caught") == 1 assert "'item_scraped_count': 3" in str(log) - @inlineCallbacks - def test_process_spider_input_without_errback(self): + @deferred_f_from_coro_f + async def test_process_spider_input_without_errback(self): """ (1.1) An exception from the process_spider_input chain should be caught by the process_spider_exception chain from the start if the Request has no errback """ - log1 = yield self.crawl_log(ProcessSpiderInputSpiderWithoutErrback) + log1 = await self.crawl_log(ProcessSpiderInputSpiderWithoutErrback) assert "Middleware: will raise IndexError" in str(log1) assert "Middleware: IndexError exception caught" in str(log1) - @inlineCallbacks - def test_process_spider_input_with_errback(self): + @deferred_f_from_coro_f + 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 process_spider_exception chain if the Request has an errback """ - log1 = yield self.crawl_log(ProcessSpiderInputSpiderWithErrback) + log1 = await self.crawl_log(ProcessSpiderInputSpiderWithErrback) assert "Middleware: IndexError exception caught" not in str(log1) assert "Middleware: will raise IndexError" in str(log1) assert "Got a Failure on the Request errback" in str(log1) @@ -362,60 +363,60 @@ class TestSpiderMiddleware(TestCase): assert "{'from': 'callback'}" not in str(log1) assert "'item_scraped_count': 1" in str(log1) - @inlineCallbacks - def test_generator_callback(self): + @deferred_f_from_coro_f + async def test_generator_callback(self): """ (2) An exception from a spider callback (returning a generator) should be caught by the process_spider_exception chain. Items yielded before the exception is raised should be processed normally. """ - log2 = yield self.crawl_log(GeneratorCallbackSpider) + log2 = await self.crawl_log(GeneratorCallbackSpider) assert "Middleware: ImportError exception caught" in str(log2) assert "'item_scraped_count': 2" in str(log2) - @inlineCallbacks - def test_async_generator_callback(self): + @deferred_f_from_coro_f + async def test_async_generator_callback(self): """ Same as test_generator_callback but with an async callback. """ - log2 = yield self.crawl_log(AsyncGeneratorCallbackSpider) + log2 = await self.crawl_log(AsyncGeneratorCallbackSpider) assert "Middleware: ImportError exception caught" in str(log2) assert "'item_scraped_count': 2" in str(log2) - @inlineCallbacks - def test_generator_callback_right_after_callback(self): + @deferred_f_from_coro_f + async def test_generator_callback_right_after_callback(self): """ (2.1) Special case of (2): Exceptions should be caught even if the middleware is placed right after the spider """ - log21 = yield self.crawl_log(GeneratorCallbackSpiderMiddlewareRightAfterSpider) + log21 = await self.crawl_log(GeneratorCallbackSpiderMiddlewareRightAfterSpider) assert "Middleware: ImportError exception caught" in str(log21) assert "'item_scraped_count': 2" in str(log21) - @inlineCallbacks - def test_not_a_generator_callback(self): + @deferred_f_from_coro_f + async def test_not_a_generator_callback(self): """ (3) An exception from a spider callback (returning a list) should be caught by the process_spider_exception chain. No items should be processed. """ - log3 = yield self.crawl_log(NotGeneratorCallbackSpider) + log3 = await self.crawl_log(NotGeneratorCallbackSpider) assert "Middleware: ZeroDivisionError exception caught" in str(log3) assert "item_scraped_count" not in str(log3) - @inlineCallbacks - def test_not_a_generator_callback_right_after_callback(self): + @deferred_f_from_coro_f + async def test_not_a_generator_callback_right_after_callback(self): """ (3.1) Special case of (3): Exceptions should be caught even if the middleware is placed right after the spider """ - log31 = yield self.crawl_log( + log31 = await self.crawl_log( NotGeneratorCallbackSpiderMiddlewareRightAfterSpider ) assert "Middleware: ZeroDivisionError exception caught" in str(log31) assert "item_scraped_count" not in str(log31) - @inlineCallbacks - def test_generator_output_chain(self): + @deferred_f_from_coro_f + async def test_generator_output_chain(self): """ (4) 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. @@ -424,7 +425,7 @@ class TestSpiderMiddleware(TestCase): The final item count should be 2 (one from the spider callback and one from the process_spider_exception chain) """ - log4 = yield self.crawl_log(GeneratorOutputChainSpider) + log4 = await self.crawl_log(GeneratorOutputChainSpider) assert "'item_scraped_count': 2" in str(log4) assert ( "GeneratorRecoverMiddleware.process_spider_exception: LookupError caught" @@ -461,8 +462,8 @@ class TestSpiderMiddleware(TestCase): assert str(item_recovered) in str(log4) assert "parse-second-item" not in str(log4) - @inlineCallbacks - def test_not_a_generator_output_chain(self): + @deferred_f_from_coro_f + 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. @@ -471,7 +472,7 @@ class TestSpiderMiddleware(TestCase): The final item count should be 1 (from the process_spider_exception chain, the items from the spider callback are lost) """ - log5 = yield self.crawl_log(NotGeneratorOutputChainSpider) + log5 = await self.crawl_log(NotGeneratorOutputChainSpider) assert "'item_scraped_count': 1" in str(log5) assert ( "GeneratorRecoverMiddleware.process_spider_exception: ReferenceError caught" From b4d11b8b2565b5686c9f395ca1a8dd085609aafc Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Wed, 11 Jun 2025 04:28:09 +0500 Subject: [PATCH 038/459] Further reduce deps on unittest. (#6884) --- .../test_downloader_handler_twisted_http11.py | 10 +- .../test_downloader_handler_twisted_http2.py | 14 +- tests/test_downloader_handlers.py | 7 +- tests/test_downloadermiddleware_robotstxt.py | 4 +- tests/test_downloaderslotssettings.py | 2 +- tests/test_engine_loop.py | 4 +- tests/test_extension_telnet.py | 2 +- tests/test_feedexport.py | 43 +- tests/test_http2_client_protocol.py | 480 ++++++++---------- tests/test_pipeline_crawl.py | 15 +- tests/test_pipeline_files.py | 16 +- tests/test_pipeline_images.py | 17 +- tests/test_pipeline_media.py | 2 +- tests/test_scheduler_base.py | 6 +- tests/test_signals.py | 4 +- tests/test_spider_start.py | 2 +- tests/test_spidermiddleware_process_start.py | 4 +- tests/test_utils_defer.py | 6 +- tests/test_utils_signal.py | 16 +- tests/test_webclient.py | 89 ++-- 20 files changed, 355 insertions(+), 388 deletions(-) diff --git a/tests/test_downloader_handler_twisted_http11.py b/tests/test_downloader_handler_twisted_http11.py index 70f55e787..7b26ce03f 100644 --- a/tests/test_downloader_handler_twisted_http11.py +++ b/tests/test_downloader_handler_twisted_http11.py @@ -39,23 +39,21 @@ class TestSimpleHttps(HTTP11DownloadHandlerMixin, TestSimpleHttpsBase): pass -class Https11WrongHostnameTestCase( - HTTP11DownloadHandlerMixin, TestHttpsWrongHostnameBase -): +class TestHttps11WrongHostname(HTTP11DownloadHandlerMixin, TestHttpsWrongHostnameBase): pass -class Https11InvalidDNSId(HTTP11DownloadHandlerMixin, TestHttpsInvalidDNSIdBase): +class TestHttps11InvalidDNSId(HTTP11DownloadHandlerMixin, TestHttpsInvalidDNSIdBase): pass -class Https11InvalidDNSPattern( +class TestHttps11InvalidDNSPattern( HTTP11DownloadHandlerMixin, TestHttpsInvalidDNSPatternBase ): pass -class Https11CustomCiphers(HTTP11DownloadHandlerMixin, TestHttpsCustomCiphersBase): +class TestHttps11CustomCiphers(HTTP11DownloadHandlerMixin, TestHttpsCustomCiphersBase): pass diff --git a/tests/test_downloader_handler_twisted_http2.py b/tests/test_downloader_handler_twisted_http2.py index e058cedae..3e685bb28 100644 --- a/tests/test_downloader_handler_twisted_http2.py +++ b/tests/test_downloader_handler_twisted_http2.py @@ -163,23 +163,25 @@ class TestHttps2(H2DownloadHandlerMixin, TestHttps11Base): assert json.loads(response.text)["headers"][header] == [value1, value2] -class Https2WrongHostnameTestCase(H2DownloadHandlerMixin, TestHttpsWrongHostnameBase): +class TestHttps2WrongHostname(H2DownloadHandlerMixin, TestHttpsWrongHostnameBase): pass -class Https2InvalidDNSId(H2DownloadHandlerMixin, TestHttpsInvalidDNSIdBase): +class TestHttps2InvalidDNSId(H2DownloadHandlerMixin, TestHttpsInvalidDNSIdBase): pass -class Https2InvalidDNSPattern(H2DownloadHandlerMixin, TestHttpsInvalidDNSPatternBase): +class TestHttps2InvalidDNSPattern( + H2DownloadHandlerMixin, TestHttpsInvalidDNSPatternBase +): pass -class Https2CustomCiphers(H2DownloadHandlerMixin, TestHttpsCustomCiphersBase): +class TestHttps2CustomCiphers(H2DownloadHandlerMixin, TestHttpsCustomCiphersBase): pass -class Http2MockServerTestCase(TestHttpMockServerBase): +class TestHttp2MockServer(TestHttpMockServerBase): """HTTP 2.0 test case with MockServer""" @property @@ -193,7 +195,7 @@ class Http2MockServerTestCase(TestHttpMockServerBase): is_secure = True -class Https2ProxyTestCase(H2DownloadHandlerMixin, TestHttpProxyBase): +class TestHttps2Proxy(H2DownloadHandlerMixin, TestHttpProxyBase): # only used for HTTPS tests keyfile = "keys/localhost.key" certfile = "keys/localhost.crt" diff --git a/tests/test_downloader_handlers.py b/tests/test_downloader_handlers.py index 2c8e96040..518dc6b24 100644 --- a/tests/test_downloader_handlers.py +++ b/tests/test_downloader_handlers.py @@ -12,6 +12,7 @@ from unittest import mock import pytest from twisted.cred import checkers, credentials, portal +from twisted.internet.defer import inlineCallbacks from twisted.protocols.ftp import FTPFactory, FTPRealm from twisted.trial import unittest from w3lib.url import path_to_file_uri @@ -340,9 +341,10 @@ class TestFTPBase(unittest.TestCase): self.portNum = self.port.getHost().port crawler = get_crawler() self.download_handler = build_from_crawler(FTPDownloadHandler, crawler) - self.addCleanup(self.port.stopListening) + @inlineCallbacks def tearDown(self): + yield self.port.stopListening() shutil.rmtree(self.directory) def _add_test_callbacks(self, deferred, callback=None, errback=None): @@ -478,9 +480,10 @@ class TestAnonymousFTP(TestFTPBase): self.portNum = self.port.getHost().port crawler = get_crawler() self.download_handler = build_from_crawler(FTPDownloadHandler, crawler) - self.addCleanup(self.port.stopListening) + @inlineCallbacks def tearDown(self): + yield self.port.stopListening() shutil.rmtree(self.directory) diff --git a/tests/test_downloadermiddleware_robotstxt.py b/tests/test_downloadermiddleware_robotstxt.py index 146b0057e..dd5d47cab 100644 --- a/tests/test_downloadermiddleware_robotstxt.py +++ b/tests/test_downloadermiddleware_robotstxt.py @@ -247,10 +247,8 @@ Disallow: /some/randome/page.html assert request.callback == NO_CALLBACK +@pytest.mark.skipif(not rerp_available(), reason="Rerp parser is not installed") class TestRobotsTxtMiddlewareWithRerp(TestRobotsTxtMiddleware): - if not rerp_available(): - skip = "Rerp parser is not installed" - def setUp(self): super().setUp() self.crawler.settings.set( diff --git a/tests/test_downloaderslotssettings.py b/tests/test_downloaderslotssettings.py index 9b7c09448..ddac95edf 100644 --- a/tests/test_downloaderslotssettings.py +++ b/tests/test_downloaderslotssettings.py @@ -49,7 +49,7 @@ class DownloaderSlotsSettingsTestSpider(MetaSpider): self.times[slot].append(time.time()) -class CrawlTestCase(TestCase): +class TestCrawl(TestCase): @classmethod def setUpClass(cls): cls.mockserver = MockServer() diff --git a/tests/test_engine_loop.py b/tests/test_engine_loop.py index c7dbc82d4..bfb8eeced 100644 --- a/tests/test_engine_loop.py +++ b/tests/test_engine_loop.py @@ -27,7 +27,7 @@ async def sleep(seconds: float = 0.001) -> None: await maybe_deferred_to_future(deferred) -class MainTestCase(TestCase): +class TestMain(TestCase): @deferred_f_from_coro_f async def test_sleep(self): """Neither asynchronous sleeps on Spider.start() nor the equivalent on @@ -119,7 +119,7 @@ class MainTestCase(TestCase): assert actual_urls == expected_urls, f"{actual_urls=} != {expected_urls=}" -class RequestSendOrderTestCase(TestCase): +class TestRequestSendOrder(TestCase): seconds = 0.1 # increase if flaky @classmethod diff --git a/tests/test_extension_telnet.py b/tests/test_extension_telnet.py index 2ac4d7830..f9e54cb28 100644 --- a/tests/test_extension_telnet.py +++ b/tests/test_extension_telnet.py @@ -8,7 +8,7 @@ from scrapy.extensions.telnet import TelnetConsole from scrapy.utils.test import get_crawler -class TelnetExtensionTest(unittest.TestCase): +class TestTelnetExtension(unittest.TestCase): def _get_console_and_portal(self, settings=None): crawler = get_crawler(settings_dict=settings) console = TelnetConsole(crawler) diff --git a/tests/test_feedexport.py b/tests/test_feedexport.py index 262c0b434..01797fd20 100644 --- a/tests/test_feedexport.py +++ b/tests/test_feedexport.py @@ -91,52 +91,53 @@ def mock_google_cloud_storage() -> tuple[Any, Any, Any]: return (client_mock, bucket_mock, blob_mock) -# TODO: replace self.mktemp() and drop the unittest.TestCase base -class TestFileFeedStorage(unittest.TestCase): - def test_store_file_uri(self): - path = Path(self.mktemp()).resolve() +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): - path = Path(self.mktemp()).resolve() / "more" / "paths" / "file.txt" + 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): - path = Path(self.mktemp()).resolve() + 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): - path = Path(self.mktemp()) + def test_store_direct_path_relative(self, tmp_path): + path = (tmp_path / "foo" / "bar").relative_to(Path.cwd()) self._assert_stores(FileFeedStorage(str(path)), path) - def test_interface(self): - path = self.mktemp() - st = FileFeedStorage(path) + def test_interface(self, tmp_path): + path = tmp_path / "file.txt" + st = FileFeedStorage(str(path)) verifyObject(IFeedStorage, st) - def _store(self, feed_options=None) -> Path: - path = Path(self.mktemp()).resolve() + @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) - return path - def test_append(self): - path = self._store() + 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): - path = self._store({"overwrite": True}) + 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( - self, storage: FileFeedStorage, path: Path, expected_content: bytes = b"content" + storage: FileFeedStorage, path: Path, expected_content: bytes = b"content" ) -> None: spider = scrapy.Spider("default") file = storage.open(spider) diff --git a/tests/test_http2_client_protocol.py b/tests/test_http2_client_protocol.py index ef1806cc0..80edd50d6 100644 --- a/tests/test_http2_client_protocol.py +++ b/tests/test_http2_client_protocol.py @@ -8,7 +8,7 @@ import string from ipaddress import IPv4Address from pathlib import Path from tempfile import mkdtemp -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any, Callable from unittest import mock from urllib.parse import urlencode @@ -32,17 +32,22 @@ from twisted.web.static import File from scrapy.http import JsonRequest, Request, Response from scrapy.settings import Settings from scrapy.spiders import Spider +from scrapy.utils.defer import ( + deferred_f_from_coro_f, + deferred_from_coro, + maybe_deferred_to_future, +) from tests.mockserver import LeafResource, Status, ssl_context_factory if TYPE_CHECKING: - from twisted.python.failure import Failure + from collections.abc import Coroutine -def generate_random_string(size): +def generate_random_string(size: int) -> str: return "".join(random.choices(string.ascii_uppercase + string.digits, k=size)) -def make_html_body(val): +def make_html_body(val: str) -> bytes: response = f"""

Hello from HTTP2

{val}

@@ -92,7 +97,7 @@ class GetDataHtmlLarge(LeafResource): class PostDataJsonMixin: @staticmethod - def make_response(request: TxRequest, extra_data: str): + def make_response(request: TxRequest, extra_data: str) -> bytes: assert request.content is not None response = { "request-headers": {}, @@ -179,7 +184,6 @@ def get_client_certificate( pem = key_file.read_text(encoding="utf-8") + certificate_file.read_text( encoding="utf-8" ) - return PrivateCertificate.loadPEM(pem) @@ -238,6 +242,7 @@ class TestHttps2ClientProtocol(TestCase): uri = URI.fromBytes(bytes(self.get_url("/"), "utf-8")) self.conn_closed_deferred = Deferred() + from scrapy.core.http2.protocol import H2ClientFactory h2_client_factory = H2ClientFactory(uri, Settings(), self.conn_closed_deferred) @@ -255,7 +260,7 @@ class TestHttps2ClientProtocol(TestCase): shutil.rmtree(self.temp_directory) self.conn_closed_deferred = None - def get_url(self, path): + def get_url(self, path: str) -> str: """ :param path: Should have / at the starting compulsorily if not empty :return: Complete url @@ -264,143 +269,146 @@ class TestHttps2ClientProtocol(TestCase): assert path[0] == "/" or path[0] == "&" return f"{self.scheme}://{self.hostname}:{self.port_number}{path}" - def make_request(self, request: Request) -> Deferred: + async def make_request(self, request: Request) -> Response: + return await maybe_deferred_to_future(self.make_request_dfd(request)) + + def make_request_dfd(self, request: Request) -> Deferred[Response]: return self.client.request(request, DummySpider()) @staticmethod - def _check_repeat(get_deferred, count): + async def _check_repeat( + get_coro: Callable[[], Coroutine[Any, Any, None]], count: int + ) -> None: d_list = [] for _ in range(count): - d = get_deferred() + d = deferred_from_coro(get_coro()) d_list.append(d) - return DeferredList(d_list, fireOnOneErrback=True) + await maybe_deferred_to_future(DeferredList(d_list, fireOnOneErrback=True)) - def _check_GET(self, request: Request, expected_body, expected_status): - def check_response(response: Response): - assert response.status == expected_status - assert response.body == expected_body - assert response.request == request + async def _check_GET( + self, request: Request, expected_body: bytes, expected_status: int + ) -> None: + response = await self.make_request(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 - content_length = int(content_length_header) - assert len(response.body) == content_length + content_length_header = response.headers.get("Content-Length") + assert content_length_header is not None + content_length = int(content_length_header) + assert len(response.body) == content_length - d = self.make_request(request) - d.addCallback(check_response) - d.addErrback(self.fail) - return d - - def test_GET_small_body(self): + @deferred_f_from_coro_f + async def test_GET_small_body(self): request = Request(self.get_url("/get-data-html-small")) - return self._check_GET(request, Data.HTML_SMALL, 200) + await self._check_GET(request, Data.HTML_SMALL, 200) - def test_GET_large_body(self): + @deferred_f_from_coro_f + async def test_GET_large_body(self): request = Request(self.get_url("/get-data-html-large")) - return self._check_GET(request, Data.HTML_LARGE, 200) + await self._check_GET(request, Data.HTML_LARGE, 200) - def _check_GET_x10(self, *args, **kwargs): - def get_deferred(): - return self._check_GET(*args, **kwargs) + async def _check_GET_x10( + self, request: Request, expected_body: bytes, expected_status: int + ) -> None: + async def get_coro() -> None: + await self._check_GET(request, expected_body, expected_status) - return self._check_repeat(get_deferred, 10) + await self._check_repeat(get_coro, 10) - def test_GET_small_body_x10(self): - return self._check_GET_x10( + @deferred_f_from_coro_f + async def test_GET_small_body_x10(self): + await self._check_GET_x10( Request(self.get_url("/get-data-html-small")), Data.HTML_SMALL, 200 ) - def test_GET_large_body_x10(self): - return self._check_GET_x10( + @deferred_f_from_coro_f + async def test_GET_large_body_x10(self): + await self._check_GET_x10( Request(self.get_url("/get-data-html-large")), Data.HTML_LARGE, 200 ) - def _check_POST_json( + async def _check_POST_json( self, request: Request, - expected_request_body, - expected_extra_data, + expected_request_body: dict[str, str], + expected_extra_data: str, expected_status: int, - ): - d = self.make_request(request) + ) -> None: + response = await self.make_request(request) - def assert_response(response: Response): - assert response.status == expected_status - assert response.request == 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 - content_length = int(content_length_header) - assert len(response.body) == content_length + content_length_header = response.headers.get("Content-Length") + assert content_length_header is not None + content_length = int(content_length_header) + assert len(response.body) == content_length - # Parse the body - content_encoding_header = response.headers[b"Content-Encoding"] - assert content_encoding_header is not None - content_encoding = str(content_encoding_header, "utf-8") - body = json.loads(str(response.body, content_encoding)) - assert "request-body" in body - assert "extra-data" in body - assert "request-headers" in body + # Parse the body + content_encoding_header = response.headers[b"Content-Encoding"] + assert content_encoding_header is not None + content_encoding = str(content_encoding_header, "utf-8") + body = json.loads(str(response.body, content_encoding)) + assert "request-body" in body + assert "extra-data" in body + assert "request-headers" in body - request_body = body["request-body"] - assert request_body == expected_request_body + request_body = body["request-body"] + assert request_body == expected_request_body - extra_data = body["extra-data"] - assert extra_data == expected_extra_data + extra_data = body["extra-data"] + assert extra_data == expected_extra_data - # Check if headers were sent successfully - request_headers = body["request-headers"] - for k, v in request.headers.items(): - k_str = str(k, "utf-8") - assert k_str in request_headers - assert request_headers[k_str] == str(v[0], "utf-8") + # Check if headers were sent successfully + request_headers = body["request-headers"] + for k, v in request.headers.items(): + k_str = str(k, "utf-8") + assert k_str in request_headers + assert request_headers[k_str] == str(v[0], "utf-8") - d.addCallback(assert_response) - d.addErrback(self.fail) - return d - - def test_POST_small_json(self): + @deferred_f_from_coro_f + async def test_POST_small_json(self): request = JsonRequest( url=self.get_url("/post-data-json-small"), method="POST", data=Data.JSON_SMALL, ) - return self._check_POST_json(request, Data.JSON_SMALL, Data.EXTRA_SMALL, 200) + await self._check_POST_json(request, Data.JSON_SMALL, Data.EXTRA_SMALL, 200) - def test_POST_large_json(self): + @deferred_f_from_coro_f + async def test_POST_large_json(self): request = JsonRequest( url=self.get_url("/post-data-json-large"), method="POST", data=Data.JSON_LARGE, ) - return self._check_POST_json(request, Data.JSON_LARGE, Data.EXTRA_LARGE, 200) + await self._check_POST_json(request, Data.JSON_LARGE, Data.EXTRA_LARGE, 200) - def _check_POST_json_x10(self, *args, **kwargs): - def get_deferred(): - return self._check_POST_json(*args, **kwargs) + async def _check_POST_json_x10(self, *args, **kwargs): + async def get_coro() -> None: + await self._check_POST_json(*args, **kwargs) - return self._check_repeat(get_deferred, 10) + await self._check_repeat(get_coro, 10) - def test_POST_small_json_x10(self): + @deferred_f_from_coro_f + async def test_POST_small_json_x10(self): request = JsonRequest( url=self.get_url("/post-data-json-small"), method="POST", data=Data.JSON_SMALL, ) - return self._check_POST_json_x10( - request, Data.JSON_SMALL, Data.EXTRA_SMALL, 200 - ) + await self._check_POST_json_x10(request, Data.JSON_SMALL, Data.EXTRA_SMALL, 200) - def test_POST_large_json_x10(self): + @deferred_f_from_coro_f + async def test_POST_large_json_x10(self): request = JsonRequest( url=self.get_url("/post-data-json-large"), method="POST", data=Data.JSON_LARGE, ) - return self._check_POST_json_x10( - request, Data.JSON_LARGE, Data.EXTRA_LARGE, 200 - ) + await self._check_POST_json_x10(request, Data.JSON_LARGE, Data.EXTRA_LARGE, 200) @inlineCallbacks def test_invalid_negotiated_protocol(self): @@ -409,77 +417,59 @@ class TestHttps2ClientProtocol(TestCase): ): request = Request(url=self.get_url("/status?n=200")) with pytest.raises(ResponseFailed): - yield self.make_request(request) + yield self.make_request_dfd(request) + @inlineCallbacks def test_cancel_request(self): request = Request(url=self.get_url("/get-data-html-large")) - - def assert_response(response: Response): - assert response.status == 499 - assert response.request == request - - d = self.make_request(request) - d.addCallback(assert_response) - d.addErrback(self.fail) + d = self.make_request_dfd(request) d.cancel() + response = yield d + assert response.status == 499 + assert response.request == request - return d - - def test_download_maxsize_exceeded(self): + @deferred_f_from_coro_f + async def test_download_maxsize_exceeded(self): request = Request( url=self.get_url("/get-data-html-large"), meta={"download_maxsize": 1000} ) + with pytest.raises(CancelledError) as exc_info: + await self.make_request(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 - def assert_cancelled_error(failure): - assert isinstance(failure.value, CancelledError) - 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(failure.value))) == 1 - - d = self.make_request(request) - d.addCallback(self.fail) - d.addErrback(assert_cancelled_error) - return d - + @inlineCallbacks def test_received_dataloss_response(self): """In case when value of Header Content-Length != len(Received Data) ProtocolError is raised""" + from h2.exceptions import InvalidBodyLengthError + request = Request(url=self.get_url("/dataloss")) + with pytest.raises(ResponseFailed) as exc_info: + yield self.make_request_dfd(request) + assert len(exc_info.value.reasons) > 0 + assert any( + isinstance(error, InvalidBodyLengthError) + for error in exc_info.value.reasons + ) - def assert_failure(failure: Failure): - assert len(failure.value.reasons) > 0 - from h2.exceptions import InvalidBodyLengthError - - assert any( - isinstance(error, InvalidBodyLengthError) - for error in failure.value.reasons - ) - - d = self.make_request(request) - d.addCallback(self.fail) - d.addErrback(assert_failure) - return d - - def test_missing_content_length_header(self): + @deferred_f_from_coro_f + async def test_missing_content_length_header(self): request = Request(url=self.get_url("/no-content-length-header")) + response = await self.make_request(request) + assert response.status == 200 + assert response.body == Data.NO_CONTENT_LENGTH + assert response.request == request + assert "Content-Length" not in response.headers - def assert_content_length(response: Response): - assert response.status == 200 - assert response.body == Data.NO_CONTENT_LENGTH - assert response.request == request - assert "Content-Length" not in response.headers - - d = self.make_request(request) - d.addCallback(assert_content_length) - d.addErrback(self.fail) - return d - - @inlineCallbacks - def _check_log_warnsize(self, request, warn_pattern, expected_body): + async def _check_log_warnsize( + self, request: Request, warn_pattern: re.Pattern[str], expected_body: bytes + ) -> None: with self.assertLogs("scrapy.core.http2.stream", level="WARNING") as cm: - response = yield self.make_request(request) + response = await self.make_request(request) assert response.status == 200 assert response.request == request assert response.body == expected_body @@ -487,8 +477,8 @@ class TestHttps2ClientProtocol(TestCase): # Check the warning is raised only once for this request assert sum(len(re.findall(warn_pattern, log)) for log in cm.output) == 1 - @inlineCallbacks - def test_log_expected_warnsize(self): + @deferred_f_from_coro_f + async def test_log_expected_warnsize(self): request = Request( url=self.get_url("/get-data-html-large"), meta={"download_warnsize": 1000} ) @@ -497,10 +487,10 @@ class TestHttps2ClientProtocol(TestCase): rf"download warn size \(1000\) in request {request}" ) - yield self._check_log_warnsize(request, warn_pattern, Data.HTML_LARGE) + await self._check_log_warnsize(request, warn_pattern, Data.HTML_LARGE) - @inlineCallbacks - def test_log_received_warnsize(self): + @deferred_f_from_coro_f + async def test_log_received_warnsize(self): request = Request( url=self.get_url("/no-content-length-header"), meta={"download_warnsize": 10}, @@ -510,20 +500,22 @@ class TestHttps2ClientProtocol(TestCase): rf"warn size \(10\) in request {request}" ) - yield self._check_log_warnsize(request, warn_pattern, Data.NO_CONTENT_LENGTH) + await self._check_log_warnsize(request, warn_pattern, Data.NO_CONTENT_LENGTH) - def test_max_concurrent_streams(self): + @deferred_f_from_coro_f + async def test_max_concurrent_streams(self): """Send 500 requests at one to check if we can handle very large number of request. """ - def get_deferred(): - return self._check_GET( + async def get_coro() -> None: + await self._check_GET( Request(self.get_url("/get-data-html-small")), Data.HTML_SMALL, 200 ) - return self._check_repeat(get_deferred, 500) + await self._check_repeat(get_coro, 500) + @inlineCallbacks def test_inactive_stream(self): """Here we send 110 requests considering the MAX_CONCURRENT_STREAMS by default is 100. After sending the first 100 requests we close the @@ -532,6 +524,7 @@ class TestHttps2ClientProtocol(TestCase): def assert_inactive_stream(failure): assert failure.check(ResponseFailed) is not None + from scrapy.core.http2.stream import InactiveStreamClosed assert any( @@ -540,14 +533,14 @@ class TestHttps2ClientProtocol(TestCase): # Send 100 request (we do not check the result) for _ in range(100): - d = self.make_request(Request(self.get_url("/get-data-html-small"))) + d = self.make_request_dfd(Request(self.get_url("/get-data-html-small"))) d.addBoth(lambda _: None) d_list.append(d) # Now send 10 extra request and save the response deferred in a list for _ in range(10): - d = self.make_request(Request(self.get_url("/get-data-html-small"))) - d.addCallback(self.fail) + d = self.make_request_dfd(Request(self.get_url("/get-data-html-small"))) + d.addCallback(lambda _: pytest.fail("This request should have failed")) d.addErrback(assert_inactive_stream) d_list.append(d) @@ -555,13 +548,15 @@ class TestHttps2ClientProtocol(TestCase): # with InactiveStreamClosed self.client.transport.loseConnection() - return DeferredList(d_list, consumeErrors=True, fireOnOneErrback=True) + yield DeferredList(d_list, consumeErrors=True, fireOnOneErrback=True) - def test_invalid_request_type(self): + @deferred_f_from_coro_f + async def test_invalid_request_type(self): with pytest.raises(TypeError): - self.make_request("https://InvalidDataTypePassed.com") + await self.make_request("https://InvalidDataTypePassed.com") - def test_query_parameters(self): + @deferred_f_from_coro_f + async def test_query_parameters(self): params = { "a": generate_random_string(20), "b": generate_random_string(20), @@ -569,133 +564,96 @@ class TestHttps2ClientProtocol(TestCase): "d": generate_random_string(20), } request = Request(self.get_url(f"/query-params?{urlencode(params)}")) + response = await self.make_request(request) + content_encoding_header = response.headers[b"Content-Encoding"] + assert content_encoding_header is not None + content_encoding = str(content_encoding_header, "utf-8") + data = json.loads(str(response.body, content_encoding)) + assert data == params - def assert_query_params(response: Response): - content_encoding_header = response.headers[b"Content-Encoding"] - assert content_encoding_header is not None - content_encoding = str(content_encoding_header, "utf-8") - data = json.loads(str(response.body, content_encoding)) - assert data == params - - d = self.make_request(request) - d.addCallback(assert_query_params) - d.addErrback(self.fail) - - return d - - def test_status_codes(self): - def assert_response_status(response: Response, expected_status: int): - assert response.status == expected_status - - d_list = [] + @deferred_f_from_coro_f + async def test_status_codes(self): for status in [200, 404]: request = Request(self.get_url(f"/status?n={status}")) - d = self.make_request(request) - d.addCallback(assert_response_status, status) - d.addErrback(self.fail) - d_list.append(d) + response = await self.make_request(request) + assert response.status == status - return DeferredList(d_list, fireOnOneErrback=True) - - def test_response_has_correct_certificate_ip_address(self): + @deferred_f_from_coro_f + async def test_response_has_correct_certificate_ip_address(self): request = Request(self.get_url("/status?n=200")) + response = await self.make_request(request) + assert response.request == request + assert isinstance(response.certificate, Certificate) + assert response.certificate.original is not None + assert response.certificate.getIssuer() == self.client_certificate.getIssuer() + assert response.certificate.getPublicKey().matches( + self.client_certificate.getPublicKey() + ) + assert isinstance(response.ip_address, IPv4Address) + assert str(response.ip_address) == "127.0.0.1" - def assert_metadata(response: Response): - assert response.request == request - assert isinstance(response.certificate, Certificate) - assert response.certificate.original is not None - assert ( - response.certificate.getIssuer() == self.client_certificate.getIssuer() - ) - assert response.certificate.getPublicKey().matches( - self.client_certificate.getPublicKey() - ) + async def _check_invalid_netloc(self, url: str) -> None: + from scrapy.core.http2.stream import InvalidHostname - assert isinstance(response.ip_address, IPv4Address) - assert str(response.ip_address) == "127.0.0.1" - - d = self.make_request(request) - d.addCallback(assert_metadata) - d.addErrback(self.fail) - - return d - - def _check_invalid_netloc(self, url): request = Request(url) + with pytest.raises(InvalidHostname) as exc_info: + await self.make_request(request) + error_msg = str(exc_info.value) + assert "localhost" in error_msg + assert "127.0.0.1" in error_msg + assert str(request) in error_msg - def assert_invalid_hostname(failure: Failure): - from scrapy.core.http2.stream import InvalidHostname + @deferred_f_from_coro_f + async def test_invalid_hostname(self): + await self._check_invalid_netloc("https://notlocalhost.notlocalhostdomain") - assert failure.check(InvalidHostname) is not None - error_msg = str(failure.value) - assert "localhost" in error_msg - assert "127.0.0.1" in error_msg - assert str(request) in error_msg - - d = self.make_request(request) - d.addCallback(self.fail) - d.addErrback(assert_invalid_hostname) - return d - - def test_invalid_hostname(self): - return self._check_invalid_netloc("https://notlocalhost.notlocalhostdomain") - - def test_invalid_host_port(self): + @deferred_f_from_coro_f + async def test_invalid_host_port(self): port = self.port_number + 1 - return self._check_invalid_netloc(f"https://127.0.0.1:{port}") + await self._check_invalid_netloc(f"https://127.0.0.1:{port}") - def test_connection_stays_with_invalid_requests(self): - d_list = [ - self.test_invalid_hostname(), - self.test_invalid_host_port(), - self.test_GET_small_body(), - self.test_POST_small_json(), - ] - - return DeferredList(d_list, fireOnOneErrback=True) + @deferred_f_from_coro_f + async def test_connection_stays_with_invalid_requests(self): + await maybe_deferred_to_future(self.test_invalid_hostname()) + await maybe_deferred_to_future(self.test_invalid_host_port()) + await maybe_deferred_to_future(self.test_GET_small_body()) + await maybe_deferred_to_future(self.test_POST_small_json()) + @inlineCallbacks def test_connection_timeout(self): request = Request(self.get_url("/timeout")) - d = self.make_request(request) # Update the timer to 1s to test connection timeout self.client.setTimeout(1) - def assert_timeout_error(failure: Failure): - for err in failure.value.reasons: - from scrapy.core.http2.protocol import H2ClientProtocol + with pytest.raises(ResponseFailed) as exc_info: + yield self.make_request_dfd(request) - if isinstance(err, TimeoutError): - assert ( - f"Connection was IDLE for more than {H2ClientProtocol.IDLE_TIMEOUT}s" - in str(err) - ) - break - else: - pytest.fail("No TimeoutError raised.") + for err in exc_info.value.reasons: + from scrapy.core.http2.protocol import H2ClientProtocol - d.addCallback(self.fail) - d.addErrback(assert_timeout_error) - return d + if isinstance(err, TimeoutError): + assert ( + f"Connection was IDLE for more than {H2ClientProtocol.IDLE_TIMEOUT}s" + in str(err) + ) + break + else: + pytest.fail("No TimeoutError raised.") - def test_request_headers_received(self): + @deferred_f_from_coro_f + async def test_request_headers_received(self): request = Request( self.get_url("/request-headers"), headers={"header-1": "header value 1", "header-2": "header value 2"}, ) - d = self.make_request(request) + response = await self.make_request(request) + assert response.status == 200 + assert response.request == request - def assert_request_headers(response: Response): - assert response.status == 200 - assert response.request == request - - response_headers = json.loads(str(response.body, "utf-8")) - assert isinstance(response_headers, dict) - for k, v in request.headers.items(): - k, v = str(k, "utf-8"), str(v[0], "utf-8") - assert k in response_headers - assert v == response_headers[k] - - d.addErrback(self.fail) - d.addCallback(assert_request_headers) - return d + response_headers = json.loads(str(response.body, "utf-8")) + assert isinstance(response_headers, dict) + for k, v in request.headers.items(): + k, v = str(k, "utf-8"), str(v[0], "utf-8") + assert k in response_headers + assert v == response_headers[k] diff --git a/tests/test_pipeline_crawl.py b/tests/test_pipeline_crawl.py index 00e534c4b..cf827e481 100644 --- a/tests/test_pipeline_crawl.py +++ b/tests/test_pipeline_crawl.py @@ -5,6 +5,7 @@ from pathlib import Path from tempfile import mkdtemp from typing import TYPE_CHECKING, Any +import pytest from testfixtures import LogCapture from twisted.internet.defer import inlineCallbacks from twisted.trial.unittest import TestCase @@ -218,18 +219,20 @@ class TestFileDownloadCrawl(TestCase): assert "ZeroDivisionError" in str(log) -skip_pillow: str | None +pillow_available: bool try: from PIL import Image # noqa: F401 except ImportError: - skip_pillow = "Missing Python Imaging Library, install https://pypi.org/pypi/Pillow" + pillow_available = False else: - skip_pillow = None + pillow_available = True -class ImageDownloadCrawlTestCase(TestFileDownloadCrawl): - skip = skip_pillow - +@pytest.mark.skipif( + not pillow_available, + reason="Missing Python Imaging Library, install https://pypi.org/pypi/Pillow", +) +class TestImageDownloadCrawl(TestFileDownloadCrawl): pipeline_class = "scrapy.pipelines.images.ImagesPipeline" store_setting_key = "IMAGES_STORE" media_key = "images" diff --git a/tests/test_pipeline_files.py b/tests/test_pipeline_files.py index 492409d02..b4eae108f 100644 --- a/tests/test_pipeline_files.py +++ b/tests/test_pipeline_files.py @@ -3,6 +3,7 @@ import os import random import time import warnings +from abc import ABC, abstractmethod from datetime import datetime from io import BytesIO from pathlib import Path @@ -265,7 +266,12 @@ class TestFilesPipeline(unittest.TestCase): assert file_path(request, item=item) == "full/path-to-store-file" -class FilesPipelineTestCaseFieldsMixin: +class TestFilesPipelineFieldsMixin(ABC): + @property + @abstractmethod + def item_class(self) -> Any: + raise NotImplementedError + def test_item_fields_default(self, tmp_path): url = "http://www.example.com/files/1.txt" item = self.item_class(name="item1", file_urls=[url]) @@ -302,7 +308,7 @@ class FilesPipelineTestCaseFieldsMixin: assert isinstance(item, self.item_class) -class TestFilesPipelineFieldsDict(FilesPipelineTestCaseFieldsMixin): +class TestFilesPipelineFieldsDict(TestFilesPipelineFieldsMixin): item_class = dict @@ -316,7 +322,7 @@ class FilesPipelineTestItem(Item): custom_files = Field() -class TestFilesPipelineFieldsItem(FilesPipelineTestCaseFieldsMixin): +class TestFilesPipelineFieldsItem(TestFilesPipelineFieldsMixin): item_class = FilesPipelineTestItem @@ -331,7 +337,7 @@ class FilesPipelineTestDataClass: custom_files: list = dataclasses.field(default_factory=list) -class TestFilesPipelineFieldsDataClass(FilesPipelineTestCaseFieldsMixin): +class TestFilesPipelineFieldsDataClass(TestFilesPipelineFieldsMixin): item_class = FilesPipelineTestDataClass @@ -346,7 +352,7 @@ class FilesPipelineTestAttrsItem: custom_files: list[dict[str, str]] = attr.ib(default=list) -class TestFilesPipelineFieldsAttrsItem(FilesPipelineTestCaseFieldsMixin): +class TestFilesPipelineFieldsAttrsItem(TestFilesPipelineFieldsMixin): item_class = FilesPipelineTestAttrsItem diff --git a/tests/test_pipeline_images.py b/tests/test_pipeline_images.py index f2ee18bd9..74b4495ad 100644 --- a/tests/test_pipeline_images.py +++ b/tests/test_pipeline_images.py @@ -3,8 +3,10 @@ from __future__ import annotations import dataclasses import io import random +from abc import ABC, abstractmethod from shutil import rmtree from tempfile import mkdtemp +from typing import Any import attr import pytest @@ -208,7 +210,12 @@ class TestImagesPipeline: assert converted.getcolors() == [(10000, (205, 230, 255))] -class ImagesPipelineTestCaseFieldsMixin: +class TestImagesPipelineFieldsMixin(ABC): + @property + @abstractmethod + def item_class(self) -> Any: + raise NotImplementedError + def test_item_fields_default(self): url = "http://www.example.com/images/1.jpg" item = self.item_class(name="item1", image_urls=[url]) @@ -245,7 +252,7 @@ class ImagesPipelineTestCaseFieldsMixin: assert isinstance(item, self.item_class) -class TestImagesPipelineFieldsDict(ImagesPipelineTestCaseFieldsMixin): +class TestImagesPipelineFieldsDict(TestImagesPipelineFieldsMixin): item_class = dict @@ -259,7 +266,7 @@ class ImagesPipelineTestItem(Item): custom_images = Field() -class TestImagesPipelineFieldsItem(ImagesPipelineTestCaseFieldsMixin): +class TestImagesPipelineFieldsItem(TestImagesPipelineFieldsMixin): item_class = ImagesPipelineTestItem @@ -274,7 +281,7 @@ class ImagesPipelineTestDataClass: custom_images: list = dataclasses.field(default_factory=list) -class TestImagesPipelineFieldsDataClass(ImagesPipelineTestCaseFieldsMixin): +class TestImagesPipelineFieldsDataClass(TestImagesPipelineFieldsMixin): item_class = ImagesPipelineTestDataClass @@ -289,7 +296,7 @@ class ImagesPipelineTestAttrsItem: custom_images: list[dict[str, str]] = attr.ib(default=list) -class TestImagesPipelineFieldsAttrsItem(ImagesPipelineTestCaseFieldsMixin): +class TestImagesPipelineFieldsAttrsItem(TestImagesPipelineFieldsMixin): item_class = ImagesPipelineTestAttrsItem diff --git a/tests/test_pipeline_media.py b/tests/test_pipeline_media.py index 2d0db6e25..40149f184 100644 --- a/tests/test_pipeline_media.py +++ b/tests/test_pipeline_media.py @@ -345,7 +345,7 @@ class TestMediaPipeline(TestBaseMediaPipeline): @inlineCallbacks def test_use_media_to_download_result(self): - req = Request("http://url", meta={"result": "ITSME", "response": self.fail}) + req = Request("http://url", meta={"result": "ITSME"}) item = {"requests": req} new_item = yield self.pipe.process_item(item, self.spider) assert new_item["results"] == [(True, "ITSME")] diff --git a/tests/test_scheduler_base.py b/tests/test_scheduler_base.py index 5c2772c30..26482fc8d 100644 --- a/tests/test_scheduler_base.py +++ b/tests/test_scheduler_base.py @@ -115,7 +115,7 @@ class TestMinimalScheduler(InterfaceCheckMixin): assert not self.scheduler.has_pending_requests() -class SimpleSchedulerTest(TestCase, InterfaceCheckMixin): +class TestSimpleScheduler(TestCase, InterfaceCheckMixin): def setUp(self): self.scheduler = SimpleScheduler() @@ -145,7 +145,7 @@ class SimpleSchedulerTest(TestCase, InterfaceCheckMixin): assert close_result == "close" -class MinimalSchedulerCrawlTest(TestCase): +class TestMinimalSchedulerCrawl(TestCase): scheduler_cls = MinimalScheduler @inlineCallbacks @@ -162,5 +162,5 @@ class MinimalSchedulerCrawlTest(TestCase): assert f"'item_scraped_count': {len(PATHS)}" in str(log) -class SimpleSchedulerCrawlTest(MinimalSchedulerCrawlTest): +class TestSimpleSchedulerCrawl(TestMinimalSchedulerCrawl): scheduler_cls = SimpleScheduler diff --git a/tests/test_signals.py b/tests/test_signals.py index 5a536896e..b20a949e8 100644 --- a/tests/test_signals.py +++ b/tests/test_signals.py @@ -21,7 +21,7 @@ class ItemSpider(Spider): return {"index": response.meta["index"]} -class MainTestCase(TestCase): +class TestMain(TestCase): @deferred_f_from_coro_f async def test_scheduler_empty(self): crawler = get_crawler() @@ -35,7 +35,7 @@ class MainTestCase(TestCase): assert len(calls) >= 1 -class MockServerTestCase(TestCase): +class TestMockServer(TestCase): @classmethod def setUpClass(cls): cls.mockserver = MockServer() diff --git a/tests/test_spider_start.py b/tests/test_spider_start.py index 1815aad76..3c7fc65d5 100644 --- a/tests/test_spider_start.py +++ b/tests/test_spider_start.py @@ -18,7 +18,7 @@ ITEM_A = {"id": "a"} ITEM_B = {"id": "b"} -class MainTestCase(TestCase): +class TestMain(TestCase): async def _test_spider(self, spider, expected_items=None): actual_items = [] expected_items = [] if expected_items is None else expected_items diff --git a/tests/test_spidermiddleware_process_start.py b/tests/test_spidermiddleware_process_start.py index 725833a49..e1c8b5fec 100644 --- a/tests/test_spidermiddleware_process_start.py +++ b/tests/test_spidermiddleware_process_start.py @@ -47,7 +47,7 @@ class UniversalSpiderMiddleware: raise NotImplementedError -# Spiders and spider middlewares for MainTestCase._test_wrap +# Spiders and spider middlewares for TestMain._test_wrap class ModernWrapSpider(Spider): @@ -106,7 +106,7 @@ class DeprecatedWrapSpiderMiddleware: yield ITEM_C -class MainTestCase(TestCase): +class TestMain(TestCase): async def _test(self, spider_middlewares, spider_cls, expected_items): actual_items = [] diff --git a/tests/test_utils_defer.py b/tests/test_utils_defer.py index 372213319..1cfaf70fa 100644 --- a/tests/test_utils_defer.py +++ b/tests/test_utils_defer.py @@ -92,10 +92,10 @@ class TestDeferUtils(unittest.TestCase): x = yield process_parallel([cb1, cb2, cb3], "res", "v1", "v2") assert x == ["(cb1 res v1 v2)", "(cb2 res v1 v2)", "(cb3 res v1 v2)"] + @inlineCallbacks def test_process_parallel_failure(self): - d = process_parallel([cb1, cb_fail, cb3], "res", "v1", "v2") - self.failUnlessFailure(d, TypeError) - return d + with pytest.raises(TypeError): + yield process_parallel([cb1, cb_fail, cb3], "res", "v1", "v2") class TestIterErrback: diff --git a/tests/test_utils_signal.py b/tests/test_utils_signal.py index 97dade26e..79bac8bc5 100644 --- a/tests/test_utils_signal.py +++ b/tests/test_utils_signal.py @@ -59,12 +59,12 @@ class TestSendCatchLog(unittest.TestCase): return "OK" -class SendCatchLogDeferredTest(TestSendCatchLog): +class TestSendCatchLogDeferred(TestSendCatchLog): def _get_result(self, signal, *a, **kw): return send_catch_log_deferred(signal, *a, **kw) -class SendCatchLogDeferredTest2(SendCatchLogDeferredTest): +class TestSendCatchLogDeferred2(TestSendCatchLogDeferred): def ok_handler(self, arg, handlers_called): from twisted.internet import reactor @@ -76,7 +76,7 @@ class SendCatchLogDeferredTest2(SendCatchLogDeferredTest): @pytest.mark.usefixtures("reactor_pytest") -class SendCatchLogDeferredAsyncDefTest(SendCatchLogDeferredTest): +class TestSendCatchLogDeferredAsyncDef(TestSendCatchLogDeferred): async def ok_handler(self, arg, handlers_called): handlers_called.add(self.ok_handler) assert arg == "test" @@ -85,7 +85,7 @@ class SendCatchLogDeferredAsyncDefTest(SendCatchLogDeferredTest): @pytest.mark.only_asyncio -class SendCatchLogDeferredAsyncioTest(SendCatchLogDeferredTest): +class TestSendCatchLogDeferredAsyncio(TestSendCatchLogDeferred): async def ok_handler(self, arg, handlers_called): handlers_called.add(self.ok_handler) assert arg == "test" @@ -93,12 +93,12 @@ class SendCatchLogDeferredAsyncioTest(SendCatchLogDeferredTest): return await get_from_asyncio_queue("OK") -class SendCatchLogAsyncTest(TestSendCatchLog): +class TestSendCatchLogAsync(TestSendCatchLog): def _get_result(self, signal, *a, **kw): return deferred_from_coro(send_catch_log_async(signal, *a, **kw)) -class SendCatchLogAsyncTest2(SendCatchLogAsyncTest): +class TestSendCatchLogAsync2(TestSendCatchLogAsync): def ok_handler(self, arg, handlers_called): from twisted.internet import reactor @@ -110,7 +110,7 @@ class SendCatchLogAsyncTest2(SendCatchLogAsyncTest): @pytest.mark.usefixtures("reactor_pytest") -class SendCatchLogAsyncAsyncDefTest(SendCatchLogAsyncTest): +class TestSendCatchLogAsyncAsyncDef(TestSendCatchLogAsync): async def ok_handler(self, arg, handlers_called): handlers_called.add(self.ok_handler) assert arg == "test" @@ -119,7 +119,7 @@ class SendCatchLogAsyncAsyncDefTest(SendCatchLogAsyncTest): @pytest.mark.only_asyncio -class SendCatchLogAsyncAsyncioTest(SendCatchLogAsyncTest): +class TestSendCatchLogAsyncAsyncio(TestSendCatchLogAsync): async def ok_handler(self, arg, handlers_called): handlers_called.add(self.ok_handler) assert arg == "test" diff --git a/tests/test_webclient.py b/tests/test_webclient.py index 8b32e40bb..569f4f639 100644 --- a/tests/test_webclient.py +++ b/tests/test_webclient.py @@ -234,35 +234,31 @@ class TestWebClient(unittest.TestCase): def getURL(self, path): return f"http://127.0.0.1:{self.portno}/{path}" + @inlineCallbacks def testPayload(self): s = "0123456789" * 10 - return getPage(self.getURL("payload"), body=s).addCallback( - self.assertEqual, to_bytes(s) - ) + body = yield getPage(self.getURL("payload"), body=s) + assert body == to_bytes(s) + @inlineCallbacks def testHostHeader(self): # if we pass Host header explicitly, it should be used, otherwise # it should extract from url - return defer.gatherResults( - [ - getPage(self.getURL("host")).addCallback( - self.assertEqual, to_bytes(f"127.0.0.1:{self.portno}") - ), - getPage( - self.getURL("host"), headers={"Host": "www.example.com"} - ).addCallback(self.assertEqual, to_bytes("www.example.com")), - ] - ) + body = yield getPage(self.getURL("host")) + assert body == to_bytes(f"127.0.0.1:{self.portno}") + body = yield getPage(self.getURL("host"), headers={"Host": "www.example.com"}) + assert body == to_bytes("www.example.com") + @inlineCallbacks def test_getPage(self): """ 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. """ - d = getPage(self.getURL("file")) - d.addCallback(self.assertEqual, b"0123456789") - return d + body = yield getPage(self.getURL("file")) + assert body == b"0123456789" + @inlineCallbacks def test_getPageHead(self): """ L{client.getPage} returns a L{Deferred} which is called back with @@ -273,22 +269,20 @@ class TestWebClient(unittest.TestCase): def _getPage(method): return getPage(self.getURL("file"), method=method) - return defer.gatherResults( - [ - _getPage("head").addCallback(self.assertEqual, b""), - _getPage("HEAD").addCallback(self.assertEqual, b""), - ] - ) + body = yield _getPage("head") + assert body == b"" + body = yield _getPage("HEAD") + assert body == b"" + @inlineCallbacks def test_timeoutNotTriggering(self): """ 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. """ - d = getPage(self.getURL("host"), timeout=100) - d.addCallback(self.assertEqual, to_bytes(f"127.0.0.1:{self.portno}")) - return d + body = yield getPage(self.getURL("host"), timeout=100) + assert body == to_bytes(f"127.0.0.1:{self.portno}") @inlineCallbacks def test_timeoutTriggering(self): @@ -307,12 +301,12 @@ class TestWebClient(unittest.TestCase): if connected: connected[0].transport.loseConnection() + @inlineCallbacks def testNotFound(self): - return getPage(self.getURL("notsuchfile")).addCallback(self._cbNoSuchFile) - - def _cbNoSuchFile(self, pageData): - assert b"404 - No Such Resource" in pageData + body = yield getPage(self.getURL("notsuchfile")) + assert b"404 - No Such Resource" in body + @inlineCallbacks def testFactoryInfo(self): from twisted.internet import reactor @@ -320,63 +314,60 @@ class TestWebClient(unittest.TestCase): parsed = urlparse(url) factory = client.ScrapyHTTPClientFactory(Request(url)) reactor.connectTCP(parsed.hostname, parsed.port, factory) - return factory.deferred.addCallback(self._cbFactoryInfo, factory) - - def _cbFactoryInfo(self, ignoredResult, 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): - return getPage(self.getURL("redirect")).addCallback(self._cbRedirect) - - def _cbRedirect(self, pageData): + body = yield getPage(self.getURL("redirect")) assert ( - pageData + body == b'\n\n \n \n' b' \n \n ' b'
click here\n \n\n' ) + @inlineCallbacks def test_encoding(self): """Test that non-standart body encoding matches Content-Encoding header""" - body = b"\xd0\x81\xd1\x8e\xd0\xaf" - dfd = getPage( - self.getURL("encoding"), body=body, response_transform=lambda r: r + original_body = b"\xd0\x81\xd1\x8e\xd0\xaf" + response = yield getPage( + self.getURL("encoding"), body=original_body, response_transform=lambda r: r ) - return dfd.addCallback(self._check_Encoding, body) - - def _check_Encoding(self, response, original_body): 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 WebClientSSLTestCase(TestContextFactoryBase): +class TestWebClientSSL(TestContextFactoryBase): + @inlineCallbacks def testPayload(self): s = "0123456789" * 10 - return getPage(self.getURL("payload"), body=s).addCallback( - self.assertEqual, to_bytes(s) - ) + body = yield getPage(self.getURL("payload"), body=s) + assert body == to_bytes(s) -class WebClientCustomCiphersSSLTestCase(WebClientSSLTestCase): +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): s = "0123456789" * 10 crawler = get_crawler( settings_dict={"DOWNLOADER_CLIENT_TLS_CIPHERS": self.custom_ciphers} ) client_context_factory = build_from_crawler(ScrapyClientContextFactory, crawler) - return getPage( + body = yield getPage( self.getURL("payload"), body=s, contextFactory=client_context_factory - ).addCallback(self.assertEqual, to_bytes(s)) + ) + assert body == to_bytes(s) @inlineCallbacks def testPayloadDisabledCipher(self): From 92c18d15b4dbed7c98dc3d5ac329c90abb23950f Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Fri, 13 Jun 2025 22:18:27 +0500 Subject: [PATCH 039/459] Remove ProcessTest and SiteTest. (#6885) * Remove ProcessTest and SiteTest. * Restore the support for Windows line endings in TestParseCommand. * Add a test for running a scrapy command in a project subdir. * Remove pywin32 from test deps. --- tests/mockserver.py | 43 +++- tests/test_command_check.py | 6 +- tests/test_command_fetch.py | 46 ++-- tests/test_command_parse.py | 379 ++++++++++++++--------------- tests/test_command_runspider.py | 17 +- tests/test_command_shell.py | 153 ++++++------ tests/test_command_startproject.py | 4 +- tests/test_command_version.py | 26 +- tests/test_commands.py | 49 ++-- tests/utils/testproc.py | 67 ----- tests/utils/testsite.py | 47 ---- tox.ini | 1 - 12 files changed, 360 insertions(+), 478 deletions(-) delete mode 100644 tests/utils/testproc.py delete mode 100644 tests/utils/testsite.py diff --git a/tests/mockserver.py b/tests/mockserver.py index e0ac127f2..841a2cfe4 100644 --- a/tests/mockserver.py +++ b/tests/mockserver.py @@ -18,11 +18,12 @@ from twisted.internet.task import deferLater from twisted.names import dns, error from twisted.names.server import DNSServerFactory from twisted.web import resource, server -from twisted.web.server import NOT_DONE_YET, GzipEncoderFactory, Site -from twisted.web.static import File -from twisted.web.util import redirectTo +from twisted.web.server import NOT_DONE_YET, Site +from twisted.web.static import Data, File +from twisted.web.util import Redirect, redirectTo from scrapy.utils.python import to_bytes, to_unicode +from tests import tests_datadir if TYPE_CHECKING: from twisted.internet.protocol import ServerFactory @@ -245,6 +246,14 @@ class ArbitraryLengthPayloadResource(LeafResource): return request.content.read() +class NoMetaRefreshRedirect(Redirect): + def render(self, request: server.Request) -> bytes: + content = Redirect.render(self, request) + return content.replace( + b'http-equiv="refresh"', b'http-no-equiv="do-not-refresh-me"' + ) + + class Root(resource.Resource): def __init__(self): resource.Resource.__init__(self) @@ -256,18 +265,26 @@ class Root(resource.Resource): self.putChild(b"raw", Raw()) self.putChild(b"echo", Echo()) self.putChild(b"payload", PayloadResource()) - self.putChild( - b"xpayload", - resource.EncodingResourceWrapper(PayloadResource(), [GzipEncoderFactory()]), - ) self.putChild(b"alpayload", ArbitraryLengthPayloadResource()) - try: - from tests import tests_datadir - - self.putChild(b"files", File(str(Path(tests_datadir, "test_site/files/")))) - except Exception: - pass + self.putChild(b"files", File(str(Path(tests_datadir, "test_site/files/")))) self.putChild(b"redirect-to", RedirectTo()) + self.putChild(b"text", Data(b"Works", "text/plain")) + self.putChild( + b"html", + Data( + b"

Works

World

", + "text/html", + ), + ) + self.putChild( + b"enc-gb18030", + Data(b"

gb18030 encoding

", "text/html; charset=gb18030"), + ) + self.putChild(b"redirect", Redirect(b"/redirected")) + self.putChild( + b"redirect-no-meta-refresh", NoMetaRefreshRedirect(b"/redirected") + ) + self.putChild(b"redirected", Data(b"Redirected here", "text/plain")) def getChild(self, name, request): return self diff --git a/tests/test_command_check.py b/tests/test_command_check.py index 975f31dfe..97bd9d726 100644 --- a/tests/test_command_check.py +++ b/tests/test_command_check.py @@ -7,10 +7,8 @@ from tests.test_commands import TestCommandBase class TestCheckCommand(TestCommandBase): - command = "check" - - def setUp(self): - super().setUp() + def setup_method(self): + super().setup_method() self.spider_name = "check_spider" self.spider = (self.proj_mod_path / "spiders" / "checkspider.py").resolve() diff --git a/tests/test_command_fetch.py b/tests/test_command_fetch.py index 89f664336..c83594361 100644 --- a/tests/test_command_fetch.py +++ b/tests/test_command_fetch.py @@ -1,35 +1,35 @@ -from twisted.internet.defer import inlineCallbacks -from twisted.trial import unittest - -from tests.utils.testproc import ProcessTest -from tests.utils.testsite import SiteTest +from tests.mockserver import MockServer +from tests.test_commands import TestProjectBase -class TestFetchCommand(ProcessTest, SiteTest, unittest.TestCase): - command = "fetch" +class TestFetchCommand(TestProjectBase): + @classmethod + def setup_class(cls): + cls.mockserver = MockServer() + cls.mockserver.__enter__() + + @classmethod + def teardown_class(cls): + cls.mockserver.__exit__(None, None, None) - @inlineCallbacks def test_output(self): - _, out, _ = yield self.execute([self.url("/text")]) - assert out.strip() == b"Works" + _, out, _ = self.proc("fetch", self.mockserver.url("/text")) + assert out.strip() == "Works" - @inlineCallbacks def test_redirect_default(self): - _, out, _ = yield self.execute([self.url("/redirect")]) - assert out.strip() == b"Redirected here" + _, out, _ = self.proc("fetch", self.mockserver.url("/redirect")) + assert out.strip() == "Redirected here" - @inlineCallbacks def test_redirect_disabled(self): - _, out, err = yield self.execute( - ["--no-redirect", self.url("/redirect-no-meta-refresh")] + _, _, err = self.proc( + "fetch", "--no-redirect", self.mockserver.url("/redirect-no-meta-refresh") ) err = err.strip() - assert b"downloader/response_status_count/302" in err, err - assert b"downloader/response_status_count/200" not in err, err + assert "downloader/response_status_count/302" in err, err + assert "downloader/response_status_count/200" not in err, err - @inlineCallbacks def test_headers(self): - _, out, _ = yield self.execute([self.url("/text"), "--headers"]) - out = out.replace(b"\r", b"") # required on win32 - assert b"Server: TwistedWeb" in out, out - assert b"Content-Type: text/plain" in out + _, out, _ = self.proc("fetch", self.mockserver.url("/text"), "--headers") + out = out.replace("\r", "") # required on win32 + assert "Server: TwistedWeb" in out, out + assert "Content-Type: text/plain" in out diff --git a/tests/test_command_parse.py b/tests/test_command_parse.py index 6681aba17..5c3120c21 100644 --- a/tests/test_command_parse.py +++ b/tests/test_command_parse.py @@ -1,29 +1,25 @@ import argparse -import os import re from pathlib import Path -from twisted.internet.defer import inlineCallbacks - from scrapy.commands import parse from scrapy.settings import Settings -from scrapy.utils.python import to_unicode +from tests.mockserver import MockServer from tests.test_commands import TestCommandBase -from tests.utils.testproc import ProcessTest -from tests.utils.testsite import SiteTest -def _textmode(bstr: bytes) -> str: - """Normalize input the same as writing to a file - and reading from it in text mode""" - return to_unicode(bstr).replace(os.linesep, "\n") +class TestParseCommand(TestCommandBase): + @classmethod + def setup_class(cls): + cls.mockserver = MockServer() + cls.mockserver.__enter__() + @classmethod + def teardown_class(cls): + cls.mockserver.__exit__(None, None, None) -class TestParseCommand(ProcessTest, SiteTest, TestCommandBase): - command = "parse" - - def setUp(self): - super().setUp() + def setup_method(self): + super().setup_method() self.spider_name = "parse_spider" (self.proj_mod_path / "spiders" / "myspider.py").write_text( f""" @@ -171,260 +167,253 @@ ITEM_PIPELINES = {{'{self.project_name}.pipelines.MyPipeline': 1}} """ ) - @inlineCallbacks def test_spider_arguments(self): - _, _, stderr = yield self.execute( - [ - "--spider", - self.spider_name, - "-a", - "test_arg=1", - "-c", - "parse", - "--verbose", - self.url("/html"), - ] + _, _, stderr = self.proc( + "parse", + "--spider", + self.spider_name, + "-a", + "test_arg=1", + "-c", + "parse", + "--verbose", + self.mockserver.url("/html"), ) - assert "DEBUG: It Works!" in _textmode(stderr) + assert "DEBUG: It Works!" in stderr - @inlineCallbacks def test_request_with_meta(self): raw_json_string = '{"foo" : "baz"}' - _, _, stderr = yield self.execute( - [ - "--spider", - self.spider_name, - "--meta", - raw_json_string, - "-c", - "parse_request_with_meta", - "--verbose", - self.url("/html"), - ] + _, _, stderr = self.proc( + "parse", + "--spider", + self.spider_name, + "--meta", + raw_json_string, + "-c", + "parse_request_with_meta", + "--verbose", + self.mockserver.url("/html"), ) - assert "DEBUG: It Works!" in _textmode(stderr) + assert "DEBUG: It Works!" in stderr - _, _, stderr = yield self.execute( - [ - "--spider", - self.spider_name, - "-m", - raw_json_string, - "-c", - "parse_request_with_meta", - "--verbose", - self.url("/html"), - ] + _, _, stderr = self.proc( + "parse", + "--spider", + self.spider_name, + "-m", + raw_json_string, + "-c", + "parse_request_with_meta", + "--verbose", + self.mockserver.url("/html"), ) - assert "DEBUG: It Works!" in _textmode(stderr) + assert "DEBUG: It Works!" in stderr - @inlineCallbacks def test_request_with_cb_kwargs(self): raw_json_string = '{"foo" : "bar", "key": "value"}' - _, _, stderr = yield self.execute( - [ - "--spider", - self.spider_name, - "--cbkwargs", - raw_json_string, - "-c", - "parse_request_with_cb_kwargs", - "--verbose", - self.url("/html"), - ] + _, _, stderr = self.proc( + "parse", + "--spider", + self.spider_name, + "--cbkwargs", + raw_json_string, + "-c", + "parse_request_with_cb_kwargs", + "--verbose", + self.mockserver.url("/html"), ) - log = _textmode(stderr) - assert "DEBUG: It Works!" in log + assert "DEBUG: It Works!" in stderr assert ( - "DEBUG: request.callback signature: (response, foo=None, key=None)" in log + "DEBUG: request.callback signature: (response, foo=None, key=None)" + in stderr ) - @inlineCallbacks def test_request_without_meta(self): - _, _, stderr = yield self.execute( - [ - "--spider", - self.spider_name, - "-c", - "parse_request_without_meta", - "--nolinks", - self.url("/html"), - ] + _, _, stderr = self.proc( + "parse", + "--spider", + self.spider_name, + "-c", + "parse_request_without_meta", + "--nolinks", + self.mockserver.url("/html"), ) - assert "DEBUG: It Works!" in _textmode(stderr) + assert "DEBUG: It Works!" in stderr - @inlineCallbacks def test_pipelines(self): - _, _, stderr = yield self.execute( - [ - "--spider", - self.spider_name, - "--pipelines", - "-c", - "parse", - "--verbose", - self.url("/html"), - ] + _, _, stderr = self.proc( + "parse", + "--spider", + self.spider_name, + "--pipelines", + "-c", + "parse", + "--verbose", + self.mockserver.url("/html"), ) - assert "INFO: It Works!" in _textmode(stderr) + assert "INFO: It Works!" in stderr - @inlineCallbacks def test_async_def_asyncio_parse_items_list(self): - status, out, stderr = yield self.execute( - [ - "--spider", - "asyncdef_asyncio_return", - "-c", - "parse", - self.url("/html"), - ] + _, out, stderr = self.proc( + "parse", + "--spider", + "asyncdef_asyncio_return", + "-c", + "parse", + self.mockserver.url("/html"), ) - assert "INFO: Got response 200" in _textmode(stderr) - assert "{'id': 1}" in _textmode(out) - assert "{'id': 2}" in _textmode(out) + assert "INFO: Got response 200" in stderr + assert "{'id': 1}" in out + assert "{'id': 2}" in out - @inlineCallbacks def test_async_def_asyncio_parse_items_single_element(self): - status, out, stderr = yield self.execute( - [ - "--spider", - "asyncdef_asyncio_return_single_element", - "-c", - "parse", - self.url("/html"), - ] + _, out, stderr = self.proc( + "parse", + "--spider", + "asyncdef_asyncio_return_single_element", + "-c", + "parse", + self.mockserver.url("/html"), ) - assert "INFO: Got response 200" in _textmode(stderr) - assert "{'foo': 42}" in _textmode(out) + assert "INFO: Got response 200" in stderr + assert "{'foo': 42}" in out - @inlineCallbacks def test_async_def_asyncgen_parse_loop(self): - status, out, stderr = yield self.execute( - [ - "--spider", - "asyncdef_asyncio_gen_loop", - "-c", - "parse", - self.url("/html"), - ] + _, out, stderr = self.proc( + "parse", + "--spider", + "asyncdef_asyncio_gen_loop", + "-c", + "parse", + self.mockserver.url("/html"), ) - assert "INFO: Got response 200" in _textmode(stderr) + assert "INFO: Got response 200" in stderr for i in range(10): - assert f"{{'foo': {i}}}" in _textmode(out) + assert f"{{'foo': {i}}}" in out - @inlineCallbacks def test_async_def_asyncgen_parse_exc(self): - status, out, stderr = yield self.execute( - [ - "--spider", - "asyncdef_asyncio_gen_exc", - "-c", - "parse", - self.url("/html"), - ] + _, out, stderr = self.proc( + "parse", + "--spider", + "asyncdef_asyncio_gen_exc", + "-c", + "parse", + self.mockserver.url("/html"), ) - assert "ValueError" in _textmode(stderr) + assert "ValueError" in stderr for i in range(7): - assert f"{{'foo': {i}}}" in _textmode(out) + assert f"{{'foo': {i}}}" in out - @inlineCallbacks def test_async_def_asyncio_parse(self): - _, _, stderr = yield self.execute( - [ - "--spider", - "asyncdef_asyncio", - "-c", - "parse", - self.url("/html"), - ] + _, _, stderr = self.proc( + "parse", + "--spider", + "asyncdef_asyncio", + "-c", + "parse", + self.mockserver.url("/html"), ) - assert "DEBUG: Got response 200" in _textmode(stderr) + assert "DEBUG: Got response 200" in stderr - @inlineCallbacks def test_parse_items(self): - status, out, stderr = yield self.execute( - ["--spider", self.spider_name, "-c", "parse", self.url("/html")] + _, out, _ = self.proc( + "parse", + "--spider", + self.spider_name, + "-c", + "parse", + self.mockserver.url("/html"), ) - assert "[{}, {'foo': 'bar'}]" in _textmode(out) + assert "[{}, {'foo': 'bar'}]" in out - @inlineCallbacks def test_parse_items_no_callback_passed(self): - status, out, stderr = yield self.execute( - ["--spider", self.spider_name, self.url("/html")] + _, out, _ = self.proc( + "parse", "--spider", self.spider_name, self.mockserver.url("/html") ) - assert "[{}, {'foo': 'bar'}]" in _textmode(out) + assert "[{}, {'foo': 'bar'}]" in out - @inlineCallbacks def test_wrong_callback_passed(self): - status, out, stderr = yield self.execute( - ["--spider", self.spider_name, "-c", "dummy", self.url("/html")] + _, out, stderr = self.proc( + "parse", + "--spider", + self.spider_name, + "-c", + "dummy", + self.mockserver.url("/html"), ) - assert re.search(r"# Scraped Items -+\n\[\]", _textmode(out)) - assert "Cannot find callback" in _textmode(stderr) + assert re.search(r"# Scraped Items -+\r?\n\[\]", out) + assert "Cannot find callback" in stderr - @inlineCallbacks def test_crawlspider_matching_rule_callback_set(self): """If a rule matches the URL, use it's defined callback.""" - status, out, stderr = yield self.execute( - ["--spider", "goodcrawl" + self.spider_name, "-r", self.url("/html")] + _, out, _ = self.proc( + "parse", + "--spider", + "goodcrawl" + self.spider_name, + "-r", + self.mockserver.url("/html"), ) - assert "[{}, {'foo': 'bar'}]" in _textmode(out) + assert "[{}, {'foo': 'bar'}]" in out - @inlineCallbacks def test_crawlspider_matching_rule_default_callback(self): """If a rule match but it has no callback set, use the 'parse' callback.""" - status, out, stderr = yield self.execute( - ["--spider", "goodcrawl" + self.spider_name, "-r", self.url("/text")] + _, out, _ = self.proc( + "parse", + "--spider", + "goodcrawl" + self.spider_name, + "-r", + self.mockserver.url("/text"), ) - assert "[{}, {'nomatch': 'default'}]" in _textmode(out) + assert "[{}, {'nomatch': 'default'}]" in out - @inlineCallbacks def test_spider_with_no_rules_attribute(self): """Using -r with a spider with no rule should not produce items.""" - status, out, stderr = yield self.execute( - ["--spider", self.spider_name, "-r", self.url("/html")] + _, out, stderr = self.proc( + "parse", "--spider", self.spider_name, "-r", self.mockserver.url("/html") ) - assert re.search(r"# Scraped Items -+\n\[\]", _textmode(out)) - assert "No CrawlSpider rules found" in _textmode(stderr) + assert re.search(r"# Scraped Items -+\r?\n\[\]", out) + assert "No CrawlSpider rules found" in stderr - @inlineCallbacks def test_crawlspider_missing_callback(self): - status, out, stderr = yield self.execute( - ["--spider", "badcrawl" + self.spider_name, "-r", self.url("/html")] + _, out, _ = self.proc( + "parse", + "--spider", + "badcrawl" + self.spider_name, + "-r", + self.mockserver.url("/html"), ) - assert re.search(r"# Scraped Items -+\n\[\]", _textmode(out)) + assert re.search(r"# Scraped Items -+\r?\n\[\]", out) - @inlineCallbacks def test_crawlspider_no_matching_rule(self): """The requested URL has no matching rule, so no items should be scraped""" - status, out, stderr = yield self.execute( - ["--spider", "badcrawl" + self.spider_name, "-r", self.url("/enc-gb18030")] + _, out, stderr = self.proc( + "parse", + "--spider", + "badcrawl" + self.spider_name, + "-r", + self.mockserver.url("/enc-gb18030"), ) - assert re.search(r"# Scraped Items -+\n\[\]", _textmode(out)) - assert "Cannot find a rule that matches" in _textmode(stderr) + assert re.search(r"# Scraped Items -+\r?\n\[\]", out) + assert "Cannot find a rule that matches" in stderr - @inlineCallbacks def test_crawlspider_not_exists_with_not_matched_url(self): - status, out, stderr = yield self.execute([self.url("/invalid_url")]) - assert status == 0 + assert self.call("parse", self.mockserver.url("/invalid_url")) == 0 - @inlineCallbacks def test_output_flag(self): """Checks if a file was created successfully having correct format containing correct data in it. """ file_name = "data.json" file_path = Path(self.proj_path, file_name) - yield self.execute( - [ - "--spider", - self.spider_name, - "-c", - "parse", - "-o", - file_name, - self.url("/html"), - ] + self.proc( + "parse", + "--spider", + self.spider_name, + "-c", + "parse", + "-o", + file_name, + self.mockserver.url("/html"), ) assert file_path.exists() diff --git a/tests/test_command_runspider.py b/tests/test_command_runspider.py index 7f8d9fb61..c1a6d9b18 100644 --- a/tests/test_command_runspider.py +++ b/tests/test_command_runspider.py @@ -7,7 +7,6 @@ from contextlib import contextmanager from pathlib import Path from tempfile import TemporaryDirectory, mkdtemp from typing import TYPE_CHECKING -from unittest import skipIf import pytest @@ -58,7 +57,7 @@ class BadSpider(scrapy.Spider): return self.proc("runspider", fname, *args) def get_log(self, code, name=None, args=()): - p, stdout, stderr = self.runspider(code, name, args=args) + _, _, stderr = self.runspider(code, name, args=args) return stderr def test_runspider(self): @@ -288,7 +287,7 @@ class MySpider(scrapy.Spider): log = self.get_log(spider_code, args=args) assert "[myspider] DEBUG: FEEDS: {'stdout:': {'format': 'json'}}" in log - @skipIf(platform.system() == "Windows", reason="Linux only") + @pytest.mark.skipif(platform.system() == "Windows", reason="Linux only") def test_absolute_path_linux(self): spider_code = """ import scrapy @@ -317,7 +316,7 @@ class MySpider(scrapy.Spider): in log ) - @skipIf(platform.system() != "Windows", reason="Windows only") + @pytest.mark.skipif(platform.system() != "Windows", reason="Windows only") def test_absolute_path_windows(self): spider_code = """ import scrapy @@ -370,18 +369,16 @@ class MySpider(scrapy.Spider): assert "The value of FOO is 42" in log +@pytest.mark.skipif( + platform.system() != "Windows", reason="Windows required for .pyw files" +) class TestWindowsRunSpiderCommand(TestRunSpiderCommand): spider_filename = "myspider.pyw" - def setUp(self): - if platform.system() != "Windows": - pytest.skip("Windows required for .pyw files") - return super().setUp() - def test_start_errors(self): log = self.get_log(self.badspider, name="badspider.pyw") assert "start" in log assert "badspider.pyw" in log def test_runspider_unable_to_load(self): - pytest.skip("Already Tested in 'RunSpiderCommandTest' ") + pytest.skip("Already Tested in 'RunSpiderCommandTest'") diff --git a/tests/test_command_shell.py b/tests/test_command_shell.py index d9f17d76b..76c1eb663 100644 --- a/tests/test_command_shell.py +++ b/tests/test_command_shell.py @@ -5,140 +5,137 @@ from pathlib import Path import pytest from pexpect.popen_spawn import PopenSpawn -from twisted.internet.defer import inlineCallbacks -from twisted.trial import unittest from scrapy.utils.reactor import _asyncio_reactor_path from tests import NON_EXISTING_RESOLVABLE, tests_datadir from tests.mockserver import MockServer -from tests.utils.testproc import ProcessTest -from tests.utils.testsite import SiteTest +from tests.test_commands import TestProjectBase -class TestShellCommand(ProcessTest, SiteTest, unittest.TestCase): - command = "shell" +class TestShellCommand(TestProjectBase): + @classmethod + def setup_class(cls): + cls.mockserver = MockServer() + cls.mockserver.__enter__() + + @classmethod + def teardown_class(cls): + cls.mockserver.__exit__(None, None, None) - @inlineCallbacks def test_empty(self): - _, out, _ = yield self.execute(["-c", "item"]) - assert b"{}" in out + _, out, _ = self.proc("shell", "-c", "item") + assert "{}" in out - @inlineCallbacks def test_response_body(self): - _, out, _ = yield self.execute([self.url("/text"), "-c", "response.body"]) - assert b"Works" in out + _, out, _ = self.proc( + "shell", self.mockserver.url("/text"), "-c", "response.body" + ) + assert "Works" in out - @inlineCallbacks def test_response_type_text(self): - _, out, _ = yield self.execute([self.url("/text"), "-c", "type(response)"]) - assert b"TextResponse" in out + _, out, _ = self.proc( + "shell", self.mockserver.url("/text"), "-c", "type(response)" + ) + assert "TextResponse" in out - @inlineCallbacks def test_response_type_html(self): - _, out, _ = yield self.execute([self.url("/html"), "-c", "type(response)"]) - assert b"HtmlResponse" in out + _, out, _ = self.proc( + "shell", self.mockserver.url("/html"), "-c", "type(response)" + ) + assert "HtmlResponse" in out - @inlineCallbacks def test_response_selector_html(self): xpath = "response.xpath(\"//p[@class='one']/text()\").get()" - _, out, _ = yield self.execute([self.url("/html"), "-c", xpath]) - assert out.strip() == b"Works" + _, out, _ = self.proc("shell", self.mockserver.url("/html"), "-c", xpath) + assert out.strip() == "Works" - @inlineCallbacks def test_response_encoding_gb18030(self): - _, out, _ = yield self.execute( - [self.url("/enc-gb18030"), "-c", "response.encoding"] + _, out, _ = self.proc( + "shell", self.mockserver.url("/enc-gb18030"), "-c", "response.encoding" ) - assert out.strip() == b"gb18030" + assert out.strip() == "gb18030" - @inlineCallbacks def test_redirect(self): - _, out, _ = yield self.execute([self.url("/redirect"), "-c", "response.url"]) - assert out.strip().endswith(b"/redirected") + _, out, _ = self.proc( + "shell", self.mockserver.url("/redirect"), "-c", "response.url" + ) + assert out.strip().endswith("/redirected") - @inlineCallbacks def test_redirect_follow_302(self): - _, out, _ = yield self.execute( - [self.url("/redirect-no-meta-refresh"), "-c", "response.status"] + _, out, _ = self.proc( + "shell", + self.mockserver.url("/redirect-no-meta-refresh"), + "-c", + "response.status", ) - assert out.strip().endswith(b"200") + assert out.strip().endswith("200") - @inlineCallbacks def test_redirect_not_follow_302(self): - _, out, _ = yield self.execute( - [ - "--no-redirect", - self.url("/redirect-no-meta-refresh"), - "-c", - "response.status", - ] + _, out, _ = self.proc( + "shell", + "--no-redirect", + self.mockserver.url("/redirect-no-meta-refresh"), + "-c", + "response.status", ) - assert out.strip().endswith(b"302") + assert out.strip().endswith("302") - @inlineCallbacks def test_fetch_redirect_follow_302(self): """Test that calling ``fetch(url)`` follows HTTP redirects by default.""" - url = self.url("/redirect-no-meta-refresh") + url = self.mockserver.url("/redirect-no-meta-refresh") code = f"fetch('{url}')" - errcode, out, errout = yield self.execute(["-c", code]) - assert errcode == 0, out - assert b"Redirecting (302)" in errout - assert b"Crawled (200)" in errout + p, out, errout = self.proc("shell", "-c", code) + assert p.returncode == 0, out + assert "Redirecting (302)" in errout + assert "Crawled (200)" in errout - @inlineCallbacks def test_fetch_redirect_not_follow_302(self): """Test that calling ``fetch(url, redirect=False)`` disables automatic redirects.""" - url = self.url("/redirect-no-meta-refresh") + url = self.mockserver.url("/redirect-no-meta-refresh") code = f"fetch('{url}', redirect=False)" - errcode, out, errout = yield self.execute(["-c", code]) - assert errcode == 0, out - assert b"Crawled (302)" in errout + p, out, errout = self.proc("shell", "-c", code) + assert p.returncode == 0, out + assert "Crawled (302)" in errout - @inlineCallbacks def test_request_replace(self): - url = self.url("/text") + url = self.mockserver.url("/text") code = f"fetch('{url}') or fetch(response.request.replace(method='POST'))" - errcode, out, _ = yield self.execute(["-c", code]) - assert errcode == 0, out + p, out, _ = self.proc("shell", "-c", code) + assert p.returncode == 0, out - @inlineCallbacks def test_scrapy_import(self): - url = self.url("/text") + url = self.mockserver.url("/text") code = f"fetch(scrapy.Request('{url}'))" - errcode, out, _ = yield self.execute(["-c", code]) - assert errcode == 0, out + p, out, _ = self.proc("shell", "-c", code) + assert p.returncode == 0, out - @inlineCallbacks def test_local_file(self): filepath = Path(tests_datadir, "test_site", "index.html") - _, out, _ = yield self.execute([str(filepath), "-c", "item"]) - assert b"{}" in out + _, out, _ = self.proc("shell", str(filepath), "-c", "item") + assert "{}" in out - @inlineCallbacks def test_local_nofile(self): filepath = "file:///tests/sample_data/test_site/nothinghere.html" - errcode, out, err = yield self.execute( - [filepath, "-c", "item"], check_code=False - ) - assert errcode == 1, out or err - assert b"No such file or directory" in err + p, out, err = self.proc("shell", filepath, "-c", "item") + assert p.returncode == 1, out or err + assert "No such file or directory" in err - @inlineCallbacks def test_dns_failures(self): if NON_EXISTING_RESOLVABLE: pytest.skip("Non-existing hosts are resolvable") url = "www.somedomainthatdoesntexi.st" - errcode, out, err = yield self.execute([url, "-c", "item"], check_code=False) - assert errcode == 1, out or err - assert b"DNS lookup failed" in err + p, out, err = self.proc("shell", url, "-c", "item") + assert p.returncode == 1, out or err + assert "DNS lookup failed" in err - @inlineCallbacks def test_shell_fetch_async(self): - url = self.url("/html") + url = self.mockserver.url("/html") code = f"fetch('{url}')" - args = ["-c", code, "--set", f"TWISTED_REACTOR={_asyncio_reactor_path}"] - _, _, err = yield self.execute(args, check_code=True) - assert b"RuntimeError: There is no current event loop in thread" not in err + p, _, err = self.proc( + "shell", "-c", code, "--set", f"TWISTED_REACTOR={_asyncio_reactor_path}" + ) + assert p.returncode == 0, err + assert "RuntimeError: There is no current event loop in thread" not in err class TestInteractiveShell: diff --git a/tests/test_command_startproject.py b/tests/test_command_startproject.py index 08bf9b0fd..988ad50b9 100644 --- a/tests/test_command_startproject.py +++ b/tests/test_command_startproject.py @@ -108,8 +108,8 @@ def get_permissions_dict( class TestStartprojectTemplates(TestProjectBase): maxDiff = None - def setUp(self): - super().setUp() + def setup_method(self): + super().setup_method() self.tmpl = str(Path(self.temp_path, "templates")) self.tmpl_proj = str(Path(self.tmpl, "project")) diff --git a/tests/test_command_version.py b/tests/test_command_version.py index 87dfb16df..de58203fc 100644 --- a/tests/test_command_version.py +++ b/tests/test_command_version.py @@ -1,29 +1,15 @@ -import sys - -from twisted.internet.defer import inlineCallbacks -from twisted.trial import unittest - import scrapy -from tests.utils.testproc import ProcessTest +from tests.test_commands import TestProjectBase -class TestVersionCommand(ProcessTest, unittest.TestCase): - command = "version" - - @inlineCallbacks +class TestVersionCommand(TestProjectBase): def test_output(self): - encoding = sys.stdout.encoding or "utf-8" - _, out, _ = yield self.execute([]) - assert out.strip().decode(encoding) == f"Scrapy {scrapy.__version__}" + _, out, _ = self.proc("version") + assert out.strip() == f"Scrapy {scrapy.__version__}" - @inlineCallbacks def test_verbose_output(self): - encoding = sys.stdout.encoding or "utf-8" - _, out, _ = yield self.execute(["-v"]) - headers = [ - line.partition(":")[0].strip() - for line in out.strip().decode(encoding).splitlines() - ] + _, out, _ = self.proc("version", "-v") + headers = [line.partition(":")[0].strip() for line in out.strip().splitlines()] assert headers == [ "Scrapy", "lxml", diff --git a/tests/test_commands.py b/tests/test_commands.py index 8ca5d51e5..851c92db4 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -10,11 +10,9 @@ from pathlib import Path from shutil import rmtree from tempfile import TemporaryFile, mkdtemp from threading import Timer -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from unittest import mock -from twisted.trial import unittest - import scrapy from scrapy.cmdline import _pop_command_name, _print_unknown_command_msg from scrapy.commands import ScrapyCommand, ScrapyHelpFormatter, view @@ -61,28 +59,30 @@ class TestCommandSettings: ) -class TestProjectBase(unittest.TestCase): +class TestProjectBase: project_name = "testproject" - def setUp(self): + def setup_method(self): self.temp_path = mkdtemp() self.cwd = self.temp_path self.proj_path = Path(self.temp_path, self.project_name) self.proj_mod_path = self.proj_path / self.project_name self.env = get_testenv() - def tearDown(self): + def teardown_method(self): rmtree(self.temp_path) - def call(self, *new_args, **kwargs): + def call(self, *args: str, **popen_kwargs: Any) -> int: with TemporaryFile() as out: - args = (sys.executable, "-m", "scrapy.cmdline", *new_args) + args = (sys.executable, "-m", "scrapy.cmdline", *args) return subprocess.call( - args, stdout=out, stderr=out, cwd=self.cwd, env=self.env, **kwargs + args, stdout=out, stderr=out, cwd=self.cwd, env=self.env, **popen_kwargs ) - def proc(self, *new_args, **popen_kwargs): - args = (sys.executable, "-m", "scrapy.cmdline", *new_args) + def proc( + self, *args: str, **popen_kwargs: Any + ) -> tuple[subprocess.Popen[bytes], str, str]: + args = (sys.executable, "-m", "scrapy.cmdline", *args) p = subprocess.Popen( args, cwd=popen_kwargs.pop("cwd", self.cwd), @@ -118,10 +118,10 @@ class TestProjectBase(unittest.TestCase): class TestCommandBase(TestProjectBase): - def setUp(self): - super().setUp() + def setup_method(self): + super().setup_method() self.call("startproject", self.project_name) - self.cwd = Path(self.temp_path, self.project_name) + self.cwd = self.proj_path self.env["SCRAPY_SETTINGS_MODULE"] = f"{self.project_name}.settings" @@ -136,8 +136,8 @@ class TestCommandCrawlerProcess(TestCommandBase): "Type of self.crawler_process: " ) - def setUp(self): - super().setUp() + def setup_method(self): + super().setup_method() (self.cwd / self.project_name / "commands").mkdir(exist_ok=True) (self.cwd / self.project_name / "commands" / "__init__.py").touch() (self.cwd / self.project_name / "commands" / f"{self.name}.py").write_text(""" @@ -363,6 +363,19 @@ Unknown command: abc assert out.getvalue().strip() == message.strip() +class TestProjectSubdir(TestProjectBase): + """Test that commands work in a subdirectory of the project.""" + + def setup_method(self): + super().setup_method() + self.call("startproject", self.project_name) + self.cwd = self.proj_path / "subdir" + self.cwd.mkdir(exist_ok=True) + + def test_list(self): + assert self.call("list") == 0 + + class TestBenchCommand(TestCommandBase): def test_run(self): _, _, log = self.proc( @@ -389,8 +402,8 @@ class TestViewCommand(TestCommandBase): class TestHelpMessage(TestCommandBase): - def setUp(self): - super().setUp() + def setup_method(self): + super().setup_method() self.commands = [ "parse", "startproject", diff --git a/tests/utils/testproc.py b/tests/utils/testproc.py deleted file mode 100644 index 85d7c940f..000000000 --- a/tests/utils/testproc.py +++ /dev/null @@ -1,67 +0,0 @@ -from __future__ import annotations - -import os -import sys -from typing import TYPE_CHECKING, cast - -from twisted.internet.defer import Deferred -from twisted.internet.error import ProcessTerminated -from twisted.internet.protocol import ProcessProtocol - -if TYPE_CHECKING: - from collections.abc import Iterable - - from twisted.python.failure import Failure - - -class ProcessTest: - command: str | None = None - prefix = [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/tests/utils/testsite.py b/tests/utils/testsite.py deleted file mode 100644 index 473738773..000000000 --- a/tests/utils/testsite.py +++ /dev/null @@ -1,47 +0,0 @@ -from urllib.parse import urljoin - -from twisted.web import resource, server, static, util - - -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) diff --git a/tox.ini b/tox.ini index 5680d98d1..f28467ec1 100644 --- a/tox.ini +++ b/tox.ini @@ -19,7 +19,6 @@ deps = pytest-xdist sybil >= 1.3.0 # https://github.com/cjw296/sybil/issues/20#issuecomment-605433422 testfixtures - pywin32; sys_platform == "win32" Twisted < 25.5.0 # https://github.com/twisted/twisted/issues/12467 [testenv] From daa1a7d0b6549f901a002bf4c8bb7c4aed23e068 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Sat, 14 Jun 2025 14:01:20 +0500 Subject: [PATCH 040/459] Remove the chdir fixture, re-enable fancy pytest asserts (#6888) * Remove the chdir fixture. * Re-enable fancy pytest asserts. * Remove doc files from pytest ignores. * Restore docs/_ext in test collection ignores. * Skip a doctest that fails on Windows. * Fix tests that were writing to the current dir. --- conftest.py | 8 +--- docs/news.rst | 4 ++ docs/topics/dynamic-content.rst | 9 +++++ docs/topics/items.rst | 4 ++ docs/topics/leaks.rst | 7 ++++ docs/topics/loaders.rst | 13 ++++++ docs/topics/selectors.rst | 3 +- docs/topics/shell.rst | 8 ++++ docs/topics/stats.rst | 4 ++ docs/topics/telnetconsole.rst | 4 ++ pyproject.toml | 16 -------- tests/test_feedexport.py | 32 ++++++++------- tests/test_pipeline_files.py | 11 +++--- tests/test_squeues_request.py | 70 ++++++++++++++------------------- 14 files changed, 110 insertions(+), 83 deletions(-) diff --git a/conftest.py b/conftest.py index ed7d14166..f952127b9 100644 --- a/conftest.py +++ b/conftest.py @@ -12,6 +12,8 @@ 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", @@ -46,12 +48,6 @@ if not H2_ENABLED: ) -@pytest.fixture -def chdir(tmpdir): - """Change to pytest-provided temporary directory""" - tmpdir.chdir() - - def pytest_addoption(parser): parser.addoption( "--reactor", diff --git a/docs/news.rst b/docs/news.rst index 36d229760..05ad611ef 100644 --- a/docs/news.rst +++ b/docs/news.rst @@ -4454,6 +4454,8 @@ Highlights: Backward-incompatible changes ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +.. skip: start + * Python 3.4 is no longer supported, and some of the minimum requirements of Scrapy have also changed: @@ -4494,6 +4496,8 @@ Backward-incompatible changes (:issue:`3804`, :issue:`3819`, :issue:`3897`, :issue:`3976`, :issue:`3998`, :issue:`4036`) +.. skip: end + See also :ref:`1.8-deprecation-removals` below. diff --git a/docs/topics/dynamic-content.rst b/docs/topics/dynamic-content.rst index 65270433f..6c57a88f1 100644 --- a/docs/topics/dynamic-content.rst +++ b/docs/topics/dynamic-content.rst @@ -111,6 +111,8 @@ you may use `curl2scrapy `_. Handling different response formats =================================== +.. skip: start + Once you have a response with the desired data, how you extract the desired data from it depends on the type of response: @@ -157,11 +159,15 @@ data from it depends on the type of response: Otherwise, you might need to convert the SVG code into a raster image, and :ref:`handle that raster image `. +.. skip: end + .. _topics-parsing-javascript: Parsing JavaScript code ======================= +.. skip: start + If the desired data is hardcoded in JavaScript, you first need to get the JavaScript code: @@ -220,6 +226,8 @@ data from it: >>> selector.css('var[name="data"]').get() 'value' +.. skip: end + .. _topics-headless-browsing: Using a headless browser @@ -242,6 +250,7 @@ it is possible to integrate ``asyncio``-based libraries which handle headless br One such library is `playwright-python`_ (an official Python port of `playwright`_). The following is a simple snippet to illustrate its usage within a Scrapy spider: +.. skip: next .. code-block:: python import scrapy diff --git a/docs/topics/items.rst b/docs/topics/items.rst index 0365c95b3..3588d033e 100644 --- a/docs/topics/items.rst +++ b/docs/topics/items.rst @@ -214,6 +214,8 @@ the :attr:`~scrapy.Item.fields` attribute. Working with Item objects ------------------------- +.. skip: start + Here are some examples of common tasks performed with items, using the ``Product`` item :ref:`declared above `. You will notice the API is very similar to the :class:`dict` API. @@ -375,6 +377,8 @@ appending more values, or changing existing values, like this: That adds (or replaces) the ``serializer`` metadata key for the ``name`` field, keeping all the previously existing metadata values. +.. skip: end + .. _supporting-item-types: diff --git a/docs/topics/leaks.rst b/docs/topics/leaks.rst index cd8914644..bbe1f3dd4 100644 --- a/docs/topics/leaks.rst +++ b/docs/topics/leaks.rst @@ -60,6 +60,8 @@ in control. Debugging memory leaks with ``trackref`` ======================================== +.. skip: start + :mod:`trackref` is a module provided by Scrapy to debug the most common cases of memory leaks. It basically tracks the references to all live Request, Response, Item, Spider and Selector objects. @@ -203,6 +205,8 @@ Here are the functions available in the :mod:`~scrapy.utils.trackref` module. ``None`` if none is found. Use :func:`print_live_refs` first to get a list of all tracked live objects per class name. +.. skip: end + .. _topics-leaks-muppy: Debugging memory leaks with muppy @@ -226,6 +230,7 @@ If you use ``pip``, you can install muppy with the following command:: Here's an example to view all Python objects available in the heap using muppy: +.. skip: start .. code-block:: pycon >>> from pympler import muppy @@ -253,6 +258,8 @@ the heap using muppy: `, using the :ref:`Product item ` declared in the :ref:`Items chapter `: +.. skip: next .. code-block:: python from scrapy.loader import ItemLoader @@ -130,6 +131,7 @@ assigned to the item. Let's see an example to illustrate how the input and output processors are called for a particular field (the same applies for any other field): +.. skip: next .. code-block:: python l = ItemLoader(Product(), some_selector) @@ -250,6 +252,7 @@ metadata. Here is an example: ) +.. skip: start .. code-block:: pycon >>> from scrapy.loader import ItemLoader @@ -259,6 +262,8 @@ metadata. Here is an example: >>> il.load_item() {'name': 'Welcome to my website', 'price': '1000'} +.. skip: end + The precedence order, for both input and output processors, is as follows: 1. Item Loader field-specific attributes: ``field_in`` and ``field_out`` (most @@ -294,6 +299,8 @@ the Item Loader that it's able to receive an Item Loader context, so the Item Loader passes the currently active context when calling it, and the processor function (``parse_length`` in this case) can thus use them. +.. skip: start + There are several ways to modify Item Loader context values: 1. By modifying the currently active Item Loader context @@ -320,6 +327,8 @@ There are several ways to modify Item Loader context values: class ProductLoader(ItemLoader): length_out = MapCompose(parse_length, unit="cm") +.. skip: end + ItemLoader objects ================== @@ -350,6 +359,7 @@ that you wish to extract. Example: +.. skip: next .. code-block:: python loader = ItemLoader(item=Item()) @@ -364,6 +374,7 @@ the footer selector. Example: +.. skip: next .. code-block:: python loader = ItemLoader(item=Item()) @@ -401,6 +412,7 @@ those dashes in the final product names. Here's how you can remove those dashes by reusing and extending the default Product Item Loader (``ProductLoader``): +.. skip: next .. code-block:: python from itemloaders.processors import MapCompose @@ -418,6 +430,7 @@ Another case where extending Item Loaders can be very helpful is when you have multiple source formats, for example XML and HTML. In the XML version you may want to remove ``CDATA`` occurrences. Here's an example of how to do it: +.. skip: next .. code-block:: python from itemloaders.processors import MapCompose diff --git a/docs/topics/selectors.rst b/docs/topics/selectors.rst index dbef07b73..40a85201a 100644 --- a/docs/topics/selectors.rst +++ b/docs/topics/selectors.rst @@ -308,6 +308,7 @@ Examples: * ``*::text`` selects all descendant text nodes of the current selector context: +..skip: next .. code-block:: pycon >>> response.css("#images *::text").getall() @@ -878,7 +879,7 @@ Example selecting links in list item with a "class" attribute ending with a digi >>> sel = Selector(text=doc, type="html") >>> sel.xpath("//li//@href").getall() ['link1.html', 'link2.html', 'link3.html', 'link4.html', 'link5.html'] - >>> sel.xpath('//li[re:test(@class, "item-\d$")]//@href').getall() + >>> sel.xpath(r'//li[re:test(@class, "item-\d$")]//@href').getall() ['link1.html', 'link2.html', 'link4.html', 'link5.html'] .. warning:: C library ``libxslt`` doesn't natively support EXSLT regular diff --git a/docs/topics/shell.rst b/docs/topics/shell.rst index 4898843e4..85a08cebd 100644 --- a/docs/topics/shell.rst +++ b/docs/topics/shell.rst @@ -142,6 +142,8 @@ Those objects are: 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/ page. Finally, we modify the (Reddit) request method to POST and re-fetch it @@ -232,6 +234,8 @@ After that, we can start playing with the objects: 'X-Ua-Compatible': ['IE=edge'], 'X-Xss-Protection': ['1; mode=block']} +.. skip: end + .. _topics-shell-inspect-response: @@ -268,6 +272,8 @@ Here's an example of how you would call it from your spider: # Rest of parsing code. +.. skip: start + When you run the spider, you will get something similar to this:: 2014-01-23 17:48:31-0400 [scrapy.core.engine] DEBUG: Crawled (200) (referer: None) @@ -301,6 +307,8 @@ crawling:: 2014-01-23 17:50:03-0400 [scrapy.core.engine] DEBUG: Crawled (200) (referer: None) ... +.. skip: end + Note that you can't use the ``fetch`` shortcut here since the Scrapy engine is blocked by the shell. However, after you leave the shell, the spider will continue crawling where it stopped, as shown above. diff --git a/docs/topics/stats.rst b/docs/topics/stats.rst index 9572a3785..e34999b58 100644 --- a/docs/topics/stats.rst +++ b/docs/topics/stats.rst @@ -42,6 +42,8 @@ attribute. Here is an example of an extension that access stats: def from_crawler(cls, crawler): return cls(crawler.stats) +.. skip: start + Set stat value: .. code-block:: python @@ -80,6 +82,8 @@ Get all stats: >>> stats.get_stats() {'custom_count': 1, 'start_time': datetime.datetime(2009, 7, 14, 21, 47, 28, 977139)} +.. skip: end + Available Stats Collectors ========================== diff --git a/docs/topics/telnetconsole.rst b/docs/topics/telnetconsole.rst index 3e9bbe56e..ae9cb634c 100644 --- a/docs/topics/telnetconsole.rst +++ b/docs/topics/telnetconsole.rst @@ -97,6 +97,8 @@ convenience: Telnet console usage examples ============================= +.. skip: start + Here are some example tasks you can do with the telnet console: View engine status @@ -146,6 +148,8 @@ To stop:: >>> engine.stop() Connection closed by foreign host. +.. skip: end + Telnet Console signals ====================== diff --git a/pyproject.toml b/pyproject.toml index 0742991db..bc809a7b1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -224,23 +224,7 @@ disable = [ [tool.pytest.ini_options] xfail_strict = true -usefixtures = "chdir" python_files = ["test_*.py", "test_*/__init__.py"] -addopts = [ - "--assert=plain", - "--ignore=docs/_ext", - "--ignore=docs/conf.py", - "--ignore=docs/news.rst", - "--ignore=docs/topics/dynamic-content.rst", - "--ignore=docs/topics/items.rst", - "--ignore=docs/topics/leaks.rst", - "--ignore=docs/topics/loaders.rst", - "--ignore=docs/topics/selectors.rst", - "--ignore=docs/topics/shell.rst", - "--ignore=docs/topics/stats.rst", - "--ignore=docs/topics/telnetconsole.rst", - "--ignore=docs/utils", -] 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", diff --git a/tests/test_feedexport.py b/tests/test_feedexport.py index 01797fd20..f8f3eb22a 100644 --- a/tests/test_feedexport.py +++ b/tests/test_feedexport.py @@ -5,6 +5,7 @@ import csv import gzip import json import lzma +import os import random import shutil import string @@ -107,8 +108,13 @@ class TestFileFeedStorage: self._assert_stores(FileFeedStorage(str(path)), path) def test_store_direct_path_relative(self, tmp_path): - path = (tmp_path / "foo" / "bar").relative_to(Path.cwd()) - self._assert_stores(FileFeedStorage(str(path)), 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" @@ -236,24 +242,22 @@ class TestBlockingFeedStorage: def test_default_temp_dir(self): b = BlockingFeedStorage() - tmp = b.open(self.get_test_spider()) - tmp_path = Path(tmp.name).parent - assert str(tmp_path) == tempfile.gettempdir() + 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): + def test_temp_file(self, tmp_path): b = BlockingFeedStorage() - tests_path = Path(__file__).resolve().parent - spider = self.get_test_spider({"FEED_TEMPDIR": str(tests_path)}) - tmp = b.open(spider) - tmp_path = Path(tmp.name).parent - assert tmp_path == tests_path + 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): + def test_invalid_folder(self, tmp_path): b = BlockingFeedStorage() - tests_path = Path(__file__).resolve().parent - invalid_path = tests_path / "invalid_path" + invalid_path = tmp_path / "invalid_path" spider = self.get_test_spider({"FEED_TEMPDIR": str(invalid_path)}) with pytest.raises(OSError, match="Not a Directory:"): diff --git a/tests/test_pipeline_files.py b/tests/test_pipeline_files.py index b4eae108f..808fde23d 100644 --- a/tests/test_pipeline_files.py +++ b/tests/test_pipeline_files.py @@ -524,21 +524,20 @@ class TestFilesPipelineCustomSettings: expected_value = settings.get(settings_attr) assert getattr(pipeline_cls, pipe_inst_attr) == expected_value - def test_file_pipeline_using_pathlike_objects(self): + def test_file_pipeline_using_pathlike_objects(self, tmp_path): class CustomFilesPipelineWithPathLikeDir(FilesPipeline): def file_path(self, request, response=None, info=None, *, item=None): return Path("subdir") / Path(request.url).name pipeline = CustomFilesPipelineWithPathLikeDir.from_crawler( - get_crawler(None, {"FILES_STORE": Path("./Temp")}) + get_crawler(None, {"FILES_STORE": tmp_path}) ) request = Request("http://example.com/image01.jpg") assert pipeline.file_path(request) == Path("subdir/image01.jpg") - def test_files_store_constructor_with_pathlike_object(self): - path = Path("./FileDir") - fs_store = FSFilesStore(path) - assert fs_store.basedir == str(path) + def test_files_store_constructor_with_pathlike_object(self, tmp_path): + fs_store = FSFilesStore(tmp_path) + assert fs_store.basedir == str(tmp_path) @pytest.mark.requires_botocore diff --git a/tests/test_squeues_request.py b/tests/test_squeues_request.py index 68bd6df68..8353ad73c 100644 --- a/tests/test_squeues_request.py +++ b/tests/test_squeues_request.py @@ -2,8 +2,7 @@ Queues that handle requests """ -import shutil -import tempfile +from pathlib import Path import pytest import queuelib @@ -23,30 +22,17 @@ from scrapy.utils.test import get_crawler class TestBaseQueue: def setup_method(self): - self.tmpdir = tempfile.mkdtemp(prefix="scrapy-queue-tests-") - self.qpath = self.tempfilename() - self.qdir = tempfile.mkdtemp() self.crawler = get_crawler(Spider) - def teardown_method(self): - shutil.rmtree(self.tmpdir) - - def tempfilename(self): - with tempfile.NamedTemporaryFile(dir=self.tmpdir) as nf: - return nf.name - - def mkdtemp(self): - return tempfile.mkdtemp(dir=self.tmpdir) - class RequestQueueTestMixin: - def queue(self): + def queue(self, base_path: Path): raise NotImplementedError - def test_one_element_with_peek(self): + def test_one_element_with_peek(self, tmp_path): if not hasattr(queuelib.queue.FifoMemoryQueue, "peek"): pytest.skip("The queuelib queues do not define peek") - q = self.queue() + q = self.queue(tmp_path) assert len(q) == 0 assert q.peek() is None assert q.pop() is None @@ -60,10 +46,10 @@ class RequestQueueTestMixin: assert q.pop() is None q.close() - def test_one_element_without_peek(self): + def test_one_element_without_peek(self, tmp_path): if hasattr(queuelib.queue.FifoMemoryQueue, "peek"): pytest.skip("The queuelib queues define peek") - q = self.queue() + q = self.queue(tmp_path) assert len(q) == 0 assert q.pop() is None req = Request("http://www.example.com") @@ -81,10 +67,10 @@ class RequestQueueTestMixin: class FifoQueueMixin(RequestQueueTestMixin): - def test_fifo_with_peek(self): + def test_fifo_with_peek(self, tmp_path): if not hasattr(queuelib.queue.FifoMemoryQueue, "peek"): pytest.skip("The queuelib queues do not define peek") - q = self.queue() + q = self.queue(tmp_path) assert len(q) == 0 assert q.peek() is None assert q.pop() is None @@ -108,10 +94,10 @@ class FifoQueueMixin(RequestQueueTestMixin): assert q.pop() is None q.close() - def test_fifo_without_peek(self): + def test_fifo_without_peek(self, tmp_path): if hasattr(queuelib.queue.FifoMemoryQueue, "peek"): - pytest.skip("The queuelib queues do not define peek") - q = self.queue() + pytest.skip("The queuelib queues define peek") + q = self.queue(tmp_path) assert len(q) == 0 assert q.pop() is None req1 = Request("http://www.example.com/1") @@ -137,10 +123,10 @@ class FifoQueueMixin(RequestQueueTestMixin): class LifoQueueMixin(RequestQueueTestMixin): - def test_lifo_with_peek(self): + def test_lifo_with_peek(self, tmp_path): if not hasattr(queuelib.queue.FifoMemoryQueue, "peek"): pytest.skip("The queuelib queues do not define peek") - q = self.queue() + q = self.queue(tmp_path) assert len(q) == 0 assert q.peek() is None assert q.pop() is None @@ -164,10 +150,10 @@ class LifoQueueMixin(RequestQueueTestMixin): assert q.pop() is None q.close() - def test_lifo_without_peek(self): + def test_lifo_without_peek(self, tmp_path): if hasattr(queuelib.queue.FifoMemoryQueue, "peek"): - pytest.skip("The queuelib queues do not define peek") - q = self.queue() + pytest.skip("The queuelib queues define peek") + q = self.queue(tmp_path) assert len(q) == 0 assert q.pop() is None req1 = Request("http://www.example.com/1") @@ -193,34 +179,38 @@ class LifoQueueMixin(RequestQueueTestMixin): class TestPickleFifoDiskQueueRequest(FifoQueueMixin, TestBaseQueue): - def queue(self): - return PickleFifoDiskQueue.from_crawler(crawler=self.crawler, key="pickle/fifo") + def queue(self, base_path): + return PickleFifoDiskQueue.from_crawler( + crawler=self.crawler, key=str(base_path / "pickle" / "fifo") + ) class TestPickleLifoDiskQueueRequest(LifoQueueMixin, TestBaseQueue): - def queue(self): - return PickleLifoDiskQueue.from_crawler(crawler=self.crawler, key="pickle/lifo") + def queue(self, base_path): + return PickleLifoDiskQueue.from_crawler( + crawler=self.crawler, key=str(base_path / "pickle" / "lifo") + ) class TestMarshalFifoDiskQueueRequest(FifoQueueMixin, TestBaseQueue): - def queue(self): + def queue(self, base_path): return MarshalFifoDiskQueue.from_crawler( - crawler=self.crawler, key="marshal/fifo" + crawler=self.crawler, key=str(base_path / "marshal" / "fifo") ) class TestMarshalLifoDiskQueueRequest(LifoQueueMixin, TestBaseQueue): - def queue(self): + def queue(self, base_path): return MarshalLifoDiskQueue.from_crawler( - crawler=self.crawler, key="marshal/lifo" + crawler=self.crawler, key=str(base_path / "marshal" / "lifo") ) class TestFifoMemoryQueueRequest(FifoQueueMixin, TestBaseQueue): - def queue(self): + def queue(self, base_path): return FifoMemoryQueue.from_crawler(crawler=self.crawler) class TestLifoMemoryQueueRequest(LifoQueueMixin, TestBaseQueue): - def queue(self): + def queue(self, base_path): return LifoMemoryQueue.from_crawler(crawler=self.crawler) From 85aeda365db01939f70d0888593e7808380c8514 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Mon, 16 Jun 2025 09:28:06 +0200 Subject: [PATCH 041/459] Clean up setting getter defaults (#6892) --- scrapy/core/downloader/__init__.py | 2 +- scrapy/downloadermiddlewares/ajaxcrawl.py | 2 +- scrapy/downloadermiddlewares/robotstxt.py | 6 ++---- scrapy/extensions/feedexport.py | 2 +- scrapy/extensions/periodic_log.py | 2 +- scrapy/settings/default_settings.py | 4 ++++ scrapy/spiders/crawl.py | 4 +--- 7 files changed, 11 insertions(+), 11 deletions(-) diff --git a/scrapy/core/downloader/__init__.py b/scrapy/core/downloader/__init__.py index 9293d7b78..4b5654826 100644 --- a/scrapy/core/downloader/__init__.py +++ b/scrapy/core/downloader/__init__.py @@ -124,7 +124,7 @@ class Downloader: ) self._slot_gc_loop.start(60) self.per_slot_settings: dict[str, dict[str, Any]] = self.settings.getdict( - "DOWNLOAD_SLOTS", {} + "DOWNLOAD_SLOTS" ) @inlineCallbacks diff --git a/scrapy/downloadermiddlewares/ajaxcrawl.py b/scrapy/downloadermiddlewares/ajaxcrawl.py index e7a8962a1..a23deaa45 100644 --- a/scrapy/downloadermiddlewares/ajaxcrawl.py +++ b/scrapy/downloadermiddlewares/ajaxcrawl.py @@ -43,7 +43,7 @@ class AjaxCrawlMiddleware: # 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", 32768) + self.lookup_bytes: int = settings.getint("AJAXCRAWL_MAXSIZE") @classmethod def from_crawler(cls, crawler: Crawler) -> Self: diff --git a/scrapy/downloadermiddlewares/robotstxt.py b/scrapy/downloadermiddlewares/robotstxt.py index aba455bdd..fbd737970 100644 --- a/scrapy/downloadermiddlewares/robotstxt.py +++ b/scrapy/downloadermiddlewares/robotstxt.py @@ -38,10 +38,8 @@ class RobotsTxtMiddleware: def __init__(self, crawler: Crawler): if not crawler.settings.getbool("ROBOTSTXT_OBEY"): raise NotConfigured - self._default_useragent: str = crawler.settings.get("USER_AGENT", "Scrapy") - self._robotstxt_useragent: str | None = crawler.settings.get( - "ROBOTSTXT_USER_AGENT", None - ) + self._default_useragent: str = crawler.settings["USER_AGENT"] + self._robotstxt_useragent: str | None = crawler.settings["ROBOTSTXT_USER_AGENT"] self.crawler: Crawler = crawler self._parsers: dict[str, RobotParser | Deferred[RobotParser | None] | None] = {} self._parserimpl: RobotParser = load_object( diff --git a/scrapy/extensions/feedexport.py b/scrapy/extensions/feedexport.py index c39a9c92e..d9e9ea775 100644 --- a/scrapy/extensions/feedexport.py +++ b/scrapy/extensions/feedexport.py @@ -479,7 +479,7 @@ class FeedExporter: uri = self.settings["FEED_URI"] # handle pathlib.Path objects uri = str(uri) if not isinstance(uri, Path) else uri.absolute().as_uri() - feed_options = {"format": self.settings.get("FEED_FORMAT", "jsonlines")} + feed_options = {"format": self.settings["FEED_FORMAT"]} self.feeds[uri] = feed_complete_default_values_from_settings( feed_options, self.settings ) diff --git a/scrapy/extensions/periodic_log.py b/scrapy/extensions/periodic_log.py index 9158482fa..98210990a 100644 --- a/scrapy/extensions/periodic_log.py +++ b/scrapy/extensions/periodic_log.py @@ -78,7 +78,7 @@ class PeriodicLog: ) ext_timing_enabled: bool = crawler.settings.getbool( - "PERIODIC_LOG_TIMING_ENABLED", False + "PERIODIC_LOG_TIMING_ENABLED" ) if not (ext_stats or ext_delta or ext_timing_enabled): raise NotConfigured diff --git a/scrapy/settings/default_settings.py b/scrapy/settings/default_settings.py index 7cd470f11..b6f47f1c3 100644 --- a/scrapy/settings/default_settings.py +++ b/scrapy/settings/default_settings.py @@ -20,6 +20,7 @@ from pathlib import Path ADDONS = {} AJAXCRAWL_ENABLED = False +AJAXCRAWL_MAXSIZE = 32768 ASYNCIO_EVENT_LOOP = None @@ -49,6 +50,8 @@ CONCURRENT_REQUESTS_PER_IP = 0 COOKIES_ENABLED = True COOKIES_DEBUG = False +CRAWLSPIDER_FOLLOW_LINKS = True + DEFAULT_DROPITEM_LOG_LEVEL = "WARNING" DEFAULT_ITEM_CLASS = "scrapy.item.Item" @@ -158,6 +161,7 @@ FEED_EXPORTERS_BASE = { "marshal": "scrapy.exporters.MarshalItemExporter", "pickle": "scrapy.exporters.PickleItemExporter", } +FEED_FORMAT = "jsonlines" FEED_STORE_EMPTY = True FEED_STORAGES = {} FEED_STORAGES_BASE = { diff --git a/scrapy/spiders/crawl.py b/scrapy/spiders/crawl.py index f44f70e40..98e7b23c0 100644 --- a/scrapy/spiders/crawl.py +++ b/scrapy/spiders/crawl.py @@ -213,7 +213,5 @@ class CrawlSpider(Spider): @classmethod 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", True - ) + spider._follow_links = crawler.settings.getbool("CRAWLSPIDER_FOLLOW_LINKS") return spider From 91b186cf1868038569ef05a334650eae2a74b5b1 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Mon, 23 Jun 2025 20:42:11 +0500 Subject: [PATCH 042/459] Use new pytest for new Twisted. (#6893) --- tox.ini | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tox.ini b/tox.ini index f28467ec1..85935b011 100644 --- a/tox.ini +++ b/tox.ini @@ -14,16 +14,16 @@ deps = pexpect >= 4.8.0 pyftpdlib >= 2.0.1 pygments - pytest != 8.2.* # https://github.com/pytest-dev/pytest/issues/12275 + pytest pytest-cov >= 4.0.0 pytest-xdist sybil >= 1.3.0 # https://github.com/cjw296/sybil/issues/20#issuecomment-605433422 testfixtures - Twisted < 25.5.0 # https://github.com/twisted/twisted/issues/12467 [testenv] deps = {[test-requirements]deps} + pytest >= 8.4.1 # https://github.com/pytest-dev/pytest/pull/13502 # mitmproxy does not support PyPy mitmproxy; implementation_name != "pypy" @@ -96,6 +96,8 @@ commands = [pinned] basepython = python3.9 deps = + # pytest 8.4.1 adds support for Twisted 25.5.0 but drops support for Twisted < 24.10.0 + pytest==8.4.0 Protego==0.1.15 Twisted==21.7.0 cryptography==37.0.0 @@ -195,6 +197,7 @@ basepython = pypy3.10 deps = PyPyDispatcher==2.1.0 {[test-requirements]deps} + pytest==8.4.0 Protego==0.1.15 Twisted==21.7.0 cryptography==41.0.5 From d1575220efc605a4e617aee4d4d9948de56fb529 Mon Sep 17 00:00:00 2001 From: Thalison Fernandes Date: Mon, 23 Jun 2025 12:49:57 -0300 Subject: [PATCH 043/459] Add .venv folder to .gitignore (#6901) --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 0a3f0ac1c..e02c2241d 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ docs/build *egg-info .tox venv +.venv build dist .idea From 712e965dbd2d58bcfdcab13e289d1e1d1cd0abae Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Mon, 23 Jun 2025 20:56:48 +0500 Subject: [PATCH 044/459] Replace Black with Ruff in contributing.rst. (#6903) --- docs/contributing.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/contributing.rst b/docs/contributing.rst index 0172887d6..3976d34c2 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -251,10 +251,10 @@ Coding style Please follow these coding conventions when writing code for inclusion in Scrapy: -* We use `black `_ for code formatting. +* We use `Ruff `_ for code formatting. There is a hook in the pre-commit config that will automatically format your code before every commit. You can also - run black manually with ``tox -e pre-commit``. + run Ruff manually with ``tox -e pre-commit``. * Don't put your name in the code you contribute; git provides enough metadata to identify author of the code. From 0d86fb69dcfbc51383e2e6fb926b9f166fee4395 Mon Sep 17 00:00:00 2001 From: Thalison Fernandes Date: Mon, 23 Jun 2025 13:56:29 -0300 Subject: [PATCH 045/459] Fix FileFeedStorage handling of Windows paths without file:// scheme (#6897) --- scrapy/extensions/feedexport.py | 2 +- tests/test_feedexport.py | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/scrapy/extensions/feedexport.py b/scrapy/extensions/feedexport.py index d9e9ea775..f7bf50a5c 100644 --- a/scrapy/extensions/feedexport.py +++ b/scrapy/extensions/feedexport.py @@ -185,7 +185,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) + 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_feedexport.py b/tests/test_feedexport.py index f8f3eb22a..7073d5a35 100644 --- a/tests/test_feedexport.py +++ b/tests/test_feedexport.py @@ -155,6 +155,11 @@ class TestFileFeedStorage: 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 + class TestFTPFeedStorage(unittest.TestCase): def get_test_spider(self, settings=None): From 9d324ebd1303613adde27dd28a2233803f1bcf14 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Mon, 23 Jun 2025 21:58:54 +0500 Subject: [PATCH 046/459] Add .vscode to .gitignore. (#6907) --- .gitignore | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/.gitignore b/.gitignore index e02c2241d..4100bcd97 100644 --- a/.gitignore +++ b/.gitignore @@ -5,15 +5,16 @@ _trial_temp* dropin.cache docs/build *egg-info -.tox -venv -.venv -build -dist -.idea +.tox/ +venv/ +.venv/ +build/ +dist/ +.idea/ +.vscode/ htmlcov/ -.coverage .pytest_cache/ +.coverage .coverage.* coverage.* *.junit.xml @@ -27,4 +28,4 @@ test-output.* Thumbs.db # OSX miscellaneous -.DS_Store \ No newline at end of file +.DS_Store From 9149b6e7fc47743cdbcb5d3471a9650a2163b09b Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Mon, 23 Jun 2025 20:42:11 +0500 Subject: [PATCH 047/459] Use new pytest for new Twisted. (#6893) --- tox.ini | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tox.ini b/tox.ini index 5680d98d1..d3dc7a3db 100644 --- a/tox.ini +++ b/tox.ini @@ -14,17 +14,17 @@ deps = pexpect >= 4.8.0 pyftpdlib >= 2.0.1 pygments - pytest != 8.2.* # https://github.com/pytest-dev/pytest/issues/12275 + pytest pytest-cov >= 4.0.0 pytest-xdist sybil >= 1.3.0 # https://github.com/cjw296/sybil/issues/20#issuecomment-605433422 testfixtures pywin32; sys_platform == "win32" - Twisted < 25.5.0 # https://github.com/twisted/twisted/issues/12467 [testenv] deps = {[test-requirements]deps} + pytest >= 8.4.1 # https://github.com/pytest-dev/pytest/pull/13502 # mitmproxy does not support PyPy mitmproxy; implementation_name != "pypy" @@ -97,6 +97,8 @@ commands = [pinned] basepython = python3.9 deps = + # pytest 8.4.1 adds support for Twisted 25.5.0 but drops support for Twisted < 24.10.0 + pytest==8.4.0 Protego==0.1.15 Twisted==21.7.0 cryptography==37.0.0 @@ -196,6 +198,7 @@ basepython = pypy3.10 deps = PyPyDispatcher==2.1.0 {[test-requirements]deps} + pytest==8.4.0 Protego==0.1.15 Twisted==21.7.0 cryptography==41.0.5 From 020bfa7e5fd98c53db17e0c72db88d43d6fbf1f6 Mon Sep 17 00:00:00 2001 From: Thalison Fernandes Date: Mon, 23 Jun 2025 12:49:57 -0300 Subject: [PATCH 048/459] Add .venv folder to .gitignore (#6901) --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 0a3f0ac1c..e02c2241d 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ docs/build *egg-info .tox venv +.venv build dist .idea From 843ad1afb1383a263b8c8da9004a899a71ea864d Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Mon, 23 Jun 2025 20:56:48 +0500 Subject: [PATCH 049/459] Replace Black with Ruff in contributing.rst. (#6903) --- docs/contributing.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/contributing.rst b/docs/contributing.rst index 0172887d6..3976d34c2 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -251,10 +251,10 @@ Coding style Please follow these coding conventions when writing code for inclusion in Scrapy: -* We use `black `_ for code formatting. +* We use `Ruff `_ for code formatting. There is a hook in the pre-commit config that will automatically format your code before every commit. You can also - run black manually with ``tox -e pre-commit``. + run Ruff manually with ``tox -e pre-commit``. * Don't put your name in the code you contribute; git provides enough metadata to identify author of the code. From 5e20b46e35c3affc5df7b5f63778e4fb924b28a8 Mon Sep 17 00:00:00 2001 From: Thalison Fernandes Date: Mon, 23 Jun 2025 13:56:29 -0300 Subject: [PATCH 050/459] Fix FileFeedStorage handling of Windows paths without file:// scheme (#6897) --- scrapy/extensions/feedexport.py | 2 +- tests/test_feedexport.py | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/scrapy/extensions/feedexport.py b/scrapy/extensions/feedexport.py index 8bcd4e40d..4ac546765 100644 --- a/scrapy/extensions/feedexport.py +++ b/scrapy/extensions/feedexport.py @@ -185,7 +185,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) + 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_feedexport.py b/tests/test_feedexport.py index 44cd10ec3..7c6425f19 100644 --- a/tests/test_feedexport.py +++ b/tests/test_feedexport.py @@ -143,6 +143,11 @@ class TestFileFeedStorage(unittest.TestCase): 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 + class TestFTPFeedStorage(unittest.TestCase): def get_test_spider(self, settings=None): From b53faacfcd64f6717645bf4cd33159cef87cd5d9 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Mon, 23 Jun 2025 21:58:54 +0500 Subject: [PATCH 051/459] Add .vscode to .gitignore. (#6907) --- .gitignore | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/.gitignore b/.gitignore index e02c2241d..4100bcd97 100644 --- a/.gitignore +++ b/.gitignore @@ -5,15 +5,16 @@ _trial_temp* dropin.cache docs/build *egg-info -.tox -venv -.venv -build -dist -.idea +.tox/ +venv/ +.venv/ +build/ +dist/ +.idea/ +.vscode/ htmlcov/ -.coverage .pytest_cache/ +.coverage .coverage.* coverage.* *.junit.xml @@ -27,4 +28,4 @@ test-output.* Thumbs.db # OSX miscellaneous -.DS_Store \ No newline at end of file +.DS_Store From 0d75355b41a84896d4ab1c19ed1b88f65206fb9f Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Mon, 23 Jun 2025 22:36:54 +0500 Subject: [PATCH 052/459] Handle exceptions in _start_request_processing(), cancel it on engine stop (#6900) --- scrapy/core/engine.py | 48 ++++++++++++++++++++++++++++++------------ scrapy/core/scraper.py | 1 - tests/test_engine.py | 23 +++++++++++++++++++- 3 files changed, 56 insertions(+), 16 deletions(-) diff --git a/scrapy/core/engine.py b/scrapy/core/engine.py index fe635dc82..d6c171247 100644 --- a/scrapy/core/engine.py +++ b/scrapy/core/engine.py @@ -7,12 +7,13 @@ For more information see docs/topics/architecture.rst from __future__ import annotations +import asyncio import logging from time import time from traceback import format_exc from typing import TYPE_CHECKING, Any, cast -from twisted.internet.defer import Deferred, inlineCallbacks, succeed +from twisted.internet.defer import CancelledError, Deferred, inlineCallbacks, succeed from twisted.python.failure import Failure from scrapy import signals @@ -108,6 +109,8 @@ class ExecutionEngine: ) self.start_time: float | None = None self._start: AsyncIterator[Any] | None = None + self._closewait: Deferred[None] | None = None + self._start_request_processing_dfd: Deferred[None] | None = None downloader_cls: type[Downloader] = load_object(self.settings["DOWNLOADER"]) try: self.scheduler_cls: type[BaseScheduler] = self._get_scheduler_class( @@ -139,9 +142,9 @@ class ExecutionEngine: self.start_time = time() await self.signals.send_catch_log_async(signal=signals.engine_started) self.running = True - self._closewait: Deferred[None] = Deferred() + self._closewait = Deferred() if _start_request_processing: - self._start_request_processing() + self._start_request_processing_dfd = self._start_request_processing() await maybe_deferred_to_future(self._closewait) def stop(self) -> Deferred[None]: @@ -150,12 +153,16 @@ class ExecutionEngine: @deferred_f_from_coro_f async def _finish_stopping_engine(_: Any) -> None: await self.signals.send_catch_log_async(signal=signals.engine_stopped) - self._closewait.callback(None) + if self._closewait: + self._closewait.callback(None) if not self.running: raise RuntimeError("Engine not running") self.running = False + if self._start_request_processing_dfd is not None: + self._start_request_processing_dfd.cancel() + self._start_request_processing_dfd = None dfd = ( self.close_spider(self.spider, reason="shutdown") if self.spider is not None @@ -217,17 +224,30 @@ class ExecutionEngine: # Starts the processing of scheduled requests, as well as a periodic # call to that processing method for scenarios where the scheduler # reports having pending requests but returns none. - assert self._slot is not None # typing - self._slot.nextcall.schedule() - self._slot.heartbeat.start(self._SLOT_HEARTBEAT_INTERVAL) + try: + assert self._slot is not None # typing + self._slot.nextcall.schedule() + self._slot.heartbeat.start(self._SLOT_HEARTBEAT_INTERVAL) - while self._start and self.spider: - await self._process_start_next() - if not self.needs_backout(): - # Give room for the outcome of self._process_start_next() to be - # processed before continuing with the next iteration. - self._slot.nextcall.schedule() - await self._slot.nextcall.wait() + while self._start and self.spider: + await self._process_start_next() + if not self.needs_backout(): + # Give room for the outcome of self._process_start_next() to be + # processed before continuing with the next iteration. + self._slot.nextcall.schedule() + await self._slot.nextcall.wait() + except (asyncio.exceptions.CancelledError, CancelledError): + # self.stop() has cancelled us, nothing to do + return + except Exception: + # an error happened, log it and stop the engine + self._start_request_processing_dfd = None + logger.error( + "Error while processing requests from start()", + exc_info=True, + extra={"spider": self.spider}, + ) + await maybe_deferred_to_future(self.stop()) def _start_scheduled_requests(self) -> None: if self._slot is None or self._slot.closing is not None or self.paused: diff --git a/scrapy/core/scraper.py b/scrapy/core/scraper.py index 1f0d57c63..dc3a287b4 100644 --- a/scrapy/core/scraper.py +++ b/scrapy/core/scraper.py @@ -140,7 +140,6 @@ class Scraper: def _check_if_closing(self) -> None: assert self.slot is not None # typing - assert self.crawler.spider if self.slot.closing and self.slot.is_idle(): assert self.crawler.spider self.slot.closing.callback(self.crawler.spider) diff --git a/tests/test_engine.py b/tests/test_engine.py index e181a36cf..d9d25c240 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -25,6 +25,7 @@ import attr import pytest from itemadapter import ItemAdapter from pydispatch import dispatcher +from testfixtures import LogCapture from twisted.internet import defer from twisted.internet.defer import inlineCallbacks from twisted.trial import unittest @@ -451,12 +452,32 @@ class TestEngine(TestEngineBase): @inlineCallbacks def test_start_already_running_exception(self): e = ExecutionEngine(get_crawler(MySpider), lambda _: None) - yield e.open_spider(MySpider(), []) + yield e.open_spider(MySpider()) e.start() with pytest.raises(RuntimeError, match="Engine already running"): yield e.start() yield e.stop() + @inlineCallbacks + def test_start_request_processing_exception(self): + class BadRequestFingerprinter: + def fingerprint(self, request): + raise ValueError # to make Scheduler.enqueue_request() fail + + class SimpleSpider(Spider): + name = "simple" + + async def start(self): + yield Request("data:,") + + crawler = get_crawler( + SimpleSpider, {"REQUEST_FINGERPRINTER_CLASS": BadRequestFingerprinter} + ) + with LogCapture() as log: + yield crawler.crawl() + assert "Error while processing requests from start()" in str(log) + assert "Spider closed (shutdown)" in str(log) + def test_short_timeout(self): args = ( sys.executable, From 7fbd56bc9baa33a4eb874f4337d740f5f25cfad3 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Mon, 23 Jun 2025 22:36:54 +0500 Subject: [PATCH 053/459] Handle exceptions in _start_request_processing(), cancel it on engine stop (#6900) --- scrapy/core/engine.py | 48 ++++++++++++++++++++++++++++++------------ scrapy/core/scraper.py | 1 - tests/test_engine.py | 23 +++++++++++++++++++- 3 files changed, 56 insertions(+), 16 deletions(-) diff --git a/scrapy/core/engine.py b/scrapy/core/engine.py index 0df9ad2b2..6c6d24d5c 100644 --- a/scrapy/core/engine.py +++ b/scrapy/core/engine.py @@ -7,12 +7,13 @@ For more information see docs/topics/architecture.rst from __future__ import annotations +import asyncio import logging from time import time from traceback import format_exc from typing import TYPE_CHECKING, Any, TypeVar, cast -from twisted.internet.defer import Deferred, inlineCallbacks, succeed +from twisted.internet.defer import CancelledError, Deferred, inlineCallbacks, succeed from twisted.internet.task import LoopingCall from twisted.python.failure import Failure @@ -102,6 +103,8 @@ class ExecutionEngine: ) self.start_time: float | None = None self._start: AsyncIterator[Any] | None = None + self._closewait: Deferred[None] | None = None + self._start_request_processing_dfd: Deferred[None] | None = None downloader_cls: type[Downloader] = load_object(self.settings["DOWNLOADER"]) try: self.scheduler_cls: type[BaseScheduler] = self._get_scheduler_class( @@ -133,9 +136,9 @@ class ExecutionEngine: self.signals.send_catch_log_deferred(signal=signals.engine_started) ) self.running = True - self._closewait: Deferred[None] = Deferred() + self._closewait = Deferred() if _start_request_processing: - self._start_request_processing() + self._start_request_processing_dfd = self._start_request_processing() await maybe_deferred_to_future(self._closewait) def stop(self) -> Deferred[None]: @@ -146,12 +149,16 @@ class ExecutionEngine: await maybe_deferred_to_future( self.signals.send_catch_log_deferred(signal=signals.engine_stopped) ) - self._closewait.callback(None) + if self._closewait: + self._closewait.callback(None) if not self.running: raise RuntimeError("Engine not running") self.running = False + if self._start_request_processing_dfd is not None: + self._start_request_processing_dfd.cancel() + self._start_request_processing_dfd = None dfd = ( self.close_spider(self.spider, reason="shutdown") if self.spider is not None @@ -213,17 +220,30 @@ class ExecutionEngine: # Starts the processing of scheduled requests, as well as a periodic # call to that processing method for scenarios where the scheduler # reports having pending requests but returns none. - assert self._slot is not None # typing - self._slot.nextcall.schedule() - self._slot.heartbeat.start(self._SLOT_HEARTBEAT_INTERVAL) + try: + assert self._slot is not None # typing + self._slot.nextcall.schedule() + self._slot.heartbeat.start(self._SLOT_HEARTBEAT_INTERVAL) - while self._start and self.spider: - await self._process_start_next() - if not self.needs_backout(): - # Give room for the outcome of self._process_start_next() to be - # processed before continuing with the next iteration. - self._slot.nextcall.schedule() - await self._slot.nextcall.wait() + while self._start and self.spider: + await self._process_start_next() + if not self.needs_backout(): + # Give room for the outcome of self._process_start_next() to be + # processed before continuing with the next iteration. + self._slot.nextcall.schedule() + await self._slot.nextcall.wait() + except (asyncio.exceptions.CancelledError, CancelledError): + # self.stop() has cancelled us, nothing to do + return + except Exception: + # an error happened, log it and stop the engine + self._start_request_processing_dfd = None + logger.error( + "Error while processing requests from start()", + exc_info=True, + extra={"spider": self.spider}, + ) + await maybe_deferred_to_future(self.stop()) def _start_scheduled_requests(self) -> None: if self._slot is None or self._slot.closing is not None or self.paused: diff --git a/scrapy/core/scraper.py b/scrapy/core/scraper.py index ac720e03f..a2fe281e0 100644 --- a/scrapy/core/scraper.py +++ b/scrapy/core/scraper.py @@ -139,7 +139,6 @@ class Scraper: def _check_if_closing(self) -> None: assert self.slot is not None # typing - assert self.crawler.spider if self.slot.closing and self.slot.is_idle(): assert self.crawler.spider self.slot.closing.callback(self.crawler.spider) diff --git a/tests/test_engine.py b/tests/test_engine.py index b2e436425..a3c5243d5 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -25,6 +25,7 @@ import attr import pytest from itemadapter import ItemAdapter from pydispatch import dispatcher +from testfixtures import LogCapture from twisted.internet import defer, reactor from twisted.trial import unittest from twisted.web import server, static, util @@ -448,7 +449,7 @@ class TestEngine(TestEngineBase): @defer.inlineCallbacks def test_start_already_running_exception(self): e = ExecutionEngine(get_crawler(MySpider), lambda _: None) - yield e.open_spider(MySpider(), []) + yield e.open_spider(MySpider()) e.start() def cb(exc: BaseException) -> None: @@ -459,6 +460,26 @@ class TestEngine(TestEngineBase): finally: yield e.stop() + @defer.inlineCallbacks + def test_start_request_processing_exception(self): + class BadRequestFingerprinter: + def fingerprint(self, request): + raise ValueError # to make Scheduler.enqueue_request() fail + + class SimpleSpider(Spider): + name = "simple" + + async def start(self): + yield Request("data:,") + + crawler = get_crawler( + SimpleSpider, {"REQUEST_FINGERPRINTER_CLASS": BadRequestFingerprinter} + ) + with LogCapture() as log: + yield crawler.crawl() + assert "Error while processing requests from start()" in str(log) + assert "Spider closed (shutdown)" in str(log) + def test_short_timeout(self): args = ( sys.executable, From d70f8a3f14252715fbc9b9541364c5b5d142335f Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Mon, 23 Jun 2025 23:39:24 +0500 Subject: [PATCH 054/459] Refactoring of test_utils_*. (#6905) --- tests/test_command_startproject.py | 2 - tests/test_request_cb_kwargs.py | 2 - tests/test_toplevel.py | 42 ++- tests/test_urlparse_monkeypatches.py | 10 - tests/test_utils_conf.py | 11 +- tests/test_utils_console.py | 35 +- tests/test_utils_curl.py | 6 +- tests/test_utils_datatypes.py | 12 +- tests/test_utils_deprecate.py | 5 +- tests/test_utils_display.py | 146 +++---- tests/test_utils_gz.py | 78 ++-- tests/test_utils_httpobj.py | 27 +- tests/test_utils_iterators.py | 80 ++-- tests/test_utils_log.py | 185 +++++---- tests/test_utils_project.py | 33 +- tests/test_utils_python.py | 283 +++++++------- tests/test_utils_request.py | 100 ++--- tests/test_utils_response.py | 370 +++++++++--------- tests/test_utils_serialize.py | 30 +- tests/test_utils_sitemap.py | 421 ++++++++++----------- tests/test_utils_spider.py | 26 +- tests/test_utils_template.py | 27 +- tests/test_utils_trackref.py | 95 ++--- tests/test_utils_url.py | 543 +++++++++++---------------- 24 files changed, 1251 insertions(+), 1318 deletions(-) delete mode 100644 tests/test_urlparse_monkeypatches.py diff --git a/tests/test_command_startproject.py b/tests/test_command_startproject.py index 988ad50b9..1edef0b4a 100644 --- a/tests/test_command_startproject.py +++ b/tests/test_command_startproject.py @@ -106,8 +106,6 @@ def get_permissions_dict( class TestStartprojectTemplates(TestProjectBase): - maxDiff = None - def setup_method(self): super().setup_method() self.tmpl = str(Path(self.temp_path, "templates")) diff --git a/tests/test_request_cb_kwargs.py b/tests/test_request_cb_kwargs.py index 9d2e5f997..1714bd4db 100644 --- a/tests/test_request_cb_kwargs.py +++ b/tests/test_request_cb_kwargs.py @@ -150,8 +150,6 @@ class KeywordArgumentsSpider(MockServerSpider): class TestCallbackKeywordArguments(TestCase): - maxDiff = None - @classmethod def setUpClass(cls): cls.mockserver = MockServer() diff --git a/tests/test_toplevel.py b/tests/test_toplevel.py index a4f31096e..66a6f5318 100644 --- a/tests/test_toplevel.py +++ b/tests/test_toplevel.py @@ -1,31 +1,35 @@ import scrapy -class TestToplevel: - def test_version(self): - assert isinstance(scrapy.__version__, str) +def test_version(): + assert isinstance(scrapy.__version__, str) - def test_version_info(self): - assert isinstance(scrapy.version_info, tuple) - def test_request_shortcut(self): - from scrapy.http import FormRequest, Request +def test_version_info(): + assert isinstance(scrapy.version_info, tuple) - assert scrapy.Request is Request - assert scrapy.FormRequest is FormRequest - def test_spider_shortcut(self): - from scrapy.spiders import Spider +def test_request_shortcut(): + from scrapy.http import FormRequest, Request - assert scrapy.Spider is Spider + assert scrapy.Request is Request + assert scrapy.FormRequest is FormRequest - def test_selector_shortcut(self): - from scrapy.selector import Selector - assert scrapy.Selector is Selector +def test_spider_shortcut(): + from scrapy.spiders import Spider - def test_item_shortcut(self): - from scrapy.item import Field, Item + assert scrapy.Spider is Spider - assert scrapy.Item is Item - assert scrapy.Field is Field + +def test_selector_shortcut(): + from scrapy.selector import Selector + + assert scrapy.Selector is Selector + + +def test_item_shortcut(): + from scrapy.item import Field, Item + + assert scrapy.Item is Item + assert scrapy.Field is Field diff --git a/tests/test_urlparse_monkeypatches.py b/tests/test_urlparse_monkeypatches.py deleted file mode 100644 index 0e1e89e81..000000000 --- a/tests/test_urlparse_monkeypatches.py +++ /dev/null @@ -1,10 +0,0 @@ -from urllib.parse import urlparse - - -class TestUrlparse: - def test_s3_url(self): - p = urlparse("s3://bucket/key/name?param=value") - assert p.scheme == "s3" - assert p.hostname == "bucket" - assert p.path == "/key/name" - assert p.query == "param=value" diff --git a/tests/test_utils_conf.py b/tests/test_utils_conf.py index 26f158380..ed7dda18d 100644 --- a/tests/test_utils_conf.py +++ b/tests/test_utils_conf.py @@ -47,12 +47,11 @@ class TestBuildComponentList: assert build_component_list(d, convert=lambda x: x) == ["b", "c", "a"] -class TestUtilsConf: - def test_arglist_to_dict(self): - assert arglist_to_dict(["arg1=val1", "arg2=val2"]) == { - "arg1": "val1", - "arg2": "val2", - } +def test_arglist_to_dict(): + assert arglist_to_dict(["arg1=val1", "arg2=val2"]) == { + "arg1": "val1", + "arg2": "val2", + } class TestFeedExportConfig: diff --git a/tests/test_utils_console.py b/tests/test_utils_console.py index 6598bdce7..dc1d96f66 100644 --- a/tests/test_utils_console.py +++ b/tests/test_utils_console.py @@ -18,23 +18,24 @@ except ImportError: ipy = False -class TestUtilsConsole: - def test_get_shell_embed_func(self): - shell = get_shell_embed_func(["invalid"]) - assert shell is None +def test_get_shell_embed_func(): + shell = get_shell_embed_func(["invalid"]) + assert shell is None - shell = get_shell_embed_func(["invalid", "python"]) - assert callable(shell) - assert shell.__name__ == "_embed_standard_shell" + shell = get_shell_embed_func(["invalid", "python"]) + assert callable(shell) + assert shell.__name__ == "_embed_standard_shell" - @pytest.mark.skipif(not bpy, reason="bpython not available in testenv") - def test_get_shell_embed_func2(self): - shell = get_shell_embed_func(["bpython"]) - assert callable(shell) - assert shell.__name__ == "_embed_bpython_shell" - @pytest.mark.skipif(not ipy, reason="IPython not available in testenv") - def test_get_shell_embed_func3(self): - # default shell should be 'ipython' - shell = get_shell_embed_func() - assert shell.__name__ == "_embed_ipython_shell" +@pytest.mark.skipif(not bpy, reason="bpython not available in testenv") +def test_get_shell_embed_func_bpython(): + shell = get_shell_embed_func(["bpython"]) + assert callable(shell) + assert shell.__name__ == "_embed_bpython_shell" + + +@pytest.mark.skipif(not ipy, reason="IPython not available in testenv") +def test_get_shell_embed_func_ipython(): + # default shell should be 'ipython' + shell = get_shell_embed_func() + assert shell.__name__ == "_embed_ipython_shell" diff --git a/tests/test_utils_curl.py b/tests/test_utils_curl.py index e8dd88049..02362693a 100644 --- a/tests/test_utils_curl.py +++ b/tests/test_utils_curl.py @@ -1,4 +1,5 @@ import warnings +from typing import Any import pytest from w3lib.http import basic_auth_header @@ -8,9 +9,8 @@ from scrapy.utils.curl import curl_to_request_kwargs class TestCurlToRequestKwargs: - maxDiff = 5000 - - def _test_command(self, curl_command, expected_result): + @staticmethod + def _test_command(curl_command: str, expected_result: dict[str, Any]) -> None: result = curl_to_request_kwargs(curl_command) assert result == expected_result try: diff --git a/tests/test_utils_datatypes.py b/tests/test_utils_datatypes.py index 75b6b0e99..352e49165 100644 --- a/tests/test_utils_datatypes.py +++ b/tests/test_utils_datatypes.py @@ -1,5 +1,6 @@ import copy import warnings +from abc import ABC, abstractmethod from collections.abc import Iterator, Mapping, MutableMapping import pytest @@ -16,7 +17,12 @@ from scrapy.utils.datatypes import ( from scrapy.utils.python import garbage_collect -class CaseInsensitiveDictBase: +class TestCaseInsensitiveDictBase(ABC): + @property + @abstractmethod + def dict_class(self) -> type[MutableMapping]: + raise NotImplementedError + def test_init_dict(self): seq = {"red": 1, "black": 3} d = self.dict_class(seq) @@ -199,7 +205,7 @@ class CaseInsensitiveDictBase: assert h1.get("header1") == h3.get("HEADER1") -class TestCaseInsensitiveDict(CaseInsensitiveDictBase): +class TestCaseInsensitiveDict(TestCaseInsensitiveDictBase): dict_class = CaseInsensitiveDict def test_repr(self): @@ -216,7 +222,7 @@ class TestCaseInsensitiveDict(CaseInsensitiveDictBase): @pytest.mark.filterwarnings("ignore::scrapy.exceptions.ScrapyDeprecationWarning") -class TestCaselessDict(CaseInsensitiveDictBase): +class TestCaselessDict(TestCaseInsensitiveDictBase): dict_class = CaselessDict def test_deprecation_message(self): diff --git a/tests/test_utils_deprecate.py b/tests/test_utils_deprecate.py index 662de0dc3..a88b5e008 100644 --- a/tests/test_utils_deprecate.py +++ b/tests/test_utils_deprecate.py @@ -1,6 +1,7 @@ import inspect import warnings from unittest import mock +from warnings import WarningMessage import pytest @@ -21,7 +22,9 @@ class NewName(SomeBaseClass): class TestWarnWhenSubclassed: - def _mywarnings(self, w, category=MyWarning): + def _mywarnings( + self, w: list[WarningMessage], category: type[Warning] = MyWarning + ) -> 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_display.py b/tests/test_utils_display.py index cea564653..20251ca59 100644 --- a/tests/test_utils_display.py +++ b/tests/test_utils_display.py @@ -3,88 +3,92 @@ from unittest import mock from scrapy.utils.display import pformat, pprint - -class TestDisplay: - object = {"a": 1} - colorized_strings = { +value = {"a": 1} +colorized_strings = { + ( ( - ( - "{\x1b[33m'\x1b[39;49;00m\x1b[33ma\x1b[39;49;00m\x1b[33m'" - "\x1b[39;49;00m: \x1b[34m1\x1b[39;49;00m}" - ) - + suffix + "{\x1b[33m'\x1b[39;49;00m\x1b[33ma\x1b[39;49;00m\x1b[33m'" + "\x1b[39;49;00m: \x1b[34m1\x1b[39;49;00m}" ) - for suffix in ( - # https://github.com/pygments/pygments/issues/2313 - "\n", # pygments ≤ 2.13 - "\x1b[37m\x1b[39;49;00m\n", # pygments ≥ 2.14 - ) - } - plain_string = "{'a': 1}" + + suffix + ) + for suffix in ( + # https://github.com/pygments/pygments/issues/2313 + "\n", # pygments ≤ 2.13 + "\x1b[37m\x1b[39;49;00m\n", # pygments ≥ 2.14 + ) +} +plain_string = "{'a': 1}" - @mock.patch("sys.platform", "linux") - @mock.patch("sys.stdout.isatty") - def test_pformat(self, isatty): - isatty.return_value = True - assert pformat(self.object) in self.colorized_strings - @mock.patch("sys.stdout.isatty") - def test_pformat_dont_colorize(self, isatty): - isatty.return_value = True - assert pformat(self.object, colorize=False) == self.plain_string +@mock.patch("sys.platform", "linux") +@mock.patch("sys.stdout.isatty") +def test_pformat(isatty): + isatty.return_value = True + assert pformat(value) in colorized_strings - def test_pformat_not_tty(self): - assert pformat(self.object) == self.plain_string - @mock.patch("sys.platform", "win32") - @mock.patch("platform.version") - @mock.patch("sys.stdout.isatty") - def test_pformat_old_windows(self, isatty, version): - isatty.return_value = True - version.return_value = "10.0.14392" - assert pformat(self.object) in self.colorized_strings +@mock.patch("sys.stdout.isatty") +def test_pformat_dont_colorize(isatty): + isatty.return_value = True + assert pformat(value, colorize=False) == plain_string - @mock.patch("sys.platform", "win32") - @mock.patch("scrapy.utils.display._enable_windows_terminal_processing") - @mock.patch("platform.version") - @mock.patch("sys.stdout.isatty") - def test_pformat_windows_no_terminal_processing( - self, isatty, version, terminal_processing - ): - isatty.return_value = True - version.return_value = "10.0.14393" - terminal_processing.return_value = False - assert pformat(self.object) == self.plain_string - @mock.patch("sys.platform", "win32") - @mock.patch("scrapy.utils.display._enable_windows_terminal_processing") - @mock.patch("platform.version") - @mock.patch("sys.stdout.isatty") - def test_pformat_windows(self, isatty, version, terminal_processing): - isatty.return_value = True - version.return_value = "10.0.14393" - terminal_processing.return_value = True - assert pformat(self.object) in self.colorized_strings +def test_pformat_not_tty(): + assert pformat(value) == plain_string - @mock.patch("sys.platform", "linux") - @mock.patch("sys.stdout.isatty") - def test_pformat_no_pygments(self, isatty): - isatty.return_value = True - import builtins +@mock.patch("sys.platform", "win32") +@mock.patch("platform.version") +@mock.patch("sys.stdout.isatty") +def test_pformat_old_windows(isatty, version): + isatty.return_value = True + version.return_value = "10.0.14392" + assert pformat(value) in colorized_strings - real_import = builtins.__import__ - def mock_import(name, globals, locals, fromlist, level): - if "pygments" in name: - raise ImportError - return real_import(name, globals, locals, fromlist, level) +@mock.patch("sys.platform", "win32") +@mock.patch("scrapy.utils.display._enable_windows_terminal_processing") +@mock.patch("platform.version") +@mock.patch("sys.stdout.isatty") +def test_pformat_windows_no_terminal_processing(isatty, version, terminal_processing): + isatty.return_value = True + version.return_value = "10.0.14393" + terminal_processing.return_value = False + assert pformat(value) == plain_string - builtins.__import__ = mock_import - assert pformat(self.object) == self.plain_string - builtins.__import__ = real_import - def test_pprint(self): - with mock.patch("sys.stdout", new=StringIO()) as mock_out: - pprint(self.object) - assert mock_out.getvalue() == "{'a': 1}\n" +@mock.patch("sys.platform", "win32") +@mock.patch("scrapy.utils.display._enable_windows_terminal_processing") +@mock.patch("platform.version") +@mock.patch("sys.stdout.isatty") +def test_pformat_windows(isatty, version, terminal_processing): + isatty.return_value = True + version.return_value = "10.0.14393" + terminal_processing.return_value = True + assert pformat(value) in colorized_strings + + +@mock.patch("sys.platform", "linux") +@mock.patch("sys.stdout.isatty") +def test_pformat_no_pygments(isatty): + isatty.return_value = True + + import builtins + + real_import = builtins.__import__ + + def mock_import(name, globals, locals, fromlist, level): + if "pygments" in name: + raise ImportError + return real_import(name, globals, locals, fromlist, level) + + builtins.__import__ = mock_import + assert pformat(value) == plain_string + builtins.__import__ = real_import + + +def test_pprint(): + with mock.patch("sys.stdout", new=StringIO()) as mock_out: + pprint(value) + assert mock_out.getvalue() == "{'a': 1}\n" diff --git a/tests/test_utils_gz.py b/tests/test_utils_gz.py index c43ed152b..06fdf9cba 100644 --- a/tests/test_utils_gz.py +++ b/tests/test_utils_gz.py @@ -11,47 +11,51 @@ from tests import tests_datadir SAMPLEDIR = Path(tests_datadir, "compressed") -class TestGunzip: - def test_gunzip_basic(self): - r1 = Response( - "http://www.example.com", - body=(SAMPLEDIR / "feed-sample1.xml.gz").read_bytes(), - ) - assert gzip_magic_number(r1) +def test_gunzip_basic(): + r1 = Response( + "http://www.example.com", + body=(SAMPLEDIR / "feed-sample1.xml.gz").read_bytes(), + ) + assert gzip_magic_number(r1) - r2 = Response("http://www.example.com", body=gunzip(r1.body)) - assert not gzip_magic_number(r2) - assert len(r2.body) == 9950 + r2 = Response("http://www.example.com", body=gunzip(r1.body)) + assert not gzip_magic_number(r2) + assert len(r2.body) == 9950 - def test_gunzip_truncated(self): - text = gunzip((SAMPLEDIR / "truncated-crc-error.gz").read_bytes()) - assert text.endswith(b"") - assert not gzip_magic_number(r2) +def test_gunzip_no_gzip_file_raises(): + with pytest.raises(BadGzipFile): + gunzip((SAMPLEDIR / "feed-sample1.xml").read_bytes()) - def test_is_gzipped_empty(self): - r1 = Response("http://www.example.com") - assert not gzip_magic_number(r1) - def test_gunzip_illegal_eof(self): - text = html_to_unicode( - "charset=cp1252", gunzip((SAMPLEDIR / "unexpected-eof.gz").read_bytes()) - )[1] - expected_text = (SAMPLEDIR / "unexpected-eof-output.txt").read_text( - encoding="utf-8" - ) - assert len(text) == len(expected_text) - assert text == expected_text +def test_gunzip_truncated_short(): + r1 = Response( + "http://www.example.com", + body=(SAMPLEDIR / "truncated-crc-error-short.gz").read_bytes(), + ) + assert gzip_magic_number(r1) + + r2 = Response("http://www.example.com", body=gunzip(r1.body)) + assert r2.body.endswith(b"") + assert not gzip_magic_number(r2) + + +def test_is_gzipped_empty(): + r1 = Response("http://www.example.com") + assert not gzip_magic_number(r1) + + +def test_gunzip_illegal_eof(): + text = html_to_unicode( + "charset=cp1252", gunzip((SAMPLEDIR / "unexpected-eof.gz").read_bytes()) + )[1] + expected_text = (SAMPLEDIR / "unexpected-eof-output.txt").read_text( + encoding="utf-8" + ) + assert len(text) == len(expected_text) + assert text == expected_text diff --git a/tests/test_utils_httpobj.py b/tests/test_utils_httpobj.py index 0c05ef7d6..9bd86f7fb 100644 --- a/tests/test_utils_httpobj.py +++ b/tests/test_utils_httpobj.py @@ -4,18 +4,17 @@ from scrapy.http import Request from scrapy.utils.httpobj import urlparse_cached -class TestHttpobjUtils: - def test_urlparse_cached(self): - url = "http://www.example.com/index.html" - request1 = Request(url) - request2 = Request(url) - req1a = urlparse_cached(request1) - req1b = urlparse_cached(request1) - req2 = urlparse_cached(request2) - urlp = urlparse(url) +def test_urlparse_cached(): + url = "http://www.example.com/index.html" + request1 = Request(url) + request2 = Request(url) + req1a = urlparse_cached(request1) + req1b = urlparse_cached(request1) + req2 = urlparse_cached(request2) + urlp = urlparse(url) - assert req1a == req2 - assert req1a == urlp - assert req1a is req1b - assert req1a is not req2 - assert req1a is not req2 + assert req1a == req2 + assert req1a == urlp + assert req1a is req1b + assert req1a is not req2 + assert req1a is not req2 diff --git a/tests/test_utils_iterators.py b/tests/test_utils_iterators.py index fa0d37866..ac32fff2c 100644 --- a/tests/test_utils_iterators.py +++ b/tests/test_utils_iterators.py @@ -1,3 +1,8 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING, Any + import pytest from scrapy.exceptions import ScrapyDeprecationWarning @@ -5,9 +10,19 @@ from scrapy.http import Response, TextResponse, XmlResponse from scrapy.utils.iterators import _body_or_str, csviter, xmliter, xmliter_lxml from tests import get_testdata +if TYPE_CHECKING: + from collections.abc import Iterator + + from scrapy import Selector + + +class TestXmliterBase(ABC): + @abstractmethod + def xmliter( + self, obj: Response | str | bytes, nodename: str, *args: Any + ) -> Iterator[Selector]: + raise NotImplementedError -class XmliterBase: - @pytest.mark.filterwarnings("ignore::scrapy.exceptions.ScrapyDeprecationWarning") def test_xmliter(self): body = b""" @@ -39,7 +54,6 @@ class XmliterBase: ("002", ["Name 2"], ["Type 2"]), ] - @pytest.mark.filterwarnings("ignore::scrapy.exceptions.ScrapyDeprecationWarning") def test_xmliter_unusual_node(self): body = b""" @@ -53,7 +67,6 @@ class XmliterBase: ] assert nodenames == [["matchme..."]] - @pytest.mark.filterwarnings("ignore::scrapy.exceptions.ScrapyDeprecationWarning") def test_xmliter_unicode(self): # example taken from https://github.com/scrapy/scrapy/issues/1665 body = """ @@ -113,7 +126,6 @@ class XmliterBase: ("27", ["A"], ["27"]), ] - @pytest.mark.filterwarnings("ignore::scrapy.exceptions.ScrapyDeprecationWarning") def test_xmliter_text(self): body = ( '' @@ -125,7 +137,6 @@ class XmliterBase: ["two"], ] - @pytest.mark.filterwarnings("ignore::scrapy.exceptions.ScrapyDeprecationWarning") def test_xmliter_namespaces(self): body = b""" @@ -163,7 +174,6 @@ class XmliterBase: assert node.xpath("id/text()").getall() == [] assert node.xpath("price/text()").getall() == [] - @pytest.mark.filterwarnings("ignore::scrapy.exceptions.ScrapyDeprecationWarning") def test_xmliter_namespaced_nodename(self): body = b""" @@ -191,7 +201,6 @@ class XmliterBase: "http://www.mydummycompany.com/images/item1.jpg" ] - @pytest.mark.filterwarnings("ignore::scrapy.exceptions.ScrapyDeprecationWarning") def test_xmliter_namespaced_nodename_missing(self): body = b""" @@ -216,7 +225,6 @@ class XmliterBase: with pytest.raises(StopIteration): next(my_iter) - @pytest.mark.filterwarnings("ignore::scrapy.exceptions.ScrapyDeprecationWarning") def test_xmliter_exception(self): body = ( '' @@ -229,13 +237,11 @@ class XmliterBase: with pytest.raises(StopIteration): next(iter) - @pytest.mark.filterwarnings("ignore::scrapy.exceptions.ScrapyDeprecationWarning") def test_xmliter_objtype_exception(self): i = self.xmliter(42, "product") with pytest.raises(TypeError): next(i) - @pytest.mark.filterwarnings("ignore::scrapy.exceptions.ScrapyDeprecationWarning") def test_xmliter_encoding(self): body = ( b'\n' @@ -250,8 +256,12 @@ class XmliterBase: ) -class TestXmliter(XmliterBase): - xmliter = staticmethod(xmliter) +@pytest.mark.filterwarnings("ignore::scrapy.exceptions.ScrapyDeprecationWarning") +class TestXmliter(TestXmliterBase): + def xmliter( + self, obj: Response | str | bytes, nodename: str, *args: Any + ) -> Iterator[Selector]: + return xmliter(obj, nodename) def test_deprecation(self): body = b""" @@ -267,8 +277,11 @@ class TestXmliter(XmliterBase): next(self.xmliter(body, "product")) -class TestLxmlXmliter(XmliterBase): - xmliter = staticmethod(xmliter_lxml) +class TestLxmlXmliter(TestXmliterBase): + def xmliter( + self, obj: Response | str | bytes, nodename: str, *args: Any + ) -> Iterator[Selector]: + return xmliter_lxml(obj, nodename, *args) def test_xmliter_iterate_namespace(self): body = b""" @@ -493,23 +506,32 @@ class TestUtilsCsv: ] -class TestHelper: +class TestBodyOrStr: bbody = b"utf8-body" ubody = bbody.decode("utf8") - txtresponse = TextResponse(url="http://example.org/", body=bbody, encoding="utf-8") - response = Response(url="http://example.org/", body=bbody) - def test_body_or_str(self): - for obj in (self.bbody, self.ubody, self.txtresponse, self.response): - r1 = _body_or_str(obj) - self._assert_type_and_value(r1, self.ubody, obj) - r2 = _body_or_str(obj, unicode=True) - self._assert_type_and_value(r2, self.ubody, obj) - 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) + @pytest.mark.parametrize( + "obj", + [ + bbody, + ubody, + TextResponse(url="http://example.org/", body=bbody, encoding="utf-8"), + Response(url="http://example.org/", body=bbody), + ], + ) + def test_body_or_str(self, obj: Response | str | bytes) -> None: + r1 = _body_or_str(obj) + self._assert_type_and_value(r1, self.ubody, obj) + r2 = _body_or_str(obj, unicode=True) + self._assert_type_and_value(r2, self.ubody, obj) + 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) - def _assert_type_and_value(self, a, b, obj): + @staticmethod + def _assert_type_and_value( + a: str | bytes, b: str | bytes, obj: Response | str | bytes + ) -> None: assert type(a) is type(b), f"Got {type(a)}, expected {type(b)} for {obj!r}" assert a == b diff --git a/tests/test_utils_log.py b/tests/test_utils_log.py index 56375606c..f40e424ff 100644 --- a/tests/test_utils_log.py +++ b/tests/test_utils_log.py @@ -22,7 +22,9 @@ from scrapy.utils.test import get_crawler from tests.spiders import LogSpider if TYPE_CHECKING: - from collections.abc import Mapping, MutableMapping + from collections.abc import Generator, Mapping, MutableMapping + + from scrapy.crawler import Crawler class TestFailureToExcInfo: @@ -70,33 +72,41 @@ class TestTopLevelFormatter: class TestLogCounterHandler: - def setup_method(self): + @pytest.fixture + def crawler(self) -> Crawler: settings = {"LOG_LEVEL": "WARNING"} - self.logger = logging.getLogger("test") - self.logger.setLevel(logging.NOTSET) - self.logger.propagate = False - self.crawler = get_crawler(settings_dict=settings) - self.handler = LogCounterHandler(self.crawler) - self.logger.addHandler(self.handler) + return get_crawler(settings_dict=settings) - def teardown_method(self): - self.logger.propagate = True - self.logger.removeHandler(self.handler) + @pytest.fixture + def logger(self, crawler: Crawler) -> Generator[logging.Logger]: + logger = logging.getLogger("test") + logger.setLevel(logging.NOTSET) + logger.propagate = False + handler = LogCounterHandler(crawler) + logger.addHandler(handler) - def test_init(self): - assert self.crawler.stats.get_value("log_count/DEBUG") is None - assert self.crawler.stats.get_value("log_count/INFO") is None - assert self.crawler.stats.get_value("log_count/WARNING") is None - assert self.crawler.stats.get_value("log_count/ERROR") is None - assert self.crawler.stats.get_value("log_count/CRITICAL") is None + yield logger - def test_accepted_level(self): - self.logger.error("test log msg") - assert self.crawler.stats.get_value("log_count/ERROR") == 1 + logger.propagate = True + logger.removeHandler(handler) - def test_filtered_out_level(self): - self.logger.debug("test log msg") - assert self.crawler.stats.get_value("log_count/INFO") is None + def test_init(self, crawler: Crawler, logger: logging.Logger) -> None: + assert crawler.stats + assert crawler.stats.get_value("log_count/DEBUG") is None + assert crawler.stats.get_value("log_count/INFO") is None + assert crawler.stats.get_value("log_count/WARNING") is None + assert crawler.stats.get_value("log_count/ERROR") is None + assert crawler.stats.get_value("log_count/CRITICAL") is None + + def test_accepted_level(self, crawler: Crawler, logger: logging.Logger) -> None: + logger.error("test log msg") + assert crawler.stats + assert crawler.stats.get_value("log_count/ERROR") == 1 + + 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 class TestStreamLogger: @@ -135,7 +145,7 @@ class TestStreamLogger: ) def test_spider_logger_adapter_process( base_extra: Mapping[str, Any], log_extra: MutableMapping, expected_extra: dict -): +) -> None: logger = logging.getLogger("test") spider_logger_adapter = SpiderLoggerAdapter(logger, base_extra) @@ -149,59 +159,75 @@ def test_spider_logger_adapter_process( class TestLogging: - def setup_method(self): - self.log_stream = StringIO() - handler = logging.StreamHandler(self.log_stream) + @pytest.fixture + def log_stream(self) -> StringIO: + return StringIO() + + @pytest.fixture + def spider(self) -> LogSpider: + return LogSpider() + + @pytest.fixture(autouse=True) + def logger(self, log_stream: StringIO) -> Generator[logging.Logger]: + handler = logging.StreamHandler(log_stream) logger = logging.getLogger("log_spider") logger.addHandler(handler) logger.setLevel(logging.DEBUG) - self.handler = handler - self.logger = logger - self.spider = LogSpider() - def teardown_method(self): - self.logger.removeHandler(self.handler) + yield logger - def test_debug_logging(self): + logger.removeHandler(handler) + + def test_debug_logging(self, log_stream: StringIO, spider: LogSpider) -> None: log_message = "Foo message" - self.spider.log_debug(log_message) - log_contents = self.log_stream.getvalue() + spider.log_debug(log_message) + log_contents = log_stream.getvalue() assert log_contents == f"{log_message}\n" - def test_info_logging(self): + def test_info_logging(self, log_stream: StringIO, spider: LogSpider) -> None: log_message = "Bar message" - self.spider.log_info(log_message) - log_contents = self.log_stream.getvalue() + spider.log_info(log_message) + log_contents = log_stream.getvalue() assert log_contents == f"{log_message}\n" - def test_warning_logging(self): + def test_warning_logging(self, log_stream: StringIO, spider: LogSpider) -> None: log_message = "Baz message" - self.spider.log_warning(log_message) - log_contents = self.log_stream.getvalue() + spider.log_warning(log_message) + log_contents = log_stream.getvalue() assert log_contents == f"{log_message}\n" - def test_error_logging(self): + def test_error_logging(self, log_stream: StringIO, spider: LogSpider) -> None: log_message = "Foo bar message" - self.spider.log_error(log_message) - log_contents = self.log_stream.getvalue() + spider.log_error(log_message) + log_contents = log_stream.getvalue() assert log_contents == f"{log_message}\n" - def test_critical_logging(self): + def test_critical_logging(self, log_stream: StringIO, spider: LogSpider) -> None: log_message = "Foo bar baz message" - self.spider.log_critical(log_message) - log_contents = self.log_stream.getvalue() + spider.log_critical(log_message) + log_contents = log_stream.getvalue() assert log_contents == f"{log_message}\n" class TestLoggingWithExtra: - def setup_method(self): - self.log_stream = StringIO() - handler = logging.StreamHandler(self.log_stream) + regex_pattern = re.compile(r"^]+>$") + + @pytest.fixture + def log_stream(self) -> StringIO: + return StringIO() + + @pytest.fixture + def spider(self) -> LogSpider: + return LogSpider() + + @pytest.fixture(autouse=True) + def logger(self, log_stream: StringIO) -> Generator[logging.Logger]: + handler = logging.StreamHandler(log_stream) formatter = logging.Formatter( '{"levelname": "%(levelname)s", "message": "%(message)s", "spider": "%(spider)s", "important_info": "%(important_info)s"}' ) @@ -209,80 +235,79 @@ class TestLoggingWithExtra: logger = logging.getLogger("log_spider") logger.addHandler(handler) logger.setLevel(logging.DEBUG) - self.handler = handler - self.logger = logger - self.spider = LogSpider() - self.regex_pattern = re.compile(r"^]+>$") - def teardown_method(self): - self.logger.removeHandler(self.handler) + yield logger - def test_debug_logging(self): + logger.removeHandler(handler) + + def test_debug_logging(self, log_stream: StringIO, spider: LogSpider) -> None: log_message = "Foo message" extra = {"important_info": "foo"} - self.spider.log_debug(log_message, extra) - log_contents = self.log_stream.getvalue() - log_contents = json.loads(log_contents) + spider.log_debug(log_message, extra) + log_contents_str = log_stream.getvalue() + log_contents = json.loads(log_contents_str) assert log_contents["levelname"] == "DEBUG" assert log_contents["message"] == log_message assert self.regex_pattern.match(log_contents["spider"]) assert log_contents["important_info"] == extra["important_info"] - def test_info_logging(self): + def test_info_logging(self, log_stream: StringIO, spider: LogSpider) -> None: log_message = "Bar message" extra = {"important_info": "bar"} - self.spider.log_info(log_message, extra) - log_contents = self.log_stream.getvalue() - log_contents = json.loads(log_contents) + spider.log_info(log_message, extra) + log_contents_str = log_stream.getvalue() + log_contents = json.loads(log_contents_str) assert log_contents["levelname"] == "INFO" assert log_contents["message"] == log_message assert self.regex_pattern.match(log_contents["spider"]) assert log_contents["important_info"] == extra["important_info"] - def test_warning_logging(self): + def test_warning_logging(self, log_stream: StringIO, spider: LogSpider) -> None: log_message = "Baz message" extra = {"important_info": "baz"} - self.spider.log_warning(log_message, extra) - log_contents = self.log_stream.getvalue() - log_contents = json.loads(log_contents) + spider.log_warning(log_message, extra) + log_contents_str = log_stream.getvalue() + log_contents = json.loads(log_contents_str) assert log_contents["levelname"] == "WARNING" assert log_contents["message"] == log_message assert self.regex_pattern.match(log_contents["spider"]) assert log_contents["important_info"] == extra["important_info"] - def test_error_logging(self): + def test_error_logging(self, log_stream: StringIO, spider: LogSpider) -> None: log_message = "Foo bar message" extra = {"important_info": "foo bar"} - self.spider.log_error(log_message, extra) - log_contents = self.log_stream.getvalue() - log_contents = json.loads(log_contents) + spider.log_error(log_message, extra) + log_contents_str = log_stream.getvalue() + log_contents = json.loads(log_contents_str) assert log_contents["levelname"] == "ERROR" assert log_contents["message"] == log_message assert self.regex_pattern.match(log_contents["spider"]) assert log_contents["important_info"] == extra["important_info"] - def test_critical_logging(self): + def test_critical_logging(self, log_stream: StringIO, spider: LogSpider) -> None: log_message = "Foo bar baz message" extra = {"important_info": "foo bar baz"} - self.spider.log_critical(log_message, extra) - log_contents = self.log_stream.getvalue() - log_contents = json.loads(log_contents) + spider.log_critical(log_message, extra) + log_contents_str = log_stream.getvalue() + log_contents = json.loads(log_contents_str) assert log_contents["levelname"] == "CRITICAL" assert log_contents["message"] == log_message assert self.regex_pattern.match(log_contents["spider"]) assert log_contents["important_info"] == extra["important_info"] - def test_overwrite_spider_extra(self): + def test_overwrite_spider_extra( + self, log_stream: StringIO, spider: LogSpider + ) -> None: log_message = "Foo message" extra = {"important_info": "foo", "spider": "shouldn't change"} - self.spider.log_error(log_message, extra) - log_contents = self.log_stream.getvalue() - log_contents = json.loads(log_contents) + spider.log_error(log_message, extra) + log_contents_str = log_stream.getvalue() + log_contents = json.loads(log_contents_str) assert log_contents["levelname"] == "ERROR" assert log_contents["message"] == log_message diff --git a/tests/test_utils_project.py b/tests/test_utils_project.py index aa250be69..20a3d940c 100644 --- a/tests/test_utils_project.py +++ b/tests/test_utils_project.py @@ -1,18 +1,17 @@ -import contextlib import os -import shutil -import tempfile import warnings from pathlib import Path +import pytest + from scrapy.utils.misc import set_environ from scrapy.utils.project import data_path, get_project_settings -@contextlib.contextmanager -def inside_a_project(): +@pytest.fixture +def proj_path(tmp_path): prev_dir = Path.cwd() - project_dir = tempfile.mkdtemp() + project_dir = tmp_path try: os.chdir(project_dir) @@ -21,21 +20,19 @@ def inside_a_project(): yield project_dir finally: os.chdir(prev_dir) - shutil.rmtree(project_dir) -class TestProjectUtils: - def test_data_path_outside_project(self): - assert str(Path(".scrapy", "somepath")) == data_path("somepath") - abspath = str(Path(os.path.sep, "absolute", "path")) - assert abspath == data_path(abspath) +def test_data_path_outside_project(): + assert str(Path(".scrapy", "somepath")) == data_path("somepath") + abspath = str(Path(os.path.sep, "absolute", "path")) + assert abspath == data_path(abspath) - def test_data_path_inside_project(self): - with inside_a_project() as proj_path: - expected = Path(proj_path, ".scrapy", "somepath") - assert expected.resolve() == Path(data_path("somepath")).resolve() - abspath = str(Path(os.path.sep, "absolute", "path").resolve()) - assert abspath == data_path(abspath) + +def test_data_path_inside_project(proj_path: Path) -> None: + expected = proj_path / ".scrapy" / "somepath" + assert expected.resolve() == Path(data_path("somepath")).resolve() + abspath = str(Path(os.path.sep, "absolute", "path").resolve()) + assert abspath == data_path(abspath) class TestGetProjectSettings: diff --git a/tests/test_utils_python.py b/tests/test_utils_python.py index 291646ad7..c933e0ac9 100644 --- a/tests/test_utils_python.py +++ b/tests/test_utils_python.py @@ -1,7 +1,10 @@ +from __future__ import annotations + import functools import operator import platform import sys +from typing import TYPE_CHECKING, TypeVar import pytest from twisted.trial import unittest @@ -20,16 +23,22 @@ from scrapy.utils.python import ( without_none_values, ) +if TYPE_CHECKING: + from collections.abc import Iterable, Mapping -class TestMutableChain: - def test_mutablechain(self): - 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)) + +_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(unittest.TestCase): @@ -112,144 +121,150 @@ class TestToBytes: assert to_bytes("a\ufffdb", "latin-1", errors="replace") == b"a?b" -class TestMemoizedMethod: - def test_memoizemethod_noargs(self): - class A: - @memoizemethod_noargs - def cached(self): - return object() +def test_memoizemethod_noargs(): + class A: + @memoizemethod_noargs + def cached(self): + return object() - def noncached(self): - return object() + def noncached(self): + return object() - a = A() - one = a.cached() - two = a.cached() - three = a.noncached() - assert one is two - assert one is not three + a = A() + one = a.cached() + two = a.cached() + three = a.noncached() + assert one is two + assert one is not three -class TestBinaryIsText: - def test_binaryistext(self): - assert binary_is_text(b"hello") - - def test_utf_16_strings_contain_null_bytes(self): - assert binary_is_text("hello".encode("utf-16")) - - def test_one_with_encoding(self): - assert binary_is_text(b"
Price \xa3
") - - def test_real_binary_bytes(self): - assert not binary_is_text(b"\x02\xa3") +@pytest.mark.parametrize( + ("value", "expected"), + [ + (b"hello", True), + ("hello".encode("utf-16"), True), + (b"
Price \xa3
", True), + (b"\x02\xa3", False), + ], +) +def test_binaryistext(value: bytes, expected: bool) -> None: + assert binary_is_text(value) is expected -class TestUtilsPython: - @pytest.mark.filterwarnings("ignore::scrapy.exceptions.ScrapyDeprecationWarning") - def test_equal_attributes(self): - class Obj: +@pytest.mark.filterwarnings("ignore::scrapy.exceptions.ScrapyDeprecationWarning") +def test_equal_attributes(): + class Obj: + pass + + a = Obj() + b = Obj() + # no attributes given return False + assert not equal_attributes(a, b, []) + # nonexistent attributes + assert not equal_attributes(a, b, ["x", "y"]) + + a.x = 1 + b.x = 1 + # equal attribute + assert equal_attributes(a, b, ["x"]) + + b.y = 2 + # obj1 has no attribute y + assert not equal_attributes(a, b, ["x", "y"]) + + a.y = 2 + # equal attributes + assert equal_attributes(a, b, ["x", "y"]) + + a.y = 1 + # different attributes + assert not equal_attributes(a, b, ["x", "y"]) + + # test callable + a.meta = {} + b.meta = {} + assert equal_attributes(a, b, ["meta"]) + + # compare ['meta']['a'] + a.meta["z"] = 1 + b.meta["z"] = 1 + + get_z = operator.itemgetter("z") + get_meta = operator.attrgetter("meta") + + def compare_z(obj): + return get_z(get_meta(obj)) + + assert equal_attributes(a, b, [compare_z, "x"]) + # fail z equality + a.meta["z"] = 2 + assert not equal_attributes(a, b, [compare_z, "x"]) + + +def test_get_func_args(): + def f1(a, b, c): + pass + + def f2(a, b=None, c=None): + pass + + def f3(a, b=None, *, c=None): + pass + + class A: + def __init__(self, a, b, c): pass - a = Obj() - b = Obj() - # no attributes given return False - assert not equal_attributes(a, b, []) - # nonexistent attributes - assert not equal_attributes(a, b, ["x", "y"]) - - a.x = 1 - b.x = 1 - # equal attribute - assert equal_attributes(a, b, ["x"]) - - b.y = 2 - # obj1 has no attribute y - assert not equal_attributes(a, b, ["x", "y"]) - - a.y = 2 - # equal attributes - assert equal_attributes(a, b, ["x", "y"]) - - a.y = 1 - # different attributes - assert not equal_attributes(a, b, ["x", "y"]) - - # test callable - a.meta = {} - b.meta = {} - assert equal_attributes(a, b, ["meta"]) - - # compare ['meta']['a'] - a.meta["z"] = 1 - b.meta["z"] = 1 - - get_z = operator.itemgetter("z") - get_meta = operator.attrgetter("meta") - - def compare_z(obj): - return get_z(get_meta(obj)) - - assert equal_attributes(a, b, [compare_z, "x"]) - # fail z equality - a.meta["z"] = 2 - assert not equal_attributes(a, b, [compare_z, "x"]) - - def test_get_func_args(self): - def f1(a, b, c): + def method(self, a, b, c): pass - def f2(a, b=None, c=None): + class Callable: + def __call__(self, a, b, c): pass - def f3(a, b=None, *, c=None): - pass + a = A(1, 2, 3) + cal = Callable() + partial_f1 = functools.partial(f1, None) + partial_f2 = functools.partial(f1, b=None) + partial_f3 = functools.partial(partial_f2, None) - class A: - def __init__(self, a, b, c): - pass + assert get_func_args(f1) == ["a", "b", "c"] + assert get_func_args(f2) == ["a", "b", "c"] + assert get_func_args(f3) == ["a", "b", "c"] + assert get_func_args(A) == ["a", "b", "c"] + assert get_func_args(a.method) == ["a", "b", "c"] + assert get_func_args(partial_f1) == ["b", "c"] + 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) == [] + assert get_func_args(str.split, stripself=True) == ["sep", "maxsplit"] + assert get_func_args(" ".join, stripself=True) == ["iterable"] - def method(self, a, b, c): - pass + if sys.version_info >= (3, 13) or platform.python_implementation() == "PyPy": + # the correct and correctly extracted signature + assert get_func_args(operator.itemgetter(2), stripself=True) == ["obj"] + elif platform.python_implementation() == "CPython": + # ["args", "kwargs"] is a correct result for the pre-3.13 incorrect function signature + # [] is an incorrect result on even older CPython (https://github.com/python/cpython/issues/86951) + assert get_func_args(operator.itemgetter(2), stripself=True) in [ + [], + ["args", "kwargs"], + ] - class Callable: - def __call__(self, a, b, c): - pass - a = A(1, 2, 3) - cal = Callable() - partial_f1 = functools.partial(f1, None) - partial_f2 = functools.partial(f1, b=None) - partial_f3 = functools.partial(partial_f2, None) - - assert get_func_args(f1) == ["a", "b", "c"] - assert get_func_args(f2) == ["a", "b", "c"] - assert get_func_args(f3) == ["a", "b", "c"] - assert get_func_args(A) == ["a", "b", "c"] - assert get_func_args(a.method) == ["a", "b", "c"] - assert get_func_args(partial_f1) == ["b", "c"] - 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) == [] - assert get_func_args(str.split, stripself=True) == ["sep", "maxsplit"] - assert get_func_args(" ".join, stripself=True) == ["iterable"] - - if sys.version_info >= (3, 13) or platform.python_implementation() == "PyPy": - # the correct and correctly extracted signature - assert get_func_args(operator.itemgetter(2), stripself=True) == ["obj"] - elif platform.python_implementation() == "CPython": - # ["args", "kwargs"] is a correct result for the pre-3.13 incorrect function signature - # [] is an incorrect result on even older CPython (https://github.com/python/cpython/issues/86951) - assert get_func_args(operator.itemgetter(2), stripself=True) in [ - [], - ["args", "kwargs"], - ] - - def test_without_none_values(self): - assert without_none_values([1, None, 3, 4]) == [1, 3, 4] - assert without_none_values((1, None, 3, 4)) == (1, 3, 4) - assert without_none_values({"one": 1, "none": None, "three": 3, "four": 4}) == { - "one": 1, - "three": 3, - "four": 4, - } +@pytest.mark.parametrize( + ("value", "expected"), + [ + ([1, None, 3, 4], [1, 3, 4]), + ((1, None, 3, 4), (1, 3, 4)), + ( + {"one": 1, "none": None, "three": 3, "four": 4}, + {"one": 1, "three": 3, "four": 4}, + ), + ], +) +def test_without_none_values( + value: Mapping[_KT, _VT] | Iterable[_KT], expected: dict[_KT, _VT] | Iterable[_KT] +) -> None: + assert without_none_values(value) == expected diff --git a/tests/test_utils_request.py b/tests/test_utils_request.py index 5b8509753..9c4cb7159 100644 --- a/tests/test_utils_request.py +++ b/tests/test_utils_request.py @@ -20,45 +20,52 @@ from scrapy.utils.request import ( from scrapy.utils.test import get_crawler -class TestUtilsRequest: - @pytest.mark.filterwarnings("ignore::scrapy.exceptions.ScrapyDeprecationWarning") - def test_request_authenticate(self): - r = Request("http://www.example.com") - request_authenticate(r, "someuser", "somepass") - assert r.headers["Authorization"] == b"Basic c29tZXVzZXI6c29tZXBhc3M=" +@pytest.mark.filterwarnings("ignore::scrapy.exceptions.ScrapyDeprecationWarning") +def test_request_authenticate(): + r = Request("http://www.example.com") + request_authenticate(r, "someuser", "somepass") + assert r.headers["Authorization"] == b"Basic c29tZXVzZXI6c29tZXBhc3M=" - def test_request_httprepr(self): - r1 = Request("http://www.example.com") - assert ( - request_httprepr(r1) == b"GET / HTTP/1.1\r\nHost: www.example.com\r\n\r\n" - ) - r1 = Request("http://www.example.com/some/page.html?arg=1") - assert ( - request_httprepr(r1) - == b"GET /some/page.html?arg=1 HTTP/1.1\r\nHost: www.example.com\r\n\r\n" - ) +@pytest.mark.parametrize( + ("r", "expected"), + [ + ( + Request("http://www.example.com"), + b"GET / HTTP/1.1\r\nHost: www.example.com\r\n\r\n", + ), + ( + Request("http://www.example.com/some/page.html?arg=1"), + b"GET /some/page.html?arg=1 HTTP/1.1\r\nHost: www.example.com\r\n\r\n", + ), + ( + Request( + "http://www.example.com", + method="POST", + headers={"Content-type": b"text/html"}, + body=b"Some body", + ), + b"POST / HTTP/1.1\r\nHost: www.example.com\r\nContent-Type: text/html\r\n\r\nSome body", + ), + ], +) +def test_request_httprepr(r: Request, expected: bytes) -> None: + assert request_httprepr(r) == expected - r1 = Request( - "http://www.example.com", - method="POST", - headers={"Content-type": b"text/html"}, - body=b"Some body", - ) - assert ( - request_httprepr(r1) - == b"POST / HTTP/1.1\r\nHost: www.example.com\r\nContent-Type: text/html\r\n\r\nSome body" - ) - def test_request_httprepr_for_non_http_request(self): - # the representation is not important but it must not fail. - request_httprepr(Request("file:///tmp/foo.txt")) - request_httprepr(Request("ftp://localhost/tmp/foo.txt")) +@pytest.mark.parametrize( + "r", + [ + Request("file:///tmp/foo.txt"), + Request("ftp://localhost/tmp/foo.txt"), + ], +) +def test_request_httprepr_for_non_http_request(r: Request) -> None: + # the representation is not important but it must not fail. + request_httprepr(r) class TestFingerprint: - maxDiff = None - function: staticmethod = staticmethod(fingerprint) cache: ( WeakKeyDictionary[Request, dict[tuple[tuple[bytes, ...] | None, bool], bytes]] @@ -229,35 +236,6 @@ class TestFingerprint: assert actual == expected -REQUEST_OBJECTS_TO_TEST = ( - Request("http://www.example.com/"), - Request("http://www.example.com/query?id=111&cat=222"), - Request("http://www.example.com/query?cat=222&id=111"), - Request("http://www.example.com/hnnoticiaj1.aspx?78132,199"), - Request("http://www.example.com/hnnoticiaj1.aspx?78160,199"), - Request("http://www.example.com/members/offers.html"), - Request( - "http://www.example.com/members/offers.html", - headers={"SESSIONID": b"somehash"}, - ), - Request( - "http://www.example.com/", - headers={"Accept-Language": b"en"}, - ), - Request( - "http://www.example.com/", - headers={ - "Accept-Language": b"en", - "SESSIONID": b"somehash", - }, - ), - Request("http://www.example.com/test.html"), - Request("http://www.example.com/test.html#fragment"), - Request("http://www.example.com", method="POST"), - Request("http://www.example.com", method="POST", body=b"request body"), -) - - class TestRequestFingerprinter: def test_default_implementation(self): crawler = get_crawler() diff --git a/tests/test_utils_response.py b/tests/test_utils_response.py index 80f2f25d5..179ca49e4 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, TextResponse +from scrapy.http import HtmlResponse, Response from scrapy.utils.python import to_bytes from scrapy.utils.response import ( _remove_html_comments, @@ -15,229 +15,203 @@ from scrapy.utils.response import ( ) -class TestResponseUtils: - dummy_response = TextResponse(url="http://example.org/", body=b"dummy_response") +def test_open_in_browser(): + url = "http:///www.example.com/some/page.html" + body = ( + b" test page test body " + ) - def test_open_in_browser(self): - url = "http:///www.example.com/some/page.html" - body = b" test page test body " + 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() + assert b'' in bbody + return True - def browser_open(burl): - path = urlparse(burl).path - if not path or not Path(path).exists(): - path = burl.replace("file://", "") - bbody = Path(path).read_bytes() - assert b'' in bbody - return True + response = HtmlResponse(url, body=body) + assert open_in_browser(response, _openfunc=browser_open), "Browser not called" - response = HtmlResponse(url, body=body) - assert open_in_browser(response, _openfunc=browser_open), "Browser not called" + resp = Response(url, body=body) + with pytest.raises(TypeError): + open_in_browser(resp, debug=True) # pylint: disable=unexpected-keyword-arg - resp = Response(url, body=body) - with pytest.raises(TypeError): - open_in_browser(resp, debug=True) # pylint: disable=unexpected-keyword-arg - def test_get_meta_refresh(self): - r1 = HtmlResponse( - "http://www.example.com", - body=b""" - - Dummy - blahablsdfsal& - """, - ) - r2 = HtmlResponse( - "http://www.example.com", - body=b""" - - Dummy - blahablsdfsal& - """, - ) - r3 = HtmlResponse( - "http://www.example.com", - body=b""" -