From 197781e3af51cd46ae7761938afa474c40c36b76 Mon Sep 17 00:00:00 2001 From: Yash nagarkar <116726926+yash08123@users.noreply.github.com> Date: Fri, 22 Sep 2023 13:42:20 +0530 Subject: [PATCH 001/119] Cover the removal of is_botocore on the release notes (#6061) --- docs/news.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/news.rst b/docs/news.rst index fc3cfd9e8..c5b75aae2 100644 --- a/docs/news.rst +++ b/docs/news.rst @@ -469,6 +469,10 @@ Deprecation removals has now been removed. (:issue:`5719`) +- The ``scrapy.utils.boto.is_botocore()`` function, deprecated in Scrapy 2.4, + has now been removed. + (:issue:`5719`) + Deprecations ~~~~~~~~~~~~ From 8dc72dfc4d5a651e034a956a03d8e0a5f4c8a94d Mon Sep 17 00:00:00 2001 From: kokobhara <146670393+kokobhara@users.noreply.github.com> Date: Mon, 2 Oct 2023 15:44:05 +0530 Subject: [PATCH 002/119] Cover PythonItemExporter backwaird-incompatible changes in 2.11 (#6081) --- docs/news.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/news.rst b/docs/news.rst index c5b75aae2..fd8fa3ea3 100644 --- a/docs/news.rst +++ b/docs/news.rst @@ -40,6 +40,9 @@ Backward-incompatible changes UTF-32). If you need to deal with JSON documents in an invalid encoding, use ``json.loads(response.text)`` instead. (:issue:`6016`) +- :class:`~scrapy.exporters.PythonItemExporter` used the binary output by + default but it no longer does. (:issue:`6006`, :issue:`6007`) + Deprecation removals ~~~~~~~~~~~~~~~~~~~~ From 492584ec07e2b41f80db86c84215208e04e10d0f Mon Sep 17 00:00:00 2001 From: Kiran <75929997+Kiran1689@users.noreply.github.com> Date: Tue, 14 Nov 2023 00:43:10 +0530 Subject: [PATCH 003/119] Updated README.rst (#6144) --- README.rst | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/README.rst b/README.rst index 1918850d6..14adff648 100644 --- a/README.rst +++ b/README.rst @@ -17,9 +17,10 @@ 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 @@ -41,7 +42,7 @@ Scrapy Overview ======== -Scrapy is a fast high-level web crawling and web scraping framework, used to +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. @@ -110,4 +111,4 @@ See https://scrapy.org/companies/ for a list. Commercial Support ================== -See https://scrapy.org/support/ for details. +See https://scrapy.org/support/ for details. \ No newline at end of file From 5fccf370b87378fe2db6bdd52b98c1e2a951df3b Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Wed, 15 Nov 2023 15:38:13 +0100 Subject: [PATCH 004/119] Update the RTD URL for coverage --- docs/conf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/conf.py b/docs/conf.py index 38ca81932..9ca0f817a 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -276,7 +276,7 @@ coverage_ignore_pyobjects = [ intersphinx_mapping = { "attrs": ("https://www.attrs.org/en/stable/", None), - "coverage": ("https://coverage.readthedocs.io/en/stable", None), + "coverage": ("https://coverage.readthedocs.io/en/latest", None), "cryptography": ("https://cryptography.io/en/latest/", None), "cssselect": ("https://cssselect.readthedocs.io/en/latest", None), "itemloaders": ("https://itemloaders.readthedocs.io/en/latest/", None), From 080fecd8900b6b1f94e8e143e90338279ba8d6e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Wed, 15 Nov 2023 15:39:30 +0100 Subject: [PATCH 005/119] Drop the Authorization header on cross-domain redirect --- docs/news.rst | 27 ++++++++++++++++++ scrapy/downloadermiddlewares/redirect.py | 10 +++++-- tests/test_downloadermiddleware_redirect.py | 31 +++++++++++++++++++++ 3 files changed, 66 insertions(+), 2 deletions(-) diff --git a/docs/news.rst b/docs/news.rst index fd8fa3ea3..b19ec2e99 100644 --- a/docs/news.rst +++ b/docs/news.rst @@ -3,6 +3,20 @@ Release notes ============= +.. _release-2.11.1: + +Scrapy 2.11.1 (unreleased) +-------------------------- + +**Security bug fix:** + +- The ``Authorization`` header is now dropped on redirects to a different + domain. Please, see the `cw9j-q3vf-hrrv security advisory`_ for more + information. + + .. _cw9j-q3vf-hrrv security advisory: https://github.com/scrapy/scrapy/security/advisories/GHSA-cw9j-q3vf-hrrv + + .. _release-2.11.0: Scrapy 2.11.0 (2023-09-18) @@ -2869,6 +2883,19 @@ affect subclasses: (:issue:`3884`) +.. _release-1.8.4: + +Scrapy 1.8.4 (unreleased) +------------------------- + +**Security bug fix:** + +- The ``Authorization`` header is now dropped on redirects to a different + domain. Please, see the `cw9j-q3vf-hrrv security advisory`_ for more + information. + + .. _cw9j-q3vf-hrrv security advisory: https://github.com/scrapy/scrapy/security/advisories/GHSA-cw9j-q3vf-hrrv + .. _release-1.8.3: diff --git a/scrapy/downloadermiddlewares/redirect.py b/scrapy/downloadermiddlewares/redirect.py index 65f1d2224..3176ed930 100644 --- a/scrapy/downloadermiddlewares/redirect.py +++ b/scrapy/downloadermiddlewares/redirect.py @@ -17,11 +17,17 @@ def _build_redirect_request(source_request, *, url, **kwargs): **kwargs, cookies=None, ) - if "Cookie" in redirect_request.headers: + has_cookie_header = "Cookie" in redirect_request.headers + has_authorization_header = "Authorization" in redirect_request.headers + if has_cookie_header or has_authorization_header: source_request_netloc = urlparse_cached(source_request).netloc redirect_request_netloc = urlparse_cached(redirect_request).netloc if source_request_netloc != redirect_request_netloc: - del redirect_request.headers["Cookie"] + if has_cookie_header: + del redirect_request.headers["Cookie"] + # https://fetch.spec.whatwg.org/#ref-for-cors-non-wildcard-request-header-name + if has_authorization_header: + del redirect_request.headers["Authorization"] return redirect_request diff --git a/tests/test_downloadermiddleware_redirect.py b/tests/test_downloadermiddleware_redirect.py index dc15b672c..10b8ca9af 100644 --- a/tests/test_downloadermiddleware_redirect.py +++ b/tests/test_downloadermiddleware_redirect.py @@ -247,6 +247,37 @@ class RedirectMiddlewareTest(unittest.TestCase): perc_encoded_utf8_url = "http://scrapytest.org/a%C3%A7%C3%A3o" self.assertEqual(perc_encoded_utf8_url, req_result.url) + def test_cross_domain_header_dropping(self): + safe_headers = {"A": "B"} + original_request = Request( + "https://example.com", + headers={"Cookie": "a=b", "Authorization": "a", **safe_headers}, + ) + + internal_response = Response( + "https://example.com", + headers={"Location": "https://example.com/a"}, + status=301, + ) + internal_redirect_request = self.mw.process_response( + original_request, internal_response, self.spider + ) + self.assertIsInstance(internal_redirect_request, Request) + self.assertEqual(original_request.headers, internal_redirect_request.headers) + + external_response = Response( + "https://example.com", + headers={"Location": "https://example.org/a"}, + status=301, + ) + external_redirect_request = self.mw.process_response( + original_request, external_response, self.spider + ) + self.assertIsInstance(external_redirect_request, Request) + self.assertEqual( + safe_headers, external_redirect_request.headers.to_unicode_dict() + ) + class MetaRefreshMiddlewareTest(unittest.TestCase): def setUp(self): From 603aa4924afc740f5cc41ca40c8eeca4b17bbe2e Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Wed, 15 Nov 2023 19:51:04 +0400 Subject: [PATCH 006/119] Improve the docs about Crawler attributes and settings initialization. --- docs/news.rst | 6 ++++-- docs/topics/spiders.rst | 10 ++++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/docs/news.rst b/docs/news.rst index 5db37969c..65d9c5181 100644 --- a/docs/news.rst +++ b/docs/news.rst @@ -32,8 +32,10 @@ Backward-incompatible changes :meth:`scrapy.crawler.Crawler.__init__` and before the settings are finalized and frozen. This change was needed to allow changing the settings in :meth:`scrapy.Spider.from_crawler`. If you want to access the final - setting values in the spider code as early as possible you can do this in - :meth:`~scrapy.Spider.start_requests`. (:issue:`6038`) + setting values and the initialized :class:`~scrapy.crawler.Crawler` + attributes in the spider code as early as possible you can do this in + :meth:`~scrapy.Spider.start_requests` or in a handler of the + :signal:`engine_started` signal. (:issue:`6038`) - The :meth:`TextResponse.json ` method now requires the response to be in a valid JSON encoding (UTF-8, UTF-16, or diff --git a/docs/topics/spiders.rst b/docs/topics/spiders.rst index 20452d558..30677fe74 100644 --- a/docs/topics/spiders.rst +++ b/docs/topics/spiders.rst @@ -142,8 +142,14 @@ scrapy.Spider method, which is handy if you want to modify them based on arguments. As a consequence, these settings aren't the final values as they can be modified later by e.g. :ref:`add-ons - `. The final settings are available in the - :meth:`start_requests` method and later. + `. For the same reason, most of the + :class:`~scrapy.crawler.Crawler` attributes aren't initialized at + this point. + + The final settings and the initialized + :class:`~scrapy.crawler.Crawler` attributes are available in the + :meth:`start_requests` method, handlers of the + :signal:`engine_started` signal and later. :param crawler: crawler to which the spider will be bound :type crawler: :class:`~scrapy.crawler.Crawler` instance From 75e99c75b3c6219df50546877e02f7bbb37324c3 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Wed, 15 Nov 2023 19:51:04 +0400 Subject: [PATCH 007/119] Improve the docs about Crawler attributes and settings initialization. --- docs/news.rst | 6 ++++-- docs/topics/spiders.rst | 10 ++++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/docs/news.rst b/docs/news.rst index fd8fa3ea3..0c202639e 100644 --- a/docs/news.rst +++ b/docs/news.rst @@ -32,8 +32,10 @@ Backward-incompatible changes :meth:`scrapy.crawler.Crawler.__init__` and before the settings are finalized and frozen. This change was needed to allow changing the settings in :meth:`scrapy.Spider.from_crawler`. If you want to access the final - setting values in the spider code as early as possible you can do this in - :meth:`~scrapy.Spider.start_requests`. (:issue:`6038`) + setting values and the initialized :class:`~scrapy.crawler.Crawler` + attributes in the spider code as early as possible you can do this in + :meth:`~scrapy.Spider.start_requests` or in a handler of the + :signal:`engine_started` signal. (:issue:`6038`) - The :meth:`TextResponse.json ` method now requires the response to be in a valid JSON encoding (UTF-8, UTF-16, or diff --git a/docs/topics/spiders.rst b/docs/topics/spiders.rst index 20452d558..30677fe74 100644 --- a/docs/topics/spiders.rst +++ b/docs/topics/spiders.rst @@ -142,8 +142,14 @@ scrapy.Spider method, which is handy if you want to modify them based on arguments. As a consequence, these settings aren't the final values as they can be modified later by e.g. :ref:`add-ons - `. The final settings are available in the - :meth:`start_requests` method and later. + `. For the same reason, most of the + :class:`~scrapy.crawler.Crawler` attributes aren't initialized at + this point. + + The final settings and the initialized + :class:`~scrapy.crawler.Crawler` attributes are available in the + :meth:`start_requests` method, handlers of the + :signal:`engine_started` signal and later. :param crawler: crawler to which the spider will be bound :type crawler: :class:`~scrapy.crawler.Crawler` instance From ffbf943e9d0fed636174fab34b2d957b95ee8800 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Mon, 2 Oct 2023 20:40:25 +0400 Subject: [PATCH 008/119] Merge pull request #6077 from 11-aryan/11-aryan --- docs/topics/request-response.rst | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/docs/topics/request-response.rst b/docs/topics/request-response.rst index 41df51589..adf3d0f4a 100644 --- a/docs/topics/request-response.rst +++ b/docs/topics/request-response.rst @@ -115,20 +115,9 @@ Request objects cookies for that domain and will be sent again in future requests. That's the typical behaviour of any regular web browser. - To create a request that does not send stored cookies and does not - store received cookies, set the ``dont_merge_cookies`` key to ``True`` - in :attr:`request.meta `. - - Example of a request that sends manually-defined cookies and ignores - cookie storage: - - .. code-block:: python - - Request( - url="http://www.example.com", - cookies={"currency": "USD", "country": "UY"}, - meta={"dont_merge_cookies": True}, - ) + Note that setting the :reqmeta:`dont_merge_cookies` key to ``True`` in + :attr:`request.meta ` causes custom cookies to be + ignored. For more info see :ref:`cookies-mw`. From 59cfdeaa5c83ca1e65be7220296366c135b7676c Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Tue, 3 Oct 2023 20:20:12 +0400 Subject: [PATCH 009/119] Merge pull request #6083 from wRAR/py3.12-release Adapt to the Python 3.12 final release --- .github/workflows/tests-macos.yml | 2 +- .github/workflows/tests-ubuntu.yml | 20 +++++++++----------- .github/workflows/tests-windows.yml | 8 ++++---- tests/requirements.txt | 6 ++---- tests/test_downloader_handlers.py | 2 +- tests/test_feedexport.py | 3 --- tests/test_pipeline_files.py | 5 ----- 7 files changed, 17 insertions(+), 29 deletions(-) diff --git a/.github/workflows/tests-macos.yml b/.github/workflows/tests-macos.yml index 3044a1af3..aa9b3851d 100644 --- a/.github/workflows/tests-macos.yml +++ b/.github/workflows/tests-macos.yml @@ -7,7 +7,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.8", "3.9", "3.10", "3.11"] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] steps: - uses: actions/checkout@v3 diff --git a/.github/workflows/tests-ubuntu.yml b/.github/workflows/tests-ubuntu.yml index 5ff92a571..62b5f123a 100644 --- a/.github/workflows/tests-ubuntu.yml +++ b/.github/workflows/tests-ubuntu.yml @@ -17,7 +17,10 @@ jobs: - python-version: "3.11" env: TOXENV: py - - python-version: "3.11" + - python-version: "3.12" + env: + TOXENV: py + - python-version: "3.12" env: TOXENV: asyncio - python-version: pypy3.9 @@ -41,22 +44,17 @@ jobs: env: TOXENV: botocore-pinned - - python-version: "3.11" + - python-version: "3.12" env: TOXENV: extra-deps - - python-version: "3.11" + - python-version: "3.12" env: TOXENV: botocore - - python-version: "3.12.0-rc.2" - env: - TOXENV: py - - python-version: "3.12.0-rc.2" + # keep until uvloop supports 3.12 + - python-version: "3.11" env: TOXENV: asyncio - - python-version: "3.12.0-rc.2" - env: - TOXENV: extra-deps steps: - uses: actions/checkout@v3 @@ -67,7 +65,7 @@ jobs: python-version: ${{ matrix.python-version }} - name: Install system libraries - if: matrix.python-version == 'pypy3.9' || contains(matrix.env.TOXENV, 'pinned') || contains(matrix.python-version, '3.12.0') + if: matrix.python-version == 'pypy3.9' || contains(matrix.env.TOXENV, 'pinned') run: | sudo apt-get update sudo apt-get install libxml2-dev libxslt-dev diff --git a/.github/workflows/tests-windows.yml b/.github/workflows/tests-windows.yml index c8d1928d7..48e0bea76 100644 --- a/.github/workflows/tests-windows.yml +++ b/.github/workflows/tests-windows.yml @@ -17,13 +17,13 @@ jobs: - python-version: "3.10" env: TOXENV: py - - python-version: "3.10" - env: - TOXENV: asyncio - python-version: "3.11" env: TOXENV: py - - python-version: "3.11" + - python-version: "3.12" + env: + TOXENV: py + - python-version: "3.12" env: TOXENV: asyncio diff --git a/tests/requirements.txt b/tests/requirements.txt index 3ea7f3333..c07fda2d6 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -1,7 +1,6 @@ # Tests requirements attrs -# https://github.com/giampaolo/pyftpdlib/issues/560 -pyftpdlib; python_version < "3.12" +pyftpdlib >= 1.5.8 pytest pytest-cov==4.0.0 pytest-xdist @@ -10,8 +9,7 @@ testfixtures # uvloop currently doesn't build on 3.12 uvloop; platform_system != "Windows" and python_version < "3.12" -# bpython requires greenlet which currently doesn't build on 3.12 -bpython; python_version < "3.12" # optional for shell wrapper tests +bpython # optional for shell wrapper tests brotli; implementation_name != 'pypy' # optional for HTTP compress downloader middleware tests # 1.1.0 is broken on PyPy: https://github.com/google/brotli/issues/1072 brotli==1.0.9; implementation_name == 'pypy' # optional for HTTP compress downloader middleware tests diff --git a/tests/test_downloader_handlers.py b/tests/test_downloader_handlers.py index 57211d97a..f12243e1d 100644 --- a/tests/test_downloader_handlers.py +++ b/tests/test_downloader_handlers.py @@ -127,7 +127,7 @@ class FileTestCase(unittest.TestCase): return self.download_request(request, Spider("foo")).addCallback(_test) def test_non_existent(self): - request = Request(f"file://{self.mktemp()}") + request = Request(path_to_file_uri(self.mktemp())) d = self.download_request(request, Spider("foo")) return self.assertFailure(d, OSError) diff --git a/tests/test_feedexport.py b/tests/test_feedexport.py index 6b82974fa..56967c0d5 100644 --- a/tests/test_feedexport.py +++ b/tests/test_feedexport.py @@ -125,9 +125,6 @@ class FileFeedStorageTest(unittest.TestCase): path.unlink() -@pytest.mark.skipif( - sys.version_info >= (3, 12), reason="pyftpdlib doesn't support Python 3.12 yet" -) class FTPFeedStorageTest(unittest.TestCase): def get_test_spider(self, settings=None): class TestSpider(scrapy.Spider): diff --git a/tests/test_pipeline_files.py b/tests/test_pipeline_files.py index bf96f17b6..468751446 100644 --- a/tests/test_pipeline_files.py +++ b/tests/test_pipeline_files.py @@ -1,7 +1,6 @@ import dataclasses import os import random -import sys import time from datetime import datetime from io import BytesIO @@ -12,7 +11,6 @@ from unittest import mock from urllib.parse import urlparse import attr -import pytest from itemadapter import ItemAdapter from twisted.internet import defer from twisted.trial import unittest @@ -648,9 +646,6 @@ class TestGCSFilesStore(unittest.TestCase): store.bucket.get_blob.assert_called_with(expected_blob_path) -@pytest.mark.skipif( - sys.version_info >= (3, 12), reason="pyftpdlib doesn't support Python 3.12 yet" -) class TestFTPFileStore(unittest.TestCase): @defer.inlineCallbacks def test_persist(self): From 538192916f496eb21846d797a6feff5c05f501cf Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Tue, 17 Oct 2023 17:08:23 +0400 Subject: [PATCH 010/119] Merge pull request #6064 from wRAR/signals-proper Refactor installing signals. --- scrapy/crawler.py | 12 +++++++----- scrapy/utils/ossignal.py | 9 +++------ scrapy/utils/testproc.py | 7 ++++--- setup.py | 3 +-- tests/CrawlerProcess/sleeping.py | 24 +++++++++++++++++++++++ tests/requirements.txt | 1 + tests/test_command_shell.py | 26 +++++++++++++++++++++++++ tests/test_crawler.py | 33 ++++++++++++++++++++++++++++++-- 8 files changed, 97 insertions(+), 18 deletions(-) create mode 100644 tests/CrawlerProcess/sleeping.py diff --git a/scrapy/crawler.py b/scrapy/crawler.py index 22fd65be7..6f54e62e9 100644 --- a/scrapy/crawler.py +++ b/scrapy/crawler.py @@ -404,8 +404,8 @@ class CrawlerProcess(CrawlerRunner): :param bool stop_after_crawl: stop or not the reactor when all crawlers have finished - :param bool install_signal_handlers: whether to install the shutdown - handlers (default: True) + :param bool install_signal_handlers: whether to install the OS signal + handlers from Twisted and Scrapy (default: True) """ from twisted.internet import reactor @@ -416,15 +416,17 @@ class CrawlerProcess(CrawlerRunner): return d.addBoth(self._stop_reactor) - if install_signal_handlers: - install_shutdown_handlers(self._signal_shutdown) resolver_class = load_object(self.settings["DNS_RESOLVER"]) resolver = create_instance(resolver_class, self.settings, self, reactor=reactor) resolver.install_on_reactor() tp = reactor.getThreadPool() tp.adjustPoolsize(maxthreads=self.settings.getint("REACTOR_THREADPOOL_MAXSIZE")) reactor.addSystemEventTrigger("before", "shutdown", self.stop) - reactor.run(installSignalHandlers=False) # blocking call + if install_signal_handlers: + reactor.addSystemEventTrigger( + "after", "startup", install_shutdown_handlers, self._signal_shutdown + ) + reactor.run(installSignalHandlers=install_signal_handlers) # blocking call def _graceful_stop_reactor(self) -> Deferred: d = self.stop() diff --git a/scrapy/utils/ossignal.py b/scrapy/utils/ossignal.py index 2334ea792..db9a71273 100644 --- a/scrapy/utils/ossignal.py +++ b/scrapy/utils/ossignal.py @@ -19,13 +19,10 @@ def install_shutdown_handlers( function: SignalHandlerT, override_sigint: bool = True ) -> None: """Install the given function as a signal handler for all common shutdown - signals (such as SIGINT, SIGTERM, etc). If override_sigint is ``False`` the - SIGINT handler won't be install if there is already a handler in place - (e.g. Pdb) + signals (such as SIGINT, SIGTERM, etc). If ``override_sigint`` is ``False`` the + SIGINT handler won't be installed if there is already a handler in place + (e.g. Pdb) """ - from twisted.internet import reactor - - reactor._handleSignals() signal.signal(signal.SIGTERM, function) if signal.getsignal(signal.SIGINT) == signal.default_int_handler or override_sigint: signal.signal(signal.SIGINT, function) diff --git a/scrapy/utils/testproc.py b/scrapy/utils/testproc.py index 5f7a7db14..0688e014b 100644 --- a/scrapy/utils/testproc.py +++ b/scrapy/utils/testproc.py @@ -2,7 +2,7 @@ from __future__ import annotations import os import sys -from typing import Iterable, Optional, Tuple, cast +from typing import Iterable, List, Optional, Tuple, cast from twisted.internet.defer import Deferred from twisted.internet.error import ProcessTerminated @@ -26,14 +26,15 @@ class ProcessTest: env = os.environ.copy() if settings is not None: env["SCRAPY_SETTINGS_MODULE"] = settings + assert self.command cmd = self.prefix + [self.command] + list(args) pp = TestProcessProtocol() - pp.deferred.addBoth(self._process_finished, cmd, check_code) + 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: str, check_code: bool + 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}" diff --git a/setup.py b/setup.py index 47c0af0b0..405633f55 100644 --- a/setup.py +++ b/setup.py @@ -6,8 +6,7 @@ version = (Path(__file__).parent / "scrapy/VERSION").read_text("ascii").strip() install_requires = [ - # 23.8.0 incompatibility: https://github.com/scrapy/scrapy/issues/6024 - "Twisted>=18.9.0,<23.8.0", + "Twisted>=18.9.0", "cryptography>=36.0.0", "cssselect>=0.9.1", "itemloaders>=1.0.1", diff --git a/tests/CrawlerProcess/sleeping.py b/tests/CrawlerProcess/sleeping.py new file mode 100644 index 000000000..420d9d328 --- /dev/null +++ b/tests/CrawlerProcess/sleeping.py @@ -0,0 +1,24 @@ +from twisted.internet.defer import Deferred + +import scrapy +from scrapy.crawler import CrawlerProcess +from scrapy.utils.defer import maybe_deferred_to_future + + +class SleepingSpider(scrapy.Spider): + name = "sleeping" + + start_urls = ["data:,;"] + + async def parse(self, response): + from twisted.internet import reactor + + d = Deferred() + reactor.callLater(3, d.callback, None) + await maybe_deferred_to_future(d) + + +process = CrawlerProcess(settings={}) + +process.crawl(SleepingSpider) +process.start() diff --git a/tests/requirements.txt b/tests/requirements.txt index c07fda2d6..d4bfead40 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -1,5 +1,6 @@ # Tests requirements attrs +pexpect >= 4.8.0 pyftpdlib >= 1.5.8 pytest pytest-cov==4.0.0 diff --git a/tests/test_command_shell.py b/tests/test_command_shell.py index 6589381f3..7d87eb62c 100644 --- a/tests/test_command_shell.py +++ b/tests/test_command_shell.py @@ -1,11 +1,15 @@ +import sys +from io import BytesIO from pathlib import Path +from pexpect.popen_spawn import PopenSpawn from twisted.internet import defer from twisted.trial import unittest from scrapy.utils.testproc import ProcessTest from scrapy.utils.testsite import SiteTest from tests import NON_EXISTING_RESOLVABLE, tests_datadir +from tests.mockserver import MockServer class ShellTest(ProcessTest, SiteTest, unittest.TestCase): @@ -133,3 +137,25 @@ class ShellTest(ProcessTest, SiteTest, unittest.TestCase): args = ["-c", code, "--set", f"TWISTED_REACTOR={reactor_path}"] _, _, err = yield self.execute(args, check_code=True) self.assertNotIn(b"RuntimeError: There is no current event loop in thread", err) + + +class InteractiveShellTest(unittest.TestCase): + def test_fetch(self): + args = ( + sys.executable, + "-m", + "scrapy.cmdline", + "shell", + ) + logfile = BytesIO() + p = PopenSpawn(args, timeout=5) + p.logfile_read = logfile + p.expect_exact("Available Scrapy objects") + with MockServer() as mockserver: + p.sendline(f"fetch('{mockserver.url('/')}')") + p.sendline("type(response)") + p.expect_exact("HtmlResponse") + p.sendeof() + p.wait() + logfile.seek(0) + self.assertNotIn("Traceback", logfile.read().decode()) diff --git a/tests/test_crawler.py b/tests/test_crawler.py index 2b141e894..60b92377d 100644 --- a/tests/test_crawler.py +++ b/tests/test_crawler.py @@ -1,13 +1,16 @@ import logging import os import platform +import signal import subprocess import sys import warnings from pathlib import Path +from typing import List import pytest from packaging.version import parse as parse_version +from pexpect.popen_spawn import PopenSpawn from pytest import mark, raises from twisted.internet import defer from twisted.trial import unittest @@ -289,9 +292,12 @@ class ScriptRunnerMixin: script_dir: Path cwd = os.getcwd() - def run_script(self, script_name: str, *script_args): + def get_script_args(self, script_name: str, *script_args: str) -> List[str]: script_path = self.script_dir / script_name - args = [sys.executable, str(script_path)] + list(script_args) + return [sys.executable, str(script_path)] + list(script_args) + + def run_script(self, script_name: str, *script_args: str) -> str: + args = self.get_script_args(script_name, *script_args) p = subprocess.Popen( args, env=get_mockserver_env(), @@ -517,6 +523,29 @@ class CrawlerProcessSubprocess(ScriptRunnerMixin, unittest.TestCase): self.assertIn("Spider closed (finished)", log) self.assertIn("The value of FOO is 42", log) + def test_shutdown_graceful(self): + sig = signal.SIGINT if sys.platform != "win32" else signal.SIGBREAK + args = self.get_script_args("sleeping.py") + p = PopenSpawn(args, timeout=5) + p.expect_exact("Spider opened") + p.expect_exact("Crawled (200)") + p.kill(sig) + p.expect_exact("shutting down gracefully") + p.expect_exact("Spider closed (shutdown)") + p.wait() + + def test_shutdown_forced(self): + sig = signal.SIGINT if sys.platform != "win32" else signal.SIGBREAK + args = self.get_script_args("sleeping.py") + p = PopenSpawn(args, timeout=5) + p.expect_exact("Spider opened") + p.expect_exact("Crawled (200)") + p.kill(sig) + p.expect_exact("shutting down gracefully") + p.kill(sig) + p.expect_exact("forcing unclean shutdown") + p.wait() + class CrawlerRunnerSubprocess(ScriptRunnerMixin, unittest.TestCase): script_dir = Path(__file__).parent.resolve() / "CrawlerRunner" From 5e4fb0bc5fc066136b171ce488599b1ddd64c83a Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Tue, 17 Oct 2023 21:24:44 +0400 Subject: [PATCH 011/119] Re-enable uvloop tests on 3.12 (#6098) --- .github/workflows/checks.yml | 4 ++-- .github/workflows/publish.yml | 2 +- .github/workflows/tests-ubuntu.yml | 5 ----- scrapy/contracts/__init__.py | 6 ++++-- tests/requirements.txt | 3 +-- tox.ini | 2 +- 6 files changed, 9 insertions(+), 13 deletions(-) diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index ee0cb4b1e..f91055ba5 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -8,7 +8,7 @@ jobs: fail-fast: false matrix: include: - - python-version: "3.11" + - python-version: "3.12" env: TOXENV: pylint - python-version: 3.8 @@ -17,7 +17,7 @@ jobs: - python-version: "3.11" # Keep in sync with .readthedocs.yml env: TOXENV: docs - - python-version: "3.11" + - python-version: "3.12" env: TOXENV: twinecheck diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 22b8996b6..095793299 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -11,7 +11,7 @@ jobs: - uses: actions/checkout@v3 - uses: actions/setup-python@v4 with: - python-version: 3.11 + python-version: 3.12 - run: | pip install --upgrade build twine python -m build diff --git a/.github/workflows/tests-ubuntu.yml b/.github/workflows/tests-ubuntu.yml index 62b5f123a..c883f958c 100644 --- a/.github/workflows/tests-ubuntu.yml +++ b/.github/workflows/tests-ubuntu.yml @@ -51,11 +51,6 @@ jobs: env: TOXENV: botocore - # keep until uvloop supports 3.12 - - python-version: "3.11" - env: - TOXENV: asyncio - steps: - uses: actions/checkout@v3 diff --git a/scrapy/contracts/__init__.py b/scrapy/contracts/__init__.py index 1ec2a0234..2d9ddd89a 100644 --- a/scrapy/contracts/__init__.py +++ b/scrapy/contracts/__init__.py @@ -41,7 +41,9 @@ class Contract: cb_result = cb(response, **cb_kwargs) if isinstance(cb_result, (AsyncGenerator, CoroutineType)): raise TypeError("Contracts don't support async callbacks") - return list(iterate_spider_output(cb_result)) + return list( # pylint: disable=return-in-finally + iterate_spider_output(cb_result) + ) request.callback = wrapper @@ -68,7 +70,7 @@ class Contract: else: results.addSuccess(self.testcase_post) finally: - return output + return output # pylint: disable=return-in-finally request.callback = wrapper diff --git a/tests/requirements.txt b/tests/requirements.txt index d4bfead40..5b75674f5 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -7,8 +7,7 @@ pytest-cov==4.0.0 pytest-xdist sybil >= 1.3.0 # https://github.com/cjw296/sybil/issues/20#issuecomment-605433422 testfixtures -# uvloop currently doesn't build on 3.12 -uvloop; platform_system != "Windows" and python_version < "3.12" +uvloop; platform_system != "Windows" bpython # optional for shell wrapper tests brotli; implementation_name != 'pypy' # optional for HTTP compress downloader middleware tests diff --git a/tox.ini b/tox.ini index 9c2522a43..381da9773 100644 --- a/tox.ini +++ b/tox.ini @@ -57,7 +57,7 @@ commands = basepython = python3 deps = {[testenv:extra-deps]deps} - pylint==2.17.5 + pylint==3.0.1 commands = pylint conftest.py docs extras scrapy setup.py tests From 1045856a50d379d145e514ec9c7aeeed231aefd6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Mon, 30 Oct 2023 09:35:29 +0100 Subject: [PATCH 012/119] Merge pull request #6112 from wRAR/test-shutdown-forced Make shutdown tests more robust. --- tests/CrawlerProcess/sleeping.py | 2 +- tests/test_command_shell.py | 5 ++++- tests/test_crawler.py | 11 +++++++++-- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/tests/CrawlerProcess/sleeping.py b/tests/CrawlerProcess/sleeping.py index 420d9d328..45479ea4f 100644 --- a/tests/CrawlerProcess/sleeping.py +++ b/tests/CrawlerProcess/sleeping.py @@ -14,7 +14,7 @@ class SleepingSpider(scrapy.Spider): from twisted.internet import reactor d = Deferred() - reactor.callLater(3, d.callback, None) + reactor.callLater(int(self.sleep), d.callback, None) await maybe_deferred_to_future(d) diff --git a/tests/test_command_shell.py b/tests/test_command_shell.py index 7d87eb62c..7918d94b2 100644 --- a/tests/test_command_shell.py +++ b/tests/test_command_shell.py @@ -1,3 +1,4 @@ +import os import sys from io import BytesIO from pathlib import Path @@ -147,8 +148,10 @@ class InteractiveShellTest(unittest.TestCase): "scrapy.cmdline", "shell", ) + env = os.environ.copy() + env["SCRAPY_PYTHON_SHELL"] = "python" logfile = BytesIO() - p = PopenSpawn(args, timeout=5) + p = PopenSpawn(args, env=env, timeout=5) p.logfile_read = logfile p.expect_exact("Available Scrapy objects") with MockServer() as mockserver: diff --git a/tests/test_crawler.py b/tests/test_crawler.py index 60b92377d..0a7f9bac8 100644 --- a/tests/test_crawler.py +++ b/tests/test_crawler.py @@ -525,7 +525,7 @@ class CrawlerProcessSubprocess(ScriptRunnerMixin, unittest.TestCase): def test_shutdown_graceful(self): sig = signal.SIGINT if sys.platform != "win32" else signal.SIGBREAK - args = self.get_script_args("sleeping.py") + args = self.get_script_args("sleeping.py", "-a", "sleep=3") p = PopenSpawn(args, timeout=5) p.expect_exact("Spider opened") p.expect_exact("Crawled (200)") @@ -534,14 +534,21 @@ class CrawlerProcessSubprocess(ScriptRunnerMixin, unittest.TestCase): p.expect_exact("Spider closed (shutdown)") p.wait() + @defer.inlineCallbacks def test_shutdown_forced(self): + from twisted.internet import reactor + sig = signal.SIGINT if sys.platform != "win32" else signal.SIGBREAK - args = self.get_script_args("sleeping.py") + args = self.get_script_args("sleeping.py", "-a", "sleep=10") p = PopenSpawn(args, timeout=5) p.expect_exact("Spider opened") p.expect_exact("Crawled (200)") p.kill(sig) p.expect_exact("shutting down gracefully") + # sending the second signal too fast often causes problems + d = defer.Deferred() + reactor.callLater(0.1, d.callback, None) + yield d p.kill(sig) p.expect_exact("forcing unclean shutdown") p.wait() From 150f9d6d888970d5f164387761989aba59e830c0 Mon Sep 17 00:00:00 2001 From: Jessica Allman-LaPorte Date: Fri, 3 Nov 2023 05:02:18 -0400 Subject: [PATCH 013/119] Make shell switching more clear in the tutorial (#6128) --- docs/intro/tutorial.rst | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/intro/tutorial.rst b/docs/intro/tutorial.rst index 19a76fc16..8ea98f29b 100644 --- a/docs/intro/tutorial.rst +++ b/docs/intro/tutorial.rst @@ -493,7 +493,15 @@ in the callback, as you can see below: "tags": quote.css("div.tags a.tag::text").getall(), } -If you run this spider, it will output the extracted data with the log:: +To run this spider, exit the scrapy shell by entering:: + + quit() + +Then, run:: + + scrapy crawl quotes + +Now, it should output the extracted data with the log:: 2016-09-19 18:57:19 [scrapy.core.scraper] DEBUG: Scraped from <200 https://quotes.toscrape.com/page/1/> {'tags': ['life', 'love'], 'author': 'André Gide', 'text': '“It is better to be hated for what you are than to be loved for what you are not.”'} From 49b284ab8508d3400582781343ea8171980b1e70 Mon Sep 17 00:00:00 2001 From: Kiran <75929997+Kiran1689@users.noreply.github.com> Date: Tue, 14 Nov 2023 00:43:10 +0530 Subject: [PATCH 014/119] Updated README.rst (#6144) --- README.rst | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/README.rst b/README.rst index 1918850d6..14adff648 100644 --- a/README.rst +++ b/README.rst @@ -17,9 +17,10 @@ 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 @@ -41,7 +42,7 @@ Scrapy Overview ======== -Scrapy is a fast high-level web crawling and web scraping framework, used to +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. @@ -110,4 +111,4 @@ See https://scrapy.org/companies/ for a list. Commercial Support ================== -See https://scrapy.org/support/ for details. +See https://scrapy.org/support/ for details. \ No newline at end of file From 6969041c5f6891a0298d7e68ece762adee1bb222 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Wed, 22 Nov 2023 15:52:00 +0100 Subject: [PATCH 015/119] Protect against gzip bombs --- .../downloadermiddlewares/httpcompression.py | 26 +++++++++++----- scrapy/utils/_compression.py | 2 ++ scrapy/utils/gz.py | 14 +++++++-- tests/sample_data/compressed/bomb-gzip.bin | Bin 0 -> 27988 bytes ...st_downloadermiddleware_httpcompression.py | 29 ++++++++++++++++-- 5 files changed, 59 insertions(+), 12 deletions(-) create mode 100644 scrapy/utils/_compression.py create mode 100644 tests/sample_data/compressed/bomb-gzip.bin diff --git a/scrapy/downloadermiddlewares/httpcompression.py b/scrapy/downloadermiddlewares/httpcompression.py index ead426951..5dd67ea87 100644 --- a/scrapy/downloadermiddlewares/httpcompression.py +++ b/scrapy/downloadermiddlewares/httpcompression.py @@ -2,9 +2,10 @@ import io import warnings import zlib -from scrapy.exceptions import NotConfigured +from scrapy.exceptions import IgnoreRequest, NotConfigured from scrapy.http import Response, TextResponse from scrapy.responsetypes import responsetypes +from scrapy.utils._compression import _DecompressionMaxSizeExceeded from scrapy.utils.deprecate import ScrapyDeprecationWarning from scrapy.utils.gz import gunzip @@ -29,24 +30,26 @@ class HttpCompressionMiddleware: """This middleware allows compressed (gzip, deflate) traffic to be sent/received from web sites""" - def __init__(self, stats=None): - self.stats = stats + def __init__(self, crawler=None): + self.stats = crawler.stats + self._max_size = crawler.settings.getint("DOWNLOAD_MAXSIZE") @classmethod def from_crawler(cls, crawler): if not crawler.settings.getbool("COMPRESSION_ENABLED"): raise NotConfigured try: - return cls(stats=crawler.stats) + return cls(crawler=crawler) except TypeError: warnings.warn( "HttpCompressionMiddleware subclasses must either modify " - "their '__init__' method to support a 'stats' parameter or " - "reimplement the 'from_crawler' method.", + "their '__init__' method to support a 'crawler' parameter or " + "reimplement their 'from_crawler' method.", ScrapyDeprecationWarning, ) result = cls() result.stats = crawler.stats + result._max_size = crawler.settings.getint("DOWNLOAD_MAXSIZE") return result def process_request(self, request, spider): @@ -59,7 +62,14 @@ class HttpCompressionMiddleware: content_encoding = response.headers.getlist("Content-Encoding") if content_encoding: encoding = content_encoding.pop() - decoded_body = self._decode(response.body, encoding.lower()) + try: + decoded_body = self._decode(response.body, encoding.lower()) + except _DecompressionMaxSizeExceeded: + raise IgnoreRequest( + f"Ignored response {response} because its body " + f"({len(response.body)}B) exceeded DOWNLOAD_MAXSIZE " + f"({self._max_size}B) during decompression." + ) if self.stats: self.stats.inc_value( "httpcompression/response_bytes", @@ -85,7 +95,7 @@ class HttpCompressionMiddleware: def _decode(self, body, encoding): if encoding == b"gzip" or encoding == b"x-gzip": - body = gunzip(body) + body = gunzip(body, max_size=self._max_size) if encoding == b"deflate": try: diff --git a/scrapy/utils/_compression.py b/scrapy/utils/_compression.py new file mode 100644 index 000000000..e726a70f5 --- /dev/null +++ b/scrapy/utils/_compression.py @@ -0,0 +1,2 @@ +class _DecompressionMaxSizeExceeded(ValueError): + pass diff --git a/scrapy/utils/gz.py b/scrapy/utils/gz.py index c7f74030e..cd5059a5c 100644 --- a/scrapy/utils/gz.py +++ b/scrapy/utils/gz.py @@ -5,8 +5,10 @@ from typing import List from scrapy.http import Response +from ._compression import _DecompressionMaxSizeExceeded -def gunzip(data: bytes) -> bytes: + +def gunzip(data: bytes, max_size: int = 0) -> bytes: """Gunzip the given data and return as much data as possible. This is resilient to CRC checksum errors. @@ -14,10 +16,10 @@ def gunzip(data: bytes) -> bytes: f = GzipFile(fileobj=BytesIO(data)) output_list: List[bytes] = [] chunk = b"." + decompressed_size = 0 while chunk: try: chunk = f.read1(8196) - output_list.append(chunk) except (OSError, EOFError, struct.error): # complete only if there is some data, otherwise re-raise # see issue 87 about catching struct.error @@ -25,6 +27,14 @@ def gunzip(data: bytes) -> bytes: if output_list: break raise + decompressed_size += len(chunk) + if max_size and decompressed_size > max_size: + raise _DecompressionMaxSizeExceeded( + f"The number of bytes decompressed so far " + f"({decompressed_size}B) exceed the specified maximum " + f"({max_size}B)." + ) + output_list.append(chunk) return b"".join(output_list) diff --git a/tests/sample_data/compressed/bomb-gzip.bin b/tests/sample_data/compressed/bomb-gzip.bin new file mode 100644 index 0000000000000000000000000000000000000000..64aa0c3696cc1c6d86d218c70635d88f2035ec9e GIT binary patch literal 27988 zcmeIyElY!86b9f&oiK|G(Y#;~27Xl0-yq1UE0YN_<|{r6TM$G+ga1HfWkC=@i}}T- zAQQ0;tA;J=f*|@M2A1oDJDzYj_mw}*X4m_rN*LQrYP)-t7`Kz1`EpV#FVq|L(0ja} zI9SSs+qBrtti6t3Pxsob#`n?W)Wc%`Y$l!UY&@@AZN0t3&;4p=`TZgaH}D5)fC3Vd zkc1>8Aqh!HLK2dYgd`*(2}wvo5|WUFBqSjTNk~Exl8}TXBq0e&NJ0{lkc1>8Aqh!H zLK2dYgd`*(2}wvo5|WUFBqSjTNk~Exl8}TXBq0e&NJ0{lkc1>8Aqh!HLK2d2h!PI& z=1wx8 S;eSfl{TO{ZZ^qTjoA3)j<4WiN literal 0 HcmV?d00001 diff --git a/tests/test_downloadermiddleware_httpcompression.py b/tests/test_downloadermiddleware_httpcompression.py index 9dad056de..f834e78f5 100644 --- a/tests/test_downloadermiddleware_httpcompression.py +++ b/tests/test_downloadermiddleware_httpcompression.py @@ -10,7 +10,7 @@ from scrapy.downloadermiddlewares.httpcompression import ( ACCEPTED_ENCODINGS, HttpCompressionMiddleware, ) -from scrapy.exceptions import NotConfigured, ScrapyDeprecationWarning +from scrapy.exceptions import IgnoreRequest, NotConfigured, ScrapyDeprecationWarning from scrapy.http import HtmlResponse, Request, Response from scrapy.responsetypes import responsetypes from scrapy.spiders import Spider @@ -35,12 +35,24 @@ FORMAT = { "html-zstd-streaming-no-content-size.bin", "zstd", ), + **{ + f"bomb-{format_id}": (f"bomb-{format_id}.bin", format_id) + for format_id in ( + # "br", + "gzip", # 27 988 → 11 511 612 + # "deflate", + # "zstd", + ) + }, } class HttpCompressionTest(TestCase): def setUp(self): - self.crawler = get_crawler(Spider) + settings = { + "DOWNLOAD_MAXSIZE": 10_000_000, # For compression bomb tests. + } + self.crawler = get_crawler(Spider, settings_dict=settings) self.spider = self.crawler._create_spider("scrapytest.org") self.mw = HttpCompressionMiddleware.from_crawler(self.crawler) self.crawler.stats.open_spider(self.spider) @@ -373,6 +385,19 @@ class HttpCompressionTest(TestCase): self.assertStatsEqual("httpcompression/response_count", None) self.assertStatsEqual("httpcompression/response_bytes", None) + def _test_compression_bomb(self, compression_id): + response = self._getresponse(f"bomb-{compression_id}") + self.assertRaises( + IgnoreRequest, + self.mw.process_response, + response.request, + response, + self.spider, + ) + + def test_compression_bomb_gzip(self): + self._test_compression_bomb("gzip") + class HttpCompressionSubclassTest(TestCase): def test_init_missing_stats(self): From 0bf29a7b1b9b6a641c486780b7b0fa455577bf39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Wed, 22 Nov 2023 16:10:50 +0100 Subject: [PATCH 016/119] Update test expectations --- scrapy/downloadermiddlewares/httpcompression.py | 4 +++- .../test_downloadermiddleware_httpcompression.py | 16 ++-------------- 2 files changed, 5 insertions(+), 15 deletions(-) diff --git a/scrapy/downloadermiddlewares/httpcompression.py b/scrapy/downloadermiddlewares/httpcompression.py index 5dd67ea87..0fec05a14 100644 --- a/scrapy/downloadermiddlewares/httpcompression.py +++ b/scrapy/downloadermiddlewares/httpcompression.py @@ -30,7 +30,9 @@ class HttpCompressionMiddleware: """This middleware allows compressed (gzip, deflate) traffic to be sent/received from web sites""" - def __init__(self, crawler=None): + def __init__(self, *, crawler=None): + if not crawler: + return self.stats = crawler.stats self._max_size = crawler.settings.getint("DOWNLOAD_MAXSIZE") diff --git a/tests/test_downloadermiddleware_httpcompression.py b/tests/test_downloadermiddleware_httpcompression.py index f834e78f5..f5dedd28d 100644 --- a/tests/test_downloadermiddleware_httpcompression.py +++ b/tests/test_downloadermiddleware_httpcompression.py @@ -127,18 +127,6 @@ class HttpCompressionTest(TestCase): self.assertStatsEqual("httpcompression/response_count", 1) self.assertStatsEqual("httpcompression/response_bytes", 74837) - def test_process_response_gzip_no_stats(self): - mw = HttpCompressionMiddleware() - response = self._getresponse("gzip") - request = response.request - - self.assertEqual(response.headers["Content-Encoding"], b"gzip") - newresponse = mw.process_response(request, response, self.spider) - self.assertEqual(mw.stats, None) - assert newresponse is not response - assert newresponse.body.startswith(b" Date: Wed, 22 Nov 2023 17:12:43 +0100 Subject: [PATCH 017/119] Protect against deflate bombs --- .../downloadermiddlewares/httpcompression.py | 20 +++------ scrapy/utils/_compression.py | 39 ++++++++++++++++++ scrapy/utils/gz.py | 2 +- tests/sample_data/compressed/bomb-deflate.bin | Bin 0 -> 27968 bytes ...st_downloadermiddleware_httpcompression.py | 5 ++- 5 files changed, 49 insertions(+), 17 deletions(-) create mode 100644 tests/sample_data/compressed/bomb-deflate.bin diff --git a/scrapy/downloadermiddlewares/httpcompression.py b/scrapy/downloadermiddlewares/httpcompression.py index 0fec05a14..8cec87c47 100644 --- a/scrapy/downloadermiddlewares/httpcompression.py +++ b/scrapy/downloadermiddlewares/httpcompression.py @@ -1,11 +1,10 @@ import io import warnings -import zlib from scrapy.exceptions import IgnoreRequest, NotConfigured from scrapy.http import Response, TextResponse from scrapy.responsetypes import responsetypes -from scrapy.utils._compression import _DecompressionMaxSizeExceeded +from scrapy.utils._compression import _DecompressionMaxSizeExceeded, _inflate from scrapy.utils.deprecate import ScrapyDeprecationWarning from scrapy.utils.gz import gunzip @@ -97,23 +96,14 @@ class HttpCompressionMiddleware: def _decode(self, body, encoding): if encoding == b"gzip" or encoding == b"x-gzip": - body = gunzip(body, max_size=self._max_size) - + return gunzip(body, max_size=self._max_size) if encoding == b"deflate": - try: - body = zlib.decompress(body) - except zlib.error: - # ugly hack to work with raw deflate content that may - # be sent by microsoft servers. For more information, see: - # http://carsten.codimi.de/gzip.yaws/ - # http://www.port80software.com/200ok/archive/2005/10/31/868.aspx - # http://www.gzip.org/zlib/zlib_faq.html#faq38 - body = zlib.decompress(body, -15) + return _inflate(body, max_size=self._max_size) if encoding == b"br" and b"br" in ACCEPTED_ENCODINGS: - body = brotli.decompress(body) + return brotli.decompress(body) if encoding == b"zstd" and b"zstd" in ACCEPTED_ENCODINGS: # Using its streaming API since its simple API could handle only cases # where there is content size data embedded in the frame reader = zstandard.ZstdDecompressor().stream_reader(io.BytesIO(body)) - body = reader.read() + return reader.read() return body diff --git a/scrapy/utils/_compression.py b/scrapy/utils/_compression.py index e726a70f5..34bf2e4f7 100644 --- a/scrapy/utils/_compression.py +++ b/scrapy/utils/_compression.py @@ -1,2 +1,41 @@ +import zlib +from io import BytesIO +from typing import List + + class _DecompressionMaxSizeExceeded(ValueError): pass + + +def _inflate(data: bytes, *, max_size: int = 0) -> bytes: + decompressor = zlib.decompressobj() + raw_decompressor = zlib.decompressobj(wbits=-15) + input_stream = BytesIO(data) + output_list: List[bytes] = [] + output_chunk = b"." + decompressed_size = 0 + CHUNK_SIZE = 8196 + while output_chunk: + input_chunk = input_stream.read(CHUNK_SIZE) + try: + output_chunk = decompressor.decompress(input_chunk) + except zlib.error: + if decompressor != raw_decompressor: + # ugly hack to work with raw deflate content that may + # be sent by microsoft servers. For more information, see: + # http://carsten.codimi.de/gzip.yaws/ + # http://www.port80software.com/200ok/archive/2005/10/31/868.aspx + # http://www.gzip.org/zlib/zlib_faq.html#faq38 + decompressor = raw_decompressor + output_chunk = decompressor.decompress(input_chunk) + else: + raise + decompressed_size += len(output_chunk) + if max_size and decompressed_size > max_size: + raise _DecompressionMaxSizeExceeded( + f"The number of bytes decompressed so far " + f"({decompressed_size}B) exceed the specified maximum " + f"({max_size}B)." + ) + output_list.append(output_chunk) + return b"".join(output_list) diff --git a/scrapy/utils/gz.py b/scrapy/utils/gz.py index cd5059a5c..548134721 100644 --- a/scrapy/utils/gz.py +++ b/scrapy/utils/gz.py @@ -8,7 +8,7 @@ from scrapy.http import Response from ._compression import _DecompressionMaxSizeExceeded -def gunzip(data: bytes, max_size: int = 0) -> bytes: +def gunzip(data: bytes, *, max_size: int = 0) -> bytes: """Gunzip the given data and return as much data as possible. This is resilient to CRC checksum errors. diff --git a/tests/sample_data/compressed/bomb-deflate.bin b/tests/sample_data/compressed/bomb-deflate.bin new file mode 100644 index 0000000000000000000000000000000000000000..3598aca0777ec2511a721e9655cabb8c21c658bd GIT binary patch literal 27968 zcmeI&u}VS#6b9f+=-?6}`2-Gb=^8GEdrPzhZ7q%$M35jzaB^}JadC@=dVsh@OD>AI zMF>rSC{CdeWcdhg3f~#dd^nu*O@FmB>%Sy!^U2^bI{%2BjpGkTvtGCQb9b0}%gx*A zC^NVm7VfVnqwxEtJUIF4gqj_=18;x=5|WUFBqSjTNk~Exl8}TXBq0e&NJ0{lkc1>8 zAqh!HLK2dYgd`*(2}wvo5|VI-C0ssb8?oTOioa2%K7C$JY75N{+<`Yh0SQS+LK2dY zgd`*(2}wvo5|WUFBqSjTNk~Exl8}TXBq0e&NJ0{lkc1>8Aqh#iPZGYjN(Y-T;OY9R z_8O#jIJamt;d0?};d0?}5|WUFBqSjTNk~Exl8}TXBq0e&NJ0{lkc1>8Aqh#im4waX I@bhBz2V-bE-2eap literal 0 HcmV?d00001 diff --git a/tests/test_downloadermiddleware_httpcompression.py b/tests/test_downloadermiddleware_httpcompression.py index f5dedd28d..3af8202cc 100644 --- a/tests/test_downloadermiddleware_httpcompression.py +++ b/tests/test_downloadermiddleware_httpcompression.py @@ -39,8 +39,8 @@ FORMAT = { f"bomb-{format_id}": (f"bomb-{format_id}.bin", format_id) for format_id in ( # "br", + "deflate", # 27 968 → 11 511 612 "gzip", # 27 988 → 11 511 612 - # "deflate", # "zstd", ) }, @@ -383,6 +383,9 @@ class HttpCompressionTest(TestCase): self.spider, ) + def test_compression_bomb_deflate(self): + self._test_compression_bomb("deflate") + def test_compression_bomb_gzip(self): self._test_compression_bomb("gzip") From fba167c5e1f356bcc452e95e92199f1a15135c60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Wed, 22 Nov 2023 17:32:09 +0100 Subject: [PATCH 018/119] Protect against brotli bombs --- .../downloadermiddlewares/httpcompression.py | 14 +++++----- scrapy/utils/_compression.py | 26 +++++++++++++++++++ tests/sample_data/compressed/bomb-br.bin | 2 ++ ...st_downloadermiddleware_httpcompression.py | 9 ++++++- 4 files changed, 43 insertions(+), 8 deletions(-) create mode 100644 tests/sample_data/compressed/bomb-br.bin diff --git a/scrapy/downloadermiddlewares/httpcompression.py b/scrapy/downloadermiddlewares/httpcompression.py index 8cec87c47..91748e57e 100644 --- a/scrapy/downloadermiddlewares/httpcompression.py +++ b/scrapy/downloadermiddlewares/httpcompression.py @@ -4,25 +4,25 @@ import warnings from scrapy.exceptions import IgnoreRequest, NotConfigured from scrapy.http import Response, TextResponse from scrapy.responsetypes import responsetypes -from scrapy.utils._compression import _DecompressionMaxSizeExceeded, _inflate +from scrapy.utils._compression import _DecompressionMaxSizeExceeded, _inflate, _unbrotli from scrapy.utils.deprecate import ScrapyDeprecationWarning from scrapy.utils.gz import gunzip ACCEPTED_ENCODINGS = [b"gzip", b"deflate"] try: - import brotli - - ACCEPTED_ENCODINGS.append(b"br") + import brotli # noqa: F401 except ImportError: pass +else: + ACCEPTED_ENCODINGS.append(b"br") try: import zstandard - - ACCEPTED_ENCODINGS.append(b"zstd") except ImportError: pass +else: + ACCEPTED_ENCODINGS.append(b"zstd") class HttpCompressionMiddleware: @@ -100,7 +100,7 @@ class HttpCompressionMiddleware: if encoding == b"deflate": return _inflate(body, max_size=self._max_size) if encoding == b"br" and b"br" in ACCEPTED_ENCODINGS: - return brotli.decompress(body) + return _unbrotli(body, max_size=self._max_size) if encoding == b"zstd" and b"zstd" in ACCEPTED_ENCODINGS: # Using its streaming API since its simple API could handle only cases # where there is content size data embedded in the frame diff --git a/scrapy/utils/_compression.py b/scrapy/utils/_compression.py index 34bf2e4f7..9a32ce4f0 100644 --- a/scrapy/utils/_compression.py +++ b/scrapy/utils/_compression.py @@ -2,6 +2,11 @@ import zlib from io import BytesIO from typing import List +try: + import brotli +except ImportError: + pass + class _DecompressionMaxSizeExceeded(ValueError): pass @@ -39,3 +44,24 @@ def _inflate(data: bytes, *, max_size: int = 0) -> bytes: ) output_list.append(output_chunk) return b"".join(output_list) + + +def _unbrotli(data: bytes, *, max_size: int = 0) -> bytes: + decompressor = brotli.Decompressor() + input_stream = BytesIO(data) + output_list: List[bytes] = [] + output_chunk = b"." + decompressed_size = 0 + CHUNK_SIZE = 8196 + while output_chunk: + input_chunk = input_stream.read(CHUNK_SIZE) + output_chunk = decompressor.process(input_chunk) + decompressed_size += len(output_chunk) + if max_size and decompressed_size > max_size: + raise _DecompressionMaxSizeExceeded( + f"The number of bytes decompressed so far " + f"({decompressed_size}B) exceed the specified maximum " + f"({max_size}B)." + ) + output_list.append(output_chunk) + return b"".join(output_list) diff --git a/tests/sample_data/compressed/bomb-br.bin b/tests/sample_data/compressed/bomb-br.bin new file mode 100644 index 000000000..50059866f --- /dev/null +++ b/tests/sample_data/compressed/bomb-br.bin @@ -0,0 +1,2 @@ +;nުVp SmoY2 +()-д=_o \ No newline at end of file diff --git a/tests/test_downloadermiddleware_httpcompression.py b/tests/test_downloadermiddleware_httpcompression.py index 3af8202cc..8858916bc 100644 --- a/tests/test_downloadermiddleware_httpcompression.py +++ b/tests/test_downloadermiddleware_httpcompression.py @@ -38,7 +38,7 @@ FORMAT = { **{ f"bomb-{format_id}": (f"bomb-{format_id}.bin", format_id) for format_id in ( - # "br", + "br", # 34 → 11 511 612 "deflate", # 27 968 → 11 511 612 "gzip", # 27 988 → 11 511 612 # "zstd", @@ -383,6 +383,13 @@ class HttpCompressionTest(TestCase): self.spider, ) + def test_compression_bomb_br(self): + try: + import brotli # noqa: F401 + except ImportError: + raise SkipTest("no brotli") + self._test_compression_bomb("br") + def test_compression_bomb_deflate(self): self._test_compression_bomb("deflate") From 9cc870387745f45f744ceef6ed226eefcce0e066 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Wed, 22 Nov 2023 17:53:00 +0100 Subject: [PATCH 019/119] Protect against zstandard bombs --- .../downloadermiddlewares/httpcompression.py | 15 ++++++----- scrapy/utils/_compression.py | 25 ++++++++++++++++++ tests/sample_data/compressed/bomb-zstd.bin | Bin 0 -> 1096 bytes ...st_downloadermiddleware_httpcompression.py | 5 +++- 4 files changed, 37 insertions(+), 8 deletions(-) create mode 100644 tests/sample_data/compressed/bomb-zstd.bin diff --git a/scrapy/downloadermiddlewares/httpcompression.py b/scrapy/downloadermiddlewares/httpcompression.py index 91748e57e..8ee1d95a6 100644 --- a/scrapy/downloadermiddlewares/httpcompression.py +++ b/scrapy/downloadermiddlewares/httpcompression.py @@ -1,10 +1,14 @@ -import io import warnings from scrapy.exceptions import IgnoreRequest, NotConfigured from scrapy.http import Response, TextResponse from scrapy.responsetypes import responsetypes -from scrapy.utils._compression import _DecompressionMaxSizeExceeded, _inflate, _unbrotli +from scrapy.utils._compression import ( + _DecompressionMaxSizeExceeded, + _inflate, + _unbrotli, + _unzstd, +) from scrapy.utils.deprecate import ScrapyDeprecationWarning from scrapy.utils.gz import gunzip @@ -18,7 +22,7 @@ else: ACCEPTED_ENCODINGS.append(b"br") try: - import zstandard + import zstandard # noqa: F401 except ImportError: pass else: @@ -102,8 +106,5 @@ class HttpCompressionMiddleware: if encoding == b"br" and b"br" in ACCEPTED_ENCODINGS: return _unbrotli(body, max_size=self._max_size) if encoding == b"zstd" and b"zstd" in ACCEPTED_ENCODINGS: - # Using its streaming API since its simple API could handle only cases - # where there is content size data embedded in the frame - reader = zstandard.ZstdDecompressor().stream_reader(io.BytesIO(body)) - return reader.read() + return _unzstd(body, max_size=self._max_size) return body diff --git a/scrapy/utils/_compression.py b/scrapy/utils/_compression.py index 9a32ce4f0..93aa254b2 100644 --- a/scrapy/utils/_compression.py +++ b/scrapy/utils/_compression.py @@ -7,6 +7,11 @@ try: except ImportError: pass +try: + import zstandard +except ImportError: + pass + class _DecompressionMaxSizeExceeded(ValueError): pass @@ -65,3 +70,23 @@ def _unbrotli(data: bytes, *, max_size: int = 0) -> bytes: ) output_list.append(output_chunk) return b"".join(output_list) + + +def _unzstd(data: bytes, *, max_size: int = 0) -> bytes: + decompressor = zstandard.ZstdDecompressor() + stream_reader = decompressor.stream_reader(BytesIO(data)) + output_list: List[bytes] = [] + output_chunk = b"." + decompressed_size = 0 + CHUNK_SIZE = 8196 + while output_chunk: + output_chunk = stream_reader.read(CHUNK_SIZE) + decompressed_size += len(output_chunk) + if max_size and decompressed_size > max_size: + raise _DecompressionMaxSizeExceeded( + f"The number of bytes decompressed so far " + f"({decompressed_size}B) exceed the specified maximum " + f"({max_size}B)." + ) + output_list.append(output_chunk) + return b"".join(output_list) diff --git a/tests/sample_data/compressed/bomb-zstd.bin b/tests/sample_data/compressed/bomb-zstd.bin new file mode 100644 index 0000000000000000000000000000000000000000..4b0efa8a41c88a38dffe7cea9e4a6726bdb137c4 GIT binary patch literal 1096 zcmdPcs{gko!e;q;1{Fqz2O$}m#R@=_sF0kWTTql*T%4Jor;wDNo219Z$k6h?-fpfB z0|SQwBg3EnmI6#5b|Mlx7l~br#Lh!vBdZBP5+5~lG&~1uT5@4vU|?kU`+vT_!f29* VWPM*?)(2)~i{-##pxebr9RRD(6-595 literal 0 HcmV?d00001 diff --git a/tests/test_downloadermiddleware_httpcompression.py b/tests/test_downloadermiddleware_httpcompression.py index 8858916bc..7babd1318 100644 --- a/tests/test_downloadermiddleware_httpcompression.py +++ b/tests/test_downloadermiddleware_httpcompression.py @@ -41,7 +41,7 @@ FORMAT = { "br", # 34 → 11 511 612 "deflate", # 27 968 → 11 511 612 "gzip", # 27 988 → 11 511 612 - # "zstd", + "zstd", # 1 096 → 11 511 612 ) }, } @@ -396,6 +396,9 @@ class HttpCompressionTest(TestCase): def test_compression_bomb_gzip(self): self._test_compression_bomb("gzip") + def test_compression_bomb_zstd(self): + self._test_compression_bomb("zstd") + class HttpCompressionSubclassTest(TestCase): def test_init_missing_stats(self): From 3fda2fe103dafa8d4d48b21c63b2321ccec9c378 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Wed, 22 Nov 2023 18:34:37 +0100 Subject: [PATCH 020/119] Protect against gzip bomb sitemaps --- scrapy/spiders/sitemap.py | 8 +++++++- tests/test_spider.py | 15 +++++++++++++-- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/scrapy/spiders/sitemap.py b/scrapy/spiders/sitemap.py index aaf75a519..cc8b13cc3 100644 --- a/scrapy/spiders/sitemap.py +++ b/scrapy/spiders/sitemap.py @@ -3,6 +3,7 @@ import re from scrapy.http import Request, XmlResponse from scrapy.spiders import Spider +from scrapy.utils._compression import _DecompressionMaxSizeExceeded from scrapy.utils.gz import gunzip, gzip_magic_number from scrapy.utils.sitemap import Sitemap, sitemap_urls_from_robots @@ -71,7 +72,12 @@ class SitemapSpider(Spider): if isinstance(response, XmlResponse): return response.body if gzip_magic_number(response): - return gunzip(response.body) + try: + return gunzip( + response.body, max_size=self.settings.getint("DOWNLOAD_MAXSIZE") + ) + except _DecompressionMaxSizeExceeded: + return None # actual gzipped sitemap files are decompressed above ; # if we are here (response body is not gzipped) # and have a response for .xml.gz, diff --git a/tests/test_spider.py b/tests/test_spider.py index 00da3d485..875ff5454 100644 --- a/tests/test_spider.py +++ b/tests/test_spider.py @@ -2,6 +2,7 @@ import gzip import inspect import warnings from io import BytesIO +from pathlib import Path from typing import Any from unittest import mock @@ -25,7 +26,7 @@ from scrapy.spiders import ( ) from scrapy.spiders.init import InitSpider from scrapy.utils.test import get_crawler -from tests import get_testdata +from tests import get_testdata, tests_datadir class SpiderTest(unittest.TestCase): @@ -489,7 +490,8 @@ class SitemapSpiderTest(SpiderTest): GZBODY = f.getvalue() def assertSitemapBody(self, response, body): - spider = self.spider_class("example.com") + crawler = get_crawler() + spider = self.spider_class.from_crawler(crawler, "example.com") self.assertEqual(spider._get_sitemap_body(response), body) def test_get_sitemap_body(self): @@ -692,6 +694,15 @@ Sitemap: /sitemap-relative-url.xml ["http://www.example.com/sitemap2.xml"], ) + def test_compression_bomb(self): + settings = {"DOWNLOAD_MAXSIZE": 10_000_000} + crawler = get_crawler(settings_dict=settings) + spider = self.spider_class.from_crawler(crawler, "example.com") + body_path = Path(tests_datadir, "compressed", "bomb-gzip.bin") + body = body_path.read_bytes() + response = Response(url="https://example.com", body=body) + self.assertIsNone(spider._get_sitemap_body(response)) + class DeprecationTest(unittest.TestCase): def test_crawl_spider(self): From e0b66c021ae20cdcc24e4bb02ffae56005d3a073 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Wed, 22 Nov 2023 19:03:24 +0100 Subject: [PATCH 021/119] Mind Spider.download_maxsize and Request.meta['download_maxsize'] --- .../downloadermiddlewares/httpcompression.py | 30 ++++-- scrapy/spiders/sitemap.py | 10 +- ...st_downloadermiddleware_httpcompression.py | 99 ++++++++++++++++--- tests/test_spider.py | 35 ++++++- 4 files changed, 143 insertions(+), 31 deletions(-) diff --git a/scrapy/downloadermiddlewares/httpcompression.py b/scrapy/downloadermiddlewares/httpcompression.py index 8ee1d95a6..e6463307e 100644 --- a/scrapy/downloadermiddlewares/httpcompression.py +++ b/scrapy/downloadermiddlewares/httpcompression.py @@ -1,5 +1,6 @@ import warnings +from scrapy import signals from scrapy.exceptions import IgnoreRequest, NotConfigured from scrapy.http import Response, TextResponse from scrapy.responsetypes import responsetypes @@ -38,6 +39,7 @@ class HttpCompressionMiddleware: return self.stats = crawler.stats self._max_size = crawler.settings.getint("DOWNLOAD_MAXSIZE") + crawler.signals.connect(self.open_spider, signals.spider_opened) @classmethod def from_crawler(cls, crawler): @@ -52,10 +54,15 @@ class HttpCompressionMiddleware: "reimplement their 'from_crawler' method.", ScrapyDeprecationWarning, ) - result = cls() - result.stats = crawler.stats - result._max_size = crawler.settings.getint("DOWNLOAD_MAXSIZE") - return result + spider = cls() + spider.stats = crawler.stats + spider._max_size = crawler.settings.getint("DOWNLOAD_MAXSIZE") + crawler.signals.connect(spider.open_spider, signals.spider_opened) + return spider + + def open_spider(self, spider): + if hasattr(spider, "download_maxsize"): + self._max_size = spider.download_maxsize def process_request(self, request, spider): request.headers.setdefault("Accept-Encoding", b", ".join(ACCEPTED_ENCODINGS)) @@ -67,8 +74,11 @@ class HttpCompressionMiddleware: content_encoding = response.headers.getlist("Content-Encoding") if content_encoding: encoding = content_encoding.pop() + max_size = request.meta.get("download_maxsize", self._max_size) try: - decoded_body = self._decode(response.body, encoding.lower()) + decoded_body = self._decode( + response.body, encoding.lower(), max_size + ) except _DecompressionMaxSizeExceeded: raise IgnoreRequest( f"Ignored response {response} because its body " @@ -98,13 +108,13 @@ class HttpCompressionMiddleware: return response - def _decode(self, body, encoding): + def _decode(self, body, encoding, max_size): if encoding == b"gzip" or encoding == b"x-gzip": - return gunzip(body, max_size=self._max_size) + return gunzip(body, max_size=max_size) if encoding == b"deflate": - return _inflate(body, max_size=self._max_size) + return _inflate(body, max_size=max_size) if encoding == b"br" and b"br" in ACCEPTED_ENCODINGS: - return _unbrotli(body, max_size=self._max_size) + return _unbrotli(body, max_size=max_size) if encoding == b"zstd" and b"zstd" in ACCEPTED_ENCODINGS: - return _unzstd(body, max_size=self._max_size) + return _unzstd(body, max_size=max_size) return body diff --git a/scrapy/spiders/sitemap.py b/scrapy/spiders/sitemap.py index cc8b13cc3..3bca3f5c2 100644 --- a/scrapy/spiders/sitemap.py +++ b/scrapy/spiders/sitemap.py @@ -72,10 +72,14 @@ class SitemapSpider(Spider): if isinstance(response, XmlResponse): return response.body if gzip_magic_number(response): + max_size = response.meta.get( + "download_maxsize", + getattr( + self, "download_maxsize", self.settings.getint("DOWNLOAD_MAXSIZE") + ), + ) try: - return gunzip( - response.body, max_size=self.settings.getint("DOWNLOAD_MAXSIZE") - ) + return gunzip(response.body, max_size=max_size) except _DecompressionMaxSizeExceeded: return None # actual gzipped sitemap files are decompressed above ; diff --git a/tests/test_downloadermiddleware_httpcompression.py b/tests/test_downloadermiddleware_httpcompression.py index 7babd1318..6d71ba71e 100644 --- a/tests/test_downloadermiddleware_httpcompression.py +++ b/tests/test_downloadermiddleware_httpcompression.py @@ -49,10 +49,7 @@ FORMAT = { class HttpCompressionTest(TestCase): def setUp(self): - settings = { - "DOWNLOAD_MAXSIZE": 10_000_000, # For compression bomb tests. - } - self.crawler = get_crawler(Spider, settings_dict=settings) + self.crawler = get_crawler(Spider) self.spider = self.crawler._create_spider("scrapytest.org") self.mw = HttpCompressionMiddleware.from_crawler(self.crawler) self.crawler.stats.open_spider(self.spider) @@ -373,31 +370,103 @@ class HttpCompressionTest(TestCase): self.assertStatsEqual("httpcompression/response_count", None) self.assertStatsEqual("httpcompression/response_bytes", None) - def _test_compression_bomb(self, compression_id): + def _test_compression_bomb_setting(self, compression_id): + settings = {"DOWNLOAD_MAXSIZE": 10_000_000} + crawler = get_crawler(Spider, settings_dict=settings) + spider = crawler._create_spider("scrapytest.org") + mw = HttpCompressionMiddleware.from_crawler(crawler) + mw.open_spider(spider) + response = self._getresponse(f"bomb-{compression_id}") self.assertRaises( IgnoreRequest, - self.mw.process_response, + mw.process_response, response.request, response, - self.spider, + spider, ) - def test_compression_bomb_br(self): + def test_compression_bomb_setting_br(self): try: import brotli # noqa: F401 except ImportError: raise SkipTest("no brotli") - self._test_compression_bomb("br") + self._test_compression_bomb_setting("br") - def test_compression_bomb_deflate(self): - self._test_compression_bomb("deflate") + def test_compression_bomb_setting_deflate(self): + self._test_compression_bomb_setting("deflate") - def test_compression_bomb_gzip(self): - self._test_compression_bomb("gzip") + def test_compression_bomb_setting_gzip(self): + self._test_compression_bomb_setting("gzip") - def test_compression_bomb_zstd(self): - self._test_compression_bomb("zstd") + def test_compression_bomb_setting_zstd(self): + self._test_compression_bomb_setting("zstd") + + def _test_compression_bomb_spider_attr(self, compression_id): + class DownloadMaxSizeSpider(Spider): + download_maxsize = 10_000_000 + + crawler = get_crawler(DownloadMaxSizeSpider) + spider = crawler._create_spider("scrapytest.org") + mw = HttpCompressionMiddleware.from_crawler(crawler) + mw.open_spider(spider) + + response = self._getresponse(f"bomb-{compression_id}") + self.assertRaises( + IgnoreRequest, + mw.process_response, + response.request, + response, + spider, + ) + + def test_compression_bomb_spider_attr_br(self): + try: + import brotli # noqa: F401 + except ImportError: + raise SkipTest("no brotli") + self._test_compression_bomb_spider_attr("br") + + def test_compression_bomb_spider_attr_deflate(self): + self._test_compression_bomb_spider_attr("deflate") + + def test_compression_bomb_spider_attr_gzip(self): + self._test_compression_bomb_spider_attr("gzip") + + def test_compression_bomb_spider_attr_zstd(self): + self._test_compression_bomb_spider_attr("zstd") + + def _test_compression_bomb_request_meta(self, compression_id): + crawler = get_crawler(Spider) + spider = crawler._create_spider("scrapytest.org") + mw = HttpCompressionMiddleware.from_crawler(crawler) + mw.open_spider(spider) + + response = self._getresponse(f"bomb-{compression_id}") + response.meta["download_maxsize"] = 10_000_000 + self.assertRaises( + IgnoreRequest, + mw.process_response, + response.request, + response, + spider, + ) + + def test_compression_bomb_request_meta_br(self): + try: + import brotli # noqa: F401 + except ImportError: + raise SkipTest("no brotli") + self._test_compression_bomb_request_meta("br") + + def test_compression_bomb_request_meta_deflate(self): + self._test_compression_bomb_request_meta("deflate") + + def test_compression_bomb_request_meta_gzip(self): + self._test_compression_bomb_request_meta("gzip") + + def test_compression_bomb_request_meta_zstd(self): + self._test_compression_bomb_request_meta("zstd") class HttpCompressionSubclassTest(TestCase): diff --git a/tests/test_spider.py b/tests/test_spider.py index 875ff5454..e8480ceb4 100644 --- a/tests/test_spider.py +++ b/tests/test_spider.py @@ -509,6 +509,7 @@ class SitemapSpiderTest(SpiderTest): url="http://www.example.com/sitemap", body=self.GZBODY, headers={"content-type": "application/gzip"}, + request=Request("http://www.example.com/sitemap"), ) self.assertSitemapBody(r, self.BODY) @@ -517,7 +518,11 @@ class SitemapSpiderTest(SpiderTest): self.assertSitemapBody(r, self.BODY) def test_get_sitemap_body_xml_url_compressed(self): - r = Response(url="http://www.example.com/sitemap.xml.gz", body=self.GZBODY) + r = Response( + url="http://www.example.com/sitemap.xml.gz", + body=self.GZBODY, + request=Request("http://www.example.com/sitemap"), + ) self.assertSitemapBody(r, self.BODY) # .xml.gz but body decoded by HttpCompression middleware already @@ -694,13 +699,37 @@ Sitemap: /sitemap-relative-url.xml ["http://www.example.com/sitemap2.xml"], ) - def test_compression_bomb(self): + def test_compression_bomb_setting(self): settings = {"DOWNLOAD_MAXSIZE": 10_000_000} crawler = get_crawler(settings_dict=settings) spider = self.spider_class.from_crawler(crawler, "example.com") body_path = Path(tests_datadir, "compressed", "bomb-gzip.bin") body = body_path.read_bytes() - response = Response(url="https://example.com", body=body) + request = Request(url="https://example.com") + response = Response(url="https://example.com", body=body, request=request) + self.assertIsNone(spider._get_sitemap_body(response)) + + def test_compression_bomb_spider_attr(self): + class DownloadMaxSizeSpider(self.spider_class): + download_maxsize = 10_000_000 + + crawler = get_crawler() + spider = DownloadMaxSizeSpider.from_crawler(crawler, "example.com") + body_path = Path(tests_datadir, "compressed", "bomb-gzip.bin") + body = body_path.read_bytes() + request = Request(url="https://example.com") + response = Response(url="https://example.com", body=body, request=request) + self.assertIsNone(spider._get_sitemap_body(response)) + + def test_compression_bomb_request_meta(self): + crawler = get_crawler() + spider = self.spider_class.from_crawler(crawler, "example.com") + body_path = Path(tests_datadir, "compressed", "bomb-gzip.bin") + body = body_path.read_bytes() + request = Request( + url="https://example.com", meta={"download_maxsize": 10_000_000} + ) + response = Response(url="https://example.com", body=body, request=request) self.assertIsNone(spider._get_sitemap_body(response)) From 1087bb7b2eab28543bf9ba13149adb7acd4a3675 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Thu, 23 Nov 2023 09:11:14 +0100 Subject: [PATCH 022/119] Update the docs --- docs/topics/request-response.rst | 1 + docs/topics/settings.rst | 36 +++++++++++++++++--------------- 2 files changed, 20 insertions(+), 17 deletions(-) diff --git a/docs/topics/request-response.rst b/docs/topics/request-response.rst index adf3d0f4a..2d1227cf8 100644 --- a/docs/topics/request-response.rst +++ b/docs/topics/request-response.rst @@ -702,6 +702,7 @@ Those are: * :reqmeta:`download_fail_on_dataloss` * :reqmeta:`download_latency` * :reqmeta:`download_maxsize` +* :reqmeta:`download_warnsize` * :reqmeta:`download_timeout` * ``ftp_password`` (See :setting:`FTP_PASSWORD` for more info) * ``ftp_user`` (See :setting:`FTP_USER` for more info) diff --git a/docs/topics/settings.rst b/docs/topics/settings.rst index 7cdfb8768..eb24b834a 100644 --- a/docs/topics/settings.rst +++ b/docs/topics/settings.rst @@ -873,40 +873,42 @@ The amount of time (in secs) that the downloader will wait before timing out. Request.meta key. .. setting:: DOWNLOAD_MAXSIZE +.. reqmeta:: download_maxsize DOWNLOAD_MAXSIZE ---------------- -Default: ``1073741824`` (1024MB) +Default: ``1073741824`` (1 GiB) -The maximum response size (in bytes) that downloader will download. +The maximum response body size (in bytes) allowed. Bigger responses are +aborted and ignored. -If you want to disable it set to 0. +This applies both before and after compression. If decompressing a response +body would exceed this limit, decompression is aborted and the response is +ignored. -.. reqmeta:: download_maxsize +Use ``0`` to disable this limit. -.. note:: - - This size can be set per spider using :attr:`download_maxsize` - spider attribute and per-request using :reqmeta:`download_maxsize` - Request.meta key. +This limit can be set per spider using the :attr:`download_maxsize` spider +attribute and per request using the :reqmeta:`download_maxsize` Request.meta +key. .. setting:: DOWNLOAD_WARNSIZE +.. reqmeta:: download_warnsize DOWNLOAD_WARNSIZE ----------------- -Default: ``33554432`` (32MB) +Default: ``33554432`` (32 MiB) -The response size (in bytes) that downloader will start to warn. +If the size of a response exceeds this value, before or after compression, a +warning will be logged about it. -If you want to disable it set to 0. +Use ``0`` to disable this limit. -.. note:: - - This size can be set per spider using :attr:`download_warnsize` - spider attribute and per-request using :reqmeta:`download_warnsize` - Request.meta key. +This limit can be set per spider using the :attr:`download_warnsize` spider +attribute and per request using the :reqmeta:`download_warnsize` Request.meta +key. .. setting:: DOWNLOAD_FAIL_ON_DATALOSS From 03d9866518ab43844ba0309394529240f4cf115e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Thu, 23 Nov 2023 10:26:47 +0100 Subject: [PATCH 023/119] Also use DOWNLOAD_WARNSIZE for decompressions --- .../downloadermiddlewares/httpcompression.py | 18 ++- scrapy/spiders/sitemap.py | 35 ++++- scrapy/utils/_compression.py | 12 +- scrapy/utils/gz.py | 4 +- ...st_downloadermiddleware_httpcompression.py | 130 ++++++++++++++++++ tests/test_spider.py | 78 +++++++++++ 6 files changed, 260 insertions(+), 17 deletions(-) diff --git a/scrapy/downloadermiddlewares/httpcompression.py b/scrapy/downloadermiddlewares/httpcompression.py index e6463307e..95bc1849d 100644 --- a/scrapy/downloadermiddlewares/httpcompression.py +++ b/scrapy/downloadermiddlewares/httpcompression.py @@ -1,4 +1,5 @@ import warnings +from logging import getLogger from scrapy import signals from scrapy.exceptions import IgnoreRequest, NotConfigured @@ -13,6 +14,8 @@ from scrapy.utils._compression import ( from scrapy.utils.deprecate import ScrapyDeprecationWarning from scrapy.utils.gz import gunzip +logger = getLogger(__name__) + ACCEPTED_ENCODINGS = [b"gzip", b"deflate"] try: @@ -39,6 +42,7 @@ class HttpCompressionMiddleware: return self.stats = crawler.stats self._max_size = crawler.settings.getint("DOWNLOAD_MAXSIZE") + self._warn_size = crawler.settings.getint("DOWNLOAD_WARNSIZE") crawler.signals.connect(self.open_spider, signals.spider_opened) @classmethod @@ -57,12 +61,15 @@ class HttpCompressionMiddleware: spider = cls() spider.stats = crawler.stats spider._max_size = crawler.settings.getint("DOWNLOAD_MAXSIZE") + spider._warn_size = crawler.settings.getint("DOWNLOAD_WARNSIZE") crawler.signals.connect(spider.open_spider, signals.spider_opened) return spider def open_spider(self, spider): if hasattr(spider, "download_maxsize"): self._max_size = spider.download_maxsize + if hasattr(spider, "download_warnsize"): + self._warn_size = spider.download_warnsize def process_request(self, request, spider): request.headers.setdefault("Accept-Encoding", b", ".join(ACCEPTED_ENCODINGS)) @@ -75,6 +82,7 @@ class HttpCompressionMiddleware: if content_encoding: encoding = content_encoding.pop() max_size = request.meta.get("download_maxsize", self._max_size) + warn_size = request.meta.get("download_warnsize", self._warn_size) try: decoded_body = self._decode( response.body, encoding.lower(), max_size @@ -82,8 +90,14 @@ class HttpCompressionMiddleware: except _DecompressionMaxSizeExceeded: raise IgnoreRequest( f"Ignored response {response} because its body " - f"({len(response.body)}B) exceeded DOWNLOAD_MAXSIZE " - f"({self._max_size}B) during decompression." + f"({len(response.body)} B) exceeded DOWNLOAD_MAXSIZE " + f"({self._max_size} B) during decompression." + ) + if len(response.body) < warn_size and len(decoded_body) >= warn_size: + logger.warning( + f"{response} body size after decompression " + f"({len(decoded_body)} B) is larger than the " + f"download warning size ({warn_size} B)." ) if self.stats: self.stats.inc_value( diff --git a/scrapy/spiders/sitemap.py b/scrapy/spiders/sitemap.py index 3bca3f5c2..0574f0ccb 100644 --- a/scrapy/spiders/sitemap.py +++ b/scrapy/spiders/sitemap.py @@ -1,5 +1,6 @@ import logging import re +from typing import TYPE_CHECKING, Any from scrapy.http import Request, XmlResponse from scrapy.spiders import Spider @@ -7,6 +8,12 @@ from scrapy.utils._compression import _DecompressionMaxSizeExceeded from scrapy.utils.gz import gunzip, gzip_magic_number from scrapy.utils.sitemap import Sitemap, sitemap_urls_from_robots +if TYPE_CHECKING: + # typing.Self requires Python 3.11 + from typing_extensions import Self + + from scrapy.crawler import Crawler + logger = logging.getLogger(__name__) @@ -16,6 +23,17 @@ class SitemapSpider(Spider): sitemap_follow = [""] sitemap_alternate_links = False + @classmethod + def from_crawler(cls, crawler: "Crawler", *args: Any, **kwargs: Any) -> "Self": + spider = super().from_crawler(crawler, *args, **kwargs) + spider._max_size = getattr( + spider, "download_maxsize", spider.settings.getint("DOWNLOAD_MAXSIZE") + ) + spider._warn_size = getattr( + spider, "download_warnsize", spider.settings.getint("DOWNLOAD_WARNSIZE") + ) + return spider + def __init__(self, *a, **kw): super().__init__(*a, **kw) self._cbs = [] @@ -72,16 +90,19 @@ class SitemapSpider(Spider): if isinstance(response, XmlResponse): return response.body if gzip_magic_number(response): - max_size = response.meta.get( - "download_maxsize", - getattr( - self, "download_maxsize", self.settings.getint("DOWNLOAD_MAXSIZE") - ), - ) + uncompressed_size = len(response.body) + max_size = response.meta.get("download_maxsize", self._max_size) + warn_size = response.meta.get("download_warnsize", self._warn_size) try: - return gunzip(response.body, max_size=max_size) + body = gunzip(response.body, max_size=max_size) except _DecompressionMaxSizeExceeded: return None + if uncompressed_size < warn_size and len(body) >= warn_size: + logger.warning( + f"{response} body size after decompression ({len(body)} B) " + f"is larger than the download warning size ({warn_size} B)." + ) + return body # actual gzipped sitemap files are decompressed above ; # if we are here (response body is not gzipped) # and have a response for .xml.gz, diff --git a/scrapy/utils/_compression.py b/scrapy/utils/_compression.py index 93aa254b2..a70f6c275 100644 --- a/scrapy/utils/_compression.py +++ b/scrapy/utils/_compression.py @@ -44,8 +44,8 @@ def _inflate(data: bytes, *, max_size: int = 0) -> bytes: if max_size and decompressed_size > max_size: raise _DecompressionMaxSizeExceeded( f"The number of bytes decompressed so far " - f"({decompressed_size}B) exceed the specified maximum " - f"({max_size}B)." + f"({decompressed_size} B) exceed the specified maximum " + f"({max_size} B)." ) output_list.append(output_chunk) return b"".join(output_list) @@ -65,8 +65,8 @@ def _unbrotli(data: bytes, *, max_size: int = 0) -> bytes: if max_size and decompressed_size > max_size: raise _DecompressionMaxSizeExceeded( f"The number of bytes decompressed so far " - f"({decompressed_size}B) exceed the specified maximum " - f"({max_size}B)." + f"({decompressed_size} B) exceed the specified maximum " + f"({max_size} B)." ) output_list.append(output_chunk) return b"".join(output_list) @@ -85,8 +85,8 @@ def _unzstd(data: bytes, *, max_size: int = 0) -> bytes: if max_size and decompressed_size > max_size: raise _DecompressionMaxSizeExceeded( f"The number of bytes decompressed so far " - f"({decompressed_size}B) exceed the specified maximum " - f"({max_size}B)." + f"({decompressed_size} B) exceed the specified maximum " + f"({max_size} B)." ) output_list.append(output_chunk) return b"".join(output_list) diff --git a/scrapy/utils/gz.py b/scrapy/utils/gz.py index 548134721..e5cf68d62 100644 --- a/scrapy/utils/gz.py +++ b/scrapy/utils/gz.py @@ -31,8 +31,8 @@ def gunzip(data: bytes, *, max_size: int = 0) -> bytes: if max_size and decompressed_size > max_size: raise _DecompressionMaxSizeExceeded( f"The number of bytes decompressed so far " - f"({decompressed_size}B) exceed the specified maximum " - f"({max_size}B)." + f"({decompressed_size} B) exceed the specified maximum " + f"({max_size} B)." ) output_list.append(chunk) return b"".join(output_list) diff --git a/tests/test_downloadermiddleware_httpcompression.py b/tests/test_downloadermiddleware_httpcompression.py index 6d71ba71e..f74fff218 100644 --- a/tests/test_downloadermiddleware_httpcompression.py +++ b/tests/test_downloadermiddleware_httpcompression.py @@ -1,9 +1,11 @@ from gzip import GzipFile from io import BytesIO +from logging import WARNING from pathlib import Path from unittest import SkipTest, TestCase from warnings import catch_warnings +from testfixtures import LogCapture from w3lib.encoding import resolve_encoding from scrapy.downloadermiddlewares.httpcompression import ( @@ -468,6 +470,134 @@ class HttpCompressionTest(TestCase): def test_compression_bomb_request_meta_zstd(self): self._test_compression_bomb_request_meta("zstd") + def _test_download_warnsize_setting(self, compression_id): + settings = {"DOWNLOAD_WARNSIZE": 10_000_000} + crawler = get_crawler(Spider, settings_dict=settings) + spider = crawler._create_spider("scrapytest.org") + mw = HttpCompressionMiddleware.from_crawler(crawler) + mw.open_spider(spider) + response = self._getresponse(f"bomb-{compression_id}") + + with LogCapture( + "scrapy.downloadermiddlewares.httpcompression", + propagate=False, + level=WARNING, + ) as log: + mw.process_response(response.request, response, spider) + log.check( + ( + "scrapy.downloadermiddlewares.httpcompression", + "WARNING", + ( + "<200 http://scrapytest.org/> body size after " + "decompression (11511612 B) is larger than the download " + "warning size (10000000 B)." + ), + ), + ) + + def test_download_warnsize_setting_br(self): + try: + import brotli # noqa: F401 + except ImportError: + raise SkipTest("no brotli") + self._test_download_warnsize_setting("br") + + def test_download_warnsize_setting_deflate(self): + self._test_download_warnsize_setting("deflate") + + def test_download_warnsize_setting_gzip(self): + self._test_download_warnsize_setting("gzip") + + def test_download_warnsize_setting_zstd(self): + self._test_download_warnsize_setting("zstd") + + def _test_download_warnsize_spider_attr(self, compression_id): + class DownloadWarnSizeSpider(Spider): + download_warnsize = 10_000_000 + + crawler = get_crawler(DownloadWarnSizeSpider) + spider = crawler._create_spider("scrapytest.org") + mw = HttpCompressionMiddleware.from_crawler(crawler) + mw.open_spider(spider) + response = self._getresponse(f"bomb-{compression_id}") + + with LogCapture( + "scrapy.downloadermiddlewares.httpcompression", + propagate=False, + level=WARNING, + ) as log: + mw.process_response(response.request, response, spider) + log.check( + ( + "scrapy.downloadermiddlewares.httpcompression", + "WARNING", + ( + "<200 http://scrapytest.org/> body size after " + "decompression (11511612 B) is larger than the download " + "warning size (10000000 B)." + ), + ), + ) + + def test_download_warnsize_spider_attr_br(self): + try: + import brotli # noqa: F401 + except ImportError: + raise SkipTest("no brotli") + self._test_download_warnsize_spider_attr("br") + + def test_download_warnsize_spider_attr_deflate(self): + self._test_download_warnsize_spider_attr("deflate") + + def test_download_warnsize_spider_attr_gzip(self): + self._test_download_warnsize_spider_attr("gzip") + + def test_download_warnsize_spider_attr_zstd(self): + self._test_download_warnsize_spider_attr("zstd") + + def _test_download_warnsize_request_meta(self, compression_id): + crawler = get_crawler(Spider) + spider = crawler._create_spider("scrapytest.org") + mw = HttpCompressionMiddleware.from_crawler(crawler) + mw.open_spider(spider) + response = self._getresponse(f"bomb-{compression_id}") + response.meta["download_warnsize"] = 10_000_000 + + with LogCapture( + "scrapy.downloadermiddlewares.httpcompression", + propagate=False, + level=WARNING, + ) as log: + mw.process_response(response.request, response, spider) + log.check( + ( + "scrapy.downloadermiddlewares.httpcompression", + "WARNING", + ( + "<200 http://scrapytest.org/> body size after " + "decompression (11511612 B) is larger than the download " + "warning size (10000000 B)." + ), + ), + ) + + def test_download_warnsize_request_meta_br(self): + try: + import brotli # noqa: F401 + except ImportError: + raise SkipTest("no brotli") + self._test_download_warnsize_request_meta("br") + + def test_download_warnsize_request_meta_deflate(self): + self._test_download_warnsize_request_meta("deflate") + + def test_download_warnsize_request_meta_gzip(self): + self._test_download_warnsize_request_meta("gzip") + + def test_download_warnsize_request_meta_zstd(self): + self._test_download_warnsize_request_meta("zstd") + class HttpCompressionSubclassTest(TestCase): def test_init_missing_stats(self): diff --git a/tests/test_spider.py b/tests/test_spider.py index e8480ceb4..3f595cc93 100644 --- a/tests/test_spider.py +++ b/tests/test_spider.py @@ -2,6 +2,7 @@ import gzip import inspect import warnings from io import BytesIO +from logging import WARNING from pathlib import Path from typing import Any from unittest import mock @@ -732,6 +733,83 @@ Sitemap: /sitemap-relative-url.xml response = Response(url="https://example.com", body=body, request=request) self.assertIsNone(spider._get_sitemap_body(response)) + def test_download_warnsize_setting(self): + settings = {"DOWNLOAD_WARNSIZE": 10_000_000} + crawler = get_crawler(settings_dict=settings) + spider = self.spider_class.from_crawler(crawler, "example.com") + body_path = Path(tests_datadir, "compressed", "bomb-gzip.bin") + body = body_path.read_bytes() + request = Request(url="https://example.com") + response = Response(url="https://example.com", body=body, request=request) + with LogCapture( + "scrapy.spiders.sitemap", propagate=False, level=WARNING + ) as log: + spider._get_sitemap_body(response) + log.check( + ( + "scrapy.spiders.sitemap", + "WARNING", + ( + "<200 https://example.com> body size after decompression " + "(11511612 B) is larger than the download warning size " + "(10000000 B)." + ), + ), + ) + + def test_download_warnsize_spider_attr(self): + class DownloadWarnSizeSpider(self.spider_class): + download_warnsize = 10_000_000 + + crawler = get_crawler() + spider = DownloadWarnSizeSpider.from_crawler(crawler, "example.com") + body_path = Path(tests_datadir, "compressed", "bomb-gzip.bin") + body = body_path.read_bytes() + request = Request( + url="https://example.com", meta={"download_warnsize": 10_000_000} + ) + response = Response(url="https://example.com", body=body, request=request) + with LogCapture( + "scrapy.spiders.sitemap", propagate=False, level=WARNING + ) as log: + spider._get_sitemap_body(response) + log.check( + ( + "scrapy.spiders.sitemap", + "WARNING", + ( + "<200 https://example.com> body size after decompression " + "(11511612 B) is larger than the download warning size " + "(10000000 B)." + ), + ), + ) + + def test_download_warnsize_request_meta(self): + crawler = get_crawler() + spider = self.spider_class.from_crawler(crawler, "example.com") + body_path = Path(tests_datadir, "compressed", "bomb-gzip.bin") + body = body_path.read_bytes() + request = Request( + url="https://example.com", meta={"download_warnsize": 10_000_000} + ) + response = Response(url="https://example.com", body=body, request=request) + with LogCapture( + "scrapy.spiders.sitemap", propagate=False, level=WARNING + ) as log: + spider._get_sitemap_body(response) + log.check( + ( + "scrapy.spiders.sitemap", + "WARNING", + ( + "<200 https://example.com> body size after decompression " + "(11511612 B) is larger than the download warning size " + "(10000000 B)." + ), + ), + ) + class DeprecationTest(unittest.TestCase): def test_crawl_spider(self): From b53ed52a22470adbe269f5a7dcc67a0da369eaf3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Thu, 23 Nov 2023 11:36:45 +0100 Subject: [PATCH 024/119] Update the release notes --- docs/news.rst | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/docs/news.rst b/docs/news.rst index 0c202639e..c4081b99b 100644 --- a/docs/news.rst +++ b/docs/news.rst @@ -3,6 +3,20 @@ Release notes ============= +.. _release-2.11.1: + +Scrapy 2.11.1 (unreleased) +-------------------------- + +**Security bug fix:** + +- :setting:`DOWNLOAD_MAXSIZE` and :setting:`DOWNLOAD_WARNSIZE` now also apply + to the decompressed response body. Please, see the `7j7m-v7m3-jqm7 security + advisory`_ for more information. + + .. _7j7m-v7m3-jqm7 security advisory: https://github.com/scrapy/scrapy/security/advisories/GHSA-7j7m-v7m3-jqm7 + + .. _release-2.11.0: Scrapy 2.11.0 (2023-09-18) @@ -2871,6 +2885,17 @@ affect subclasses: (:issue:`3884`) +.. _release-1.8.4: + +Scrapy 1.8.4 (unreleased) +------------------------- + +**Security bug fix:** + +- :setting:`DOWNLOAD_MAXSIZE` and :setting:`DOWNLOAD_WARNSIZE` now also apply + to the decompressed response body. Please, see the `7j7m-v7m3-jqm7 security + advisory`_ for more information. + .. _release-1.8.3: From cf80e5670e8317858b9f60008a5d2d97b4988da0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Thu, 23 Nov 2023 12:07:15 +0100 Subject: [PATCH 025/119] Solve linting and typing issues --- scrapy/downloadermiddlewares/httpcompression.py | 2 +- scrapy/spiders/sitemap.py | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/scrapy/downloadermiddlewares/httpcompression.py b/scrapy/downloadermiddlewares/httpcompression.py index 95bc1849d..6c8b659bd 100644 --- a/scrapy/downloadermiddlewares/httpcompression.py +++ b/scrapy/downloadermiddlewares/httpcompression.py @@ -93,7 +93,7 @@ class HttpCompressionMiddleware: f"({len(response.body)} B) exceeded DOWNLOAD_MAXSIZE " f"({self._max_size} B) during decompression." ) - if len(response.body) < warn_size and len(decoded_body) >= warn_size: + if len(response.body) < warn_size <= len(decoded_body): logger.warning( f"{response} body size after decompression " f"({len(decoded_body)} B) is larger than the " diff --git a/scrapy/spiders/sitemap.py b/scrapy/spiders/sitemap.py index 0574f0ccb..386aa6a6e 100644 --- a/scrapy/spiders/sitemap.py +++ b/scrapy/spiders/sitemap.py @@ -22,6 +22,8 @@ class SitemapSpider(Spider): sitemap_rules = [("", "parse")] sitemap_follow = [""] sitemap_alternate_links = False + _max_size: int + _warn_size: int @classmethod def from_crawler(cls, crawler: "Crawler", *args: Any, **kwargs: Any) -> "Self": @@ -97,7 +99,7 @@ class SitemapSpider(Spider): body = gunzip(response.body, max_size=max_size) except _DecompressionMaxSizeExceeded: return None - if uncompressed_size < warn_size and len(body) >= warn_size: + if uncompressed_size < warn_size <= len(body): logger.warning( f"{response} body size after decompression ({len(body)} B) " f"is larger than the download warning size ({warn_size} B)." From 8e25f8c157e53c9f5df51e950fed18becfe7797d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Thu, 23 Nov 2023 14:12:59 +0100 Subject: [PATCH 026/119] Fix bad message --- scrapy/downloadermiddlewares/httpcompression.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scrapy/downloadermiddlewares/httpcompression.py b/scrapy/downloadermiddlewares/httpcompression.py index 6c8b659bd..f03294d65 100644 --- a/scrapy/downloadermiddlewares/httpcompression.py +++ b/scrapy/downloadermiddlewares/httpcompression.py @@ -91,7 +91,7 @@ class HttpCompressionMiddleware: raise IgnoreRequest( f"Ignored response {response} because its body " f"({len(response.body)} B) exceeded DOWNLOAD_MAXSIZE " - f"({self._max_size} B) during decompression." + f"({max_size} B) during decompression." ) if len(response.body) < warn_size <= len(decoded_body): logger.warning( From 09ce0ef52681e2e8344b6848b28bc222016362ef Mon Sep 17 00:00:00 2001 From: Laerte Pereira Date: Thu, 23 Nov 2023 10:50:46 -0300 Subject: [PATCH 027/119] Remove test_download_gzip_response test --- tests/test_downloader_handlers.py | 27 --------------------------- 1 file changed, 27 deletions(-) diff --git a/tests/test_downloader_handlers.py b/tests/test_downloader_handlers.py index f12243e1d..924ece6f9 100644 --- a/tests/test_downloader_handlers.py +++ b/tests/test_downloader_handlers.py @@ -726,33 +726,6 @@ class Http11MockServerTestCase(unittest.TestCase): reason = crawler.spider.meta["close_reason"] self.assertTrue(reason, "finished") - @defer.inlineCallbacks - def test_download_gzip_response(self): - crawler = get_crawler(SingleRequestSpider, self.settings_dict) - body = b"1" * 100 # PayloadResource requires body length to be 100 - request = Request( - self.mockserver.url("/payload"), - method="POST", - body=body, - meta={"download_maxsize": 50}, - ) - yield crawler.crawl(seed=request) - failure = crawler.spider.meta["failure"] - # download_maxsize < 100, hence the CancelledError - self.assertIsInstance(failure.value, defer.CancelledError) - - # See issue https://twistedmatrix.com/trac/ticket/8175 - raise unittest.SkipTest("xpayload fails on PY3") - crawler = get_crawler(SingleRequestSpider, self.settings_dict) - request.headers.setdefault(b"Accept-Encoding", b"gzip,deflate") - request = request.replace(url=self.mockserver.url("/xpayload")) - yield crawler.crawl(seed=request) - # download_maxsize = 50 is enough for the gzipped response - failure = crawler.spider.meta.get("failure") - self.assertIsNone(failure) - reason = crawler.spider.meta["close_reason"] - self.assertTrue(reason, "finished") - class UriResource(resource.Resource): """Return the full uri that was requested""" From 5f2827efe7b069e514a87bd0f7a9589f49f0a97a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Fri, 24 Nov 2023 10:02:56 +0100 Subject: [PATCH 028/119] Make HttpCompressionMiddleware changes backward-comaptible --- scrapy/downloadermiddlewares/httpcompression.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scrapy/downloadermiddlewares/httpcompression.py b/scrapy/downloadermiddlewares/httpcompression.py index f03294d65..1a3f6962a 100644 --- a/scrapy/downloadermiddlewares/httpcompression.py +++ b/scrapy/downloadermiddlewares/httpcompression.py @@ -37,8 +37,10 @@ class HttpCompressionMiddleware: """This middleware allows compressed (gzip, deflate) traffic to be sent/received from web sites""" - def __init__(self, *, crawler=None): + def __init__(self, stats=None, *, crawler=None): if not crawler: + if stats: + self.stats = stats return self.stats = crawler.stats self._max_size = crawler.settings.getint("DOWNLOAD_MAXSIZE") From 6c278e1862c5453ec45a8f6a5c2472710895cad9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Fri, 24 Nov 2023 10:15:21 +0100 Subject: [PATCH 029/119] =?UTF-8?q?List[bytes]=20=E2=86=92=20BytesIO?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scrapy/utils/_compression.py | 22 ++++++++++++---------- scrapy/utils/gz.py | 12 ++++++------ 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/scrapy/utils/_compression.py b/scrapy/utils/_compression.py index a70f6c275..b17fd7881 100644 --- a/scrapy/utils/_compression.py +++ b/scrapy/utils/_compression.py @@ -1,6 +1,5 @@ import zlib from io import BytesIO -from typing import List try: import brotli @@ -21,7 +20,7 @@ def _inflate(data: bytes, *, max_size: int = 0) -> bytes: decompressor = zlib.decompressobj() raw_decompressor = zlib.decompressobj(wbits=-15) input_stream = BytesIO(data) - output_list: List[bytes] = [] + output_stream = BytesIO() output_chunk = b"." decompressed_size = 0 CHUNK_SIZE = 8196 @@ -47,14 +46,15 @@ def _inflate(data: bytes, *, max_size: int = 0) -> bytes: f"({decompressed_size} B) exceed the specified maximum " f"({max_size} B)." ) - output_list.append(output_chunk) - return b"".join(output_list) + output_stream.write(output_chunk) + output_stream.seek(0) + return output_stream.read() def _unbrotli(data: bytes, *, max_size: int = 0) -> bytes: decompressor = brotli.Decompressor() input_stream = BytesIO(data) - output_list: List[bytes] = [] + output_stream = BytesIO() output_chunk = b"." decompressed_size = 0 CHUNK_SIZE = 8196 @@ -68,14 +68,15 @@ def _unbrotli(data: bytes, *, max_size: int = 0) -> bytes: f"({decompressed_size} B) exceed the specified maximum " f"({max_size} B)." ) - output_list.append(output_chunk) - return b"".join(output_list) + output_stream.write(output_chunk) + output_stream.seek(0) + return output_stream.read() def _unzstd(data: bytes, *, max_size: int = 0) -> bytes: decompressor = zstandard.ZstdDecompressor() stream_reader = decompressor.stream_reader(BytesIO(data)) - output_list: List[bytes] = [] + output_stream = BytesIO() output_chunk = b"." decompressed_size = 0 CHUNK_SIZE = 8196 @@ -88,5 +89,6 @@ def _unzstd(data: bytes, *, max_size: int = 0) -> bytes: f"({decompressed_size} B) exceed the specified maximum " f"({max_size} B)." ) - output_list.append(output_chunk) - return b"".join(output_list) + output_stream.write(output_chunk) + output_stream.seek(0) + return output_stream.read() diff --git a/scrapy/utils/gz.py b/scrapy/utils/gz.py index e5cf68d62..5d23e8f05 100644 --- a/scrapy/utils/gz.py +++ b/scrapy/utils/gz.py @@ -1,7 +1,6 @@ import struct from gzip import GzipFile from io import BytesIO -from typing import List from scrapy.http import Response @@ -14,7 +13,7 @@ def gunzip(data: bytes, *, max_size: int = 0) -> bytes: This is resilient to CRC checksum errors. """ f = GzipFile(fileobj=BytesIO(data)) - output_list: List[bytes] = [] + output_stream = BytesIO() chunk = b"." decompressed_size = 0 while chunk: @@ -23,8 +22,8 @@ def gunzip(data: bytes, *, max_size: int = 0) -> bytes: except (OSError, EOFError, struct.error): # complete only if there is some data, otherwise re-raise # see issue 87 about catching struct.error - # some pages are quite small so output_list is empty - if output_list: + # some pages are quite small so output_stream is empty + if output_stream: break raise decompressed_size += len(chunk) @@ -34,8 +33,9 @@ def gunzip(data: bytes, *, max_size: int = 0) -> bytes: f"({decompressed_size} B) exceed the specified maximum " f"({max_size} B)." ) - output_list.append(chunk) - return b"".join(output_list) + output_stream.write(chunk) + output_stream.seek(0) + return output_stream.read() def gzip_magic_number(response: Response) -> bool: From 62398e424c0a3d66d0bdd3908e47284cbe797ef9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Fri, 24 Nov 2023 10:20:14 +0100 Subject: [PATCH 030/119] =?UTF-8?q?CHUNK=5FSIZE:=208=20KiB=20=E2=86=92=203?= =?UTF-8?q?2=20KiB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scrapy/utils/_compression.py | 12 ++++++------ scrapy/utils/gz.py | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/scrapy/utils/_compression.py b/scrapy/utils/_compression.py index b17fd7881..5610595d3 100644 --- a/scrapy/utils/_compression.py +++ b/scrapy/utils/_compression.py @@ -12,6 +12,9 @@ except ImportError: pass +_CHUNK_SIZE = 65536 # 64 KiB + + class _DecompressionMaxSizeExceeded(ValueError): pass @@ -23,9 +26,8 @@ def _inflate(data: bytes, *, max_size: int = 0) -> bytes: output_stream = BytesIO() output_chunk = b"." decompressed_size = 0 - CHUNK_SIZE = 8196 while output_chunk: - input_chunk = input_stream.read(CHUNK_SIZE) + input_chunk = input_stream.read(_CHUNK_SIZE) try: output_chunk = decompressor.decompress(input_chunk) except zlib.error: @@ -57,9 +59,8 @@ def _unbrotli(data: bytes, *, max_size: int = 0) -> bytes: output_stream = BytesIO() output_chunk = b"." decompressed_size = 0 - CHUNK_SIZE = 8196 while output_chunk: - input_chunk = input_stream.read(CHUNK_SIZE) + input_chunk = input_stream.read(_CHUNK_SIZE) output_chunk = decompressor.process(input_chunk) decompressed_size += len(output_chunk) if max_size and decompressed_size > max_size: @@ -79,9 +80,8 @@ def _unzstd(data: bytes, *, max_size: int = 0) -> bytes: output_stream = BytesIO() output_chunk = b"." decompressed_size = 0 - CHUNK_SIZE = 8196 while output_chunk: - output_chunk = stream_reader.read(CHUNK_SIZE) + output_chunk = stream_reader.read(_CHUNK_SIZE) decompressed_size += len(output_chunk) if max_size and decompressed_size > max_size: raise _DecompressionMaxSizeExceeded( diff --git a/scrapy/utils/gz.py b/scrapy/utils/gz.py index 5d23e8f05..cf7316e82 100644 --- a/scrapy/utils/gz.py +++ b/scrapy/utils/gz.py @@ -4,7 +4,7 @@ from io import BytesIO from scrapy.http import Response -from ._compression import _DecompressionMaxSizeExceeded +from ._compression import _CHUNK_SIZE, _DecompressionMaxSizeExceeded def gunzip(data: bytes, *, max_size: int = 0) -> bytes: @@ -18,7 +18,7 @@ def gunzip(data: bytes, *, max_size: int = 0) -> bytes: decompressed_size = 0 while chunk: try: - chunk = f.read1(8196) + chunk = f.read1(_CHUNK_SIZE) except (OSError, EOFError, struct.error): # complete only if there is some data, otherwise re-raise # see issue 87 about catching struct.error From 8a73c6c90c5984292d39a0a9d4be86e792d81cb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Fri, 24 Nov 2023 10:25:01 +0100 Subject: [PATCH 031/119] Fix HttpCompressionMiddleware backward compatibility --- scrapy/downloadermiddlewares/httpcompression.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/scrapy/downloadermiddlewares/httpcompression.py b/scrapy/downloadermiddlewares/httpcompression.py index 1a3f6962a..58ca1017f 100644 --- a/scrapy/downloadermiddlewares/httpcompression.py +++ b/scrapy/downloadermiddlewares/httpcompression.py @@ -39,8 +39,9 @@ class HttpCompressionMiddleware: def __init__(self, stats=None, *, crawler=None): if not crawler: - if stats: - self.stats = stats + self.stats = stats + self._max_size = 1073741824 + self._warn_size = 33554432 return self.stats = crawler.stats self._max_size = crawler.settings.getint("DOWNLOAD_MAXSIZE") From a113208a0643263fdd7198238bf9213f9148bc1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Fri, 24 Nov 2023 11:35:15 +0100 Subject: [PATCH 032/119] Fix BytesIO non-emptiness check --- scrapy/utils/gz.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scrapy/utils/gz.py b/scrapy/utils/gz.py index cf7316e82..2e487d88b 100644 --- a/scrapy/utils/gz.py +++ b/scrapy/utils/gz.py @@ -23,7 +23,7 @@ def gunzip(data: bytes, *, max_size: int = 0) -> bytes: # complete only if there is some data, otherwise re-raise # see issue 87 about catching struct.error # some pages are quite small so output_stream is empty - if output_stream: + if output_stream.getbuffer().nbytes > 0: break raise decompressed_size += len(chunk) From c947f51077e1ab246f30764cc4cc7a1cc5835d40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Wed, 29 Nov 2023 11:54:08 +0100 Subject: [PATCH 033/119] Set an arbitrary upper limit on ReDoS-vulnerable regexps --- docs/news.rst | 28 ++++++++++++++++++++++++++++ scrapy/utils/iterators.py | 12 +++++++----- scrapy/utils/response.py | 4 ++-- 3 files changed, 37 insertions(+), 7 deletions(-) diff --git a/docs/news.rst b/docs/news.rst index fd8fa3ea3..121cc0322 100644 --- a/docs/news.rst +++ b/docs/news.rst @@ -3,6 +3,22 @@ Release notes ============= +.. _release-2.11.1: + +Scrapy 2.11.1 (unreleased) +-------------------------- + +**Security bug fix:** + +- The regular expressions of the ``iternodes`` node iterator of + :class:`~scrapy.spiders.XMLFeedSpider` are no longer susceptible to a + `ReDoS attack`_. Please, see the `cc65-xxvf-f7r9 security + advisory`_ for more information. + + .. _ReDoS attack: https://owasp.org/www-community/attacks/Regular_expression_Denial_of_Service_-_ReDoS + .. _cc65-xxvf-f7r9 security advisory: https://github.com/scrapy/scrapy/security/advisories/GHSA-cc65-xxvf-f7r9 + + .. _release-2.11.0: Scrapy 2.11.0 (2023-09-18) @@ -2869,6 +2885,18 @@ affect subclasses: (:issue:`3884`) +.. _release-1.8.4: + +Scrapy 1.8.4 (unreleased) +------------------------- + +**Security bug fix:** + +- The regular expressions of the ``iternodes`` node iterator of + :class:`~scrapy.spiders.XMLFeedSpider` are no longer susceptible to a + `ReDoS attack`_. Please, see the `cc65-xxvf-f7r9 security + advisory`_ for more information. + .. _release-1.8.3: diff --git a/scrapy/utils/iterators.py b/scrapy/utils/iterators.py index 03d779afb..6b89334e0 100644 --- a/scrapy/utils/iterators.py +++ b/scrapy/utils/iterators.py @@ -40,10 +40,10 @@ def xmliter( """ nodename_patt = re.escape(nodename) - DOCUMENT_HEADER_RE = re.compile(r"<\?xml[^>]+>\s*", re.S) + DOCUMENT_HEADER_RE = re.compile(r"<\?xml[^>]{1,1024}>\s*", re.S) HEADER_END_RE = re.compile(rf"<\s*/{nodename_patt}\s*>", re.S) - END_TAG_RE = re.compile(r"<\s*/([^\s>]+)\s*>", re.S) - NAMESPACE_RE = re.compile(r"((xmlns[:A-Za-z]*)=[^>\s]+)", re.S) + END_TAG_RE = re.compile(r"<\s*/([^\s>]{1,1024})\s*>", re.S) + NAMESPACE_RE = re.compile(r"((xmlns[:A-Za-z]{,1024})=[^>\s]+)", re.S) text = _body_or_str(obj) document_header_match = re.search(DOCUMENT_HEADER_RE, text) @@ -57,13 +57,15 @@ def xmliter( for tagname in reversed(re.findall(END_TAG_RE, header_end)): assert header_end_idx tag = re.search( - rf"<\s*{tagname}.*?xmlns[:=][^>]*>", text[: header_end_idx[1]], re.S + rf"<\s*{tagname}.{{,1024}}?xmlns[:=][^>]{{,1024}}>", + text[: header_end_idx[1]], + re.S, ) if tag: for x in re.findall(NAMESPACE_RE, tag.group()): namespaces[x[1]] = x[0] - r = re.compile(rf"<{nodename_patt}[\s>].*?", re.DOTALL) + r = re.compile(rf"<{nodename_patt}[\s>].{{,1024}}?", re.DOTALL) for match in r.finditer(text): nodetext = ( document_header diff --git a/scrapy/utils/response.py b/scrapy/utils/response.py index c540d6278..b0e106c5e 100644 --- a/scrapy/utils/response.py +++ b/scrapy/utils/response.py @@ -91,8 +91,8 @@ def open_in_browser( if isinstance(response, HtmlResponse): if b"' - body = re.sub(b"", b"", body, flags=re.DOTALL) - body = re.sub(rb"(|\s.*?>))", to_bytes(repl), body) + body = re.sub(b"", b"", body, flags=re.DOTALL) + body = re.sub(rb"(|\s.{,1024}?>))", to_bytes(repl), body) ext = ".html" elif isinstance(response, TextResponse): ext = ".txt" From eb8b2c5197f3dd8570bad1945b23886ee57d045f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Wed, 29 Nov 2023 12:13:04 +0100 Subject: [PATCH 034/119] Mention open_in_browser in the release notes --- docs/news.rst | 16 ++++++++-------- docs/topics/debug.rst | 18 +++--------------- scrapy/utils/response.py | 17 +++++++++++++++-- 3 files changed, 26 insertions(+), 25 deletions(-) diff --git a/docs/news.rst b/docs/news.rst index 121cc0322..2bbe833a5 100644 --- a/docs/news.rst +++ b/docs/news.rst @@ -10,10 +10,10 @@ Scrapy 2.11.1 (unreleased) **Security bug fix:** -- The regular expressions of the ``iternodes`` node iterator of - :class:`~scrapy.spiders.XMLFeedSpider` are no longer susceptible to a - `ReDoS attack`_. Please, see the `cc65-xxvf-f7r9 security - advisory`_ for more information. +- Fixed regular expressions susceptible to a `ReDoS attack`_ affecting the + ``iternodes`` node iterator of :class:`~scrapy.spiders.XMLFeedSpider` and + the :func:`~scrapy.utils.response.open_in_browser` function. Please, see + the `cc65-xxvf-f7r9 security advisory`_ for more information. .. _ReDoS attack: https://owasp.org/www-community/attacks/Regular_expression_Denial_of_Service_-_ReDoS .. _cc65-xxvf-f7r9 security advisory: https://github.com/scrapy/scrapy/security/advisories/GHSA-cc65-xxvf-f7r9 @@ -2892,10 +2892,10 @@ Scrapy 1.8.4 (unreleased) **Security bug fix:** -- The regular expressions of the ``iternodes`` node iterator of - :class:`~scrapy.spiders.XMLFeedSpider` are no longer susceptible to a - `ReDoS attack`_. Please, see the `cc65-xxvf-f7r9 security - advisory`_ for more information. +- Fixed regular expressions susceptible to a `ReDoS attack`_ affecting the + ``iternodes`` node iterator of :class:`~scrapy.spiders.XMLFeedSpider` and + the :func:`~scrapy.utils.response.open_in_browser` function. Please, see + the `cc65-xxvf-f7r9 security advisory`_ for more information. .. _release-1.8.3: diff --git a/docs/topics/debug.rst b/docs/topics/debug.rst index 49c5b0410..988e37bbd 100644 --- a/docs/topics/debug.rst +++ b/docs/topics/debug.rst @@ -125,25 +125,15 @@ Fortunately, the :command:`shell` is your bread and butter in this case (see See also: :ref:`topics-shell-inspect-response`. + Open in browser =============== Sometimes you just want to see how a certain response looks in a browser, you -can use the ``open_in_browser`` function for that. Here is an example of how -you would use it: +can use the :func:`~scrapy.utils.response.open_in_browser` function for that: -.. code-block:: python +.. autofunction:: scrapy.utils.response.open_in_browser - from scrapy.utils.response import open_in_browser - - - def parse_details(self, response): - if "item name" not in response.body: - open_in_browser(response) - -``open_in_browser`` will open a browser with the response received by Scrapy at -that point, adjusting the `base tag`_ so that images and styles are displayed -properly. Logging ======= @@ -163,8 +153,6 @@ available in all future runs should they be necessary again: For more information, check the :ref:`topics-logging` section. -.. _base tag: https://www.w3schools.com/tags/tag_base.asp - .. _debug-vscode: Visual Studio Code diff --git a/scrapy/utils/response.py b/scrapy/utils/response.py index b0e106c5e..8401d4ed1 100644 --- a/scrapy/utils/response.py +++ b/scrapy/utils/response.py @@ -81,8 +81,21 @@ def open_in_browser( ], _openfunc: Callable[[str], Any] = webbrowser.open, ) -> Any: - """Open the given response in a local web browser, populating the - tag for external links to work + """Open *response* in a local web browser, adjusting the `base tag`_ for + external links to work, e.g. so that images and styles are displayed. + + .. _base tag: https://www.w3schools.com/tags/tag_base.asp + + For example: + + .. code-block:: python + + from scrapy.utils.response import open_in_browser + + + def parse_details(self, response): + if "item name" not in response.body: + open_in_browser(response) """ from scrapy.http import HtmlResponse, TextResponse From 40b3efbbee3919e8f6900daeaed5e14183cabfd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Wed, 29 Nov 2023 12:47:04 +0100 Subject: [PATCH 035/119] Remove open_in_browser from the 1.8.4 release notes --- docs/news.rst | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/news.rst b/docs/news.rst index 2bbe833a5..c14815d06 100644 --- a/docs/news.rst +++ b/docs/news.rst @@ -2893,9 +2893,8 @@ Scrapy 1.8.4 (unreleased) **Security bug fix:** - Fixed regular expressions susceptible to a `ReDoS attack`_ affecting the - ``iternodes`` node iterator of :class:`~scrapy.spiders.XMLFeedSpider` and - the :func:`~scrapy.utils.response.open_in_browser` function. Please, see - the `cc65-xxvf-f7r9 security advisory`_ for more information. + ``iternodes`` node iterator of :class:`~scrapy.spiders.XMLFeedSpider`. + Please, see the `cc65-xxvf-f7r9 security advisory`_ for more information. .. _release-1.8.3: From 731f7495563591f1b76b1b2ae83f07d2239b7897 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Thu, 30 Nov 2023 10:54:09 +0100 Subject: [PATCH 036/119] Extend Request.meta documentation (#5565) --- docs/topics/request-response.rst | 47 ++++++++++++++++++++++++++------ 1 file changed, 38 insertions(+), 9 deletions(-) diff --git a/docs/topics/request-response.rst b/docs/topics/request-response.rst index adf3d0f4a..8edf710bc 100644 --- a/docs/topics/request-response.rst +++ b/docs/topics/request-response.rst @@ -193,18 +193,47 @@ Request objects :meth:`replace`. .. attribute:: Request.meta + :value: {} - A dict that contains arbitrary metadata for this request. This dict is - empty for new Requests, and is usually populated by different Scrapy - components (extensions, middlewares, etc). So the data contained in this - dict depends on the extensions you have enabled. + A dictionary of arbitrary metadata for the request. - See :ref:`topics-request-meta` for a list of special meta keys - recognized by Scrapy. + You may extend request metadata as you see fit. - This dict is :doc:`shallow copied ` when the request is - cloned using the ``copy()`` or ``replace()`` methods, and can also be - accessed, in your spider, from the ``response.meta`` attribute. + Request metadata can also be accessed through the + :attr:`~scrapy.http.Response.meta` attribute of a response. + + To pass data from one spider callback to another, consider using + :attr:`cb_kwargs` instead. However, request metadata may be the right + choice in certain scenarios, such as to maintain some debugging data + across all follow-up requests (e.g. the source URL). + + A common use of request metadata is to define request-specific + parameters for Scrapy components (extensions, middlewares, etc.). For + example, if you set ``dont_retry`` to ``True``, + :class:`~scrapy.downloadermiddlewares.retry.RetryMiddleware` will never + retry that request, even if it fails. See :ref:`topics-request-meta`. + + You may also use request metadata in your custom Scrapy components, for + example, to keep request state information relevant to your component. + For example, + :class:`~scrapy.downloadermiddlewares.retry.RetryMiddleware` uses the + ``retry_times`` metadata key to keep track of how many times a request + has been retried so far. + + Copying all the metadata of a previous request into a new, follow-up + request in a spider callback is a bad practice, because request + metadata may include metadata set by Scrapy components that is not + meant to be copied into other requests. For example, copying the + ``retry_times`` metadata key into follow-up requests can lower the + amount of retries allowed for those follow-up requests. + + You should only copy all request metadata from one request to another + if the new request is meant to replace the old request, as is often the + case when returning a request from a :ref:`downloader middleware + ` method. + + Also mind that the :meth:`copy` and :meth:`replace` request methods + :doc:`shallow-copy ` request metadata. .. attribute:: Request.cb_kwargs From 70ba3a0868dd6d9b996beba7ff5e8ec62773b206 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Thu, 30 Nov 2023 11:01:22 +0100 Subject: [PATCH 037/119] =?UTF-8?q?SPM=20=E2=86=92=20Zyte=20API=20(#6163)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/topics/practices.rst | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/docs/topics/practices.rst b/docs/topics/practices.rst index f64da22d8..b1b8c9e9c 100644 --- a/docs/topics/practices.rst +++ b/docs/topics/practices.rst @@ -288,9 +288,8 @@ Here are some tips to keep in mind when dealing with these kinds of sites: * use a pool of rotating IPs. For example, the free `Tor project`_ or paid services like `ProxyMesh`_. An open source alternative is `scrapoxy`_, a super proxy that you can attach your own proxies to. -* use a highly distributed downloader that circumvents bans internally, so you - can just focus on parsing clean pages. One example of such downloaders is - `Zyte Smart Proxy Manager`_ +* use a ban avoidance service, such as `Zyte API`_, which provides a `Scrapy + plugin `__ If you are still unable to prevent your bot getting banned, consider contacting `commercial support`_. @@ -301,4 +300,4 @@ If you are still unable to prevent your bot getting banned, consider contacting .. _Common Crawl: https://commoncrawl.org/ .. _testspiders: https://github.com/scrapinghub/testspiders .. _scrapoxy: https://scrapoxy.io/ -.. _Zyte Smart Proxy Manager: https://www.zyte.com/smart-proxy-manager/ +.. _Zyte API: https://docs.zyte.com/zyte-api/get-started.html From bb74badd1bd66c59a63268c52342507c76d290b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Mon, 11 Dec 2023 17:39:55 +0100 Subject: [PATCH 038/119] =?UTF-8?q?spider=20=E2=86=92=20mw?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scrapy/downloadermiddlewares/httpcompression.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/scrapy/downloadermiddlewares/httpcompression.py b/scrapy/downloadermiddlewares/httpcompression.py index 58ca1017f..816be25a1 100644 --- a/scrapy/downloadermiddlewares/httpcompression.py +++ b/scrapy/downloadermiddlewares/httpcompression.py @@ -61,12 +61,12 @@ class HttpCompressionMiddleware: "reimplement their 'from_crawler' method.", ScrapyDeprecationWarning, ) - spider = cls() - spider.stats = crawler.stats - spider._max_size = crawler.settings.getint("DOWNLOAD_MAXSIZE") - spider._warn_size = crawler.settings.getint("DOWNLOAD_WARNSIZE") - crawler.signals.connect(spider.open_spider, signals.spider_opened) - return spider + mw = cls() + mw.stats = crawler.stats + mw._max_size = crawler.settings.getint("DOWNLOAD_MAXSIZE") + mw._warn_size = crawler.settings.getint("DOWNLOAD_WARNSIZE") + crawler.signals.connect(mw.open_spider, signals.spider_opened) + return mw def open_spider(self, spider): if hasattr(spider, "download_maxsize"): From 1533b69032e2fb5e495e88a3fed57c0d98502612 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Wed, 13 Dec 2023 12:01:35 +0100 Subject: [PATCH 039/119] Test and address ReDoS attack vectors for open_in_browser --- scrapy/utils/response.py | 6 +++--- tests/test_utils_response.py | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/scrapy/utils/response.py b/scrapy/utils/response.py index 8401d4ed1..4369e6439 100644 --- a/scrapy/utils/response.py +++ b/scrapy/utils/response.py @@ -103,9 +103,9 @@ def open_in_browser( body = response.body if isinstance(response, HtmlResponse): if b"' - body = re.sub(b"", b"", body, flags=re.DOTALL) - body = re.sub(rb"(|\s.{,1024}?>))", to_bytes(repl), body) + repl = rf'\0' + body = re.sub(b"(?s)|$)", b"", body) + body = re.sub(rb"]*?>)", to_bytes(repl), body, count=1) ext = ".html" elif isinstance(response, TextResponse): ext = ".txt" diff --git a/tests/test_utils_response.py b/tests/test_utils_response.py index 80e15a60f..942584d92 100644 --- a/tests/test_utils_response.py +++ b/tests/test_utils_response.py @@ -1,10 +1,12 @@ import unittest import warnings from pathlib import Path +from time import process_time from urllib.parse import urlparse from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.http import HtmlResponse, Response, TextResponse +from scrapy.settings.default_settings import DOWNLOAD_MAXSIZE from scrapy.utils.python import to_bytes from scrapy.utils.response import ( get_base_url, @@ -198,3 +200,37 @@ class ResponseUtilsTest(unittest.TestCase): assert open_in_browser( r5, _openfunc=check_base_url ), "Inject unique base url with conditional comment" + + def test_open_in_browser_redos_comment(self): + MAX_CPU_TIME = 30 + + # Exploit input from + # https://makenowjust-labs.github.io/recheck/playground/ + # for // (old pattern to remove comments). + body = b"->" + + response = HtmlResponse("https://example.com", body=body) + + start_time = process_time() + + open_in_browser(response, lambda url: True) + + end_time = process_time() + self.assertLess(end_time - start_time, MAX_CPU_TIME) + + def test_open_in_browser_redos_head(self): + MAX_CPU_TIME = 15 + + # Exploit input from + # https://makenowjust-labs.github.io/recheck/playground/ + # for /(|\s.*?>))/ (old pattern to find the head element). + body = b" Date: Tue, 17 Oct 2023 17:49:22 -0300 Subject: [PATCH 040/119] Remove deprecated scrapy.downloadermiddlewares.decompression --- scrapy/downloadermiddlewares/decompression.py | 94 ------------------- ...test_downloadermiddleware_decompression.py | 53 ----------- 2 files changed, 147 deletions(-) delete mode 100644 scrapy/downloadermiddlewares/decompression.py delete mode 100644 tests/test_downloadermiddleware_decompression.py diff --git a/scrapy/downloadermiddlewares/decompression.py b/scrapy/downloadermiddlewares/decompression.py deleted file mode 100644 index 3b8702419..000000000 --- a/scrapy/downloadermiddlewares/decompression.py +++ /dev/null @@ -1,94 +0,0 @@ -""" This module implements the DecompressionMiddleware which tries to recognise -and extract the potentially compressed responses that may arrive. -""" - -import bz2 -import gzip -import logging -import tarfile -import zipfile -from io import BytesIO -from tempfile import mktemp -from warnings import warn - -from scrapy.exceptions import ScrapyDeprecationWarning -from scrapy.responsetypes import responsetypes - -warn( - "scrapy.downloadermiddlewares.decompression is deprecated", - ScrapyDeprecationWarning, - stacklevel=2, -) - - -logger = logging.getLogger(__name__) - - -class DecompressionMiddleware: - """This middleware tries to recognise and extract the possibly compressed - responses that may arrive.""" - - def __init__(self): - self._formats = { - "tar": self._is_tar, - "zip": self._is_zip, - "gz": self._is_gzip, - "bz2": self._is_bzip2, - } - - def _is_tar(self, response): - archive = BytesIO(response.body) - try: - tar_file = tarfile.open(name=mktemp(), fileobj=archive) - except tarfile.ReadError: - return - - body = tar_file.extractfile(tar_file.members[0]).read() - respcls = responsetypes.from_args(filename=tar_file.members[0].name, body=body) - return response.replace(body=body, cls=respcls) - - def _is_zip(self, response): - archive = BytesIO(response.body) - try: - zip_file = zipfile.ZipFile(archive) - except zipfile.BadZipFile: - return - - namelist = zip_file.namelist() - body = zip_file.read(namelist[0]) - respcls = responsetypes.from_args(filename=namelist[0], body=body) - return response.replace(body=body, cls=respcls) - - def _is_gzip(self, response): - archive = BytesIO(response.body) - try: - body = gzip.GzipFile(fileobj=archive).read() - except OSError: - return - - respcls = responsetypes.from_args(body=body) - return response.replace(body=body, cls=respcls) - - def _is_bzip2(self, response): - try: - body = bz2.decompress(response.body) - except OSError: - return - - respcls = responsetypes.from_args(body=body) - return response.replace(body=body, cls=respcls) - - def process_response(self, request, response, spider): - if not response.body: - return response - - for fmt, func in self._formats.items(): - new_response = func(response) - if new_response: - logger.debug( - "Decompressed response with format: %(responsefmt)s", - {"responsefmt": fmt}, - extra={"spider": spider}, - ) - return new_response - return response diff --git a/tests/test_downloadermiddleware_decompression.py b/tests/test_downloadermiddleware_decompression.py deleted file mode 100644 index 95739414e..000000000 --- a/tests/test_downloadermiddleware_decompression.py +++ /dev/null @@ -1,53 +0,0 @@ -from unittest import TestCase, main - -from scrapy.downloadermiddlewares.decompression import DecompressionMiddleware -from scrapy.http import Response, XmlResponse -from scrapy.spiders import Spider -from scrapy.utils.test import assert_samelines -from tests import get_testdata - - -def _test_data(formats): - uncompressed_body = get_testdata("compressed", "feed-sample1.xml") - test_responses = {} - for format in formats: - body = get_testdata("compressed", "feed-sample1." + format) - test_responses[format] = Response("http://foo.com/bar", body=body) - return uncompressed_body, test_responses - - -class DecompressionMiddlewareTest(TestCase): - test_formats = ["tar", "xml.bz2", "xml.gz", "zip"] - uncompressed_body, test_responses = _test_data(test_formats) - - def setUp(self): - self.mw = DecompressionMiddleware() - self.spider = Spider("foo") - - def test_known_compression_formats(self): - for fmt in self.test_formats: - rsp = self.test_responses[fmt] - new = self.mw.process_response(None, rsp, self.spider) - error_msg = f"Failed {fmt}, response type {type(new).__name__}" - assert isinstance(new, XmlResponse), error_msg - assert_samelines(self, new.body, self.uncompressed_body, fmt) - - def test_plain_response(self): - rsp = Response(url="http://test.com", body=self.uncompressed_body) - new = self.mw.process_response(None, rsp, self.spider) - assert new is rsp - assert_samelines(self, new.body, rsp.body) - - def test_empty_response(self): - rsp = Response(url="http://test.com", body=b"") - new = self.mw.process_response(None, rsp, self.spider) - assert new is rsp - assert not rsp.body - assert not new.body - - def tearDown(self): - del self.mw - - -if __name__ == "__main__": - main() From 12b10a7a6427c43968cc18d98a3ed3c6366eeabd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Wed, 13 Dec 2023 13:35:05 +0100 Subject: [PATCH 041/119] Cover scrapy.downloadermiddlewares.decompression in the release notes --- docs/news.rst | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/news.rst b/docs/news.rst index c4081b99b..a12bda53f 100644 --- a/docs/news.rst +++ b/docs/news.rst @@ -16,6 +16,10 @@ Scrapy 2.11.1 (unreleased) .. _7j7m-v7m3-jqm7 security advisory: https://github.com/scrapy/scrapy/security/advisories/GHSA-7j7m-v7m3-jqm7 +- Also in relation with the `7j7m-v7m3-jqm7 security advisory`_, the + deprecated ``scrapy.downloadermiddlewares.decompression`` module has been + removed. + .. _release-2.11.0: @@ -2896,6 +2900,10 @@ Scrapy 1.8.4 (unreleased) to the decompressed response body. Please, see the `7j7m-v7m3-jqm7 security advisory`_ for more information. +- Also in relation with the `7j7m-v7m3-jqm7 security advisory`_, use of the + ``scrapy.downloadermiddlewares.decompression`` module is discouraged and + will trigger a warning. + .. _release-1.8.3: From 4f72b49f975a406784779dc19ede31364f92235f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Fri, 15 Dec 2023 10:06:13 +0100 Subject: [PATCH 042/119] Fix namespaces nodename support for xmliter_lxml --- scrapy/utils/iterators.py | 23 +++++++++++++++++++++-- tests/test_utils_iterators.py | 5 ----- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/scrapy/utils/iterators.py b/scrapy/utils/iterators.py index 6b89334e0..9c53ab524 100644 --- a/scrapy/utils/iterators.py +++ b/scrapy/utils/iterators.py @@ -12,11 +12,14 @@ from typing import ( List, Literal, Optional, + Tuple, Union, cast, overload, ) +from lxml import etree + from scrapy.http import Response, TextResponse from scrapy.selector import Selector from scrapy.utils.python import re_rsearch, to_unicode @@ -77,15 +80,31 @@ def xmliter( yield Selector(text=nodetext, type="xml") +def _resolve_xml_namespace(element_name: str, data: bytes) -> Tuple[str, str]: + if ":" not in element_name: + return element_name, None, None + reader: "SupportsReadClose[bytes]" = _StreamReader(data) + node_prefix, element_name = element_name.split(":", maxsplit=1) + ns_iterator = etree.iterparse( + reader, encoding=reader.encoding, events=("start-ns",) + ) + for event, (_prefix, _namespace) in ns_iterator: + if _prefix != node_prefix: + continue + return element_name, _prefix, _namespace + return f"{node_prefix}:{element_name}", None, None + + def xmliter_lxml( obj: Union[Response, str, bytes], nodename: str, namespace: Optional[str] = None, prefix: str = "x", ) -> Generator[Selector, Any, None]: - from lxml import etree + if not namespace: + nodename, prefix, namespace = _resolve_xml_namespace(nodename, obj) - reader = _StreamReader(obj) + reader: "SupportsReadClose[bytes]" = _StreamReader(obj) tag = f"{{{namespace}}}{nodename}" if namespace else nodename iterable = etree.iterparse( cast("SupportsReadClose[bytes]", reader), tag=tag, encoding=reader.encoding diff --git a/tests/test_utils_iterators.py b/tests/test_utils_iterators.py index 3598fa0bb..24f03155b 100644 --- a/tests/test_utils_iterators.py +++ b/tests/test_utils_iterators.py @@ -1,4 +1,3 @@ -from pytest import mark from twisted.trial import unittest from scrapy.http import Response, TextResponse, XmlResponse @@ -247,10 +246,6 @@ class XmliterTestCase(unittest.TestCase): class LxmlXmliterTestCase(XmliterTestCase): xmliter = staticmethod(xmliter_lxml) - @mark.xfail(reason="known bug of the current implementation") - def test_xmliter_namespaced_nodename(self): - super().test_xmliter_namespaced_nodename() - def test_xmliter_iterate_namespace(self): body = b""" From d50f436a73ef13fca8d3d9c302ae1a48e984a4a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Fri, 15 Dec 2023 10:08:45 +0100 Subject: [PATCH 043/119] Enable huge_tree for xmliter_lxml --- scrapy/utils/iterators.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/scrapy/utils/iterators.py b/scrapy/utils/iterators.py index 9c53ab524..1c51c0c6a 100644 --- a/scrapy/utils/iterators.py +++ b/scrapy/utils/iterators.py @@ -86,7 +86,10 @@ def _resolve_xml_namespace(element_name: str, data: bytes) -> Tuple[str, str]: reader: "SupportsReadClose[bytes]" = _StreamReader(data) node_prefix, element_name = element_name.split(":", maxsplit=1) ns_iterator = etree.iterparse( - reader, encoding=reader.encoding, events=("start-ns",) + reader, + encoding=reader.encoding, + events=("start-ns",), + huge_tree=True, ) for event, (_prefix, _namespace) in ns_iterator: if _prefix != node_prefix: @@ -107,7 +110,10 @@ def xmliter_lxml( reader: "SupportsReadClose[bytes]" = _StreamReader(obj) tag = f"{{{namespace}}}{nodename}" if namespace else nodename iterable = etree.iterparse( - cast("SupportsReadClose[bytes]", reader), tag=tag, encoding=reader.encoding + reader, + tag=tag, + encoding=reader.encoding, + huge_tree=True, ) selxpath = "//" + (f"{prefix}:{nodename}" if namespace else nodename) for _, node in iterable: From 9655b0b8eb4bbc66b0fe540a19265b6342cf371b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Fri, 15 Dec 2023 10:19:47 +0100 Subject: [PATCH 044/119] Mark slow tests, with their own tox env and CI job --- .github/workflows/tests-ubuntu.yml | 6 ++++ pytest.ini | 2 ++ tests/test_utils_response.py | 50 +++++++++++++++++------------- tox.ini | 6 ++++ 4 files changed, 42 insertions(+), 22 deletions(-) diff --git a/.github/workflows/tests-ubuntu.yml b/.github/workflows/tests-ubuntu.yml index 5ff92a571..7562cf22b 100644 --- a/.github/workflows/tests-ubuntu.yml +++ b/.github/workflows/tests-ubuntu.yml @@ -47,6 +47,9 @@ jobs: - python-version: "3.11" env: TOXENV: botocore + - python-version: "3.11" + env: + TOXENV: slow - python-version: "3.12.0-rc.2" env: @@ -57,6 +60,9 @@ jobs: - python-version: "3.12.0-rc.2" env: TOXENV: extra-deps + - python-version: "3.12.0-rc.2" + env: + TOXENV: slow steps: - uses: actions/checkout@v3 diff --git a/pytest.ini b/pytest.ini index 16983be5e..877fbcd1d 100644 --- a/pytest.ini +++ b/pytest.ini @@ -17,10 +17,12 @@ addopts = --ignore=docs/topics/stats.rst --ignore=docs/topics/telnetconsole.rst --ignore=docs/utils + -m 'not slow' 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 requires_uvloop: marks tests as only enabled when uvloop is known to be working + slow: marks tests as slow, not executed by default filterwarnings = ignore:scrapy.downloadermiddlewares.decompression is deprecated ignore:Module scrapy.utils.reqser is deprecated diff --git a/tests/test_utils_response.py b/tests/test_utils_response.py index 942584d92..93b9bacaf 100644 --- a/tests/test_utils_response.py +++ b/tests/test_utils_response.py @@ -4,6 +4,8 @@ from pathlib import Path from time import process_time from urllib.parse import urlparse +import pytest + from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.http import HtmlResponse, Response, TextResponse from scrapy.settings.default_settings import DOWNLOAD_MAXSIZE @@ -201,36 +203,40 @@ class ResponseUtilsTest(unittest.TestCase): r5, _openfunc=check_base_url ), "Inject unique base url with conditional comment" - def test_open_in_browser_redos_comment(self): - MAX_CPU_TIME = 30 - # Exploit input from - # https://makenowjust-labs.github.io/recheck/playground/ - # for // (old pattern to remove comments). - body = b"->" +@pytest.mark.slow +def test_open_in_browser_redos_comment(): + MAX_CPU_TIME = 30 - response = HtmlResponse("https://example.com", body=body) + # Exploit input from + # https://makenowjust-labs.github.io/recheck/playground/ + # for // (old pattern to remove comments). + body = b"->" - start_time = process_time() + response = HtmlResponse("https://example.com", body=body) - open_in_browser(response, lambda url: True) + start_time = process_time() - end_time = process_time() - self.assertLess(end_time - start_time, MAX_CPU_TIME) + open_in_browser(response, lambda url: True) - def test_open_in_browser_redos_head(self): - MAX_CPU_TIME = 15 + end_time = process_time() + assert (end_time - start_time) < MAX_CPU_TIME - # Exploit input from - # https://makenowjust-labs.github.io/recheck/playground/ - # for /(|\s.*?>))/ (old pattern to find the head element). - body = b"|\s.*?>))/ (old pattern to find the head element). + body = b" Date: Fri, 15 Dec 2023 10:23:24 +0100 Subject: [PATCH 045/119] Restore the implementation of xmliter --- scrapy/utils/iterators.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/scrapy/utils/iterators.py b/scrapy/utils/iterators.py index 1c51c0c6a..b67029433 100644 --- a/scrapy/utils/iterators.py +++ b/scrapy/utils/iterators.py @@ -43,10 +43,10 @@ def xmliter( """ nodename_patt = re.escape(nodename) - DOCUMENT_HEADER_RE = re.compile(r"<\?xml[^>]{1,1024}>\s*", re.S) + DOCUMENT_HEADER_RE = re.compile(r"<\?xml[^>]+>\s*", re.S) HEADER_END_RE = re.compile(rf"<\s*/{nodename_patt}\s*>", re.S) - END_TAG_RE = re.compile(r"<\s*/([^\s>]{1,1024})\s*>", re.S) - NAMESPACE_RE = re.compile(r"((xmlns[:A-Za-z]{,1024})=[^>\s]+)", re.S) + END_TAG_RE = re.compile(r"<\s*/([^\s>]+)\s*>", re.S) + NAMESPACE_RE = re.compile(r"((xmlns[:A-Za-z]*)=[^>\s]+)", re.S) text = _body_or_str(obj) document_header_match = re.search(DOCUMENT_HEADER_RE, text) @@ -60,15 +60,13 @@ def xmliter( for tagname in reversed(re.findall(END_TAG_RE, header_end)): assert header_end_idx tag = re.search( - rf"<\s*{tagname}.{{,1024}}?xmlns[:=][^>]{{,1024}}>", - text[: header_end_idx[1]], - re.S, + rf"<\s*{tagname}.*?xmlns[:=][^>]*>", text[: header_end_idx[1]], re.S ) if tag: for x in re.findall(NAMESPACE_RE, tag.group()): namespaces[x[1]] = x[0] - r = re.compile(rf"<{nodename_patt}[\s>].{{,1024}}?", re.DOTALL) + r = re.compile(rf"<{nodename_patt}[\s>].*?", re.DOTALL) for match in r.finditer(text): nodetext = ( document_header From 150d96764b5a455c75315596ca8ba5ded0f416dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Fri, 15 Dec 2023 11:42:55 +0100 Subject: [PATCH 046/119] Deprecate xmliter in favor of xmliter_lxml --- docs/faq.rst | 10 ++++--- docs/news.rst | 32 +++++++++++++++++----- scrapy/spiders/feed.py | 4 +-- scrapy/utils/iterators.py | 23 ++++++++++++++-- tests/test_utils_iterators.py | 35 +++++++++++++++++++++--- tests/test_utils_response.py | 50 +++++++++++++++++------------------ 6 files changed, 110 insertions(+), 44 deletions(-) diff --git a/docs/faq.rst b/docs/faq.rst index 20dd814df..657802fd3 100644 --- a/docs/faq.rst +++ b/docs/faq.rst @@ -297,9 +297,13 @@ build the DOM of the entire feed in memory, and this can be quite slow and consume a lot of memory. In order to avoid parsing all the entire feed at once in memory, you can use -the functions ``xmliter`` and ``csviter`` from ``scrapy.utils.iterators`` -module. In fact, this is what the feed spiders (see :ref:`topics-spiders`) use -under the cover. +the :func:`~scrapy.utils.iterators.xmliter_lxml` and +:func:`~scrapy.utils.iterators.csviter` functions. In fact, this is what +:class:`~scrapy.spiders.XMLFeedSpider` uses. + +.. autofunction:: scrapy.utils.iterators.xmliter_lxml + +.. autofunction:: scrapy.utils.iterators.csviter Does Scrapy manage cookies automatically? ----------------------------------------- diff --git a/docs/news.rst b/docs/news.rst index c14815d06..57b99e94f 100644 --- a/docs/news.rst +++ b/docs/news.rst @@ -10,12 +10,23 @@ Scrapy 2.11.1 (unreleased) **Security bug fix:** -- Fixed regular expressions susceptible to a `ReDoS attack`_ affecting the - ``iternodes`` node iterator of :class:`~scrapy.spiders.XMLFeedSpider` and - the :func:`~scrapy.utils.response.open_in_browser` function. Please, see - the `cc65-xxvf-f7r9 security advisory`_ for more information. +- Addressed `ReDoS vulnerabilities`_: - .. _ReDoS attack: https://owasp.org/www-community/attacks/Regular_expression_Denial_of_Service_-_ReDoS + - ``scrapy.utils.iterators.xmliter`` is now deprecated in favor of + :func:`~scrapy.utils.iterators.xmliter_lxml`, which + :class:`~scrapy.spiders.XMLFeedSpider` now uses. + + To minimize the impact of this change on existing code, + :func:`~scrapy.utils.iterators.xmliter_lxml` now supports indicating + the node namespace with a prefix in the node name, and big files with + highly nested trees. + + - Fixed regular expressions in the implementation of the + :func:`~scrapy.utils.response.open_in_browser` function. + + Please, see the `cc65-xxvf-f7r9 security advisory`_ for more information. + + .. _ReDoS vulnerabilities: https://owasp.org/www-community/attacks/Regular_expression_Denial_of_Service_-_ReDoS .. _cc65-xxvf-f7r9 security advisory: https://github.com/scrapy/scrapy/security/advisories/GHSA-cc65-xxvf-f7r9 @@ -2892,8 +2903,15 @@ Scrapy 1.8.4 (unreleased) **Security bug fix:** -- Fixed regular expressions susceptible to a `ReDoS attack`_ affecting the - ``iternodes`` node iterator of :class:`~scrapy.spiders.XMLFeedSpider`. +- Due to its `ReDoS vulnerabilities`_, ``scrapy.utils.iterators.xmliter`` is + now deprecated in favor of :func:`~scrapy.utils.iterators.xmliter_lxml`, + which :class:`~scrapy.spiders.XMLFeedSpider` now uses. + + To minimize the impact of this change on existing code, + :func:`~scrapy.utils.iterators.xmliter_lxml` now supports indicating + the node namespace as a prefix in the node name, and big files with highly + nested trees when using lxml 4.2 or later. + Please, see the `cc65-xxvf-f7r9 security advisory`_ for more information. diff --git a/scrapy/spiders/feed.py b/scrapy/spiders/feed.py index 6afadc577..42675c76a 100644 --- a/scrapy/spiders/feed.py +++ b/scrapy/spiders/feed.py @@ -7,7 +7,7 @@ See documentation in docs/topics/spiders.rst from scrapy.exceptions import NotConfigured, NotSupported from scrapy.selector import Selector from scrapy.spiders import Spider -from scrapy.utils.iterators import csviter, xmliter +from scrapy.utils.iterators import csviter, xmliter_lxml from scrapy.utils.spider import iterate_spider_output @@ -84,7 +84,7 @@ class XMLFeedSpider(Spider): return self.parse_nodes(response, nodes) def _iternodes(self, response): - for node in xmliter(response, self.itertag): + for node in xmliter_lxml(response, self.itertag): self._register_namespaces(node) yield node diff --git a/scrapy/utils/iterators.py b/scrapy/utils/iterators.py index b67029433..7574e377a 100644 --- a/scrapy/utils/iterators.py +++ b/scrapy/utils/iterators.py @@ -17,9 +17,12 @@ from typing import ( cast, overload, ) +from warnings import warn from lxml import etree +from packaging.version import Version +from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.http import Response, TextResponse from scrapy.selector import Selector from scrapy.utils.python import re_rsearch, to_unicode @@ -29,6 +32,12 @@ if TYPE_CHECKING: logger = logging.getLogger(__name__) +_LXML_VERSION = Version(etree.__version__) +_LXML_HUGE_TREE_VERSION = Version("4.2") +_ITERPARSE_KWARGS = {} +if _LXML_VERSION >= _LXML_HUGE_TREE_VERSION: + _ITERPARSE_KWARGS["huge_tree"] = True + def xmliter( obj: Union[Response, str, bytes], nodename: str @@ -41,6 +50,16 @@ def xmliter( - a unicode string - a string encoded as utf-8 """ + warn( + ( + "xmliter is deprecated and its use strongly discouraged because " + "it is vulnerable to ReDoS attacks. Use xmliter_lxml instead. See " + "https://github.com/scrapy/scrapy/security/advisories/GHSA-cc65-xxvf-f7r9" + ), + ScrapyDeprecationWarning, + stacklevel=2, + ) + nodename_patt = re.escape(nodename) DOCUMENT_HEADER_RE = re.compile(r"<\?xml[^>]+>\s*", re.S) @@ -87,7 +106,7 @@ def _resolve_xml_namespace(element_name: str, data: bytes) -> Tuple[str, str]: reader, encoding=reader.encoding, events=("start-ns",), - huge_tree=True, + **_ITERPARSE_KWARGS, ) for event, (_prefix, _namespace) in ns_iterator: if _prefix != node_prefix: @@ -111,7 +130,7 @@ def xmliter_lxml( reader, tag=tag, encoding=reader.encoding, - huge_tree=True, + **_ITERPARSE_KWARGS, ) selxpath = "//" + (f"{prefix}:{nodename}" if namespace else nodename) for _, node in iterable: diff --git a/tests/test_utils_iterators.py b/tests/test_utils_iterators.py index 24f03155b..505cc276c 100644 --- a/tests/test_utils_iterators.py +++ b/tests/test_utils_iterators.py @@ -1,13 +1,14 @@ +import pytest from twisted.trial import unittest +from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.http import Response, TextResponse, XmlResponse from scrapy.utils.iterators import _body_or_str, csviter, xmliter, xmliter_lxml from tests import get_testdata -class XmliterTestCase(unittest.TestCase): - xmliter = staticmethod(xmliter) - +class XmliterBaseTestCase: + @pytest.mark.filterwarnings("ignore::scrapy.exceptions.ScrapyDeprecationWarning") def test_xmliter(self): body = b""" @@ -39,6 +40,7 @@ class XmliterTestCase(unittest.TestCase): attrs, [("001", ["Name 1"], ["Type 1"]), ("002", ["Name 2"], ["Type 2"])] ) + @pytest.mark.filterwarnings("ignore::scrapy.exceptions.ScrapyDeprecationWarning") def test_xmliter_unusual_node(self): body = b""" @@ -52,6 +54,7 @@ class XmliterTestCase(unittest.TestCase): ] self.assertEqual(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 = """ @@ -111,6 +114,7 @@ class XmliterTestCase(unittest.TestCase): [("26", ["-"], ["80"]), ("21", ["Ab"], ["76"]), ("27", ["A"], ["27"])], ) + @pytest.mark.filterwarnings("ignore::scrapy.exceptions.ScrapyDeprecationWarning") def test_xmliter_text(self): body = ( '' @@ -122,6 +126,7 @@ class XmliterTestCase(unittest.TestCase): [["one"], ["two"]], ) + @pytest.mark.filterwarnings("ignore::scrapy.exceptions.ScrapyDeprecationWarning") def test_xmliter_namespaces(self): body = b""" @@ -161,6 +166,7 @@ class XmliterTestCase(unittest.TestCase): self.assertEqual(node.xpath("id/text()").getall(), []) self.assertEqual(node.xpath("price/text()").getall(), []) + @pytest.mark.filterwarnings("ignore::scrapy.exceptions.ScrapyDeprecationWarning") def test_xmliter_namespaced_nodename(self): body = b""" @@ -189,6 +195,7 @@ class XmliterTestCase(unittest.TestCase): ["http://www.mydummycompany.com/images/item1.jpg"], ) + @pytest.mark.filterwarnings("ignore::scrapy.exceptions.ScrapyDeprecationWarning") def test_xmliter_namespaced_nodename_missing(self): body = b""" @@ -213,6 +220,7 @@ class XmliterTestCase(unittest.TestCase): with self.assertRaises(StopIteration): next(my_iter) + @pytest.mark.filterwarnings("ignore::scrapy.exceptions.ScrapyDeprecationWarning") def test_xmliter_exception(self): body = ( '' @@ -225,10 +233,12 @@ class XmliterTestCase(unittest.TestCase): self.assertRaises(StopIteration, next, iter) + @pytest.mark.filterwarnings("ignore::scrapy.exceptions.ScrapyDeprecationWarning") def test_xmliter_objtype_exception(self): i = self.xmliter(42, "product") self.assertRaises(TypeError, next, i) + @pytest.mark.filterwarnings("ignore::scrapy.exceptions.ScrapyDeprecationWarning") def test_xmliter_encoding(self): body = ( b'\n' @@ -243,7 +253,24 @@ class XmliterTestCase(unittest.TestCase): ) -class LxmlXmliterTestCase(XmliterTestCase): +class XmliterTestCase(XmliterBaseTestCase, unittest.TestCase): + xmliter = staticmethod(xmliter) + + def test_deprecation(self): + body = b""" + + + + + """ + with pytest.warns( + ScrapyDeprecationWarning, + match="xmliter", + ): + next(self.xmliter(body, "product")) + + +class LxmlXmliterTestCase(XmliterBaseTestCase, unittest.TestCase): xmliter = staticmethod(xmliter_lxml) def test_xmliter_iterate_namespace(self): diff --git a/tests/test_utils_response.py b/tests/test_utils_response.py index 93b9bacaf..1dbe187bf 100644 --- a/tests/test_utils_response.py +++ b/tests/test_utils_response.py @@ -203,40 +203,38 @@ class ResponseUtilsTest(unittest.TestCase): r5, _openfunc=check_base_url ), "Inject unique base url with conditional comment" + @pytest.mark.slow + def test_open_in_browser_redos_comment(self): + MAX_CPU_TIME = 30 -@pytest.mark.slow -def test_open_in_browser_redos_comment(): - MAX_CPU_TIME = 30 + # Exploit input from + # https://makenowjust-labs.github.io/recheck/playground/ + # for // (old pattern to remove comments). + body = b"->" - # Exploit input from - # https://makenowjust-labs.github.io/recheck/playground/ - # for // (old pattern to remove comments). - body = b"->" + response = HtmlResponse("https://example.com", body=body) - response = HtmlResponse("https://example.com", body=body) + start_time = process_time() - start_time = process_time() + open_in_browser(response, lambda url: True) - open_in_browser(response, lambda url: True) + end_time = process_time() + self.assertLess(end_time - start_time, MAX_CPU_TIME) - end_time = process_time() - assert (end_time - start_time) < MAX_CPU_TIME + @pytest.mark.slow + def test_open_in_browser_redos_head(self): + MAX_CPU_TIME = 15 + # Exploit input from + # https://makenowjust-labs.github.io/recheck/playground/ + # for /(|\s.*?>))/ (old pattern to find the head element). + body = b"|\s.*?>))/ (old pattern to find the head element). - body = b" Date: Fri, 15 Dec 2023 11:49:22 +0100 Subject: [PATCH 047/119] Minor naming changes --- scrapy/utils/iterators.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/scrapy/utils/iterators.py b/scrapy/utils/iterators.py index 7574e377a..f239630f4 100644 --- a/scrapy/utils/iterators.py +++ b/scrapy/utils/iterators.py @@ -101,18 +101,18 @@ def _resolve_xml_namespace(element_name: str, data: bytes) -> Tuple[str, str]: if ":" not in element_name: return element_name, None, None reader: "SupportsReadClose[bytes]" = _StreamReader(data) - node_prefix, element_name = element_name.split(":", maxsplit=1) + input_prefix, element_name = element_name.split(":", maxsplit=1) ns_iterator = etree.iterparse( reader, encoding=reader.encoding, events=("start-ns",), **_ITERPARSE_KWARGS, ) - for event, (_prefix, _namespace) in ns_iterator: - if _prefix != node_prefix: + for event, (prefix, namespace) in ns_iterator: + if prefix != input_prefix: continue - return element_name, _prefix, _namespace - return f"{node_prefix}:{element_name}", None, None + return element_name, prefix, namespace + return f"{input_prefix}:{element_name}", None, None def xmliter_lxml( From a49c8762dd163b60cc73c4486a662471cfa7ac7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Fri, 15 Dec 2023 12:14:53 +0100 Subject: [PATCH 048/119] Avoid calling iterparse twice --- scrapy/utils/iterators.py | 42 +++++++++++++++++---------------------- 1 file changed, 18 insertions(+), 24 deletions(-) diff --git a/scrapy/utils/iterators.py b/scrapy/utils/iterators.py index f239630f4..8610e9b77 100644 --- a/scrapy/utils/iterators.py +++ b/scrapy/utils/iterators.py @@ -12,7 +12,6 @@ from typing import ( List, Literal, Optional, - Tuple, Union, cast, overload, @@ -97,43 +96,38 @@ def xmliter( yield Selector(text=nodetext, type="xml") -def _resolve_xml_namespace(element_name: str, data: bytes) -> Tuple[str, str]: - if ":" not in element_name: - return element_name, None, None - reader: "SupportsReadClose[bytes]" = _StreamReader(data) - input_prefix, element_name = element_name.split(":", maxsplit=1) - ns_iterator = etree.iterparse( - reader, - encoding=reader.encoding, - events=("start-ns",), - **_ITERPARSE_KWARGS, - ) - for event, (prefix, namespace) in ns_iterator: - if prefix != input_prefix: - continue - return element_name, prefix, namespace - return f"{input_prefix}:{element_name}", None, None - - def xmliter_lxml( obj: Union[Response, str, bytes], nodename: str, namespace: Optional[str] = None, prefix: str = "x", ) -> Generator[Selector, Any, None]: - if not namespace: - nodename, prefix, namespace = _resolve_xml_namespace(nodename, obj) - reader: "SupportsReadClose[bytes]" = _StreamReader(obj) tag = f"{{{namespace}}}{nodename}" if namespace else nodename iterable = etree.iterparse( reader, - tag=tag, encoding=reader.encoding, + events=("end", "start-ns"), **_ITERPARSE_KWARGS, ) selxpath = "//" + (f"{prefix}:{nodename}" if namespace else nodename) - for _, node in iterable: + needs_namespace_resolution = not namespace and ":" in nodename + if needs_namespace_resolution: + prefix, nodename = nodename.split(":", maxsplit=1) + for event, data in iterable: + if event == "start-ns": + if needs_namespace_resolution: + _prefix, _namespace = data + if _prefix != prefix: + continue + namespace = _namespace + needs_namespace_resolution = False + selxpath = f"//{prefix}:{nodename}" + tag = f"{{{namespace}}}{nodename}" + continue + node = data + if node.tag != tag: + continue nodetext = etree.tostring(node, encoding="unicode") node.clear() xs = Selector(text=nodetext, type="xml") From ce9d290eff8b5992023ffa5e833881b83a0669c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Fri, 15 Dec 2023 12:23:26 +0100 Subject: [PATCH 049/119] Remove the lxml version check for huge_tree on xmliter_lxml iterparse supports the option since lxml 2.2.1, it was the HTML parser that only got it in 4.2 --- docs/news.rst | 2 +- scrapy/utils/iterators.py | 9 +-------- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/docs/news.rst b/docs/news.rst index 525ddbf40..fab8b6f20 100644 --- a/docs/news.rst +++ b/docs/news.rst @@ -19,7 +19,7 @@ Scrapy 2.11.1 (unreleased) To minimize the impact of this change on existing code, :func:`~scrapy.utils.iterators.xmliter_lxml` now supports indicating the node namespace with a prefix in the node name, and big files with - highly nested trees. + highly nested trees when using libxml2 2.7+. - Fixed regular expressions in the implementation of the :func:`~scrapy.utils.response.open_in_browser` function. diff --git a/scrapy/utils/iterators.py b/scrapy/utils/iterators.py index 8610e9b77..b6abe2e0c 100644 --- a/scrapy/utils/iterators.py +++ b/scrapy/utils/iterators.py @@ -19,7 +19,6 @@ from typing import ( from warnings import warn from lxml import etree -from packaging.version import Version from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.http import Response, TextResponse @@ -31,12 +30,6 @@ if TYPE_CHECKING: logger = logging.getLogger(__name__) -_LXML_VERSION = Version(etree.__version__) -_LXML_HUGE_TREE_VERSION = Version("4.2") -_ITERPARSE_KWARGS = {} -if _LXML_VERSION >= _LXML_HUGE_TREE_VERSION: - _ITERPARSE_KWARGS["huge_tree"] = True - def xmliter( obj: Union[Response, str, bytes], nodename: str @@ -108,7 +101,7 @@ def xmliter_lxml( reader, encoding=reader.encoding, events=("end", "start-ns"), - **_ITERPARSE_KWARGS, + huge_tree=True, ) selxpath = "//" + (f"{prefix}:{nodename}" if namespace else nodename) needs_namespace_resolution = not namespace and ":" in nodename From bc138ef8e958f4bac5a4413d40566efc2b59acfa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Fri, 15 Dec 2023 12:24:04 +0100 Subject: [PATCH 050/119] Minor release notes fix --- docs/news.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/news.rst b/docs/news.rst index fab8b6f20..f346d1239 100644 --- a/docs/news.rst +++ b/docs/news.rst @@ -2912,7 +2912,7 @@ Scrapy 1.8.4 (unreleased) To minimize the impact of this change on existing code, :func:`~scrapy.utils.iterators.xmliter_lxml` now supports indicating the node namespace as a prefix in the node name, and big files with highly - nested trees when using lxml 4.2 or later. + nested trees when using libxml2 2.7+. Please, see the `cc65-xxvf-f7r9 security advisory`_ for more information. From c7c7a488b950806888691f58dda0b06478b98c7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Fri, 15 Dec 2023 13:18:23 +0100 Subject: [PATCH 051/119] Fix typing issues --- scrapy/utils/iterators.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/scrapy/utils/iterators.py b/scrapy/utils/iterators.py index b6abe2e0c..ab48e525f 100644 --- a/scrapy/utils/iterators.py +++ b/scrapy/utils/iterators.py @@ -95,10 +95,10 @@ def xmliter_lxml( namespace: Optional[str] = None, prefix: str = "x", ) -> Generator[Selector, Any, None]: - reader: "SupportsReadClose[bytes]" = _StreamReader(obj) + reader = _StreamReader(obj) tag = f"{{{namespace}}}{nodename}" if namespace else nodename iterable = etree.iterparse( - reader, + cast("SupportsReadClose[bytes]", reader), encoding=reader.encoding, events=("end", "start-ns"), huge_tree=True, @@ -109,6 +109,7 @@ def xmliter_lxml( prefix, nodename = nodename.split(":", maxsplit=1) for event, data in iterable: if event == "start-ns": + assert isinstance(data, tuple) if needs_namespace_resolution: _prefix, _namespace = data if _prefix != prefix: @@ -118,6 +119,7 @@ def xmliter_lxml( selxpath = f"//{prefix}:{nodename}" tag = f"{{{namespace}}}{nodename}" continue + assert isinstance(data, etree._Element) node = data if node.tag != tag: continue From 27781a85e738052e0441c81d773b3ec124194594 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Fri, 15 Dec 2023 13:52:12 +0100 Subject: [PATCH 052/119] Fix bad closing tags in XMLFeedSpider tests --- tests/test_spider.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_spider.py b/tests/test_spider.py index 00da3d485..5c4007d87 100644 --- a/tests/test_spider.py +++ b/tests/test_spider.py @@ -149,10 +149,10 @@ class XMLFeedSpiderTest(SpiderTest): body = b""" - http://www.example.com/Special-Offers.html2009-08-16 + http://www.example.com/Special-Offers.html2009-08-16 - http://www.example.com/2009-08-16 + http://www.example.com/2009-08-16 """ response = XmlResponse(url="http://example.com/sitemap.xml", body=body) From c67f73069570dd6dbe8427f55d6f9e65af07132a Mon Sep 17 00:00:00 2001 From: Swati Anshu <97234193+sa2415@users.noreply.github.com> Date: Mon, 18 Dec 2023 05:51:02 -0500 Subject: [PATCH 053/119] =?UTF-8?q?create=5Finstance=20=E2=86=92=20build?= =?UTF-8?q?=5Ffrom=5Fcrawler,=20build=5Ffrom=5Fsettings=20(#6169)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scrapy/addons.py | 6 +- scrapy/core/downloader/contextfactory.py | 16 ++--- scrapy/core/downloader/handlers/__init__.py | 9 ++- scrapy/core/downloader/handlers/http10.py | 9 ++- scrapy/core/downloader/handlers/s3.py | 9 ++- scrapy/core/engine.py | 6 +- scrapy/core/scheduler.py | 14 ++-- scrapy/crawler.py | 9 ++- scrapy/extensions/feedexport.py | 4 +- scrapy/middleware.py | 7 +- scrapy/pqueues.py | 5 +- scrapy/utils/misc.py | 47 +++++++++++++ tests/test_addons.py | 6 +- tests/test_downloader_handlers.py | 53 ++++++--------- tests/test_downloader_handlers_http2.py | 6 +- tests/test_settings/__init__.py | 4 +- tests/test_utils_misc/__init__.py | 74 +++++++++++++++++++++ tests/test_webclient.py | 10 +-- 18 files changed, 197 insertions(+), 97 deletions(-) diff --git a/scrapy/addons.py b/scrapy/addons.py index 9060d4f3f..65d7a0310 100644 --- a/scrapy/addons.py +++ b/scrapy/addons.py @@ -4,7 +4,7 @@ from typing import TYPE_CHECKING, Any, List from scrapy.exceptions import NotConfigured from scrapy.settings import Settings from scrapy.utils.conf import build_component_list -from scrapy.utils.misc import create_instance, load_object +from scrapy.utils.misc import build_from_crawler, load_object if TYPE_CHECKING: from scrapy.crawler import Crawler @@ -32,9 +32,7 @@ class AddonManager: for clspath in build_component_list(settings["ADDONS"]): try: addoncls = load_object(clspath) - addon = create_instance( - addoncls, settings=settings, crawler=self.crawler - ) + addon = build_from_crawler(addoncls, self.crawler) addon.update_settings(settings) self.addons.append(addon) except NotConfigured as e: diff --git a/scrapy/core/downloader/contextfactory.py b/scrapy/core/downloader/contextfactory.py index 909cc273f..dba4d8cdc 100644 --- a/scrapy/core/downloader/contextfactory.py +++ b/scrapy/core/downloader/contextfactory.py @@ -20,7 +20,7 @@ from scrapy.core.downloader.tls import ( openssl_methods, ) from scrapy.settings import BaseSettings -from scrapy.utils.misc import create_instance, load_object +from scrapy.utils.misc import build_from_crawler, load_object if TYPE_CHECKING: from twisted.internet._sslverify import ClientTLSOptions @@ -165,18 +165,16 @@ def load_context_factory_from_settings(settings, crawler): context_factory_cls = load_object(settings["DOWNLOADER_CLIENTCONTEXTFACTORY"]) # try method-aware context factory try: - context_factory = create_instance( - objcls=context_factory_cls, - settings=settings, - crawler=crawler, + context_factory = build_from_crawler( + context_factory_cls, + crawler, method=ssl_method, ) except TypeError: # use context factory defaults - context_factory = create_instance( - objcls=context_factory_cls, - settings=settings, - crawler=crawler, + context_factory = build_from_crawler( + context_factory_cls, + crawler, ) msg = ( f"{settings['DOWNLOADER_CLIENTCONTEXTFACTORY']} does not accept " diff --git a/scrapy/core/downloader/handlers/__init__.py b/scrapy/core/downloader/handlers/__init__.py index 6a211aafa..416669b7f 100644 --- a/scrapy/core/downloader/handlers/__init__.py +++ b/scrapy/core/downloader/handlers/__init__.py @@ -9,7 +9,7 @@ from twisted.internet.defer import Deferred from scrapy import Request, Spider, signals from scrapy.exceptions import NotConfigured, NotSupported from scrapy.utils.httpobj import urlparse_cached -from scrapy.utils.misc import create_instance, load_object +from scrapy.utils.misc import build_from_crawler, load_object from scrapy.utils.python import without_none_values if TYPE_CHECKING: @@ -55,10 +55,9 @@ class DownloadHandlers: dhcls = load_object(path) if skip_lazy and getattr(dhcls, "lazy", True): return None - dh = create_instance( - objcls=dhcls, - settings=self._crawler.settings, - crawler=self._crawler, + dh = build_from_crawler( + dhcls, + self._crawler, ) except NotConfigured as ex: self._notconfigured[scheme] = str(ex) diff --git a/scrapy/core/downloader/handlers/http10.py b/scrapy/core/downloader/handlers/http10.py index 6c1dac4a5..b6ac7a251 100644 --- a/scrapy/core/downloader/handlers/http10.py +++ b/scrapy/core/downloader/handlers/http10.py @@ -1,6 +1,6 @@ """Download handlers for http and https schemes """ -from scrapy.utils.misc import create_instance, load_object +from scrapy.utils.misc import build_from_crawler, load_object from scrapy.utils.python import to_unicode @@ -30,10 +30,9 @@ class HTTP10DownloadHandler: host, port = to_unicode(factory.host), factory.port if factory.scheme == b"https": - client_context_factory = create_instance( - objcls=self.ClientContextFactory, - settings=self._settings, - crawler=self._crawler, + client_context_factory = build_from_crawler( + self.ClientContextFactory, + self._crawler, ) return reactor.connectSSL(host, port, factory, client_context_factory) return reactor.connectTCP(host, port, factory) diff --git a/scrapy/core/downloader/handlers/s3.py b/scrapy/core/downloader/handlers/s3.py index 81d8e8115..1f7533759 100644 --- a/scrapy/core/downloader/handlers/s3.py +++ b/scrapy/core/downloader/handlers/s3.py @@ -2,7 +2,7 @@ from scrapy.core.downloader.handlers.http import HTTPDownloadHandler from scrapy.exceptions import NotConfigured from scrapy.utils.boto import is_botocore_available from scrapy.utils.httpobj import urlparse_cached -from scrapy.utils.misc import create_instance +from scrapy.utils.misc import build_from_crawler class S3DownloadHandler: @@ -50,10 +50,9 @@ class S3DownloadHandler: ) ) - _http_handler = create_instance( - objcls=httpdownloadhandler, - settings=settings, - crawler=crawler, + _http_handler = build_from_crawler( + httpdownloadhandler, + crawler, ) self._download_http = _http_handler.download_request diff --git a/scrapy/core/engine.py b/scrapy/core/engine.py index dd1f56f8c..545cd401f 100644 --- a/scrapy/core/engine.py +++ b/scrapy/core/engine.py @@ -34,7 +34,7 @@ from scrapy.settings import BaseSettings, Settings from scrapy.signalmanager import SignalManager from scrapy.spiders import Spider from scrapy.utils.log import failure_to_exc_info, logformatter_adapter -from scrapy.utils.misc import create_instance, load_object +from scrapy.utils.misc import build_from_crawler, load_object from scrapy.utils.reactor import CallLaterOnce if TYPE_CHECKING: @@ -358,9 +358,7 @@ class ExecutionEngine: raise RuntimeError(f"No free spider slot when opening {spider.name!r}") logger.info("Spider opened", extra={"spider": spider}) nextcall = CallLaterOnce(self._next_request) - scheduler = create_instance( - self.scheduler_cls, settings=None, crawler=self.crawler - ) + scheduler = build_from_crawler(self.scheduler_cls, self.crawler) start_requests = yield self.scraper.spidermw.process_start_requests( start_requests, spider ) diff --git a/scrapy/core/scheduler.py b/scrapy/core/scheduler.py index 17c95f1ea..f41b83a67 100644 --- a/scrapy/core/scheduler.py +++ b/scrapy/core/scheduler.py @@ -14,7 +14,7 @@ from scrapy.http.request import Request from scrapy.spiders import Spider from scrapy.statscollectors import StatsCollector from scrapy.utils.job import job_dir -from scrapy.utils.misc import create_instance, load_object +from scrapy.utils.misc import build_from_crawler, load_object if TYPE_CHECKING: # typing.Self requires Python 3.11 @@ -202,7 +202,7 @@ class Scheduler(BaseScheduler): """ dupefilter_cls = load_object(crawler.settings["DUPEFILTER_CLASS"]) return cls( - dupefilter=create_instance(dupefilter_cls, crawler.settings, crawler), + dupefilter=build_from_crawler(dupefilter_cls, crawler), jobdir=job_dir(crawler.settings), dqclass=load_object(crawler.settings["SCHEDULER_DISK_QUEUE"]), mqclass=load_object(crawler.settings["SCHEDULER_MEMORY_QUEUE"]), @@ -322,10 +322,9 @@ class Scheduler(BaseScheduler): def _mq(self): """Create a new priority queue instance, with in-memory storage""" - return create_instance( + return build_from_crawler( self.pqclass, - settings=None, - crawler=self.crawler, + self.crawler, downstream_queue_cls=self.mqclass, key="", ) @@ -334,10 +333,9 @@ class Scheduler(BaseScheduler): """Create a new priority queue instance, with disk storage""" assert self.dqdir state = self._read_dqs_state(self.dqdir) - q = create_instance( + q = build_from_crawler( self.pqclass, - settings=None, - crawler=self.crawler, + self.crawler, downstream_queue_cls=self.dqclass, key=self.dqdir, startprios=state, diff --git a/scrapy/crawler.py b/scrapy/crawler.py index 1d3a11208..844d5f759 100644 --- a/scrapy/crawler.py +++ b/scrapy/crawler.py @@ -39,7 +39,7 @@ from scrapy.utils.log import ( log_reactor_info, log_scrapy_info, ) -from scrapy.utils.misc import create_instance, load_object +from scrapy.utils.misc import build_from_crawler, load_object from scrapy.utils.ossignal import install_shutdown_handlers, signal_names from scrapy.utils.reactor import ( install_reactor, @@ -109,10 +109,9 @@ class Crawler: lf_cls: Type[LogFormatter] = load_object(self.settings["LOG_FORMATTER"]) self.logformatter = lf_cls.from_crawler(self) - self.request_fingerprinter = create_instance( + self.request_fingerprinter = build_from_crawler( load_object(self.settings["REQUEST_FINGERPRINTER_CLASS"]), - settings=self.settings, - crawler=self, + self, ) reactor_class: str = self.settings["TWISTED_REACTOR"] @@ -404,7 +403,7 @@ class CrawlerProcess(CrawlerRunner): d.addBoth(self._stop_reactor) resolver_class = load_object(self.settings["DNS_RESOLVER"]) - resolver = create_instance(resolver_class, self.settings, self, reactor=reactor) + resolver = build_from_crawler(resolver_class, self, reactor=reactor) resolver.install_on_reactor() tp = reactor.getThreadPool() tp.adjustPoolsize(maxthreads=self.settings.getint("REACTOR_THREADPOOL_MAXSIZE")) diff --git a/scrapy/extensions/feedexport.py b/scrapy/extensions/feedexport.py index fadbbb582..e5e363b52 100644 --- a/scrapy/extensions/feedexport.py +++ b/scrapy/extensions/feedexport.py @@ -28,7 +28,7 @@ from scrapy.utils.defer import maybe_deferred_to_future from scrapy.utils.deprecate import create_deprecated_class from scrapy.utils.ftp import ftp_store_file from scrapy.utils.log import failure_to_exc_info -from scrapy.utils.misc import create_instance, load_object +from scrapy.utils.misc import build_from_crawler, load_object from scrapy.utils.python import without_none_values logger = logging.getLogger(__name__) @@ -371,7 +371,7 @@ class FeedSlot: self._exporting = True def _get_instance(self, objcls, *args, **kwargs): - return create_instance(objcls, self.settings, self.crawler, *args, **kwargs) + return build_from_crawler(objcls, self.crawler, *args, **kwargs) def _get_exporter(self, file, format, *args, **kwargs): return self._get_instance(self.exporters[format], file, *args, **kwargs) diff --git a/scrapy/middleware.py b/scrapy/middleware.py index 090588130..f60c726f9 100644 --- a/scrapy/middleware.py +++ b/scrapy/middleware.py @@ -23,7 +23,7 @@ from scrapy import Spider from scrapy.exceptions import NotConfigured from scrapy.settings import Settings from scrapy.utils.defer import process_chain, process_parallel -from scrapy.utils.misc import create_instance, load_object +from scrapy.utils.misc import build_from_crawler, build_from_settings, load_object if TYPE_CHECKING: # typing.Self requires Python 3.11 @@ -64,7 +64,10 @@ class MiddlewareManager: for clspath in mwlist: try: mwcls = load_object(clspath) - mw = create_instance(mwcls, settings, crawler) + if crawler is not None: + mw = build_from_crawler(mwcls, crawler) + else: + mw = build_from_settings(mwcls, settings) middlewares.append(mw) enabled.append(clspath) except NotConfigured as e: diff --git a/scrapy/pqueues.py b/scrapy/pqueues.py index 62a9af477..b62d2fe58 100644 --- a/scrapy/pqueues.py +++ b/scrapy/pqueues.py @@ -1,7 +1,7 @@ import hashlib import logging -from scrapy.utils.misc import create_instance +from scrapy.utils.misc import build_from_crawler logger = logging.getLogger(__name__) @@ -72,9 +72,8 @@ class ScrapyPriorityQueue: self.curprio = min(startprios) def qfactory(self, key): - return create_instance( + return build_from_crawler( self.downstream_queue_cls, - None, self.crawler, self.key + "/" + str(key), ) diff --git a/scrapy/utils/misc.py b/scrapy/utils/misc.py index a9364bea2..b38190cb3 100644 --- a/scrapy/utils/misc.py +++ b/scrapy/utils/misc.py @@ -25,6 +25,7 @@ from typing import ( cast, ) +from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.item import Item from scrapy.utils.datatypes import LocalWeakReferencedCache @@ -142,6 +143,13 @@ def create_instance(objcls, settings, crawler, *args, **kwargs): Raises ``TypeError`` if the resulting instance is ``None`` (e.g. if an extension has not been implemented correctly). """ + warnings.warn( + "The create_instance() function is deprecated. " + "Please use build_from_crawler() or build_from_settings() instead.", + category=ScrapyDeprecationWarning, + stacklevel=2, + ) + if settings is None: if crawler is None: raise ValueError("Specify at least one of settings and crawler.") @@ -160,6 +168,45 @@ def create_instance(objcls, settings, crawler, *args, **kwargs): return instance +def build_from_crawler(objcls, crawler, /, *args, **kwargs): + """Construct a class instance using its ``from_crawler`` constructor. + + ``*args`` and ``**kwargs`` are forwarded to the constructor. + + Raises ``TypeError`` if the resulting instance is ``None``. + """ + if hasattr(objcls, "from_crawler"): + instance = objcls.from_crawler(crawler, *args, **kwargs) + method_name = "from_crawler" + elif hasattr(objcls, "from_settings"): + instance = objcls.from_settings(crawler.settings, *args, **kwargs) + method_name = "from_settings" + else: + instance = objcls(*args, **kwargs) + method_name = "__new__" + if instance is None: + raise TypeError(f"{objcls.__qualname__}.{method_name} returned None") + return instance + + +def build_from_settings(objcls, settings, /, *args, **kwargs): + """Construct a class instance using its ``from_settings`` constructor. + + ``*args`` and ``**kwargs`` are forwarded to the constructor. + + Raises ``TypeError`` if the resulting instance is ``None``. + """ + if hasattr(objcls, "from_settings"): + instance = objcls.from_settings(settings, *args, **kwargs) + method_name = "from_settings" + else: + instance = objcls(*args, **kwargs) + method_name = "__new__" + if instance is None: + raise TypeError(f"{objcls.__qualname__}.{method_name} returned None") + return instance + + @contextmanager def set_environ(**kwargs: str) -> Generator[None, Any, None]: """Temporarily set environment variables inside the context manager and diff --git a/tests/test_addons.py b/tests/test_addons.py index 0f4f2e5b8..f1b01bc5c 100644 --- a/tests/test_addons.py +++ b/tests/test_addons.py @@ -89,7 +89,7 @@ class AddonManagerTest(unittest.TestCase): self.assertEqual([a.number for a in manager.addons], expected_order) self.assertEqual(crawler.settings.getint("KEY1"), expected_order[-1]) - def test_create_instance(self): + def test_build_from_crawler(self): settings_dict = { "ADDONS": {"tests.test_addons.CreateInstanceAddon": 0}, "MYADDON": {"MYADDON_KEY": "val"}, @@ -167,12 +167,12 @@ class AddonManagerTest(unittest.TestCase): pass with patch("scrapy.addons.logger") as logger_mock: - with patch("scrapy.addons.create_instance") as create_instance_mock: + with patch("scrapy.addons.build_from_crawler") as build_from_crawler_mock: settings_dict = { "ADDONS": {LoggedAddon: 1}, } addon = LoggedAddon() - create_instance_mock.return_value = addon + build_from_crawler_mock.return_value = addon crawler = get_crawler(settings_dict=settings_dict) logger_mock.info.assert_called_once_with( "Enabled addons:\n%(addons)s", diff --git a/tests/test_downloader_handlers.py b/tests/test_downloader_handlers.py index 924ece6f9..dd07d33f1 100644 --- a/tests/test_downloader_handlers.py +++ b/tests/test_downloader_handlers.py @@ -29,7 +29,7 @@ from scrapy.http import Headers, HtmlResponse, Request from scrapy.http.response.text import TextResponse from scrapy.responsetypes import responsetypes from scrapy.spiders import Spider -from scrapy.utils.misc import create_instance +from scrapy.utils.misc import build_from_crawler from scrapy.utils.python import to_bytes from scrapy.utils.test import get_crawler, skip_if_no_boto from tests import NON_EXISTING_RESOLVABLE @@ -109,7 +109,7 @@ class FileTestCase(unittest.TestCase): # add a special char to check that they are handled correctly self.tmpname = Path(self.mktemp() + "^") Path(self.tmpname).write_text("0123456789", encoding="utf-8") - handler = create_instance(FileDownloadHandler, None, get_crawler()) + handler = build_from_crawler(FileDownloadHandler, get_crawler()) self.download_request = handler.download_request def tearDown(self): @@ -257,8 +257,8 @@ class HttpTestCase(unittest.TestCase): else: self.port = reactor.listenTCP(0, self.wrapper, interface=self.host) self.portno = self.port.getHost().port - self.download_handler = create_instance( - self.download_handler_cls, None, get_crawler() + self.download_handler = build_from_crawler( + self.download_handler_cls, get_crawler() ) self.download_request = self.download_handler.download_request @@ -557,7 +557,7 @@ class Http11TestCase(HttpTestCase): def test_download_broken_content_allow_data_loss_via_setting(self, url="broken"): crawler = get_crawler(settings_dict={"DOWNLOAD_FAIL_ON_DATALOSS": False}) - download_handler = create_instance(self.download_handler_cls, None, crawler) + 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) @@ -590,7 +590,7 @@ class Https11TestCase(Http11TestCase): crawler = get_crawler( settings_dict={"DOWNLOADER_CLIENT_TLS_VERBOSE_LOGGING": True} ) - download_handler = create_instance(self.download_handler_cls, None, crawler) + download_handler = build_from_crawler(self.download_handler_cls, crawler) try: with LogCapture() as log_capture: request = Request(self.getURL("file")) @@ -669,9 +669,7 @@ class Https11CustomCiphers(unittest.TestCase): crawler = get_crawler( settings_dict={"DOWNLOADER_CLIENT_TLS_CIPHERS": "CAMELLIA256-SHA"} ) - self.download_handler = create_instance( - self.download_handler_cls, None, crawler - ) + self.download_handler = build_from_crawler(self.download_handler_cls, crawler) self.download_request = self.download_handler.download_request @defer.inlineCallbacks @@ -751,8 +749,8 @@ class HttpProxyTestCase(unittest.TestCase): wrapper = WrappingFactory(site) self.port = reactor.listenTCP(0, wrapper, interface="127.0.0.1") self.portno = self.port.getHost().port - self.download_handler = create_instance( - self.download_handler_cls, None, get_crawler() + self.download_handler = build_from_crawler( + self.download_handler_cls, get_crawler() ) self.download_request = self.download_handler.download_request @@ -830,10 +828,9 @@ class S3AnonTestCase(unittest.TestCase): def setUp(self): skip_if_no_boto() crawler = get_crawler() - self.s3reqh = create_instance( - objcls=S3DownloadHandler, - settings=None, - crawler=crawler, + self.s3reqh = build_from_crawler( + S3DownloadHandler, + crawler, httpdownloadhandler=HttpDownloadHandlerMock, # anon=True, # implicit ) @@ -861,10 +858,9 @@ class S3TestCase(unittest.TestCase): def setUp(self): skip_if_no_boto() crawler = get_crawler() - s3reqh = create_instance( - objcls=S3DownloadHandler, - settings=None, - crawler=crawler, + s3reqh = build_from_crawler( + S3DownloadHandler, + crawler, aws_access_key_id=self.AWS_ACCESS_KEY_ID, aws_secret_access_key=self.AWS_SECRET_ACCESS_KEY, httpdownloadhandler=HttpDownloadHandlerMock, @@ -889,10 +885,9 @@ class S3TestCase(unittest.TestCase): def test_extra_kw(self): try: crawler = get_crawler() - create_instance( - objcls=S3DownloadHandler, - settings=None, - crawler=crawler, + build_from_crawler( + S3DownloadHandler, + crawler, extra_kw=True, ) except Exception as e: @@ -1039,9 +1034,7 @@ class BaseFTPTestCase(unittest.TestCase): self.port = reactor.listenTCP(0, self.factory, interface="127.0.0.1") self.portNum = self.port.getHost().port crawler = get_crawler() - self.download_handler = create_instance( - FTPDownloadHandler, crawler.settings, crawler - ) + self.download_handler = build_from_crawler(FTPDownloadHandler, crawler) self.addCleanup(self.port.stopListening) def tearDown(self): @@ -1185,9 +1178,7 @@ class AnonymousFTPTestCase(BaseFTPTestCase): self.port = reactor.listenTCP(0, self.factory, interface="127.0.0.1") self.portNum = self.port.getHost().port crawler = get_crawler() - self.download_handler = create_instance( - FTPDownloadHandler, crawler.settings, crawler - ) + self.download_handler = build_from_crawler(FTPDownloadHandler, crawler) self.addCleanup(self.port.stopListening) def tearDown(self): @@ -1197,9 +1188,7 @@ class AnonymousFTPTestCase(BaseFTPTestCase): class DataURITestCase(unittest.TestCase): def setUp(self): crawler = get_crawler() - self.download_handler = create_instance( - DataURIDownloadHandler, crawler.settings, crawler - ) + self.download_handler = build_from_crawler(DataURIDownloadHandler, crawler) self.download_request = self.download_handler.download_request self.spider = Spider("foo") diff --git a/tests/test_downloader_handlers_http2.py b/tests/test_downloader_handlers_http2.py index 31fa1430d..322075043 100644 --- a/tests/test_downloader_handlers_http2.py +++ b/tests/test_downloader_handlers_http2.py @@ -11,7 +11,7 @@ from twisted.web.http import H2_ENABLED from scrapy.http import Request from scrapy.spiders import Spider -from scrapy.utils.misc import create_instance +from scrapy.utils.misc import build_from_crawler from scrapy.utils.test import get_crawler from tests.mockserver import ssl_context_factory from tests.test_downloader_handlers import ( @@ -240,8 +240,8 @@ class Https2ProxyTestCase(Http11ProxyTestCase): interface=self.host, ) self.portno = self.port.getHost().port - self.download_handler = create_instance( - self.download_handler_cls, None, get_crawler() + self.download_handler = build_from_crawler( + self.download_handler_cls, get_crawler() ) self.download_request = self.download_handler.download_request diff --git a/tests/test_settings/__init__.py b/tests/test_settings/__init__.py index e7799737f..3fde5e8c5 100644 --- a/tests/test_settings/__init__.py +++ b/tests/test_settings/__init__.py @@ -440,7 +440,7 @@ class SettingsTest(unittest.TestCase): def test_passing_objects_as_values(self): from scrapy.core.downloader.handlers.file import FileDownloadHandler - from scrapy.utils.misc import create_instance + from scrapy.utils.misc import build_from_crawler from scrapy.utils.test import get_crawler class TestPipeline: @@ -468,7 +468,7 @@ class SettingsTest(unittest.TestCase): myhandler = settings.getdict("DOWNLOAD_HANDLERS").pop("ftp") self.assertEqual(myhandler, FileDownloadHandler) - myhandler_instance = create_instance(myhandler, None, get_crawler()) + myhandler_instance = build_from_crawler(myhandler, get_crawler()) self.assertIsInstance(myhandler_instance, FileDownloadHandler) self.assertTrue(hasattr(myhandler_instance, "download_request")) diff --git a/tests/test_utils_misc/__init__.py b/tests/test_utils_misc/__init__.py index 69793ee75..ee3314d8e 100644 --- a/tests/test_utils_misc/__init__.py +++ b/tests/test_utils_misc/__init__.py @@ -7,6 +7,8 @@ from unittest import mock from scrapy.item import Field, Item from scrapy.utils.misc import ( arg_to_iter, + build_from_crawler, + build_from_settings, create_instance, load_object, rel_has_nofollow, @@ -153,6 +155,78 @@ class UtilsMiscTestCase(unittest.TestCase): with self.assertRaises(TypeError): create_instance(m, settings, None) + def test_build_from_crawler(self): + settings = mock.MagicMock() + crawler = mock.MagicMock(spec_set=["settings"]) + args = (True, 100.0) + kwargs = {"key": "val"} + + def _test_with_crawler(mock, settings, crawler): + build_from_crawler(mock, crawler, *args, **kwargs) + if hasattr(mock, "from_crawler"): + mock.from_crawler.assert_called_once_with(crawler, *args, **kwargs) + if hasattr(mock, "from_settings"): + self.assertEqual(mock.from_settings.call_count, 0) + self.assertEqual(mock.call_count, 0) + elif hasattr(mock, "from_settings"): + mock.from_settings.assert_called_once_with(settings, *args, **kwargs) + self.assertEqual(mock.call_count, 0) + else: + mock.assert_called_once_with(*args, **kwargs) + + # Check usage of correct constructor using three mocks: + # 1. with no alternative constructors + # 2. with from_crawler() constructor + # 3. with from_settings() and from_crawler() constructor + spec_sets = ( + ["__qualname__"], + ["__qualname__", "from_crawler"], + ["__qualname__", "from_settings", "from_crawler"], + ) + for specs in spec_sets: + m = mock.MagicMock(spec_set=specs) + _test_with_crawler(m, settings, crawler) + m.reset_mock() + + # Check adoption of crawler + m = mock.MagicMock(spec_set=["__qualname__", "from_crawler"]) + m.from_crawler.return_value = None + with self.assertRaises(TypeError): + build_from_crawler(m, crawler, *args, **kwargs) + + def test_build_from_settings(self): + settings = mock.MagicMock() + args = (True, 100.0) + kwargs = {"key": "val"} + + def _test_with_settings(mock, settings): + build_from_settings(mock, settings, *args, **kwargs) + if hasattr(mock, "from_settings"): + mock.from_settings.assert_called_once_with(settings, *args, **kwargs) + self.assertEqual(mock.call_count, 0) + else: + mock.assert_called_once_with(*args, **kwargs) + + # Check usage of correct constructor using three mocks: + # 1. with no alternative constructors + # 2. with from_settings() constructor + # 3. with from_settings() and from_crawler() constructor + spec_sets = ( + ["__qualname__"], + ["__qualname__", "from_settings"], + ["__qualname__", "from_settings", "from_crawler"], + ) + for specs in spec_sets: + m = mock.MagicMock(spec_set=specs) + _test_with_settings(m, settings) + m.reset_mock() + + # Check adoption of crawler settings + m = mock.MagicMock(spec_set=["__qualname__", "from_settings"]) + m.from_settings.return_value = None + with self.assertRaises(TypeError): + build_from_settings(m, settings, *args, **kwargs) + def test_set_environ(self): assert os.environ.get("some_test_environ") is None with set_environ(some_test_environ="test_value"): diff --git a/tests/test_webclient.py b/tests/test_webclient.py index 0042fe8f0..d4b6ba15b 100644 --- a/tests/test_webclient.py +++ b/tests/test_webclient.py @@ -24,7 +24,7 @@ from scrapy.core.downloader import webclient as client from scrapy.core.downloader.contextfactory import ScrapyClientContextFactory from scrapy.http import Headers, Request from scrapy.settings import Settings -from scrapy.utils.misc import create_instance +from scrapy.utils.misc import build_from_settings from scrapy.utils.python import to_bytes, to_unicode from tests.mockserver import ( BrokenDownloadResource, @@ -470,8 +470,8 @@ class WebClientCustomCiphersSSLTestCase(WebClientSSLTestCase): def testPayload(self): s = "0123456789" * 10 settings = Settings({"DOWNLOADER_CLIENT_TLS_CIPHERS": self.custom_ciphers}) - client_context_factory = create_instance( - ScrapyClientContextFactory, settings=settings, crawler=None + client_context_factory = build_from_settings( + ScrapyClientContextFactory, settings ) return getPage( self.getURL("payload"), body=s, contextFactory=client_context_factory @@ -482,8 +482,8 @@ class WebClientCustomCiphersSSLTestCase(WebClientSSLTestCase): settings = Settings( {"DOWNLOADER_CLIENT_TLS_CIPHERS": "ECDHE-RSA-AES256-GCM-SHA384"} ) - client_context_factory = create_instance( - ScrapyClientContextFactory, settings=settings, crawler=None + client_context_factory = build_from_settings( + ScrapyClientContextFactory, settings ) d = getPage( self.getURL("payload"), body=s, contextFactory=client_context_factory From 1864f48e9e3752e4cb7e24c22b2d51b727e4086c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Wed, 20 Dec 2023 12:47:18 +0100 Subject: [PATCH 054/119] =?UTF-8?q?Link=20to=20Zyte=E2=80=99s=20export=20g?= =?UTF-8?q?uides?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/topics/feed-exports.rst | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/topics/feed-exports.rst b/docs/topics/feed-exports.rst index 700775e4b..f64bbac06 100644 --- a/docs/topics/feed-exports.rst +++ b/docs/topics/feed-exports.rst @@ -13,6 +13,11 @@ Scrapy provides this functionality out of the box with the Feed Exports, which allows you to generate feeds with the scraped items, using multiple serialization formats and storage backends. +This page provides detailed documentation for all feed export features. If you +are looking for a step-by-step guide, check out `Zyte’s export guides`_. + +.. _Zyte’s export guides: https://docs.zyte.com/web-scraping/guides/export/index.html#exporting-scraped-data + .. _topics-feed-format: Serialization formats From b095dd218fe64f2541079d691e3c2c68d2e03ff9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Thu, 30 Nov 2023 10:54:09 +0100 Subject: [PATCH 055/119] Extend Request.meta documentation (#5565) --- docs/topics/request-response.rst | 47 ++++++++++++++++++++++++++------ 1 file changed, 38 insertions(+), 9 deletions(-) diff --git a/docs/topics/request-response.rst b/docs/topics/request-response.rst index adf3d0f4a..8edf710bc 100644 --- a/docs/topics/request-response.rst +++ b/docs/topics/request-response.rst @@ -193,18 +193,47 @@ Request objects :meth:`replace`. .. attribute:: Request.meta + :value: {} - A dict that contains arbitrary metadata for this request. This dict is - empty for new Requests, and is usually populated by different Scrapy - components (extensions, middlewares, etc). So the data contained in this - dict depends on the extensions you have enabled. + A dictionary of arbitrary metadata for the request. - See :ref:`topics-request-meta` for a list of special meta keys - recognized by Scrapy. + You may extend request metadata as you see fit. - This dict is :doc:`shallow copied ` when the request is - cloned using the ``copy()`` or ``replace()`` methods, and can also be - accessed, in your spider, from the ``response.meta`` attribute. + Request metadata can also be accessed through the + :attr:`~scrapy.http.Response.meta` attribute of a response. + + To pass data from one spider callback to another, consider using + :attr:`cb_kwargs` instead. However, request metadata may be the right + choice in certain scenarios, such as to maintain some debugging data + across all follow-up requests (e.g. the source URL). + + A common use of request metadata is to define request-specific + parameters for Scrapy components (extensions, middlewares, etc.). For + example, if you set ``dont_retry`` to ``True``, + :class:`~scrapy.downloadermiddlewares.retry.RetryMiddleware` will never + retry that request, even if it fails. See :ref:`topics-request-meta`. + + You may also use request metadata in your custom Scrapy components, for + example, to keep request state information relevant to your component. + For example, + :class:`~scrapy.downloadermiddlewares.retry.RetryMiddleware` uses the + ``retry_times`` metadata key to keep track of how many times a request + has been retried so far. + + Copying all the metadata of a previous request into a new, follow-up + request in a spider callback is a bad practice, because request + metadata may include metadata set by Scrapy components that is not + meant to be copied into other requests. For example, copying the + ``retry_times`` metadata key into follow-up requests can lower the + amount of retries allowed for those follow-up requests. + + You should only copy all request metadata from one request to another + if the new request is meant to replace the old request, as is often the + case when returning a request from a :ref:`downloader middleware + ` method. + + Also mind that the :meth:`copy` and :meth:`replace` request methods + :doc:`shallow-copy ` request metadata. .. attribute:: Request.cb_kwargs From 369712ee50f7438c2863359f053cb1b221a42169 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Thu, 30 Nov 2023 11:01:22 +0100 Subject: [PATCH 056/119] =?UTF-8?q?SPM=20=E2=86=92=20Zyte=20API=20(#6163)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/topics/practices.rst | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/docs/topics/practices.rst b/docs/topics/practices.rst index f64da22d8..b1b8c9e9c 100644 --- a/docs/topics/practices.rst +++ b/docs/topics/practices.rst @@ -288,9 +288,8 @@ Here are some tips to keep in mind when dealing with these kinds of sites: * use a pool of rotating IPs. For example, the free `Tor project`_ or paid services like `ProxyMesh`_. An open source alternative is `scrapoxy`_, a super proxy that you can attach your own proxies to. -* use a highly distributed downloader that circumvents bans internally, so you - can just focus on parsing clean pages. One example of such downloaders is - `Zyte Smart Proxy Manager`_ +* use a ban avoidance service, such as `Zyte API`_, which provides a `Scrapy + plugin `__ If you are still unable to prevent your bot getting banned, consider contacting `commercial support`_. @@ -301,4 +300,4 @@ If you are still unable to prevent your bot getting banned, consider contacting .. _Common Crawl: https://commoncrawl.org/ .. _testspiders: https://github.com/scrapinghub/testspiders .. _scrapoxy: https://scrapoxy.io/ -.. _Zyte Smart Proxy Manager: https://www.zyte.com/smart-proxy-manager/ +.. _Zyte API: https://docs.zyte.com/zyte-api/get-started.html From 48a9a58ff27d24910b55a0ad5e6b014589c71115 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Wed, 20 Dec 2023 12:47:18 +0100 Subject: [PATCH 057/119] =?UTF-8?q?Link=20to=20Zyte=E2=80=99s=20export=20g?= =?UTF-8?q?uides?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/topics/feed-exports.rst | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/topics/feed-exports.rst b/docs/topics/feed-exports.rst index 700775e4b..f64bbac06 100644 --- a/docs/topics/feed-exports.rst +++ b/docs/topics/feed-exports.rst @@ -13,6 +13,11 @@ Scrapy provides this functionality out of the box with the Feed Exports, which allows you to generate feeds with the scraped items, using multiple serialization formats and storage backends. +This page provides detailed documentation for all feed export features. If you +are looking for a step-by-step guide, check out `Zyte’s export guides`_. + +.. _Zyte’s export guides: https://docs.zyte.com/web-scraping/guides/export/index.html#exporting-scraped-data + .. _topics-feed-format: Serialization formats From d25cfe5315c9c0346776529a6e14ffb405913d2e Mon Sep 17 00:00:00 2001 From: Jalil SA <61639983+jxlil@users.noreply.github.com> Date: Thu, 21 Dec 2023 03:36:21 -0600 Subject: [PATCH 058/119] Add JsonResponse (#6174) --- docs/topics/request-response.rst | 10 ++++++++++ scrapy/http/__init__.py | 1 + scrapy/http/response/json.py | 12 ++++++++++++ scrapy/responsetypes.py | 6 +++--- tests/test_responsetypes.py | 14 +++++++++++--- 5 files changed, 37 insertions(+), 6 deletions(-) create mode 100644 scrapy/http/response/json.py diff --git a/docs/topics/request-response.rst b/docs/topics/request-response.rst index 8edf710bc..9d64eee45 100644 --- a/docs/topics/request-response.rst +++ b/docs/topics/request-response.rst @@ -1357,3 +1357,13 @@ XmlResponse objects line. See :attr:`TextResponse.encoding`. .. _bug in lxml: https://bugs.launchpad.net/lxml/+bug/1665241 + +JsonResponse objects +-------------------- + +.. class:: JsonResponse(url[, ...]) + + The :class:`JsonResponse` class is a subclass of :class:`TextResponse` + that is used when the response has a `JSON MIME type + `_ in its `Content-Type` + header. diff --git a/scrapy/http/__init__.py b/scrapy/http/__init__.py index ac3946302..d0b726bad 100644 --- a/scrapy/http/__init__.py +++ b/scrapy/http/__init__.py @@ -12,5 +12,6 @@ from scrapy.http.request.json_request import JsonRequest from scrapy.http.request.rpc import XmlRpcRequest from scrapy.http.response import Response from scrapy.http.response.html import HtmlResponse +from scrapy.http.response.json import JsonResponse from scrapy.http.response.text import TextResponse from scrapy.http.response.xml import XmlResponse diff --git a/scrapy/http/response/json.py b/scrapy/http/response/json.py new file mode 100644 index 000000000..219691094 --- /dev/null +++ b/scrapy/http/response/json.py @@ -0,0 +1,12 @@ +""" +This module implements the JsonResponse class that is used when the response +has a JSON MIME type in its Content-Type header. + +See documentation in docs/topics/request-response.rst +""" + +from scrapy.http.response.text import TextResponse + + +class JsonResponse(TextResponse): + pass diff --git a/scrapy/responsetypes.py b/scrapy/responsetypes.py index 9e411d4aa..0d127d851 100644 --- a/scrapy/responsetypes.py +++ b/scrapy/responsetypes.py @@ -21,9 +21,9 @@ class ResponseTypes: "application/xhtml+xml": "scrapy.http.HtmlResponse", "application/vnd.wap.xhtml+xml": "scrapy.http.HtmlResponse", "application/xml": "scrapy.http.XmlResponse", - "application/json": "scrapy.http.TextResponse", - "application/x-json": "scrapy.http.TextResponse", - "application/json-amazonui-streaming": "scrapy.http.TextResponse", + "application/json": "scrapy.http.JsonResponse", + "application/x-json": "scrapy.http.JsonResponse", + "application/json-amazonui-streaming": "scrapy.http.JsonResponse", "application/javascript": "scrapy.http.TextResponse", "application/x-javascript": "scrapy.http.TextResponse", "text/xml": "scrapy.http.XmlResponse", diff --git a/tests/test_responsetypes.py b/tests/test_responsetypes.py index 6e1ed82f0..713a83d52 100644 --- a/tests/test_responsetypes.py +++ b/tests/test_responsetypes.py @@ -1,6 +1,13 @@ import unittest -from scrapy.http import Headers, HtmlResponse, Response, TextResponse, XmlResponse +from scrapy.http import ( + Headers, + HtmlResponse, + JsonResponse, + Response, + TextResponse, + XmlResponse, +) from scrapy.responsetypes import responsetypes @@ -40,8 +47,9 @@ class ResponseTypesTest(unittest.TestCase): ("application/vnd.wap.xhtml+xml; charset=utf-8", HtmlResponse), ("application/xml; charset=UTF-8", XmlResponse), ("application/octet-stream", Response), - ("application/x-json; encoding=UTF8;charset=UTF-8", TextResponse), - ("application/json-amazonui-streaming;charset=UTF-8", TextResponse), + ("application/json; encoding=UTF8;charset=UTF-8", JsonResponse), + ("application/x-json; encoding=UTF8;charset=UTF-8", JsonResponse), + ("application/json-amazonui-streaming;charset=UTF-8", JsonResponse), (b"application/x-download; filename=\x80dummy.txt", Response), ] for source, cls in mappings: From 0e78acb65798a1eb4e55a472232e77d55e6c7cd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Thu, 21 Dec 2023 21:01:07 +0100 Subject: [PATCH 059/119] MediaPipeline: log media_to_download errors before stripping them (#5068) --- scrapy/pipelines/media.py | 10 +++++----- tests/test_pipeline_crawl.py | 24 ++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/scrapy/pipelines/media.py b/scrapy/pipelines/media.py index 75532034a..fc156ab41 100644 --- a/scrapy/pipelines/media.py +++ b/scrapy/pipelines/media.py @@ -112,14 +112,14 @@ class MediaPipeline: info.downloading.add(fp) dfd = mustbe_deferred(self.media_to_download, request, info, item=item) dfd.addCallback(self._check_media_to_download, request, info, item=item) + dfd.addErrback(self._log_exception) dfd.addBoth(self._cache_result_and_execute_waiters, fp, info) - dfd.addErrback( - lambda f: logger.error( - f.value, exc_info=failure_to_exc_info(f), extra={"spider": info.spider} - ) - ) return dfd.addBoth(lambda _: wad) # it must return wad at last + def _log_exception(self, result): + logger.exception(result) + return result + def _modify_media_request(self, request): if self.handle_httpstatus_list: request.meta["handle_httpstatus_list"] = self.handle_httpstatus_list diff --git a/tests/test_pipeline_crawl.py b/tests/test_pipeline_crawl.py index ed8483483..c41ab483f 100644 --- a/tests/test_pipeline_crawl.py +++ b/tests/test_pipeline_crawl.py @@ -9,6 +9,7 @@ from w3lib.url import add_or_replace_parameter from scrapy import signals from scrapy.crawler import CrawlerRunner +from scrapy.utils.misc import load_object from tests.mockserver import MockServer from tests.spiders import SimpleSpider @@ -193,6 +194,29 @@ class FileDownloadCrawlTestCase(TestCase): crawler.stats.get_value("downloader/response_status_count/302"), 3 ) + @defer.inlineCallbacks + def test_download_media_file_path_error(self): + cls = load_object(self.pipeline_class) + + class ExceptionRaisingMediaPipeline(cls): + def file_path(self, request, response=None, info=None, *, item=None): + return 1 / 0 + + settings = { + **self.settings, + "ITEM_PIPELINES": {ExceptionRaisingMediaPipeline: 1}, + } + runner = CrawlerRunner(settings) + crawler = self._create_crawler(MediaDownloadSpider, runner=runner) + with LogCapture() as log: + yield crawler.crawl( + self.mockserver.url("/files/images/"), + media_key=self.media_key, + media_urls_key=self.media_urls_key, + mockserver=self.mockserver, + ) + self.assertIn("ZeroDivisionError", str(log)) + skip_pillow: Optional[str] try: From 34e01a8a933cc24e1daf2c3a0cc024f37e3614f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Thu, 28 Dec 2023 12:25:01 +0100 Subject: [PATCH 060/119] Update quotes.toscrape.com page copies (#6190) --- docs/_tests/quotes.html | 2 +- docs/_tests/quotes1.html | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/_tests/quotes.html b/docs/_tests/quotes.html index 71aff8847..f4002ecd1 100644 --- a/docs/_tests/quotes.html +++ b/docs/_tests/quotes.html @@ -273,7 +273,7 @@ Quotes by: GoodReads.com

diff --git a/docs/_tests/quotes1.html b/docs/_tests/quotes1.html index 71aff8847..f4002ecd1 100644 --- a/docs/_tests/quotes1.html +++ b/docs/_tests/quotes1.html @@ -273,7 +273,7 @@ Quotes by: GoodReads.com

From 19022849428573a9bdf5f3217638d6e3c86d1796 Mon Sep 17 00:00:00 2001 From: Chan Sau Yee <15137352+y26805@users.noreply.github.com> Date: Fri, 29 Dec 2023 20:32:51 +0900 Subject: [PATCH 061/119] Update black reference in docs (#6192) --- docs/contributing.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/contributing.rst b/docs/contributing.rst index 2b3249601..d728338da 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -178,7 +178,7 @@ Scrapy: * We use `black `_ 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 black``. + run black 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 40e623b2768598e36c4f367bd166b36fffceb3f6 Mon Sep 17 00:00:00 2001 From: Chan Sau Yee <15137352+y26805@users.noreply.github.com> Date: Fri, 29 Dec 2023 20:33:37 +0900 Subject: [PATCH 062/119] Add type hints (#6191) --- scrapy/pipelines/files.py | 4 +++- scrapy/pipelines/images.py | 7 +++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/scrapy/pipelines/files.py b/scrapy/pipelines/files.py index 5c09ab37e..1990ba825 100644 --- a/scrapy/pipelines/files.py +++ b/scrapy/pipelines/files.py @@ -340,7 +340,9 @@ class FilesPipeline(MediaPipeline): DEFAULT_FILES_URLS_FIELD = "file_urls" DEFAULT_FILES_RESULT_FIELD = "files" - def __init__(self, store_uri, download_func=None, settings=None): + def __init__( + self, store_uri: Union[str, PathLike], download_func=None, settings=None + ): store_uri = _to_string(store_uri) if not store_uri: raise NotConfigured diff --git a/scrapy/pipelines/images.py b/scrapy/pipelines/images.py index 1bd9832a8..02c4b1361 100644 --- a/scrapy/pipelines/images.py +++ b/scrapy/pipelines/images.py @@ -8,7 +8,8 @@ import hashlib import warnings from contextlib import suppress from io import BytesIO -from typing import Dict, Tuple +from os import PathLike +from typing import Dict, Tuple, Union from itemadapter import ItemAdapter @@ -53,7 +54,9 @@ class ImagesPipeline(FilesPipeline): DEFAULT_IMAGES_URLS_FIELD = "image_urls" DEFAULT_IMAGES_RESULT_FIELD = "images" - def __init__(self, store_uri, download_func=None, settings=None): + def __init__( + self, store_uri: Union[str, PathLike], download_func=None, settings=None + ): try: from PIL import Image From badc7c5be9dcfaf7c8acbb87fa530f0477ec3c35 Mon Sep 17 00:00:00 2001 From: Chan Sau Yee <15137352+y26805@users.noreply.github.com> Date: Fri, 29 Dec 2023 20:32:51 +0900 Subject: [PATCH 063/119] Update black reference in docs (#6192) --- docs/contributing.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/contributing.rst b/docs/contributing.rst index 2b3249601..d728338da 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -178,7 +178,7 @@ Scrapy: * We use `black `_ 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 black``. + run black 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 6127f7d27824de1f9847f7bb07f9755c955d9c3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Thu, 28 Dec 2023 12:25:01 +0100 Subject: [PATCH 064/119] Update quotes.toscrape.com page copies (#6190) --- docs/_tests/quotes.html | 2 +- docs/_tests/quotes1.html | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/_tests/quotes.html b/docs/_tests/quotes.html index 71aff8847..f4002ecd1 100644 --- a/docs/_tests/quotes.html +++ b/docs/_tests/quotes.html @@ -273,7 +273,7 @@ Quotes by: GoodReads.com

diff --git a/docs/_tests/quotes1.html b/docs/_tests/quotes1.html index 71aff8847..f4002ecd1 100644 --- a/docs/_tests/quotes1.html +++ b/docs/_tests/quotes1.html @@ -273,7 +273,7 @@ Quotes by: GoodReads.com

From c7b2b097b18c0b30d06cea4b803d5d42aca98715 Mon Sep 17 00:00:00 2001 From: Rotzbua Date: Fri, 5 Jan 2024 10:50:45 +0100 Subject: [PATCH 065/119] fix(typo): correct `successfully` --- tests/test_extension_periodic_log.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_extension_periodic_log.py b/tests/test_extension_periodic_log.py index 502ada6be..b7312bbcd 100644 --- a/tests/test_extension_periodic_log.py +++ b/tests/test_extension_periodic_log.py @@ -67,7 +67,7 @@ def extension(settings=None): class TestPeriodicLog(unittest.TestCase): def test_extension_enabled(self): - # Expected that settings for this extension loaded succesfully + # Expected that settings for this extension loaded successfully # And on certain conditions - extension raising NotConfigured # "PERIODIC_LOG_STATS": True -> set to {"enabled": True} From 0d445a3224ecb0abfdf56a93e181692d3b5e4a6b Mon Sep 17 00:00:00 2001 From: Rotzbua Date: Fri, 5 Jan 2024 12:30:10 +0100 Subject: [PATCH 066/119] refactor(yield): use `yield from` syntax --- scrapy/core/spidermw.py | 3 +-- scrapy/spiders/crawl.py | 3 +-- scrapy/spiders/feed.py | 6 ++---- scrapy/spiders/sitemap.py | 3 +-- scrapy/utils/python.py | 3 +-- scrapy/utils/request.py | 3 +-- tests/spiders.py | 3 +-- tests/test_feedexport.py | 6 ++---- tests/test_spider.py | 3 +-- tests/test_spidermiddleware.py | 9 +++------ tests/test_utils_defer.py | 3 +-- 11 files changed, 15 insertions(+), 30 deletions(-) diff --git a/scrapy/core/spidermw.py b/scrapy/core/spidermw.py index dcf1a6dbc..031a0be36 100644 --- a/scrapy/core/spidermw.py +++ b/scrapy/core/spidermw.py @@ -103,8 +103,7 @@ class SpiderMiddlewareManager(MiddlewareManager): ) -> Union[Generator, AsyncGenerator]: def process_sync(iterable: Iterable) -> Generator: try: - for r in iterable: - yield r + yield from iterable except Exception as ex: exception_result = self._process_spider_exception( response, spider, Failure(ex), exception_processor_index diff --git a/scrapy/spiders/crawl.py b/scrapy/spiders/crawl.py index 31e845716..ebb4f5984 100644 --- a/scrapy/spiders/crawl.py +++ b/scrapy/spiders/crawl.py @@ -131,8 +131,7 @@ class CrawlSpider(Spider): def _handle_failure(self, failure, errback): if errback: results = errback(failure) or () - for request_or_item in iterate_spider_output(results): - yield request_or_item + yield from iterate_spider_output(results) def _compile_rules(self): self._rules = [] diff --git a/scrapy/spiders/feed.py b/scrapy/spiders/feed.py index 6afadc577..47827e442 100644 --- a/scrapy/spiders/feed.py +++ b/scrapy/spiders/feed.py @@ -58,8 +58,7 @@ class XMLFeedSpider(Spider): for selector in nodes: ret = iterate_spider_output(self.parse_node(response, selector)) - for result_item in self.process_results(response, ret): - yield result_item + yield from self.process_results(response, ret) def _parse(self, response, **kwargs): if not hasattr(self, "parse_node"): @@ -133,8 +132,7 @@ class CSVFeedSpider(Spider): response, self.delimiter, self.headers, quotechar=self.quotechar ): ret = iterate_spider_output(self.parse_row(response, row)) - for result_item in self.process_results(response, ret): - yield result_item + yield from self.process_results(response, ret) def _parse(self, response, **kwargs): if not hasattr(self, "parse_row"): diff --git a/scrapy/spiders/sitemap.py b/scrapy/spiders/sitemap.py index aaf75a519..974665fe0 100644 --- a/scrapy/spiders/sitemap.py +++ b/scrapy/spiders/sitemap.py @@ -33,8 +33,7 @@ class SitemapSpider(Spider): attributes, for example, you can filter locs with lastmod greater than a given date (see docs). """ - for entry in entries: - yield entry + yield from entries def _parse_sitemap(self, response): if response.url.endswith("/robots.txt"): diff --git a/scrapy/utils/python.py b/scrapy/utils/python.py index 0b5dc324f..68ca96b69 100644 --- a/scrapy/utils/python.py +++ b/scrapy/utils/python.py @@ -57,8 +57,7 @@ def iflatten(x: Iterable) -> Iterable: Similar to ``.flatten()``, but returns iterator instead""" for el in x: if is_listlike(el): - for el_ in iflatten(el): - yield el_ + yield from iflatten(el) else: yield el diff --git a/scrapy/utils/request.py b/scrapy/utils/request.py index 24fcbd85e..cea1bc727 100644 --- a/scrapy/utils/request.py +++ b/scrapy/utils/request.py @@ -44,8 +44,7 @@ def _serialize_headers( for header in headers: if header in request.headers: yield header - for value in request.headers.getlist(header): - yield value + yield from request.headers.getlist(header) def request_fingerprint( diff --git a/tests/spiders.py b/tests/spiders.py index f29dea2a1..3df153a12 100644 --- a/tests/spiders.py +++ b/tests/spiders.py @@ -301,8 +301,7 @@ class BrokenStartRequestsSpider(FollowAllSpider): def parse(self, response): self.seedsseen.append(response.meta.get("seed")) - for req in super().parse(response): - yield req + yield from super().parse(response) class SingleRequestSpider(MetaSpider): diff --git a/tests/test_feedexport.py b/tests/test_feedexport.py index 89169fd7c..c7d955bc7 100644 --- a/tests/test_feedexport.py +++ b/tests/test_feedexport.py @@ -673,8 +673,7 @@ class FeedExportTestBase(ABC, unittest.TestCase): name = "testspider" def parse(self, response): - for item in items: - yield item + yield from items data = yield self.run_and_export(TestSpider, settings) return data @@ -2696,8 +2695,7 @@ class BatchDeliveriesTest(FeedExportTestBase): name = "testspider" def parse(self, response): - for item in items: - yield item + yield from items with MockServer() as server: TestSpider.start_urls = [server.url("/")] diff --git a/tests/test_spider.py b/tests/test_spider.py index 00da3d485..9ce40f921 100644 --- a/tests/test_spider.py +++ b/tests/test_spider.py @@ -317,8 +317,7 @@ class CrawlSpiderTest(SpiderTest): rules = (Rule(LinkExtractor(), process_links="dummy_process_links"),) def dummy_process_links(self, links): - for link in links: - yield link + yield from links spider = _CrawlSpider() output = list(spider._requests_to_follow(response)) diff --git a/tests/test_spidermiddleware.py b/tests/test_spidermiddleware.py index d167adbb7..38ca8d950 100644 --- a/tests/test_spidermiddleware.py +++ b/tests/test_spidermiddleware.py @@ -170,8 +170,7 @@ class BaseAsyncSpiderMiddlewareTestCase(SpiderMiddlewareTestCase): class ProcessSpiderOutputSimpleMiddleware: def process_spider_output(self, response, result, spider): - for r in result: - yield r + yield from result class ProcessSpiderOutputAsyncGenMiddleware: @@ -182,8 +181,7 @@ class ProcessSpiderOutputAsyncGenMiddleware: class ProcessSpiderOutputUniversalMiddleware: def process_spider_output(self, response, result, spider): - for r in result: - yield r + yield from result async def process_spider_output_async(self, response, result, spider): async for r in result: @@ -324,8 +322,7 @@ class ProcessSpiderOutputInvalidResult(BaseAsyncSpiderMiddlewareTestCase): class ProcessStartRequestsSimpleMiddleware: def process_start_requests(self, start_requests, spider): - for r in start_requests: - yield r + yield from start_requests class ProcessStartRequestsSimple(BaseAsyncSpiderMiddlewareTestCase): diff --git a/tests/test_utils_defer.py b/tests/test_utils_defer.py index bb0ebc2a4..a7d54b565 100644 --- a/tests/test_utils_defer.py +++ b/tests/test_utils_defer.py @@ -107,8 +107,7 @@ class DeferUtilsTest(unittest.TestCase): class IterErrbackTest(unittest.TestCase): def test_iter_errback_good(self): def itergood(): - for x in range(10): - yield x + yield from range(10) errors = [] out = list(iter_errback(itergood(), errors.append)) From 42c481cb4a12a81e88923930e21a661eee967a5f Mon Sep 17 00:00:00 2001 From: Rotzbua Date: Fri, 5 Jan 2024 12:36:36 +0100 Subject: [PATCH 067/119] refactor(): use `OSError` exception https://docs.astral.sh/ruff/rules/os-error-alias/ --- scrapy/pipelines/files.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/scrapy/pipelines/files.py b/scrapy/pipelines/files.py index 1990ba825..73064ad10 100644 --- a/scrapy/pipelines/files.py +++ b/scrapy/pipelines/files.py @@ -8,7 +8,6 @@ import functools import hashlib import logging import mimetypes -import os import time from collections import defaultdict from contextlib import suppress @@ -66,7 +65,7 @@ class FSFilesStore: absolute_path = self._get_filesystem_path(path) try: last_modified = absolute_path.stat().st_mtime - except os.error: + except OSError: return {} with absolute_path.open("rb") as f: From 745b8412f6ec02605c27d295b2be9d71b624cf70 Mon Sep 17 00:00:00 2001 From: Rotzbua Date: Fri, 5 Jan 2024 14:53:51 +0100 Subject: [PATCH 068/119] fix(flake8): lint errors E226 missing whitespace around arithmetic operator E201 whitespace after '{' --- scrapy/extensions/memusage.py | 6 +++--- tests/test_utils_iterators.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/scrapy/extensions/memusage.py b/scrapy/extensions/memusage.py index 221967bda..ca766c938 100644 --- a/scrapy/extensions/memusage.py +++ b/scrapy/extensions/memusage.py @@ -128,9 +128,9 @@ class MemoryUsage: def _send_report(self, rcpts, subject): """send notification mail with some additional useful info""" stats = self.crawler.stats - s = f"Memory usage at engine startup : {stats.get_value('memusage/startup')/1024/1024}M\r\n" - s += f"Maximum memory usage : {stats.get_value('memusage/max')/1024/1024}M\r\n" - s += f"Current memory usage : {self.get_virtual_size()/1024/1024}M\r\n" + s = f"Memory usage at engine startup : {stats.get_value('memusage/startup') / 1024 / 1024}M\r\n" + s += f"Maximum memory usage : {stats.get_value('memusage/max') / 1024 / 1024}M\r\n" + s += f"Current memory usage : {self.get_virtual_size() / 1024 / 1024}M\r\n" s += ( "ENGINE STATUS ------------------------------------------------------- \r\n" diff --git a/tests/test_utils_iterators.py b/tests/test_utils_iterators.py index 3598fa0bb..4a0c34d82 100644 --- a/tests/test_utils_iterators.py +++ b/tests/test_utils_iterators.py @@ -524,6 +524,6 @@ class TestHelper(unittest.TestCase): def _assert_type_and_value(self, a, b, obj): self.assertTrue( - type(a) is type(b), f"Got {type(a)}, expected {type(b)} for { obj!r}" + type(a) is type(b), f"Got {type(a)}, expected {type(b)} for {obj!r}" ) self.assertEqual(a, b) From 68fccb1d58f291f70e864fd8ecd167887bda4112 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Sat, 6 Jan 2024 01:35:56 +0400 Subject: [PATCH 069/119] Fix and re-enable newer mitmproxy usage in tests. --- tests/test_proxy_connect.py | 3 ++- tox.ini | 8 ++------ 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/tests/test_proxy_connect.py b/tests/test_proxy_connect.py index dc0a82086..46d42e9f6 100644 --- a/tests/test_proxy_connect.py +++ b/tests/test_proxy_connect.py @@ -31,6 +31,7 @@ sys.exit(mitmdump()) self.proc = Popen( [ sys.executable, + "-u", "-c", script, "--listen-host", @@ -46,7 +47,7 @@ sys.exit(mitmdump()) stdout=PIPE, ) line = self.proc.stdout.readline().decode("utf-8") - host_port = re.search(r"listening at http://([^:]+:\d+)", line).group(1) + host_port = re.search(r"listening at (?:http://)?([^:]+:\d+)", line).group(1) address = f"http://{self.auth_user}:{self.auth_pass}@{host_port}" return address diff --git a/tox.ini b/tox.ini index 932c0b805..d9dcacc01 100644 --- a/tox.ini +++ b/tox.ini @@ -11,11 +11,7 @@ minversion = 1.7.0 deps = -rtests/requirements.txt # mitmproxy does not support PyPy - # Python 3.9+ requires mitmproxy >= 5.3.0 - # mitmproxy >= 5.3.0 requires h2 >= 4.0, Twisted 21.2 requires h2 < 4.0 - #mitmproxy >= 5.3.0; python_version >= '3.9' and implementation_name != 'pypy' - # The tests hang with mitmproxy 8.0.0: https://github.com/scrapy/scrapy/issues/5454 - mitmproxy >= 4.0.4, < 8; python_version < '3.9' and implementation_name != 'pypy' + mitmproxy; implementation_name != 'pypy' passenv = S3_TEST_FILE_URI AWS_ACCESS_KEY_ID @@ -87,7 +83,7 @@ deps = lxml==4.4.1 -rtests/requirements.txt - # mitmproxy 4.0.4+ requires upgrading some of the pinned dependencies + # mitmproxy 8.0.0 requires upgrading some of the pinned dependencies # above, hence we do not install it in pinned environments at the moment setenv = _SCRAPY_PINNED=true From c2baf4d0dad5f656e51dce6e00118fbc0419d0db Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Fri, 12 Jan 2024 18:30:41 +0400 Subject: [PATCH 070/119] Remove a defer.returnValue call. --- tests/test_feedexport.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_feedexport.py b/tests/test_feedexport.py index c7d955bc7..277555608 100644 --- a/tests/test_feedexport.py +++ b/tests/test_feedexport.py @@ -2299,7 +2299,7 @@ class BatchDeliveriesTest(FeedExportTestBase): content[feed["format"]].append(file.read_bytes()) finally: self.tearDown() - defer.returnValue(content) + return content @defer.inlineCallbacks def assertExportedJsonLines(self, items, rows, settings=None): From fa0c598096de6e26a7b22e7d53cf8c073f96f3a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Mon, 15 Jan 2024 13:14:02 +0100 Subject: [PATCH 071/119] Add component getters to Crawler (#6181) --- scrapy/crawler.py | 42 +++++ tests/test_crawler.py | 388 ++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 419 insertions(+), 11 deletions(-) diff --git a/scrapy/crawler.py b/scrapy/crawler.py index 844d5f759..1db9ace28 100644 --- a/scrapy/crawler.py +++ b/scrapy/crawler.py @@ -178,6 +178,48 @@ class Crawler: assert self.engine yield maybeDeferred(self.engine.stop) + @staticmethod + def _get_component(component_class, components): + for component in components: + if isinstance(component, component_class): + return component + return None + + def get_addon(self, cls): + return self._get_component(cls, self.addons.addons) + + def get_downloader_middleware(self, cls): + if not self.engine: + raise RuntimeError( + "Crawler.get_downloader_middleware() can only be called after " + "the crawl engine has been created." + ) + return self._get_component(cls, self.engine.downloader.middleware.middlewares) + + def get_extension(self, cls): + if not self.extensions: + raise RuntimeError( + "Crawler.get_extension() can only be called after the " + "extension manager has been created." + ) + return self._get_component(cls, self.extensions.middlewares) + + def get_item_pipeline(self, cls): + if not self.engine: + raise RuntimeError( + "Crawler.get_item_pipeline() can only be called after the " + "crawl engine has been created." + ) + return self._get_component(cls, self.engine.scraper.itemproc.middlewares) + + def get_spider_middleware(self, cls): + if not self.engine: + raise RuntimeError( + "Crawler.get_spider_middleware() can only be called after the " + "crawl engine has been created." + ) + return self._get_component(cls, self.engine.scraper.spidermw.middlewares) + class CrawlerRunner: """ diff --git a/tests/test_crawler.py b/tests/test_crawler.py index 92bd5f38f..989208694 100644 --- a/tests/test_crawler.py +++ b/tests/test_crawler.py @@ -12,12 +12,13 @@ import pytest from packaging.version import parse as parse_version from pexpect.popen_spawn import PopenSpawn from pytest import mark, raises -from twisted.internet import defer +from twisted.internet.defer import Deferred, inlineCallbacks from twisted.trial import unittest from w3lib import __version__ as w3lib_version from zope.interface.exceptions import MultipleInvalid import scrapy +from scrapy import Spider from scrapy.crawler import Crawler, CrawlerProcess, CrawlerRunner from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.extensions import telnet @@ -29,6 +30,19 @@ from scrapy.utils.spider import DefaultSpider from scrapy.utils.test import get_crawler from tests.mockserver import MockServer, get_mockserver_env +# To prevent warnings. +BASE_SETTINGS = { + "REQUEST_FINGERPRINTER_IMPLEMENTATION": "2.7", +} + + +def get_raw_crawler(spidercls=None, settings_dict=None): + """get_crawler alternative that only calls the __init__ method of the + crawler.""" + settings = Settings() + settings.setdict(settings_dict or {}) + return Crawler(spidercls or DefaultSpider, settings) + class BaseCrawlerTest(unittest.TestCase): def assertOptionIsDefault(self, settings, key): @@ -39,7 +53,7 @@ class BaseCrawlerTest(unittest.TestCase): class CrawlerTestCase(BaseCrawlerTest): def test_populate_spidercls_settings(self): spider_settings = {"TEST1": "spider", "TEST2": "spider"} - project_settings = {"TEST1": "project", "TEST3": "project"} + project_settings = {**BASE_SETTINGS, "TEST1": "project", "TEST3": "project"} class CustomSettingsSpider(DefaultSpider): custom_settings = spider_settings @@ -71,9 +85,9 @@ class CrawlerTestCase(BaseCrawlerTest): with raises(ValueError): Crawler(DefaultSpider()) - @defer.inlineCallbacks + @inlineCallbacks def test_crawler_crawl_twice_deprecated(self): - crawler = Crawler(NoRequestsSpider) + crawler = get_raw_crawler(NoRequestsSpider, BASE_SETTINGS) yield crawler.crawl() with pytest.warns( ScrapyDeprecationWarning, @@ -81,6 +95,358 @@ class CrawlerTestCase(BaseCrawlerTest): ): yield crawler.crawl() + def test_get_addon(self): + class ParentAddon: + pass + + class TrackingAddon(ParentAddon): + instances = [] + + def __init__(self): + TrackingAddon.instances.append(self) + + def update_settings(self, settings): + pass + + settings = { + **BASE_SETTINGS, + "ADDONS": { + TrackingAddon: 0, + }, + } + crawler = get_crawler(settings_dict=settings) + self.assertEqual(len(TrackingAddon.instances), 1) + expected = TrackingAddon.instances[-1] + + addon = crawler.get_addon(TrackingAddon) + self.assertEqual(addon, expected) + + addon = crawler.get_addon(DefaultSpider) + self.assertIsNone(addon) + + addon = crawler.get_addon(ParentAddon) + self.assertEqual(addon, expected) + + class ChildAddon(TrackingAddon): + pass + + addon = crawler.get_addon(ChildAddon) + self.assertIsNone(addon) + + @inlineCallbacks + def test_get_downloader_middleware(self): + class ParentDownloaderMiddleware: + pass + + class TrackingDownloaderMiddleware(ParentDownloaderMiddleware): + instances = [] + + def __init__(self): + TrackingDownloaderMiddleware.instances.append(self) + + class MySpider(Spider): + name = "myspider" + + @classmethod + def from_crawler(cls, crawler): + return cls(crawler=crawler) + + def __init__(self, crawler): + self.crawler = crawler + + def start_requests(self): + MySpider.result = crawler.get_downloader_middleware(MySpider.cls) + return + yield + + settings = { + **BASE_SETTINGS, + "DOWNLOADER_MIDDLEWARES": { + TrackingDownloaderMiddleware: 0, + }, + } + + crawler = get_raw_crawler(MySpider, settings) + MySpider.cls = TrackingDownloaderMiddleware + yield crawler.crawl() + self.assertEqual(len(TrackingDownloaderMiddleware.instances), 1) + self.assertEqual(MySpider.result, TrackingDownloaderMiddleware.instances[-1]) + + crawler = get_raw_crawler(MySpider, settings) + MySpider.cls = DefaultSpider + yield crawler.crawl() + self.assertIsNone(MySpider.result) + + crawler = get_raw_crawler(MySpider, settings) + MySpider.cls = ParentDownloaderMiddleware + yield crawler.crawl() + self.assertEqual(MySpider.result, TrackingDownloaderMiddleware.instances[-1]) + + class ChildDownloaderMiddleware(TrackingDownloaderMiddleware): + pass + + crawler = get_raw_crawler(MySpider, settings) + MySpider.cls = ChildDownloaderMiddleware + yield crawler.crawl() + self.assertIsNone(MySpider.result) + + def test_get_downloader_middleware_not_crawling(self): + crawler = get_raw_crawler(settings_dict=BASE_SETTINGS) + self.assertRaises( + RuntimeError, crawler.get_downloader_middleware, DefaultSpider + ) + + @inlineCallbacks + def test_get_downloader_middleware_no_engine(self): + class MySpider(Spider): + name = "myspider" + + @classmethod + def from_crawler(cls, crawler): + try: + crawler.get_downloader_middleware(DefaultSpider) + except Exception as e: + MySpider.result = e + raise + + crawler = get_raw_crawler(MySpider, BASE_SETTINGS) + with raises(RuntimeError): + yield crawler.crawl() + + @inlineCallbacks + def test_get_extension(self): + class ParentExtension: + pass + + class TrackingExtension(ParentExtension): + instances = [] + + def __init__(self): + TrackingExtension.instances.append(self) + + class MySpider(Spider): + name = "myspider" + + @classmethod + def from_crawler(cls, crawler): + return cls(crawler=crawler) + + def __init__(self, crawler): + self.crawler = crawler + + def start_requests(self): + MySpider.result = crawler.get_extension(MySpider.cls) + return + yield + + settings = { + **BASE_SETTINGS, + "EXTENSIONS": { + TrackingExtension: 0, + }, + } + + crawler = get_raw_crawler(MySpider, settings) + MySpider.cls = TrackingExtension + yield crawler.crawl() + self.assertEqual(len(TrackingExtension.instances), 1) + self.assertEqual(MySpider.result, TrackingExtension.instances[-1]) + + crawler = get_raw_crawler(MySpider, settings) + MySpider.cls = DefaultSpider + yield crawler.crawl() + self.assertIsNone(MySpider.result) + + crawler = get_raw_crawler(MySpider, settings) + MySpider.cls = ParentExtension + yield crawler.crawl() + self.assertEqual(MySpider.result, TrackingExtension.instances[-1]) + + class ChildExtension(TrackingExtension): + pass + + crawler = get_raw_crawler(MySpider, settings) + MySpider.cls = ChildExtension + yield crawler.crawl() + self.assertIsNone(MySpider.result) + + def test_get_extension_not_crawling(self): + crawler = get_raw_crawler(settings_dict=BASE_SETTINGS) + self.assertRaises(RuntimeError, crawler.get_extension, DefaultSpider) + + @inlineCallbacks + def test_get_extension_no_engine(self): + class MySpider(Spider): + name = "myspider" + + @classmethod + def from_crawler(cls, crawler): + try: + crawler.get_extension(DefaultSpider) + except Exception as e: + MySpider.result = e + raise + + crawler = get_raw_crawler(MySpider, BASE_SETTINGS) + with raises(RuntimeError): + yield crawler.crawl() + + @inlineCallbacks + def test_get_item_pipeline(self): + class ParentItemPipeline: + pass + + class TrackingItemPipeline(ParentItemPipeline): + instances = [] + + def __init__(self): + TrackingItemPipeline.instances.append(self) + + class MySpider(Spider): + name = "myspider" + + @classmethod + def from_crawler(cls, crawler): + return cls(crawler=crawler) + + def __init__(self, crawler): + self.crawler = crawler + + def start_requests(self): + MySpider.result = crawler.get_item_pipeline(MySpider.cls) + return + yield + + settings = { + **BASE_SETTINGS, + "ITEM_PIPELINES": { + TrackingItemPipeline: 0, + }, + } + + crawler = get_raw_crawler(MySpider, settings) + MySpider.cls = TrackingItemPipeline + yield crawler.crawl() + self.assertEqual(len(TrackingItemPipeline.instances), 1) + self.assertEqual(MySpider.result, TrackingItemPipeline.instances[-1]) + + crawler = get_raw_crawler(MySpider, settings) + MySpider.cls = DefaultSpider + yield crawler.crawl() + self.assertIsNone(MySpider.result) + + crawler = get_raw_crawler(MySpider, settings) + MySpider.cls = ParentItemPipeline + yield crawler.crawl() + self.assertEqual(MySpider.result, TrackingItemPipeline.instances[-1]) + + class ChildItemPipeline(TrackingItemPipeline): + pass + + crawler = get_raw_crawler(MySpider, settings) + MySpider.cls = ChildItemPipeline + yield crawler.crawl() + self.assertIsNone(MySpider.result) + + def test_get_item_pipeline_not_crawling(self): + crawler = get_raw_crawler(settings_dict=BASE_SETTINGS) + self.assertRaises(RuntimeError, crawler.get_item_pipeline, DefaultSpider) + + @inlineCallbacks + def test_get_item_pipeline_no_engine(self): + class MySpider(Spider): + name = "myspider" + + @classmethod + def from_crawler(cls, crawler): + try: + crawler.get_item_pipeline(DefaultSpider) + except Exception as e: + MySpider.result = e + raise + + crawler = get_raw_crawler(MySpider, BASE_SETTINGS) + with raises(RuntimeError): + yield crawler.crawl() + + @inlineCallbacks + def test_get_spider_middleware(self): + class ParentSpiderMiddleware: + pass + + class TrackingSpiderMiddleware(ParentSpiderMiddleware): + instances = [] + + def __init__(self): + TrackingSpiderMiddleware.instances.append(self) + + class MySpider(Spider): + name = "myspider" + + @classmethod + def from_crawler(cls, crawler): + return cls(crawler=crawler) + + def __init__(self, crawler): + self.crawler = crawler + + def start_requests(self): + MySpider.result = crawler.get_spider_middleware(MySpider.cls) + return + yield + + settings = { + **BASE_SETTINGS, + "SPIDER_MIDDLEWARES": { + TrackingSpiderMiddleware: 0, + }, + } + + crawler = get_raw_crawler(MySpider, settings) + MySpider.cls = TrackingSpiderMiddleware + yield crawler.crawl() + self.assertEqual(len(TrackingSpiderMiddleware.instances), 1) + self.assertEqual(MySpider.result, TrackingSpiderMiddleware.instances[-1]) + + crawler = get_raw_crawler(MySpider, settings) + MySpider.cls = DefaultSpider + yield crawler.crawl() + self.assertIsNone(MySpider.result) + + crawler = get_raw_crawler(MySpider, settings) + MySpider.cls = ParentSpiderMiddleware + yield crawler.crawl() + self.assertEqual(MySpider.result, TrackingSpiderMiddleware.instances[-1]) + + class ChildSpiderMiddleware(TrackingSpiderMiddleware): + pass + + crawler = get_raw_crawler(MySpider, settings) + MySpider.cls = ChildSpiderMiddleware + yield crawler.crawl() + self.assertIsNone(MySpider.result) + + def test_get_spider_middleware_not_crawling(self): + crawler = get_raw_crawler(settings_dict=BASE_SETTINGS) + self.assertRaises(RuntimeError, crawler.get_spider_middleware, DefaultSpider) + + @inlineCallbacks + def test_get_spider_middleware_no_engine(self): + class MySpider(Spider): + name = "myspider" + + @classmethod + def from_crawler(cls, crawler): + try: + crawler.get_spider_middleware(DefaultSpider) + except Exception as e: + MySpider.result = e + raise + + crawler = get_raw_crawler(MySpider, BASE_SETTINGS) + with raises(RuntimeError): + yield crawler.crawl() + class SpiderSettingsTestCase(unittest.TestCase): def test_spider_custom_settings(self): @@ -223,20 +589,20 @@ class CrawlerRunnerHasSpider(unittest.TestCase): def _runner(self): return CrawlerRunner({"REQUEST_FINGERPRINTER_IMPLEMENTATION": "2.7"}) - @defer.inlineCallbacks + @inlineCallbacks def test_crawler_runner_bootstrap_successful(self): runner = self._runner() yield runner.crawl(NoRequestsSpider) self.assertFalse(runner.bootstrap_failed) - @defer.inlineCallbacks + @inlineCallbacks def test_crawler_runner_bootstrap_successful_for_several(self): runner = self._runner() yield runner.crawl(NoRequestsSpider) yield runner.crawl(NoRequestsSpider) self.assertFalse(runner.bootstrap_failed) - @defer.inlineCallbacks + @inlineCallbacks def test_crawler_runner_bootstrap_failed(self): runner = self._runner() @@ -249,7 +615,7 @@ class CrawlerRunnerHasSpider(unittest.TestCase): self.assertTrue(runner.bootstrap_failed) - @defer.inlineCallbacks + @inlineCallbacks def test_crawler_runner_bootstrap_failed_for_several(self): runner = self._runner() @@ -264,7 +630,7 @@ class CrawlerRunnerHasSpider(unittest.TestCase): self.assertTrue(runner.bootstrap_failed) - @defer.inlineCallbacks + @inlineCallbacks def test_crawler_runner_asyncio_enabled_true(self): if self.reactor_pytest == "asyncio": CrawlerRunner( @@ -531,7 +897,7 @@ class CrawlerProcessSubprocess(ScriptRunnerMixin, unittest.TestCase): p.expect_exact("Spider closed (shutdown)") p.wait() - @defer.inlineCallbacks + @inlineCallbacks def test_shutdown_forced(self): from twisted.internet import reactor @@ -543,7 +909,7 @@ class CrawlerProcessSubprocess(ScriptRunnerMixin, unittest.TestCase): p.kill(sig) p.expect_exact("shutting down gracefully") # sending the second signal too fast often causes problems - d = defer.Deferred() + d = Deferred() reactor.callLater(0.1, d.callback, None) yield d p.kill(sig) From e8dadb959219afea1d3a3f67ce03ac3c7a51520c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Mon, 15 Jan 2024 13:37:03 +0100 Subject: [PATCH 072/119] scrapy parse: fix the signature of callbacks from the CLI (#6182) --- scrapy/commands/parse.py | 59 ++++++++++++++++++++----------------- tests/test_command_check.py | 4 +-- tests/test_command_parse.py | 18 ++++++++++- 3 files changed, 51 insertions(+), 30 deletions(-) diff --git a/scrapy/commands/parse.py b/scrapy/commands/parse.py index ac937e464..c9f8586d3 100644 --- a/scrapy/commands/parse.py +++ b/scrapy/commands/parse.py @@ -1,3 +1,4 @@ +import functools import inspect import json import logging @@ -251,39 +252,40 @@ class Command(BaseRunSpiderCommand): return scraped_data + def _get_callback(self, *, spider, opts, response=None): + cb = None + if response: + cb = response.meta["_callback"] + if not cb: + if opts.callback: + cb = opts.callback + elif response and opts.rules and self.first_response == response: + cb = self.get_callback_from_rules(spider, response) + if not cb: + raise ValueError( + f"Cannot find a rule that matches {response.url!r} in spider: " + f"{spider.name}" + ) + else: + cb = "parse" + + if not callable(cb): + cb_method = getattr(spider, cb, None) + if callable(cb_method): + cb = cb_method + else: + raise ValueError( + f"Cannot find callback {cb!r} in spider: {spider.name}" + ) + return cb + def prepare_request(self, spider, request, opts): def callback(response, **cb_kwargs): # memorize first request if not self.first_response: self.first_response = response - # determine real callback - cb = response.meta["_callback"] - if not cb: - if opts.callback: - cb = opts.callback - elif opts.rules and self.first_response == response: - cb = self.get_callback_from_rules(spider, response) - - if not cb: - logger.error( - "Cannot find a rule that matches %(url)r in spider: %(spider)s", - {"url": response.url, "spider": spider.name}, - ) - return - else: - cb = "parse" - - if not callable(cb): - cb_method = getattr(spider, cb, None) - if callable(cb_method): - cb = cb_method - else: - logger.error( - "Cannot find callback %(callback)r in spider: %(spider)s", - {"callback": cb, "spider": spider.name}, - ) - return + cb = self._get_callback(spider=spider, opts=opts, response=response) # parse items and requests depth = response.meta["_depth"] @@ -303,6 +305,9 @@ class Command(BaseRunSpiderCommand): request.meta["_depth"] = 1 request.meta["_callback"] = request.callback + if not request.callback and not opts.rules: + cb = self._get_callback(spider=spider, opts=opts) + functools.update_wrapper(callback, cb) request.callback = callback return request diff --git a/tests/test_command_check.py b/tests/test_command_check.py index 129ef0121..592494aba 100644 --- a/tests/test_command_check.py +++ b/tests/test_command_check.py @@ -16,11 +16,11 @@ import scrapy class CheckSpider(scrapy.Spider): name = '{self.spider_name}' - start_urls = ['http://toscrape.com'] + start_urls = ['data:,'] def parse(self, response, **cb_kwargs): \"\"\" - @url http://toscrape.com + @url data:, {contracts} \"\"\" {parse_def} diff --git a/tests/test_command_parse.py b/tests/test_command_parse.py index 037333c03..9356d6b79 100644 --- a/tests/test_command_parse.py +++ b/tests/test_command_parse.py @@ -78,9 +78,21 @@ class AsyncDefAsyncioGenExcSpider(scrapy.Spider): if i > 5: raise ValueError("Stopping the processing") +class CallbackSignatureDownloaderMiddleware: + def process_request(self, request, spider): + from inspect import signature + spider.logger.debug(f"request.callback signature: {{signature(request.callback)}}") + + class MySpider(scrapy.Spider): name = '{self.spider_name}' + custom_settings = {{ + "DOWNLOADER_MIDDLEWARES": {{ + CallbackSignatureDownloaderMiddleware: 0, + }} + }} + def parse(self, response): if getattr(self, 'test_arg', None): self.logger.debug('It Works!') @@ -220,7 +232,11 @@ ITEM_PIPELINES = {{'{self.project_name}.pipelines.MyPipeline': 1}} self.url("/html"), ] ) - self.assertIn("DEBUG: It Works!", _textmode(stderr)) + log = _textmode(stderr) + self.assertIn("DEBUG: It Works!", log) + self.assertIn( + "DEBUG: request.callback signature: (response, foo=None, key=None)", log + ) @defer.inlineCallbacks def test_request_without_meta(self): From d5233bb57f35c54b0c03981dc59c7328ca44cb9a Mon Sep 17 00:00:00 2001 From: Rotzbua Date: Mon, 15 Jan 2024 14:11:33 +0100 Subject: [PATCH 073/119] chore(docs): update `sphinx` dependencies (#6200) --- docs/requirements.txt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/requirements.txt b/docs/requirements.txt index 9f9aef711..5f683d34c 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,4 +1,4 @@ -sphinx==5.0.2 -sphinx-hoverxref==1.1.1 -sphinx-notfound-page==0.8 -sphinx-rtd-theme==1.0.0 +sphinx==6.2.1 +sphinx-hoverxref==1.3.0 +sphinx-notfound-page==1.0.0 +sphinx-rtd-theme==2.0.0 From 09a7efef7c75558c9ea198a00fc11ab26fb16ce5 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Fri, 12 Jan 2024 18:30:41 +0400 Subject: [PATCH 074/119] Remove a defer.returnValue call. --- tests/test_feedexport.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_feedexport.py b/tests/test_feedexport.py index 56967c0d5..ae5810fb8 100644 --- a/tests/test_feedexport.py +++ b/tests/test_feedexport.py @@ -2300,7 +2300,7 @@ class BatchDeliveriesTest(FeedExportTestBase): content[feed["format"]].append(file.read_bytes()) finally: self.tearDown() - defer.returnValue(content) + return content @defer.inlineCallbacks def assertExportedJsonLines(self, items, rows, settings=None): From 88285e75b6b7a22689208c2d321a1eda60b64003 Mon Sep 17 00:00:00 2001 From: Kevin Toms Date: Wed, 17 Jan 2024 10:05:22 -0500 Subject: [PATCH 075/119] Add FAQ on making a blank request --- docs/faq.rst | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/docs/faq.rst b/docs/faq.rst index 20dd814df..9df4490d4 100644 --- a/docs/faq.rst +++ b/docs/faq.rst @@ -405,6 +405,25 @@ or :class:`~scrapy.signals.headers_received` signals and raising a :ref:`topics-stop-response-download` topic for additional information and examples. +.. _faq-blank-request: + +How can I make a blank request? +------------------------------- + +.. code-block:: python + + from scrapy import Request + + yield Request( + url="data:,", + callback=self.your_call_back, + ) + +In this case, the URL is set to a data URI scheme. Data URLs allow you to include data +in-line in web pages as if they were external resources. The "data:" scheme with an empty +content (",") essentially creates a request to a data URL without any specific content. + + Running ``runspider`` I get ``error: No spider found in file: `` -------------------------------------------------------------------------- From 46f94ec9cb0f480999f018ceab4a5751abaf180e Mon Sep 17 00:00:00 2001 From: Kevin Toms Date: Wed, 17 Jan 2024 15:49:51 -0500 Subject: [PATCH 076/119] Fix test Wrap the yield line in a function to prevent throwing error when the code snippet is executed --- docs/faq.rst | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/faq.rst b/docs/faq.rst index 9df4490d4..0282fc6e2 100644 --- a/docs/faq.rst +++ b/docs/faq.rst @@ -414,10 +414,11 @@ How can I make a blank request? from scrapy import Request - yield Request( - url="data:,", - callback=self.your_call_back, - ) + def make_blank_request(your_call_back): + yield Request( + url="data:,", + callback=your_call_back, + ) In this case, the URL is set to a data URI scheme. Data URLs allow you to include data in-line in web pages as if they were external resources. The "data:" scheme with an empty From 9074c16497bde04f5d561df1d584abe2cc73f183 Mon Sep 17 00:00:00 2001 From: Kevin Toms Date: Thu, 18 Jan 2024 09:36:25 -0500 Subject: [PATCH 077/119] make suggestion --- docs/faq.rst | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/docs/faq.rst b/docs/faq.rst index 0282fc6e2..2113b0964 100644 --- a/docs/faq.rst +++ b/docs/faq.rst @@ -414,11 +414,8 @@ How can I make a blank request? from scrapy import Request - def make_blank_request(your_call_back): - yield Request( - url="data:,", - callback=your_call_back, - ) + + blank_request = Request("data:,") In this case, the URL is set to a data URI scheme. Data URLs allow you to include data in-line in web pages as if they were external resources. The "data:" scheme with an empty From 2487e3cc035e490777b921badc84c11b9fb77b20 Mon Sep 17 00:00:00 2001 From: Laerte Pereira Date: Wed, 31 Jan 2024 10:05:50 -0300 Subject: [PATCH 078/119] Cleanup deprecated fingerprint code in scrapy.utils.request --- scrapy/settings/default_settings.py | 2 +- scrapy/utils/request.py | 143 +-------------- tests/test_utils_request.py | 262 +--------------------------- 3 files changed, 6 insertions(+), 401 deletions(-) diff --git a/scrapy/settings/default_settings.py b/scrapy/settings/default_settings.py index d6b3585e2..02494bad0 100644 --- a/scrapy/settings/default_settings.py +++ b/scrapy/settings/default_settings.py @@ -260,7 +260,7 @@ REFERER_ENABLED = True REFERRER_POLICY = "scrapy.spidermiddlewares.referer.DefaultReferrerPolicy" REQUEST_FINGERPRINTER_CLASS = "scrapy.utils.request.RequestFingerprinter" -REQUEST_FINGERPRINTER_IMPLEMENTATION = "2.6" +REQUEST_FINGERPRINTER_IMPLEMENTATION = "2.7" RETRY_ENABLED = True RETRY_TIMES = 2 # initial response + 2 retries = 3 requests diff --git a/scrapy/utils/request.py b/scrapy/utils/request.py index cea1bc727..b230cd214 100644 --- a/scrapy/utils/request.py +++ b/scrapy/utils/request.py @@ -5,7 +5,6 @@ scrapy.http.Request objects import hashlib import json -import warnings from typing import ( TYPE_CHECKING, Any, @@ -26,7 +25,6 @@ from w3lib.http import basic_auth_header from w3lib.url import canonicalize_url from scrapy import Request, Spider -from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.utils.httpobj import urlparse_cached from scrapy.utils.misc import load_object from scrapy.utils.python import to_bytes, to_unicode @@ -34,9 +32,6 @@ from scrapy.utils.python import to_bytes, to_unicode if TYPE_CHECKING: from scrapy.crawler import Crawler -_deprecated_fingerprint_cache: "WeakKeyDictionary[Request, Dict[Tuple[Optional[Tuple[bytes, ...]], bool], str]]" -_deprecated_fingerprint_cache = WeakKeyDictionary() - def _serialize_headers( headers: Iterable[bytes], request: Request @@ -47,120 +42,6 @@ def _serialize_headers( yield from request.headers.getlist(header) -def request_fingerprint( - request: Request, - include_headers: Optional[Iterable[Union[bytes, str]]] = None, - keep_fragments: bool = False, -) -> str: - """ - Return the request fingerprint as an hexadecimal string. - - The request fingerprint is a hash that uniquely identifies the resource the - request points to. For example, take the following two urls: - - http://www.example.com/query?id=111&cat=222 - http://www.example.com/query?cat=222&id=111 - - Even though those are two different URLs both point to the same resource - and are equivalent (i.e. they should return the same response). - - Another example are cookies used to store session ids. Suppose the - following page is only accessible to authenticated users: - - http://www.example.com/members/offers.html - - Lots of sites use a cookie to store the session id, which adds a random - component to the HTTP Request and thus should be ignored when calculating - the fingerprint. - - For this reason, request headers are ignored by default when calculating - the fingerprint. If you want to include specific headers use the - include_headers argument, which is a list of Request headers to include. - - Also, servers usually ignore fragments in urls when handling requests, - so they are also ignored by default when calculating the fingerprint. - If you want to include them, set the keep_fragments argument to True - (for instance when handling requests with a headless browser). - """ - if include_headers or keep_fragments: - message = ( - "Call to deprecated function " - "scrapy.utils.request.request_fingerprint().\n" - "\n" - "If you are using this function in a Scrapy component because you " - "need a non-default fingerprinting algorithm, and you are OK " - "with that non-default fingerprinting algorithm being used by " - "all Scrapy components and not just the one calling this " - "function, use crawler.request_fingerprinter.fingerprint() " - "instead in your Scrapy component (you can get the crawler " - "object from the 'from_crawler' class method), and use the " - "'REQUEST_FINGERPRINTER_CLASS' setting to configure your " - "non-default fingerprinting algorithm.\n" - "\n" - "Otherwise, consider using the " - "scrapy.utils.request.fingerprint() function instead.\n" - "\n" - "If you switch to 'fingerprint()', or assign the " - "'REQUEST_FINGERPRINTER_CLASS' setting a class that uses " - "'fingerprint()', the generated fingerprints will not only be " - "bytes instead of a string, but they will also be different from " - "those generated by 'request_fingerprint()'. Before you switch, " - "make sure that you understand the consequences of this (e.g. " - "cache invalidation) and are OK with them; otherwise, consider " - "implementing your own function which returns the same " - "fingerprints as the deprecated 'request_fingerprint()' function." - ) - else: - message = ( - "Call to deprecated function " - "scrapy.utils.request.request_fingerprint().\n" - "\n" - "If you are using this function in a Scrapy component, and you " - "are OK with users of your component changing the fingerprinting " - "algorithm through settings, use " - "crawler.request_fingerprinter.fingerprint() instead in your " - "Scrapy component (you can get the crawler object from the " - "'from_crawler' class method).\n" - "\n" - "Otherwise, consider using the " - "scrapy.utils.request.fingerprint() function instead.\n" - "\n" - "Either way, the resulting fingerprints will be returned as " - "bytes, not as a string, and they will also be different from " - "those generated by 'request_fingerprint()'. Before you switch, " - "make sure that you understand the consequences of this (e.g. " - "cache invalidation) and are OK with them; otherwise, consider " - "implementing your own function which returns the same " - "fingerprints as the deprecated 'request_fingerprint()' function." - ) - warnings.warn(message, category=ScrapyDeprecationWarning, stacklevel=2) - processed_include_headers: Optional[Tuple[bytes, ...]] = None - if include_headers: - processed_include_headers = tuple( - to_bytes(h.lower()) for h in sorted(include_headers) - ) - cache = _deprecated_fingerprint_cache.setdefault(request, {}) - cache_key = (processed_include_headers, keep_fragments) - if cache_key not in cache: - fp = hashlib.sha1() - fp.update(to_bytes(request.method)) - fp.update( - to_bytes(canonicalize_url(request.url, keep_fragments=keep_fragments)) - ) - fp.update(request.body or b"") - if processed_include_headers: - for part in _serialize_headers(processed_include_headers, request): - fp.update(part) - cache[cache_key] = fp.hexdigest() - return cache[cache_key] - - -def _request_fingerprint_as_bytes(*args: Any, **kwargs: Any) -> bytes: - with warnings.catch_warnings(): - warnings.simplefilter("ignore") - return bytes.fromhex(request_fingerprint(*args, **kwargs)) - - _fingerprint_cache: "WeakKeyDictionary[Request, Dict[Tuple[Optional[Tuple[bytes, ...]], bool], bytes]]" _fingerprint_cache = WeakKeyDictionary() @@ -258,32 +139,14 @@ class RequestFingerprinter: "REQUEST_FINGERPRINTER_IMPLEMENTATION" ) else: - implementation = "2.6" - if implementation == "2.6": - message = ( - "'2.6' is a deprecated value for the " - "'REQUEST_FINGERPRINTER_IMPLEMENTATION' setting.\n" - "\n" - "It is also the default value. In other words, it is normal " - "to get this warning if you have not defined a value for the " - "'REQUEST_FINGERPRINTER_IMPLEMENTATION' setting. This is so " - "for backward compatibility reasons, but it will change in a " - "future version of Scrapy.\n" - "\n" - "See the documentation of the " - "'REQUEST_FINGERPRINTER_IMPLEMENTATION' setting for " - "information on how to handle this deprecation." - ) - warnings.warn(message, category=ScrapyDeprecationWarning, stacklevel=2) - self._fingerprint = _request_fingerprint_as_bytes - elif implementation == "2.7": + implementation = "2.7" + if implementation == "2.7": self._fingerprint = fingerprint else: raise ValueError( f"Got an invalid value on setting " f"'REQUEST_FINGERPRINTER_IMPLEMENTATION': " - f"{implementation!r}. Valid values are '2.6' (deprecated) " - f"and '2.7'." + f"{implementation!r}. Valid value is '2.7'." ) def fingerprint(self, request: Request) -> bytes: diff --git a/tests/test_utils_request.py b/tests/test_utils_request.py index e6d1abe3f..f6bc9ba6f 100644 --- a/tests/test_utils_request.py +++ b/tests/test_utils_request.py @@ -1,23 +1,15 @@ import json import unittest -import warnings from hashlib import sha1 -from typing import Dict, Mapping, Optional, Tuple, Union +from typing import Dict, Optional, Tuple, Union from weakref import WeakKeyDictionary -import pytest -from w3lib.url import canonicalize_url - from scrapy.http import Request -from scrapy.utils.deprecate import ScrapyDeprecationWarning from scrapy.utils.python import to_bytes from scrapy.utils.request import ( - _deprecated_fingerprint_cache, _fingerprint_cache, - _request_fingerprint_as_bytes, fingerprint, request_authenticate, - request_fingerprint, request_httprepr, request_to_curl, ) @@ -233,168 +225,6 @@ class FingerprintTest(unittest.TestCase): self.assertEqual(actual, expected) -class RequestFingerprintTest(FingerprintTest): - function = staticmethod(request_fingerprint) - cache = _deprecated_fingerprint_cache - known_hashes: Tuple[Tuple[Request, Union[bytes, str], Dict], ...] = ( - ( - Request("http://example.org"), - "b2e5245ef826fd9576c93bd6e392fce3133fab62", - {}, - ), - ( - Request("https://example.org"), - "bd10a0a89ea32cdee77917320f1309b0da87e892", - {}, - ), - ( - Request("https://example.org?a"), - "2fb7d48ae02f04b749f40caa969c0bc3c43204ce", - {}, - ), - ( - Request("https://example.org?a=b"), - "42e5fe149b147476e3f67ad0670c57b4cc57856a", - {}, - ), - ( - Request("https://example.org?a=b&a"), - "d23a9787cb56c6375c2cae4453c5a8c634526942", - {}, - ), - ( - Request("https://example.org?a=b&a=c"), - "9a18a7a8552a9182b7f1e05d33876409e421e5c5", - {}, - ), - ( - Request("https://example.org", method="POST"), - "ba20a80cb5c5ca460021ceefb3c2467b2bfd1bc6", - {}, - ), - ( - Request("https://example.org", body=b"a"), - "4bb136e54e715a4ea7a9dd1101831765d33f2d60", - {}, - ), - ( - Request("https://example.org", method="POST", body=b"a"), - "6c6595374a304b293be762f7b7be3f54e9947c65", - {}, - ), - ( - Request("https://example.org#a", headers={"A": b"B"}), - "bd10a0a89ea32cdee77917320f1309b0da87e892", - {}, - ), - ( - Request("https://example.org#a", headers={"A": b"B"}), - "515b633cb3ca502a33a9d8c890e889ec1e425e65", - {"include_headers": ["A"]}, - ), - ( - Request("https://example.org#a", headers={"A": b"B"}), - "505c96e7da675920dfef58725e8c957dfdb38f47", - {"keep_fragments": True}, - ), - ( - Request("https://example.org#a", headers={"A": b"B"}), - "d6f673cdcb661b7970c2b9a00ee63e87d1e2e5da", - {"include_headers": ["A"], "keep_fragments": True}, - ), - ( - Request("https://example.org/ab"), - "4e2870fee58582d6f81755e9b8fdefe3cba0c951", - {}, - ), - ( - Request("https://example.org/a", body=b"b"), - "4e2870fee58582d6f81755e9b8fdefe3cba0c951", - {}, - ), - ) - - def setUp(self) -> None: - warnings.simplefilter("ignore", ScrapyDeprecationWarning) - - def tearDown(self) -> None: - warnings.simplefilter("default", ScrapyDeprecationWarning) - - @pytest.mark.xfail(reason="known bug kept for backward compatibility", strict=True) - def test_part_separation(self): - super().test_part_separation() - - -class RequestFingerprintDeprecationTest(unittest.TestCase): - def test_deprecation_default_parameters(self): - with pytest.warns(ScrapyDeprecationWarning) as warnings: - request_fingerprint(Request("http://www.example.com")) - messages = [str(warning.message) for warning in warnings] - self.assertTrue( - any("Call to deprecated function" in message for message in messages) - ) - self.assertFalse(any("non-default" in message for message in messages)) - - def test_deprecation_non_default_parameters(self): - with pytest.warns(ScrapyDeprecationWarning) as warnings: - request_fingerprint(Request("http://www.example.com"), keep_fragments=True) - messages = [str(warning.message) for warning in warnings] - self.assertTrue( - any("Call to deprecated function" in message for message in messages) - ) - self.assertTrue(any("non-default" in message for message in messages)) - - -class RequestFingerprintAsBytesTest(FingerprintTest): - function = staticmethod(_request_fingerprint_as_bytes) - cache = _deprecated_fingerprint_cache - known_hashes = RequestFingerprintTest.known_hashes - - def test_caching(self): - r1 = Request("http://www.example.com/hnnoticiaj1.aspx?78160,199") - self.assertEqual( - self.function(r1), bytes.fromhex(self.cache[r1][self.default_cache_key]) - ) - - @pytest.mark.xfail(reason="known bug kept for backward compatibility", strict=True) - def test_part_separation(self): - super().test_part_separation() - - def test_hashes(self): - actual = [ - self.function(request, **kwargs) for request, _, kwargs in self.known_hashes - ] - expected = [ - bytes.fromhex(_fingerprint) for _, _fingerprint, _ in self.known_hashes - ] - self.assertEqual(actual, expected) - - -_fingerprint_cache_2_6: Mapping[Request, Tuple[None, bool]] = WeakKeyDictionary() - - -def request_fingerprint_2_6(request, include_headers=None, keep_fragments=False): - if include_headers: - include_headers = tuple(to_bytes(h.lower()) for h in sorted(include_headers)) - cache = _fingerprint_cache_2_6.setdefault(request, {}) - cache_key = (include_headers, keep_fragments) - if cache_key not in cache: - fp = sha1() - fp.update(to_bytes(request.method)) - fp.update( - to_bytes(canonicalize_url(request.url, keep_fragments=keep_fragments)) - ) - fp.update(request.body or b"") - if include_headers: - for hdr in include_headers: - if hdr in request.headers: - fp.update(hdr) - for v in request.headers.getlist(hdr): - fp.update(v) - cache[cache_key] = fp.hexdigest() - return cache[cache_key] - - REQUEST_OBJECTS_TO_TEST = ( Request("http://www.example.com/"), Request("http://www.example.com/query?id=111&cat=222"), @@ -424,105 +254,17 @@ REQUEST_OBJECTS_TO_TEST = ( ) -class BackwardCompatibilityTestCase(unittest.TestCase): - def test_function_backward_compatibility(self): - include_headers_to_test = ( - None, - ["Accept-Language"], - ["accept-language", "sessionid"], - ["SESSIONID", "Accept-Language"], - ) - for request_object in REQUEST_OBJECTS_TO_TEST: - for include_headers in include_headers_to_test: - for keep_fragments in (False, True): - with warnings.catch_warnings(): - warnings.simplefilter("ignore") - fp = request_fingerprint( - request_object, - include_headers=include_headers, - keep_fragments=keep_fragments, - ) - old_fp = request_fingerprint_2_6( - request_object, - include_headers=include_headers, - keep_fragments=keep_fragments, - ) - self.assertEqual(fp, old_fp) - - def test_component_backward_compatibility(self): - for request_object in REQUEST_OBJECTS_TO_TEST: - with warnings.catch_warnings(): - warnings.simplefilter("ignore") - crawler = get_crawler(prevent_warnings=False) - fp = crawler.request_fingerprinter.fingerprint(request_object) - old_fp = request_fingerprint_2_6(request_object) - self.assertEqual(fp.hex(), old_fp) - - def test_custom_component_backward_compatibility(self): - """Tests that the backward-compatible request fingerprinting class featured - in the documentation is indeed backward compatible and does not cause a - warning to be logged.""" - - class RequestFingerprinter: - cache = WeakKeyDictionary() - - def fingerprint(self, request): - if request not in self.cache: - fp = sha1() - fp.update(to_bytes(request.method)) - fp.update(to_bytes(canonicalize_url(request.url))) - fp.update(request.body or b"") - self.cache[request] = fp.digest() - return self.cache[request] - - for request_object in REQUEST_OBJECTS_TO_TEST: - with warnings.catch_warnings() as logged_warnings: - settings = { - "REQUEST_FINGERPRINTER_CLASS": RequestFingerprinter, - } - crawler = get_crawler(settings_dict=settings) - fp = crawler.request_fingerprinter.fingerprint(request_object) - old_fp = request_fingerprint_2_6(request_object) - self.assertEqual(fp.hex(), old_fp) - self.assertFalse(logged_warnings) - - class RequestFingerprinterTestCase(unittest.TestCase): def test_default_implementation(self): - with warnings.catch_warnings(record=True) as logged_warnings: - crawler = get_crawler(prevent_warnings=False) - request = Request("https://example.com") - self.assertEqual( - crawler.request_fingerprinter.fingerprint(request), - _request_fingerprint_as_bytes(request), - ) - self.assertTrue(logged_warnings) - - def test_deprecated_implementation(self): - settings = { - "REQUEST_FINGERPRINTER_IMPLEMENTATION": "2.6", - } - with warnings.catch_warnings(record=True) as logged_warnings: - crawler = get_crawler(settings_dict=settings) - request = Request("https://example.com") - self.assertEqual( - crawler.request_fingerprinter.fingerprint(request), - _request_fingerprint_as_bytes(request), - ) - self.assertTrue(logged_warnings) - - def test_recommended_implementation(self): settings = { "REQUEST_FINGERPRINTER_IMPLEMENTATION": "2.7", } - with warnings.catch_warnings(record=True) as logged_warnings: - crawler = get_crawler(settings_dict=settings) + crawler = get_crawler(settings_dict=settings) request = Request("https://example.com") self.assertEqual( crawler.request_fingerprinter.fingerprint(request), fingerprint(request), ) - self.assertFalse(logged_warnings) def test_unknown_implementation(self): settings = { From 019443dd5761afd5b4f7bba5948508554f1cb5f1 Mon Sep 17 00:00:00 2001 From: Laerte Pereira Date: Wed, 31 Jan 2024 10:08:07 -0300 Subject: [PATCH 079/119] Remove settings from default implementation test --- tests/test_utils_request.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/test_utils_request.py b/tests/test_utils_request.py index f6bc9ba6f..68f6eb045 100644 --- a/tests/test_utils_request.py +++ b/tests/test_utils_request.py @@ -256,10 +256,7 @@ REQUEST_OBJECTS_TO_TEST = ( class RequestFingerprinterTestCase(unittest.TestCase): def test_default_implementation(self): - settings = { - "REQUEST_FINGERPRINTER_IMPLEMENTATION": "2.7", - } - crawler = get_crawler(settings_dict=settings) + crawler = get_crawler() request = Request("https://example.com") self.assertEqual( crawler.request_fingerprinter.fingerprint(request), From bacaf0db7ac8b3b424af41d24e12e89a3a15b004 Mon Sep 17 00:00:00 2001 From: Laerte Pereira Date: Wed, 31 Jan 2024 10:14:48 -0300 Subject: [PATCH 080/119] Update documentation --- docs/topics/request-response.rst | 28 ++-------------------------- 1 file changed, 2 insertions(+), 26 deletions(-) diff --git a/docs/topics/request-response.rst b/docs/topics/request-response.rst index 9d64eee45..6dbcb4584 100644 --- a/docs/topics/request-response.rst +++ b/docs/topics/request-response.rst @@ -477,20 +477,12 @@ REQUEST_FINGERPRINTER_IMPLEMENTATION .. versionadded:: 2.7 -Default: ``'2.6'`` +Default: ``'2.7'`` Determines which request fingerprinting algorithm is used by the default request fingerprinter class (see :setting:`REQUEST_FINGERPRINTER_CLASS`). -Possible values are: - -- ``'2.6'`` (default) - - This implementation uses the same request fingerprinting algorithm as - Scrapy 2.6 and earlier versions. - - Even though this is the default value for backward compatibility reasons, - it is a deprecated value. +Possible value is: - ``'2.7'`` @@ -500,29 +492,13 @@ Possible values are: New projects should use this value. The :command:`startproject` command sets this value in the generated ``settings.py`` file. -If you are using the default value (``'2.6'``) for this setting, and you are -using Scrapy components where changing the request fingerprinting algorithm -would cause undesired results, you need to carefully decide when to change the -value of this setting, or switch the :setting:`REQUEST_FINGERPRINTER_CLASS` -setting to a custom request fingerprinter class that implements the 2.6 request -fingerprinting algorithm and does not log this warning ( -:ref:`2.6-request-fingerprinter` includes an example implementation of such a -class). - Scenarios where changing the request fingerprinting algorithm may cause undesired results include, for example, using the HTTP cache middleware (see :class:`~scrapy.downloadermiddlewares.httpcache.HttpCacheMiddleware`). Changing the request fingerprinting algorithm would invalidate the current cache, requiring you to redownload all requests again. -Otherwise, set :setting:`REQUEST_FINGERPRINTER_IMPLEMENTATION` to ``'2.7'`` in -your settings to switch already to the request fingerprinting implementation -that will be the only request fingerprinting implementation available in a -future version of Scrapy, and remove the deprecation warning triggered by using -the default value (``'2.6'``). - -.. _2.6-request-fingerprinter: .. _custom-request-fingerprinter: Writing your own request fingerprinter From 24634f1bb236f72a1a7b73900f8e3b524f7717b5 Mon Sep 17 00:00:00 2001 From: Laerte Pereira Date: Wed, 31 Jan 2024 12:29:43 -0300 Subject: [PATCH 081/119] Attend PR comments --- docs/topics/request-response.rst | 30 ------------------- scrapy/settings/default_settings.py | 2 +- .../templates/project/module/settings.py.tmpl | 1 - scrapy/utils/request.py | 16 ++++++++-- scrapy/utils/test.py | 3 -- tests/test_utils_request.py | 14 +++++++++ 6 files changed, 28 insertions(+), 38 deletions(-) diff --git a/docs/topics/request-response.rst b/docs/topics/request-response.rst index 6dbcb4584..67fc0c6e9 100644 --- a/docs/topics/request-response.rst +++ b/docs/topics/request-response.rst @@ -469,36 +469,6 @@ import path. .. autoclass:: scrapy.utils.request.RequestFingerprinter - -.. setting:: REQUEST_FINGERPRINTER_IMPLEMENTATION - -REQUEST_FINGERPRINTER_IMPLEMENTATION -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -.. versionadded:: 2.7 - -Default: ``'2.7'`` - -Determines which request fingerprinting algorithm is used by the default -request fingerprinter class (see :setting:`REQUEST_FINGERPRINTER_CLASS`). - -Possible value is: - -- ``'2.7'`` - - This implementation was introduced in Scrapy 2.7 to fix an issue of the - previous implementation. - - New projects should use this value. The :command:`startproject` command - sets this value in the generated ``settings.py`` file. - -Scenarios where changing the request fingerprinting algorithm may cause -undesired results include, for example, using the HTTP cache middleware (see -:class:`~scrapy.downloadermiddlewares.httpcache.HttpCacheMiddleware`). -Changing the request fingerprinting algorithm would invalidate the current -cache, requiring you to redownload all requests again. - - .. _custom-request-fingerprinter: Writing your own request fingerprinter diff --git a/scrapy/settings/default_settings.py b/scrapy/settings/default_settings.py index 02494bad0..49ab1b5ef 100644 --- a/scrapy/settings/default_settings.py +++ b/scrapy/settings/default_settings.py @@ -260,7 +260,7 @@ REFERER_ENABLED = True REFERRER_POLICY = "scrapy.spidermiddlewares.referer.DefaultReferrerPolicy" REQUEST_FINGERPRINTER_CLASS = "scrapy.utils.request.RequestFingerprinter" -REQUEST_FINGERPRINTER_IMPLEMENTATION = "2.7" +REQUEST_FINGERPRINTER_IMPLEMENTATION = "SENTINEL" RETRY_ENABLED = True RETRY_TIMES = 2 # initial response + 2 retries = 3 requests diff --git a/scrapy/templates/project/module/settings.py.tmpl b/scrapy/templates/project/module/settings.py.tmpl index ecb1e5e5c..b4779e555 100644 --- a/scrapy/templates/project/module/settings.py.tmpl +++ b/scrapy/templates/project/module/settings.py.tmpl @@ -88,6 +88,5 @@ ROBOTSTXT_OBEY = True #HTTPCACHE_STORAGE = "scrapy.extensions.httpcache.FilesystemCacheStorage" # Set settings whose default value is deprecated to a future-proof value -REQUEST_FINGERPRINTER_IMPLEMENTATION = "2.7" TWISTED_REACTOR = "twisted.internet.asyncioreactor.AsyncioSelectorReactor" FEED_EXPORT_ENCODING = "utf-8" diff --git a/scrapy/utils/request.py b/scrapy/utils/request.py index b230cd214..068e5bdcb 100644 --- a/scrapy/utils/request.py +++ b/scrapy/utils/request.py @@ -5,6 +5,7 @@ scrapy.http.Request objects import hashlib import json +import warnings from typing import ( TYPE_CHECKING, Any, @@ -25,6 +26,7 @@ from w3lib.http import basic_auth_header from w3lib.url import canonicalize_url from scrapy import Request, Spider +from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.utils.httpobj import urlparse_cached from scrapy.utils.misc import load_object from scrapy.utils.python import to_bytes, to_unicode @@ -139,14 +141,22 @@ class RequestFingerprinter: "REQUEST_FINGERPRINTER_IMPLEMENTATION" ) else: - implementation = "2.7" - if implementation == "2.7": + implementation = "SENTINEL" + + if implementation == "SENTINEL": + self._fingerprint = fingerprint + elif implementation == "2.7": + message = ( + "'REQUEST_FINGERPRINTER_IMPLEMENTATION' is a deprecated setting.\n" + "And it will be removed in future version of Scrapy." + ) + warnings.warn(message, category=ScrapyDeprecationWarning, stacklevel=2) self._fingerprint = fingerprint else: raise ValueError( f"Got an invalid value on setting " f"'REQUEST_FINGERPRINTER_IMPLEMENTATION': " - f"{implementation!r}. Valid value is '2.7'." + f"{implementation!r}. Valid values are '2.7' and 'SENTINEL'." ) def fingerprint(self, request: Request) -> bytes: diff --git a/scrapy/utils/test.py b/scrapy/utils/test.py index 709e0b00d..c6a31cacf 100644 --- a/scrapy/utils/test.py +++ b/scrapy/utils/test.py @@ -76,7 +76,6 @@ class TestSpider(Spider): def get_crawler( spidercls: Optional[Type[Spider]] = None, settings_dict: Optional[Dict[str, Any]] = None, - prevent_warnings: bool = True, ) -> Crawler: """Return an unconfigured Crawler object. If settings_dict is given, it will be used to populate the crawler settings with a project level @@ -86,8 +85,6 @@ def get_crawler( # Set by default settings that prevent deprecation warnings. settings: Dict[str, Any] = {} - if prevent_warnings: - settings["REQUEST_FINGERPRINTER_IMPLEMENTATION"] = "2.7" settings.update(settings_dict or {}) runner = CrawlerRunner(settings) crawler = runner.create_crawler(spidercls or TestSpider) diff --git a/tests/test_utils_request.py b/tests/test_utils_request.py index 68f6eb045..c0c44875e 100644 --- a/tests/test_utils_request.py +++ b/tests/test_utils_request.py @@ -1,5 +1,6 @@ import json import unittest +import warnings from hashlib import sha1 from typing import Dict, Optional, Tuple, Union from weakref import WeakKeyDictionary @@ -263,6 +264,19 @@ class RequestFingerprinterTestCase(unittest.TestCase): fingerprint(request), ) + def test_deprecated_implementation(self): + settings = { + "REQUEST_FINGERPRINTER_IMPLEMENTATION": "2.7", + } + with warnings.catch_warnings(record=True) as logged_warnings: + crawler = get_crawler(settings_dict=settings) + request = Request("https://example.com") + self.assertEqual( + crawler.request_fingerprinter.fingerprint(request), + fingerprint(request), + ) + self.assertTrue(logged_warnings) + def test_unknown_implementation(self): settings = { "REQUEST_FINGERPRINTER_IMPLEMENTATION": "2.5", From 7001193c802029612542ab7a30fa8c0e147a1894 Mon Sep 17 00:00:00 2001 From: Laerte Pereira Date: Wed, 31 Jan 2024 12:53:08 -0300 Subject: [PATCH 082/119] Simplify the logic --- scrapy/utils/request.py | 12 ++---------- scrapy/utils/test.py | 3 +++ tests/test_utils_request.py | 7 ------- 3 files changed, 5 insertions(+), 17 deletions(-) diff --git a/scrapy/utils/request.py b/scrapy/utils/request.py index 068e5bdcb..db0b44cf4 100644 --- a/scrapy/utils/request.py +++ b/scrapy/utils/request.py @@ -143,21 +143,13 @@ class RequestFingerprinter: else: implementation = "SENTINEL" - if implementation == "SENTINEL": - self._fingerprint = fingerprint - elif implementation == "2.7": + if implementation != "SENTINEL": message = ( "'REQUEST_FINGERPRINTER_IMPLEMENTATION' is a deprecated setting.\n" "And it will be removed in future version of Scrapy." ) warnings.warn(message, category=ScrapyDeprecationWarning, stacklevel=2) - self._fingerprint = fingerprint - else: - raise ValueError( - f"Got an invalid value on setting " - f"'REQUEST_FINGERPRINTER_IMPLEMENTATION': " - f"{implementation!r}. Valid values are '2.7' and 'SENTINEL'." - ) + self._fingerprint = fingerprint def fingerprint(self, request: Request) -> bytes: return self._fingerprint(request) diff --git a/scrapy/utils/test.py b/scrapy/utils/test.py index c6a31cacf..9234ec2ea 100644 --- a/scrapy/utils/test.py +++ b/scrapy/utils/test.py @@ -76,6 +76,7 @@ class TestSpider(Spider): def get_crawler( spidercls: Optional[Type[Spider]] = None, settings_dict: Optional[Dict[str, Any]] = None, + prevent_warnings: bool = True, ) -> Crawler: """Return an unconfigured Crawler object. If settings_dict is given, it will be used to populate the crawler settings with a project level @@ -85,6 +86,8 @@ def get_crawler( # Set by default settings that prevent deprecation warnings. settings: Dict[str, Any] = {} + if prevent_warnings: + pass settings.update(settings_dict or {}) runner = CrawlerRunner(settings) crawler = runner.create_crawler(spidercls or TestSpider) diff --git a/tests/test_utils_request.py b/tests/test_utils_request.py index c0c44875e..633077eec 100644 --- a/tests/test_utils_request.py +++ b/tests/test_utils_request.py @@ -277,13 +277,6 @@ class RequestFingerprinterTestCase(unittest.TestCase): ) self.assertTrue(logged_warnings) - def test_unknown_implementation(self): - settings = { - "REQUEST_FINGERPRINTER_IMPLEMENTATION": "2.5", - } - with self.assertRaises(ValueError): - get_crawler(settings_dict=settings) - class CustomRequestFingerprinterTestCase(unittest.TestCase): def test_include_headers(self): From 53ccf0016d99e54adec6f98236cce37ede835f63 Mon Sep 17 00:00:00 2001 From: Laerte Pereira Date: Wed, 31 Jan 2024 13:18:10 -0300 Subject: [PATCH 083/119] Remove empty statement --- scrapy/utils/test.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/scrapy/utils/test.py b/scrapy/utils/test.py index 9234ec2ea..7a8c5c859 100644 --- a/scrapy/utils/test.py +++ b/scrapy/utils/test.py @@ -86,8 +86,6 @@ def get_crawler( # Set by default settings that prevent deprecation warnings. settings: Dict[str, Any] = {} - if prevent_warnings: - pass settings.update(settings_dict or {}) runner = CrawlerRunner(settings) crawler = runner.create_crawler(spidercls or TestSpider) From c5dad41190551578c2973c34520952f26f75dc7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Fri, 2 Feb 2024 14:03:16 +0100 Subject: [PATCH 084/119] Speed up tests, remove comments without regexps --- .github/workflows/tests-ubuntu.yml | 3 -- scrapy/utils/response.py | 14 +++++++- tests/test_utils_response.py | 51 ++++++++++++++++++++++++++---- tox.ini | 6 ---- 4 files changed, 57 insertions(+), 17 deletions(-) diff --git a/.github/workflows/tests-ubuntu.yml b/.github/workflows/tests-ubuntu.yml index 388ba9572..338c99584 100644 --- a/.github/workflows/tests-ubuntu.yml +++ b/.github/workflows/tests-ubuntu.yml @@ -50,9 +50,6 @@ jobs: - python-version: "3.12" env: TOXENV: botocore - - python-version: "3.12" - env: - TOXENV: slow steps: - uses: actions/checkout@v3 diff --git a/scrapy/utils/response.py b/scrapy/utils/response.py index 4369e6439..fabfb1167 100644 --- a/scrapy/utils/response.py +++ b/scrapy/utils/response.py @@ -74,6 +74,18 @@ def response_httprepr(response: Response) -> bytes: return b"".join(values) +def _remove_html_comments(body): + start = body.find(b"", start + 1) + if end == -1: + return body[:start] + else: + body = body[:start] + body[end + 3 :] + start = body.find(b"|$)", b"", body) body = re.sub(rb"]*?>)", to_bytes(repl), body, count=1) ext = ".html" elif isinstance(response, TextResponse): diff --git a/tests/test_utils_response.py b/tests/test_utils_response.py index 1dbe187bf..db3c31b89 100644 --- a/tests/test_utils_response.py +++ b/tests/test_utils_response.py @@ -8,9 +8,9 @@ import pytest from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.http import HtmlResponse, Response, TextResponse -from scrapy.settings.default_settings import DOWNLOAD_MAXSIZE from scrapy.utils.python import to_bytes from scrapy.utils.response import ( + _remove_html_comments, get_base_url, get_meta_refresh, open_in_browser, @@ -203,14 +203,13 @@ class ResponseUtilsTest(unittest.TestCase): r5, _openfunc=check_base_url ), "Inject unique base url with conditional comment" - @pytest.mark.slow def test_open_in_browser_redos_comment(self): - MAX_CPU_TIME = 30 + MAX_CPU_TIME = 0.001 # Exploit input from # https://makenowjust-labs.github.io/recheck/playground/ # for // (old pattern to remove comments). - body = b"->" + body = b"->" response = HtmlResponse("https://example.com", body=body) @@ -221,14 +220,13 @@ class ResponseUtilsTest(unittest.TestCase): end_time = process_time() self.assertLess(end_time - start_time, MAX_CPU_TIME) - @pytest.mark.slow def test_open_in_browser_redos_head(self): - MAX_CPU_TIME = 15 + MAX_CPU_TIME = 0.001 # Exploit input from # https://makenowjust-labs.github.io/recheck/playground/ # for /(|\s.*?>))/ (old pattern to find the head element). - body = b"b", + b"ab", + ), + ( + b"ac", + b"ac", + ), + ( + b"acccd", + b"acd", + ), + ( + b"ad", + b"ad", + ), + ), +) +def test_remove_html_comments(input_body, output_body): + assert ( + _remove_html_comments(input_body) == output_body + ), f"{_remove_html_comments(input_body)=} == {output_body=}" diff --git a/tox.ini b/tox.ini index e87d6a175..381da9773 100644 --- a/tox.ini +++ b/tox.ini @@ -221,9 +221,3 @@ setenv = {[pinned]setenv} commands = pytest --cov=scrapy --cov-report=xml --cov-report= {posargs:tests -k s3} - - -[testenv:slow] -basepython = python3 -commands = - {[testenv]commands} -m 'slow' From 810aaa637da12a1f393291eb2b13aa0c8a163efb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Fri, 2 Feb 2024 14:04:28 +0100 Subject: [PATCH 085/119] Undo an unintended change --- .github/workflows/tests-ubuntu.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/tests-ubuntu.yml b/.github/workflows/tests-ubuntu.yml index 338c99584..c883f958c 100644 --- a/.github/workflows/tests-ubuntu.yml +++ b/.github/workflows/tests-ubuntu.yml @@ -50,6 +50,7 @@ jobs: - python-version: "3.12" env: TOXENV: botocore + steps: - uses: actions/checkout@v3 From 5e5a92026e43023b80f7733844a2703c3f966009 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Fri, 2 Feb 2024 14:06:45 +0100 Subject: [PATCH 086/119] Remove slow leftovers --- pytest.ini | 2 -- 1 file changed, 2 deletions(-) diff --git a/pytest.ini b/pytest.ini index 877fbcd1d..16983be5e 100644 --- a/pytest.ini +++ b/pytest.ini @@ -17,12 +17,10 @@ addopts = --ignore=docs/topics/stats.rst --ignore=docs/topics/telnetconsole.rst --ignore=docs/utils - -m 'not slow' 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 requires_uvloop: marks tests as only enabled when uvloop is known to be working - slow: marks tests as slow, not executed by default filterwarnings = ignore:scrapy.downloadermiddlewares.decompression is deprecated ignore:Module scrapy.utils.reqser is deprecated From 1c9d308accd38a91ffa92e3aff8912cd792070eb Mon Sep 17 00:00:00 2001 From: Andy <128531452+Andy-W-Developer@users.noreply.github.com> Date: Tue, 6 Feb 2024 00:52:01 +1300 Subject: [PATCH 087/119] Cover the deprecation and removal of response_httprepr in the release notes (#6216) --- docs/news.rst | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/news.rst b/docs/news.rst index 65d9c5181..d90e32560 100644 --- a/docs/news.rst +++ b/docs/news.rst @@ -62,6 +62,9 @@ Deprecation removals 1.0.0, use :attr:`CrawlerRunner.spider_loader ` instead. (:issue:`6010`) +- The :func:`scrapy.utils.response.response_httprepr` function, deprecated in + Scrapy 2.6.0, has now been removed. (:issue:`6111`) + Deprecations ~~~~~~~~~~~~ @@ -1157,6 +1160,9 @@ Deprecations Instead, call :meth:`~scrapy.core.engine.ExecutionEngine.open_spider` first to set the :class:`~scrapy.Spider` object. +- :func:`scrapy.utils.response.response_httprepr` is now deprecated. + (:issue:`4972`) + New features ~~~~~~~~~~~~ From a55e933c11899997757bd4107738f9472d1d3c2e Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Wed, 14 Feb 2024 20:08:40 +0400 Subject: [PATCH 088/119] Release notes for 2.11.1 (#6150) --- docs/news.rst | 58 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/docs/news.rst b/docs/news.rst index 0c202639e..c26cef22c 100644 --- a/docs/news.rst +++ b/docs/news.rst @@ -3,6 +3,64 @@ Release notes ============= +.. _release-2.11.1: + +Scrapy 2.11.1 (YYYY-MM-DD) +-------------------------- + +Highlights: + +- Support for Twisted >= 23.8.0. + +- Documentation improvements. + +Modified requirements +~~~~~~~~~~~~~~~~~~~~~ + +- The Twisted dependency is no longer restricted to < 23.8.0. (:issue:`6024`, + :issue:`6064`, :issue:`6142`) + +Bug fixes +~~~~~~~~~ + +- The OS signal handling code was refactored to no longer use private Twisted + functions. (:issue:`6024`, :issue:`6064`, :issue:`6112`) + +Documentation +~~~~~~~~~~~~~ + +- Improved documentation for :class:`~scrapy.crawler.Crawler` initialization + changes made in the 2.11.0 release. (:issue:`6057`, :issue:`6147`) + +- Extended documentation for :attr:`Request.meta `. + (:issue:`5565`) + +- Fixed the :reqmeta:`dont_merge_cookies` documentation. (:issue:`5936`, + :issue:`6077`) + +- Added a link to Zyte's export guides to the :ref:`feed exports + ` documentation. (:issue:`6183`) + +- Added a missing note about backward-incompatible changes in + :class:`~scrapy.exporters.PythonItemExporter` to the 2.11.0 release notes. + (:issue:`6060`, :issue:`6081`) + +- Added a missing note about removing the deprecated + ``scrapy.utils.boto.is_botocore()`` function to the 2.8.0 release notes. + (:issue:`6056`, :issue:`6061`) + +- Other documentation improvements. (:issue:`6128`, :issue:`6144`, + :issue:`6163`, :issue:`6190`, :issue:`6192`) + +Quality assurance +~~~~~~~~~~~~~~~~~ + +- Added Python 3.12 to the CI configuration, re-enabled tests that were + disabled when the pre-release support was added. (:issue:`5985`, + :issue:`6083`, :issue:`6098`) + +- Fixed a test issue on PyPy 7.3.14. (:issue:`6204`, :issue:`6205`) + .. _release-2.11.0: Scrapy 2.11.0 (2023-09-18) From 6b88b3346c393f07c4e4481405c3fd1ab4cc58a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Wed, 14 Feb 2024 18:16:40 +0100 Subject: [PATCH 089/119] Set the release date of versions 2.11.1 and 1.8.4 --- docs/news.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/news.rst b/docs/news.rst index 16e7e79a7..518632a5b 100644 --- a/docs/news.rst +++ b/docs/news.rst @@ -5,7 +5,7 @@ Release notes .. _release-2.11.1: -Scrapy 2.11.1 (unreleased) +Scrapy 2.11.1 (2024-02-14) -------------------------- Highlights: @@ -2972,7 +2972,7 @@ affect subclasses: .. _release-1.8.4: -Scrapy 1.8.4 (unreleased) +Scrapy 1.8.4 (2024-02-14) ------------------------- **Security bug fixes:** From 502addc717b6b971425a9385359a382b8d0187a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Wed, 14 Feb 2024 18:17:48 +0100 Subject: [PATCH 090/119] =?UTF-8?q?Bump=20version:=202.11.0=20=E2=86=92=20?= =?UTF-8?q?2.11.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpversion.cfg | 2 +- scrapy/VERSION | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index f76bf783d..6ce6e2a59 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 2.11.0 +current_version = 2.11.1 commit = True tag = True tag_name = {new_version} diff --git a/scrapy/VERSION b/scrapy/VERSION index 46b81d815..6ceb272ee 100644 --- a/scrapy/VERSION +++ b/scrapy/VERSION @@ -1 +1 @@ -2.11.0 +2.11.1 From 2f1d345e74d19e33016f9e69fcda0bda9afb568d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Wed, 14 Feb 2024 18:59:01 +0100 Subject: [PATCH 091/119] Solve test issues --- ...st_downloadermiddleware_httpcompression.py | 24 +++++++++++++++++++ tests/test_utils_response.py | 4 ++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/tests/test_downloadermiddleware_httpcompression.py b/tests/test_downloadermiddleware_httpcompression.py index f74fff218..9deb81c37 100644 --- a/tests/test_downloadermiddleware_httpcompression.py +++ b/tests/test_downloadermiddleware_httpcompression.py @@ -402,6 +402,10 @@ class HttpCompressionTest(TestCase): self._test_compression_bomb_setting("gzip") def test_compression_bomb_setting_zstd(self): + try: + import zstandard # noqa: F401 + except ImportError: + raise SkipTest("no zstd support (zstandard)") self._test_compression_bomb_setting("zstd") def _test_compression_bomb_spider_attr(self, compression_id): @@ -436,6 +440,10 @@ class HttpCompressionTest(TestCase): self._test_compression_bomb_spider_attr("gzip") def test_compression_bomb_spider_attr_zstd(self): + try: + import zstandard # noqa: F401 + except ImportError: + raise SkipTest("no zstd support (zstandard)") self._test_compression_bomb_spider_attr("zstd") def _test_compression_bomb_request_meta(self, compression_id): @@ -468,6 +476,10 @@ class HttpCompressionTest(TestCase): self._test_compression_bomb_request_meta("gzip") def test_compression_bomb_request_meta_zstd(self): + try: + import zstandard # noqa: F401 + except ImportError: + raise SkipTest("no zstd support (zstandard)") self._test_compression_bomb_request_meta("zstd") def _test_download_warnsize_setting(self, compression_id): @@ -510,6 +522,10 @@ class HttpCompressionTest(TestCase): self._test_download_warnsize_setting("gzip") def test_download_warnsize_setting_zstd(self): + try: + import zstandard # noqa: F401 + except ImportError: + raise SkipTest("no zstd support (zstandard)") self._test_download_warnsize_setting("zstd") def _test_download_warnsize_spider_attr(self, compression_id): @@ -554,6 +570,10 @@ class HttpCompressionTest(TestCase): self._test_download_warnsize_spider_attr("gzip") def test_download_warnsize_spider_attr_zstd(self): + try: + import zstandard # noqa: F401 + except ImportError: + raise SkipTest("no zstd support (zstandard)") self._test_download_warnsize_spider_attr("zstd") def _test_download_warnsize_request_meta(self, compression_id): @@ -596,6 +616,10 @@ class HttpCompressionTest(TestCase): self._test_download_warnsize_request_meta("gzip") def test_download_warnsize_request_meta_zstd(self): + try: + import zstandard # noqa: F401 + except ImportError: + raise SkipTest("no zstd support (zstandard)") self._test_download_warnsize_request_meta("zstd") diff --git a/tests/test_utils_response.py b/tests/test_utils_response.py index db3c31b89..37ef89e76 100644 --- a/tests/test_utils_response.py +++ b/tests/test_utils_response.py @@ -204,7 +204,7 @@ class ResponseUtilsTest(unittest.TestCase): ), "Inject unique base url with conditional comment" def test_open_in_browser_redos_comment(self): - MAX_CPU_TIME = 0.001 + MAX_CPU_TIME = 0.02 # Exploit input from # https://makenowjust-labs.github.io/recheck/playground/ @@ -221,7 +221,7 @@ class ResponseUtilsTest(unittest.TestCase): self.assertLess(end_time - start_time, MAX_CPU_TIME) def test_open_in_browser_redos_head(self): - MAX_CPU_TIME = 0.001 + MAX_CPU_TIME = 0.02 # Exploit input from # https://makenowjust-labs.github.io/recheck/playground/ From 660e3b19532c50eac7d135549e594b7f98285184 Mon Sep 17 00:00:00 2001 From: Jalil SA <61639983+jxlil@users.noreply.github.com> Date: Thu, 15 Feb 2024 16:55:08 -0600 Subject: [PATCH 092/119] update: docs/topics/items.rst --- docs/topics/items.rst | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/docs/topics/items.rst b/docs/topics/items.rst index 3c38ac2dc..97ed7a900 100644 --- a/docs/topics/items.rst +++ b/docs/topics/items.rst @@ -399,12 +399,7 @@ In code that receives an item, such as methods of :ref:`item pipelines `, it is a good practice to use the :class:`~itemadapter.ItemAdapter` class and the :func:`~itemadapter.is_item` function to write code that works for -any :ref:`supported item type `: - -.. autoclass:: itemadapter.ItemAdapter - -.. autofunction:: itemadapter.is_item - +any supported item type. Other classes related to items ============================== From 3e7b704c08aacd51a8a7589e32c73c7754582eed Mon Sep 17 00:00:00 2001 From: Jalil SA <61639983+jxlil@users.noreply.github.com> Date: Thu, 15 Feb 2024 16:57:44 -0600 Subject: [PATCH 093/119] update: docs/topics/selectors.rst --- docs/topics/selectors.rst | 5 ----- 1 file changed, 5 deletions(-) diff --git a/docs/topics/selectors.rst b/docs/topics/selectors.rst index 4a64d530b..c841400b6 100644 --- a/docs/topics/selectors.rst +++ b/docs/topics/selectors.rst @@ -1032,11 +1032,6 @@ whereas the CSS lookup is translated into XPath and thus runs more efficiently, so performance-wise its uses are limited to situations that are not easily described with CSS selectors. -Parsel also simplifies adding your own XPath extensions. - -.. autofunction:: parsel.xpathfuncs.set_xpathfunc - - .. _topics-selectors-ref: Built-in Selectors reference From 9bb973dc54766a0f8d10eca0947d11f195c1a1be Mon Sep 17 00:00:00 2001 From: Kevin Lloyd Bernal Date: Fri, 16 Feb 2024 19:25:38 +0800 Subject: [PATCH 094/119] Refactor LogStats extension to log IPM and RPM to stats on spider_close (#4111) --- scrapy/extensions/logstats.py | 44 +++++++++++++++++++------ tests/test_logstats.py | 62 +++++++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 10 deletions(-) create mode 100644 tests/test_logstats.py diff --git a/scrapy/extensions/logstats.py b/scrapy/extensions/logstats.py index 78874a6db..9f63e9c4b 100644 --- a/scrapy/extensions/logstats.py +++ b/scrapy/extensions/logstats.py @@ -9,7 +9,10 @@ logger = logging.getLogger(__name__) class LogStats: - """Log basic scraping stats periodically""" + """Log basic scraping stats periodically like: + * RPM - Requests per Minute + * IPM - Items per Minute + """ def __init__(self, stats, interval=60.0): self.stats = stats @@ -35,24 +38,45 @@ class LogStats: self.task.start(self.interval) def log(self, spider): - items = self.stats.get_value("item_scraped_count", 0) - pages = self.stats.get_value("response_received_count", 0) - irate = (items - self.itemsprev) * self.multiplier - prate = (pages - self.pagesprev) * self.multiplier - self.pagesprev, self.itemsprev = pages, items + self.calculate_stats() msg = ( "Crawled %(pages)d pages (at %(pagerate)d pages/min), " "scraped %(items)d items (at %(itemrate)d items/min)" ) log_args = { - "pages": pages, - "pagerate": prate, - "items": items, - "itemrate": irate, + "pages": self.pages, + "pagerate": self.prate, + "items": self.items, + "itemrate": self.irate, } logger.info(msg, log_args, extra={"spider": spider}) + def calculate_stats(self): + self.items = self.stats.get_value("item_scraped_count", 0) + self.pages = self.stats.get_value("response_received_count", 0) + self.irate = (self.items - self.itemsprev) * self.multiplier + self.prate = (self.pages - self.pagesprev) * self.multiplier + self.pagesprev, self.itemsprev = self.pages, self.items + def spider_closed(self, spider, reason): if self.task and self.task.running: self.task.stop() + + rpm_final, ipm_final = self.calculate_final_stats(spider) + self.stats.set_value("responses_per_minute", rpm_final) + self.stats.set_value("items_per_minute", ipm_final) + + def calculate_final_stats(self, spider): + start_time = self.stats.get_value("start_time") + finished_time = self.stats.get_value("finished_time") + + if not start_time or not finished_time: + return None, None + + mins_elapsed = (finished_time - start_time).seconds / 60 + + items = self.stats.get_value("item_scraped_count", 0) + pages = self.stats.get_value("response_received_count", 0) + + return (pages / mins_elapsed), (items / mins_elapsed) diff --git a/tests/test_logstats.py b/tests/test_logstats.py new file mode 100644 index 000000000..d87285df7 --- /dev/null +++ b/tests/test_logstats.py @@ -0,0 +1,62 @@ +import unittest +from datetime import datetime + +from scrapy.extensions.logstats import LogStats +from scrapy.utils.test import get_crawler +from tests.spiders import SimpleSpider + + +class TestLogStats(unittest.TestCase): + def setUp(self): + self.crawler = get_crawler(SimpleSpider) + self.spider = self.crawler._create_spider("spidey") + self.stats = self.crawler.stats + + self.stats.set_value("response_received_count", 4802) + self.stats.set_value("item_scraped_count", 3201) + + def test_stats_calculations(self): + logstats = LogStats.from_crawler(self.crawler) + + with self.assertRaises(AttributeError): + logstats.pagesprev + logstats.itemsprev + + logstats.spider_opened(self.spider) + self.assertEqual(logstats.pagesprev, 4802) + self.assertEqual(logstats.itemsprev, 3201) + + logstats.calculate_stats() + self.assertEqual(logstats.items, 3201) + self.assertEqual(logstats.pages, 4802) + self.assertEqual(logstats.irate, 0.0) + self.assertEqual(logstats.prate, 0.0) + self.assertEqual(logstats.pagesprev, 4802) + self.assertEqual(logstats.itemsprev, 3201) + + # Simulate what happens after a minute + self.stats.set_value("response_received_count", 5187) + self.stats.set_value("item_scraped_count", 3492) + logstats.calculate_stats() + self.assertEqual(logstats.items, 3492) + self.assertEqual(logstats.pages, 5187) + self.assertEqual(logstats.irate, 291.0) + self.assertEqual(logstats.prate, 385.0) + self.assertEqual(logstats.pagesprev, 5187) + self.assertEqual(logstats.itemsprev, 3492) + + # Simulate when spider closes after running for 30 mins + self.stats.set_value("start_time", datetime.fromtimestamp(1655100172)) + self.stats.set_value("finished_time", datetime.fromtimestamp(1655101972)) + logstats.spider_closed(self.spider, "test reason") + self.assertEqual(self.stats.get_value("responses_per_minute"), 172.9) + self.assertEqual(self.stats.get_value("items_per_minute"), 116.4) + + def test_stats_calculations_no_time(self): + """The stat values should be None since the start and finish time are + not available. + """ + logstats = LogStats.from_crawler(self.crawler) + logstats.spider_closed(self.spider, "test reason") + self.assertIsNone(self.stats.get_value("responses_per_minute")) + self.assertIsNone(self.stats.get_value("items_per_minute")) From 36f72877ba8863a7fc39383e79f478400f6c09e9 Mon Sep 17 00:00:00 2001 From: Jalil SA <61639983+jxlil@users.noreply.github.com> Date: Fri, 16 Feb 2024 10:39:16 -0600 Subject: [PATCH 095/119] update: docs/topics/selectors.rst --- docs/topics/selectors.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/topics/selectors.rst b/docs/topics/selectors.rst index c841400b6..e32fc2b70 100644 --- a/docs/topics/selectors.rst +++ b/docs/topics/selectors.rst @@ -1032,6 +1032,9 @@ whereas the CSS lookup is translated into XPath and thus runs more efficiently, so performance-wise its uses are limited to situations that are not easily described with CSS selectors. +Parsel also simplifies adding your own XPath extensions with +:func:`~parsel.xpathfuncs.set_xpathfunc`. + .. _topics-selectors-ref: Built-in Selectors reference From c4e4b9b56e7fe10c5e7472b152dd47253a97af5b Mon Sep 17 00:00:00 2001 From: Mikhail Korobov Date: Tue, 20 Feb 2024 14:50:16 +0500 Subject: [PATCH 096/119] Add a SECURITY.md file (#6051) --- .bumpversion.cfg | 4 ++++ SECURITY.md | 12 ++++++++++++ 2 files changed, 16 insertions(+) create mode 100644 SECURITY.md diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 6ce6e2a59..968a34d96 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -5,3 +5,7 @@ tag = True tag_name = {new_version} [bumpversion:file:scrapy/VERSION] + +[bumpversion:file:SECURITY.md] +parse = (?P\d+)\.(?P\d+)\.x +serialize = {major}.{minor}.x diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000..51305d95e --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,12 @@ +# Security Policy + +## Supported Versions + +| Version | Supported | +| ------- | ------------------ | +| 2.11.x | :white_check_mark: | +| < 2.11.x | :x: | + +## Reporting a Vulnerability + +Please report the vulnerability using https://github.com/scrapy/scrapy/security/advisories/new. From ee1189512f652fae72f013c9d4759976b8b69994 Mon Sep 17 00:00:00 2001 From: Laerte Pereira <5853172+Laerte@users.noreply.github.com> Date: Tue, 20 Feb 2024 08:47:29 -0300 Subject: [PATCH 097/119] Replace urlparse with urlparse_cached where possible (#6229) --- docs/topics/media-pipeline.rst | 8 ++++---- scrapy/core/http2/stream.py | 6 +++--- scrapy/downloadermiddlewares/redirect.py | 4 ++-- tests/CrawlerRunner/ip_address.py | 3 ++- tests/test_http_cookies.py | 10 +++++----- tests/test_http_request.py | 11 ++++++----- tests/test_scheduler_base.py | 5 +++-- 7 files changed, 25 insertions(+), 22 deletions(-) diff --git a/docs/topics/media-pipeline.rst b/docs/topics/media-pipeline.rst index da0587aa4..c96dd0f99 100644 --- a/docs/topics/media-pipeline.rst +++ b/docs/topics/media-pipeline.rst @@ -532,14 +532,14 @@ See here the methods that you can override in your custom Files Pipeline: .. code-block:: python from pathlib import PurePosixPath - from urllib.parse import urlparse + from scrapy.utils.httpobj import urlparse_cached from scrapy.pipelines.files import FilesPipeline class MyFilesPipeline(FilesPipeline): def file_path(self, request, response=None, info=None, *, item=None): - return "files/" + PurePosixPath(urlparse(request.url).path).name + return "files/" + PurePosixPath(urlparse_cached(request).path).name Similarly, you can use the ``item`` to determine the file path based on some item property. @@ -690,14 +690,14 @@ See here the methods that you can override in your custom Images Pipeline: .. code-block:: python from pathlib import PurePosixPath - from urllib.parse import urlparse + from scrapy.utils.httpobj import urlparse_cached from scrapy.pipelines.images import ImagesPipeline class MyImagesPipeline(ImagesPipeline): def file_path(self, request, response=None, info=None, *, item=None): - return "files/" + PurePosixPath(urlparse(request.url).path).name + return "files/" + PurePosixPath(urlparse_cached(request).path).name Similarly, you can use the ``item`` to determine the file path based on some item property. diff --git a/scrapy/core/http2/stream.py b/scrapy/core/http2/stream.py index 39d5921f4..0f282d83d 100644 --- a/scrapy/core/http2/stream.py +++ b/scrapy/core/http2/stream.py @@ -2,7 +2,6 @@ import logging from enum import Enum from io import BytesIO from typing import TYPE_CHECKING, Dict, List, Optional, Tuple -from urllib.parse import urlparse from h2.errors import ErrorCodes from h2.exceptions import H2Error, ProtocolError, StreamClosedError @@ -15,6 +14,7 @@ from twisted.web.client import ResponseFailed from scrapy.http import Request from scrapy.http.headers import Headers from scrapy.responsetypes import responsetypes +from scrapy.utils.httpobj import urlparse_cached if TYPE_CHECKING: from scrapy.core.http2.protocol import H2ClientProtocol @@ -185,7 +185,7 @@ class Stream: def check_request_url(self) -> bool: # Make sure that we are sending the request to the correct URL - url = urlparse(self._request.url) + url = urlparse_cached(self._request) return ( url.netloc == str(self._protocol.metadata["uri"].host, "utf-8") or url.netloc == str(self._protocol.metadata["uri"].netloc, "utf-8") @@ -194,7 +194,7 @@ class Stream: ) def _get_request_headers(self) -> List[Tuple[str, str]]: - url = urlparse(self._request.url) + url = urlparse_cached(self._request) path = url.path if url.query: diff --git a/scrapy/downloadermiddlewares/redirect.py b/scrapy/downloadermiddlewares/redirect.py index 83afdf7d7..24089afea 100644 --- a/scrapy/downloadermiddlewares/redirect.py +++ b/scrapy/downloadermiddlewares/redirect.py @@ -2,7 +2,7 @@ from __future__ import annotations import logging from typing import TYPE_CHECKING, Any, List, Union, cast -from urllib.parse import urljoin, urlparse +from urllib.parse import urljoin from w3lib.url import safe_url_string @@ -125,7 +125,7 @@ class RedirectMiddleware(BaseRedirectMiddleware): assert response.headers["Location"] is not None location = safe_url_string(response.headers["Location"]) if response.headers["Location"].startswith(b"//"): - request_scheme = urlparse(request.url).scheme + request_scheme = urlparse_cached(request).scheme location = request_scheme + "://" + location.lstrip("/") redirected_url = urljoin(request.url, location) diff --git a/tests/CrawlerRunner/ip_address.py b/tests/CrawlerRunner/ip_address.py index 23260ab0d..5bf7512bc 100644 --- a/tests/CrawlerRunner/ip_address.py +++ b/tests/CrawlerRunner/ip_address.py @@ -9,6 +9,7 @@ from twisted.python.runtime import platform from scrapy import Request, Spider from scrapy.crawler import CrawlerRunner +from scrapy.utils.httpobj import urlparse_cached from scrapy.utils.log import configure_logging from tests.mockserver import MockDNSServer, MockServer @@ -30,7 +31,7 @@ class LocalhostSpider(Spider): yield Request(self.url) def parse(self, response): - netloc = urlparse(response.url).netloc + netloc = urlparse_cached(response).netloc host = netloc.split(":")[0] self.logger.info(f"Host: {host}") self.logger.info(f"Type: {type(response.ip_address)}") diff --git a/tests/test_http_cookies.py b/tests/test_http_cookies.py index 9e43b72b0..8b5554914 100644 --- a/tests/test_http_cookies.py +++ b/tests/test_http_cookies.py @@ -1,8 +1,8 @@ from unittest import TestCase -from urllib.parse import urlparse from scrapy.http import Request, Response from scrapy.http.cookies import WrappedRequest, WrappedResponse +from scrapy.utils.httpobj import urlparse_cached class WrappedRequestTest(TestCase): @@ -17,12 +17,12 @@ class WrappedRequestTest(TestCase): self.assertEqual(self.wrapped.full_url, self.request.url) def test_get_host(self): - self.assertEqual(self.wrapped.get_host(), urlparse(self.request.url).netloc) - self.assertEqual(self.wrapped.host, urlparse(self.request.url).netloc) + self.assertEqual(self.wrapped.get_host(), urlparse_cached(self.request).netloc) + self.assertEqual(self.wrapped.host, urlparse_cached(self.request).netloc) def test_get_type(self): - self.assertEqual(self.wrapped.get_type(), urlparse(self.request.url).scheme) - self.assertEqual(self.wrapped.type, urlparse(self.request.url).scheme) + self.assertEqual(self.wrapped.get_type(), urlparse_cached(self.request).scheme) + self.assertEqual(self.wrapped.type, urlparse_cached(self.request).scheme) def test_is_unverifiable(self): self.assertFalse(self.wrapped.is_unverifiable()) diff --git a/tests/test_http_request.py b/tests/test_http_request.py index 6dc9ec8b7..04fcaa231 100644 --- a/tests/test_http_request.py +++ b/tests/test_http_request.py @@ -5,7 +5,7 @@ import warnings import xmlrpc.client from typing import Any, Dict, List from unittest import mock -from urllib.parse import parse_qs, unquote_to_bytes, urlparse +from urllib.parse import parse_qs, unquote_to_bytes from scrapy.http import ( FormRequest, @@ -16,6 +16,7 @@ from scrapy.http import ( XmlRpcRequest, ) from scrapy.http.request import NO_CALLBACK +from scrapy.utils.httpobj import urlparse_cached from scrapy.utils.python import to_bytes, to_unicode @@ -617,8 +618,8 @@ class FormRequestTest(RequestTest): method="GET", formdata=(("foo", "bar"), ("foo", "baz")), ) - self.assertEqual(urlparse(req.url).hostname, "www.example.com") - self.assertEqual(urlparse(req.url).query, "foo=bar&foo=baz") + self.assertEqual(urlparse_cached(req).hostname, "www.example.com") + self.assertEqual(urlparse_cached(req).query, "foo=bar&foo=baz") def test_from_response_override_duplicate_form_key(self): response = _buildresponse( @@ -666,8 +667,8 @@ class FormRequestTest(RequestTest): response, formdata={"one": ["two", "three"], "six": "seven"} ) self.assertEqual(r1.method, "GET") - self.assertEqual(urlparse(r1.url).hostname, "www.example.com") - self.assertEqual(urlparse(r1.url).path, "/this/get.php") + self.assertEqual(urlparse_cached(r1).hostname, "www.example.com") + self.assertEqual(urlparse_cached(r1).path, "/this/get.php") fs = _qs(r1) self.assertEqual(set(fs[b"test"]), {b"val1", b"val2"}) self.assertEqual(set(fs[b"one"]), {b"two", b"three"}) diff --git a/tests/test_scheduler_base.py b/tests/test_scheduler_base.py index 76ca777a8..5db2e4e50 100644 --- a/tests/test_scheduler_base.py +++ b/tests/test_scheduler_base.py @@ -1,6 +1,6 @@ from typing import Dict, Optional from unittest import TestCase -from urllib.parse import urljoin, urlparse +from urllib.parse import urljoin from testfixtures import LogCapture from twisted.internet import defer @@ -9,6 +9,7 @@ from twisted.trial.unittest import TestCase as TwistedTestCase from scrapy.core.scheduler import BaseScheduler from scrapy.http import Request from scrapy.spiders import Spider +from scrapy.utils.httpobj import urlparse_cached from scrapy.utils.request import fingerprint from scrapy.utils.test import get_crawler from tests.mockserver import MockServer @@ -57,7 +58,7 @@ class TestSpider(Spider): self.start_urls = map(mockserver.url, PATHS) def parse(self, response): - return {"path": urlparse(response.url).path} + return {"path": urlparse_cached(response).path} class InterfaceCheckMixin: From e8e6d28479a0479361cd3de0fb854c243ed684b1 Mon Sep 17 00:00:00 2001 From: Elias Ram Date: Tue, 20 Feb 2024 20:41:18 +0100 Subject: [PATCH 098/119] test #8 added tests for LxmlLinkExtractor --- tests/test_linkextractors.py | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tests/test_linkextractors.py b/tests/test_linkextractors.py index 18e9608c1..66a30c635 100644 --- a/tests/test_linkextractors.py +++ b/tests/test_linkextractors.py @@ -2,6 +2,7 @@ import pickle import re import unittest from typing import Optional +from unittest.mock import Mock from packaging.version import Version from pytest import mark @@ -851,3 +852,36 @@ class LxmlLinkExtractorTestCase(Base.LinkExtractorTestCase): ), ], ) + + def test_link_allowed_is_false_with_empty_url(self): + mock_link = Mock() + mock_link.url = "" + expected = False + + actual = LxmlLinkExtractor()._link_allowed(mock_link) + + self.assertEqual(expected, actual) + + def test_link_allowed_is_false_with_bad_url_prefix(self): + mock_link = Mock() + mock_link.url = "htp://should_be_http.com" + expected = False + + actual = LxmlLinkExtractor()._link_allowed(mock_link) + + self.assertEqual(expected, actual) + + def test_link_allowed_is_false_with_missing_url_prefix(self): + mock_link = Mock() + mock_link.url = "should_have_prefix.com" + expected = False + + actual = LxmlLinkExtractor()._link_allowed(mock_link) + + self.assertEqual(expected, actual) + + def test_link_allowed_raises_with_none_url(self): + mock_link = Mock() + mock_link.url = None + + self.assertRaises(AttributeError, LxmlLinkExtractor()._link_allowed, mock_link) From e27d320c3cde3c965e61a674e8880043943cf17a Mon Sep 17 00:00:00 2001 From: noon <14049705+noon-io@users.noreply.github.com> Date: Tue, 20 Feb 2024 23:58:39 +0100 Subject: [PATCH 099/119] test #3 Increased branch coverage for form.py --- tests/test_http_request.py | 56 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/tests/test_http_request.py b/tests/test_http_request.py index 6dc9ec8b7..510ae74ba 100644 --- a/tests/test_http_request.py +++ b/tests/test_http_request.py @@ -1642,6 +1642,62 @@ class JsonRequestTest(RequestTest): self.assertEqual(kwargs["ensure_ascii"], True) self.assertEqual(kwargs["allow_nan"], True) + def test_form_response_with_invalid_formdata_type_error(self): + """Test that a form response with invalid form data throws a type error""" + response = _buildresponse( + """ +
+ +
+ """ + ) + with self.assertRaises(ValueError) as context: + FormRequest.from_response(response, formdata=123) + + self.assertIn( + "formdata should be a dict or iterable of tuples", str(context.exception) + ) + + def test_form_response_with_custom_invalid_formdata_value_error(self): + """Test that a form response with invalid form data throws a value error""" + response = _buildresponse( + """ +
+ +
+ """ + ) + + class CustomFormdata: + def __iter__(self): + raise ValueError("Custom iteration error for testing") + + with self.assertRaises(ValueError) as context: + FormRequest.from_response(response, formdata=CustomFormdata()) + + self.assertIn( + "formdata should be a dict or iterable of tuples", str(context.exception) + ) + + def test_get_form_with_xpath_no_form_parent(self): + """Test that _get_from raised a ValueError when an XPath selects an element + not nested within a
and no parent is found""" + response = _buildresponse( + """ +
+

This paragraph is not inside a form.

+
+ + +
+ """ + ) + + with self.assertRaises(ValueError) as context: + FormRequest.from_response(response, formxpath='//div[@id="outside-form"]/p') + + self.assertIn("No
element found with", str(context.exception)) + def tearDown(self): warnings.resetwarnings() super().tearDown() From e2a0c85f1167c7c32219eaf095c1818b2c74702c Mon Sep 17 00:00:00 2001 From: noon <14049705+noon-io@users.noreply.github.com> Date: Wed, 21 Feb 2024 02:21:24 +0100 Subject: [PATCH 100/119] doc #3 Clarified test comments --- tests/test_http_request.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_http_request.py b/tests/test_http_request.py index 510ae74ba..95e4a7be0 100644 --- a/tests/test_http_request.py +++ b/tests/test_http_request.py @@ -1643,7 +1643,7 @@ class JsonRequestTest(RequestTest): self.assertEqual(kwargs["allow_nan"], True) def test_form_response_with_invalid_formdata_type_error(self): - """Test that a form response with invalid form data throws a type error""" + """Test that a ValueError is raised for non-iterable and non-dict formdata input""" response = _buildresponse( """ @@ -1659,7 +1659,7 @@ class JsonRequestTest(RequestTest): ) def test_form_response_with_custom_invalid_formdata_value_error(self): - """Test that a form response with invalid form data throws a value error""" + """Test that a ValueError is raised for fault-inducing iterable formdata input""" response = _buildresponse( """ From 12b4417c56d8aa76cbe3a36c026962612453ee6e Mon Sep 17 00:00:00 2001 From: noon <14049705+noon-io@users.noreply.github.com> Date: Wed, 21 Feb 2024 02:30:50 +0100 Subject: [PATCH 101/119] test #22 Improve json_request.py coverage --- tests/test_http_request.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/test_http_request.py b/tests/test_http_request.py index 6dc9ec8b7..d1c435468 100644 --- a/tests/test_http_request.py +++ b/tests/test_http_request.py @@ -1642,6 +1642,26 @@ class JsonRequestTest(RequestTest): self.assertEqual(kwargs["ensure_ascii"], True) self.assertEqual(kwargs["allow_nan"], True) + def test_replacement_both_body_and_data_warns(self): + """Test that we can get a warning if both body and data are passed for branch coverage""" + body1 = None + body2 = b"body" + data1 = { + "name1": "value1", + } + data2 = { + "name2": "value2", + } + r1 = self.request_class(url="http://www.example.com/", data=data1, body=body1) + + with mock.patch("warnings.warn") as mock_warn: + r1.replace(data=data2, body=body2) + mock_warn.assert_called_once() + (warning_message,), _ = mock_warn.call_args + self.assertIn( + "Both body and data passed. data will be ignored", warning_message + ) + def tearDown(self): warnings.resetwarnings() super().tearDown() From bc036542a82ec054dd6a36b4b928a9bc6ae48e63 Mon Sep 17 00:00:00 2001 From: noon <14049705+noon-io@users.noreply.github.com> Date: Wed, 21 Feb 2024 02:35:45 +0100 Subject: [PATCH 102/119] refactor #3 Moved tests to FormRequestTest --- tests/test_http_request.py | 112 ++++++++++++++++++------------------- 1 file changed, 56 insertions(+), 56 deletions(-) diff --git a/tests/test_http_request.py b/tests/test_http_request.py index 95e4a7be0..39afc5fd1 100644 --- a/tests/test_http_request.py +++ b/tests/test_http_request.py @@ -1426,6 +1426,62 @@ class FormRequestTest(RequestTest): r = self.request_class.from_response(response) self.assertEqual(r.method, expected) + def test_form_response_with_invalid_formdata_type_error(self): + """Test that a ValueError is raised for non-iterable and non-dict formdata input""" + response = _buildresponse( + """ + + +
+ """ + ) + with self.assertRaises(ValueError) as context: + FormRequest.from_response(response, formdata=123) + + self.assertIn( + "formdata should be a dict or iterable of tuples", str(context.exception) + ) + + def test_form_response_with_custom_invalid_formdata_value_error(self): + """Test that a ValueError is raised for fault-inducing iterable formdata input""" + response = _buildresponse( + """ +
+ +
+ """ + ) + + class CustomFormdata: + def __iter__(self): + raise ValueError("Custom iteration error for testing") + + with self.assertRaises(ValueError) as context: + FormRequest.from_response(response, formdata=CustomFormdata()) + + self.assertIn( + "formdata should be a dict or iterable of tuples", str(context.exception) + ) + + def test_get_form_with_xpath_no_form_parent(self): + """Test that _get_from raised a ValueError when an XPath selects an element + not nested within a
and no parent is found""" + response = _buildresponse( + """ +
+

This paragraph is not inside a form.

+
+ + +
+ """ + ) + + with self.assertRaises(ValueError) as context: + FormRequest.from_response(response, formxpath='//div[@id="outside-form"]/p') + + self.assertIn("No
element found with", str(context.exception)) + def _buildresponse(body, **kwargs): kwargs.setdefault("body", body) @@ -1642,62 +1698,6 @@ class JsonRequestTest(RequestTest): self.assertEqual(kwargs["ensure_ascii"], True) self.assertEqual(kwargs["allow_nan"], True) - def test_form_response_with_invalid_formdata_type_error(self): - """Test that a ValueError is raised for non-iterable and non-dict formdata input""" - response = _buildresponse( - """ - - -
- """ - ) - with self.assertRaises(ValueError) as context: - FormRequest.from_response(response, formdata=123) - - self.assertIn( - "formdata should be a dict or iterable of tuples", str(context.exception) - ) - - def test_form_response_with_custom_invalid_formdata_value_error(self): - """Test that a ValueError is raised for fault-inducing iterable formdata input""" - response = _buildresponse( - """ -
- -
- """ - ) - - class CustomFormdata: - def __iter__(self): - raise ValueError("Custom iteration error for testing") - - with self.assertRaises(ValueError) as context: - FormRequest.from_response(response, formdata=CustomFormdata()) - - self.assertIn( - "formdata should be a dict or iterable of tuples", str(context.exception) - ) - - def test_get_form_with_xpath_no_form_parent(self): - """Test that _get_from raised a ValueError when an XPath selects an element - not nested within a
and no parent is found""" - response = _buildresponse( - """ -
-

This paragraph is not inside a form.

-
- - -
- """ - ) - - with self.assertRaises(ValueError) as context: - FormRequest.from_response(response, formxpath='//div[@id="outside-form"]/p') - - self.assertIn("No
element found with", str(context.exception)) - def tearDown(self): warnings.resetwarnings() super().tearDown() From 6fc78270427c41e401a01a46551d27dd4ddf846c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Wed, 21 Feb 2024 14:27:42 +0100 Subject: [PATCH 103/119] Do not close the underlying file from compression plugins (#6239) --- docs/topics/feed-exports.rst | 8 +++++++- scrapy/extensions/postprocessing.py | 3 --- tests/test_feedexport.py | 2 ++ 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/docs/topics/feed-exports.rst b/docs/topics/feed-exports.rst index f64bbac06..922b765db 100644 --- a/docs/topics/feed-exports.rst +++ b/docs/topics/feed-exports.rst @@ -390,7 +390,13 @@ Each plugin is a class that must implement the following methods: .. method:: close(self) - Close the target file object. + Clean up the plugin. + + For example, you might want to close a file wrapper that you might have + used to compress data written into the file received in the ``__init__`` + method. + + .. warning:: Do not close the file from the ``__init__`` method. To pass a parameter to your plugin, use :ref:`feed options `. You can then access those parameters from the ``__init__`` method of your plugin. diff --git a/scrapy/extensions/postprocessing.py b/scrapy/extensions/postprocessing.py index 79e3b1656..17969c5b0 100644 --- a/scrapy/extensions/postprocessing.py +++ b/scrapy/extensions/postprocessing.py @@ -42,7 +42,6 @@ class GzipPlugin: def close(self) -> None: self.gzipfile.close() - self.file.close() class Bz2Plugin: @@ -69,7 +68,6 @@ class Bz2Plugin: def close(self) -> None: self.bz2file.close() - self.file.close() class LZMAPlugin: @@ -111,7 +109,6 @@ class LZMAPlugin: def close(self) -> None: self.lzmafile.close() - self.file.close() # io.IOBase is subclassed here, so that exporters can use the PostProcessingManager diff --git a/tests/test_feedexport.py b/tests/test_feedexport.py index 277555608..d7560b5ff 100644 --- a/tests/test_feedexport.py +++ b/tests/test_feedexport.py @@ -1731,6 +1731,7 @@ class FeedExportTest(FeedExportTestBase): def store(self, file): Storage.store_file = file + Storage.file_was_closed = file.closed file.close() settings = { @@ -1746,6 +1747,7 @@ class FeedExportTest(FeedExportTestBase): } yield self.exported_no_data(settings) self.assertIs(Storage.open_file, Storage.store_file) + self.assertFalse(Storage.file_was_closed) class FeedPostProcessedExportsTest(FeedExportTestBase): From f19045403a44011a62162d73fedacf5038af098f Mon Sep 17 00:00:00 2001 From: Elias Ram Date: Wed, 21 Feb 2024 16:03:51 +0100 Subject: [PATCH 104/119] test #8 made tests cleaner --- tests/test_linkextractors.py | 34 ++++++---------------------------- 1 file changed, 6 insertions(+), 28 deletions(-) diff --git a/tests/test_linkextractors.py b/tests/test_linkextractors.py index 66a30c635..55ea9eed2 100644 --- a/tests/test_linkextractors.py +++ b/tests/test_linkextractors.py @@ -2,7 +2,6 @@ import pickle import re import unittest from typing import Optional -from unittest.mock import Mock from packaging.version import Version from pytest import mark @@ -854,34 +853,13 @@ class LxmlLinkExtractorTestCase(Base.LinkExtractorTestCase): ) def test_link_allowed_is_false_with_empty_url(self): - mock_link = Mock() - mock_link.url = "" - expected = False - - actual = LxmlLinkExtractor()._link_allowed(mock_link) - - self.assertEqual(expected, actual) + bad_link = Link("") + self.assertFalse(LxmlLinkExtractor()._link_allowed(bad_link)) def test_link_allowed_is_false_with_bad_url_prefix(self): - mock_link = Mock() - mock_link.url = "htp://should_be_http.com" - expected = False - - actual = LxmlLinkExtractor()._link_allowed(mock_link) - - self.assertEqual(expected, actual) + bad_link = Link("htp://should_be_http.example") + self.assertFalse(LxmlLinkExtractor()._link_allowed(bad_link)) def test_link_allowed_is_false_with_missing_url_prefix(self): - mock_link = Mock() - mock_link.url = "should_have_prefix.com" - expected = False - - actual = LxmlLinkExtractor()._link_allowed(mock_link) - - self.assertEqual(expected, actual) - - def test_link_allowed_raises_with_none_url(self): - mock_link = Mock() - mock_link.url = None - - self.assertRaises(AttributeError, LxmlLinkExtractor()._link_allowed, mock_link) + bad_link = Link("should_have_prefix.example") + self.assertFalse(LxmlLinkExtractor()._link_allowed(bad_link)) From b7a7ae7dbbaddd9d14b50c1257370af51e1ac1b5 Mon Sep 17 00:00:00 2001 From: noon <14049705+noon-io@users.noreply.github.com> Date: Wed, 21 Feb 2024 21:04:45 +0100 Subject: [PATCH 105/119] refactor #22 Change comment and warning catching --- tests/test_http_request.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/tests/test_http_request.py b/tests/test_http_request.py index d1c435468..a45293695 100644 --- a/tests/test_http_request.py +++ b/tests/test_http_request.py @@ -1643,7 +1643,7 @@ class JsonRequestTest(RequestTest): self.assertEqual(kwargs["allow_nan"], True) def test_replacement_both_body_and_data_warns(self): - """Test that we can get a warning if both body and data are passed for branch coverage""" + """Test that we get a warning if both body and data are passed""" body1 = None body2 = b"body" data1 = { @@ -1654,12 +1654,11 @@ class JsonRequestTest(RequestTest): } r1 = self.request_class(url="http://www.example.com/", data=data1, body=body1) - with mock.patch("warnings.warn") as mock_warn: + with warnings.catch_warnings(record=True) as _warnings: r1.replace(data=data2, body=body2) - mock_warn.assert_called_once() - (warning_message,), _ = mock_warn.call_args self.assertIn( - "Both body and data passed. data will be ignored", warning_message + "Both body and data passed. data will be ignored", + str(_warnings[0].message), ) def tearDown(self): From 877398a3dee8e92300ef52177b986be16a55f277 Mon Sep 17 00:00:00 2001 From: noon <14049705+noon-io@users.noreply.github.com> Date: Wed, 21 Feb 2024 21:13:57 +0100 Subject: [PATCH 106/119] refactor #3 Remove inner class in form test --- tests/test_http_request.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/tests/test_http_request.py b/tests/test_http_request.py index 39afc5fd1..71d442a81 100644 --- a/tests/test_http_request.py +++ b/tests/test_http_request.py @@ -1452,12 +1452,8 @@ class FormRequestTest(RequestTest): """ ) - class CustomFormdata: - def __iter__(self): - raise ValueError("Custom iteration error for testing") - with self.assertRaises(ValueError) as context: - FormRequest.from_response(response, formdata=CustomFormdata()) + FormRequest.from_response(response, formdata=("a",)) self.assertIn( "formdata should be a dict or iterable of tuples", str(context.exception) From e208f82076182e71a4eb8470eb527363158f4b0c Mon Sep 17 00:00:00 2001 From: vishesh10 Date: Thu, 22 Feb 2024 16:46:24 +0530 Subject: [PATCH 107/119] Add support for multiple-compressed responses (#6063) --- .../downloadermiddlewares/httpcompression.py | 34 ++++++++-- .../compressed/html-gzip-deflate-gzip.bin | Bin 0 -> 8014 bytes .../compressed/html-gzip-deflate.bin | Bin 0 -> 7991 bytes ...st_downloadermiddleware_httpcompression.py | 58 ++++++++++++++++++ 4 files changed, 87 insertions(+), 5 deletions(-) create mode 100644 tests/sample_data/compressed/html-gzip-deflate-gzip.bin create mode 100644 tests/sample_data/compressed/html-gzip-deflate.bin diff --git a/scrapy/downloadermiddlewares/httpcompression.py b/scrapy/downloadermiddlewares/httpcompression.py index 709333948..1e340abb6 100644 --- a/scrapy/downloadermiddlewares/httpcompression.py +++ b/scrapy/downloadermiddlewares/httpcompression.py @@ -1,6 +1,7 @@ from __future__ import annotations import warnings +from itertools import chain from logging import getLogger from typing import TYPE_CHECKING, List, Optional, Union @@ -102,18 +103,18 @@ class HttpCompressionMiddleware: if isinstance(response, Response): content_encoding = response.headers.getlist("Content-Encoding") if content_encoding: - encoding = content_encoding.pop() max_size = request.meta.get("download_maxsize", self._max_size) warn_size = request.meta.get("download_warnsize", self._warn_size) try: - decoded_body = self._decode( - response.body, encoding.lower(), max_size + decoded_body, content_encoding = self._handle_encoding( + response.body, content_encoding, max_size ) except _DecompressionMaxSizeExceeded: raise IgnoreRequest( f"Ignored response {response} because its body " - f"({len(response.body)} B) exceeded DOWNLOAD_MAXSIZE " - f"({max_size} B) during decompression." + f"({len(response.body)} B compressed) exceeded " + f"DOWNLOAD_MAXSIZE ({max_size} B) during " + f"decompression." ) if len(response.body) < warn_size <= len(decoded_body): logger.warning( @@ -121,6 +122,7 @@ class HttpCompressionMiddleware: f"({len(decoded_body)} B) is larger than the " f"download warning size ({warn_size} B)." ) + response.headers["Content-Encoding"] = content_encoding if self.stats: self.stats.inc_value( "httpcompression/response_bytes", @@ -144,6 +146,28 @@ class HttpCompressionMiddleware: return response + def _handle_encoding(self, body, content_encoding, max_size): + to_decode, to_keep = self._split_encodings(content_encoding) + for encoding in to_decode: + body = self._decode(body, encoding, max_size) + return body, to_keep + + def _split_encodings(self, content_encoding): + to_keep = [ + encoding.strip().lower() + for encoding in chain.from_iterable( + encodings.split(b",") for encodings in content_encoding + ) + ] + to_decode = [] + while to_keep: + encoding = to_keep.pop() + if encoding not in ACCEPTED_ENCODINGS: + to_keep.append(encoding) + return to_decode, to_keep + to_decode.append(encoding) + return to_decode, to_keep + def _decode(self, body: bytes, encoding: bytes, max_size: int) -> bytes: if encoding == b"gzip" or encoding == b"x-gzip": return gunzip(body, max_size=max_size) diff --git a/tests/sample_data/compressed/html-gzip-deflate-gzip.bin b/tests/sample_data/compressed/html-gzip-deflate-gzip.bin new file mode 100644 index 0000000000000000000000000000000000000000..d66f4c5a03df80085208d8990df59e1702c63b50 GIT binary patch literal 8014 zcmV-UAF<#ciwFP!000000|7T5$l!RKHC1(7lmGWskY!}j3`dRLA?64iIN z(Bm6+BEw8}=7FI~PdU-NoI$sYi==Cu_J|W)>riui_5yjZAmBi- za14I9JL=!~z9y>ZG1)S5g}nXRqrXC3vsTsJxlC(lv|L*QFL5BGB|t7A5yV z8)A3oNJKx+@#>nN_hRjk)tc|^i3jX>&B*BY@$Ev^66`9i!wQBVG74zW%9*8cbkc6?RKdAV?G-_trm>*sW}ar*a&5LpX`354B2 zJVft*L#}$lU@P1H7kM35$fXWsR*Qqj@tWcB+nQ6xzO@V ze?a^36_WPKCXE`Ns`Negn^k~p+;;T*8$ie;1bp6St z(w-Ipec=t_y1GQ*KveU{lv3?#F=k0kbRY>0A4F~1xD=?@M3uRBHJka-ks|i%Zhnt8W>_q2-r0 z^-u!V5+n|%pa!682{6Q>_06P`h@o0wBYJVSaF=L&RRKcrX_*L8RPz(U)gdu0GGdDR zmc_)V1l1k>bRUH7XWSo5PdMZvi$h1BEM-6_=In`%3tnn}lq#Bg^$gCISUqXVBb1=* zd=tDfApw0l+JMM+?{BO)BF&g7SF7Y^v$HLN3tN#W8E16ETYD2oYs&_?H2}6vR3UK( z_{)kAF7wb}OL;&Fv4dgPP)f^a3N7o&phv&nA!Y*U!Q7&zuaeqQZM719B2RW`lsx3o zn}CT^%CI5x5-raR$+spIROp~2V919=AR9CgHJ-+tBW9%yxo|vdE`0z*Kq8?Yy!_nCd0S zh&z!TM(H1d`T$FT@GA2%irjKTzDys+x{4~d>X-f>r5ug~b{GdfW>#P(#egxpc%qf6 z>DP)e>+TYNEZc`qf&?IRFykM(p%xoY;UW7o?z&cs=h!}l(6Jr)`SWdK%$PEnW3qZN(FbDwU!XPf{z^VG<~FX z&xfzFGk;zP0l}tQw4^=M2HBeW_}kj`4nrkCy!EuEN`yGtlr9ASNb>n1Y8?{hyUM@n!6o3f z6SG?oNxgcFz!&||FlnvZyt!%0ht!C`uw6er024X2R##+YlH+!Jlk?0>v5gEL1%8^X z#)~KRTHSOzRcp-VW^=~w(7#UAm9^kQx;Vr2PGE?Fi@jVzteIk@LTaV7eJLKzoOm^= zPO$GM*vFVAZU!MA4#ARF2CfGRScR@PcD$ zx534=Hc-ue8xs{+-&UcPpC)Xd7P2{PfTKW?JyEN9_`8l>uA(3n5>A+qtPXu`_XUVQ zSssHW4u@`d^A|e*kKaHzuJTpiGM-8+Ly1c<2iC3JFUo< zuG0h_{fJ>0^G(Byut|Y%JNI)2#|sQRp@sAHH!(o6B>-kztNl)s#BD}o67^i9h602L z{~(>?#Z}a6wPtl!Tjv=t0QYO=sSl_g)aK1@h0XK2QUM>Bq8mX8455VR*bA`qLcE{P zM=BU97HPKOP!*(gOI%nFIMs&%*7TC1QXyPlr!Br|VtiyOO^K>`nL%uB-=Xf-N=YvA zinAzm&>c!yr$(75NdAB;6JUoc<4hR$PCiEiLK4#-XXaeqq`;M74STk+$|gl@#TbsT z#1dxlq1DSMz+?M2UO`98lP=Pz75o=3rSO~9friQqcBbNCTAjSQS6nH$OTla{J~mI8 zOpmodeX0B7fihrZv|maZ?9PR=U{+SH8C)Sl+Y#C*^iBvRu}u;!hnbWd*Hx)Tu`HOM zpUC<>_^F2Vzj7;UJsc)}GSxslTdO>3RL8-k77`?Shp`IDGp?Wdh{L469oaO;TUu@%0*;vkQn?|LgUZR| zJe2~e7&)3r#>c=AgZ8%M%8nW>k}Mg<)`AFoN#oKU=p_!*+2@p3Xb%dplB7c6#m^F^ zJVQQCvP+cc#vyt!(Qj zKjP3Qeu8C3XBwpx(-o(>uH$xE#F%SBX{0P~fp#%uib4XImbHj=Ouiy|w#+_)3nwU9 z?5uu@tThe(elbD&;ORsRtBxK!DNJFn5ZE(U86;msFc&>QK`mydl9MQdHFgNK>8sXa zv@MeNjEsL}qsm5UR>g7L?ljS^nA3SIHTN^E_RnREelLy@BWanbG zquU4T#~@qxdWf@1^+a{AB@u`3=0~=CRJK)7#IEy#nH@7?UUhTzK$}MJpb^ABP%5Ij zeyxZ=Q?VVUj2q`eTk7^z$5IzT(`v2hR{h8uiWx+0IY-wX5YPI@DHNT$RJ+Z-4#@T@#!%X`l_lvrgE4Q>mq6fs`- zQ7EgmGApdZRm8D;n2*b)*`R7UB^_0^4*|O6d*eZcO^iioi|TN;?fXLNS(GU>@ALV< zOzmx4P+Q!J`I<6umbbnlFN+XJ<38VdIe!3dHSCX)tCWekKgVA0xc0cP8n<2zCq7r$_&$>#yt9zxNF&WewuA9^hBUskUknYcv^aAdSlL~f@qSeOj>tl zVEbAjyk$2R1oR-)T=4d+VlRSjUk3G+hNh@*o6aZ)?S0D5mer{6QycM9yAffGQ z_VfUD341ZN6LUGQYHkxKjMO(-%?t3jeq9#h`Z1?@3ws{3yVFxHzwrI1@#QmG5?T?+ zWe&>tDBWs5H5zyh=5c6GdF9-uF_%_A9hj$#cHxkcnxlmfpP$c**KPEpW0H~ineFyY zip9_NO-^rd@+Oq-OJxNMwHMm<$;d^yBBwL|p^?v{WPtrA`x8ZGOECmwOIY;NFG+by z*}{S=lWfcneHg3#RF^R3UctdXf+7_Hil z6d-11AO0ks`-HCq9g(iP!1M31F>XJUY8eXcUucF_sfX>x2&+L~Kz@>3Tj#Dd5|E8C z!<QWOGi~F~SlKb3yxK$ymy9d}zWl`l#B5^3K`vs+c}trIdW$lDBndDt@2D zAAMtE7xhRUTw*`pAYj%+w8T`suGak0TPL=(UI-C=oz|u!rVBXp`0dGpB-Si#RhMN! zN#F3di4856Utsk=PRy-c8(&Yetg~DcF>F{alA!UE-UGbm35gyo?ME3=LZtoedinK} z6hGfNW|0!~!P=Y(Hfb9Rrsa?tjUZx|fK6pV5+eD0P$K-ABaiq#O107{&BfC~vH7m{M zEC2n~Nqh5o@x<3SMxK1^wLTzxrAByM(n>h?42QnZb`}w21xROuo`^hOdWvF$Qh{9Q zuUVQKHFUQ|cPmodPVCV>dHRw@%=A7S({K@6t9j}1jnLDs(f|zo z3`P&G#bJ_kPC8u~c~e~-VMN95EkcP*^IyNYgJlT>P(9T7xV23Qk31N3wYT{-^xSRE z-rC&nn)djd=@^j1ma_7W{g+F!!pfFtyYJ7&&acShdj6RrC(azS1Aca`2Ha;IU!Nfk zvc|%Ey0hC$z1r_)=lD+cE|Ibe(Y4t|zsOZJv|kr^n26bl`3s&!o`n6q4vW3NJb4j! z%;VdBEn!t8X6XBI#fIGOHL-%w{H-s@{yke0RhYnZ)B5&M@b;AWAVAzR6@Po)Oth!Jcvi1Eobj`;5XZY}OZ_ zlnNN&{hQE{J|j%T9HpVJVMTq+=L_|;jY@jbEU0Dae0XnZGhk)CR^a`&lb3}-6`qfD zZ4E#J{ai|AvuO5gy?GADo|;yexy_EptN~b8j5h}^V0N15{~ZeKMWs3+hCyF(2JfW~ zGrMcxT|fH6=X5l_rE)(3FaHslsKQ+Q)E4SQHVNePoJfs)VDdO!qXRU4jK31&1mJGq z3c`x?vVs=|8V;E~P95$V4U$%ReJ7K79qpcwB$o71pyfVeo>e;9TH}b>ptL4rm!DLG z;+pZ)_acn`lI&_=*Qy?F^+*B#lJSWhhoD$$4B%6e5IVhmNh+JbfN5}+>_?r1*G@F>u%?zVnIk-zqoT}p!1WZdl~ft~!*?-K1S@Pm>jaowvWTzM3M z3BO3Q?cBgXz-ghNjex$)Dyy}b^}|<#e!ZPG_y=}x|A_)rabLSx-L3Fveo_CZ*(l&Y zD-PmNR`wb_gMTng=Jr(dN|pQ^@_U)=5Ad>nB1uM>`*TdVu_VwMedMxb*nyPzg`whV zkp!u@25{G8YUy=6CQ1wNN)g&6{F=>QtTR>ZABWAWo}llHD)P_;%+YYq&gY@8@cO_s zgsbW7fYdvsb3{I${S2^W_|oxnh~E+hdmPVOOZg(**+rdNR_}~QcPu)+`Bfzp9$76y z6T+np{EKgEE;^}z?xk*7^H=Rw_Ys&}O=pgIx*((K=_d(n<;Q-?yDPf$immw+a9f!H z&u^NUNJVA#re0KVsW!U&hkgFmIcdB0{_ z#Bl%6wRy!kdZG*EcO>Zk^tvU+>`RLvYZ(XNwi&{ID`}XlJ5jhWcb(`*Lva1(KUTBV zhfaSma5(B6EQ06UYGaH6pkq)+gpv0f;?D?Z<(D_BQI+_A-n$!-Rx+%bNmE5C^N6sZ z?!#B=k!8R0OqYJba7(V4f z>$L&v0%`mTRKf(w9UN05)wyYYrqA$yrzw6Dy=dAet5K{Gj|r9|XeeqX^HPspIr%#f z-Aw!@o>~Pv&;!?>3M34&iT~68Cnva*Wr1jYc~RHe=R+*6RqTW*oNBxbKRqZb9+QW% z+~M1SJh1f^$Bx-%y(xrahiea@_nRT6?LEfN`chIKuKs7I=8dB3HQ$^*Vq_U4V;+(w z&xG)L+F8d!V#+C|rY7Nm0t0<2|A#65JFnvXdz1cP70V_MJz=!LJ0Xg_V*T+sRYv;R zk6NoD&MlCTj4_HDj!_nEA@WQ$2I`K>-@yWCZO+TFwM`)3rU>GemRv%4iXAX)Dm$R- za*9iw5l!Q?@hHkwSSVMuBT)PUJq%QA%gVpgTKg&{h&TAP(v%`-=@0$|LgRkkMv*{8b~W^|czkMAmIW zmlU}Cp)R`JX^iwkhun`=j|IJMt*!gDfG2X2|n?_@z@CA|)6 z3`06Xrc9BdI_ki=huE7)wOrRUO1{@QOe>+%_%A2EQQWFNd=WDq<&a z+_E-HUS;ZI-9l`!iYI!%9(=|zhQ77j+{T2b5cw5!bbDUf=X&-JWBK8lV86803mq~1R=iDU`^3$m7bIF+9 z1>vegA@&$FPKA@#v43`Eu62Rt3%EZq4I{GSipQk~{<(ySVwZW*v7nY8DCIaGya}hV zb^LoCY$}$>rHhKM+9xQUJjFN)ix3ju;lz;|4_8E@aq8(rEQ`u5(1-rvY8O*hm9TM) z?v19F-4lL0hz@HJwCY-)YAfY8wL@DPP!sFt@}Zl+BIlB3eakrVV!u0lfz`qaC+TFa zo}g~k#v4}w^-$=`d?OmxcKMV^k9F?V$tm<+F`dJ-+9d2#zYW^bZ}=`Ae|+H{TIepN zl-j37Gk^rkAH_*WbxV5sq#3&T_Y;W{Hh&k5?X!wpJ5ztb&+T^4JdK=dGS%b;M3lok z@2Yrle?&a;Kd8JjC2RWrb~TWLyojo%L=OInJrbKQS=DL^zTULK{mimaP~o>9B`!G9 z1rZ|Y`F3|MTRC2aZin8z6E*r5Y@{V=g`>eKzeWSR-;Hm$VN7-k9`}SlnRez0?PW_HBspUFdbM}- z`4+T860?A9h}Ey4oA{2x3_H41NP%T%chNw{(GIq9EQ*Y9xGp-=#g3S%a<~fBOt0%KB-SuxU8W zdyDjj-LJI#r2m8#xQ%lF^Upn6O;t$R)PT|G8FgluVbv@@`NnC&62)cDzqs-1ki!e2 zQLpvFTNk`&ItA-gK|IUd6W$WE`OV2^VtdeoRF$(4HPoZ{lnn~If%mEl(;L+Y<6f`68G1^B8dYC&;MZOJ$>6i> zf)IOGg!+1{gSn9E?Bai$VjF=Y#bq>rVp4u97H}lC1h(a+7xTRT1R8SZT zjjA;JE2E<+!gMD)?ge)hV97F(Hu!bu2fU$dP(%Q$vFOokK4LJ5o~bmuh}Tv7!&50^ z2RR8~I!V7qCEjqF?N_u*)FCy>W4a0ARS>d}F&eizQK56ySvR6MAU@p&Rt5gNuE;ok z+%4f_^-8>64gPq2)FWXPIDL;JVuG2lZBs)hSEL#BEj;puW0iGW@UO~-99A96VP4=A z7vEFbl7a>Z20k~mk2MEdGaQ{L>X)uj`mt@oggAT5M?lu3kE=B-y^pm{{fl6rkLNJ7 zKt^x8KUavhaoJ{Qng2|8e0|)w2p2y6!scm*n_g9g=;I|AEpsSX7>LxdbZi~UQ;pL$t#qTaOdBB3>-Lu`E&L;goo6E^}JZg0%i1 zzRKR8DoM>^WCz;RRey$cp8V!!MKh-uH3Z4v=PJU%cenZLe%~3n_3%XWvv!dd!_z1K Q142u<+9f5~j5i+u01a&HYybcN literal 0 HcmV?d00001 diff --git a/tests/sample_data/compressed/html-gzip-deflate.bin b/tests/sample_data/compressed/html-gzip-deflate.bin new file mode 100644 index 0000000000000000000000000000000000000000..5066842ef7d137786f43682c99c63e24eae261ec GIT binary patch literal 7991 zcmV-7AIRW%oHbQ-T$BIzRgh+cATb04X;45K35n4uFiN_IbaxEt?(QBT-6bI%gMrdW zNC-&B1is_*d;R`>&U5a$aqfN7MqxiOdBgVM33^wKK@!z>x6tDob|S-J=M>3F*C}#i zpg=;RAY+OFu42tH27|;~He|t~fdY4cUpv;8DWh17mkiDKZ8oR6255#$`;^y|kJ#;? zxZmCH0IwUPfE&w;W6mC=J92Aq@D5>dKjnXAi8OEWb_!_iS#~fwGCbtuyjp-AtQ6Qh!9x|h6#k-K|Dn7e?zW%!eA@g{ug;2 zSIDIfWLArV$MKrs@!OhH#^cL0WYl^0UI%=kR9k=B_A)Qm$E~I3!7;!$o1qk8bai$0 z(9m&vV&{Fq&xAfh} zCx_;QKOonFU+l7@A9jL(uMon{A@W9RA`k8t>d`GQ2X~JEpZi1Q=MQO|SK8vM+5Rqb zNB)|77G#M1rOm7EfR2YdZP>Ajwtq{<%8JqQr+_AJuiL*C-u)d>S!0bV?h74vhZC@3 z=mNR;@3eoAl-w)#i(S#AN(b?X)64iR@2lOVp%xs(en(=nItgp9 zteho0ETa9jxz^azT6KFto8yn`)^wyQe)r2mxqRX9r0n;eZthlqzi)lf$gpawo<-4_ zWs@Ru?>0`==(vF)ue8i7)q!`hc=KVs(4C}xBq~%nyIW($VR27?5HdSAd$p+PG+Q<3 z=JX?E5BbG)ru1mgZQ`p<*2uyA&2`VF8!ev;n7CS9nkbTpoDqag%nX{F-B*ajmZ_E} zzAMc%WH8aFop{dxa@|W+WG@82m*hw`(NKl)veJk1wX8NmWJ8o0yIn~1i|&iVSO4r6 zhg*`A_INbYkH?l5sY&xufWq||LC>6Om9^Nn9Za7uH?qn{#^c%s->hatVnbxHDVago zvvYQfHn3dM0Lrh!Y|4ihp`{x%M@qkdR4w;u5)PNxb4msS?e+R8M%i zx|G#;|7Sj>+Kr(nNvl=s#h7&K(^Q)&bFZS6yX0k5*0Z+GlJo|jkF3i4$;^OQ57^5= z(o-G30nwx@Q5gl<(wK&8d+RxS>(ZVU0e#^O;<~y-;6POK z$dpp;YB6R>O>`g$4Ie~p+PD;`*F=@Mcjah1+f^y4DG$KRv4gK@@*42rs>OBK-tJk# zBo0IOuH@KtboRk*yOW*Pu;72iq4OfT!*m6U0IviN6^JWD09zwx?v-QI^BnyiyP^*g zk*m=WcxE50YsMf3%ODSkfHa8!#d4t`9g9oS+^cUH#i8YwHuX>f))FKRr=SL)YY8yK zqV>(Bl8B*NU?X~Qw{Vwed{qHL@oAX|QdIL3!qp)$Eiz(?`mjtgFDf0Qbkd-V*?mRLP$$|IDZ?0gfvG9dwdI@*B9ckgek zI3mrMDOaoHX0x*`f(u)bC>dvT!&`e3NNdXmxitW`O;jOq2l&g15H9o3U`u&G3bBJ> z*HB8!XbLUs$)HES-XUfJ>A~EhrmvFPQf;*oej-nHXp}tU(VKvYQ_8R*^Aau349T}9 z6;$Y;Bw)ygL?9b95H+60oFitX4Y_bUYc72NL_jIPp-ix({Hqu5WWBCKa0J%=WL4po z_59n&8e0u^R4PobS19xvc?i?yj1BpKWtr@ZO6|O|N0{m*$cQ_U9Y*ONg8BeUf$%Ex zGK$=CL%vKO#=43sx9XSvAEg|Q1a=q)K4w;6CdGg;yLh6Ns_EB?G3)LUe=OUFPl5y> zbTM8LgD@UoQj|SD`V1>%D(IVWwuNEWsDO0RdliTp;sd1$<>lv6pwe66%xrI~f1Y6I zld8TzRFtsfVAg~R7eJzpoSC7ilS&15o3)k@yn>G$@HBm-be|jwv^!mBhuF5Ldwqin+(Ejek3mwEyKHNNTa*p-HyOc_60M+ zaCzS!OmznYn@;+HVl+_cx)fJtzwTb{g>KDC&4>kLHi|*)Ehrrf9Rb0nTePG-)CSp_ z`uN-0^$tTNK)m&|rb>i3+LSH?|48!rA!;2G=DW(j>cJ)8wiB~k5J|myjldWE(J*PP z+q}7H%7@g5z_49EJpdCqwN_VTWs>7|dz16bOtFm&9|eAzt;UNd_FCO^J5_7U=4Nxo z?$EzZ)s?m2L%KM_^-f@jf{VReLado$q(W+?w0$Wa&762Os!p))C)mfBCT<2H9}dBi zR|c*J3Rs1%H+HG@lRIb;>$kzhwKh=Aej5`NSl?Em zmY*hUpBAz?Y=EOcl08wYdHB1IU9O@a6%tOEkgN`UZTAI;KUp4wB@ZFu1!;_7t`2cm zwHX^~U1`AuLc#`lD?;bD3af$7>?>0C^s>S`E0j~d|B$UF(yGz|dMKp=i#7A+Eatv0 z+Y@oB!$ceSO?)(1$=G2M#J}2v6p4R9z1o+ruH!j!9y_hbm#)(U9{q@681qfTjIc?8 za69*N2FD8wJfVg2^*1p&3R;9@OT|ZiUVBx>5ljn4%j&2@Iix=-3Oe^g_I!&qpd4Di&$B;ZPN%bxT}W z4>;9_0oL@AqEaDTU#Bg;X<~e2Dou&1d6_|MZr`Eq)=Eh(@`|%4bkH42S*J#sC`kT* zD-&RcE8|QU_f9@X140thA7|!V-lV{lVGVn>vC1YzY{eLku*4E(@uAhrD8OU;H(o(U z%abnBs1^JdFQxFC)`5n~40fjCVOpKMx>sB&xJ$uoEj~6+nM{whKz*tE`iR4%za7~$$6H!%9RiM-1X8&nl7q_0gEe*twdt$YVze!jDP!O7)T)*T{@n^Bs`>R0NXRS0ODLK^4 zQZ0+L9I3`&^y=k5k(1>-dmlV!_*)w-t5wh_*^>v{=w#<&wxinz>&GBl_j-u4O7%o_ zuO$(O@8(Cgd{nkoQN*tEf|(sNVqSG~^+20O@SqXIKTs;7x_+&QKvS_Dri>fsLtEZT#Eb{4E8b2D$k6=_^BS(=abXFVCRhuW!R+ttjZes(H`C{g>|LktN7X2U zH}sx~x?Dzg;m><8L&a^R&eRVqJj;|X8M-iQO};NXDLa_^tO3RcU*0#Ye4l;VDXzm+ z!7=(y%fq)?kGKd}lSCz4Oz>&1Q;*g}RFU2H1G5Iz)bCJTu@uwiusx{ahA8f zA}@;&NaH@=dO3dpZZ+(WlB<-7xj)BV@3{83uo|~s4deWO@RJ}YoA_s~?5Z92w)t)+ zt~g*Nk>n`}lyzawX;Hn;?NLGNLG;PyZ6LhBou#BapR?V*KwCT|i6Uq$J zpvFAFMZ=2322km{z&X(1v@KYP{Q@astGvdgr!uIq4b_shiwi9zXuWD`+ zD2&uMS@@Lc#dNanGl7tgyg z2?~~d)P82jl<>(=w_blhtVD-x?dg5@uk)?Xw5*Y-UKp*~juaqfW*`0}p8JHa1RasC zyTJ4Bu`zBxlxi6Y?O$kyR;h>W#t5rHUqF76TwCX^H4>1GF~girA&aurTV!)i*fGKq z4s${KW64;`aeQdPGWw|6hVstY@~W6VV5O9N-jcU_LK}p~6w}}lcmtSD@KTgc8 zT^nCdv#hgR6ftaAE|Q?}limZo<_U=&EbT`bQ9`8s?Rxq3lN3MSIcAX(^ugMB=T>94 z{U+4FQEyUwVpSgHnN9>91Lu8#Q#dMt3Vx+)nJ# zK6(0*M$Gg+9Mf58F^D( z9brVp?kz%zO!HsAxr1d11W-NH`M9-B36DG&bhWqnHT2wV&fePG@0#}boaq>l!XNM!(2aHMCzBc$kRUiTMkjMV^HHy$*}LzdU&ncg*A4el1~DBxdOQam9w* z?lrN3(EP10$o@TB6IGbNbkq9wQ1JGY_#i;wV74aUvQ6}*qT1xj22WOE$VHbf)e)Yr*=2gk%Lvj9F(8Ly?r%OLTOq6~U8;Jr;x z|G9We8Si~}H%!JH#=gm9ES?eBOu?RV1Ouf-Kl_ZrEo{~opOgw1;QgD>kv=0##2lre zuVF=f%;yXBw2ex7(k!TD>3n!^X)|DDy;k7;x09EJK^2~lbZrel1N~e|WwU7ZY`u97 z$DW#2n7Pf4$E*QZSBy6YE?{<==>HuG>_w$IA%;O;aR%?D4l}!J;9WoZ!{>A~zNKi8U84guevH2o;{@Pt;0nTu^s<5%1{w~TJx(3& z8V!OsS>3JhXMR!tsM#psKPwL6P*(ODJ%fKROy>4f z^h%Zd9P)da><{p=ej-Umnfr50xUnSA8hzxlW!Qm~_=Tb3YLNt~xCU_7WNPVkJSIvD z@JbQdCH$JrU#v4#?H`BDtDd0mj4JZb1ggv5Y~{y(%DXGN^NOwc6mVOa0ncxmnMg%t_NHD`aH%%B z{D*!1*5q&a7hdmARB!x*=>UdQ*P)Hf{}CqEa)NB*OLU7$r%)1!WIPj~+L7?cLrjGu z-%A~N^rNr#EVW#8sKpuBe9^_E5DV5m^=j1^cA471qN-ZcF>}AH;#I4BnTG;jvw5cR z>AinxCK^Sn1P{D2G;?=Em#$&ri{mXXMwko4GA66iR0qDAGI7s_({ql_ur<5` z`O5FIlm5Xp^U1L=NoA5ZD_yb1>txaQI?o)gSU2yDell%OI$gCBnE7FftFhq_HH7gK zvQ8#vJb+$q2nyuNyjXTh#O%D99&e^X)xkCtVWSJQ78pL|LhH2w>jG)~3RJ=b$sHV1 zBh|TSex}dxf2S#a6TN8KC#zAc5swL$BWNgUCi7B{T{-zX5Zz4tCZ1XaJJ18yp9&-l zvWfrG|0gH7lVyQueR)yW+UG+ou2t-WDV%D&3_m?6D;|@FvfSa@fjqGF7RQd+X1ytd zV~1-Gp!b_0rtLk(&-zkQAFlpqr{;~K>own;K4N4UBx4?uCeMWMdfHjXLSo7(rluz0 zfdT`4D*uNm{yVSY{d<%CU=_5Rv9(Pg-=+xSmX=&Xd5Rq{Y$`jT>vD=qoDogqwDBm)Rahuj zwIfjc13e5>Y|F~O(^~r~CWtrqwbGO#Y9(Tc9r@j2MgIauWSK=mJfWs$vB*exer}OH z|7tJNF**>7XChJ*bL;$dp~@rjPop4H@^oMQ!K3h!_~eG<*+*|y6uLoHxlpo>n={v~tv z4BZfGvX2QN7D*)5KQCpwzi^g&;gNUVi&_gBnP7pt#> zZf=OA8ZjCtbTSq&t#o~}gMoVqhdA3dExN#$;_qZZVkNx}Y79dK@d%MZHt6!bA4~h+&xgOLmAz@KQ)oN|wNLRk|Dk2|ziV(Uw5Bj?;D5%SZh>~qPO-38&QLm~DUG){$+*Rg+g zX0COC<_ow#F%2WK$fb*lui7Ul zo;<}k3X2dD-{HiO8V^@QqH*f!L@bNSEzpPl;c6FCR+X@EjP8x5mfaJ6JBSWz5wz-B zpK2@RH?>1s8c-AK=klSOz#`|8W_`;z@?yU`e1X-%3Mc7guAZQ7)y5lF0rgPm%X}jm z)^_=nNso2z)yXOJUNN1+wAv)>Q@;(`(r@@K9)En{9$M%wrIgyIMKgc|%OAx_M|Dej z`lK1U`S%lv5;lJqjqS6FTsu>L!O!h>&peHsYckd321Jy@JnyP_aeqWS@;|7&GbL;K z{&qExgS?2UrbG_@iaipWFIm-U3clX7!Trp#QBdKxA0;k0(ghJB>G^hdE?YTXhHi)6 zy%RP17i^>@X@#S~DZfSoyx)y)xM56o3Lf`_Kbdys3GHP`FFGe^NcJoR1<`HjQWFdH zFaUDNKc>nA!3wN1+YY;@T_n}L!`!|HomkMhsQL^18i*iBaA3)Ur=i?~E)K%yeyw1& zMWw*6ubnD=zMM@csBC~{lV4(DUl2%VDZNK+NhCR9`Fgc?^!XOFLlU!qZHU#cpqu!P z!VEjQR7iny@WSXC$;@tStk;l`SA*b?S zP+Xv44!3lHWTGJ6PHH54ecz=$m05$X9)J4`Bg*<|m#}F#&3lXVhTX5U{G|Vc7PyUb z0Q1j1T1{0*+SGv2=oxipm|@i{Kl#RK!V<-0&%e0w>yX0>qEWB)!dn--XgUS!R6#t; z-4ostwE4}+XJUKMgH)BX5jE7K_~YGksK)T!bE_E>ch=;8LLNy8-3wcydeP{pA08b~ z%1vMzE|d)lyMgzr3)36b2;*L_z8QK-f*Ms{bKuuq{K??6?1B(`SA_a{tb@6b>g?iw zn_?S*BgJJjfMQa9E97+z@bA@{rZ@A+42VK83%I3ONmNi63yrEY`zxcPDZ+FoJMIN{ z6=2CSkv8~s=m)%^Y*0i1tFh?OY(8QziJqx6yNK6S`@>TyV+T12U^+>^MkU^Gn(bG# zOVlAX%450-;#Cl`kTDv!I#Ho>)mb;9I3PaV237_ByspSNe%vkLWA#eBUJd?uebggi z6*zs5BVvM?ux(RACs(8y^({Q|hGUg=T=1{Th8$KM%VA#N6c^u9+LD3>2nIekw2w6h zTQeM;DC(E4QTnlM!h|?`%tt`hq>rmLEWMAlPW_8uppWM;v_M90ygyfnwsF~JXqo>^ zcYJ-^xCj?M{leyHhnrqih3MlY7%g)sSQv=Zv2<)5%2SQxP=Im(-$gIq_-1-Vz^C05 zFlr+*WGx-rB}25sBU_IXw<2CFy|FA&<>8cgY%X(J(So%8Aim1ppDIbsVq^!})K!0m tb)Nj@W<@in7&QdR;O8pB!FRX$>we!Ex%Kcw^s{!67Q@pg{{uoxxY|2R=d%C+ literal 0 HcmV?d00001 diff --git a/tests/test_downloadermiddleware_httpcompression.py b/tests/test_downloadermiddleware_httpcompression.py index 9deb81c37..ae5569d0a 100644 --- a/tests/test_downloadermiddleware_httpcompression.py +++ b/tests/test_downloadermiddleware_httpcompression.py @@ -27,6 +27,8 @@ FORMAT = { "x-gzip": ("html-gzip.bin", "gzip"), "rawdeflate": ("html-rawdeflate.bin", "deflate"), "zlibdeflate": ("html-zlibdeflate.bin", "deflate"), + "gzip-deflate": ("html-gzip-deflate.bin", "gzip, deflate"), + "gzip-deflate-gzip": ("html-gzip-deflate-gzip.bin", "gzip, deflate, gzip"), "br": ("html-br.bin", "br"), # $ zstd raw.html --content-size -o html-zstd-static-content-size.bin "zstd-static-content-size": ("html-zstd-static-content-size.bin", "zstd"), @@ -205,6 +207,62 @@ class HttpCompressionTest(TestCase): assert newresponse is not response self.assertEqual(newresponse.headers.getlist("Content-Encoding"), [b"uuencode"]) + def test_multi_compression_single_header(self): + response = self._getresponse("gzip-deflate") + request = response.request + newresponse = self.mw.process_response(request, response, self.spider) + assert newresponse is not response + assert "Content-Encoding" not in newresponse.headers + assert newresponse.body.startswith(b" Date: Mon, 26 Feb 2024 10:53:06 -0800 Subject: [PATCH 108/119] Remove usage of deprecated mktemp (#5285) --- .bandit.yml | 1 - tests/test_commands.py | 5 ++--- tests/test_downloader_handlers.py | 28 ++++++++++++---------------- tests/test_http2_client_protocol.py | 4 ++-- tests/test_pipeline_crawl.py | 4 ++-- tests/test_spiderloader/__init__.py | 4 ++-- tests/test_spiderstate.py | 5 ++--- tests/test_squeues_request.py | 2 +- tests/test_webclient.py | 7 +++---- 9 files changed, 26 insertions(+), 34 deletions(-) diff --git a/.bandit.yml b/.bandit.yml index 2aae8a0aa..8c6a08e1b 100644 --- a/.bandit.yml +++ b/.bandit.yml @@ -4,7 +4,6 @@ skips: - B105 - B301 - B303 -- B306 - B307 - B311 - B320 diff --git a/tests/test_commands.py b/tests/test_commands.py index 36f800850..2f36baa87 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -6,13 +6,12 @@ import platform import re import subprocess import sys -import tempfile from contextlib import contextmanager from itertools import chain from pathlib import Path from shutil import copytree, rmtree from stat import S_IWRITE as ANYONE_WRITE_PERMISSION -from tempfile import mkdtemp +from tempfile import TemporaryFile, mkdtemp from threading import Timer from typing import Dict, Generator, Optional, Union from unittest import skipIf @@ -82,7 +81,7 @@ class ProjectTest(unittest.TestCase): rmtree(self.temp_path) def call(self, *new_args, **kwargs): - with tempfile.TemporaryFile() as out: + with TemporaryFile() as out: args = (sys.executable, "-m", "scrapy.cmdline") + new_args return subprocess.call( args, stdout=out, stderr=out, cwd=self.cwd, env=self.env, **kwargs diff --git a/tests/test_downloader_handlers.py b/tests/test_downloader_handlers.py index dd07d33f1..d3fd63847 100644 --- a/tests/test_downloader_handlers.py +++ b/tests/test_downloader_handlers.py @@ -2,8 +2,8 @@ import contextlib import os import shutil import sys -import tempfile from pathlib import Path +from tempfile import mkdtemp, mkstemp from typing import Optional, Type from unittest import SkipTest, mock @@ -107,13 +107,14 @@ class LoadTestCase(unittest.TestCase): class FileTestCase(unittest.TestCase): def setUp(self): # add a special char to check that they are handled correctly - self.tmpname = Path(self.mktemp() + "^") + 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 def tearDown(self): - self.tmpname.unlink() + os.close(self.fd) + os.remove(self.tmpname) def test_download(self): def _test(response): @@ -122,12 +123,12 @@ class FileTestCase(unittest.TestCase): self.assertEqual(response.body, b"0123456789") self.assertEqual(response.protocol, None) - request = Request(path_to_file_uri(str(self.tmpname))) + request = Request(path_to_file_uri(self.tmpname)) assert request.url.upper().endswith("%5E") return self.download_request(request, Spider("foo")).addCallback(_test) def test_non_existent(self): - request = Request(path_to_file_uri(self.mktemp())) + request = Request(path_to_file_uri(mkdtemp())) d = self.download_request(request, Spider("foo")) return self.assertFailure(d, OSError) @@ -224,8 +225,7 @@ class HttpTestCase(unittest.TestCase): certfile = "keys/localhost.crt" def setUp(self): - self.tmpname = Path(self.mktemp()) - self.tmpname.mkdir() + self.tmpname = Path(mkdtemp()) (self.tmpname / "file").write_bytes(b"0123456789") r = static.File(str(self.tmpname)) r.putChild(b"redirect", util.Redirect(b"/file")) @@ -651,8 +651,7 @@ class Https11CustomCiphers(unittest.TestCase): certfile = "keys/localhost.crt" def setUp(self): - self.tmpname = Path(self.mktemp()) - self.tmpname.mkdir() + self.tmpname = Path(mkdtemp()) (self.tmpname / "file").write_bytes(b"0123456789") r = static.File(str(self.tmpname)) self.site = server.Site(r, timeout=None) @@ -1015,8 +1014,7 @@ class BaseFTPTestCase(unittest.TestCase): from scrapy.core.downloader.handlers.ftp import FTPDownloadHandler # setup dirs and test file - self.directory = Path(self.mktemp()) - self.directory.mkdir() + self.directory = Path(mkdtemp()) userdir = self.directory / self.username userdir.mkdir() for filename, content in self.test_files: @@ -1092,7 +1090,7 @@ class BaseFTPTestCase(unittest.TestCase): return self._add_test_callbacks(d, _test) def test_ftp_local_filename(self): - f, local_fname = tempfile.mkstemp() + f, local_fname = mkstemp() fname_bytes = to_bytes(local_fname) local_fname = Path(local_fname) os.close(f) @@ -1113,7 +1111,7 @@ class BaseFTPTestCase(unittest.TestCase): return self._add_test_callbacks(d, _test) def _test_response_class(self, filename, response_class): - f, local_fname = tempfile.mkstemp() + f, local_fname = mkstemp() local_fname = Path(local_fname) os.close(f) meta = {} @@ -1163,9 +1161,7 @@ class AnonymousFTPTestCase(BaseFTPTestCase): from scrapy.core.downloader.handlers.ftp import FTPDownloadHandler # setup dir and test file - self.directory = Path(self.mktemp()) - self.directory.mkdir() - + self.directory = Path(mkdtemp()) for filename, content in self.test_files: (self.directory / filename).write_bytes(content) diff --git a/tests/test_http2_client_protocol.py b/tests/test_http2_client_protocol.py index 8fdf3d56f..995c02a1a 100644 --- a/tests/test_http2_client_protocol.py +++ b/tests/test_http2_client_protocol.py @@ -5,6 +5,7 @@ import shutil import string from ipaddress import IPv4Address from pathlib import Path +from tempfile import mkdtemp from typing import Dict from unittest import mock, skipIf from urllib.parse import urlencode @@ -185,8 +186,7 @@ class Https2ClientProtocolTestCase(TestCase): certificate_file = Path(__file__).parent / "keys" / "localhost.crt" def _init_resource(self): - self.temp_directory = self.mktemp() - Path(self.temp_directory).mkdir() + self.temp_directory = mkdtemp() r = File(self.temp_directory) r.putChild(b"get-data-html-small", GetDataHtmlSmall()) r.putChild(b"get-data-html-large", GetDataHtmlLarge()) diff --git a/tests/test_pipeline_crawl.py b/tests/test_pipeline_crawl.py index c41ab483f..be9811980 100644 --- a/tests/test_pipeline_crawl.py +++ b/tests/test_pipeline_crawl.py @@ -1,5 +1,6 @@ import shutil from pathlib import Path +from tempfile import mkdtemp from typing import Optional, Set from testfixtures import LogCapture @@ -67,8 +68,7 @@ class FileDownloadCrawlTestCase(TestCase): self.mockserver.__enter__() # prepare a directory for storing files - self.tmpmediastore = Path(self.mktemp()) - self.tmpmediastore.mkdir() + self.tmpmediastore = Path(mkdtemp()) self.settings = { "REQUEST_FINGERPRINTER_IMPLEMENTATION": "2.7", "ITEM_PIPELINES": {self.pipeline_class: 1}, diff --git a/tests/test_spiderloader/__init__.py b/tests/test_spiderloader/__init__.py index 04025d30d..f950739f2 100644 --- a/tests/test_spiderloader/__init__.py +++ b/tests/test_spiderloader/__init__.py @@ -3,6 +3,7 @@ import sys import tempfile import warnings from pathlib import Path +from tempfile import mkdtemp from twisted.trial import unittest from zope.interface.verify import verifyObject @@ -139,8 +140,7 @@ class SpiderLoaderTest(unittest.TestCase): class DuplicateSpiderNameLoaderTest(unittest.TestCase): def setUp(self): orig_spiders_dir = module_dir / "test_spiders" - self.tmpdir = Path(self.mktemp()) - self.tmpdir.mkdir() + self.tmpdir = Path(mkdtemp()) self.spiders_dir = self.tmpdir / "test_spiders_xxx" _copytree(orig_spiders_dir, self.spiders_dir) sys.path.append(str(self.tmpdir)) diff --git a/tests/test_spiderstate.py b/tests/test_spiderstate.py index f97125b76..59d18d92e 100644 --- a/tests/test_spiderstate.py +++ b/tests/test_spiderstate.py @@ -1,6 +1,6 @@ import shutil from datetime import datetime, timezone -from pathlib import Path +from tempfile import mkdtemp from twisted.trial import unittest @@ -12,8 +12,7 @@ from scrapy.utils.test import get_crawler class SpiderStateTest(unittest.TestCase): def test_store_load(self): - jobdir = self.mktemp() - Path(jobdir).mkdir() + jobdir = mkdtemp() try: spider = Spider(name="default") dt = datetime.now(tz=timezone.utc) diff --git a/tests/test_squeues_request.py b/tests/test_squeues_request.py index b444c32b7..499ca46b8 100644 --- a/tests/test_squeues_request.py +++ b/tests/test_squeues_request.py @@ -25,7 +25,7 @@ class BaseQueueTestCase(unittest.TestCase): def setUp(self): self.tmpdir = tempfile.mkdtemp(prefix="scrapy-queue-tests-") self.qpath = self.tempfilename() - self.qdir = self.mkdtemp() + self.qdir = tempfile.mkdtemp() self.crawler = get_crawler(Spider) def tearDown(self): diff --git a/tests/test_webclient.py b/tests/test_webclient.py index d4b6ba15b..53558814d 100644 --- a/tests/test_webclient.py +++ b/tests/test_webclient.py @@ -4,6 +4,7 @@ Tests borrowed from the twisted.web.client tests. """ import shutil from pathlib import Path +from tempfile import mkdtemp import OpenSSL.SSL from twisted.internet import defer, reactor @@ -274,8 +275,7 @@ class WebClientTestCase(unittest.TestCase): return reactor.listenTCP(0, site, interface="127.0.0.1") def setUp(self): - self.tmpname = Path(self.mktemp()) - self.tmpname.mkdir() + self.tmpname = Path(mkdtemp()) (self.tmpname / "file").write_bytes(b"0123456789") r = static.File(str(self.tmpname)) r.putChild(b"redirect", util.Redirect(b"/file")) @@ -440,8 +440,7 @@ class WebClientSSLTestCase(unittest.TestCase): return f"https://127.0.0.1:{self.portno}/{path}" def setUp(self): - self.tmpname = Path(self.mktemp()) - self.tmpname.mkdir() + self.tmpname = Path(mkdtemp()) (self.tmpname / "file").write_bytes(b"0123456789") r = static.File(str(self.tmpname)) r.putChild(b"payload", PayloadResource()) From 2d46b4acf5855faaf2d6baa36615f08bd8aefccf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Tue, 27 Feb 2024 09:28:02 +0100 Subject: [PATCH 109/119] Complete coverage for the AutoThrottle extension (#6245) --- docs/topics/autothrottle.rst | 2 +- scrapy/extensions/throttle.py | 5 + tests/test_extension_throttle.py | 340 +++++++++++++++++++++++++++++++ 3 files changed, 346 insertions(+), 1 deletion(-) create mode 100644 tests/test_extension_throttle.py diff --git a/docs/topics/autothrottle.rst b/docs/topics/autothrottle.rst index 8e6aae65c..5370d77b3 100644 --- a/docs/topics/autothrottle.rst +++ b/docs/topics/autothrottle.rst @@ -131,7 +131,7 @@ AUTOTHROTTLE_TARGET_CONCURRENCY Default: ``1.0`` Average number of requests Scrapy should be sending in parallel to remote -websites. +websites. It must be higher than ``0.0``. By default, AutoThrottle adjusts the delay to send a single concurrent request to each of the remote websites. Set this option to diff --git a/scrapy/extensions/throttle.py b/scrapy/extensions/throttle.py index 396800775..d217c7a69 100644 --- a/scrapy/extensions/throttle.py +++ b/scrapy/extensions/throttle.py @@ -16,6 +16,11 @@ class AutoThrottle: self.target_concurrency = crawler.settings.getfloat( "AUTOTHROTTLE_TARGET_CONCURRENCY" ) + if self.target_concurrency <= 0.0: + raise NotConfigured( + f"AUTOTHROTTLE_TARGET_CONCURRENCY " + f"({self.target_concurrency!r}) must be higher than 0." + ) crawler.signals.connect(self._spider_opened, signal=signals.spider_opened) crawler.signals.connect( self._response_downloaded, signal=signals.response_downloaded diff --git a/tests/test_extension_throttle.py b/tests/test_extension_throttle.py new file mode 100644 index 000000000..dae4ea966 --- /dev/null +++ b/tests/test_extension_throttle.py @@ -0,0 +1,340 @@ +from logging import INFO +from unittest.mock import Mock + +import pytest + +from scrapy import Request, Spider +from scrapy.exceptions import NotConfigured +from scrapy.extensions.throttle import AutoThrottle +from scrapy.http.response import Response +from scrapy.settings.default_settings import ( + AUTOTHROTTLE_MAX_DELAY, + AUTOTHROTTLE_START_DELAY, + DOWNLOAD_DELAY, +) +from scrapy.utils.misc import build_from_crawler +from scrapy.utils.test import get_crawler as _get_crawler + +UNSET = object() + + +class TestSpider(Spider): + name = "test" + + +def get_crawler(settings=None, spidercls=None): + settings = settings or {} + settings["AUTOTHROTTLE_ENABLED"] = True + return _get_crawler(settings_dict=settings, spidercls=spidercls) + + +@pytest.mark.parametrize( + ("value", "expected"), + ( + (UNSET, False), + (False, False), + (True, True), + ), +) +def test_enabled(value, expected): + settings = {} + if value is not UNSET: + settings["AUTOTHROTTLE_ENABLED"] = value + crawler = _get_crawler(settings_dict=settings) + if expected: + build_from_crawler(AutoThrottle, crawler) + else: + with pytest.raises(NotConfigured): + build_from_crawler(AutoThrottle, crawler) + + +@pytest.mark.parametrize( + "value", + ( + 0.0, + -1.0, + ), +) +def test_target_concurrency_invalid(value): + settings = {"AUTOTHROTTLE_TARGET_CONCURRENCY": value} + crawler = get_crawler(settings) + with pytest.raises(NotConfigured): + build_from_crawler(AutoThrottle, crawler) + + +@pytest.mark.parametrize( + ("spider", "setting", "expected"), + ( + (UNSET, UNSET, DOWNLOAD_DELAY), + (1.0, UNSET, 1.0), + (UNSET, 1.0, 1.0), + (1.0, 2.0, 1.0), + (3.0, 2.0, 3.0), + ), +) +def test_mindelay_definition(spider, setting, expected): + settings = {} + if setting is not UNSET: + settings["DOWNLOAD_DELAY"] = setting + + class _TestSpider(Spider): + name = "test" + + if spider is not UNSET: + _TestSpider.download_delay = spider + + crawler = get_crawler(settings, _TestSpider) + at = build_from_crawler(AutoThrottle, crawler) + at._spider_opened(_TestSpider()) + assert at.mindelay == expected + + +@pytest.mark.parametrize( + ("value", "expected"), + ( + (UNSET, AUTOTHROTTLE_MAX_DELAY), + (1.0, 1.0), + ), +) +def test_maxdelay_definition(value, expected): + settings = {} + if value is not UNSET: + settings["AUTOTHROTTLE_MAX_DELAY"] = value + crawler = get_crawler(settings) + at = build_from_crawler(AutoThrottle, crawler) + at._spider_opened(TestSpider()) + assert at.maxdelay == expected + + +@pytest.mark.parametrize( + ("min_spider", "min_setting", "start_setting", "expected"), + ( + (UNSET, UNSET, UNSET, AUTOTHROTTLE_START_DELAY), + (AUTOTHROTTLE_START_DELAY - 1.0, UNSET, UNSET, AUTOTHROTTLE_START_DELAY), + (AUTOTHROTTLE_START_DELAY + 1.0, UNSET, UNSET, AUTOTHROTTLE_START_DELAY + 1.0), + (UNSET, AUTOTHROTTLE_START_DELAY - 1.0, UNSET, AUTOTHROTTLE_START_DELAY), + (UNSET, AUTOTHROTTLE_START_DELAY + 1.0, UNSET, AUTOTHROTTLE_START_DELAY + 1.0), + (UNSET, UNSET, AUTOTHROTTLE_START_DELAY - 1.0, AUTOTHROTTLE_START_DELAY - 1.0), + (UNSET, UNSET, AUTOTHROTTLE_START_DELAY + 1.0, AUTOTHROTTLE_START_DELAY + 1.0), + ( + AUTOTHROTTLE_START_DELAY + 1.0, + AUTOTHROTTLE_START_DELAY + 2.0, + UNSET, + AUTOTHROTTLE_START_DELAY + 1.0, + ), + ( + AUTOTHROTTLE_START_DELAY + 2.0, + UNSET, + AUTOTHROTTLE_START_DELAY + 1.0, + AUTOTHROTTLE_START_DELAY + 2.0, + ), + ( + AUTOTHROTTLE_START_DELAY + 1.0, + UNSET, + AUTOTHROTTLE_START_DELAY + 2.0, + AUTOTHROTTLE_START_DELAY + 2.0, + ), + ), +) +def test_startdelay_definition(min_spider, min_setting, start_setting, expected): + settings = {} + if min_setting is not UNSET: + settings["DOWNLOAD_DELAY"] = min_setting + if start_setting is not UNSET: + settings["AUTOTHROTTLE_START_DELAY"] = start_setting + + class _TestSpider(Spider): + name = "test" + + if min_spider is not UNSET: + _TestSpider.download_delay = min_spider + + crawler = get_crawler(settings, _TestSpider) + at = build_from_crawler(AutoThrottle, crawler) + spider = _TestSpider() + at._spider_opened(spider) + assert spider.download_delay == expected + + +@pytest.mark.parametrize( + ("meta", "slot"), + ( + ({}, None), + ({"download_latency": 1.0}, None), + ({"download_slot": "foo"}, None), + ({"download_slot": "foo"}, "foo"), + ({"download_latency": 1.0, "download_slot": "foo"}, None), + ), +) +def test_skipped(meta, slot): + crawler = get_crawler() + at = build_from_crawler(AutoThrottle, crawler) + spider = TestSpider() + at._spider_opened(spider) + request = Request("https://example.com", meta=meta) + + crawler.engine = Mock() + crawler.engine.downloader = Mock() + crawler.engine.downloader.slots = {} + if slot is not None: + crawler.engine.downloader.slots[slot] = object() + at._adjust_delay = None # Raise exception if called. + + at._response_downloaded(None, request, spider) + + +@pytest.mark.parametrize( + ("download_latency", "target_concurrency", "slot_delay", "expected"), + ( + (2.0, 2.0, 1.0, 1.0), + (1.0, 2.0, 1.0, 0.75), + (4.0, 2.0, 1.0, 2.0), + (2.0, 1.0, 1.0, 2.0), + (2.0, 4.0, 1.0, 0.75), + (2.0, 2.0, 0.5, 1.0), + (2.0, 2.0, 2.0, 1.5), + ), +) +def test_adjustment(download_latency, target_concurrency, slot_delay, expected): + settings = {"AUTOTHROTTLE_TARGET_CONCURRENCY": target_concurrency} + crawler = get_crawler(settings) + at = build_from_crawler(AutoThrottle, crawler) + spider = TestSpider() + at._spider_opened(spider) + meta = {"download_latency": download_latency, "download_slot": "foo"} + request = Request("https://example.com", meta=meta) + response = Response(request.url) + + crawler.engine = Mock() + crawler.engine.downloader = Mock() + crawler.engine.downloader.slots = {} + slot = Mock() + slot.delay = slot_delay + crawler.engine.downloader.slots["foo"] = slot + + at._response_downloaded(response, request, spider) + + assert slot.delay == expected, f"{slot.delay} != {expected}" + + +@pytest.mark.parametrize( + ("mindelay", "maxdelay", "expected"), + ( + (0.5, 2.0, 1.0), + (0.25, 0.5, 0.5), + (2.0, 4.0, 2.0), + ), +) +def test_adjustment_limits(mindelay, maxdelay, expected): + download_latency, target_concurrency, slot_delay = (2.0, 2.0, 1.0) + # expected adjustment without limits with these values: 1.0 + settings = { + "AUTOTHROTTLE_MAX_DELAY": maxdelay, + "AUTOTHROTTLE_TARGET_CONCURRENCY": target_concurrency, + "DOWNLOAD_DELAY": mindelay, + } + crawler = get_crawler(settings) + at = build_from_crawler(AutoThrottle, crawler) + spider = TestSpider() + at._spider_opened(spider) + meta = {"download_latency": download_latency, "download_slot": "foo"} + request = Request("https://example.com", meta=meta) + response = Response(request.url) + + crawler.engine = Mock() + crawler.engine.downloader = Mock() + crawler.engine.downloader.slots = {} + slot = Mock() + slot.delay = slot_delay + crawler.engine.downloader.slots["foo"] = slot + + at._response_downloaded(response, request, spider) + + assert slot.delay == expected, f"{slot.delay} != {expected}" + + +@pytest.mark.parametrize( + ("download_latency", "target_concurrency", "slot_delay", "expected"), + ( + (2.0, 2.0, 1.0, 1.0), + (1.0, 2.0, 1.0, 1.0), # Instead of 0.75 + (4.0, 2.0, 1.0, 2.0), + ), +) +def test_adjustment_bad_response( + download_latency, target_concurrency, slot_delay, expected +): + settings = {"AUTOTHROTTLE_TARGET_CONCURRENCY": target_concurrency} + crawler = get_crawler(settings) + at = build_from_crawler(AutoThrottle, crawler) + spider = TestSpider() + at._spider_opened(spider) + meta = {"download_latency": download_latency, "download_slot": "foo"} + request = Request("https://example.com", meta=meta) + response = Response(request.url, status=400) + + crawler.engine = Mock() + crawler.engine.downloader = Mock() + crawler.engine.downloader.slots = {} + slot = Mock() + slot.delay = slot_delay + crawler.engine.downloader.slots["foo"] = slot + + at._response_downloaded(response, request, spider) + + assert slot.delay == expected, f"{slot.delay} != {expected}" + + +def test_debug(caplog): + settings = {"AUTOTHROTTLE_DEBUG": True} + crawler = get_crawler(settings) + at = build_from_crawler(AutoThrottle, crawler) + spider = TestSpider() + at._spider_opened(spider) + meta = {"download_latency": 1.0, "download_slot": "foo"} + request = Request("https://example.com", meta=meta) + response = Response(request.url, body=b"foo") + + crawler.engine = Mock() + crawler.engine.downloader = Mock() + crawler.engine.downloader.slots = {} + slot = Mock() + slot.delay = 2.0 + slot.transferring = (None, None) + crawler.engine.downloader.slots["foo"] = slot + + caplog.clear() + with caplog.at_level(INFO): + at._response_downloaded(response, request, spider) + + assert caplog.record_tuples == [ + ( + "scrapy.extensions.throttle", + INFO, + "slot: foo | conc: 2 | delay: 1500 ms (-500) | latency: 1000 ms | size: 3 bytes", + ), + ] + + +def test_debug_disabled(caplog): + crawler = get_crawler() + at = build_from_crawler(AutoThrottle, crawler) + spider = TestSpider() + at._spider_opened(spider) + meta = {"download_latency": 1.0, "download_slot": "foo"} + request = Request("https://example.com", meta=meta) + response = Response(request.url, body=b"foo") + + crawler.engine = Mock() + crawler.engine.downloader = Mock() + crawler.engine.downloader.slots = {} + slot = Mock() + slot.delay = 2.0 + slot.transferring = (None, None) + crawler.engine.downloader.slots["foo"] = slot + + caplog.clear() + with caplog.at_level(INFO): + at._response_downloaded(response, request, spider) + + assert caplog.record_tuples == [] From d87f949526470fc4847c99f58e1dcc40d4e9ed00 Mon Sep 17 00:00:00 2001 From: Laerte Pereira Date: Tue, 27 Feb 2024 17:08:13 -0300 Subject: [PATCH 110/119] Use defusedxml.xmlrpc --- scrapy/http/request/rpc.py | 4 ++++ setup.py | 1 + 2 files changed, 5 insertions(+) diff --git a/scrapy/http/request/rpc.py b/scrapy/http/request/rpc.py index bde860a66..2bf5ba4b6 100644 --- a/scrapy/http/request/rpc.py +++ b/scrapy/http/request/rpc.py @@ -7,9 +7,13 @@ See documentation in docs/topics/request-response.rst import xmlrpc.client as xmlrpclib from typing import Any, Optional +from defusedxml import xmlrpc + from scrapy.http.request import Request from scrapy.utils.python import get_func_args +xmlrpc.monkey_patch() + DUMPS_ARGS = get_func_args(xmlrpclib.dumps) diff --git a/setup.py b/setup.py index 405633f55..2d6d26b0c 100644 --- a/setup.py +++ b/setup.py @@ -22,6 +22,7 @@ install_requires = [ "packaging", "tldextract", "lxml>=4.4.1", + "defusedxml>=0.7.1", ] extras_require = { ':platform_python_implementation == "CPython"': ["PyDispatcher>=2.0.5"], From 7f945ad6db728234987629b2299750abee5c1781 Mon Sep 17 00:00:00 2001 From: Laerte Pereira Date: Tue, 27 Feb 2024 17:39:29 -0300 Subject: [PATCH 111/119] Import defusedxml.xmlrpc using alias --- scrapy/http/request/rpc.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scrapy/http/request/rpc.py b/scrapy/http/request/rpc.py index 2bf5ba4b6..5a2107f76 100644 --- a/scrapy/http/request/rpc.py +++ b/scrapy/http/request/rpc.py @@ -7,12 +7,12 @@ See documentation in docs/topics/request-response.rst import xmlrpc.client as xmlrpclib from typing import Any, Optional -from defusedxml import xmlrpc +import defusedxml.xmlrpc as xml_rpc from scrapy.http.request import Request from scrapy.utils.python import get_func_args -xmlrpc.monkey_patch() +xml_rpc.monkey_patch() DUMPS_ARGS = get_func_args(xmlrpclib.dumps) From 008ebb65fc2f0e7cfe9fa43d7ac938a94b3098fb Mon Sep 17 00:00:00 2001 From: Laerte Pereira Date: Tue, 27 Feb 2024 18:10:28 -0300 Subject: [PATCH 112/119] Change immport style --- scrapy/http/request/rpc.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scrapy/http/request/rpc.py b/scrapy/http/request/rpc.py index 5a2107f76..84b433990 100644 --- a/scrapy/http/request/rpc.py +++ b/scrapy/http/request/rpc.py @@ -7,12 +7,12 @@ See documentation in docs/topics/request-response.rst import xmlrpc.client as xmlrpclib from typing import Any, Optional -import defusedxml.xmlrpc as xml_rpc +import defusedxml.xmlrpc from scrapy.http.request import Request from scrapy.utils.python import get_func_args -xml_rpc.monkey_patch() +defusedxml.xmlrpc.monkey_patch() DUMPS_ARGS = get_func_args(xmlrpclib.dumps) From 282767f23b2e71969bea3bd5492abde88d2054c6 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Tue, 27 Feb 2024 19:49:06 +0500 Subject: [PATCH 113/119] Bump black. --- .flake8 | 2 +- .pre-commit-config.yaml | 4 ++-- docs/topics/addons.rst | 3 +-- scrapy/commands/__init__.py | 1 + scrapy/commands/shell.py | 1 + scrapy/core/downloader/handlers/__init__.py | 6 +++--- scrapy/core/downloader/handlers/http10.py | 1 + scrapy/core/downloader/middleware.py | 1 + scrapy/core/engine.py | 1 + scrapy/core/http2/stream.py | 12 ++++++------ scrapy/core/scraper.py | 1 + scrapy/core/spidermw.py | 1 + scrapy/downloadermiddlewares/defaultheaders.py | 1 + scrapy/downloadermiddlewares/downloadtimeout.py | 1 + scrapy/downloadermiddlewares/retry.py | 1 + scrapy/exceptions.py | 1 + scrapy/extension.py | 1 + scrapy/extensions/corestats.py | 1 + scrapy/extensions/memusage.py | 1 + scrapy/extensions/postprocessing.py | 1 + scrapy/http/request/__init__.py | 17 +++++++++++------ scrapy/http/request/rpc.py | 1 + scrapy/http/response/__init__.py | 1 + scrapy/http/response/text.py | 1 + scrapy/link.py | 1 + scrapy/linkextractors/__init__.py | 1 + scrapy/linkextractors/lxmlhtml.py | 1 + scrapy/loader/__init__.py | 1 + scrapy/mail.py | 1 + scrapy/pipelines/__init__.py | 1 + scrapy/pipelines/files.py | 1 + scrapy/pipelines/images.py | 1 + scrapy/responsetypes.py | 1 + scrapy/selector/unified.py | 1 + scrapy/settings/__init__.py | 1 - scrapy/shell.py | 1 + scrapy/spidermiddlewares/httperror.py | 1 + scrapy/spidermiddlewares/offsite.py | 1 + scrapy/spidermiddlewares/referer.py | 1 + scrapy/spiders/__init__.py | 1 + scrapy/spiders/feed.py | 1 + scrapy/statscollectors.py | 1 + scrapy/utils/defer.py | 7 +++---- scrapy/utils/deprecate.py | 6 ++---- scrapy/utils/iterators.py | 11 +++++------ scrapy/utils/misc.py | 1 + scrapy/utils/python.py | 7 +++---- scrapy/utils/request.py | 7 ++++--- scrapy/utils/response.py | 7 ++++--- scrapy/utils/signal.py | 1 + scrapy/utils/sitemap.py | 1 + scrapy/utils/spider.py | 15 +++++---------- scrapy/utils/url.py | 1 + tests/mocks/dummydbm.py | 1 + tests/spiders.py | 1 + tests/test_exporters.py | 4 +++- tests/test_pipeline_media.py | 6 +++--- tests/test_responsetypes.py | 5 ++++- tests/test_webclient.py | 1 + 59 files changed, 102 insertions(+), 60 deletions(-) diff --git a/.flake8 b/.flake8 index 544d72956..62ccad9cf 100644 --- a/.flake8 +++ b/.flake8 @@ -1,7 +1,7 @@ [flake8] max-line-length = 119 -ignore = W503, E203 +ignore = E203, E501, E701, E704, W503 exclude = docs/conf.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0cff5cc73..83bc65b67 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,7 +9,7 @@ repos: hooks: - id: flake8 - repo: https://github.com/psf/black.git - rev: 23.9.1 + rev: 24.2.0 hooks: - id: black - repo: https://github.com/pycqa/isort @@ -21,4 +21,4 @@ repos: hooks: - id: blacken-docs additional_dependencies: - - black==23.9.1 + - black==24.2.0 diff --git a/docs/topics/addons.rst b/docs/topics/addons.rst index 1bf2172bd..d2fc41003 100644 --- a/docs/topics/addons.rst +++ b/docs/topics/addons.rst @@ -150,8 +150,7 @@ Access the crawler instance: def from_crawler(cls, crawler): return cls(crawler) - def update_settings(self, settings): - ... + def update_settings(self, settings): ... Use a fallback component: diff --git a/scrapy/commands/__init__.py b/scrapy/commands/__init__.py index 2aa569cdd..27993710e 100644 --- a/scrapy/commands/__init__.py +++ b/scrapy/commands/__init__.py @@ -1,6 +1,7 @@ """ Base class for Scrapy commands """ + import argparse import os from pathlib import Path diff --git a/scrapy/commands/shell.py b/scrapy/commands/shell.py index 12e37babc..f72a23c6a 100644 --- a/scrapy/commands/shell.py +++ b/scrapy/commands/shell.py @@ -3,6 +3,7 @@ Scrapy Shell See documentation in docs/topics/shell.rst """ + from argparse import Namespace from threading import Thread from typing import List, Type diff --git a/scrapy/core/downloader/handlers/__init__.py b/scrapy/core/downloader/handlers/__init__.py index 416669b7f..ade51ca63 100644 --- a/scrapy/core/downloader/handlers/__init__.py +++ b/scrapy/core/downloader/handlers/__init__.py @@ -21,9 +21,9 @@ logger = logging.getLogger(__name__) class DownloadHandlers: def __init__(self, crawler: "Crawler"): self._crawler: "Crawler" = crawler - self._schemes: Dict[ - str, Union[str, Callable] - ] = {} # stores acceptable schemes on instancing + self._schemes: Dict[str, Union[str, Callable]] = ( + {} + ) # stores acceptable schemes on instancing self._handlers: Dict[str, Any] = {} # stores instanced handlers for schemes self._notconfigured: Dict[str, str] = {} # remembers failed handlers handlers: Dict[str, Union[str, Callable]] = without_none_values( diff --git a/scrapy/core/downloader/handlers/http10.py b/scrapy/core/downloader/handlers/http10.py index b6ac7a251..d168c2b2e 100644 --- a/scrapy/core/downloader/handlers/http10.py +++ b/scrapy/core/downloader/handlers/http10.py @@ -1,5 +1,6 @@ """Download handlers for http and https schemes """ + from scrapy.utils.misc import build_from_crawler, load_object from scrapy.utils.python import to_unicode diff --git a/scrapy/core/downloader/middleware.py b/scrapy/core/downloader/middleware.py index dca13c01e..52ebe4e22 100644 --- a/scrapy/core/downloader/middleware.py +++ b/scrapy/core/downloader/middleware.py @@ -3,6 +3,7 @@ Downloader Middleware manager See documentation in docs/topics/downloader-middleware.rst """ + from typing import Any, Callable, Generator, List, Union, cast from twisted.internet.defer import Deferred, inlineCallbacks diff --git a/scrapy/core/engine.py b/scrapy/core/engine.py index 545cd401f..2db085081 100644 --- a/scrapy/core/engine.py +++ b/scrapy/core/engine.py @@ -4,6 +4,7 @@ This is the Scrapy engine which controls the Scheduler, Downloader and Spider. For more information see docs/topics/architecture.rst """ + import logging from time import time from typing import ( diff --git a/scrapy/core/http2/stream.py b/scrapy/core/http2/stream.py index 0f282d83d..4132fc385 100644 --- a/scrapy/core/http2/stream.py +++ b/scrapy/core/http2/stream.py @@ -111,17 +111,17 @@ class Stream: # Metadata of an HTTP/2 connection stream # initialized when stream is instantiated self.metadata: Dict = { - "request_content_length": 0 - if self._request.body is None - else len(self._request.body), + "request_content_length": ( + 0 if self._request.body is None else len(self._request.body) + ), # Flag to keep track whether the stream has initiated the request "request_sent": False, # Flag to track whether we have logged about exceeding download warnsize "reached_warnsize": False, # Each time we send a data frame, we will decrease value by the amount send. - "remaining_content_length": 0 - if self._request.body is None - else len(self._request.body), + "remaining_content_length": ( + 0 if self._request.body is None else len(self._request.body) + ), # Flag to keep track whether client (self) have closed this stream "stream_closed_local": False, # Flag to keep track whether the server has closed the stream diff --git a/scrapy/core/scraper.py b/scrapy/core/scraper.py index 8fb16b8a9..272841e01 100644 --- a/scrapy/core/scraper.py +++ b/scrapy/core/scraper.py @@ -1,5 +1,6 @@ """This module implements the Scraper component which parses responses and extracts information from them""" + from __future__ import annotations import logging diff --git a/scrapy/core/spidermw.py b/scrapy/core/spidermw.py index 031a0be36..1ccfd08a2 100644 --- a/scrapy/core/spidermw.py +++ b/scrapy/core/spidermw.py @@ -3,6 +3,7 @@ Spider Middleware manager See documentation in docs/topics/spider-middleware.rst """ + import logging from inspect import isasyncgenfunction, iscoroutine from itertools import islice diff --git a/scrapy/downloadermiddlewares/defaultheaders.py b/scrapy/downloadermiddlewares/defaultheaders.py index 8aec37cf1..58fd415b9 100644 --- a/scrapy/downloadermiddlewares/defaultheaders.py +++ b/scrapy/downloadermiddlewares/defaultheaders.py @@ -3,6 +3,7 @@ DefaultHeaders downloader middleware See documentation in docs/topics/downloader-middleware.rst """ + from __future__ import annotations from typing import TYPE_CHECKING, Iterable, Tuple, Union diff --git a/scrapy/downloadermiddlewares/downloadtimeout.py b/scrapy/downloadermiddlewares/downloadtimeout.py index 1c904c05b..fd7c03a38 100644 --- a/scrapy/downloadermiddlewares/downloadtimeout.py +++ b/scrapy/downloadermiddlewares/downloadtimeout.py @@ -3,6 +3,7 @@ Download timeout middleware See documentation in docs/topics/downloader-middleware.rst """ + from __future__ import annotations from typing import TYPE_CHECKING, Union diff --git a/scrapy/downloadermiddlewares/retry.py b/scrapy/downloadermiddlewares/retry.py index 3c494de78..46587a898 100644 --- a/scrapy/downloadermiddlewares/retry.py +++ b/scrapy/downloadermiddlewares/retry.py @@ -9,6 +9,7 @@ RETRY_HTTP_CODES - which HTTP response codes to retry Failed pages are collected on the scraping process and rescheduled at the end, once the spider has finished crawling all regular (non failed) pages. """ + from __future__ import annotations import warnings diff --git a/scrapy/exceptions.py b/scrapy/exceptions.py index 6d188c489..e7ecdbe0c 100644 --- a/scrapy/exceptions.py +++ b/scrapy/exceptions.py @@ -4,6 +4,7 @@ Scrapy core exceptions These exceptions are documented in docs/topics/exceptions.rst. Please don't add new exceptions here without documenting them there. """ + from typing import Any # Internal diff --git a/scrapy/extension.py b/scrapy/extension.py index 4e365cfa1..6be14450c 100644 --- a/scrapy/extension.py +++ b/scrapy/extension.py @@ -3,6 +3,7 @@ The Extension Manager See documentation in docs/topics/extensions.rst """ + from scrapy.middleware import MiddlewareManager from scrapy.utils.conf import build_component_list diff --git a/scrapy/extensions/corestats.py b/scrapy/extensions/corestats.py index 302a615f2..717c249d9 100644 --- a/scrapy/extensions/corestats.py +++ b/scrapy/extensions/corestats.py @@ -1,6 +1,7 @@ """ Extension for collecting core stats like items scraped and start/finish times """ + from datetime import datetime, timezone from scrapy import signals diff --git a/scrapy/extensions/memusage.py b/scrapy/extensions/memusage.py index ca766c938..4d4501c44 100644 --- a/scrapy/extensions/memusage.py +++ b/scrapy/extensions/memusage.py @@ -3,6 +3,7 @@ MemoryUsage extension See documentation in docs/topics/extensions.rst """ + import logging import socket import sys diff --git a/scrapy/extensions/postprocessing.py b/scrapy/extensions/postprocessing.py index 17969c5b0..f8b59827b 100644 --- a/scrapy/extensions/postprocessing.py +++ b/scrapy/extensions/postprocessing.py @@ -1,6 +1,7 @@ """ Extension for processing data before they are exported to feeds. """ + from bz2 import BZ2File from gzip import GzipFile from io import IOBase diff --git a/scrapy/http/request/__init__.py b/scrapy/http/request/__init__.py index a1c5a5e51..6269ee86a 100644 --- a/scrapy/http/request/__init__.py +++ b/scrapy/http/request/__init__.py @@ -4,6 +4,7 @@ requests in Scrapy. See documentation in docs/topics/request-response.rst """ + import inspect from typing import ( Any, @@ -231,12 +232,16 @@ class Request(object_ref): """ d = { "url": self.url, # urls are safe (safe_string_url) - "callback": _find_method(spider, self.callback) - if callable(self.callback) - else self.callback, - "errback": _find_method(spider, self.errback) - if callable(self.errback) - else self.errback, + "callback": ( + _find_method(spider, self.callback) + if callable(self.callback) + else self.callback + ), + "errback": ( + _find_method(spider, self.errback) + if callable(self.errback) + else self.errback + ), "headers": dict(self.headers), } for attr in self.attributes: diff --git a/scrapy/http/request/rpc.py b/scrapy/http/request/rpc.py index 84b433990..e20e7c438 100644 --- a/scrapy/http/request/rpc.py +++ b/scrapy/http/request/rpc.py @@ -4,6 +4,7 @@ This module implements the XmlRpcRequest class which is a more convenient class See documentation in docs/topics/request-response.rst """ + import xmlrpc.client as xmlrpclib from typing import Any, Optional diff --git a/scrapy/http/response/__init__.py b/scrapy/http/response/__init__.py index 6eae3e8b3..d73dfce4b 100644 --- a/scrapy/http/response/__init__.py +++ b/scrapy/http/response/__init__.py @@ -4,6 +4,7 @@ responses in Scrapy. See documentation in docs/topics/request-response.rst """ + from __future__ import annotations from ipaddress import IPv4Address, IPv6Address diff --git a/scrapy/http/response/text.py b/scrapy/http/response/text.py index 6596d8a5c..2816610fb 100644 --- a/scrapy/http/response/text.py +++ b/scrapy/http/response/text.py @@ -4,6 +4,7 @@ discovering (through HTTP headers) to base Response class. See documentation in docs/topics/request-response.rst """ + from __future__ import annotations import json diff --git a/scrapy/link.py b/scrapy/link.py index 0868ae5ef..4bdbc1823 100644 --- a/scrapy/link.py +++ b/scrapy/link.py @@ -4,6 +4,7 @@ This module defines the Link object used in Link extractors. For actual link extractors implementation see scrapy.linkextractors, or its documentation in: docs/topics/link-extractors.rst """ + from typing import Any diff --git a/scrapy/linkextractors/__init__.py b/scrapy/linkextractors/__init__.py index 3774430a7..73a63651c 100644 --- a/scrapy/linkextractors/__init__.py +++ b/scrapy/linkextractors/__init__.py @@ -5,6 +5,7 @@ This package contains a collection of Link Extractors. For more info see docs/topics/link-extractors.rst """ + import re # common file extensions that are not followed if they occur in links diff --git a/scrapy/linkextractors/lxmlhtml.py b/scrapy/linkextractors/lxmlhtml.py index 23cbd0116..d76db20ba 100644 --- a/scrapy/linkextractors/lxmlhtml.py +++ b/scrapy/linkextractors/lxmlhtml.py @@ -1,6 +1,7 @@ """ Link extractor based on lxml.html """ + import logging import operator from functools import partial diff --git a/scrapy/loader/__init__.py b/scrapy/loader/__init__.py index 1042a3d48..529fa279e 100644 --- a/scrapy/loader/__init__.py +++ b/scrapy/loader/__init__.py @@ -3,6 +3,7 @@ Item Loader See documentation in docs/topics/loaders.rst """ + import itemloaders from scrapy.item import Item diff --git a/scrapy/mail.py b/scrapy/mail.py index 237327451..4b18b6003 100644 --- a/scrapy/mail.py +++ b/scrapy/mail.py @@ -3,6 +3,7 @@ Mail sending helpers See documentation in docs/topics/email.rst """ + import logging from email import encoders as Encoders from email.mime.base import MIMEBase diff --git a/scrapy/pipelines/__init__.py b/scrapy/pipelines/__init__.py index c97d71fb6..f9544d329 100644 --- a/scrapy/pipelines/__init__.py +++ b/scrapy/pipelines/__init__.py @@ -3,6 +3,7 @@ Item pipeline See documentation in docs/item-pipeline.rst """ + from typing import Any, List from twisted.internet.defer import Deferred diff --git a/scrapy/pipelines/files.py b/scrapy/pipelines/files.py index 73064ad10..1d7625299 100644 --- a/scrapy/pipelines/files.py +++ b/scrapy/pipelines/files.py @@ -3,6 +3,7 @@ Files Pipeline See documentation in topics/media-pipeline.rst """ + import base64 import functools import hashlib diff --git a/scrapy/pipelines/images.py b/scrapy/pipelines/images.py index 02c4b1361..8169583f8 100644 --- a/scrapy/pipelines/images.py +++ b/scrapy/pipelines/images.py @@ -3,6 +3,7 @@ Images Pipeline See documentation in topics/media-pipeline.rst """ + import functools import hashlib import warnings diff --git a/scrapy/responsetypes.py b/scrapy/responsetypes.py index 0d127d851..702e50536 100644 --- a/scrapy/responsetypes.py +++ b/scrapy/responsetypes.py @@ -2,6 +2,7 @@ This module implements a class which returns the appropriate Response class based on different criteria. """ + from io import StringIO from mimetypes import MimeTypes from pkgutil import get_data diff --git a/scrapy/selector/unified.py b/scrapy/selector/unified.py index 863fb6032..75d5e9fbd 100644 --- a/scrapy/selector/unified.py +++ b/scrapy/selector/unified.py @@ -1,6 +1,7 @@ """ XPath selectors based on lxml """ + from typing import Any, Optional, Type, Union from parsel import Selector as _ParselSelector diff --git a/scrapy/settings/__init__.py b/scrapy/settings/__init__.py index b5d8fdb12..d270a72f4 100644 --- a/scrapy/settings/__init__.py +++ b/scrapy/settings/__init__.py @@ -58,7 +58,6 @@ def get_settings_priority(priority: Union[int, str]) -> int: class SettingsAttribute: - """Class for storing data related to settings attributes. This class is intended for internal usage, you should try Settings class diff --git a/scrapy/shell.py b/scrapy/shell.py index bb3b1461c..05909977a 100644 --- a/scrapy/shell.py +++ b/scrapy/shell.py @@ -3,6 +3,7 @@ See documentation in docs/topics/shell.rst """ + import os import signal diff --git a/scrapy/spidermiddlewares/httperror.py b/scrapy/spidermiddlewares/httperror.py index 94450b35b..35c869a75 100644 --- a/scrapy/spidermiddlewares/httperror.py +++ b/scrapy/spidermiddlewares/httperror.py @@ -3,6 +3,7 @@ HttpError Spider Middleware See documentation in docs/topics/spider-middleware.rst """ + from __future__ import annotations import logging diff --git a/scrapy/spidermiddlewares/offsite.py b/scrapy/spidermiddlewares/offsite.py index a5214702d..dd2fccfcb 100644 --- a/scrapy/spidermiddlewares/offsite.py +++ b/scrapy/spidermiddlewares/offsite.py @@ -3,6 +3,7 @@ Offsite Spider Middleware See documentation in docs/topics/spider-middleware.rst """ + from __future__ import annotations import logging diff --git a/scrapy/spidermiddlewares/referer.py b/scrapy/spidermiddlewares/referer.py index a29e0ebb5..a0b6851e5 100644 --- a/scrapy/spidermiddlewares/referer.py +++ b/scrapy/spidermiddlewares/referer.py @@ -2,6 +2,7 @@ RefererMiddleware: populates Request referer field, based on the Response which originated it. """ + from __future__ import annotations import warnings diff --git a/scrapy/spiders/__init__.py b/scrapy/spiders/__init__.py index e16d71727..72c2aaba7 100644 --- a/scrapy/spiders/__init__.py +++ b/scrapy/spiders/__init__.py @@ -3,6 +3,7 @@ Base class for Scrapy spiders See documentation in docs/topics/spiders.rst """ + from __future__ import annotations import logging diff --git a/scrapy/spiders/feed.py b/scrapy/spiders/feed.py index 599af7360..5caf8c79e 100644 --- a/scrapy/spiders/feed.py +++ b/scrapy/spiders/feed.py @@ -4,6 +4,7 @@ for scraping from an XML feed. See documentation in docs/topics/spiders.rst """ + from scrapy.exceptions import NotConfigured, NotSupported from scrapy.selector import Selector from scrapy.spiders import Spider diff --git a/scrapy/statscollectors.py b/scrapy/statscollectors.py index 15193aac5..ab571a3ab 100644 --- a/scrapy/statscollectors.py +++ b/scrapy/statscollectors.py @@ -1,6 +1,7 @@ """ Scrapy extension for collecting scraping stats """ + import logging import pprint from typing import TYPE_CHECKING, Any, Dict, Optional diff --git a/scrapy/utils/defer.py b/scrapy/utils/defer.py index bf3c5ef5b..c391db9fd 100644 --- a/scrapy/utils/defer.py +++ b/scrapy/utils/defer.py @@ -1,6 +1,7 @@ """ Helper functions for dealing with Twisted deferreds """ + import asyncio import inspect from asyncio import Future @@ -304,13 +305,11 @@ _T = TypeVar("_T") @overload -def deferred_from_coro(o: _CT) -> Deferred: - ... +def deferred_from_coro(o: _CT) -> Deferred: ... @overload -def deferred_from_coro(o: _T) -> _T: - ... +def deferred_from_coro(o: _T) -> _T: ... def deferred_from_coro(o: _T) -> Union[Deferred, _T]: diff --git a/scrapy/utils/deprecate.py b/scrapy/utils/deprecate.py index ea577c44a..e0f2ac763 100644 --- a/scrapy/utils/deprecate.py +++ b/scrapy/utils/deprecate.py @@ -138,13 +138,11 @@ DEPRECATION_RULES: List[Tuple[str, str]] = [] @overload -def update_classpath(path: str) -> str: - ... +def update_classpath(path: str) -> str: ... @overload -def update_classpath(path: Any) -> Any: - ... +def update_classpath(path: Any) -> Any: ... def update_classpath(path: Any) -> Any: diff --git a/scrapy/utils/iterators.py b/scrapy/utils/iterators.py index c56be5ea2..93a2ba7a1 100644 --- a/scrapy/utils/iterators.py +++ b/scrapy/utils/iterators.py @@ -225,18 +225,17 @@ def csviter( @overload -def _body_or_str(obj: Union[Response, str, bytes]) -> str: - ... +def _body_or_str(obj: Union[Response, str, bytes]) -> str: ... @overload -def _body_or_str(obj: Union[Response, str, bytes], unicode: Literal[True]) -> str: - ... +def _body_or_str(obj: Union[Response, str, bytes], unicode: Literal[True]) -> str: ... @overload -def _body_or_str(obj: Union[Response, str, bytes], unicode: Literal[False]) -> bytes: - ... +def _body_or_str( + obj: Union[Response, str, bytes], unicode: Literal[False] +) -> bytes: ... def _body_or_str( diff --git a/scrapy/utils/misc.py b/scrapy/utils/misc.py index b38190cb3..7b43760a8 100644 --- a/scrapy/utils/misc.py +++ b/scrapy/utils/misc.py @@ -1,4 +1,5 @@ """Helper functions which don't fit anywhere else""" + import ast import hashlib import inspect diff --git a/scrapy/utils/python.py b/scrapy/utils/python.py index 68ca96b69..7b408c49c 100644 --- a/scrapy/utils/python.py +++ b/scrapy/utils/python.py @@ -1,6 +1,7 @@ """ This module contains essential stuff that should've come with Python itself ;) """ + import collections.abc import gc import inspect @@ -285,13 +286,11 @@ def equal_attributes( @overload -def without_none_values(iterable: Mapping) -> dict: - ... +def without_none_values(iterable: Mapping) -> dict: ... @overload -def without_none_values(iterable: Iterable) -> Iterable: - ... +def without_none_values(iterable: Iterable) -> Iterable: ... def without_none_values(iterable: Union[Mapping, Iterable]) -> Union[dict, Iterable]: diff --git a/scrapy/utils/request.py b/scrapy/utils/request.py index db0b44cf4..e99d1eeb5 100644 --- a/scrapy/utils/request.py +++ b/scrapy/utils/request.py @@ -44,7 +44,9 @@ def _serialize_headers( yield from request.headers.getlist(header) -_fingerprint_cache: "WeakKeyDictionary[Request, Dict[Tuple[Optional[Tuple[bytes, ...]], bool], bytes]]" +_fingerprint_cache: ( + "WeakKeyDictionary[Request, Dict[Tuple[Optional[Tuple[bytes, ...]], bool], bytes]]" +) _fingerprint_cache = WeakKeyDictionary() @@ -114,8 +116,7 @@ def fingerprint( class RequestFingerprinterProtocol(Protocol): - def fingerprint(self, request: Request) -> bytes: - ... + def fingerprint(self, request: Request) -> bytes: ... class RequestFingerprinter: diff --git a/scrapy/utils/response.py b/scrapy/utils/response.py index 33cd692bf..63a484b42 100644 --- a/scrapy/utils/response.py +++ b/scrapy/utils/response.py @@ -2,6 +2,7 @@ This module provides some useful functions for working with scrapy.http.Response objects """ + import os import re import tempfile @@ -29,9 +30,9 @@ def get_base_url(response: "scrapy.http.response.text.TextResponse") -> str: return _baseurl_cache[response] -_metaref_cache: "WeakKeyDictionary[Response, Union[Tuple[None, None], Tuple[float, str]]]" = ( - WeakKeyDictionary() -) +_metaref_cache: ( + "WeakKeyDictionary[Response, Union[Tuple[None, None], Tuple[float, str]]]" +) = WeakKeyDictionary() def get_meta_refresh( diff --git a/scrapy/utils/signal.py b/scrapy/utils/signal.py index 21a12a19e..a25100c03 100644 --- a/scrapy/utils/signal.py +++ b/scrapy/utils/signal.py @@ -1,4 +1,5 @@ """Helper functions for working with signals""" + import collections.abc import logging from typing import Any as TypingAny diff --git a/scrapy/utils/sitemap.py b/scrapy/utils/sitemap.py index 3d2ecc9a7..8bf941eb2 100644 --- a/scrapy/utils/sitemap.py +++ b/scrapy/utils/sitemap.py @@ -4,6 +4,7 @@ Module for processing Sitemaps. Note: The main purpose of this module is to provide support for the SitemapSpider, its API is subject to change without notice. """ + from typing import Any, Dict, Generator, Iterator, Optional from urllib.parse import urljoin diff --git a/scrapy/utils/spider.py b/scrapy/utils/spider.py index 704df8657..855bc8f87 100644 --- a/scrapy/utils/spider.py +++ b/scrapy/utils/spider.py @@ -39,13 +39,11 @@ def iterate_spider_output(result: AsyncGenerator) -> AsyncGenerator: # type: ig @overload -def iterate_spider_output(result: CoroutineType) -> Deferred: - ... +def iterate_spider_output(result: CoroutineType) -> Deferred: ... @overload -def iterate_spider_output(result: _T) -> Iterable: - ... +def iterate_spider_output(result: _T) -> Iterable: ... def iterate_spider_output(result: Any) -> Union[Iterable, AsyncGenerator, Deferred]: @@ -83,8 +81,7 @@ def spidercls_for_request( default_spidercls: Type[Spider], log_none: bool = ..., log_multiple: bool = ..., -) -> Type[Spider]: - ... +) -> Type[Spider]: ... @overload @@ -94,8 +91,7 @@ def spidercls_for_request( default_spidercls: Literal[None], log_none: bool = ..., log_multiple: bool = ..., -) -> Optional[Type[Spider]]: - ... +) -> Optional[Type[Spider]]: ... @overload @@ -105,8 +101,7 @@ def spidercls_for_request( *, log_none: bool = ..., log_multiple: bool = ..., -) -> Optional[Type[Spider]]: - ... +) -> Optional[Type[Spider]]: ... def spidercls_for_request( diff --git a/scrapy/utils/url.py b/scrapy/utils/url.py index 22b4197f9..9d97cb12f 100644 --- a/scrapy/utils/url.py +++ b/scrapy/utils/url.py @@ -5,6 +5,7 @@ library. Some of the functions that used to be imported from this module have been moved to the w3lib.url module. Always import those from there instead. """ + import re from typing import TYPE_CHECKING, Iterable, Optional, Type, Union, cast from urllib.parse import ParseResult, urldefrag, urlparse, urlunparse diff --git a/tests/mocks/dummydbm.py b/tests/mocks/dummydbm.py index 2869ff8f7..bde3de228 100644 --- a/tests/mocks/dummydbm.py +++ b/tests/mocks/dummydbm.py @@ -1,4 +1,5 @@ """DBM-like dummy module""" + import collections from typing import Any, DefaultDict diff --git a/tests/spiders.py b/tests/spiders.py index 3df153a12..94969db99 100644 --- a/tests/spiders.py +++ b/tests/spiders.py @@ -1,6 +1,7 @@ """ Some spiders used for testing and benchmarking """ + import asyncio import time from urllib.parse import urlencode diff --git a/tests/test_exporters.py b/tests/test_exporters.py index c11913365..59b724495 100644 --- a/tests/test_exporters.py +++ b/tests/test_exporters.py @@ -121,7 +121,9 @@ class BaseItemExporterTest(unittest.TestCase): self.assertEqual(name, "John\xa3") ie = self._get_exporter(fields_to_export={"name": "名稱"}) - self.assertEqual(list(ie._get_serialized_fields(self.i)), [("名稱", "John\xa3")]) + self.assertEqual( + list(ie._get_serialized_fields(self.i)), [("名稱", "John\xa3")] + ) def test_field_custom_serializer(self): i = self.custom_field_item_class(name="John\xa3", age="22") diff --git a/tests/test_pipeline_media.py b/tests/test_pipeline_media.py index 820484565..d477b59be 100644 --- a/tests/test_pipeline_media.py +++ b/tests/test_pipeline_media.py @@ -22,9 +22,9 @@ from scrapy.utils.test import get_crawler try: from PIL import Image # noqa: imported just to check for the import error except ImportError: - skip_pillow: Optional[ - str - ] = "Missing Python Imaging Library, install https://pypi.python.org/pypi/Pillow" + skip_pillow: Optional[str] = ( + "Missing Python Imaging Library, install https://pypi.python.org/pypi/Pillow" + ) else: skip_pillow = None diff --git a/tests/test_responsetypes.py b/tests/test_responsetypes.py index 713a83d52..2633cca5b 100644 --- a/tests/test_responsetypes.py +++ b/tests/test_responsetypes.py @@ -33,7 +33,10 @@ class ResponseTypesTest(unittest.TestCase): ("attachment;filename=dataµ.tar.gz".encode("latin-1"), Response), ("attachment;filename=data高.doc".encode("gbk"), Response), ("attachment;filename=دورهdata.html".encode("cp720"), HtmlResponse), - ("attachment;filename=日本語版Wikipedia.xml".encode("iso2022_jp"), XmlResponse), + ( + "attachment;filename=日本語版Wikipedia.xml".encode("iso2022_jp"), + XmlResponse, + ), ] for source, cls in mappings: retcls = responsetypes.from_content_disposition(source) diff --git a/tests/test_webclient.py b/tests/test_webclient.py index 53558814d..cce119001 100644 --- a/tests/test_webclient.py +++ b/tests/test_webclient.py @@ -2,6 +2,7 @@ from twisted.internet import defer Tests borrowed from the twisted.web.client tests. """ + import shutil from pathlib import Path from tempfile import mkdtemp From 6e5918345b8eb10674e11d9c4c5db8c9028be674 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Tue, 27 Feb 2024 19:50:54 +0500 Subject: [PATCH 114/119] Bump bandit, flake8 and isort. --- .pre-commit-config.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 83bc65b67..a911d4cfe 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,11 +1,11 @@ repos: - repo: https://github.com/PyCQA/bandit - rev: 1.7.5 + rev: 1.7.7 hooks: - id: bandit args: [-r, -c, .bandit.yml] - repo: https://github.com/PyCQA/flake8 - rev: 6.1.0 + rev: 7.0.0 hooks: - id: flake8 - repo: https://github.com/psf/black.git @@ -13,7 +13,7 @@ repos: hooks: - id: black - repo: https://github.com/pycqa/isort - rev: 5.12.0 + rev: 5.13.2 hooks: - id: isort - repo: https://github.com/adamchainz/blacken-docs From 68104b9f48802d1ecc1c892c61aaa197500435b5 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Tue, 27 Feb 2024 20:21:27 +0500 Subject: [PATCH 115/119] Update .bandit.yml, add problem names. --- .bandit.yml | 35 +++++++++++++++++------------------ 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/.bandit.yml b/.bandit.yml index 8c6a08e1b..6e8331c0f 100644 --- a/.bandit.yml +++ b/.bandit.yml @@ -1,20 +1,19 @@ skips: -- B101 -- B113 # https://github.com/PyCQA/bandit/issues/1010 -- B105 -- B301 -- B303 -- B307 -- B311 -- B320 -- B321 -- B324 -- B402 # https://github.com/scrapy/scrapy/issues/4180 -- B403 -- B404 -- B406 -- B410 -- B503 -- B603 -- B605 +- B101 # assert_used +- B105 # hardcoded_password_string +- B301 # pickle +- B307 # eval +- B311 # random +- B320 # xml_bad_etree +- B321 # ftplib, https://github.com/scrapy/scrapy/issues/4180 +- B324 # hashlib "Use of weak SHA1 hash for security" +- B402 # import_ftplib, https://github.com/scrapy/scrapy/issues/4180 +- B403 # import_pickle +- B404 # import_subprocess +- B406 # import_xml_sax +- B410 # import_lxml +- B411 # import_xmlrpclib, https://github.com/PyCQA/bandit/issues/1082 +- B503 # ssl_with_bad_defaults +- B603 # subprocess_without_shell_equals_true +- B605 # start_process_with_a_shell exclude_dirs: ['tests'] From d2c05d9d96394e111ead1aa097402cdbc18c0859 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Tue, 27 Feb 2024 20:43:57 +0500 Subject: [PATCH 116/119] Bump mypy and type stubs. --- scrapy/utils/spider.py | 3 +-- scrapy/utils/ssl.py | 2 +- tox.ini | 14 +++++++------- 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/scrapy/utils/spider.py b/scrapy/utils/spider.py index 855bc8f87..cbbb01d85 100644 --- a/scrapy/utils/spider.py +++ b/scrapy/utils/spider.py @@ -34,8 +34,7 @@ _T = TypeVar("_T") # https://stackoverflow.com/questions/60222982 @overload -def iterate_spider_output(result: AsyncGenerator) -> AsyncGenerator: # type: ignore[misc] - ... +def iterate_spider_output(result: AsyncGenerator) -> AsyncGenerator: ... # type: ignore[overload-overlap] @overload diff --git a/scrapy/utils/ssl.py b/scrapy/utils/ssl.py index e74769c65..d520ef809 100644 --- a/scrapy/utils/ssl.py +++ b/scrapy/utils/ssl.py @@ -1,6 +1,6 @@ from typing import Any, Optional -import OpenSSL._util as pyOpenSSLutil # type: ignore[import-untyped] +import OpenSSL._util as pyOpenSSLutil import OpenSSL.SSL import OpenSSL.version from OpenSSL.crypto import X509Name diff --git a/tox.ini b/tox.ini index 359ff0f73..e787c7bf3 100644 --- a/tox.ini +++ b/tox.ini @@ -29,14 +29,14 @@ install_command = [testenv:typing] basepython = python3 deps = - mypy==1.6.1 - typing-extensions==4.8.0 + mypy==1.8.0 + typing-extensions==4.10.0 types-attrs==19.1.0 - types-lxml==2023.10.21 - types-Pillow==10.1.0.0 - types-Pygments==2.16.0.0 - types-pyOpenSSL==23.3.0.0 - types-setuptools==68.2.0.0 + types-lxml==2024.2.9 + types-Pillow==10.2.0.20240213 + types-Pygments==2.17.0.20240106 + types-pyOpenSSL==24.0.0.20240130 + types-setuptools==69.1.0.20240223 # 2.1.2 fixes a typing bug: https://github.com/scrapy/w3lib/pull/211 w3lib >= 2.1.2 commands = From 4f9dd998dcf01c003bc2a053b6bd78091d12ecd5 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Tue, 27 Feb 2024 22:01:36 +0500 Subject: [PATCH 117/119] Bump pylint, cleanup the ignored tags. --- docs/conf.py | 2 +- pylintrc | 17 +----------- .../downloadermiddlewares/httpcompression.py | 2 +- scrapy/http/headers.py | 4 ++- scrapy/utils/ossignal.py | 6 ++++- scrapy/utils/signal.py | 5 +++- tests/test_commands.py | 27 ------------------- tests/test_item.py | 4 ++- tests/test_linkextractors.py | 3 --- tests/test_loader_deprecated.py | 6 ++--- tests/test_settings/__init__.py | 2 +- tests/test_utils_datatypes.py | 2 +- tests/test_utils_signal.py | 6 ----- tests/test_utils_spider.py | 2 +- tox.ini | 2 +- 15 files changed, 25 insertions(+), 65 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 9ca0f817a..399078010 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -227,7 +227,7 @@ latex_documents = [ # A list of regular expressions that match URIs that should not be checked when # doing a linkcheck build. linkcheck_ignore = [ - "http://localhost:\d+", + r"http://localhost:\d+", "http://hg.scrapy.org", "http://directory.google.com/", ] diff --git a/pylintrc b/pylintrc index c8654b8d3..78004e78a 100644 --- a/pylintrc +++ b/pylintrc @@ -4,21 +4,14 @@ jobs=1 # >1 hides results [MESSAGES CONTROL] disable=abstract-method, - anomalous-backslash-in-string, arguments-differ, arguments-renamed, attribute-defined-outside-init, bad-classmethod-argument, - bad-mcs-classmethod-argument, bare-except, broad-except, broad-exception-raised, c-extension-no-member, - catching-non-exception, - cell-var-from-loop, - comparison-with-callable, - consider-using-dict-items, - consider-using-in, consider-using-with, cyclic-import, dangerous-default-value, @@ -32,7 +25,6 @@ disable=abstract-method, implicit-str-concat, import-error, import-outside-toplevel, - import-self, inconsistent-return-statements, inherit-non-class, invalid-name, @@ -44,7 +36,6 @@ disable=abstract-method, logging-fstring-interpolation, logging-not-lazy, lost-exception, - method-hidden, missing-docstring, no-else-raise, no-else-return, @@ -52,7 +43,7 @@ disable=abstract-method, no-method-argument, no-name-in-module, no-self-argument, - no-value-for-parameter, + no-value-for-parameter, # https://github.com/pylint-dev/pylint/issues/3268 not-callable, pointless-exception-statement, pointless-statement, @@ -77,14 +68,10 @@ disable=abstract-method, too-many-public-methods, too-many-return-statements, unbalanced-tuple-unpacking, - undefined-variable, - undefined-loop-variable, - unexpected-special-method-signature, unnecessary-comprehension, unnecessary-dunder-call, unnecessary-pass, unreachable, - unsubscriptable-object, unused-argument, unused-import, unused-private-member, @@ -92,8 +79,6 @@ disable=abstract-method, unused-wildcard-import, use-dict-literal, used-before-assignment, - useless-object-inheritance, # Required for Python 2 support useless-return, - useless-super-delegation, wildcard-import, wrong-import-position diff --git a/scrapy/downloadermiddlewares/httpcompression.py b/scrapy/downloadermiddlewares/httpcompression.py index 1e340abb6..f0ad24f72 100644 --- a/scrapy/downloadermiddlewares/httpcompression.py +++ b/scrapy/downloadermiddlewares/httpcompression.py @@ -169,7 +169,7 @@ class HttpCompressionMiddleware: return to_decode, to_keep def _decode(self, body: bytes, encoding: bytes, max_size: int) -> bytes: - if encoding == b"gzip" or encoding == b"x-gzip": + if encoding in {b"gzip", b"x-gzip"}: return gunzip(body, max_size=max_size) if encoding == b"deflate": return _inflate(body, max_size=max_size) diff --git a/scrapy/http/headers.py b/scrapy/http/headers.py index 21eb9fb73..73aee7178 100644 --- a/scrapy/http/headers.py +++ b/scrapy/http/headers.py @@ -113,7 +113,9 @@ class Headers(CaselessDict): return ((k, self.getlist(k)) for k in self.keys()) def values(self) -> List[Optional[bytes]]: # type: ignore[override] - return [self[k] for k in self.keys()] + return [ + self[k] for k in self.keys() # pylint: disable=consider-using-dict-items + ] def to_string(self) -> bytes: # cast() can be removed if the headers_dict_to_raw() hint is improved diff --git a/scrapy/utils/ossignal.py b/scrapy/utils/ossignal.py index db9a71273..5985a847e 100644 --- a/scrapy/utils/ossignal.py +++ b/scrapy/utils/ossignal.py @@ -24,7 +24,11 @@ def install_shutdown_handlers( (e.g. Pdb) """ signal.signal(signal.SIGTERM, function) - if signal.getsignal(signal.SIGINT) == signal.default_int_handler or override_sigint: + if ( + signal.getsignal(signal.SIGINT) # pylint: disable=comparison-with-callable + == signal.default_int_handler + or override_sigint + ): signal.signal(signal.SIGINT, function) # Catch Ctrl-Break in windows if hasattr(signal, "SIGBREAK"): diff --git a/scrapy/utils/signal.py b/scrapy/utils/signal.py index a25100c03..89cfbd2ec 100644 --- a/scrapy/utils/signal.py +++ b/scrapy/utils/signal.py @@ -98,7 +98,10 @@ def send_catch_log_deferred( robustApply, receiver, signal=signal, sender=sender, *arguments, **named ) d.addErrback(logerror, receiver) - d.addBoth(lambda result: (receiver, result)) + # TODO https://pylint.readthedocs.io/en/latest/user_guide/messages/warning/cell-var-from-loop.html + d.addBoth( + lambda result: (receiver, result) # pylint: disable=cell-var-from-loop + ) dfds.append(d) d = DeferredList(dfds) d.addCallback(lambda out: [x[1] for x in out]) diff --git a/tests/test_commands.py b/tests/test_commands.py index 2f36baa87..febad21da 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -991,38 +991,11 @@ class MySpider(scrapy.Spider): class WindowsRunSpiderCommandTest(RunSpiderCommandTest): spider_filename = "myspider.pyw" - def setUp(self): - super().setUp() - def test_start_requests_errors(self): log = self.get_log(self.badspider, name="badspider.pyw") self.assertIn("start_requests", log) self.assertIn("badspider.pyw", log) - def test_run_good_spider(self): - super().test_run_good_spider() - - def test_runspider(self): - super().test_runspider() - - def test_runspider_dnscache_disabled(self): - super().test_runspider_dnscache_disabled() - - def test_runspider_log_level(self): - super().test_runspider_log_level() - - def test_runspider_log_short_names(self): - super().test_runspider_log_short_names() - - def test_runspider_no_spider_found(self): - super().test_runspider_no_spider_found() - - def test_output(self): - super().test_output() - - def test_overwrite_output(self): - super().test_overwrite_output() - def test_runspider_unable_to_load(self): raise unittest.SkipTest("Already Tested in 'RunSpiderCommandTest' ") diff --git a/tests/test_item.py b/tests/test_item.py index ce2b4fd15..daf5d4f59 100644 --- a/tests/test_item.py +++ b/tests/test_item.py @@ -290,7 +290,9 @@ class ItemMetaTest(unittest.TestCase): class ItemMetaClassCellRegression(unittest.TestCase): def test_item_meta_classcell_regression(self): class MyItem(Item, metaclass=ItemMeta): - def __init__(self, *args, **kwargs): + def __init__( + self, *args, **kwargs + ): # pylint: disable=useless-parent-delegation # This call to super() trigger the __classcell__ propagation # requirement. When not done properly raises an error: # TypeError: __class__ set to diff --git a/tests/test_linkextractors.py b/tests/test_linkextractors.py index 55ea9eed2..6b4df90d8 100644 --- a/tests/test_linkextractors.py +++ b/tests/test_linkextractors.py @@ -818,9 +818,6 @@ class LxmlLinkExtractorTestCase(Base.LinkExtractorTestCase): ], ) - def test_restrict_xpaths_with_html_entities(self): - super().test_restrict_xpaths_with_html_entities() - @mark.skipif( Version(w3lib_version) < Version("2.0.0"), reason=( diff --git a/tests/test_loader_deprecated.py b/tests/test_loader_deprecated.py index d7f773d5c..99cdf88d9 100644 --- a/tests/test_loader_deprecated.py +++ b/tests/test_loader_deprecated.py @@ -678,11 +678,11 @@ class SelectJmesTestCase(unittest.TestCase): } def test_output(self): - for tl in self.test_list_equals: - expr, test_list, expected = self.test_list_equals[tl] + for k, v in self.test_list_equals.items(): + expr, test_list, expected = v test = SelectJmes(expr)(test_list) self.assertEqual( - test, expected, msg=f'test "{tl}" got {test} expected {expected}' + test, expected, msg=f'test "{k}" got {test} expected {expected}' ) diff --git a/tests/test_settings/__init__.py b/tests/test_settings/__init__.py index 3fde5e8c5..9ee248538 100644 --- a/tests/test_settings/__init__.py +++ b/tests/test_settings/__init__.py @@ -426,7 +426,7 @@ class SettingsTest(unittest.TestCase): mydict = settings.get("TEST_DICT") self.assertIsInstance(mydict, BaseSettings) self.assertIn("key", mydict) - self.assertEqual(mydict["key"], "val") + self.assertEqual(mydict["key"], "val") # pylint: disable=unsubscriptable-object self.assertEqual(mydict.getpriority("key"), 0) @mock.patch("scrapy.settings.default_settings", default_settings) diff --git a/tests/test_utils_datatypes.py b/tests/test_utils_datatypes.py index 9e5f88f48..be5c6de81 100644 --- a/tests/test_utils_datatypes.py +++ b/tests/test_utils_datatypes.py @@ -353,7 +353,7 @@ class LocalWeakReferencedCacheTest(unittest.TestCase): for i, r in enumerate(refs): self.assertIn(r, cache) self.assertEqual(cache[r], i) - del r # delete reference to the last object in the list + del r # delete reference to the last object in the list # pylint: disable=undefined-loop-variable # delete half of the objects, make sure that is reflected in the cache for _ in range(max // 2): diff --git a/tests/test_utils_signal.py b/tests/test_utils_signal.py index 65b99e0c4..60232f10b 100644 --- a/tests/test_utils_signal.py +++ b/tests/test_utils_signal.py @@ -75,9 +75,6 @@ class SendCatchLogDeferredAsyncDefTest(SendCatchLogDeferredTest): await defer.succeed(42) return "OK" - def test_send_catch_log(self): - return super().test_send_catch_log() - @mark.only_asyncio() class SendCatchLogDeferredAsyncioTest(SendCatchLogDeferredTest): @@ -87,9 +84,6 @@ class SendCatchLogDeferredAsyncioTest(SendCatchLogDeferredTest): await asyncio.sleep(0.2) return await get_from_asyncio_queue("OK") - def test_send_catch_log(self): - return super().test_send_catch_log() - class SendCatchLogTest2(unittest.TestCase): def test_error_logged_if_deferred_not_supported(self): diff --git a/tests/test_utils_spider.py b/tests/test_utils_spider.py index 460ae40c3..dd1d26448 100644 --- a/tests/test_utils_spider.py +++ b/tests/test_utils_spider.py @@ -26,7 +26,7 @@ class UtilsSpidersTestCase(unittest.TestCase): self.assertEqual(list(iterate_spider_output([r, i, o])), [r, i, o]) def test_iter_spider_classes(self): - import tests.test_utils_spider + import tests.test_utils_spider # pylint: disable=import-self it = iter_spider_classes(tests.test_utils_spider) self.assertEqual(set(it), {MySpider1, MySpider2}) diff --git a/tox.ini b/tox.ini index e787c7bf3..4ed9b3bd7 100644 --- a/tox.ini +++ b/tox.ini @@ -53,7 +53,7 @@ commands = basepython = python3 deps = {[testenv:extra-deps]deps} - pylint==3.0.1 + pylint==3.1.0 commands = pylint conftest.py docs extras scrapy setup.py tests From 63acd0720970c87450fdbcb9aa6967118c9c1cf2 Mon Sep 17 00:00:00 2001 From: Laerte Pereira Date: Wed, 28 Feb 2024 16:14:08 -0300 Subject: [PATCH 118/119] Fix and re-enable unnecessary-comprehension and use-dict-literal pylint tags --- pylintrc | 2 -- scrapy/downloadermiddlewares/httpcompression.py | 2 +- scrapy/spiders/crawl.py | 2 +- scrapy/utils/python.py | 2 +- 4 files changed, 3 insertions(+), 5 deletions(-) diff --git a/pylintrc b/pylintrc index 78004e78a..c60e4e16a 100644 --- a/pylintrc +++ b/pylintrc @@ -68,7 +68,6 @@ disable=abstract-method, too-many-public-methods, too-many-return-statements, unbalanced-tuple-unpacking, - unnecessary-comprehension, unnecessary-dunder-call, unnecessary-pass, unreachable, @@ -77,7 +76,6 @@ disable=abstract-method, unused-private-member, unused-variable, unused-wildcard-import, - use-dict-literal, used-before-assignment, useless-return, wildcard-import, diff --git a/scrapy/downloadermiddlewares/httpcompression.py b/scrapy/downloadermiddlewares/httpcompression.py index f0ad24f72..aa3abe853 100644 --- a/scrapy/downloadermiddlewares/httpcompression.py +++ b/scrapy/downloadermiddlewares/httpcompression.py @@ -135,7 +135,7 @@ class HttpCompressionMiddleware: respcls = responsetypes.from_args( headers=response.headers, url=response.url, body=decoded_body ) - kwargs = dict(cls=respcls, body=decoded_body) + kwargs = {"cls": respcls, "body": decoded_body} if issubclass(respcls, TextResponse): # force recalculating the encoding until we make sure the # responsetypes guessing is reliable diff --git a/scrapy/spiders/crawl.py b/scrapy/spiders/crawl.py index ebb4f5984..2a3913da5 100644 --- a/scrapy/spiders/crawl.py +++ b/scrapy/spiders/crawl.py @@ -85,7 +85,7 @@ class CrawlSpider(Spider): url=link.url, callback=self._callback, errback=self._errback, - meta=dict(rule=rule_index, link_text=link.text), + meta={"rule": rule_index, "link_text": link.text}, ) def _requests_to_follow(self, response): diff --git a/scrapy/utils/python.py b/scrapy/utils/python.py index 7b408c49c..1e7364e49 100644 --- a/scrapy/utils/python.py +++ b/scrapy/utils/python.py @@ -162,7 +162,7 @@ def re_rsearch( pattern = re.compile(pattern) for chunk, offset in _chunk_iter(): - matches = [match for match in pattern.finditer(chunk)] + matches = list(pattern.finditer(chunk)) if matches: start, end = matches[-1].span() return offset + start, offset + end From 26a16f2c43dc96fe33d0b0fc8846402e9ae97e9a Mon Sep 17 00:00:00 2001 From: Laerte Pereira Date: Wed, 28 Feb 2024 16:36:19 -0300 Subject: [PATCH 119/119] Fix tests --- tests/test_crawl.py | 10 ++--- tests/test_downloadermiddleware_cookies.py | 2 +- tests/test_downloadermiddleware_httpauth.py | 4 +- tests/test_exporters.py | 24 +++++------ tests/test_linkextractors.py | 38 ++++++++-------- tests/test_loader_deprecated.py | 20 ++++----- tests/test_mail.py | 2 +- tests/test_pipeline_crawl.py | 2 +- tests/test_pipeline_files.py | 2 +- tests/test_pipeline_images.py | 18 ++++---- tests/test_pipeline_media.py | 48 ++++++++++----------- tests/test_scheduler.py | 26 +++++------ tests/test_spidermiddleware_offsite.py | 17 +++++--- tests/test_utils_iterators.py | 16 +++---- tests/test_utils_template.py | 2 +- 15 files changed, 117 insertions(+), 114 deletions(-) diff --git a/tests/test_crawl.py b/tests/test_crawl.py index 96d43b2b9..6cde4ed8c 100644 --- a/tests/test_crawl.py +++ b/tests/test_crawl.py @@ -76,11 +76,11 @@ class CrawlTestCase(TestCase): @defer.inlineCallbacks def _test_delay(self, total, delay, randomize=False): - crawl_kwargs = dict( - maxlatency=delay * 2, - mockserver=self.mockserver, - total=total, - ) + crawl_kwargs = { + "maxlatency": delay * 2, + "mockserver": self.mockserver, + "total": total, + } tolerance = 1 - (0.6 if randomize else 0.2) settings = {"DOWNLOAD_DELAY": delay, "RANDOMIZE_DOWNLOAD_DELAY": randomize} diff --git a/tests/test_downloadermiddleware_cookies.py b/tests/test_downloadermiddleware_cookies.py index 4a81a638e..425fabcc7 100644 --- a/tests/test_downloadermiddleware_cookies.py +++ b/tests/test_downloadermiddleware_cookies.py @@ -320,7 +320,7 @@ class CookiesMiddlewareTest(TestCase): @pytest.mark.xfail(reason="Cookie header is not currently being processed") def test_keep_cookie_from_default_request_headers_middleware(self): - DEFAULT_REQUEST_HEADERS = dict(Cookie="default=value; asdf=qwerty") + DEFAULT_REQUEST_HEADERS = {"Cookie": "default=value; asdf=qwerty"} mw_default_headers = DefaultHeadersMiddleware(DEFAULT_REQUEST_HEADERS.items()) # overwrite with values from 'cookies' request argument req1 = Request("http://example.org", cookies={"default": "something"}) diff --git a/tests/test_downloadermiddleware_httpauth.py b/tests/test_downloadermiddleware_httpauth.py index fc110e6cc..500af6536 100644 --- a/tests/test_downloadermiddleware_httpauth.py +++ b/tests/test_downloadermiddleware_httpauth.py @@ -59,7 +59,7 @@ class HttpAuthMiddlewareTest(unittest.TestCase): self.assertEqual(req.headers["Authorization"], basic_auth_header("foo", "bar")) def test_auth_already_set(self): - req = Request("http://example.com/", headers=dict(Authorization="Digest 123")) + req = Request("http://example.com/", headers={"Authorization": "Digest 123"}) assert self.mw.process_request(req, self.spider) is None self.assertEqual(req.headers["Authorization"], b"Digest 123") @@ -79,6 +79,6 @@ class HttpAuthAnyMiddlewareTest(unittest.TestCase): self.assertEqual(req.headers["Authorization"], basic_auth_header("foo", "bar")) def test_auth_already_set(self): - req = Request("http://example.com/", headers=dict(Authorization="Digest 123")) + req = Request("http://example.com/", headers={"Authorization": "Digest 123"}) assert self.mw.process_request(req, self.spider) is None self.assertEqual(req.headers["Authorization"], b"Digest 123") diff --git a/tests/test_exporters.py b/tests/test_exporters.py index 59b724495..fa9389044 100644 --- a/tests/test_exporters.py +++ b/tests/test_exporters.py @@ -152,7 +152,7 @@ class PythonItemExporterTest(BaseItemExporterTest): def test_nested_item(self): i1 = self.item_class(name="Joseph", age="22") - i2 = dict(name="Maria", age=i1) + i2 = {"name": "Maria", "age": i1} i3 = self.item_class(name="Jesus", age=i2) ie = self._get_exporter() exported = ie.export_item(i3) @@ -185,7 +185,7 @@ class PythonItemExporterTest(BaseItemExporterTest): def test_export_item_dict_list(self): i1 = self.item_class(name="Joseph", age="22") - i2 = dict(name="Maria", age=[i1]) + i2 = {"name": "Maria", "age": [i1]} i3 = self.item_class(name="Jesus", age=[i2]) ie = self._get_exporter() exported = ie.export_item(i3) @@ -373,7 +373,7 @@ class CsvItemExporterTest(BaseItemExporterTest): def test_join_multivalue_not_strings(self): self.assertExportResult( - item=dict(name="John", friends=[4, 8]), + item={"name": "John", "friends": [4, 8]}, include_headers_line=False, expected='"[4, 8]",John\r\n', ) @@ -388,14 +388,14 @@ class CsvItemExporterTest(BaseItemExporterTest): def test_errors_default(self): with self.assertRaises(UnicodeEncodeError): self.assertExportResult( - item=dict(text="W\u0275\u200Brd"), + item={"text": "W\u0275\u200Brd"}, expected=None, encoding="windows-1251", ) def test_errors_xmlcharrefreplace(self): self.assertExportResult( - item=dict(text="W\u0275\u200Brd"), + item={"text": "W\u0275\u200Brd"}, include_headers_line=False, expected="Wɵ​rd\r\n", encoding="windows-1251", @@ -455,8 +455,8 @@ class XmlItemExporterTest(BaseItemExporterTest): ) def test_nested_item(self): - i1 = dict(name="foo\xa3hoo", age="22") - i2 = dict(name="bar", age=i1) + i1 = {"name": "foo\xa3hoo", "age": "22"} + i2 = {"name": "bar", "age": i1} i3 = self.item_class(name="buz", age=i2) self.assertExportResult( @@ -478,8 +478,8 @@ class XmlItemExporterTest(BaseItemExporterTest): ) def test_nested_list_item(self): - i1 = dict(name="foo") - i2 = dict(name="bar", v2={"egg": ["spam"]}) + i1 = {"name": "foo"} + i2 = {"name": "bar", "v2": {"egg": ["spam"]}} i3 = self.item_class(name="buz", age=[i1, i2]) self.assertExportResult( @@ -534,7 +534,7 @@ class JsonLinesItemExporterTest(BaseItemExporterTest): def test_nested_item(self): i1 = self.item_class(name="Joseph", age="22") - i2 = dict(name="Maria", age=i1) + i2 = {"name": "Maria", "age": i1} i3 = self.item_class(name="Jesus", age=i2) self.ie.start_exporting() self.ie.export_item(i3) @@ -622,9 +622,9 @@ class JsonItemExporterTest(JsonLinesItemExporterTest): self.assertEqual(exported, [expected]) def test_nested_dict_item(self): - i1 = dict(name="Joseph\xa3", age="22") + i1 = {"name": "Joseph\xa3", "age": "22"} i2 = self.item_class(name="Maria", age=i1) - i3 = dict(name="Jesus", age=i2) + i3 = {"name": "Jesus", "age": i2} self.ie.start_exporting() self.ie.export_item(i3) self.ie.finish_exporting() diff --git a/tests/test_linkextractors.py b/tests/test_linkextractors.py index 6b4df90d8..217c7a299 100644 --- a/tests/test_linkextractors.py +++ b/tests/test_linkextractors.py @@ -37,7 +37,7 @@ class Base: page4_url = "http://example.com/page%204.html" self.assertEqual( - [link for link in lx.extract_links(self.response)], + list(lx.extract_links(self.response)), [ Link(url="http://example.com/sample1.html", text=""), Link(url="http://example.com/sample2.html", text="sample 2"), @@ -55,7 +55,7 @@ class Base: def test_extract_filter_allow(self): lx = self.extractor_cls(allow=("sample",)) self.assertEqual( - [link for link in lx.extract_links(self.response)], + list(lx.extract_links(self.response)), [ Link(url="http://example.com/sample1.html", text=""), Link(url="http://example.com/sample2.html", text="sample 2"), @@ -70,7 +70,7 @@ class Base: def test_extract_filter_allow_with_duplicates(self): lx = self.extractor_cls(allow=("sample",), unique=False) self.assertEqual( - [link for link in lx.extract_links(self.response)], + list(lx.extract_links(self.response)), [ Link(url="http://example.com/sample1.html", text=""), Link(url="http://example.com/sample2.html", text="sample 2"), @@ -93,7 +93,7 @@ class Base: def test_extract_filter_allow_with_duplicates_canonicalize(self): lx = self.extractor_cls(allow=("sample",), unique=False, canonicalize=True) self.assertEqual( - [link for link in lx.extract_links(self.response)], + list(lx.extract_links(self.response)), [ Link(url="http://example.com/sample1.html", text=""), Link(url="http://example.com/sample2.html", text="sample 2"), @@ -116,7 +116,7 @@ class Base: def test_extract_filter_allow_no_duplicates_canonicalize(self): lx = self.extractor_cls(allow=("sample",), unique=True, canonicalize=True) self.assertEqual( - [link for link in lx.extract_links(self.response)], + list(lx.extract_links(self.response)), [ Link(url="http://example.com/sample1.html", text=""), Link(url="http://example.com/sample2.html", text="sample 2"), @@ -127,7 +127,7 @@ class Base: def test_extract_filter_allow_and_deny(self): lx = self.extractor_cls(allow=("sample",), deny=("3",)) self.assertEqual( - [link for link in lx.extract_links(self.response)], + list(lx.extract_links(self.response)), [ Link(url="http://example.com/sample1.html", text=""), Link(url="http://example.com/sample2.html", text="sample 2"), @@ -137,7 +137,7 @@ class Base: def test_extract_filter_allowed_domains(self): lx = self.extractor_cls(allow_domains=("google.com",)) self.assertEqual( - [link for link in lx.extract_links(self.response)], + list(lx.extract_links(self.response)), [ Link(url="http://www.google.com/something", text=""), ], @@ -148,7 +148,7 @@ class Base: lx = self.extractor_cls(allow="sample") self.assertEqual( - [link for link in lx.extract_links(self.response)], + list(lx.extract_links(self.response)), [ Link(url="http://example.com/sample1.html", text=""), Link(url="http://example.com/sample2.html", text="sample 2"), @@ -162,7 +162,7 @@ class Base: lx = self.extractor_cls(allow="sample", deny="3") self.assertEqual( - [link for link in lx.extract_links(self.response)], + list(lx.extract_links(self.response)), [ Link(url="http://example.com/sample1.html", text=""), Link(url="http://example.com/sample2.html", text="sample 2"), @@ -171,7 +171,7 @@ class Base: lx = self.extractor_cls(allow_domains="google.com") self.assertEqual( - [link for link in lx.extract_links(self.response)], + list(lx.extract_links(self.response)), [ Link(url="http://www.google.com/something", text=""), ], @@ -179,7 +179,7 @@ class Base: lx = self.extractor_cls(deny_domains="example.com") self.assertEqual( - [link for link in lx.extract_links(self.response)], + list(lx.extract_links(self.response)), [ Link(url="http://www.google.com/something", text=""), ], @@ -265,7 +265,7 @@ class Base: def test_restrict_xpaths(self): lx = self.extractor_cls(restrict_xpaths=('//div[@id="subwrapper"]',)) self.assertEqual( - [link for link in lx.extract_links(self.response)], + list(lx.extract_links(self.response)), [ Link(url="http://example.com/sample1.html", text=""), Link(url="http://example.com/sample2.html", text="sample 2"), @@ -337,7 +337,7 @@ class Base: restrict_css=("#subwrapper + a",), ) self.assertEqual( - [link for link in lx.extract_links(self.response)], + list(lx.extract_links(self.response)), [ Link(url="http://example.com/sample1.html", text=""), Link(url="http://example.com/sample2.html", text="sample 2"), @@ -705,7 +705,7 @@ class Base: response = HtmlResponse("http://example.org/index.html", body=html) lx = self.extractor_cls() self.assertEqual( - [link for link in lx.extract_links(response)], + list(lx.extract_links(response)), [ Link( url="http://example.org/item1.html", @@ -758,7 +758,7 @@ class LxmlLinkExtractorTestCase(Base.LinkExtractorTestCase): response = HtmlResponse("http://example.org/index.html", body=html) lx = self.extractor_cls() self.assertEqual( - [link for link in lx.extract_links(response)], + list(lx.extract_links(response)), [ Link( url="http://example.org/item1.html", text="Item 1", nofollow=False @@ -779,7 +779,7 @@ class LxmlLinkExtractorTestCase(Base.LinkExtractorTestCase): # Simple text inclusion test lx = self.extractor_cls(restrict_text="dog") self.assertEqual( - [link for link in lx.extract_links(response)], + list(lx.extract_links(response)), [ Link( url="http://example.org/item2.html", @@ -791,7 +791,7 @@ class LxmlLinkExtractorTestCase(Base.LinkExtractorTestCase): # Unique regex test lx = self.extractor_cls(restrict_text=r"of.*dog") self.assertEqual( - [link for link in lx.extract_links(response)], + list(lx.extract_links(response)), [ Link( url="http://example.org/item2.html", @@ -803,7 +803,7 @@ class LxmlLinkExtractorTestCase(Base.LinkExtractorTestCase): # Multiple regex test lx = self.extractor_cls(restrict_text=[r"of.*dog", r"of.*cat"]) self.assertEqual( - [link for link in lx.extract_links(response)], + list(lx.extract_links(response)), [ Link( url="http://example.org/item1.html", @@ -834,7 +834,7 @@ class LxmlLinkExtractorTestCase(Base.LinkExtractorTestCase): response = HtmlResponse("http://example.org/index.html", body=html) lx = self.extractor_cls() self.assertEqual( - [link for link in lx.extract_links(response)], + list(lx.extract_links(response)), [ Link( url="http://example.org/item2.html", diff --git a/tests/test_loader_deprecated.py b/tests/test_loader_deprecated.py index 99cdf88d9..528efa142 100644 --- a/tests/test_loader_deprecated.py +++ b/tests/test_loader_deprecated.py @@ -565,37 +565,37 @@ class NoInputReprocessingFromDictTest(unittest.TestCase): """ def test_avoid_reprocessing_with_initial_values_single(self): - il = NoInputReprocessingDictLoader(item=dict(title="foo")) + il = NoInputReprocessingDictLoader(item={"title": "foo"}) il_loaded = il.load_item() - self.assertEqual(il_loaded, dict(title="foo")) + self.assertEqual(il_loaded, {"title": "foo"}) self.assertEqual( - NoInputReprocessingDictLoader(item=il_loaded).load_item(), dict(title="foo") + NoInputReprocessingDictLoader(item=il_loaded).load_item(), {"title": "foo"} ) def test_avoid_reprocessing_with_initial_values_list(self): - il = NoInputReprocessingDictLoader(item=dict(title=["foo", "bar"])) + il = NoInputReprocessingDictLoader(item={"title": ["foo", "bar"]}) il_loaded = il.load_item() - self.assertEqual(il_loaded, dict(title="foo")) + self.assertEqual(il_loaded, {"title": "foo"}) self.assertEqual( - NoInputReprocessingDictLoader(item=il_loaded).load_item(), dict(title="foo") + NoInputReprocessingDictLoader(item=il_loaded).load_item(), {"title": "foo"} ) def test_avoid_reprocessing_without_initial_values_single(self): il = NoInputReprocessingDictLoader() il.add_value("title", "foo") il_loaded = il.load_item() - self.assertEqual(il_loaded, dict(title="FOO")) + self.assertEqual(il_loaded, {"title": "FOO"}) self.assertEqual( - NoInputReprocessingDictLoader(item=il_loaded).load_item(), dict(title="FOO") + NoInputReprocessingDictLoader(item=il_loaded).load_item(), {"title": "FOO"} ) def test_avoid_reprocessing_without_initial_values_list(self): il = NoInputReprocessingDictLoader() il.add_value("title", ["foo", "bar"]) il_loaded = il.load_item() - self.assertEqual(il_loaded, dict(title="FOO")) + self.assertEqual(il_loaded, {"title": "FOO"}) self.assertEqual( - NoInputReprocessingDictLoader(item=il_loaded).load_item(), dict(title="FOO") + NoInputReprocessingDictLoader(item=il_loaded).load_item(), {"title": "FOO"} ) diff --git a/tests/test_mail.py b/tests/test_mail.py index 2535e58db..ff1505397 100644 --- a/tests/test_mail.py +++ b/tests/test_mail.py @@ -91,7 +91,7 @@ class MailSenderTest(unittest.TestCase): self.assertEqual(attach.get_payload(decode=True), b"content") def _catch_mail_sent(self, **kwargs): - self.catched_msg = dict(**kwargs) + self.catched_msg = {**kwargs} def test_send_utf8(self): subject = "sübjèçt" diff --git a/tests/test_pipeline_crawl.py b/tests/test_pipeline_crawl.py index be9811980..5a9a217ce 100644 --- a/tests/test_pipeline_crawl.py +++ b/tests/test_pipeline_crawl.py @@ -140,7 +140,7 @@ class FileDownloadCrawlTestCase(TestCase): self.assertEqual(logs.count(file_dl_failure), 3) # check that no files were written to the media store - self.assertEqual([x for x in self.tmpmediastore.iterdir()], []) + self.assertEqual(list(self.tmpmediastore.iterdir()), []) @defer.inlineCallbacks def test_download_media(self): diff --git a/tests/test_pipeline_files.py b/tests/test_pipeline_files.py index e7000e314..0babde4d9 100644 --- a/tests/test_pipeline_files.py +++ b/tests/test_pipeline_files.py @@ -221,7 +221,7 @@ class FilesPipelineTestCase(unittest.TestCase): file_path = CustomFilesPipeline.from_settings( Settings({"FILES_STORE": self.tempdir}) ).file_path - item = dict(path="path-to-store-file") + item = {"path": "path-to-store-file"} request = Request("http://example.com") self.assertEqual(file_path(request, item=item), "full/path-to-store-file") diff --git a/tests/test_pipeline_images.py b/tests/test_pipeline_images.py index 2e2e06b89..18a2454b3 100644 --- a/tests/test_pipeline_images.py +++ b/tests/test_pipeline_images.py @@ -132,7 +132,7 @@ class ImagesPipelineTestCase(unittest.TestCase): thumb_path = CustomImagesPipeline.from_settings( Settings({"IMAGES_STORE": self.tempdir}) ).thumb_path - item = dict(path="path-to-store-file") + item = {"path": "path-to-store-file"} request = Request("http://example.com") self.assertEqual( thumb_path(request, "small", item=item), "thumb/small/path-to-store-file" @@ -433,14 +433,14 @@ class ImagesPipelineTestCaseCustomSettings(unittest.TestCase): ] # This should match what is defined in ImagesPipeline. - default_pipeline_settings = dict( - MIN_WIDTH=0, - MIN_HEIGHT=0, - EXPIRES=90, - THUMBS={}, - IMAGES_URLS_FIELD="image_urls", - IMAGES_RESULT_FIELD="images", - ) + default_pipeline_settings = { + "MIN_WIDTH": 0, + "MIN_HEIGHT": 0, + "EXPIRES": 90, + "THUMBS": {}, + "IMAGES_URLS_FIELD": "image_urls", + "IMAGES_RESULT_FIELD": "images", + } def setUp(self): self.tempdir = mkdtemp() diff --git a/tests/test_pipeline_media.py b/tests/test_pipeline_media.py index d477b59be..d4dde4a40 100644 --- a/tests/test_pipeline_media.py +++ b/tests/test_pipeline_media.py @@ -59,7 +59,7 @@ class BaseMediaPipelineTestCase(unittest.TestCase): assert self.pipe.media_to_download(request, self.info) is None def test_default_get_media_requests(self): - item = dict(name="name") + item = {"name": "name"} assert self.pipe.get_media_requests(item, self.info) is None def test_default_media_downloaded(self): @@ -73,7 +73,7 @@ class BaseMediaPipelineTestCase(unittest.TestCase): assert self.pipe.media_failed(fail, request, self.info) is fail def test_default_item_completed(self): - item = dict(name="name") + item = {"name": "name"} assert self.pipe.item_completed([], item, self.info) is item # Check that failures are logged by default @@ -98,7 +98,7 @@ class BaseMediaPipelineTestCase(unittest.TestCase): @inlineCallbacks def test_default_process_item(self): - item = dict(name="name") + item = {"name": "name"} new_item = yield self.pipe.process_item(item, self.spider) assert new_item is item @@ -226,11 +226,11 @@ class MediaPipelineTestCase(BaseMediaPipelineTestCase): rsp = Response("http://url1") req = Request( "http://url1", - meta=dict(response=rsp), + meta={"response": rsp}, callback=self._callback, errback=self._errback, ) - item = dict(requests=req) + item = {"requests": req} new_item = yield self.pipe.process_item(item, self.spider) self.assertEqual(new_item["results"], [(True, rsp)]) self.assertEqual( @@ -250,11 +250,11 @@ class MediaPipelineTestCase(BaseMediaPipelineTestCase): fail = Failure(Exception()) req = Request( "http://url1", - meta=dict(response=fail), + meta={"response": fail}, callback=self._callback, errback=self._errback, ) - item = dict(requests=req) + item = {"requests": req} new_item = yield self.pipe.process_item(item, self.spider) self.assertEqual(new_item["results"], [(False, fail)]) self.assertEqual( @@ -272,10 +272,10 @@ class MediaPipelineTestCase(BaseMediaPipelineTestCase): def test_mix_of_success_and_failure(self): self.pipe.LOG_FAILED_RESULTS = False rsp1 = Response("http://url1") - req1 = Request("http://url1", meta=dict(response=rsp1)) + req1 = Request("http://url1", meta={"response": rsp1}) fail = Failure(Exception()) - req2 = Request("http://url2", meta=dict(response=fail)) - item = dict(requests=[req1, req2]) + req2 = Request("http://url2", meta={"response": fail}) + item = {"requests": [req1, req2]} new_item = yield self.pipe.process_item(item, self.spider) self.assertEqual(new_item["results"], [(True, rsp1), (False, fail)]) m = self.pipe._mockcalled @@ -294,7 +294,7 @@ class MediaPipelineTestCase(BaseMediaPipelineTestCase): def test_get_media_requests(self): # returns single Request (without callback) req = Request("http://url") - item = dict(requests=req) # pass a single item + item = {"requests": req} # pass a single item new_item = yield self.pipe.process_item(item, self.spider) assert new_item is item self.assertIn(self.fingerprint(req), self.info.downloaded) @@ -302,7 +302,7 @@ class MediaPipelineTestCase(BaseMediaPipelineTestCase): # returns iterable of Requests req1 = Request("http://url1") req2 = Request("http://url2") - item = dict(requests=iter([req1, req2])) + item = {"requests": iter([req1, req2])} new_item = yield self.pipe.process_item(item, self.spider) assert new_item is item assert self.fingerprint(req1) in self.info.downloaded @@ -311,17 +311,17 @@ class MediaPipelineTestCase(BaseMediaPipelineTestCase): @inlineCallbacks def test_results_are_cached_across_multiple_items(self): rsp1 = Response("http://url1") - req1 = Request("http://url1", meta=dict(response=rsp1)) - item = dict(requests=req1) + req1 = Request("http://url1", meta={"response": rsp1}) + item = {"requests": req1} new_item = yield self.pipe.process_item(item, self.spider) self.assertTrue(new_item is item) self.assertEqual(new_item["results"], [(True, rsp1)]) # rsp2 is ignored, rsp1 must be in results because request fingerprints are the same req2 = Request( - req1.url, meta=dict(response=Response("http://donot.download.me")) + req1.url, meta={"response": Response("http://donot.download.me")} ) - item = dict(requests=req2) + item = {"requests": req2} new_item = yield self.pipe.process_item(item, self.spider) self.assertTrue(new_item is item) self.assertEqual(self.fingerprint(req1), self.fingerprint(req2)) @@ -330,11 +330,11 @@ class MediaPipelineTestCase(BaseMediaPipelineTestCase): @inlineCallbacks def test_results_are_cached_for_requests_of_single_item(self): rsp1 = Response("http://url1") - req1 = Request("http://url1", meta=dict(response=rsp1)) + req1 = Request("http://url1", meta={"response": rsp1}) req2 = Request( - req1.url, meta=dict(response=Response("http://donot.download.me")) + req1.url, meta={"response": Response("http://donot.download.me")} ) - item = dict(requests=[req1, req2]) + item = {"requests": [req1, req2]} new_item = yield self.pipe.process_item(item, self.spider) self.assertTrue(new_item is item) self.assertEqual(new_item["results"], [(True, rsp1), (True, rsp1)]) @@ -359,16 +359,16 @@ class MediaPipelineTestCase(BaseMediaPipelineTestCase): def rsp2_func(): self.fail("it must cache rsp1 result and must not try to redownload") - req1 = Request("http://url", meta=dict(response=rsp1_func)) - req2 = Request(req1.url, meta=dict(response=rsp2_func)) - item = dict(requests=[req1, req2]) + req1 = Request("http://url", meta={"response": rsp1_func}) + req2 = Request(req1.url, meta={"response": rsp2_func}) + item = {"requests": [req1, req2]} new_item = yield self.pipe.process_item(item, self.spider) self.assertEqual(new_item["results"], [(True, rsp1), (True, rsp1)]) @inlineCallbacks def test_use_media_to_download_result(self): - req = Request("http://url", meta=dict(result="ITSME", response=self.fail)) - item = dict(requests=req) + req = Request("http://url", meta={"result": "ITSME", "response": self.fail}) + item = {"requests": req} new_item = yield self.pipe.process_item(item, self.spider) self.assertEqual(new_item["results"], [(True, "ITSME")]) self.assertEqual( diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index f8465a5ff..37099dae6 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -45,15 +45,15 @@ class MockDownloader: class MockCrawler(Crawler): def __init__(self, priority_queue_cls, jobdir): - settings = dict( - SCHEDULER_DEBUG=False, - SCHEDULER_DISK_QUEUE="scrapy.squeues.PickleLifoDiskQueue", - SCHEDULER_MEMORY_QUEUE="scrapy.squeues.LifoMemoryQueue", - SCHEDULER_PRIORITY_QUEUE=priority_queue_cls, - JOBDIR=jobdir, - DUPEFILTER_CLASS="scrapy.dupefilters.BaseDupeFilter", - REQUEST_FINGERPRINTER_IMPLEMENTATION="2.7", - ) + settings = { + "SCHEDULER_DEBUG": False, + "SCHEDULER_DISK_QUEUE": "scrapy.squeues.PickleLifoDiskQueue", + "SCHEDULER_MEMORY_QUEUE": "scrapy.squeues.LifoMemoryQueue", + "SCHEDULER_PRIORITY_QUEUE": priority_queue_cls, + "JOBDIR": jobdir, + "DUPEFILTER_CLASS": "scrapy.dupefilters.BaseDupeFilter", + "REQUEST_FINGERPRINTER_IMPLEMENTATION": "2.7", + } super().__init__(Spider, settings) self.engine = MockEngine(downloader=MockDownloader()) self.stats = load_object(self.settings["STATS_CLASS"])(self) @@ -338,10 +338,10 @@ class TestIntegrationWithDownloaderAwareInMemory(TestCase): class TestIncompatibility(unittest.TestCase): def _incompatible(self): - settings = dict( - SCHEDULER_PRIORITY_QUEUE="scrapy.pqueues.DownloaderAwarePriorityQueue", - CONCURRENT_REQUESTS_PER_IP=1, - ) + settings = { + "SCHEDULER_PRIORITY_QUEUE": "scrapy.pqueues.DownloaderAwarePriorityQueue", + "CONCURRENT_REQUESTS_PER_IP": 1, + } crawler = get_crawler(Spider, settings) scheduler = Scheduler.from_crawler(crawler) spider = Spider(name="spider") diff --git a/tests/test_spidermiddleware_offsite.py b/tests/test_spidermiddleware_offsite.py index ea45b7698..837f1c2c8 100644 --- a/tests/test_spidermiddleware_offsite.py +++ b/tests/test_spidermiddleware_offsite.py @@ -16,10 +16,10 @@ class TestOffsiteMiddleware(TestCase): self.mw.spider_opened(self.spider) def _get_spiderargs(self): - return dict( - name="foo", - allowed_domains=["scrapytest.org", "scrapy.org", "scrapy.test.org"], - ) + return { + "name": "foo", + "allowed_domains": ["scrapytest.org", "scrapy.org", "scrapy.test.org"], + } def test_process_spider_output(self): res = Response("http://scrapytest.org") @@ -50,7 +50,7 @@ class TestOffsiteMiddleware(TestCase): class TestOffsiteMiddleware2(TestOffsiteMiddleware): def _get_spiderargs(self): - return dict(name="foo", allowed_domains=None) + return {"name": "foo", "allowed_domains": None} def test_process_spider_output(self): res = Response("http://scrapytest.org") @@ -61,13 +61,16 @@ class TestOffsiteMiddleware2(TestOffsiteMiddleware): class TestOffsiteMiddleware3(TestOffsiteMiddleware2): def _get_spiderargs(self): - return dict(name="foo") + return {"name": "foo"} class TestOffsiteMiddleware4(TestOffsiteMiddleware3): def _get_spiderargs(self): bad_hostname = urlparse("http:////scrapytest.org").hostname - return dict(name="foo", allowed_domains=["scrapytest.org", None, bad_hostname]) + return { + "name": "foo", + "allowed_domains": ["scrapytest.org", None, bad_hostname], + } def test_process_spider_output(self): res = Response("http://scrapytest.org") diff --git a/tests/test_utils_iterators.py b/tests/test_utils_iterators.py index ee22e6675..ec377bb19 100644 --- a/tests/test_utils_iterators.py +++ b/tests/test_utils_iterators.py @@ -355,7 +355,7 @@ class UtilsCsvTestCase(unittest.TestCase): response = TextResponse(url="http://example.com/", body=body) csv = csviter(response) - result = [row for row in csv] + result = list(csv) self.assertEqual( result, [ @@ -377,7 +377,7 @@ class UtilsCsvTestCase(unittest.TestCase): csv = csviter(response, delimiter="\t") self.assertEqual( - [row for row in csv], + list(csv), [ {"id": "1", "name": "alpha", "value": "foobar"}, {"id": "2", "name": "unicode", "value": "\xfan\xedc\xf3d\xe9\u203d"}, @@ -394,7 +394,7 @@ class UtilsCsvTestCase(unittest.TestCase): csv1 = csviter(response1, quotechar="'") self.assertEqual( - [row for row in csv1], + list(csv1), [ {"id": "1", "name": "alpha", "value": "foobar"}, {"id": "2", "name": "unicode", "value": "\xfan\xedc\xf3d\xe9\u203d"}, @@ -407,7 +407,7 @@ class UtilsCsvTestCase(unittest.TestCase): csv2 = csviter(response2, delimiter="|", quotechar="'") self.assertEqual( - [row for row in csv2], + list(csv2), [ {"id": "1", "name": "alpha", "value": "foobar"}, {"id": "2", "name": "unicode", "value": "\xfan\xedc\xf3d\xe9\u203d"}, @@ -422,7 +422,7 @@ class UtilsCsvTestCase(unittest.TestCase): csv = csviter(response) self.assertEqual( - [row for row in csv], + list(csv), [ {"'id'": "1", "'name'": "'alpha'", "'value'": "'foobar'"}, { @@ -441,7 +441,7 @@ class UtilsCsvTestCase(unittest.TestCase): csv = csviter(response, delimiter="\t") self.assertEqual( - [row for row in csv], + list(csv), [ {"id": "1", "name": "alpha", "value": "foobar"}, {"id": "2", "name": "unicode", "value": "\xfan\xedc\xf3d\xe9\u203d"}, @@ -458,7 +458,7 @@ class UtilsCsvTestCase(unittest.TestCase): csv = csviter(response, headers=[h.decode("utf-8") for h in headers]) self.assertEqual( - [row for row in csv], + list(csv), [ {"id": "1", "name": "alpha", "value": "foobar"}, {"id": "2", "name": "unicode", "value": "\xfan\xedc\xf3d\xe9\u203d"}, @@ -475,7 +475,7 @@ class UtilsCsvTestCase(unittest.TestCase): csv = csviter(response) self.assertEqual( - [row for row in csv], + list(csv), [ {"id": "1", "name": "alpha", "value": "foobar"}, {"id": "2", "name": "unicode", "value": "\xfan\xedc\xf3d\xe9\u203d"}, diff --git a/tests/test_utils_template.py b/tests/test_utils_template.py index cbe80e157..fc42c0d2f 100644 --- a/tests/test_utils_template.py +++ b/tests/test_utils_template.py @@ -16,7 +16,7 @@ class UtilsRenderTemplateFileTestCase(unittest.TestCase): rmtree(self.tmp_path) def test_simple_render(self): - context = dict(project_name="proj", name="spi", classname="TheSpider") + context = {"project_name": "proj", "name": "spi", "classname": "TheSpider"} template = "from ${project_name}.spiders.${name} import ${classname}" rendered = "from proj.spiders.spi import TheSpider"