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/191] 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/191] 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 5fccf370b87378fe2db6bdd52b98c1e2a951df3b Mon Sep 17 00:00:00 2001
From: Andrey Rakhmatullin
Date: Wed, 15 Nov 2023 15:38:13 +0100
Subject: [PATCH 003/191] 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 004/191] 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 75e99c75b3c6219df50546877e02f7bbb37324c3 Mon Sep 17 00:00:00 2001
From: Andrey Rakhmatullin
Date: Wed, 15 Nov 2023 19:51:04 +0400
Subject: [PATCH 005/191] 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 006/191] 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 007/191] 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 008/191] 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 009/191] 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 010/191] 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 011/191] 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 012/191] 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 013/191] 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 014/191] 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 015/191] 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 016/191] 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 017/191] 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 018/191] 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 019/191] 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 020/191] 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 021/191] 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 022/191] 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 023/191] 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 024/191] 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 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 025/191] 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 026/191] =?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 027/191] =?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 028/191] 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 029/191] 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 030/191] 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>].*?{nodename_patt}>", re.DOTALL)
+ r = re.compile(rf"<{nodename_patt}[\s>].{{,1024}}?{nodename_patt}>", 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 031/191] 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 032/191] 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 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 033/191] =?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 034/191] 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 035/191] 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 036/191] 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 037/191] 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 038/191] 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 039/191] 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 040/191] 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}}?{nodename_patt}>", re.DOTALL)
+ r = re.compile(rf"<{nodename_patt}[\s>].*?{nodename_patt}>", 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 041/191] 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 042/191] 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 043/191] 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 044/191] 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 045/191] 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 046/191] 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 047/191] 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 048/191] =?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 049/191] =?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 050/191] 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 051/191] =?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 052/191] =?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 053/191] 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 054/191] 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 055/191] 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
- Made with ❤ by Scrapinghub
+ Made with ❤ by Zyte
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
- Made with ❤ by Scrapinghub
+ Made with ❤ by Zyte
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 056/191] 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 057/191] 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 058/191] 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 059/191] 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
- Made with ❤ by Scrapinghub
+ Made with ❤ by Zyte
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
- Made with ❤ by Scrapinghub
+ Made with ❤ by Zyte
From c7b2b097b18c0b30d06cea4b803d5d42aca98715 Mon Sep 17 00:00:00 2001
From: Rotzbua
Date: Fri, 5 Jan 2024 10:50:45 +0100
Subject: [PATCH 060/191] 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 061/191] 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 062/191] 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 063/191] 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 064/191] 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 065/191] 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 066/191] 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 067/191] 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 068/191] 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 069/191] 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 070/191] 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 071/191] 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 072/191] 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 073/191] 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 074/191] 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 075/191] 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 076/191] 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 077/191] 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 078/191] 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 079/191] 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 080/191] 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 081/191] 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 082/191] 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 083/191] 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 084/191] 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 085/191] =?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 086/191] 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 bccb4cf18ba38c8bf09d61d19e0ffabaf15554b1 Mon Sep 17 00:00:00 2001
From: Jalil SA <61639983+jxlil@users.noreply.github.com>
Date: Wed, 14 Feb 2024 12:29:29 -0600
Subject: [PATCH 087/191] fix: LxmlLinkExtractor unique_list missing key
---
scrapy/linkextractors/lxmlhtml.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/scrapy/linkextractors/lxmlhtml.py b/scrapy/linkextractors/lxmlhtml.py
index 23cbd0116..98781ba7f 100644
--- a/scrapy/linkextractors/lxmlhtml.py
+++ b/scrapy/linkextractors/lxmlhtml.py
@@ -248,5 +248,5 @@ class LxmlLinkExtractor:
links = self._extract_links(doc, response.url, response.encoding, base_url)
all_links.extend(self._process_links(links))
if self.link_extractor.unique:
- return unique_list(all_links)
+ return unique_list(all_links, key=self.link_extractor.link_key)
return all_links
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 088/191] 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 089/191] 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 090/191] 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 091/191] 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 5e51417a485f296354e9639f85fb0b51a4a3e533 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Adri=C3=A1n=20Chaves?=
Date: Fri, 16 Feb 2024 20:10:52 +0100
Subject: [PATCH 092/191] Add tests, fix canonicalize passing
---
scrapy/linkextractors/lxmlhtml.py | 2 +-
tests/test_linkextractors.py | 112 ++++++++++++++++++++++++++++++
2 files changed, 113 insertions(+), 1 deletion(-)
diff --git a/scrapy/linkextractors/lxmlhtml.py b/scrapy/linkextractors/lxmlhtml.py
index 98781ba7f..7abdaaec4 100644
--- a/scrapy/linkextractors/lxmlhtml.py
+++ b/scrapy/linkextractors/lxmlhtml.py
@@ -153,7 +153,7 @@ class LxmlLinkExtractor:
unique=unique,
process=process_value,
strip=strip,
- canonicalized=canonicalize,
+ canonicalized=not canonicalize,
)
self.allow_res = [
x if isinstance(x, _re_type) else re.compile(x) for x in arg_to_iter(allow)
diff --git a/tests/test_linkextractors.py b/tests/test_linkextractors.py
index 18e9608c1..f23b8988e 100644
--- a/tests/test_linkextractors.py
+++ b/tests/test_linkextractors.py
@@ -745,6 +745,118 @@ class Base:
lx = self.extractor_cls()
self.assertIsInstance(pickle.loads(pickle.dumps(lx)), self.extractor_cls)
+ def test_link_extractor_aggregation(self):
+ """When a parameter like restrict_css is used, the underlying
+ implementation calls its internal link extractor once per selector
+ matching the specified restrictions, and then aggregates the
+ extracted links.
+
+ Test that aggregation respects the unique and canonicalize
+ parameters.
+ """
+ # unique=True (default), canonicalize=False (default)
+ lx = self.extractor_cls(restrict_css=("div",))
+ response = HtmlResponse(
+ "https://example.com",
+ body=b"""
+
+
+ """,
+ )
+ actual = lx.extract_links(response)
+ self.assertEqual(
+ actual,
+ [
+ Link(url="https://example.com/a", text="a1"),
+ Link(url="https://example.com/b?a=1&b=2", text="b1"),
+ Link(url="https://example.com/b?b=2&a=1", text="b2"),
+ ],
+ )
+
+ # unique=True (default), canonicalize=True
+ lx = self.extractor_cls(restrict_css=("div",), canonicalize=True)
+ response = HtmlResponse(
+ "https://example.com",
+ body=b"""
+
+
+ """,
+ )
+ actual = lx.extract_links(response)
+ self.assertEqual(
+ actual,
+ [
+ Link(url="https://example.com/a", text="a1"),
+ Link(url="https://example.com/b?a=1&b=2", text="b1"),
+ ],
+ )
+
+ # unique=False, canonicalize=False (default)
+ lx = self.extractor_cls(restrict_css=("div",), unique=False)
+ response = HtmlResponse(
+ "https://example.com",
+ body=b"""
+
+
+ """,
+ )
+ actual = lx.extract_links(response)
+ self.assertEqual(
+ actual,
+ [
+ Link(url="https://example.com/a", text="a1"),
+ Link(url="https://example.com/b?a=1&b=2", text="b1"),
+ Link(url="https://example.com/a", text="a2"),
+ Link(url="https://example.com/b?b=2&a=1", text="b2"),
+ ],
+ )
+
+ # unique=False, canonicalize=True
+ lx = self.extractor_cls(
+ restrict_css=("div",), unique=False, canonicalize=True
+ )
+ response = HtmlResponse(
+ "https://example.com",
+ body=b"""
+
+
+ """,
+ )
+ actual = lx.extract_links(response)
+ self.assertEqual(
+ actual,
+ [
+ Link(url="https://example.com/a", text="a1"),
+ Link(url="https://example.com/b?a=1&b=2", text="b1"),
+ Link(url="https://example.com/a", text="a2"),
+ Link(url="https://example.com/b?a=1&b=2", text="b2"),
+ ],
+ )
+
class LxmlLinkExtractorTestCase(Base.LinkExtractorTestCase):
extractor_cls = LxmlLinkExtractor
From c4e4b9b56e7fe10c5e7472b152dd47253a97af5b Mon Sep 17 00:00:00 2001
From: Mikhail Korobov
Date: Tue, 20 Feb 2024 14:50:16 +0500
Subject: [PATCH 093/191] 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 094/191] 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 f096f17fa4ac1307fa1c81ae082bb52e9f86653a Mon Sep 17 00:00:00 2001
From: Elias Ram
Date: Tue, 20 Feb 2024 20:32:02 +0100
Subject: [PATCH 095/191] test #6 added tests for check command
---
tests/test_command_check.py | 55 +++++++++++++++++++++++++++++++++++++
1 file changed, 55 insertions(+)
diff --git a/tests/test_command_check.py b/tests/test_command_check.py
index 592494aba..d503628b8 100644
--- a/tests/test_command_check.py
+++ b/tests/test_command_check.py
@@ -1,3 +1,8 @@
+import sys
+from io import StringIO
+from unittest.mock import Mock, PropertyMock, patch
+
+from scrapy.commands.check import Command
from tests.test_commands import CommandTest
@@ -94,3 +99,53 @@ class CheckSpider(scrapy.Spider):
raise Exception('SCRAPY_CHECK not set')
"""
self._test_contract(parse_def=parse_def)
+
+ @patch("scrapy.commands.check.ContractsManager")
+ def test_run_with_opts_list_prints_spider(self, cm_cls_mock):
+ output = StringIO()
+ sys.stdout = output
+ cmd = Command()
+ cmd.settings = Mock(getwithbase=Mock(return_value={}))
+ cm_cls_mock.return_value = cm_mock = Mock()
+ spider_loader_mock = Mock()
+ cmd.crawler_process = Mock(spider_loader=spider_loader_mock)
+ spider_name = "FakeSpider"
+ spider_cls_mock = Mock()
+ type(spider_cls_mock).name = PropertyMock(return_value=spider_name)
+ spider_loader_mock.load.side_effect = lambda x: {spider_name: spider_cls_mock}[
+ x
+ ]
+ tested_methods = ["fakeMethod1", "fakeMethod2"]
+ cm_mock.tested_methods_from_spidercls.side_effect = lambda x: {
+ spider_cls_mock: tested_methods
+ }[x]
+
+ cmd.run([spider_name], Mock(list=True))
+
+ self.assertEqual(
+ "FakeSpider\n * fakeMethod1\n * fakeMethod2\n", output.getvalue()
+ )
+ sys.stdout = sys.__stdout__
+
+ @patch("scrapy.commands.check.ContractsManager")
+ def test_run_without_opts_list_does_not_crawl_spider_with_no_tested_methods(
+ self, cm_cls_mock
+ ):
+ cmd = Command()
+ cmd.settings = Mock(getwithbase=Mock(return_value={}))
+ cm_cls_mock.return_value = cm_mock = Mock()
+ spider_loader_mock = Mock()
+ cmd.crawler_process = Mock(spider_loader=spider_loader_mock)
+ spider_name = "FakeSpider"
+ spider_cls_mock = Mock()
+ spider_loader_mock.load.side_effect = lambda x: {spider_name: spider_cls_mock}[
+ x
+ ]
+ tested_methods = []
+ cm_mock.tested_methods_from_spidercls.side_effect = lambda x: {
+ spider_cls_mock: tested_methods
+ }[x]
+
+ cmd.run([spider_name], Mock(list=False))
+
+ cmd.crawler_process.crawl.assert_not_called()
From e8e6d28479a0479361cd3de0fb854c243ed684b1 Mon Sep 17 00:00:00 2001
From: Elias Ram
Date: Tue, 20 Feb 2024 20:41:18 +0100
Subject: [PATCH 096/191] 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 097/191] 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
+ """
+ )
+
+ with self.assertRaises(ValueError) as context:
+ FormRequest.from_response(response, formxpath='//div[@id="outside-form"]/p')
+
+ self.assertIn("No
+ """
+ )
+ 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
+ """
+ )
+
+ with self.assertRaises(ValueError) as context:
+ FormRequest.from_response(response, formxpath='//div[@id="outside-form"]/p')
+
+ self.assertIn("No
- """
- )
- 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
- """
- )
-
- with self.assertRaises(ValueError) as context:
- FormRequest.from_response(response, formxpath='//div[@id="outside-form"]/p')
-
- self.assertIn("No