From 8dd48a08e4e5dfab80b1d715fb88c4df946fd797 Mon Sep 17 00:00:00 2001
From: Andrey Rakhmatullin
Date: Wed, 13 Sep 2023 20:46:32 +0400
Subject: [PATCH 0001/1041] Move PeriodicLog docs from Debugging to General
purpose.
---
docs/topics/extensions.rst | 110 +++++++++++++++++++------------------
1 file changed, 57 insertions(+), 53 deletions(-)
diff --git a/docs/topics/extensions.rst b/docs/topics/extensions.rst
index 0286581c0..f7b2f3799 100644
--- a/docs/topics/extensions.rst
+++ b/docs/topics/extensions.rst
@@ -350,52 +350,8 @@ full list of parameters, including examples on how to instantiate
.. module:: scrapy.extensions.debug
:synopsis: Extensions for debugging Scrapy
-Debugging extensions
---------------------
-
-Stack trace dump extension
-~~~~~~~~~~~~~~~~~~~~~~~~~~
-
-.. class:: StackTraceDump
-
-Dumps information about the running process when a `SIGQUIT`_ or `SIGUSR2`_
-signal is received. The information dumped is the following:
-
-1. engine status (using ``scrapy.utils.engine.get_engine_status()``)
-2. live references (see :ref:`topics-leaks-trackrefs`)
-3. stack trace of all threads
-
-After the stack trace and engine status is dumped, the Scrapy process continues
-running normally.
-
-This extension only works on POSIX-compliant platforms (i.e. not Windows),
-because the `SIGQUIT`_ and `SIGUSR2`_ signals are not available on Windows.
-
-There are at least two ways to send Scrapy the `SIGQUIT`_ signal:
-
-1. By pressing Ctrl-\ while a Scrapy process is running (Linux only?)
-2. By running this command (assuming ```` is the process id of the Scrapy
- process)::
-
- kill -QUIT
-
-.. _SIGUSR2: https://en.wikipedia.org/wiki/SIGUSR1_and_SIGUSR2
-.. _SIGQUIT: https://en.wikipedia.org/wiki/SIGQUIT
-
-Debugger extension
-~~~~~~~~~~~~~~~~~~
-
-.. class:: Debugger
-
-Invokes a :doc:`Python debugger ` inside a running Scrapy process when a `SIGUSR2`_
-signal is received. After the debugger is exited, the Scrapy process continues
-running normally.
-
-For more info see `Debugging in Python`_.
-
-This extension only works on POSIX-compliant platforms (i.e. not Windows).
-
-.. _Debugging in Python: https://pythonconquerstheuniverse.wordpress.com/2009/09/10/debugging-in-python/
+.. module:: scrapy.extensions.periodic_log
+ :synopsis: Periodic stats logging
Periodic log extension
~~~~~~~~~~~~~~~~~~~~~~
@@ -441,10 +397,10 @@ This extension periodically logs rich stat data as a JSON object::
This extension logs the following configurable sections:
-- ``"delta"`` shows how some numeric stats have changed since the last stats
+- ``"delta"`` shows how some numeric stats have changed since the last stats
log message.
-
- The :setting:`PERIODIC_LOG_DELTA` setting determines the target stats. They
+
+ The :setting:`PERIODIC_LOG_DELTA` setting determines the target stats. They
must have ``int`` or ``float`` values.
- ``"stats"`` shows the current value of some stats.
@@ -453,11 +409,11 @@ This extension logs the following configurable sections:
- ``"time"`` shows detailed timing data.
- The :setting:`PERIODIC_LOG_TIMING_ENABLED` setting determines whether or
+ The :setting:`PERIODIC_LOG_TIMING_ENABLED` setting determines whether or
not to show this section.
-This extension logs data at the start, then on a fixed time interval
-configurable through the :setting:`LOGSTATS_INTERVAL` setting, and finally
+This extension logs data at the start, then on a fixed time interval
+configurable through the :setting:`LOGSTATS_INTERVAL` setting, and finally
right before the crawl ends.
@@ -507,4 +463,52 @@ PERIODIC_LOG_TIMING_ENABLED
Default: ``False``
-``True`` enables logging of timing data (i.e. the ``"time"`` section).
\ No newline at end of file
+``True`` enables logging of timing data (i.e. the ``"time"`` section).
+
+
+Debugging extensions
+--------------------
+
+Stack trace dump extension
+~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+.. class:: StackTraceDump
+
+Dumps information about the running process when a `SIGQUIT`_ or `SIGUSR2`_
+signal is received. The information dumped is the following:
+
+1. engine status (using ``scrapy.utils.engine.get_engine_status()``)
+2. live references (see :ref:`topics-leaks-trackrefs`)
+3. stack trace of all threads
+
+After the stack trace and engine status is dumped, the Scrapy process continues
+running normally.
+
+This extension only works on POSIX-compliant platforms (i.e. not Windows),
+because the `SIGQUIT`_ and `SIGUSR2`_ signals are not available on Windows.
+
+There are at least two ways to send Scrapy the `SIGQUIT`_ signal:
+
+1. By pressing Ctrl-\ while a Scrapy process is running (Linux only?)
+2. By running this command (assuming ```` is the process id of the Scrapy
+ process)::
+
+ kill -QUIT
+
+.. _SIGUSR2: https://en.wikipedia.org/wiki/SIGUSR1_and_SIGUSR2
+.. _SIGQUIT: https://en.wikipedia.org/wiki/SIGQUIT
+
+Debugger extension
+~~~~~~~~~~~~~~~~~~
+
+.. class:: Debugger
+
+Invokes a :doc:`Python debugger ` inside a running Scrapy process when a `SIGUSR2`_
+signal is received. After the debugger is exited, the Scrapy process continues
+running normally.
+
+For more info see `Debugging in Python`_.
+
+This extension only works on POSIX-compliant platforms (i.e. not Windows).
+
+.. _Debugging in Python: https://pythonconquerstheuniverse.wordpress.com/2009/09/10/debugging-in-python/
From f96a3ed5f0e5dcc2bf6849219248c3da2fb85c72 Mon Sep 17 00:00:00 2001
From: Andrey Rakhmatullin
Date: Wed, 13 Sep 2023 20:46:55 +0400
Subject: [PATCH 0002/1041] Cover up to cddb8c15d in the release notes.
---
docs/news.rst | 51 +++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 51 insertions(+)
diff --git a/docs/news.rst b/docs/news.rst
index 9e758f05d..2237697c7 100644
--- a/docs/news.rst
+++ b/docs/news.rst
@@ -8,6 +8,13 @@ Release notes
Scrapy 2.11.0 (to be released)
------------------------------
+Highlights:
+
+-
+
+- Periodic stats logging.
+
+
Backward-incompatible changes
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@@ -20,6 +27,50 @@ Backward-incompatible changes
(:issue:`5968`)
+Deprecation removals
+~~~~~~~~~~~~~~~~~~~~
+
+- Removed the binary export mode of
+ :class:`~scrapy.exporters.PythonItemExporter`, deprecated in Scrapy 1.1.0.
+ (:issue:`6006`, :issue:`6007`)
+
+- Removed the ``CrawlerRunner.spiders`` attribute, deprecated in Scrapy
+ 1.0.0, use :attr:`CrawlerRunner.spider_loader
+ ` instead. (:issue:`6010`)
+
+New features
+~~~~~~~~~~~~
+
+- Added the :class:`~scrapy.extensions.periodic_log.PeriodicLog` extension
+ which can be enabled to log stats and/or their differences periodically.
+ (:issue:`5926`)
+
+- Links to ``.webp`` files are now ignored by :ref:`link extractors
+ `. (:issue:`6021`)
+
+Bug fixes
+~~~~~~~~~
+
+- :meth:`scrapy.settings.BaseSettings.getdictorlist`, used to parse
+ :setting:`FEED_EXPORT_FIELDS`, now handles tuple values. (:issue:`6011`,
+ :issue:`6013`)
+
+- Calls to ``datetime.utcnow()``, no longer recommended to be used, have been
+ replaced with calls to ``datetime.now()`` with a timezone. (:issue:`6014`)
+
+Documentation
+~~~~~~~~~~~~~
+
+- Updated a deprecated function call in a pipeline example. (:issue:`6008`,
+ :issue:`6009`)
+
+Quality assurance
+~~~~~~~~~~~~~~~~~
+
+- Extended typing hints. (:issue:`6003`, :issue:`6005`)
+
+- Other CI and pre-commit improvements. (:issue:`6002`, :issue:`6013`)
+
.. _release-2.10.1:
Scrapy 2.10.1 (2023-08-30)
From 922ff5738448205c5b7e5ced533bb8820b168480 Mon Sep 17 00:00:00 2001
From: Andrey Rakhmatullin
Date: Thu, 14 Sep 2023 15:51:00 +0400
Subject: [PATCH 0003/1041] Improve the backwards compatibility for
RetryMiddleware.EXCEPTIONS_TO_RETRY.
---
scrapy/downloadermiddlewares/retry.py | 27 ++++++++++++++++--------
tests/test_downloadermiddleware_retry.py | 17 ++++++++++++++-
2 files changed, 34 insertions(+), 10 deletions(-)
diff --git a/scrapy/downloadermiddlewares/retry.py b/scrapy/downloadermiddlewares/retry.py
index 205bb48b1..af590be47 100644
--- a/scrapy/downloadermiddlewares/retry.py
+++ b/scrapy/downloadermiddlewares/retry.py
@@ -23,12 +23,13 @@ from scrapy.utils.response import response_status_message
retry_logger = getLogger(__name__)
+DEPRECATED_ATTRIBUTE = "EXCEPTIONS_TO_RETRY"
-class BackwardsCompatibilityMetaclass(type):
- @property
- def EXCEPTIONS_TO_RETRY(cls):
+
+def backwards_compatibility_getattr(self, name):
+ if name == DEPRECATED_ATTRIBUTE:
warnings.warn(
- "Attribute RetryMiddleware.EXCEPTIONS_TO_RETRY is deprecated. "
+ f"Attribute RetryMiddleware.{DEPRECATED_ATTRIBUTE} is deprecated. "
"Use the RETRY_EXCEPTIONS setting instead.",
ScrapyDeprecationWarning,
stacklevel=2,
@@ -37,6 +38,13 @@ class BackwardsCompatibilityMetaclass(type):
load_object(x) if isinstance(x, str) else x
for x in Settings().getlist("RETRY_EXCEPTIONS")
)
+ raise AttributeError(
+ f"{self.__class__.__name__!r} object has no attribute {name!r}"
+ )
+
+
+class BackwardsCompatibilityMetaclass(type):
+ __getattr__ = backwards_compatibility_getattr
def get_retry_request(
@@ -137,15 +145,14 @@ class RetryMiddleware(metaclass=BackwardsCompatibilityMetaclass):
)
self.priority_adjust = settings.getint("RETRY_PRIORITY_ADJUST")
- if not hasattr(
- self, "EXCEPTIONS_TO_RETRY"
- ): # If EXCEPTIONS_TO_RETRY is not "overriden"
+ try:
+ self.exceptions_to_retry = self.__getattribute__(DEPRECATED_ATTRIBUTE)
+ except AttributeError:
+ # If EXCEPTIONS_TO_RETRY is not "overridden"
self.exceptions_to_retry = tuple(
load_object(x) if isinstance(x, str) else x
for x in settings.getlist("RETRY_EXCEPTIONS")
)
- else:
- self.exceptions_to_retry = self.EXCEPTIONS_TO_RETRY
@classmethod
def from_crawler(cls, crawler):
@@ -175,3 +182,5 @@ class RetryMiddleware(metaclass=BackwardsCompatibilityMetaclass):
max_retry_times=max_retry_times,
priority_adjust=priority_adjust,
)
+
+ __getattr__ = backwards_compatibility_getattr
diff --git a/tests/test_downloadermiddleware_retry.py b/tests/test_downloadermiddleware_retry.py
index 97ae1e29a..661175840 100644
--- a/tests/test_downloadermiddleware_retry.py
+++ b/tests/test_downloadermiddleware_retry.py
@@ -122,7 +122,7 @@ class RetryTest(unittest.TestCase):
req = Request(f"http://www.scrapytest.org/{exc.__name__}")
self._test_retry_exception(req, exc("foo"), mw)
- def test_exception_to_retry_customMiddleware(self):
+ def test_exception_to_retry_custom_middleware(self):
exc = ValueError
with warnings.catch_warnings(record=True) as warns:
@@ -138,6 +138,21 @@ class RetryTest(unittest.TestCase):
assert isinstance(req, Request)
self.assertEqual(req.meta["retry_times"], 1)
+ def test_exception_to_retry_custom_middleware_self(self):
+ class MyRetryMiddleware(RetryMiddleware):
+ def process_exception(self, request, exception, spider):
+ if isinstance(exception, self.EXCEPTIONS_TO_RETRY):
+ return self._retry(request, exception, spider)
+
+ exc = OSError
+ mw2 = MyRetryMiddleware.from_crawler(self.crawler)
+ req = Request(f"http://www.scrapytest.org/{exc.__name__}")
+ with warnings.catch_warnings(record=True) as warns:
+ req = mw2.process_exception(req, exc("foo"), self.spider)
+ assert isinstance(req, Request)
+ self.assertEqual(req.meta["retry_times"], 1)
+ self.assertEqual(len(warns), 1)
+
def _test_retry_exception(self, req, exception, mw=None):
if mw is None:
mw = self.mw
From 800c1f112e883070d97aa679b4bae10f618633a9 Mon Sep 17 00:00:00 2001
From: Andrey Rakhmatullin
Date: Fri, 15 Sep 2023 13:13:06 +0400
Subject: [PATCH 0004/1041] Remove the constant.
---
scrapy/downloadermiddlewares/retry.py | 8 +++-----
1 file changed, 3 insertions(+), 5 deletions(-)
diff --git a/scrapy/downloadermiddlewares/retry.py b/scrapy/downloadermiddlewares/retry.py
index af590be47..380623cea 100644
--- a/scrapy/downloadermiddlewares/retry.py
+++ b/scrapy/downloadermiddlewares/retry.py
@@ -23,13 +23,11 @@ from scrapy.utils.response import response_status_message
retry_logger = getLogger(__name__)
-DEPRECATED_ATTRIBUTE = "EXCEPTIONS_TO_RETRY"
-
def backwards_compatibility_getattr(self, name):
- if name == DEPRECATED_ATTRIBUTE:
+ if name == "EXCEPTIONS_TO_RETRY":
warnings.warn(
- f"Attribute RetryMiddleware.{DEPRECATED_ATTRIBUTE} is deprecated. "
+ "Attribute RetryMiddleware.EXCEPTIONS_TO_RETRY is deprecated. "
"Use the RETRY_EXCEPTIONS setting instead.",
ScrapyDeprecationWarning,
stacklevel=2,
@@ -146,7 +144,7 @@ class RetryMiddleware(metaclass=BackwardsCompatibilityMetaclass):
self.priority_adjust = settings.getint("RETRY_PRIORITY_ADJUST")
try:
- self.exceptions_to_retry = self.__getattribute__(DEPRECATED_ATTRIBUTE)
+ self.exceptions_to_retry = self.__getattribute__("EXCEPTIONS_TO_RETRY")
except AttributeError:
# If EXCEPTIONS_TO_RETRY is not "overridden"
self.exceptions_to_retry = tuple(
From c2346b4a95e51ec1d3a255e19b49042b4598a02d Mon Sep 17 00:00:00 2001
From: Andrey Rakhmatullin
Date: Fri, 15 Sep 2023 19:15:05 +0400
Subject: [PATCH 0005/1041] Update the release notes up to current master.
---
docs/news.rst | 72 ++++++++++++++++++++++++++++++++++++++++++++-------
1 file changed, 63 insertions(+), 9 deletions(-)
diff --git a/docs/news.rst b/docs/news.rst
index 2237697c7..7e26299c7 100644
--- a/docs/news.rst
+++ b/docs/news.rst
@@ -10,7 +10,9 @@ Scrapy 2.11.0 (to be released)
Highlights:
--
+- Spiders can now modify :ref:`settings ` in their
+ :meth:`~scrapy.Spider.from_crawler` methods, e.g. based on :ref:`spider
+ arguments `.
- Periodic stats logging.
@@ -18,14 +20,25 @@ Highlights:
Backward-incompatible changes
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+- Most of the initialization of :class:`scrapy.crawler.Crawler` instances is
+ now done in :meth:`~scrapy.crawler.Crawler.crawl`, so the state of
+ instances before that method is called is now different compared to older
+ Scrapy versions. We do not recommend using the
+ :class:`~scrapy.crawler.Crawler` instances before
+ :meth:`~scrapy.crawler.Crawler.crawl` is called. (:issue:`6038`)
+
+- :meth:`scrapy.Spider.from_crawler` is now called before the initialization
+ of various components previously initialized in
+ :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`)
+
- The :meth:`TextResponse.json ` method now
requires the response to be in a valid JSON encoding (UTF-8, UTF-16, or
- UTF-32).
-
- If you need to deal with JSON documents in an invalid encoding, use
- ``json.loads(response.text)`` instead.
-
- (:issue:`5968`)
+ UTF-32). If you need to deal with JSON documents in an invalid encoding,
+ use ``json.loads(response.text)`` instead. (:issue:`6016`)
Deprecation removals
~~~~~~~~~~~~~~~~~~~~
@@ -38,19 +51,55 @@ Deprecation removals
1.0.0, use :attr:`CrawlerRunner.spider_loader
` instead. (:issue:`6010`)
+ .. note:: If you are using this Scrapy version on Scrapy Cloud with a stack
+ that includes an older Scrapy version and get a "TypeError:
+ Unexpected options: binary" error, you may need to add
+ ``scrapinghub-entrypoint-scrapy > 0.14.0`` to your project
+ requirements or switch to a stack that includes Scrapy 2.11.
+
+Deprecations
+~~~~~~~~~~~~
+
+- Running :meth:`~scrapy.crawler.Crawler.crawl` more than once on the same
+ :class:`scrapy.crawler.Crawler` instance is now deprecated. (:issue:`1587`,
+ :issue:`6040`)
+
New features
~~~~~~~~~~~~
+- Changed the :class:`scrapy.crawler.Crawler` initialization order, so that
+ most of the initialization that previously happened in
+ :meth:`~scrapy.crawler.Crawler.__init__` now happens in
+ :meth:`~scrapy.crawler.Crawler.crawl` after the spider instance is created.
+ This allows spider instances to modify settings in their
+ :meth:`~scrapy.Spider.from_crawler` methods, e.g. based on :ref:`spider
+ arguments `. (:issue:`1305`, :issue:`1580`, :issue:`2392`,
+ :issue:`3663`, :issue:`6038`)
+
- Added the :class:`~scrapy.extensions.periodic_log.PeriodicLog` extension
which can be enabled to log stats and/or their differences periodically.
(:issue:`5926`)
+- Optimized the memory usage in :meth:`TextResponse.json
+ ` by removing unnecessary body decoding.
+ (:issue:`5968`, :issue:`6016`)
+
- Links to ``.webp`` files are now ignored by :ref:`link extractors
`. (:issue:`6021`)
Bug fixes
~~~~~~~~~
+- Fixed logging enabled add-ons. (:issue:`6036`)
+
+- Fixed :class:`~scrapy.mail.MailSender` producing invalid message bodies
+ when the ``charset`` argument is passed to
+ :meth:`~scrapy.mail.MailSender.send`. (:issue:`5096`, :issue:`5118`)
+
+- Fixed an exception when accessing ``self.EXCEPTIONS_TO_RETRY`` from a
+ subclass of :class:`~scrapy.downloadermiddlewares.retry.RetryMiddleware`.
+ (:issue:`6049`, :issue:`6050`)
+
- :meth:`scrapy.settings.BaseSettings.getdictorlist`, used to parse
:setting:`FEED_EXPORT_FIELDS`, now handles tuple values. (:issue:`6011`,
:issue:`6013`)
@@ -67,9 +116,14 @@ Documentation
Quality assurance
~~~~~~~~~~~~~~~~~
-- Extended typing hints. (:issue:`6003`, :issue:`6005`)
+- Extended typing hints. (:issue:`6003`, :issue:`6005`, :issue:`6031`,
+ :issue:`6034`)
-- Other CI and pre-commit improvements. (:issue:`6002`, :issue:`6013`)
+- Pinned brotli_ to 1.0.9 for the PyPy tests as 1.1.0 breaks them.
+ (:issue:`6044`, :issue:`6045`)
+
+- Other CI and pre-commit improvements. (:issue:`6002`, :issue:`6013`,
+ :issue:`6046`)
.. _release-2.10.1:
From 2fa768399a27aca615bccfc7c466758a968f10fe Mon Sep 17 00:00:00 2001
From: Andrey Rakhmatullin
Date: Fri, 15 Sep 2023 19:19:42 +0400
Subject: [PATCH 0006/1041] Replace the VERSION vars.
---
docs/topics/settings.rst | 2 +-
docs/topics/spiders.rst | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/docs/topics/settings.rst b/docs/topics/settings.rst
index 3006fb8b1..7cdfb8768 100644
--- a/docs/topics/settings.rst
+++ b/docs/topics/settings.rst
@@ -98,7 +98,7 @@ and settings set there should use the "spider" priority explicitly:
super().update_settings(settings)
settings.set("SOME_SETTING", "some value", priority="spider")
-.. versionadded:: VERSION
+.. versionadded:: 2.11
It's also possible to modify the settings in the
:meth:`~scrapy.Spider.from_crawler` method, e.g. based on :ref:`spider
diff --git a/docs/topics/spiders.rst b/docs/topics/spiders.rst
index 1ca7eda7b..20452d558 100644
--- a/docs/topics/spiders.rst
+++ b/docs/topics/spiders.rst
@@ -136,7 +136,7 @@ scrapy.Spider
attributes in the new instance so they can be accessed later inside the
spider's code.
- .. versionchanged:: VERSION
+ .. versionchanged:: 2.11
The settings in ``crawler.settings`` can now be modified in this
method, which is handy if you want to modify them based on
From 528911da85f871fd0f7546d4d16bbea556793d4b Mon Sep 17 00:00:00 2001
From: Andrey Rakhmatullin
Date: Mon, 18 Sep 2023 14:35:28 +0400
Subject: [PATCH 0007/1041] Fix/reword the release notes.
---
docs/news.rst | 20 ++++++++------------
1 file changed, 8 insertions(+), 12 deletions(-)
diff --git a/docs/news.rst b/docs/news.rst
index 7e26299c7..0566ff28e 100644
--- a/docs/news.rst
+++ b/docs/news.rst
@@ -14,7 +14,7 @@ Highlights:
:meth:`~scrapy.Spider.from_crawler` methods, e.g. based on :ref:`spider
arguments `.
-- Periodic stats logging.
+- Periodic logging of stats.
Backward-incompatible changes
@@ -47,16 +47,16 @@ Deprecation removals
:class:`~scrapy.exporters.PythonItemExporter`, deprecated in Scrapy 1.1.0.
(:issue:`6006`, :issue:`6007`)
-- Removed the ``CrawlerRunner.spiders`` attribute, deprecated in Scrapy
- 1.0.0, use :attr:`CrawlerRunner.spider_loader
- ` instead. (:issue:`6010`)
-
.. note:: If you are using this Scrapy version on Scrapy Cloud with a stack
that includes an older Scrapy version and get a "TypeError:
Unexpected options: binary" error, you may need to add
- ``scrapinghub-entrypoint-scrapy > 0.14.0`` to your project
+ ``scrapinghub-entrypoint-scrapy >= 0.14.1`` to your project
requirements or switch to a stack that includes Scrapy 2.11.
+- Removed the ``CrawlerRunner.spiders`` attribute, deprecated in Scrapy
+ 1.0.0, use :attr:`CrawlerRunner.spider_loader
+ ` instead. (:issue:`6010`)
+
Deprecations
~~~~~~~~~~~~
@@ -67,12 +67,8 @@ Deprecations
New features
~~~~~~~~~~~~
-- Changed the :class:`scrapy.crawler.Crawler` initialization order, so that
- most of the initialization that previously happened in
- :meth:`~scrapy.crawler.Crawler.__init__` now happens in
- :meth:`~scrapy.crawler.Crawler.crawl` after the spider instance is created.
- This allows spider instances to modify settings in their
- :meth:`~scrapy.Spider.from_crawler` methods, e.g. based on :ref:`spider
+- Spiders can now modify settings in their
+ :meth:`~scrapy.Spider.from_crawler` method, e.g. based on :ref:`spider
arguments `. (:issue:`1305`, :issue:`1580`, :issue:`2392`,
:issue:`3663`, :issue:`6038`)
From f2fb4760d2e98cc92ab00a84bcf20cd8a6de3799 Mon Sep 17 00:00:00 2001
From: Andrey Rakhmatullin
Date: Mon, 18 Sep 2023 15:24:28 +0400
Subject: [PATCH 0008/1041] =?UTF-8?q?Bump=20version:=202.10.1=20=E2=86=92?=
=?UTF-8?q?=202.11.0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.bumpversion.cfg | 2 +-
docs/news.rst | 4 ++--
scrapy/VERSION | 2 +-
3 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/.bumpversion.cfg b/.bumpversion.cfg
index 0bcd1ab01..f76bf783d 100644
--- a/.bumpversion.cfg
+++ b/.bumpversion.cfg
@@ -1,5 +1,5 @@
[bumpversion]
-current_version = 2.10.1
+current_version = 2.11.0
commit = True
tag = True
tag_name = {new_version}
diff --git a/docs/news.rst b/docs/news.rst
index 0566ff28e..fc3cfd9e8 100644
--- a/docs/news.rst
+++ b/docs/news.rst
@@ -5,8 +5,8 @@ Release notes
.. _release-2.11.0:
-Scrapy 2.11.0 (to be released)
-------------------------------
+Scrapy 2.11.0 (2023-09-18)
+--------------------------
Highlights:
diff --git a/scrapy/VERSION b/scrapy/VERSION
index 8bbb6e406..46b81d815 100644
--- a/scrapy/VERSION
+++ b/scrapy/VERSION
@@ -1 +1 @@
-2.10.1
+2.11.0
From d5cc469ca93cf9ee726aba1620893b308b2da5a1 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Adri=C3=A1n=20Chaves?=
Date: Thu, 21 Sep 2023 13:06:12 +0200
Subject: [PATCH 0009/1041] Add py.typed
---
MANIFEST.in | 1 +
scrapy/py.typed | 0
2 files changed, 1 insertion(+)
create mode 100644 scrapy/py.typed
diff --git a/MANIFEST.in b/MANIFEST.in
index ae7db51fa..4920dc0c3 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -7,6 +7,7 @@ include NEWS
include scrapy/VERSION
include scrapy/mime.types
+include scrapy/py.typed
include codecov.yml
include conftest.py
diff --git a/scrapy/py.typed b/scrapy/py.typed
new file mode 100644
index 000000000..e69de29bb
From e84fb6d5cb15a36022dbb9ef27f9924383d317fc Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Adri=C3=A1n=20Chaves?=
Date: Thu, 21 Sep 2023 13:39:39 +0200
Subject: [PATCH 0010/1041] Add package_data={"scrapy": "py.typed"} to setup.py
---
setup.py | 1 +
1 file changed, 1 insertion(+)
diff --git a/setup.py b/setup.py
index 47c0af0b0..aff51b1cd 100644
--- a/setup.py
+++ b/setup.py
@@ -48,6 +48,7 @@ setup(
license="BSD",
packages=find_packages(exclude=("tests", "tests.*")),
include_package_data=True,
+ package_data={"scrapy": "py.typed"},
zip_safe=False,
entry_points={"console_scripts": ["scrapy = scrapy.cmdline:execute"]},
classifiers=[
From 11bdc3df590d461634865027d6911cc5f8116cfe Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Adri=C3=A1n=20Chaves?=
Date: Thu, 21 Sep 2023 13:40:40 +0200
Subject: [PATCH 0011/1041] Revert "Add package_data={"scrapy": "py.typed"} to
setup.py"
This reverts commit e84fb6d5cb15a36022dbb9ef27f9924383d317fc.
---
setup.py | 1 -
1 file changed, 1 deletion(-)
diff --git a/setup.py b/setup.py
index aff51b1cd..47c0af0b0 100644
--- a/setup.py
+++ b/setup.py
@@ -48,7 +48,6 @@ setup(
license="BSD",
packages=find_packages(exclude=("tests", "tests.*")),
include_package_data=True,
- package_data={"scrapy": "py.typed"},
zip_safe=False,
entry_points={"console_scripts": ["scrapy = scrapy.cmdline:execute"]},
classifiers=[
From 908da8ba829fd0d8a2738625e84051435305b67f 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 0012/1041] 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 720f351a3eea5e5bfa83a6eaf50210cd1fa43992 Mon Sep 17 00:00:00 2001
From: Andrey Rakhmatullin
Date: Sun, 24 Sep 2023 18:12:52 +0400
Subject: [PATCH 0013/1041] Refactor installing signals.
---
scrapy/crawler.py | 8 +++++---
scrapy/utils/ossignal.py | 9 +++------
2 files changed, 8 insertions(+), 9 deletions(-)
diff --git a/scrapy/crawler.py b/scrapy/crawler.py
index 22fd65be7..15aec2757 100644
--- a/scrapy/crawler.py
+++ b/scrapy/crawler.py
@@ -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() # 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)
From 4b14215f83996a18d34c95bec953ef9cfba05245 Mon Sep 17 00:00:00 2001
From: Andrey Rakhmatullin
Date: Sun, 24 Sep 2023 18:17:43 +0400
Subject: [PATCH 0014/1041] Remove the Twisted version restriction.
---
setup.py | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
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",
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 0015/1041] 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 0c6440a427a6f3ed2920de38f97d15d692833851 Mon Sep 17 00:00:00 2001
From: Andrey Rakhmatullin
Date: Sun, 24 Sep 2023 19:25:59 +0400
Subject: [PATCH 0016/1041] Fix additional typing errors with new Twisted.
---
scrapy/utils/testproc.py | 7 ++++---
1 file changed, 4 insertions(+), 3 deletions(-)
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}"
From 0630e4aaa10c3fb8c79c2542a229f5c0632cddde Mon Sep 17 00:00:00 2001
From: Andrey Rakhmatullin
Date: Sun, 24 Sep 2023 19:36:29 +0400
Subject: [PATCH 0017/1041] Fix `scrapy shell`.
---
scrapy/crawler.py | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/scrapy/crawler.py b/scrapy/crawler.py
index 15aec2757..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
@@ -426,7 +426,7 @@ class CrawlerProcess(CrawlerRunner):
reactor.addSystemEventTrigger(
"after", "startup", install_shutdown_handlers, self._signal_shutdown
)
- reactor.run() # blocking call
+ reactor.run(installSignalHandlers=install_signal_handlers) # blocking call
def _graceful_stop_reactor(self) -> Deferred:
d = self.stop()
From d19e315b0b07b070f45cf3c082d4dfe0e6a87186 Mon Sep 17 00:00:00 2001
From: Andrey Rakhmatullin
Date: Sun, 24 Sep 2023 21:51:19 +0400
Subject: [PATCH 0018/1041] Add an interactive test for `scrapy shell`.
---
tests/requirements.txt | 1 +
tests/test_command_shell.py | 26 ++++++++++++++++++++++++++
2 files changed, 27 insertions(+)
diff --git a/tests/requirements.txt b/tests/requirements.txt
index 3ea7f3333..dc004f3f1 100644
--- a/tests/requirements.txt
+++ b/tests/requirements.txt
@@ -1,5 +1,6 @@
# Tests requirements
attrs
+pexpect >= 4.8.0
# https://github.com/giampaolo/pyftpdlib/issues/560
pyftpdlib; python_version < "3.12"
pytest
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())
From 4b5fb9b5a6c7738aee22b5080fd96588feea39a5 Mon Sep 17 00:00:00 2001
From: Andrey Rakhmatullin
Date: Sun, 24 Sep 2023 22:52:17 +0400
Subject: [PATCH 0019/1041] Add a test for SIGTERM handling.
---
tests/CrawlerProcess/sleeping.py | 24 ++++++++++++++++++++++++
tests/test_crawler.py | 31 +++++++++++++++++++++++++++++--
2 files changed, 53 insertions(+), 2 deletions(-)
create mode 100644 tests/CrawlerProcess/sleeping.py
diff --git a/tests/CrawlerProcess/sleeping.py b/tests/CrawlerProcess/sleeping.py
new file mode 100644
index 000000000..b46f7ee2d
--- /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(2, d.callback, None)
+ await maybe_deferred_to_future(d)
+
+
+process = CrawlerProcess(settings={})
+
+process.crawl(SleepingSpider)
+process.start()
diff --git a/tests/test_crawler.py b/tests/test_crawler.py
index 2b141e894..f0a308d95 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,27 @@ 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):
+ args = self.get_script_args("sleeping.py")
+ p = PopenSpawn(args, timeout=5)
+ p.expect_exact("Spider opened")
+ p.expect_exact("Crawled (200)")
+ p.kill(signal.SIGTERM)
+ p.expect_exact("shutting down gracefully")
+ p.expect_exact("Spider closed (shutdown)")
+ p.wait()
+
+ def test_shutdown_forced(self):
+ args = self.get_script_args("sleeping.py")
+ p = PopenSpawn(args, timeout=5)
+ p.expect_exact("Spider opened")
+ p.expect_exact("Crawled (200)")
+ p.kill(signal.SIGTERM)
+ p.expect_exact("shutting down gracefully")
+ p.kill(signal.SIGTERM)
+ p.expect_exact("forcing unclean shutdown")
+ p.wait()
+
class CrawlerRunnerSubprocess(ScriptRunnerMixin, unittest.TestCase):
script_dir = Path(__file__).parent.resolve() / "CrawlerRunner"
From eb5e2e79ba8d2c492737a6e2ab8e0d4de2bd1a6e Mon Sep 17 00:00:00 2001
From: Andrey Rakhmatullin
Date: Sun, 24 Sep 2023 23:31:46 +0400
Subject: [PATCH 0020/1041] Use SIGINT instead of SIGTERM to support Windows.
---
tests/test_crawler.py | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/tests/test_crawler.py b/tests/test_crawler.py
index f0a308d95..b43a5826c 100644
--- a/tests/test_crawler.py
+++ b/tests/test_crawler.py
@@ -528,7 +528,7 @@ class CrawlerProcessSubprocess(ScriptRunnerMixin, unittest.TestCase):
p = PopenSpawn(args, timeout=5)
p.expect_exact("Spider opened")
p.expect_exact("Crawled (200)")
- p.kill(signal.SIGTERM)
+ p.kill(signal.SIGINT)
p.expect_exact("shutting down gracefully")
p.expect_exact("Spider closed (shutdown)")
p.wait()
@@ -538,9 +538,9 @@ class CrawlerProcessSubprocess(ScriptRunnerMixin, unittest.TestCase):
p = PopenSpawn(args, timeout=5)
p.expect_exact("Spider opened")
p.expect_exact("Crawled (200)")
- p.kill(signal.SIGTERM)
+ p.kill(signal.SIGINT)
p.expect_exact("shutting down gracefully")
- p.kill(signal.SIGTERM)
+ p.kill(signal.SIGINT)
p.expect_exact("forcing unclean shutdown")
p.wait()
From 4c98d6068aa467f2ce88cfaf5b6ea52ae5aaf640 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Adri=C3=A1n=20Chaves?=
Date: Mon, 25 Sep 2023 10:18:35 +0200
Subject: [PATCH 0021/1041] Add a template for question/help issues
---
.github/ISSUE_TEMPLATE/question.md | 13 +++++++++++++
1 file changed, 13 insertions(+)
create mode 100644 .github/ISSUE_TEMPLATE/question.md
diff --git a/.github/ISSUE_TEMPLATE/question.md b/.github/ISSUE_TEMPLATE/question.md
new file mode 100644
index 000000000..63cae77e7
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/question.md
@@ -0,0 +1,13 @@
+---
+name: Question / Help
+about: Ask a question about Scrapy or ask for help with your Scrapy code.
+---
+
+Thanks for taking an interest in Scrapy!
+
+The Scrapy GitHub issue tracker is not meant for questions or help. Please ask
+for help in the [Scrapy community resources](https://scrapy.org/community/)
+instead.
+
+The GitHub issue tracker's purpose is to deal with bug reports and feature
+requests for the project itself.
From 83fff6c9511fdde1024d8cd33d99314ddf4eb2ef Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Mikael=20Schir=C3=A9n?=
Date: Thu, 28 Sep 2023 23:50:14 +0200
Subject: [PATCH 0022/1041] Add dotx to exclude for documents
---
scrapy/linkextractors/__init__.py | 1 +
1 file changed, 1 insertion(+)
diff --git a/scrapy/linkextractors/__init__.py b/scrapy/linkextractors/__init__.py
index 906188184..492c455fd 100644
--- a/scrapy/linkextractors/__init__.py
+++ b/scrapy/linkextractors/__init__.py
@@ -73,6 +73,7 @@ IGNORED_EXTENSIONS = [
"pps",
"doc",
"docx",
+ "dotx",
"odt",
"ods",
"odg",
From da6e75d00a8a64fea0073816d59eef2ae3b4c672 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Mikael=20Schir=C3=A9n?=
Date: Fri, 29 Sep 2023 09:44:04 +0200
Subject: [PATCH 0023/1041] Added more common MS Office file extensions
---
scrapy/linkextractors/__init__.py | 9 +++++++++
1 file changed, 9 insertions(+)
diff --git a/scrapy/linkextractors/__init__.py b/scrapy/linkextractors/__init__.py
index 492c455fd..6b8be909e 100644
--- a/scrapy/linkextractors/__init__.py
+++ b/scrapy/linkextractors/__init__.py
@@ -67,12 +67,21 @@ IGNORED_EXTENSIONS = [
"webm",
# office suites
"xls",
+ "xlsm",
"xlsx",
+ "xltm",
+ "xltx",
+ "potm",
+ "potx",
"ppt",
+ "pptm",
"pptx",
"pps",
"doc",
+ "docb",
+ "docm",
"docx",
+ "dotm",
"dotx",
"odt",
"ods",
From fe5ef0a80a0008788e35c5b9d3c016e40ddf610f Mon Sep 17 00:00:00 2001
From: Aryan <1111aryantiwari@gmail.com>
Date: Sun, 1 Oct 2023 11:47:51 +0530
Subject: [PATCH 0024/1041] Fixed the dont_merge_cookies example in docs
---
docs/topics/request-response.rst | 11 ++++-------
1 file changed, 4 insertions(+), 7 deletions(-)
diff --git a/docs/topics/request-response.rst b/docs/topics/request-response.rst
index 41df51589..8f204fb13 100644
--- a/docs/topics/request-response.rst
+++ b/docs/topics/request-response.rst
@@ -115,21 +115,18 @@ 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:
+ Example of a request that sends manually-defined cookies:
.. code-block:: python
Request(
url="http://www.example.com",
cookies={"currency": "USD", "country": "UY"},
- meta={"dont_merge_cookies": True},
)
+ It is recommended not to set the ``dont_merge_cookies`` key to ``True``
+ in :attr:`request.meta ` as it causes custom cookies to be ignored.
+
For more info see :ref:`cookies-mw`.
.. caution:: Cookies set via the ``Cookie`` header are not considered by the
From 884840e3a30da24067825f97d3003d4c677b854e Mon Sep 17 00:00:00 2001
From: Andrew Armbruster
Date: Sun, 1 Oct 2023 12:12:24 +0200
Subject: [PATCH 0025/1041] Use concurrency groups to limit duplicate jobs
Leverage concurrency groups along with cancel-in-progress to favor running the most recent job.
Concurrency groups are on a per workflow, per branch/tag basis. So, pushing newer updates to a branch, e.g. as part of a PR, should cancel any in progress runs of workflows that have been retriggered.
See:
- https://docs.github.com/en/enterprise-cloud@latest/actions/using-workflows/workflow-syntax-for-github-actions#concurrency
- https://docs.github.com/en/enterprise-cloud@latest/actions/learn-github-actions/contexts#github-context
---
.github/workflows/checks.yml | 4 ++++
.github/workflows/publish.yml | 4 ++++
.github/workflows/tests-macos.yml | 4 ++++
.github/workflows/tests-ubuntu.yml | 4 ++++
.github/workflows/tests-windows.yml | 4 ++++
5 files changed, 20 insertions(+)
diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml
index ee0cb4b1e..afa713032 100644
--- a/.github/workflows/checks.yml
+++ b/.github/workflows/checks.yml
@@ -1,6 +1,10 @@
name: Checks
on: [push, pull_request]
+concurrency:
+ group: ${{github.workflow}}-${{ github.ref }}
+ cancel-in-progress: true
+
jobs:
checks:
runs-on: ubuntu-latest
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 22b8996b6..ec4d8fb32 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -4,6 +4,10 @@ on:
tags:
- '[0-9]+.[0-9]+.[0-9]+'
+concurrency:
+ group: ${{github.workflow}}-${{ github.ref }}
+ cancel-in-progress: true
+
jobs:
publish:
runs-on: ubuntu-latest
diff --git a/.github/workflows/tests-macos.yml b/.github/workflows/tests-macos.yml
index 3044a1af3..47392ff88 100644
--- a/.github/workflows/tests-macos.yml
+++ b/.github/workflows/tests-macos.yml
@@ -1,6 +1,10 @@
name: macOS
on: [push, pull_request]
+concurrency:
+ group: ${{github.workflow}}-${{ github.ref }}
+ cancel-in-progress: true
+
jobs:
tests:
runs-on: macos-11
diff --git a/.github/workflows/tests-ubuntu.yml b/.github/workflows/tests-ubuntu.yml
index 5ff92a571..84e2fdc36 100644
--- a/.github/workflows/tests-ubuntu.yml
+++ b/.github/workflows/tests-ubuntu.yml
@@ -1,6 +1,10 @@
name: Ubuntu
on: [push, pull_request]
+concurrency:
+ group: ${{github.workflow}}-${{ github.ref }}
+ cancel-in-progress: true
+
jobs:
tests:
runs-on: ubuntu-latest
diff --git a/.github/workflows/tests-windows.yml b/.github/workflows/tests-windows.yml
index c8d1928d7..9949dbae8 100644
--- a/.github/workflows/tests-windows.yml
+++ b/.github/workflows/tests-windows.yml
@@ -1,6 +1,10 @@
name: Windows
on: [push, pull_request]
+concurrency:
+ group: ${{github.workflow}}-${{ github.ref }}
+ cancel-in-progress: true
+
jobs:
tests:
runs-on: windows-latest
From e146c3a2fc9059101f27112fc5e797f89e642fb5 Mon Sep 17 00:00:00 2001
From: Aryan <1111aryantiwari@gmail.com>
Date: Mon, 2 Oct 2023 15:36:29 +0530
Subject: [PATCH 0026/1041] removed the entire example for dont_merge_cookies
---
docs/topics/request-response.rst | 9 ---------
1 file changed, 9 deletions(-)
diff --git a/docs/topics/request-response.rst b/docs/topics/request-response.rst
index 8f204fb13..d02af2a10 100644
--- a/docs/topics/request-response.rst
+++ b/docs/topics/request-response.rst
@@ -115,15 +115,6 @@ Request objects
cookies for that domain and will be sent again in future requests.
That's the typical behaviour of any regular web browser.
- Example of a request that sends manually-defined cookies:
-
- .. code-block:: python
-
- Request(
- url="http://www.example.com",
- cookies={"currency": "USD", "country": "UY"},
- )
-
It is recommended not to set the ``dont_merge_cookies`` key to ``True``
in :attr:`request.meta ` as it causes custom cookies to be ignored.
From ef1ed4fab7315dce1163930b16b0b898e1bbf5a6 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 0027/1041] 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 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 0028/1041] 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 42adbb21043f75bec5f513eb3b7ed86c988f7562 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Adri=C3=A1n=20Chaves?=
Date: Mon, 2 Oct 2023 14:43:30 +0200
Subject: [PATCH 0029/1041] Update docs/topics/request-response.rst
---
docs/topics/request-response.rst | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/docs/topics/request-response.rst b/docs/topics/request-response.rst
index d02af2a10..adf3d0f4a 100644
--- a/docs/topics/request-response.rst
+++ b/docs/topics/request-response.rst
@@ -115,8 +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.
- It is recommended not to set the ``dont_merge_cookies`` key to ``True``
- in :attr:`request.meta ` as it causes custom cookies to be ignored.
+ 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 1ed9ed4f923ca40e0b43a72bff9e90d38db3a3bb Mon Sep 17 00:00:00 2001
From: Andrey Rakhmatullin
Date: Mon, 2 Oct 2023 21:33:02 +0400
Subject: [PATCH 0030/1041] Require pyftpdlib that supports Python 3.12.
---
tests/requirements.txt | 3 +--
tests/test_feedexport.py | 3 ---
tests/test_pipeline_files.py | 5 -----
3 files changed, 1 insertion(+), 10 deletions(-)
diff --git a/tests/requirements.txt b/tests/requirements.txt
index 3ea7f3333..7178fb5b8 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
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 60d5f391c41996bb3003f8a30c634d59f0eecf7f Mon Sep 17 00:00:00 2001
From: Andrey Rakhmatullin
Date: Mon, 2 Oct 2023 21:35:45 +0400
Subject: [PATCH 0031/1041] Use the 3.12 release, enable it for all OSes.
---
.github/workflows/tests-macos.yml | 2 +-
.github/workflows/tests-ubuntu.yml | 20 +++++++++-----------
.github/workflows/tests-windows.yml | 8 ++++----
3 files changed, 14 insertions(+), 16 deletions(-)
diff --git a/.github/workflows/tests-macos.yml b/.github/workflows/tests-macos.yml
index 47392ff88..c23b437d2 100644
--- a/.github/workflows/tests-macos.yml
+++ b/.github/workflows/tests-macos.yml
@@ -11,7 +11,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 84e2fdc36..80b597dc2 100644
--- a/.github/workflows/tests-ubuntu.yml
+++ b/.github/workflows/tests-ubuntu.yml
@@ -21,7 +21,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
@@ -45,22 +48,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
@@ -71,7 +69,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 9949dbae8..955924349 100644
--- a/.github/workflows/tests-windows.yml
+++ b/.github/workflows/tests-windows.yml
@@ -21,13 +21,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
From 9ae8d97d81fd0b660603473a3d0601f9d1aa77f5 Mon Sep 17 00:00:00 2001
From: Klaus Rettinghaus
Date: Tue, 3 Oct 2023 13:04:18 +0200
Subject: [PATCH 0032/1041] Update GitHub Actions checkout action to version 4
(#6084)
---
.github/workflows/checks.yml | 4 ++--
.github/workflows/publish.yml | 2 +-
.github/workflows/tests-macos.yml | 2 +-
.github/workflows/tests-ubuntu.yml | 2 +-
.github/workflows/tests-windows.yml | 2 +-
5 files changed, 6 insertions(+), 6 deletions(-)
diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml
index afa713032..0ceb53848 100644
--- a/.github/workflows/checks.yml
+++ b/.github/workflows/checks.yml
@@ -26,7 +26,7 @@ jobs:
TOXENV: twinecheck
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
@@ -42,5 +42,5 @@ jobs:
pre-commit:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- uses: pre-commit/action@v3.0.0
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index ec4d8fb32..dd778fc64 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -12,7 +12,7 @@ jobs:
publish:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- uses: actions/setup-python@v4
with:
python-version: 3.11
diff --git a/.github/workflows/tests-macos.yml b/.github/workflows/tests-macos.yml
index 47392ff88..d0d7c248a 100644
--- a/.github/workflows/tests-macos.yml
+++ b/.github/workflows/tests-macos.yml
@@ -14,7 +14,7 @@ jobs:
python-version: ["3.8", "3.9", "3.10", "3.11"]
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
diff --git a/.github/workflows/tests-ubuntu.yml b/.github/workflows/tests-ubuntu.yml
index 84e2fdc36..b6d5cd27a 100644
--- a/.github/workflows/tests-ubuntu.yml
+++ b/.github/workflows/tests-ubuntu.yml
@@ -63,7 +63,7 @@ jobs:
TOXENV: extra-deps
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
diff --git a/.github/workflows/tests-windows.yml b/.github/workflows/tests-windows.yml
index 9949dbae8..eaf4df41b 100644
--- a/.github/workflows/tests-windows.yml
+++ b/.github/workflows/tests-windows.yml
@@ -32,7 +32,7 @@ jobs:
TOXENV: asyncio
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
From fb4debda04cff4e33daf62d95c71ab2919c37c07 Mon Sep 17 00:00:00 2001
From: Andrey Rakhmatullin
Date: Tue, 3 Oct 2023 16:47:03 +0400
Subject: [PATCH 0033/1041] Use path_to_file_uri to fix the test on Windows.
---
tests/test_downloader_handlers.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
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)
From a338873e3acbb6024de2c371392dc5beaa73376d Mon Sep 17 00:00:00 2001
From: Andrey Rakhmatullin
Date: Tue, 3 Oct 2023 16:50:52 +0400
Subject: [PATCH 0034/1041] Re-enable bpython on Python 3.12.
---
tests/requirements.txt | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/tests/requirements.txt b/tests/requirements.txt
index 7178fb5b8..c07fda2d6 100644
--- a/tests/requirements.txt
+++ b/tests/requirements.txt
@@ -9,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
From c65567988da2f6dd8ad894cf0cf57f2c074be10f Mon Sep 17 00:00:00 2001
From: Andrey Rakhmatullin
Date: Fri, 6 Oct 2023 13:27:02 +0400
Subject: [PATCH 0035/1041] Change supported PyPy versions to 3.9 and 3.10.
(#6087)
* Change support PyPy versions to 3.9 and 3.10.
* Update the RTD URL for coverage.
* Move pypy3-pinned back to pypy3.8.
---
.github/workflows/tests-ubuntu.yml | 5 ++++-
docs/conf.py | 2 +-
2 files changed, 5 insertions(+), 2 deletions(-)
diff --git a/.github/workflows/tests-ubuntu.yml b/.github/workflows/tests-ubuntu.yml
index 7ac0305f5..a307eb337 100644
--- a/.github/workflows/tests-ubuntu.yml
+++ b/.github/workflows/tests-ubuntu.yml
@@ -30,6 +30,9 @@ jobs:
- python-version: pypy3.9
env:
TOXENV: pypy3
+ - python-version: pypy3.10
+ env:
+ TOXENV: pypy3
# pinned deps
- python-version: 3.8.17
@@ -69,7 +72,7 @@ jobs:
python-version: ${{ matrix.python-version }}
- name: Install system libraries
- if: matrix.python-version == 'pypy3.9' || contains(matrix.env.TOXENV, 'pinned')
+ if: contains(matrix.python-version, 'pypy') || contains(matrix.env.TOXENV, 'pinned')
run: |
sudo apt-get update
sudo apt-get install libxml2-dev libxslt-dev
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 2f436c05e088b063b70f000a638409772df27138 Mon Sep 17 00:00:00 2001
From: Andrey Rakhmatullin
Date: Sun, 8 Oct 2023 22:55:05 +0400
Subject: [PATCH 0036/1041] Use SIGBREAK in Windows tests.
---
tests/test_crawler.py | 8 +++++---
1 file changed, 5 insertions(+), 3 deletions(-)
diff --git a/tests/test_crawler.py b/tests/test_crawler.py
index b43a5826c..60b92377d 100644
--- a/tests/test_crawler.py
+++ b/tests/test_crawler.py
@@ -524,23 +524,25 @@ class CrawlerProcessSubprocess(ScriptRunnerMixin, unittest.TestCase):
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(signal.SIGINT)
+ 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(signal.SIGINT)
+ p.kill(sig)
p.expect_exact("shutting down gracefully")
- p.kill(signal.SIGINT)
+ p.kill(sig)
p.expect_exact("forcing unclean shutdown")
p.wait()
From 029a56384dcac0a52fe40f1bebaaa53eda1cc902 Mon Sep 17 00:00:00 2001
From: Andrey Rakhmatullin
Date: Mon, 9 Oct 2023 00:16:22 +0400
Subject: [PATCH 0037/1041] Increase the timeout.
---
tests/CrawlerProcess/sleeping.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/tests/CrawlerProcess/sleeping.py b/tests/CrawlerProcess/sleeping.py
index b46f7ee2d..420d9d328 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(2, d.callback, None)
+ reactor.callLater(3, d.callback, None)
await maybe_deferred_to_future(d)
From 064256b059cf792ba0c1983c64ae19204df3785e Mon Sep 17 00:00:00 2001
From: Sandesh Pyakurel <82999440+Sandesh-Pyakurel@users.noreply.github.com>
Date: Tue, 17 Oct 2023 22:24:39 +0545
Subject: [PATCH 0038/1041] Typo fixed in news.rst file (#6094)
---
docs/news.rst | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/news.rst b/docs/news.rst
index fd8fa3ea3..5db37969c 100644
--- a/docs/news.rst
+++ b/docs/news.rst
@@ -324,7 +324,7 @@ Deprecations
New features
~~~~~~~~~~~~
-- Settings correponding to :setting:`DOWNLOAD_DELAY`,
+- Settings corresponding to :setting:`DOWNLOAD_DELAY`,
:setting:`CONCURRENT_REQUESTS_PER_DOMAIN` and
:setting:`RANDOMIZE_DOWNLOAD_DELAY` can now be set on a per-domain basis
via the new :setting:`DOWNLOAD_SLOTS` setting. (:issue:`5328`)
From 5807970a22b9781e291a623c9a713d3124265295 Mon Sep 17 00:00:00 2001
From: Andrey Rakhmatullin
Date: Tue, 17 Oct 2023 21:07:13 +0400
Subject: [PATCH 0039/1041] Add typing hints to CaselessDict and Headers.
(#6097)
---
scrapy/core/http2/stream.py | 4 +-
scrapy/http/headers.py | 93 ++++++++++++++++++++---------
scrapy/http/request/__init__.py | 16 ++++-
scrapy/http/response/__init__.py | 4 +-
scrapy/http/response/text.py | 8 +--
scrapy/utils/datatypes.py | 56 +++++++++++------
tests/test_http2_client_protocol.py | 16 +++--
7 files changed, 137 insertions(+), 60 deletions(-)
diff --git a/scrapy/core/http2/stream.py b/scrapy/core/http2/stream.py
index 6c6ed6f9b..39d5921f4 100644
--- a/scrapy/core/http2/stream.py
+++ b/scrapy/core/http2/stream.py
@@ -228,8 +228,8 @@ class Stream:
content_length_name = self._request.headers.normkey(b"Content-Length")
for name, values in self._request.headers.items():
- for value in values:
- value = str(value, "utf-8")
+ for value_bytes in values:
+ value = str(value_bytes, "utf-8")
if name == content_length_name:
if value != content_length:
logger.warning(
diff --git a/scrapy/http/headers.py b/scrapy/http/headers.py
index 822597c84..21eb9fb73 100644
--- a/scrapy/http/headers.py
+++ b/scrapy/http/headers.py
@@ -1,41 +1,73 @@
+from __future__ import annotations
+
from collections.abc import Mapping
+from typing import (
+ TYPE_CHECKING,
+ Any,
+ AnyStr,
+ Dict,
+ Iterable,
+ List,
+ Optional,
+ Tuple,
+ Union,
+ cast,
+)
from w3lib.http import headers_dict_to_raw
from scrapy.utils.datatypes import CaseInsensitiveDict, CaselessDict
from scrapy.utils.python import to_unicode
+if TYPE_CHECKING:
+ # typing.Self requires Python 3.11
+ from typing_extensions import Self
+
+_RawValueT = Union[bytes, str, int]
+
+
+# isn't fully compatible typing-wise with either dict or CaselessDict,
+# but it needs refactoring anyway, see also https://github.com/scrapy/scrapy/pull/5146
class Headers(CaselessDict):
"""Case insensitive http headers dictionary"""
- def __init__(self, seq=None, encoding="utf-8"):
- self.encoding = encoding
+ def __init__(
+ self,
+ seq: Union[Mapping[AnyStr, Any], Iterable[Tuple[AnyStr, Any]], None] = None,
+ encoding: str = "utf-8",
+ ):
+ self.encoding: str = encoding
super().__init__(seq)
- def update(self, seq):
+ def update( # type: ignore[override]
+ self, seq: Union[Mapping[AnyStr, Any], Iterable[Tuple[AnyStr, Any]]]
+ ) -> None:
seq = seq.items() if isinstance(seq, Mapping) else seq
- iseq = {}
+ iseq: Dict[bytes, List[bytes]] = {}
for k, v in seq:
iseq.setdefault(self.normkey(k), []).extend(self.normvalue(v))
super().update(iseq)
- def normkey(self, key):
+ def normkey(self, key: AnyStr) -> bytes: # type: ignore[override]
"""Normalize key to bytes"""
return self._tobytes(key.title())
- def normvalue(self, value):
+ def normvalue(self, value: Union[_RawValueT, Iterable[_RawValueT]]) -> List[bytes]:
"""Normalize values to bytes"""
+ _value: Iterable[_RawValueT]
if value is None:
- value = []
+ _value = []
elif isinstance(value, (str, bytes)):
- value = [value]
- elif not hasattr(value, "__iter__"):
- value = [value]
+ _value = [value]
+ elif hasattr(value, "__iter__"):
+ _value = value
+ else:
+ _value = [value]
- return [self._tobytes(x) for x in value]
+ return [self._tobytes(x) for x in _value]
- def _tobytes(self, x):
+ def _tobytes(self, x: _RawValueT) -> bytes:
if isinstance(x, bytes):
return x
if isinstance(x, str):
@@ -44,49 +76,52 @@ class Headers(CaselessDict):
return str(x).encode(self.encoding)
raise TypeError(f"Unsupported value type: {type(x)}")
- def __getitem__(self, key):
+ def __getitem__(self, key: AnyStr) -> Optional[bytes]:
try:
- return super().__getitem__(key)[-1]
+ return cast(List[bytes], super().__getitem__(key))[-1]
except IndexError:
return None
- def get(self, key, def_val=None):
+ def get(self, key: AnyStr, def_val: Any = None) -> Optional[bytes]:
try:
- return super().get(key, def_val)[-1]
+ return cast(List[bytes], super().get(key, def_val))[-1]
except IndexError:
return None
- def getlist(self, key, def_val=None):
+ def getlist(self, key: AnyStr, def_val: Any = None) -> List[bytes]:
try:
- return super().__getitem__(key)
+ return cast(List[bytes], super().__getitem__(key))
except KeyError:
if def_val is not None:
return self.normvalue(def_val)
return []
- def setlist(self, key, list_):
+ def setlist(self, key: AnyStr, list_: Iterable[_RawValueT]) -> None:
self[key] = list_
- def setlistdefault(self, key, default_list=()):
+ def setlistdefault(
+ self, key: AnyStr, default_list: Iterable[_RawValueT] = ()
+ ) -> Any:
return self.setdefault(key, default_list)
- def appendlist(self, key, value):
+ def appendlist(self, key: AnyStr, value: Iterable[_RawValueT]) -> None:
lst = self.getlist(key)
lst.extend(self.normvalue(value))
self[key] = lst
- def items(self):
+ def items(self) -> Iterable[Tuple[bytes, List[bytes]]]: # type: ignore[override]
return ((k, self.getlist(k)) for k in self.keys())
- def values(self):
+ def values(self) -> List[Optional[bytes]]: # type: ignore[override]
return [self[k] for k in self.keys()]
- def to_string(self):
- return headers_dict_to_raw(self)
+ def to_string(self) -> bytes:
+ # cast() can be removed if the headers_dict_to_raw() hint is improved
+ return cast(bytes, headers_dict_to_raw(self))
- def to_unicode_dict(self):
- """Return headers as a CaselessDict with unicode keys
- and unicode values. Multiple values are joined with ','.
+ def to_unicode_dict(self) -> CaseInsensitiveDict:
+ """Return headers as a CaseInsensitiveDict with str keys
+ and str values. Multiple values are joined with ','.
"""
return CaseInsensitiveDict(
(
@@ -96,7 +131,7 @@ class Headers(CaselessDict):
for key, value in self.items()
)
- def __copy__(self):
+ def __copy__(self) -> Self:
return self.__class__(self)
copy = __copy__
diff --git a/scrapy/http/request/__init__.py b/scrapy/http/request/__init__.py
index 9ba6ddf20..0b443c7d0 100644
--- a/scrapy/http/request/__init__.py
+++ b/scrapy/http/request/__init__.py
@@ -5,7 +5,19 @@ requests in Scrapy.
See documentation in docs/topics/request-response.rst
"""
import inspect
-from typing import Callable, List, Optional, Tuple, Type, TypeVar, Union
+from typing import (
+ Any,
+ AnyStr,
+ Callable,
+ Iterable,
+ List,
+ Mapping,
+ Optional,
+ Tuple,
+ Type,
+ TypeVar,
+ Union,
+)
from w3lib.url import safe_url_string
@@ -77,7 +89,7 @@ class Request(object_ref):
url: str,
callback: Optional[Callable] = None,
method: str = "GET",
- headers: Optional[dict] = None,
+ headers: Union[Mapping[AnyStr, Any], Iterable[Tuple[AnyStr, Any]], None] = None,
body: Optional[Union[bytes, str]] = None,
cookies: Optional[Union[dict, List[dict]]] = None,
meta: Optional[dict] = None,
diff --git a/scrapy/http/response/__init__.py b/scrapy/http/response/__init__.py
index a82ed834a..82274fc3a 100644
--- a/scrapy/http/response/__init__.py
+++ b/scrapy/http/response/__init__.py
@@ -4,7 +4,7 @@ responses in Scrapy.
See documentation in docs/topics/request-response.rst
"""
-from typing import Generator, Tuple
+from typing import Any, AnyStr, Generator, Iterable, Mapping, Tuple, Union
from urllib.parse import urljoin
from scrapy.exceptions import NotSupported
@@ -42,7 +42,7 @@ class Response(object_ref):
self,
url: str,
status=200,
- headers=None,
+ headers: Union[Mapping[AnyStr, Any], Iterable[Tuple[AnyStr, Any]], None] = None,
body=b"",
flags=None,
request=None,
diff --git a/scrapy/http/response/text.py b/scrapy/http/response/text.py
index 47d7bc10f..98ae1f307 100644
--- a/scrapy/http/response/text.py
+++ b/scrapy/http/response/text.py
@@ -8,7 +8,7 @@ from __future__ import annotations
import json
from contextlib import suppress
-from typing import TYPE_CHECKING, Any, Generator, Optional, Tuple
+from typing import TYPE_CHECKING, Any, Generator, Optional, Tuple, cast
from urllib.parse import urljoin
import parsel
@@ -102,14 +102,14 @@ class TextResponse(Response):
return urljoin(get_base_url(self), url)
@memoizemethod_noargs
- def _headers_encoding(self):
- content_type = self.headers.get(b"Content-Type", b"")
+ def _headers_encoding(self) -> Optional[str]:
+ content_type = cast(bytes, self.headers.get(b"Content-Type", b""))
return http_content_type_encoding(to_unicode(content_type, encoding="latin-1"))
def _body_inferred_encoding(self):
if self._cached_benc is None:
content_type = to_unicode(
- self.headers.get(b"Content-Type", b""), encoding="latin-1"
+ cast(bytes, self.headers.get(b"Content-Type", b"")), encoding="latin-1"
)
benc, ubody = html_to_unicode(
content_type,
diff --git a/scrapy/utils/datatypes.py b/scrapy/utils/datatypes.py
index d5b9544cc..0ba2fe4e2 100644
--- a/scrapy/utils/datatypes.py
+++ b/scrapy/utils/datatypes.py
@@ -5,14 +5,32 @@ Python Standard Library.
This module must not depend on any module outside the Standard Library.
"""
+from __future__ import annotations
+
import collections
import warnings
import weakref
from collections.abc import Mapping
-from typing import Any, AnyStr, Optional, OrderedDict, Sequence, TypeVar
+from typing import (
+ TYPE_CHECKING,
+ Any,
+ AnyStr,
+ Iterable,
+ Optional,
+ OrderedDict,
+ Sequence,
+ Tuple,
+ TypeVar,
+ Union,
+)
from scrapy.exceptions import ScrapyDeprecationWarning
+if TYPE_CHECKING:
+ # typing.Self requires Python 3.11
+ from typing_extensions import Self
+
+
_KT = TypeVar("_KT")
_VT = TypeVar("_VT")
@@ -20,7 +38,7 @@ _VT = TypeVar("_VT")
class CaselessDict(dict):
__slots__ = ()
- def __new__(cls, *args, **kwargs):
+ def __new__(cls, *args: Any, **kwargs: Any) -> Self:
from scrapy.http.headers import Headers
if issubclass(cls, CaselessDict) and not issubclass(cls, Headers):
@@ -32,54 +50,58 @@ class CaselessDict(dict):
)
return super().__new__(cls, *args, **kwargs)
- def __init__(self, seq=None):
+ def __init__(
+ self,
+ seq: Union[Mapping[AnyStr, Any], Iterable[Tuple[AnyStr, Any]], None] = None,
+ ):
super().__init__()
if seq:
self.update(seq)
- def __getitem__(self, key):
+ def __getitem__(self, key: AnyStr) -> Any:
return dict.__getitem__(self, self.normkey(key))
- def __setitem__(self, key, value):
+ def __setitem__(self, key: AnyStr, value: Any) -> None:
dict.__setitem__(self, self.normkey(key), self.normvalue(value))
- def __delitem__(self, key):
+ def __delitem__(self, key: AnyStr) -> None:
dict.__delitem__(self, self.normkey(key))
- def __contains__(self, key):
+ def __contains__(self, key: AnyStr) -> bool: # type: ignore[override]
return dict.__contains__(self, self.normkey(key))
has_key = __contains__
- def __copy__(self):
+ def __copy__(self) -> Self:
return self.__class__(self)
copy = __copy__
- def normkey(self, key):
+ def normkey(self, key: AnyStr) -> AnyStr:
"""Method to normalize dictionary key access"""
return key.lower()
- def normvalue(self, value):
+ def normvalue(self, value: Any) -> Any:
"""Method to normalize values prior to be set"""
return value
- def get(self, key, def_val=None):
+ def get(self, key: AnyStr, def_val: Any = None) -> Any:
return dict.get(self, self.normkey(key), self.normvalue(def_val))
- def setdefault(self, key, def_val=None):
- return dict.setdefault(self, self.normkey(key), self.normvalue(def_val))
+ def setdefault(self, key: AnyStr, def_val: Any = None) -> Any:
+ return dict.setdefault(self, self.normkey(key), self.normvalue(def_val)) # type: ignore[arg-type]
- def update(self, seq):
+ # doesn't fully implement MutableMapping.update()
+ def update(self, seq: Union[Mapping[AnyStr, Any], Iterable[Tuple[AnyStr, Any]]]) -> None: # type: ignore[override]
seq = seq.items() if isinstance(seq, Mapping) else seq
iseq = ((self.normkey(k), self.normvalue(v)) for k, v in seq)
super().update(iseq)
@classmethod
- def fromkeys(cls, keys, value=None):
- return cls((k, value) for k in keys)
+ def fromkeys(cls, keys: Iterable[AnyStr], value: Any = None) -> Self: # type: ignore[override]
+ return cls((k, value) for k in keys) # type: ignore[misc]
- def pop(self, key, *args):
+ def pop(self, key: AnyStr, *args: Any) -> Any:
return dict.pop(self, self.normkey(key), *args)
diff --git a/tests/test_http2_client_protocol.py b/tests/test_http2_client_protocol.py
index 4709007a8..deb35a579 100644
--- a/tests/test_http2_client_protocol.py
+++ b/tests/test_http2_client_protocol.py
@@ -275,7 +275,9 @@ class Https2ClientProtocolTestCase(TestCase):
self.assertEqual(response.body, expected_body)
self.assertEqual(response.request, request)
- content_length = int(response.headers.get("Content-Length"))
+ content_length_header = response.headers.get("Content-Length")
+ assert content_length_header is not None
+ content_length = int(content_length_header)
self.assertEqual(len(response.body), content_length)
d = self.make_request(request)
@@ -320,11 +322,15 @@ class Https2ClientProtocolTestCase(TestCase):
self.assertEqual(response.status, expected_status)
self.assertEqual(response.request, request)
- content_length = int(response.headers.get("Content-Length"))
+ content_length_header = response.headers.get("Content-Length")
+ assert content_length_header is not None
+ content_length = int(content_length_header)
self.assertEqual(len(response.body), content_length)
# Parse the body
- content_encoding = str(response.headers[b"Content-Encoding"], "utf-8")
+ content_encoding_header = response.headers[b"Content-Encoding"]
+ assert content_encoding_header is not None
+ content_encoding = str(content_encoding_header, "utf-8")
body = json.loads(str(response.body, content_encoding))
self.assertIn("request-body", body)
self.assertIn("extra-data", body)
@@ -562,7 +568,9 @@ class Https2ClientProtocolTestCase(TestCase):
request = Request(self.get_url(f"/query-params?{urlencode(params)}"))
def assert_query_params(response: Response):
- content_encoding = str(response.headers[b"Content-Encoding"], "utf-8")
+ content_encoding_header = response.headers[b"Content-Encoding"]
+ assert content_encoding_header is not None
+ content_encoding = str(content_encoding_header, "utf-8")
data = json.loads(str(response.body, content_encoding))
self.assertEqual(data, params)
From 991121fa91aee4d428ae09e75427d4e91970a41b Mon Sep 17 00:00:00 2001
From: Andrey Rakhmatullin
Date: Tue, 17 Oct 2023 21:24:44 +0400
Subject: [PATCH 0040/1041] 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 0ceb53848..d6fc0f6c5 100644
--- a/.github/workflows/checks.yml
+++ b/.github/workflows/checks.yml
@@ -12,7 +12,7 @@ jobs:
fail-fast: false
matrix:
include:
- - python-version: "3.11"
+ - python-version: "3.12"
env:
TOXENV: pylint
- python-version: 3.8
@@ -21,7 +21,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 dd778fc64..affaa32a5 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -15,7 +15,7 @@ jobs:
- uses: actions/checkout@v4
- 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 a307eb337..f50a4d104 100644
--- a/.github/workflows/tests-ubuntu.yml
+++ b/.github/workflows/tests-ubuntu.yml
@@ -58,11 +58,6 @@ jobs:
env:
TOXENV: botocore
- # keep until uvloop supports 3.12
- - python-version: "3.11"
- env:
- TOXENV: asyncio
-
steps:
- uses: actions/checkout@v4
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 2cb1e10c764175a9d7deee387feafaee0dfaa70b Mon Sep 17 00:00:00 2001
From: Andrey Rakhmatullin
Date: Tue, 17 Oct 2023 23:56:54 +0400
Subject: [PATCH 0041/1041] Make shutdown tests more robust.
---
tests/CrawlerProcess/sleeping.py | 2 +-
tests/test_crawler.py | 4 ++--
2 files changed, 3 insertions(+), 3 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_crawler.py b/tests/test_crawler.py
index 60b92377d..5c11ca6e0 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)")
@@ -536,7 +536,7 @@ class CrawlerProcessSubprocess(ScriptRunnerMixin, unittest.TestCase):
def test_shutdown_forced(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=10")
p = PopenSpawn(args, timeout=5)
p.expect_exact("Spider opened")
p.expect_exact("Crawled (200)")
From 7a5cefbcfa1c7b7b0207263bf0d3d6b5e7dcc49a Mon Sep 17 00:00:00 2001
From: Laerte Pereira
Date: Tue, 17 Oct 2023 17:49:22 -0300
Subject: [PATCH 0042/1041] 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 bdb4abcc7a0c5b1d452557bc74087c7dbb3ff06f Mon Sep 17 00:00:00 2001
From: Laerte Pereira
Date: Tue, 17 Oct 2023 17:52:18 -0300
Subject: [PATCH 0043/1041] Remove the deprecation warning from
CrawlerRunner._get_spider_loader()
---
scrapy/crawler.py | 15 +--------------
1 file changed, 1 insertion(+), 14 deletions(-)
diff --git a/scrapy/crawler.py b/scrapy/crawler.py
index 6f54e62e9..1d3a11208 100644
--- a/scrapy/crawler.py
+++ b/scrapy/crawler.py
@@ -12,7 +12,6 @@ from twisted.internet.defer import (
inlineCallbacks,
maybeDeferred,
)
-from zope.interface.exceptions import DoesNotImplement
try:
# zope >= 5.0 only supports MultipleInvalid
@@ -205,19 +204,7 @@ class CrawlerRunner:
"""Get SpiderLoader instance from settings"""
cls_path = settings.get("SPIDER_LOADER_CLASS")
loader_cls = load_object(cls_path)
- excs = (
- (DoesNotImplement, MultipleInvalid) if MultipleInvalid else DoesNotImplement
- )
- try:
- verifyClass(ISpiderLoader, loader_cls)
- except excs:
- warnings.warn(
- "SPIDER_LOADER_CLASS (previously named SPIDER_MANAGER_CLASS) does "
- "not fully implement scrapy.interfaces.ISpiderLoader interface. "
- "Please add all missing methods to avoid unexpected runtime errors.",
- category=ScrapyDeprecationWarning,
- stacklevel=2,
- )
+ verifyClass(ISpiderLoader, loader_cls)
return loader_cls.from_settings(settings.frozencopy())
def __init__(self, settings: Union[Dict[str, Any], Settings, None] = None):
From 83f500a352c038fe6afb7b04f0de00e20fe1b887 Mon Sep 17 00:00:00 2001
From: Laerte Pereira
Date: Tue, 17 Oct 2023 18:14:08 -0300
Subject: [PATCH 0044/1041] Remove
scrapy.pipelines.media.MediaPipeline._make_compatible
---
scrapy/pipelines/media.py | 20 --------------------
1 file changed, 20 deletions(-)
diff --git a/scrapy/pipelines/media.py b/scrapy/pipelines/media.py
index 401029439..8cc4df855 100644
--- a/scrapy/pipelines/media.py
+++ b/scrapy/pipelines/media.py
@@ -44,9 +44,6 @@ class MediaPipeline:
self.allow_redirects = settings.getbool(resolve("MEDIA_ALLOW_REDIRECTS"), False)
self._handle_statuses(self.allow_redirects)
- # Check if deprecated methods are being used and make them compatible
- self._make_compatible()
-
def _handle_statuses(self, allow_redirects):
self.handle_httpstatus_list = None
if allow_redirects:
@@ -126,23 +123,6 @@ class MediaPipeline:
)
return dfd.addBoth(lambda _: wad) # it must return wad at last
- def _make_compatible(self):
- """Make overridable methods of MediaPipeline and subclasses backwards compatible"""
- methods = [
- "file_path",
- "thumb_path",
- "media_to_download",
- "media_downloaded",
- "file_downloaded",
- "image_downloaded",
- "get_images",
- ]
-
- for method_name in methods:
- method = getattr(self, method_name, None)
- if callable(method):
- setattr(self, method_name, self._compatible(method))
-
def _compatible(self, func):
"""Wrapper for overridable methods to allow backwards compatibility"""
self._check_signature(func)
From 85c57778b5ac701f30721c4aa968efc8474c6e1e Mon Sep 17 00:00:00 2001
From: Laerte Pereira
Date: Tue, 17 Oct 2023 18:15:20 -0300
Subject: [PATCH 0045/1041] Remove deprecated JSONRequest
---
scrapy/http/request/__init__.py | 2 +-
scrapy/http/request/json_request.py | 4 ----
2 files changed, 1 insertion(+), 5 deletions(-)
diff --git a/scrapy/http/request/__init__.py b/scrapy/http/request/__init__.py
index 0b443c7d0..06a52f5fd 100644
--- a/scrapy/http/request/__init__.py
+++ b/scrapy/http/request/__init__.py
@@ -200,7 +200,7 @@ class Request(object_ref):
``ignore_unknown_options=False``.
.. caution:: Using :meth:`from_curl` from :class:`~scrapy.http.Request`
- subclasses, such as :class:`~scrapy.http.JSONRequest`, or
+ subclasses, such as :class:`~scrapy.http.JsonRequest`, or
:class:`~scrapy.http.XmlRpcRequest`, as well as having
:ref:`downloader middlewares `
and
diff --git a/scrapy/http/request/json_request.py b/scrapy/http/request/json_request.py
index 510c903db..f52c0401d 100644
--- a/scrapy/http/request/json_request.py
+++ b/scrapy/http/request/json_request.py
@@ -11,7 +11,6 @@ import warnings
from typing import Optional, Tuple
from scrapy.http.request import Request
-from scrapy.utils.deprecate import create_deprecated_class
class JsonRequest(Request):
@@ -58,6 +57,3 @@ class JsonRequest(Request):
def _dumps(self, data: dict) -> str:
"""Convert to JSON"""
return json.dumps(data, **self._dumps_kwargs)
-
-
-JSONRequest = create_deprecated_class("JSONRequest", JsonRequest)
From aa0a4258264716ad5f99d9fca7c64bde8cf3576b Mon Sep 17 00:00:00 2001
From: Laerte Pereira
Date: Tue, 17 Oct 2023 18:20:12 -0300
Subject: [PATCH 0046/1041] Remove deprecated code moved to itemloaders
---
scrapy/loader/common.py | 21 ------------------
scrapy/loader/processors.py | 20 -----------------
scrapy/utils/misc.py | 38 ---------------------------------
tests/test_loader_deprecated.py | 23 --------------------
4 files changed, 102 deletions(-)
delete mode 100644 scrapy/loader/common.py
delete mode 100644 scrapy/loader/processors.py
diff --git a/scrapy/loader/common.py b/scrapy/loader/common.py
deleted file mode 100644
index 3e8644e0c..000000000
--- a/scrapy/loader/common.py
+++ /dev/null
@@ -1,21 +0,0 @@
-"""Common functions used in Item Loaders code"""
-
-import warnings
-
-from itemloaders import common
-
-from scrapy.utils.deprecate import ScrapyDeprecationWarning
-
-
-def wrap_loader_context(function, context):
- """Wrap functions that receive loader_context to contain the context
- "pre-loaded" and expose a interface that receives only one argument
- """
- warnings.warn(
- "scrapy.loader.common.wrap_loader_context has moved to a new library."
- "Please update your reference to itemloaders.common.wrap_loader_context",
- ScrapyDeprecationWarning,
- stacklevel=2,
- )
-
- return common.wrap_loader_context(function, context)
diff --git a/scrapy/loader/processors.py b/scrapy/loader/processors.py
deleted file mode 100644
index b82c6d5c7..000000000
--- a/scrapy/loader/processors.py
+++ /dev/null
@@ -1,20 +0,0 @@
-"""
-This module provides some commonly used processors for Item Loaders.
-
-See documentation in docs/topics/loaders.rst
-"""
-from itemloaders import processors
-
-from scrapy.utils.deprecate import create_deprecated_class
-
-MapCompose = create_deprecated_class("MapCompose", processors.MapCompose)
-
-Compose = create_deprecated_class("Compose", processors.Compose)
-
-TakeFirst = create_deprecated_class("TakeFirst", processors.TakeFirst)
-
-Identity = create_deprecated_class("Identity", processors.Identity)
-
-SelectJmes = create_deprecated_class("SelectJmes", processors.SelectJmes)
-
-Join = create_deprecated_class("Join", processors.Join)
diff --git a/scrapy/utils/misc.py b/scrapy/utils/misc.py
index b3c28da92..a9364bea2 100644
--- a/scrapy/utils/misc.py
+++ b/scrapy/utils/misc.py
@@ -21,17 +21,12 @@ from typing import (
Iterable,
List,
Optional,
- Pattern,
Union,
cast,
)
-from w3lib.html import replace_entities
-
from scrapy.item import Item
from scrapy.utils.datatypes import LocalWeakReferencedCache
-from scrapy.utils.deprecate import ScrapyDeprecationWarning
-from scrapy.utils.python import flatten, to_unicode
if TYPE_CHECKING:
from scrapy import Spider
@@ -108,39 +103,6 @@ def walk_modules(path: str) -> List[ModuleType]:
return mods
-def extract_regex(
- regex: Union[str, Pattern], text: str, encoding: str = "utf-8"
-) -> List[str]:
- """Extract a list of unicode strings from the given text/encoding using the following policies:
-
- * if the regex contains a named group called "extract" that will be returned
- * if the regex contains multiple numbered groups, all those will be returned (flattened)
- * if the regex doesn't contain any group the entire regex matching is returned
- """
- warnings.warn(
- "scrapy.utils.misc.extract_regex has moved to parsel.utils.extract_regex.",
- ScrapyDeprecationWarning,
- stacklevel=2,
- )
-
- if isinstance(regex, str):
- regex = re.compile(regex, re.UNICODE)
-
- try:
- # named group
- strings = [regex.search(text).group("extract")] # type: ignore[union-attr]
- except Exception:
- # full regex or numbered groups
- strings = regex.findall(text)
- strings = flatten(strings)
-
- if isinstance(text, str):
- return [replace_entities(s, keep=["lt", "amp"]) for s in strings]
- return [
- replace_entities(to_unicode(s, encoding), keep=["lt", "amp"]) for s in strings
- ]
-
-
def md5sum(file: IO) -> str:
"""Calculate the md5 checksum of a file-like object without reading its
whole content in memory.
diff --git a/tests/test_loader_deprecated.py b/tests/test_loader_deprecated.py
index 3eae87599..d7f773d5c 100644
--- a/tests/test_loader_deprecated.py
+++ b/tests/test_loader_deprecated.py
@@ -4,7 +4,6 @@ Once we remove the references from scrapy, we can remove these tests.
"""
import unittest
-import warnings
from functools import partial
from itemloaders.processors import (
@@ -18,9 +17,6 @@ from itemloaders.processors import (
from scrapy.item import Field, Item
from scrapy.loader import ItemLoader
-from scrapy.loader.common import wrap_loader_context
-from scrapy.utils.deprecate import ScrapyDeprecationWarning
-from scrapy.utils.misc import extract_regex
# test items
@@ -722,24 +718,5 @@ class FunctionProcessorTestCase(unittest.TestCase):
self.assertEqual(dict(lo.load_item()), {"foo": ["BAR", "ASDF", "QWERTY"]})
-class DeprecatedUtilityFunctionsTestCase(unittest.TestCase):
- def test_deprecated_wrap_loader_context(self):
- def function(*args):
- return None
-
- with warnings.catch_warnings(record=True) as w:
- wrap_loader_context(function, context={})
-
- assert len(w) == 1
- assert issubclass(w[0].category, ScrapyDeprecationWarning)
-
- def test_deprecated_extract_regex(self):
- with warnings.catch_warnings(record=True) as w:
- extract_regex(r"\w+", "this is a test")
-
- assert len(w) == 1
- assert issubclass(w[0].category, ScrapyDeprecationWarning)
-
-
if __name__ == "__main__":
unittest.main()
From ad4e8b64d4343e01e944c9698afaa29802d557a9 Mon Sep 17 00:00:00 2001
From: Laerte Pereira
Date: Tue, 17 Oct 2023 18:58:27 -0300
Subject: [PATCH 0047/1041] fix test_spider_manager_verify_interface test
---
tests/test_crawler.py | 7 ++-----
1 file changed, 2 insertions(+), 5 deletions(-)
diff --git a/tests/test_crawler.py b/tests/test_crawler.py
index 60b92377d..a71b9c2b7 100644
--- a/tests/test_crawler.py
+++ b/tests/test_crawler.py
@@ -15,6 +15,7 @@ from pytest import mark, raises
from twisted.internet import defer
from twisted.trial import unittest
from w3lib import __version__ as w3lib_version
+from zope.interface.exceptions import MultipleInvalid
import scrapy
from scrapy.crawler import Crawler, CrawlerProcess, CrawlerRunner
@@ -179,11 +180,7 @@ class CrawlerRunnerTestCase(BaseCrawlerTest):
"SPIDER_LOADER_CLASS": SpiderLoaderWithWrongInterface,
}
)
- with warnings.catch_warnings(record=True) as w:
- self.assertRaises(AttributeError, CrawlerRunner, settings)
- self.assertEqual(len(w), 1)
- self.assertIn("SPIDER_LOADER_CLASS", str(w[0].message))
- self.assertIn("scrapy.interfaces.ISpiderLoader", str(w[0].message))
+ self.assertRaises(MultipleInvalid, CrawlerRunner, settings)
def test_crawler_runner_accepts_dict(self):
runner = CrawlerRunner({"foo": "bar"})
From 2de95f1fc01fa8835f85c2554faba6bb536e71f8 Mon Sep 17 00:00:00 2001
From: Laerte Pereira
Date: Tue, 17 Oct 2023 19:44:12 -0300
Subject: [PATCH 0048/1041] Remove leftover from _make_compatible removal
---
scrapy/pipelines/media.py | 14 ------
tests/test_pipeline_media.py | 97 ------------------------------------
2 files changed, 111 deletions(-)
diff --git a/scrapy/pipelines/media.py b/scrapy/pipelines/media.py
index 8cc4df855..34ab18404 100644
--- a/scrapy/pipelines/media.py
+++ b/scrapy/pipelines/media.py
@@ -123,20 +123,6 @@ class MediaPipeline:
)
return dfd.addBoth(lambda _: wad) # it must return wad at last
- def _compatible(self, func):
- """Wrapper for overridable methods to allow backwards compatibility"""
- self._check_signature(func)
-
- @functools.wraps(func)
- def wrapper(*args, **kwargs):
- if self._expects_item[func.__name__]:
- return func(*args, **kwargs)
-
- kwargs.pop("item", None)
- return func(*args, **kwargs)
-
- return wrapper
-
def _check_signature(self, func):
sig = signature(func)
self._expects_item[func.__name__] = True
diff --git a/tests/test_pipeline_media.py b/tests/test_pipeline_media.py
index d655eb128..820484565 100644
--- a/tests/test_pipeline_media.py
+++ b/tests/test_pipeline_media.py
@@ -15,7 +15,6 @@ from scrapy.pipelines.images import ImagesPipeline
from scrapy.pipelines.media import MediaPipeline
from scrapy.settings import Settings
from scrapy.spiders import Spider
-from scrapy.utils.deprecate import ScrapyDeprecationWarning
from scrapy.utils.log import failure_to_exc_info
from scrapy.utils.signal import disconnect_all
from scrapy.utils.test import get_crawler
@@ -427,102 +426,6 @@ class MockedMediaPipelineDeprecatedMethods(ImagesPipeline):
return super().image_downloaded(response, request, info)
-class MediaPipelineDeprecatedMethodsTestCase(unittest.TestCase):
- skip = skip_pillow
-
- def setUp(self):
- settings_dict = {
- "IMAGES_STORE": "store-uri",
- "IMAGES_THUMBS": {"small": (50, 50)},
- }
- crawler = get_crawler(spidercls=None, settings_dict=settings_dict)
- self.pipe = MockedMediaPipelineDeprecatedMethods.from_crawler(crawler)
- self.pipe.download_func = _mocked_download_func
- self.pipe.open_spider(None)
- self.item = dict(image_urls=["http://picsum.photos/id/1014/200/300"], images=[])
-
- def _assert_method_called_with_warnings(self, method, message, warnings):
- self.assertIn(method, self.pipe._mockcalled)
- warningShown = False
- for warning in warnings:
- if (
- warning["message"] == message
- and warning["category"] == ScrapyDeprecationWarning
- ):
- warningShown = True
- self.assertTrue(warningShown)
-
- @inlineCallbacks
- def test_media_to_download_called(self):
- yield self.pipe.process_item(self.item, None)
- warnings = self.flushWarnings([MediaPipeline._compatible])
- message = (
- "media_to_download(self, request, info) is deprecated, "
- "please use media_to_download(self, request, info, *, item=None)"
- )
- self._assert_method_called_with_warnings("media_to_download", message, warnings)
-
- @inlineCallbacks
- def test_media_downloaded_called(self):
- yield self.pipe.process_item(self.item, None)
- warnings = self.flushWarnings([MediaPipeline._compatible])
- message = (
- "media_downloaded(self, response, request, info) is deprecated, "
- "please use media_downloaded(self, response, request, info, *, item=None)"
- )
- self._assert_method_called_with_warnings("media_downloaded", message, warnings)
-
- @inlineCallbacks
- def test_file_downloaded_called(self):
- yield self.pipe.process_item(self.item, None)
- warnings = self.flushWarnings([MediaPipeline._compatible])
- message = (
- "file_downloaded(self, response, request, info) is deprecated, "
- "please use file_downloaded(self, response, request, info, *, item=None)"
- )
- self._assert_method_called_with_warnings("file_downloaded", message, warnings)
-
- @inlineCallbacks
- def test_file_path_called(self):
- yield self.pipe.process_item(self.item, None)
- warnings = self.flushWarnings([MediaPipeline._compatible])
- message = (
- "file_path(self, request, response=None, info=None) is deprecated, "
- "please use file_path(self, request, response=None, info=None, *, item=None)"
- )
- self._assert_method_called_with_warnings("file_path", message, warnings)
-
- @inlineCallbacks
- def test_thumb_path_called(self):
- yield self.pipe.process_item(self.item, None)
- warnings = self.flushWarnings([MediaPipeline._compatible])
- message = (
- "thumb_path(self, request, thumb_id, response=None, info=None) is deprecated, "
- "please use thumb_path(self, request, thumb_id, response=None, info=None, *, item=None)"
- )
- self._assert_method_called_with_warnings("thumb_path", message, warnings)
-
- @inlineCallbacks
- def test_get_images_called(self):
- yield self.pipe.process_item(self.item, None)
- warnings = self.flushWarnings([MediaPipeline._compatible])
- message = (
- "get_images(self, response, request, info) is deprecated, "
- "please use get_images(self, response, request, info, *, item=None)"
- )
- self._assert_method_called_with_warnings("get_images", message, warnings)
-
- @inlineCallbacks
- def test_image_downloaded_called(self):
- yield self.pipe.process_item(self.item, None)
- warnings = self.flushWarnings([MediaPipeline._compatible])
- message = (
- "image_downloaded(self, response, request, info) is deprecated, "
- "please use image_downloaded(self, response, request, info, *, item=None)"
- )
- self._assert_method_called_with_warnings("image_downloaded", message, warnings)
-
-
class MediaPipelineAllowRedirectSettingsTestCase(unittest.TestCase):
def _assert_request_no3xx(self, pipeline_class, settings):
pipe = pipeline_class(settings=Settings(settings))
From 4ad727f0b566f2e50587e7bd2c9c691f10700daf Mon Sep 17 00:00:00 2001
From: Laerte Pereira
Date: Tue, 17 Oct 2023 19:47:01 -0300
Subject: [PATCH 0049/1041] Remove _check_signature
---
scrapy/pipelines/media.py | 18 ------------------
1 file changed, 18 deletions(-)
diff --git a/scrapy/pipelines/media.py b/scrapy/pipelines/media.py
index 34ab18404..75532034a 100644
--- a/scrapy/pipelines/media.py
+++ b/scrapy/pipelines/media.py
@@ -1,8 +1,6 @@
import functools
import logging
from collections import defaultdict
-from inspect import signature
-from warnings import warn
from twisted.internet.defer import Deferred, DeferredList
from twisted.python.failure import Failure
@@ -11,7 +9,6 @@ from scrapy.http.request import NO_CALLBACK
from scrapy.settings import Settings
from scrapy.utils.datatypes import SequenceExclude
from scrapy.utils.defer import defer_result, mustbe_deferred
-from scrapy.utils.deprecate import ScrapyDeprecationWarning
from scrapy.utils.log import failure_to_exc_info
from scrapy.utils.misc import arg_to_iter
@@ -123,21 +120,6 @@ class MediaPipeline:
)
return dfd.addBoth(lambda _: wad) # it must return wad at last
- def _check_signature(self, func):
- sig = signature(func)
- self._expects_item[func.__name__] = True
-
- if "item" not in sig.parameters:
- old_params = str(sig)[1:-1]
- new_params = old_params + ", *, item=None"
- warn(
- f"{func.__name__}(self, {old_params}) is deprecated, "
- f"please use {func.__name__}(self, {new_params})",
- ScrapyDeprecationWarning,
- stacklevel=2,
- )
- self._expects_item[func.__name__] = False
-
def _modify_media_request(self, request):
if self.handle_httpstatus_list:
request.meta["handle_httpstatus_list"] = self.handle_httpstatus_list
From a2b9351f04f0b9f81edbf3c3ebdf2ad1d5ba847e Mon Sep 17 00:00:00 2001
From: Laerte Pereira
Date: Tue, 17 Oct 2023 20:20:38 -0300
Subject: [PATCH 0050/1041] Remove support for using HttpAuthMiddleware without
http_auth_domain
---
scrapy/downloadermiddlewares/httpauth.py | 20 +------------
tests/test_downloadermiddleware_httpauth.py | 33 +--------------------
2 files changed, 2 insertions(+), 51 deletions(-)
diff --git a/scrapy/downloadermiddlewares/httpauth.py b/scrapy/downloadermiddlewares/httpauth.py
index de5a81388..5228db786 100644
--- a/scrapy/downloadermiddlewares/httpauth.py
+++ b/scrapy/downloadermiddlewares/httpauth.py
@@ -3,13 +3,10 @@ HTTP basic auth downloader middleware
See documentation in docs/topics/downloader-middleware.rst
"""
-import warnings
from w3lib.http import basic_auth_header
from scrapy import signals
-from scrapy.exceptions import ScrapyDeprecationWarning
-from scrapy.utils.httpobj import urlparse_cached
from scrapy.utils.url import url_is_from_any_domain
@@ -28,25 +25,10 @@ class HttpAuthMiddleware:
pwd = getattr(spider, "http_pass", "")
if usr or pwd:
self.auth = basic_auth_header(usr, pwd)
- if not hasattr(spider, "http_auth_domain"):
- warnings.warn(
- "Using HttpAuthMiddleware without http_auth_domain is deprecated and can cause security "
- "problems if the spider makes requests to several different domains. http_auth_domain "
- "will be set to the domain of the first request, please set it to the correct value "
- "explicitly.",
- category=ScrapyDeprecationWarning,
- )
- self.domain_unset = True
- else:
- self.domain = spider.http_auth_domain
- self.domain_unset = False
+ self.domain = spider.http_auth_domain
def process_request(self, request, spider):
auth = getattr(self, "auth", None)
if auth and b"Authorization" not in request.headers:
- domain = urlparse_cached(request).hostname
- if self.domain_unset:
- self.domain = domain
- self.domain_unset = False
if not self.domain or url_is_from_any_domain(request.url, [self.domain]):
request.headers[b"Authorization"] = auth
diff --git a/tests/test_downloadermiddleware_httpauth.py b/tests/test_downloadermiddleware_httpauth.py
index 6b79234d0..fc110e6cc 100644
--- a/tests/test_downloadermiddleware_httpauth.py
+++ b/tests/test_downloadermiddleware_httpauth.py
@@ -1,10 +1,8 @@
import unittest
-import pytest
from w3lib.http import basic_auth_header
from scrapy.downloadermiddlewares.httpauth import HttpAuthMiddleware
-from scrapy.exceptions import ScrapyDeprecationWarning
from scrapy.http import Request
from scrapy.spiders import Spider
@@ -31,39 +29,10 @@ class HttpAuthMiddlewareLegacyTest(unittest.TestCase):
self.spider = TestSpiderLegacy("foo")
def test_auth(self):
- with pytest.warns(
- ScrapyDeprecationWarning,
- match="Using HttpAuthMiddleware without http_auth_domain is deprecated",
- ):
+ with self.assertRaises(AttributeError):
mw = HttpAuthMiddleware()
mw.spider_opened(self.spider)
- # initial request, sets the domain and sends the header
- req = Request("http://example.com/")
- assert mw.process_request(req, self.spider) is None
- self.assertEqual(req.headers["Authorization"], basic_auth_header("foo", "bar"))
-
- # subsequent request to the same domain, should send the header
- req = Request("http://example.com/")
- assert mw.process_request(req, self.spider) is None
- self.assertEqual(req.headers["Authorization"], basic_auth_header("foo", "bar"))
-
- # subsequent request to a different domain, shouldn't send the header
- req = Request("http://example-noauth.com/")
- assert mw.process_request(req, self.spider) is None
- self.assertNotIn("Authorization", req.headers)
-
- def test_auth_already_set(self):
- with pytest.warns(
- ScrapyDeprecationWarning,
- match="Using HttpAuthMiddleware without http_auth_domain is deprecated",
- ):
- mw = HttpAuthMiddleware()
- mw.spider_opened(self.spider)
- req = Request("http://example.com/", headers=dict(Authorization="Digest 123"))
- assert mw.process_request(req, self.spider) is None
- self.assertEqual(req.headers["Authorization"], b"Digest 123")
-
class HttpAuthMiddlewareTest(unittest.TestCase):
def setUp(self):
From 3db438127cb4e13f02d16322b3a88a84ca4655db Mon Sep 17 00:00:00 2001
From: Laerte Pereira
Date: Tue, 17 Oct 2023 21:38:38 -0300
Subject: [PATCH 0051/1041] Remove support for HttpCompressionMiddleware
subclasses without stats
---
.../downloadermiddlewares/httpcompression.py | 15 +------------
...st_downloadermiddleware_httpcompression.py | 21 +++----------------
2 files changed, 4 insertions(+), 32 deletions(-)
diff --git a/scrapy/downloadermiddlewares/httpcompression.py b/scrapy/downloadermiddlewares/httpcompression.py
index ead426951..7b1d3f829 100644
--- a/scrapy/downloadermiddlewares/httpcompression.py
+++ b/scrapy/downloadermiddlewares/httpcompression.py
@@ -1,11 +1,9 @@
import io
-import warnings
import zlib
from scrapy.exceptions import NotConfigured
from scrapy.http import Response, TextResponse
from scrapy.responsetypes import responsetypes
-from scrapy.utils.deprecate import ScrapyDeprecationWarning
from scrapy.utils.gz import gunzip
ACCEPTED_ENCODINGS = [b"gzip", b"deflate"]
@@ -36,18 +34,7 @@ class HttpCompressionMiddleware:
def from_crawler(cls, crawler):
if not crawler.settings.getbool("COMPRESSION_ENABLED"):
raise NotConfigured
- try:
- return cls(stats=crawler.stats)
- except TypeError:
- warnings.warn(
- "HttpCompressionMiddleware subclasses must either modify "
- "their '__init__' method to support a 'stats' parameter or "
- "reimplement the 'from_crawler' method.",
- ScrapyDeprecationWarning,
- )
- result = cls()
- result.stats = crawler.stats
- return result
+ return cls(stats=crawler.stats)
def process_request(self, request, spider):
request.headers.setdefault("Accept-Encoding", b", ".join(ACCEPTED_ENCODINGS))
diff --git a/tests/test_downloadermiddleware_httpcompression.py b/tests/test_downloadermiddleware_httpcompression.py
index 9dad056de..fcbe50e2b 100644
--- a/tests/test_downloadermiddleware_httpcompression.py
+++ b/tests/test_downloadermiddleware_httpcompression.py
@@ -2,7 +2,6 @@ from gzip import GzipFile
from io import BytesIO
from pathlib import Path
from unittest import SkipTest, TestCase
-from warnings import catch_warnings
from w3lib.encoding import resolve_encoding
@@ -10,7 +9,7 @@ from scrapy.downloadermiddlewares.httpcompression import (
ACCEPTED_ENCODINGS,
HttpCompressionMiddleware,
)
-from scrapy.exceptions import NotConfigured, ScrapyDeprecationWarning
+from scrapy.exceptions import NotConfigured
from scrapy.http import HtmlResponse, Request, Response
from scrapy.responsetypes import responsetypes
from scrapy.spiders import Spider
@@ -381,20 +380,6 @@ class HttpCompressionSubclassTest(TestCase):
super().__init__()
crawler = get_crawler(Spider)
- with catch_warnings(record=True) as caught_warnings:
+
+ with self.assertRaises(TypeError):
HttpCompressionMiddlewareSubclass.from_crawler(crawler)
- messages = tuple(
- str(warning.message)
- for warning in caught_warnings
- if warning.category is ScrapyDeprecationWarning
- )
- self.assertEqual(
- messages,
- (
- (
- "HttpCompressionMiddleware subclasses must either modify "
- "their '__init__' method to support a 'stats' parameter "
- "or reimplement the 'from_crawler' method."
- ),
- ),
- )
From 644ab3af48a16f746f843a8390ff9e1593dc5ce0 Mon Sep 17 00:00:00 2001
From: Laerte Pereira
Date: Tue, 17 Oct 2023 22:39:09 -0300
Subject: [PATCH 0052/1041] Remove support for feed storage backends without
feed_options
---
scrapy/extensions/feedexport.py | 14 +---
tests/test_feedexport.py | 115 +++-----------------------------
2 files changed, 11 insertions(+), 118 deletions(-)
diff --git a/scrapy/extensions/feedexport.py b/scrapy/extensions/feedexport.py
index 4e846d1bd..fadbbb582 100644
--- a/scrapy/extensions/feedexport.py
+++ b/scrapy/extensions/feedexport.py
@@ -29,7 +29,7 @@ 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.python import get_func_args, without_none_values
+from scrapy.utils.python import without_none_values
logger = logging.getLogger(__name__)
@@ -42,17 +42,7 @@ except ImportError:
def build_storage(builder, uri, *args, feed_options=None, preargs=(), **kwargs):
- argument_names = get_func_args(builder)
- if "feed_options" in argument_names:
- kwargs["feed_options"] = feed_options
- else:
- warnings.warn(
- f"{builder.__qualname__} does not support the 'feed_options' keyword argument. Add a "
- "'feed_options' parameter to its signature to remove this "
- "warning. This parameter will become mandatory in a future "
- "version of Scrapy.",
- category=ScrapyDeprecationWarning,
- )
+ kwargs["feed_options"] = feed_options
return builder(*preargs, uri, *args, **kwargs)
diff --git a/tests/test_feedexport.py b/tests/test_feedexport.py
index 56967c0d5..62fcebde1 100644
--- a/tests/test_feedexport.py
+++ b/tests/test_feedexport.py
@@ -2850,20 +2850,8 @@ class StdoutFeedStoragePreFeedOptionsTest(unittest.TestCase):
"FEED_URI": "file:///tmp/foobar",
"FEED_STORAGES": {"file": StdoutFeedStorageWithoutFeedOptions},
}
- with pytest.warns(
- ScrapyDeprecationWarning,
- match="The `FEED_URI` and `FEED_FORMAT` settings have been deprecated",
- ):
- crawler = get_crawler(settings_dict=settings_dict)
- feed_exporter = FeedExporter.from_crawler(crawler)
-
- spider = scrapy.Spider("default")
- with pytest.warns(
- ScrapyDeprecationWarning,
- match="StdoutFeedStorageWithoutFeedOptions does not support "
- "the 'feed_options' keyword argument.",
- ):
- feed_exporter.open_spider(spider)
+ with pytest.raises(TypeError):
+ get_crawler(settings_dict=settings_dict)
class FileFeedStorageWithoutFeedOptions(FileFeedStorage):
@@ -2872,10 +2860,6 @@ class FileFeedStorageWithoutFeedOptions(FileFeedStorage):
class FileFeedStoragePreFeedOptionsTest(unittest.TestCase):
- """Make sure that any feed exporter created by users before the
- introduction of the ``feed_options`` parameter continues to work as
- expected, and simply issues a warning."""
-
maxDiff = None
def test_init(self):
@@ -2884,20 +2868,8 @@ class FileFeedStoragePreFeedOptionsTest(unittest.TestCase):
"FEED_URI": f"file:///{temp.name}",
"FEED_STORAGES": {"file": FileFeedStorageWithoutFeedOptions},
}
- with pytest.warns(
- ScrapyDeprecationWarning,
- match="The `FEED_URI` and `FEED_FORMAT` settings have been deprecated",
- ):
- crawler = get_crawler(settings_dict=settings_dict)
- feed_exporter = FeedExporter.from_crawler(crawler)
- spider = scrapy.Spider("default")
-
- with pytest.warns(
- ScrapyDeprecationWarning,
- match="FileFeedStorageWithoutFeedOptions does not support "
- "the 'feed_options' keyword argument.",
- ):
- feed_exporter.open_spider(spider)
+ with self.assertRaises(TypeError):
+ get_crawler(settings_dict=settings_dict)
class S3FeedStorageWithoutFeedOptions(S3FeedStorage):
@@ -2912,10 +2884,6 @@ class S3FeedStorageWithoutFeedOptionsWithFromCrawler(S3FeedStorage):
class S3FeedStoragePreFeedOptionsTest(unittest.TestCase):
- """Make sure that any feed exporter created by users before the
- introduction of the ``feed_options`` parameter continues to work as
- expected, and simply issues a warning."""
-
maxDiff = None
def setUp(self):
@@ -2936,34 +2904,15 @@ class S3FeedStoragePreFeedOptionsTest(unittest.TestCase):
spider = scrapy.Spider("default")
spider.crawler = crawler
- with pytest.warns(
- ScrapyDeprecationWarning,
- match="S3FeedStorageWithoutFeedOptions does not support "
- "the 'feed_options' keyword argument.",
- ):
- feed_exporter.open_spider(spider)
+ feed_exporter.open_spider(spider)
def test_from_crawler(self):
settings_dict = {
"FEED_URI": "file:///tmp/foobar",
"FEED_STORAGES": {"file": S3FeedStorageWithoutFeedOptionsWithFromCrawler},
}
- with pytest.warns(
- ScrapyDeprecationWarning,
- match="The `FEED_URI` and `FEED_FORMAT` settings have been deprecated",
- ):
- crawler = get_crawler(settings_dict=settings_dict)
- feed_exporter = FeedExporter.from_crawler(crawler)
-
- spider = scrapy.Spider("default")
- spider.crawler = crawler
-
- with pytest.warns(
- ScrapyDeprecationWarning,
- match="S3FeedStorageWithoutFeedOptionsWithFromCrawler.from_crawler does not support "
- "the 'feed_options' keyword argument.",
- ):
- feed_exporter.open_spider(spider)
+ with pytest.raises(TypeError):
+ get_crawler(settings_dict=settings_dict)
class FTPFeedStorageWithoutFeedOptions(FTPFeedStorage):
@@ -2971,17 +2920,7 @@ class FTPFeedStorageWithoutFeedOptions(FTPFeedStorage):
super().__init__(uri)
-class FTPFeedStorageWithoutFeedOptionsWithFromCrawler(FTPFeedStorage):
- @classmethod
- def from_crawler(cls, crawler, uri):
- return super().from_crawler(crawler, uri)
-
-
class FTPFeedStoragePreFeedOptionsTest(unittest.TestCase):
- """Make sure that any feed exporter created by users before the
- introduction of the ``feed_options`` parameter continues to work as
- expected, and simply issues a warning."""
-
maxDiff = None
def test_init(self):
@@ -2989,44 +2928,8 @@ class FTPFeedStoragePreFeedOptionsTest(unittest.TestCase):
"FEED_URI": "ftp://localhost/foo",
"FEED_STORAGES": {"ftp": FTPFeedStorageWithoutFeedOptions},
}
- with pytest.warns(
- ScrapyDeprecationWarning,
- match="The `FEED_URI` and `FEED_FORMAT` settings have been deprecated",
- ):
- crawler = get_crawler(settings_dict=settings_dict)
- feed_exporter = FeedExporter.from_crawler(crawler)
-
- spider = scrapy.Spider("default")
- spider.crawler = crawler
-
- with pytest.warns(
- ScrapyDeprecationWarning,
- match="FTPFeedStorageWithoutFeedOptions does not support "
- "the 'feed_options' keyword argument.",
- ):
- feed_exporter.open_spider(spider)
-
- def test_from_crawler(self):
- settings_dict = {
- "FEED_URI": "ftp://localhost/foo",
- "FEED_STORAGES": {"ftp": FTPFeedStorageWithoutFeedOptionsWithFromCrawler},
- }
- with pytest.warns(
- ScrapyDeprecationWarning,
- match="The `FEED_URI` and `FEED_FORMAT` settings have been deprecated",
- ):
- crawler = get_crawler(settings_dict=settings_dict)
- feed_exporter = FeedExporter.from_crawler(crawler)
-
- spider = scrapy.Spider("default")
- spider.crawler = crawler
-
- with pytest.warns(
- ScrapyDeprecationWarning,
- match="FTPFeedStorageWithoutFeedOptionsWithFromCrawler.from_crawler does not support "
- "the 'feed_options' keyword argument.",
- ):
- feed_exporter.open_spider(spider)
+ with pytest.raises(TypeError):
+ get_crawler(settings_dict=settings_dict)
class URIParamsTest:
From 0956b764657b9f1d414e196a339988d3951c49dc Mon Sep 17 00:00:00 2001
From: Laerte Pereira
Date: Tue, 17 Oct 2023 22:44:15 -0300
Subject: [PATCH 0053/1041] Remove support for dupefilters without a
fingerprinter
---
scrapy/dupefilters.py | 35 +++++------------------------------
1 file changed, 5 insertions(+), 30 deletions(-)
diff --git a/scrapy/dupefilters.py b/scrapy/dupefilters.py
index 0b20f53b9..dd2420e98 100644
--- a/scrapy/dupefilters.py
+++ b/scrapy/dupefilters.py
@@ -3,14 +3,12 @@ from __future__ import annotations
import logging
from pathlib import Path
from typing import TYPE_CHECKING, Optional, Set
-from warnings import warn
from twisted.internet.defer import Deferred
from scrapy.http.request import Request
from scrapy.settings import BaseSettings
from scrapy.spiders import Spider
-from scrapy.utils.deprecate import ScrapyDeprecationWarning
from scrapy.utils.job import job_dir
from scrapy.utils.request import (
RequestFingerprinter,
@@ -75,38 +73,15 @@ class RFPDupeFilter(BaseDupeFilter):
fingerprinter: Optional[RequestFingerprinterProtocol] = None,
) -> Self:
debug = settings.getbool("DUPEFILTER_DEBUG")
- try:
- return cls(job_dir(settings), debug, fingerprinter=fingerprinter)
- except TypeError:
- warn(
- "RFPDupeFilter subclasses must either modify their '__init__' "
- "method to support a 'fingerprinter' parameter or reimplement "
- "the 'from_settings' class method.",
- ScrapyDeprecationWarning,
- )
- result = cls(job_dir(settings), debug)
- result.fingerprinter = fingerprinter or RequestFingerprinter()
- return result
+ return cls(job_dir(settings), debug, fingerprinter=fingerprinter)
@classmethod
def from_crawler(cls, crawler: Crawler) -> Self:
assert crawler.request_fingerprinter
- try:
- return cls.from_settings(
- crawler.settings,
- fingerprinter=crawler.request_fingerprinter,
- )
- except TypeError:
- warn(
- "RFPDupeFilter subclasses must either modify their overridden "
- "'__init__' method and 'from_settings' class method to "
- "support a 'fingerprinter' parameter, or reimplement the "
- "'from_crawler' class method.",
- ScrapyDeprecationWarning,
- )
- result = cls.from_settings(crawler.settings)
- result.fingerprinter = crawler.request_fingerprinter
- return result
+ return cls.from_settings(
+ crawler.settings,
+ fingerprinter=crawler.request_fingerprinter,
+ )
def request_seen(self, request: Request) -> bool:
fp = self.request_fingerprint(request)
From 39ee8d1ee2decce995c4dafc5da5a6fb55f478c9 Mon Sep 17 00:00:00 2001
From: Andrey Rakhmatullin
Date: Wed, 18 Oct 2023 11:10:21 +0400
Subject: [PATCH 0054/1041] Deprecate ReppyRobotParser (#6099)
---
docs/topics/downloader-middleware.rst | 3 ++-
scrapy/robotstxt.py | 3 +++
2 files changed, 5 insertions(+), 1 deletion(-)
diff --git a/docs/topics/downloader-middleware.rst b/docs/topics/downloader-middleware.rst
index a8e5b23bf..1abbc4968 100644
--- a/docs/topics/downloader-middleware.rst
+++ b/docs/topics/downloader-middleware.rst
@@ -1039,8 +1039,8 @@ RobotsTxtMiddleware
* :ref:`Protego ` (default)
* :ref:`RobotFileParser `
- * :ref:`Reppy `
* :ref:`Robotexclusionrulesparser `
+ * :ref:`Reppy ` (deprecated)
You can change the robots.txt_ parser with the :setting:`ROBOTSTXT_PARSER`
setting. Or you can also :ref:`implement support for a new parser `.
@@ -1133,6 +1133,7 @@ In order to use this parser:
.. warning:: `Upstream issue #122
`_ prevents reppy usage in Python 3.9+.
+ Because of this the Reppy parser is deprecated.
* Set :setting:`ROBOTSTXT_PARSER` setting to
``scrapy.robotstxt.ReppyRobotParser``
diff --git a/scrapy/robotstxt.py b/scrapy/robotstxt.py
index 604b5e314..5c5ac4e41 100644
--- a/scrapy/robotstxt.py
+++ b/scrapy/robotstxt.py
@@ -1,7 +1,9 @@
import logging
import sys
from abc import ABCMeta, abstractmethod
+from warnings import warn
+from scrapy.exceptions import ScrapyDeprecationWarning
from scrapy.utils.python import to_unicode
logger = logging.getLogger(__name__)
@@ -79,6 +81,7 @@ class PythonRobotParser(RobotParser):
class ReppyRobotParser(RobotParser):
def __init__(self, robotstxt_body, spider):
+ warn("ReppyRobotParser is deprecated.", ScrapyDeprecationWarning, stacklevel=2)
from reppy.robots import Robots
self.spider = spider
From 38dbd4399361d6e3ac14cda40f4aee8d04204346 Mon Sep 17 00:00:00 2001
From: Laerte Pereira
Date: Wed, 18 Oct 2023 06:29:47 -0300
Subject: [PATCH 0055/1041] Remove tests not necessary anymore
---
...st_downloadermiddleware_httpcompression.py | 12 ---
tests/test_feedexport.py | 97 -------------------
2 files changed, 109 deletions(-)
diff --git a/tests/test_downloadermiddleware_httpcompression.py b/tests/test_downloadermiddleware_httpcompression.py
index fcbe50e2b..a96b710f3 100644
--- a/tests/test_downloadermiddleware_httpcompression.py
+++ b/tests/test_downloadermiddleware_httpcompression.py
@@ -371,15 +371,3 @@ class HttpCompressionTest(TestCase):
self.assertEqual(response.body, b"")
self.assertStatsEqual("httpcompression/response_count", None)
self.assertStatsEqual("httpcompression/response_bytes", None)
-
-
-class HttpCompressionSubclassTest(TestCase):
- def test_init_missing_stats(self):
- class HttpCompressionMiddlewareSubclass(HttpCompressionMiddleware):
- def __init__(self):
- super().__init__()
-
- crawler = get_crawler(Spider)
-
- with self.assertRaises(TypeError):
- HttpCompressionMiddlewareSubclass.from_crawler(crawler)
diff --git a/tests/test_feedexport.py b/tests/test_feedexport.py
index 62fcebde1..89169fd7c 100644
--- a/tests/test_feedexport.py
+++ b/tests/test_feedexport.py
@@ -2835,103 +2835,6 @@ class FeedExportInitTest(unittest.TestCase):
self.assertIsInstance(exporter, FeedExporter)
-class StdoutFeedStorageWithoutFeedOptions(StdoutFeedStorage):
- def __init__(self, uri):
- super().__init__(uri)
-
-
-class StdoutFeedStoragePreFeedOptionsTest(unittest.TestCase):
- """Make sure that any feed exporter created by users before the
- introduction of the ``feed_options`` parameter continues to work as
- expected, and simply issues a warning."""
-
- def test_init(self):
- settings_dict = {
- "FEED_URI": "file:///tmp/foobar",
- "FEED_STORAGES": {"file": StdoutFeedStorageWithoutFeedOptions},
- }
- with pytest.raises(TypeError):
- get_crawler(settings_dict=settings_dict)
-
-
-class FileFeedStorageWithoutFeedOptions(FileFeedStorage):
- def __init__(self, uri):
- super().__init__(uri)
-
-
-class FileFeedStoragePreFeedOptionsTest(unittest.TestCase):
- maxDiff = None
-
- def test_init(self):
- with tempfile.NamedTemporaryFile() as temp:
- settings_dict = {
- "FEED_URI": f"file:///{temp.name}",
- "FEED_STORAGES": {"file": FileFeedStorageWithoutFeedOptions},
- }
- with self.assertRaises(TypeError):
- get_crawler(settings_dict=settings_dict)
-
-
-class S3FeedStorageWithoutFeedOptions(S3FeedStorage):
- def __init__(self, uri, access_key, secret_key, acl, endpoint_url, **kwargs):
- super().__init__(uri, access_key, secret_key, acl, endpoint_url, **kwargs)
-
-
-class S3FeedStorageWithoutFeedOptionsWithFromCrawler(S3FeedStorage):
- @classmethod
- def from_crawler(cls, crawler, uri):
- return super().from_crawler(crawler, uri)
-
-
-class S3FeedStoragePreFeedOptionsTest(unittest.TestCase):
- maxDiff = None
-
- def setUp(self):
- skip_if_no_boto()
-
- def test_init(self):
- settings_dict = {
- "FEED_URI": "file:///tmp/foobar",
- "FEED_STORAGES": {"file": S3FeedStorageWithoutFeedOptions},
- }
- with pytest.warns(
- ScrapyDeprecationWarning,
- match="The `FEED_URI` and `FEED_FORMAT` settings have been deprecated",
- ):
- crawler = get_crawler(settings_dict=settings_dict)
- feed_exporter = FeedExporter.from_crawler(crawler)
-
- spider = scrapy.Spider("default")
- spider.crawler = crawler
-
- feed_exporter.open_spider(spider)
-
- def test_from_crawler(self):
- settings_dict = {
- "FEED_URI": "file:///tmp/foobar",
- "FEED_STORAGES": {"file": S3FeedStorageWithoutFeedOptionsWithFromCrawler},
- }
- with pytest.raises(TypeError):
- get_crawler(settings_dict=settings_dict)
-
-
-class FTPFeedStorageWithoutFeedOptions(FTPFeedStorage):
- def __init__(self, uri, use_active_mode=False):
- super().__init__(uri)
-
-
-class FTPFeedStoragePreFeedOptionsTest(unittest.TestCase):
- maxDiff = None
-
- def test_init(self):
- settings_dict = {
- "FEED_URI": "ftp://localhost/foo",
- "FEED_STORAGES": {"ftp": FTPFeedStorageWithoutFeedOptions},
- }
- with pytest.raises(TypeError):
- get_crawler(settings_dict=settings_dict)
-
-
class URIParamsTest:
spider_name = "uri_params_spider"
deprecated_options = False
From cc9c415bf3312e2c23288dfeb5f977a00874d0a2 Mon Sep 17 00:00:00 2001
From: Andrey Rakhmatullin
Date: Wed, 18 Oct 2023 18:21:34 +0400
Subject: [PATCH 0056/1041] Disable ipython for InteractiveShellTest.
---
tests/test_command_shell.py | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
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:
From 6b0c18e921c046d1c14106c629243e96d6cd60f0 Mon Sep 17 00:00:00 2001
From: Andrey Rakhmatullin
Date: Thu, 19 Oct 2023 23:13:47 +0400
Subject: [PATCH 0057/1041] Improve signal sending in test_shutdown_forced.
---
tests/test_crawler.py | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/tests/test_crawler.py b/tests/test_crawler.py
index 5c11ca6e0..0a7f9bac8 100644
--- a/tests/test_crawler.py
+++ b/tests/test_crawler.py
@@ -534,7 +534,10 @@ 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", "-a", "sleep=10")
p = PopenSpawn(args, timeout=5)
@@ -542,6 +545,10 @@ class CrawlerProcessSubprocess(ScriptRunnerMixin, unittest.TestCase):
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 1f797d0fdb2615af31a125e343ebbae664d4b238 Mon Sep 17 00:00:00 2001
From: Chenwei Niu <61908960+Chenwei-Niu@users.noreply.github.com>
Date: Mon, 30 Oct 2023 19:59:11 +1100
Subject: [PATCH 0058/1041] Removed some deprecated functions and
functionalities (#6116)
---
scrapy/utils/log.py | 13 +---------
scrapy/utils/response.py | 20 ---------------
tests/test_downloadermiddleware_stats.py | 23 -----------------
tests/test_utils_response.py | 32 ------------------------
4 files changed, 1 insertion(+), 87 deletions(-)
diff --git a/scrapy/utils/log.py b/scrapy/utils/log.py
index fdea46a3d..2a38f151a 100644
--- a/scrapy/utils/log.py
+++ b/scrapy/utils/log.py
@@ -2,7 +2,6 @@ from __future__ import annotations
import logging
import sys
-import warnings
from logging.config import dictConfig
from types import TracebackType
from typing import TYPE_CHECKING, Any, List, Optional, Tuple, Type, Union, cast
@@ -11,7 +10,6 @@ from twisted.python import log as twisted_log
from twisted.python.failure import Failure
import scrapy
-from scrapy.exceptions import ScrapyDeprecationWarning
from scrapy.settings import Settings
from scrapy.utils.versions import scrapy_components_versions
@@ -232,18 +230,9 @@ def logformatter_adapter(logkws: dict) -> Tuple[int, str, dict]:
and adapts it into a tuple of positional arguments for logger.log calls,
handling backward compatibility as well.
"""
- if not {"level", "msg", "args"} <= set(logkws):
- warnings.warn("Missing keys in LogFormatter method", ScrapyDeprecationWarning)
-
- if "format" in logkws:
- warnings.warn(
- "`format` key in LogFormatter methods has been "
- "deprecated, use `msg` instead",
- ScrapyDeprecationWarning,
- )
level = logkws.get("level", logging.INFO)
- message = logkws.get("format", logkws.get("msg"))
+ message = logkws.get("msg") or ""
# NOTE: This also handles 'args' being an empty dict, that case doesn't
# play well in logger.log calls
args = logkws if not logkws.get("args") else logkws["args"]
diff --git a/scrapy/utils/response.py b/scrapy/utils/response.py
index c540d6278..77d54aff9 100644
--- a/scrapy/utils/response.py
+++ b/scrapy/utils/response.py
@@ -14,7 +14,6 @@ from w3lib import html
import scrapy
from scrapy.http.response import Response
-from scrapy.utils.decorators import deprecated
from scrapy.utils.python import to_bytes, to_unicode
_baseurl_cache: "WeakKeyDictionary[Response, str]" = WeakKeyDictionary()
@@ -55,25 +54,6 @@ def response_status_message(status: Union[bytes, float, int, str]) -> str:
return f"{status_int} {to_unicode(message)}"
-@deprecated
-def response_httprepr(response: Response) -> bytes:
- """Return raw HTTP representation (as bytes) of the given response. This
- is provided only for reference, since it's not the exact stream of bytes
- that was received (that's not exposed by Twisted).
- """
- values = [
- b"HTTP/1.1 ",
- to_bytes(str(response.status)),
- b" ",
- to_bytes(http.RESPONSES.get(response.status, b"")),
- b"\r\n",
- ]
- if response.headers:
- values.extend([response.headers.to_string(), b"\r\n"])
- values.extend([b"\r\n", response.body])
- return b"".join(values)
-
-
def open_in_browser(
response: Union[
"scrapy.http.response.html.HtmlResponse",
diff --git a/tests/test_downloadermiddleware_stats.py b/tests/test_downloadermiddleware_stats.py
index 39dfe9ab5..5b7181848 100644
--- a/tests/test_downloadermiddleware_stats.py
+++ b/tests/test_downloadermiddleware_stats.py
@@ -1,12 +1,8 @@
-import warnings
-from itertools import product
from unittest import TestCase
from scrapy.downloadermiddlewares.stats import DownloaderStats
-from scrapy.exceptions import ScrapyDeprecationWarning
from scrapy.http import Request, Response
from scrapy.spiders import Spider
-from scrapy.utils.response import response_httprepr
from scrapy.utils.test import get_crawler
@@ -40,25 +36,6 @@ class TestDownloaderStats(TestCase):
self.mw.process_response(self.req, self.res, self.spider)
self.assertStatsEqual("downloader/response_count", 1)
- def test_response_len(self):
- body = (b"", b"not_empty") # empty/notempty body
- headers = (
- {},
- {"lang": "en"},
- {"lang": "en", "User-Agent": "scrapy"},
- ) # 0 headers, 1h and 2h
- test_responses = [ # form test responses with all combinations of body/headers
- Response(url="scrapytest.org", status=200, body=r[0], headers=r[1])
- for r in product(body, headers)
- ]
- for test_response in test_responses:
- self.crawler.stats.set_value("downloader/response_bytes", 0)
- self.mw.process_response(self.req, test_response, self.spider)
- with warnings.catch_warnings():
- warnings.simplefilter("ignore", ScrapyDeprecationWarning)
- resp_size = len(response_httprepr(test_response))
- self.assertStatsEqual("downloader/response_bytes", resp_size)
-
def test_process_exception(self):
self.mw.process_exception(self.req, MyException(), self.spider)
self.assertStatsEqual("downloader/exception_count", 1)
diff --git a/tests/test_utils_response.py b/tests/test_utils_response.py
index 80e15a60f..661fb47a3 100644
--- a/tests/test_utils_response.py
+++ b/tests/test_utils_response.py
@@ -1,16 +1,13 @@
import unittest
-import warnings
from pathlib import Path
from urllib.parse import urlparse
-from scrapy.exceptions import ScrapyDeprecationWarning
from scrapy.http import HtmlResponse, Response, TextResponse
from scrapy.utils.python import to_bytes
from scrapy.utils.response import (
get_base_url,
get_meta_refresh,
open_in_browser,
- response_httprepr,
response_status_message,
)
@@ -20,35 +17,6 @@ __doctests__ = ["scrapy.utils.response"]
class ResponseUtilsTest(unittest.TestCase):
dummy_response = TextResponse(url="http://example.org/", body=b"dummy_response")
- def test_response_httprepr(self):
- with warnings.catch_warnings():
- warnings.simplefilter("ignore", ScrapyDeprecationWarning)
-
- r1 = Response("http://www.example.com")
- self.assertEqual(response_httprepr(r1), b"HTTP/1.1 200 OK\r\n\r\n")
-
- r1 = Response(
- "http://www.example.com",
- status=404,
- headers={"Content-type": "text/html"},
- body=b"Some body",
- )
- self.assertEqual(
- response_httprepr(r1),
- b"HTTP/1.1 404 Not Found\r\nContent-Type: text/html\r\n\r\nSome body",
- )
-
- r1 = Response(
- "http://www.example.com",
- status=6666,
- headers={"Content-type": "text/html"},
- body=b"Some body",
- )
- self.assertEqual(
- response_httprepr(r1),
- b"HTTP/1.1 6666 \r\nContent-Type: text/html\r\n\r\nSome body",
- )
-
def test_open_in_browser(self):
url = "http:///www.example.com/some/page.html"
body = b" test page test body "
From 8dff9633d0b80aff0c6f481e395755f3c8985f27 Mon Sep 17 00:00:00 2001
From: andy53
Date: Mon, 30 Oct 2023 21:40:26 -0600
Subject: [PATCH 0059/1041] added_extensions
---
scrapy/linkextractors/__init__.py | 10 ++++++++++
1 file changed, 10 insertions(+)
diff --git a/scrapy/linkextractors/__init__.py b/scrapy/linkextractors/__init__.py
index 6b8be909e..3774430a7 100644
--- a/scrapy/linkextractors/__init__.py
+++ b/scrapy/linkextractors/__init__.py
@@ -96,6 +96,16 @@ IGNORED_EXTENSIONS = [
"dmg",
"iso",
"apk",
+ "jar",
+ "sh",
+ "rb",
+ "js",
+ "hta",
+ "bat",
+ "cpl",
+ "msi",
+ "msp",
+ "py",
]
From 04024f1e796f99e77dda544396722fb9203f1d49 Mon Sep 17 00:00:00 2001
From: nihilisticneuralnet
<138315505+nihilisticneuralnet@users.noreply.github.com>
Date: Tue, 31 Oct 2023 19:07:26 +0530
Subject: [PATCH 0060/1041] [Solved] JOBDIR= None for when Scheduler
initializes disk queue even if JOBDIR is empty string (#6124)
Co-authored-by: John Doe
---
docs/topics/settings.rst | 2 +-
scrapy/core/scheduler.py | 2 +-
scrapy/settings/default_settings.py | 2 ++
scrapy/utils/job.py | 6 ++++--
4 files changed, 8 insertions(+), 4 deletions(-)
diff --git a/docs/topics/settings.rst b/docs/topics/settings.rst
index 7cdfb8768..06f8481ba 100644
--- a/docs/topics/settings.rst
+++ b/docs/topics/settings.rst
@@ -1120,7 +1120,7 @@ modify this setting in your project, modify :setting:`ITEM_PIPELINES` instead.
JOBDIR
------
-Default: ``''``
+Default: ``None``
A string indicating the directory for storing the state of a crawl when
:ref:`pausing and resuming crawls `.
diff --git a/scrapy/core/scheduler.py b/scrapy/core/scheduler.py
index 70b6dc8a1..17c95f1ea 100644
--- a/scrapy/core/scheduler.py
+++ b/scrapy/core/scheduler.py
@@ -352,7 +352,7 @@ class Scheduler(BaseScheduler):
def _dqdir(self, jobdir: Optional[str]) -> Optional[str]:
"""Return a folder name to keep disk queue state at"""
- if jobdir is not None:
+ if jobdir:
dqdir = Path(jobdir, "requests.queue")
if not dqdir.exists():
dqdir.mkdir(parents=True)
diff --git a/scrapy/settings/default_settings.py b/scrapy/settings/default_settings.py
index fa06e5ee8..d6b3585e2 100644
--- a/scrapy/settings/default_settings.py
+++ b/scrapy/settings/default_settings.py
@@ -206,6 +206,8 @@ ITEM_PROCESSOR = "scrapy.pipelines.ItemPipelineManager"
ITEM_PIPELINES = {}
ITEM_PIPELINES_BASE = {}
+JOBDIR = None
+
LOG_ENABLED = True
LOG_ENCODING = "utf-8"
LOG_FORMATTER = "scrapy.logformatter.LogFormatter"
diff --git a/scrapy/utils/job.py b/scrapy/utils/job.py
index c49f7d758..e230e4235 100644
--- a/scrapy/utils/job.py
+++ b/scrapy/utils/job.py
@@ -5,7 +5,9 @@ from scrapy.settings import BaseSettings
def job_dir(settings: BaseSettings) -> Optional[str]:
- path: str = settings["JOBDIR"]
- if path and not Path(path).exists():
+ path: Optional[str] = settings["JOBDIR"]
+ if not path:
+ return None
+ if not Path(path).exists():
Path(path).mkdir(parents=True)
return path
From 732557e6988dd3d1df26f973e3ae651f7bcbdc5b Mon Sep 17 00:00:00 2001
From: Andrey Rakhmatullin
Date: Tue, 31 Oct 2023 22:34:43 +0400
Subject: [PATCH 0061/1041] Full typing for scrapy/http/request.
---
scrapy/http/common.py | 7 +++--
scrapy/http/request/__init__.py | 47 ++++++++++++++++-------------
scrapy/http/request/form.py | 27 +++++++++++------
scrapy/http/request/json_request.py | 8 +++--
scrapy/http/request/rpc.py | 4 +--
5 files changed, 55 insertions(+), 38 deletions(-)
diff --git a/scrapy/http/common.py b/scrapy/http/common.py
index bc8861574..a3d9d5b81 100644
--- a/scrapy/http/common.py
+++ b/scrapy/http/common.py
@@ -1,5 +1,8 @@
-def obsolete_setter(setter, attrname):
- def newsetter(self, value):
+from typing import Any, Callable, NoReturn
+
+
+def obsolete_setter(setter: Callable, attrname: str) -> Callable[[Any, Any], NoReturn]:
+ def newsetter(self: Any, value: Any) -> NoReturn:
c = self.__class__.__name__
msg = f"{c}.{attrname} is not modifiable, use {c}.replace() instead"
raise AttributeError(msg)
diff --git a/scrapy/http/request/__init__.py b/scrapy/http/request/__init__.py
index 06a52f5fd..7c9a4ba95 100644
--- a/scrapy/http/request/__init__.py
+++ b/scrapy/http/request/__init__.py
@@ -9,14 +9,17 @@ from typing import (
Any,
AnyStr,
Callable,
+ Dict,
Iterable,
List,
Mapping,
+ NoReturn,
Optional,
Tuple,
Type,
TypeVar,
Union,
+ cast,
)
from w3lib.url import safe_url_string
@@ -32,7 +35,7 @@ from scrapy.utils.url import escape_ajax
RequestTypeVar = TypeVar("RequestTypeVar", bound="Request")
-def NO_CALLBACK(*args, **kwargs):
+def NO_CALLBACK(*args: Any, **kwargs: Any) -> NoReturn:
"""When assigned to the ``callback`` parameter of
:class:`~scrapy.http.Request`, it indicates that the request is not meant
to have a spider callback at all.
@@ -92,21 +95,21 @@ class Request(object_ref):
headers: Union[Mapping[AnyStr, Any], Iterable[Tuple[AnyStr, Any]], None] = None,
body: Optional[Union[bytes, str]] = None,
cookies: Optional[Union[dict, List[dict]]] = None,
- meta: Optional[dict] = None,
+ meta: Optional[Dict[str, Any]] = None,
encoding: str = "utf-8",
priority: int = 0,
dont_filter: bool = False,
errback: Optional[Callable] = None,
flags: Optional[List[str]] = None,
- cb_kwargs: Optional[dict] = None,
+ cb_kwargs: Optional[Dict[str, Any]] = None,
) -> None:
- self._encoding = encoding # this one has to be set first
- self.method = str(method).upper()
+ self._encoding: str = encoding # this one has to be set first
+ self.method: str = str(method).upper()
self._set_url(url)
self._set_body(body)
if not isinstance(priority, int):
raise TypeError(f"Request priority not an integer: {priority!r}")
- self.priority = priority
+ self.priority: int = priority
if not (callable(callback) or callback is None):
raise TypeError(
@@ -114,25 +117,27 @@ class Request(object_ref):
)
if not (callable(errback) or errback is None):
raise TypeError(f"errback must be a callable, got {type(errback).__name__}")
- self.callback = callback
- self.errback = errback
+ self.callback: Optional[Callable] = callback
+ self.errback: Optional[Callable] = errback
- self.cookies = cookies or {}
- self.headers = Headers(headers or {}, encoding=encoding)
- self.dont_filter = dont_filter
+ self.cookies: Union[dict, List[dict]] = cookies or {}
+ self.headers: Headers = Headers(headers or {}, encoding=encoding)
+ self.dont_filter: bool = dont_filter
- self._meta = dict(meta) if meta else None
- self._cb_kwargs = dict(cb_kwargs) if cb_kwargs else None
- self.flags = [] if flags is None else list(flags)
+ self._meta: Optional[Dict[str, Any]] = dict(meta) if meta else None
+ self._cb_kwargs: Optional[Dict[str, Any]] = (
+ dict(cb_kwargs) if cb_kwargs else None
+ )
+ self.flags: List[str] = [] if flags is None else list(flags)
@property
- def cb_kwargs(self) -> dict:
+ def cb_kwargs(self) -> Dict[str, Any]:
if self._cb_kwargs is None:
self._cb_kwargs = {}
return self._cb_kwargs
@property
- def meta(self) -> dict:
+ def meta(self) -> Dict[str, Any]:
if self._meta is None:
self._meta = {}
return self._meta
@@ -174,19 +179,19 @@ class Request(object_ref):
def copy(self) -> "Request":
return self.replace()
- def replace(self, *args, **kwargs) -> "Request":
+ def replace(self, *args: Any, **kwargs: Any) -> "Request":
"""Create a new Request with the same attributes except for those given new values"""
for x in self.attributes:
kwargs.setdefault(x, getattr(self, x))
cls = kwargs.pop("cls", self.__class__)
- return cls(*args, **kwargs)
+ return cast(Request, cls(*args, **kwargs))
@classmethod
def from_curl(
cls: Type[RequestTypeVar],
curl_command: str,
ignore_unknown_options: bool = True,
- **kwargs,
+ **kwargs: Any,
) -> RequestTypeVar:
"""Create a Request object from a string containing a `cURL
`_ command. It populates the HTTP method, the
@@ -219,7 +224,7 @@ class Request(object_ref):
request_kwargs.update(kwargs)
return cls(**request_kwargs)
- def to_dict(self, *, spider: Optional["scrapy.Spider"] = None) -> dict:
+ def to_dict(self, *, spider: Optional["scrapy.Spider"] = None) -> Dict[str, Any]:
"""Return a dictionary containing the Request's data.
Use :func:`~scrapy.utils.request.request_from_dict` to convert back into a :class:`~scrapy.Request` object.
@@ -244,7 +249,7 @@ class Request(object_ref):
return d
-def _find_method(obj, func):
+def _find_method(obj: Any, func: Callable) -> str:
"""Helper function for Request.to_dict"""
# Only instance methods contain ``__func__``
if obj and hasattr(func, "__func__"):
diff --git a/scrapy/http/request/form.py b/scrapy/http/request/form.py
index 8da779b7c..0f80a0ab7 100644
--- a/scrapy/http/request/form.py
+++ b/scrapy/http/request/form.py
@@ -5,7 +5,9 @@ This module implements the FormRequest class which is a more convenient class
See documentation in docs/topics/request-response.rst
"""
-from typing import Iterable, List, Optional, Tuple, Type, TypeVar, Union, cast
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Any, Iterable, List, Optional, Tuple, Union, cast
from urllib.parse import urlencode, urljoin, urlsplit, urlunsplit
from lxml.html import (
@@ -24,7 +26,10 @@ from scrapy.http.response.text import TextResponse
from scrapy.utils.python import is_listlike, to_bytes
from scrapy.utils.response import get_base_url
-FormRequestTypeVar = TypeVar("FormRequestTypeVar", bound="FormRequest")
+if TYPE_CHECKING:
+ # typing.Self requires Python 3.11
+ from typing_extensions import Self
+
FormdataKVType = Tuple[str, Union[str, Iterable[str]]]
FormdataType = Optional[Union[dict, List[FormdataKVType]]]
@@ -33,7 +38,9 @@ FormdataType = Optional[Union[dict, List[FormdataKVType]]]
class FormRequest(Request):
valid_form_methods = ["GET", "POST"]
- def __init__(self, *args, formdata: FormdataType = None, **kwargs) -> None:
+ def __init__(
+ self, *args: Any, formdata: FormdataType = None, **kwargs: Any
+ ) -> None:
if formdata and kwargs.get("method") is None:
kwargs["method"] = "POST"
@@ -54,7 +61,7 @@ class FormRequest(Request):
@classmethod
def from_response(
- cls: Type[FormRequestTypeVar],
+ cls,
response: TextResponse,
formname: Optional[str] = None,
formid: Optional[str] = None,
@@ -64,8 +71,8 @@ class FormRequest(Request):
dont_click: bool = False,
formxpath: Optional[str] = None,
formcss: Optional[str] = None,
- **kwargs,
- ) -> FormRequestTypeVar:
+ **kwargs: Any,
+ ) -> Self:
kwargs.setdefault("encoding", response.encoding)
if formcss is not None:
@@ -121,12 +128,12 @@ def _get_form(
if formname is not None:
f = root.xpath(f'//form[@name="{formname}"]')
if f:
- return f[0]
+ return cast(FormElement, f[0])
if formid is not None:
f = root.xpath(f'//form[@id="{formid}"]')
if f:
- return f[0]
+ return cast(FormElement, f[0])
# Get form element from xpath, if not found, go up
if formxpath is not None:
@@ -135,7 +142,7 @@ def _get_form(
el = nodes[0]
while True:
if el.tag == "form":
- return el
+ return cast(FormElement, el)
el = el.getparent()
if el is None:
break
@@ -147,7 +154,7 @@ def _get_form(
except IndexError:
raise IndexError(f"Form number {formnumber} not found in {response}")
else:
- return form
+ return cast(FormElement, form)
def _get_inputs(
diff --git a/scrapy/http/request/json_request.py b/scrapy/http/request/json_request.py
index f52c0401d..1dd9e6c87 100644
--- a/scrapy/http/request/json_request.py
+++ b/scrapy/http/request/json_request.py
@@ -8,7 +8,7 @@ See documentation in docs/topics/request-response.rst
import copy
import json
import warnings
-from typing import Optional, Tuple
+from typing import Any, Optional, Tuple
from scrapy.http.request import Request
@@ -16,7 +16,9 @@ from scrapy.http.request import Request
class JsonRequest(Request):
attributes: Tuple[str, ...] = Request.attributes + ("dumps_kwargs",)
- def __init__(self, *args, dumps_kwargs: Optional[dict] = None, **kwargs) -> None:
+ def __init__(
+ self, *args: Any, dumps_kwargs: Optional[dict] = None, **kwargs: Any
+ ) -> None:
dumps_kwargs = copy.deepcopy(dumps_kwargs) if dumps_kwargs is not None else {}
dumps_kwargs.setdefault("sort_keys", True)
self._dumps_kwargs = dumps_kwargs
@@ -42,7 +44,7 @@ class JsonRequest(Request):
def dumps_kwargs(self) -> dict:
return self._dumps_kwargs
- def replace(self, *args, **kwargs) -> Request:
+ def replace(self, *args: Any, **kwargs: Any) -> Request:
body_passed = kwargs.get("body", None) is not None
data = kwargs.pop("data", None)
data_passed = data is not None
diff --git a/scrapy/http/request/rpc.py b/scrapy/http/request/rpc.py
index 43692923b..bde860a66 100644
--- a/scrapy/http/request/rpc.py
+++ b/scrapy/http/request/rpc.py
@@ -5,7 +5,7 @@ This module implements the XmlRpcRequest class which is a more convenient class
See documentation in docs/topics/request-response.rst
"""
import xmlrpc.client as xmlrpclib
-from typing import Optional
+from typing import Any, Optional
from scrapy.http.request import Request
from scrapy.utils.python import get_func_args
@@ -14,7 +14,7 @@ DUMPS_ARGS = get_func_args(xmlrpclib.dumps)
class XmlRpcRequest(Request):
- def __init__(self, *args, encoding: Optional[str] = None, **kwargs):
+ def __init__(self, *args: Any, encoding: Optional[str] = None, **kwargs: Any):
if "body" not in kwargs and "params" in kwargs:
kw = dict((k, kwargs.pop(k)) for k in DUMPS_ARGS if k in kwargs)
kwargs["body"] = xmlrpclib.dumps(**kw)
From 4cb2fc2c3b9deba49ae3a32400819ed95ea262c9 Mon Sep 17 00:00:00 2001
From: Andrey Rakhmatullin
Date: Tue, 31 Oct 2023 22:55:39 +0400
Subject: [PATCH 0062/1041] Update typing package versions.
---
scrapy/utils/ssl.py | 2 +-
tox.ini | 10 +++++-----
2 files changed, 6 insertions(+), 6 deletions(-)
diff --git a/scrapy/utils/ssl.py b/scrapy/utils/ssl.py
index d520ef809..e74769c65 100644
--- a/scrapy/utils/ssl.py
+++ b/scrapy/utils/ssl.py
@@ -1,6 +1,6 @@
from typing import Any, Optional
-import OpenSSL._util as pyOpenSSLutil
+import OpenSSL._util as pyOpenSSLutil # type: ignore[import-untyped]
import OpenSSL.SSL
import OpenSSL.version
from OpenSSL.crypto import X509Name
diff --git a/tox.ini b/tox.ini
index 381da9773..932c0b805 100644
--- a/tox.ini
+++ b/tox.ini
@@ -33,13 +33,13 @@ install_command =
[testenv:typing]
basepython = python3
deps =
- mypy==1.5.1
- typing-extensions==4.7.1
+ mypy==1.6.1
+ typing-extensions==4.8.0
types-attrs==19.1.0
- types-lxml==2023.3.28
- types-Pillow==10.0.0.3
+ types-lxml==2023.10.21
+ types-Pillow==10.1.0.0
types-Pygments==2.16.0.0
- types-pyOpenSSL==23.2.0.2
+ types-pyOpenSSL==23.3.0.0
types-setuptools==68.2.0.0
# 2.1.2 fixes a typing bug: https://github.com/scrapy/w3lib/pull/211
w3lib >= 2.1.2
From 01d9d28324cbd5c7edff0d25ba00d8491400698a Mon Sep 17 00:00:00 2001
From: Andrey Rakhmatullin
Date: Tue, 31 Oct 2023 23:14:00 +0400
Subject: [PATCH 0063/1041] Full typing for scrapy/http/response/__init__.py.
---
scrapy/core/downloader/handlers/datauri.py | 9 +-
scrapy/core/scraper.py | 1 +
scrapy/http/response/__init__.py | 142 ++++++++++++---------
scrapy/http/response/text.py | 16 +--
tests/test_http2_client_protocol.py | 1 +
5 files changed, 98 insertions(+), 71 deletions(-)
diff --git a/scrapy/core/downloader/handlers/datauri.py b/scrapy/core/downloader/handlers/datauri.py
index 8b78c53c1..a7ae56a85 100644
--- a/scrapy/core/downloader/handlers/datauri.py
+++ b/scrapy/core/downloader/handlers/datauri.py
@@ -1,6 +1,9 @@
+from typing import Any, Dict
+
from w3lib.url import parse_data_uri
-from scrapy.http import TextResponse
+from scrapy import Request, Spider
+from scrapy.http import Response, TextResponse
from scrapy.responsetypes import responsetypes
from scrapy.utils.decorators import defers
@@ -9,11 +12,11 @@ class DataURIDownloadHandler:
lazy = False
@defers
- def download_request(self, request, spider):
+ def download_request(self, request: Request, spider: Spider) -> Response:
uri = parse_data_uri(request.url)
respcls = responsetypes.from_mimetype(uri.media_type)
- resp_kwargs = {}
+ resp_kwargs: Dict[str, Any] = {}
if issubclass(respcls, TextResponse) and uri.media_type.split("/")[0] == "text":
charset = uri.media_type_parameters.get("charset")
resp_kwargs["encoding"] = charset
diff --git a/scrapy/core/scraper.py b/scrapy/core/scraper.py
index b2c26507c..8fb16b8a9 100644
--- a/scrapy/core/scraper.py
+++ b/scrapy/core/scraper.py
@@ -206,6 +206,7 @@ class Scraper:
if isinstance(result, Response):
if getattr(result, "request", None) is None:
result.request = request
+ assert result.request
callback = result.request.callback or spider._parse
warn_on_generator_with_return_value(spider, callback)
dfd = defer_succeed(result)
diff --git a/scrapy/http/response/__init__.py b/scrapy/http/response/__init__.py
index 82274fc3a..8e9237dad 100644
--- a/scrapy/http/response/__init__.py
+++ b/scrapy/http/response/__init__.py
@@ -4,9 +4,28 @@ responses in Scrapy.
See documentation in docs/topics/request-response.rst
"""
-from typing import Any, AnyStr, Generator, Iterable, Mapping, Tuple, Union
+from __future__ import annotations
+
+from ipaddress import IPv4Address, IPv6Address
+from typing import (
+ TYPE_CHECKING,
+ Any,
+ AnyStr,
+ Callable,
+ Dict,
+ Generator,
+ Iterable,
+ List,
+ Mapping,
+ Optional,
+ Tuple,
+ Union,
+ cast,
+)
from urllib.parse import urljoin
+from twisted.internet.ssl import Certificate
+
from scrapy.exceptions import NotSupported
from scrapy.http.common import obsolete_setter
from scrapy.http.headers import Headers
@@ -14,6 +33,9 @@ from scrapy.http.request import Request
from scrapy.link import Link
from scrapy.utils.trackref import object_ref
+if TYPE_CHECKING:
+ from scrapy.selector import SelectorList
+
class Response(object_ref):
"""An object that represents an HTTP response, which is usually
@@ -41,29 +63,29 @@ class Response(object_ref):
def __init__(
self,
url: str,
- status=200,
+ status: int = 200,
headers: Union[Mapping[AnyStr, Any], Iterable[Tuple[AnyStr, Any]], None] = None,
- body=b"",
- flags=None,
- request=None,
- certificate=None,
- ip_address=None,
- protocol=None,
+ body: bytes = b"",
+ flags: Optional[List[str]] = None,
+ request: Optional[Request] = None,
+ certificate: Optional[Certificate] = None,
+ ip_address: Union[IPv4Address, IPv6Address, None] = None,
+ protocol: Optional[str] = None,
):
- self.headers = Headers(headers or {})
- self.status = int(status)
+ self.headers: Headers = Headers(headers or {})
+ self.status: int = int(status)
self._set_body(body)
self._set_url(url)
- self.request = request
- self.flags = [] if flags is None else list(flags)
- self.certificate = certificate
- self.ip_address = ip_address
- self.protocol = protocol
+ self.request: Optional[Request] = request
+ self.flags: List[str] = [] if flags is None else list(flags)
+ self.certificate: Optional[Certificate] = certificate
+ self.ip_address: Union[IPv4Address, IPv6Address, None] = ip_address
+ self.protocol: Optional[str] = protocol
@property
- def cb_kwargs(self):
+ def cb_kwargs(self) -> Dict[str, Any]:
try:
- return self.request.cb_kwargs
+ return self.request.cb_kwargs # type: ignore[union-attr]
except AttributeError:
raise AttributeError(
"Response.cb_kwargs not available, this response "
@@ -71,21 +93,21 @@ class Response(object_ref):
)
@property
- def meta(self):
+ def meta(self) -> Dict[str, Any]:
try:
- return self.request.meta
+ return self.request.meta # type: ignore[union-attr]
except AttributeError:
raise AttributeError(
"Response.meta not available, this response "
"is not tied to any request"
)
- def _get_url(self):
+ def _get_url(self) -> str:
return self._url
- def _set_url(self, url: str):
+ def _set_url(self, url: str) -> None:
if isinstance(url, str):
- self._url = url
+ self._url: str = url
else:
raise TypeError(
f"{type(self).__name__} url must be str, " f"got {type(url).__name__}"
@@ -93,10 +115,10 @@ class Response(object_ref):
url = property(_get_url, obsolete_setter(_set_url, "url"))
- def _get_body(self):
+ def _get_body(self) -> bytes:
return self._body
- def _set_body(self, body):
+ def _set_body(self, body: Optional[bytes]) -> None:
if body is None:
self._body = b""
elif not isinstance(body, bytes):
@@ -110,45 +132,45 @@ class Response(object_ref):
body = property(_get_body, obsolete_setter(_set_body, "body"))
- def __repr__(self):
+ def __repr__(self) -> str:
return f"<{self.status} {self.url}>"
- def copy(self):
+ def copy(self) -> Response:
"""Return a copy of this Response"""
return self.replace()
- def replace(self, *args, **kwargs):
+ def replace(self, *args: Any, **kwargs: Any) -> Response:
"""Create a new Response with the same attributes except for those given new values"""
for x in self.attributes:
kwargs.setdefault(x, getattr(self, x))
cls = kwargs.pop("cls", self.__class__)
- return cls(*args, **kwargs)
+ return cast(Response, cls(*args, **kwargs))
- def urljoin(self, url):
+ def urljoin(self, url: str) -> str:
"""Join this Response's url with a possible relative url to form an
absolute interpretation of the latter."""
- return urljoin(self.url, url)
+ return urljoin(cast(str, self.url), url)
@property
- def text(self):
+ def text(self) -> str:
"""For subclasses of TextResponse, this will return the body
as str
"""
raise AttributeError("Response content isn't text")
- def css(self, *a, **kw):
+ def css(self, *a: Any, **kw: Any) -> SelectorList:
"""Shortcut method implemented only by responses whose content
is text (subclasses of TextResponse).
"""
raise NotSupported("Response content isn't text")
- def jmespath(self, *a, **kw):
+ def jmespath(self, *a: Any, **kw: Any) -> SelectorList:
"""Shortcut method implemented only by responses whose content
is text (subclasses of TextResponse).
"""
raise NotSupported("Response content isn't text")
- def xpath(self, *a, **kw):
+ def xpath(self, *a: Any, **kw: Any) -> SelectorList:
"""Shortcut method implemented only by responses whose content
is text (subclasses of TextResponse).
"""
@@ -156,19 +178,19 @@ class Response(object_ref):
def follow(
self,
- url,
- callback=None,
- method="GET",
- headers=None,
- body=None,
- cookies=None,
- meta=None,
- encoding="utf-8",
- priority=0,
- dont_filter=False,
- errback=None,
- cb_kwargs=None,
- flags=None,
+ url: Union[str, Link],
+ callback: Optional[Callable] = None,
+ method: str = "GET",
+ headers: Union[Mapping[AnyStr, Any], Iterable[Tuple[AnyStr, Any]], None] = None,
+ body: Optional[Union[bytes, str]] = None,
+ cookies: Optional[Union[dict, List[dict]]] = None,
+ meta: Optional[Dict[str, Any]] = None,
+ encoding: str = "utf-8",
+ priority: int = 0,
+ dont_filter: bool = False,
+ errback: Optional[Callable] = None,
+ cb_kwargs: Optional[Dict[str, Any]] = None,
+ flags: Optional[List[str]] = None,
) -> Request:
"""
Return a :class:`~.Request` instance to follow a link ``url``.
@@ -207,19 +229,19 @@ class Response(object_ref):
def follow_all(
self,
- urls,
- callback=None,
- method="GET",
- headers=None,
- body=None,
- cookies=None,
- meta=None,
- encoding="utf-8",
- priority=0,
- dont_filter=False,
- errback=None,
- cb_kwargs=None,
- flags=None,
+ urls: Iterable[Union[str, Link]],
+ callback: Optional[Callable] = None,
+ method: str = "GET",
+ headers: Union[Mapping[AnyStr, Any], Iterable[Tuple[AnyStr, Any]], None] = None,
+ body: Optional[Union[bytes, str]] = None,
+ cookies: Optional[Union[dict, List[dict]]] = None,
+ meta: Optional[Dict[str, Any]] = None,
+ encoding: str = "utf-8",
+ priority: int = 0,
+ dont_filter: bool = False,
+ errback: Optional[Callable] = None,
+ cb_kwargs: Optional[Dict[str, Any]] = None,
+ flags: Optional[List[str]] = None,
) -> Generator[Request, None, None]:
"""
.. versionadded:: 2.0
diff --git a/scrapy/http/response/text.py b/scrapy/http/response/text.py
index 98ae1f307..21a93fbd2 100644
--- a/scrapy/http/response/text.py
+++ b/scrapy/http/response/text.py
@@ -27,7 +27,7 @@ from scrapy.utils.python import memoizemethod_noargs, to_unicode
from scrapy.utils.response import get_base_url
if TYPE_CHECKING:
- from scrapy.selector import Selector
+ from scrapy.selector import Selector, SelectorList
_NONE = object()
@@ -138,26 +138,26 @@ class TextResponse(Response):
return read_bom(self.body)[0]
@property
- def selector(self):
+ def selector(self) -> Selector:
from scrapy.selector import Selector
if self._cached_selector is None:
self._cached_selector = Selector(self)
return self._cached_selector
- def jmespath(self, query, **kwargs):
+ def jmespath(self, query: str, **kwargs: Any) -> SelectorList:
if not hasattr(self.selector, "jmespath"): # type: ignore[attr-defined]
raise AttributeError(
"Please install parsel >= 1.8.1 to get jmespath support"
)
- return self.selector.jmespath(query, **kwargs) # type: ignore[attr-defined]
+ return cast(SelectorList, self.selector.jmespath(query, **kwargs)) # type: ignore[attr-defined]
- def xpath(self, query, **kwargs):
- return self.selector.xpath(query, **kwargs)
+ def xpath(self, query: str, **kwargs: Any) -> SelectorList:
+ return cast(SelectorList, self.selector.xpath(query, **kwargs))
- def css(self, query):
- return self.selector.css(query)
+ def css(self, query: str) -> SelectorList:
+ return cast(SelectorList, self.selector.css(query))
def follow(
self,
diff --git a/tests/test_http2_client_protocol.py b/tests/test_http2_client_protocol.py
index deb35a579..8fdf3d56f 100644
--- a/tests/test_http2_client_protocol.py
+++ b/tests/test_http2_client_protocol.py
@@ -600,6 +600,7 @@ class Https2ClientProtocolTestCase(TestCase):
def assert_metadata(response: Response):
self.assertEqual(response.request, request)
self.assertIsInstance(response.certificate, Certificate)
+ assert response.certificate # typing
self.assertIsNotNone(response.certificate.original)
self.assertEqual(
response.certificate.getIssuer(), self.client_certificate.getIssuer()
From 24f21e96b99bac1ba991fe02c8823782833ca298 Mon Sep 17 00:00:00 2001
From: Andrey Rakhmatullin
Date: Wed, 1 Nov 2023 01:53:25 +0400
Subject: [PATCH 0064/1041] Full typing for scrapy/http/response/text.py.
---
scrapy/http/response/__init__.py | 6 +-
scrapy/http/response/text.py | 113 +++++++++++++++++--------------
2 files changed, 68 insertions(+), 51 deletions(-)
diff --git a/scrapy/http/response/__init__.py b/scrapy/http/response/__init__.py
index 8e9237dad..61010f14f 100644
--- a/scrapy/http/response/__init__.py
+++ b/scrapy/http/response/__init__.py
@@ -185,7 +185,7 @@ class Response(object_ref):
body: Optional[Union[bytes, str]] = None,
cookies: Optional[Union[dict, List[dict]]] = None,
meta: Optional[Dict[str, Any]] = None,
- encoding: str = "utf-8",
+ encoding: Optional[str] = "utf-8",
priority: int = 0,
dont_filter: bool = False,
errback: Optional[Callable] = None,
@@ -205,6 +205,8 @@ class Response(object_ref):
.. versionadded:: 2.0
The *flags* parameter.
"""
+ if encoding is None:
+ raise ValueError("encoding can't be None")
if isinstance(url, Link):
url = url.url
elif url is None:
@@ -236,7 +238,7 @@ class Response(object_ref):
body: Optional[Union[bytes, str]] = None,
cookies: Optional[Union[dict, List[dict]]] = None,
meta: Optional[Dict[str, Any]] = None,
- encoding: str = "utf-8",
+ encoding: Optional[str] = "utf-8",
priority: int = 0,
dont_filter: bool = False,
errback: Optional[Callable] = None,
diff --git a/scrapy/http/response/text.py b/scrapy/http/response/text.py
index 21a93fbd2..6596d8a5c 100644
--- a/scrapy/http/response/text.py
+++ b/scrapy/http/response/text.py
@@ -8,7 +8,21 @@ from __future__ import annotations
import json
from contextlib import suppress
-from typing import TYPE_CHECKING, Any, Generator, Optional, Tuple, cast
+from typing import (
+ TYPE_CHECKING,
+ Any,
+ AnyStr,
+ Callable,
+ Dict,
+ Generator,
+ Iterable,
+ List,
+ Mapping,
+ Optional,
+ Tuple,
+ Union,
+ cast,
+)
from urllib.parse import urljoin
import parsel
@@ -23,6 +37,7 @@ from w3lib.html import strip_html5_whitespace
from scrapy.http import Request
from scrapy.http.response import Response
+from scrapy.link import Link
from scrapy.utils.python import memoizemethod_noargs, to_unicode
from scrapy.utils.response import get_base_url
@@ -39,20 +54,14 @@ class TextResponse(Response):
attributes: Tuple[str, ...] = Response.attributes + ("encoding",)
def __init__(self, *args: Any, **kwargs: Any):
- self._encoding = kwargs.pop("encoding", None)
+ self._encoding: Optional[str] = kwargs.pop("encoding", None)
self._cached_benc: Optional[str] = None
self._cached_ubody: Optional[str] = None
self._cached_selector: Optional[Selector] = None
super().__init__(*args, **kwargs)
- def _set_url(self, url):
- if isinstance(url, str):
- self._url = to_unicode(url, self.encoding)
- else:
- super()._set_url(url)
-
- def _set_body(self, body):
- self._body = b"" # used by encoding detection
+ def _set_body(self, body: Union[str, bytes, None]) -> None:
+ self._body: bytes = b"" # used by encoding detection
if isinstance(body, str):
if self._encoding is None:
raise TypeError(
@@ -64,10 +73,10 @@ class TextResponse(Response):
super()._set_body(body)
@property
- def encoding(self):
+ def encoding(self) -> str:
return self._declared_encoding() or self._body_inferred_encoding()
- def _declared_encoding(self):
+ def _declared_encoding(self) -> Optional[str]:
return (
self._encoding
or self._bom_encoding()
@@ -75,7 +84,7 @@ class TextResponse(Response):
or self._body_declared_encoding()
)
- def json(self):
+ def json(self) -> Any:
"""
.. versionadded:: 2.2
@@ -96,7 +105,7 @@ class TextResponse(Response):
self._cached_ubody = html_to_unicode(charset, self.body)[1]
return self._cached_ubody
- def urljoin(self, url):
+ def urljoin(self, url: str) -> str:
"""Join this Response's url with a possible relative url to form an
absolute interpretation of the latter."""
return urljoin(get_base_url(self), url)
@@ -106,7 +115,7 @@ class TextResponse(Response):
content_type = cast(bytes, self.headers.get(b"Content-Type", b""))
return http_content_type_encoding(to_unicode(content_type, encoding="latin-1"))
- def _body_inferred_encoding(self):
+ def _body_inferred_encoding(self) -> str:
if self._cached_benc is None:
content_type = to_unicode(
cast(bytes, self.headers.get(b"Content-Type", b"")), encoding="latin-1"
@@ -121,20 +130,21 @@ class TextResponse(Response):
self._cached_ubody = ubody
return self._cached_benc
- def _auto_detect_fun(self, text):
+ def _auto_detect_fun(self, text: bytes) -> Optional[str]:
for enc in (self._DEFAULT_ENCODING, "utf-8", "cp1252"):
try:
text.decode(enc)
except UnicodeError:
continue
return resolve_encoding(enc)
+ return None
@memoizemethod_noargs
- def _body_declared_encoding(self):
+ def _body_declared_encoding(self) -> Optional[str]:
return html_body_declared_encoding(self.body)
@memoizemethod_noargs
- def _bom_encoding(self):
+ def _bom_encoding(self) -> Optional[str]:
return read_bom(self.body)[0]
@property
@@ -146,6 +156,8 @@ class TextResponse(Response):
return self._cached_selector
def jmespath(self, query: str, **kwargs: Any) -> SelectorList:
+ from scrapy.selector import SelectorList
+
if not hasattr(self.selector, "jmespath"): # type: ignore[attr-defined]
raise AttributeError(
"Please install parsel >= 1.8.1 to get jmespath support"
@@ -154,26 +166,30 @@ class TextResponse(Response):
return cast(SelectorList, self.selector.jmespath(query, **kwargs)) # type: ignore[attr-defined]
def xpath(self, query: str, **kwargs: Any) -> SelectorList:
+ from scrapy.selector import SelectorList
+
return cast(SelectorList, self.selector.xpath(query, **kwargs))
def css(self, query: str) -> SelectorList:
+ from scrapy.selector import SelectorList
+
return cast(SelectorList, self.selector.css(query))
def follow(
self,
- url,
- callback=None,
- method="GET",
- headers=None,
- body=None,
- cookies=None,
- meta=None,
- encoding=None,
- priority=0,
- dont_filter=False,
- errback=None,
- cb_kwargs=None,
- flags=None,
+ url: Union[str, Link, parsel.Selector],
+ callback: Optional[Callable] = None,
+ method: str = "GET",
+ headers: Union[Mapping[AnyStr, Any], Iterable[Tuple[AnyStr, Any]], None] = None,
+ body: Optional[Union[bytes, str]] = None,
+ cookies: Optional[Union[dict, List[dict]]] = None,
+ meta: Optional[Dict[str, Any]] = None,
+ encoding: Optional[str] = None,
+ priority: int = 0,
+ dont_filter: bool = False,
+ errback: Optional[Callable] = None,
+ cb_kwargs: Optional[Dict[str, Any]] = None,
+ flags: Optional[List[str]] = None,
) -> Request:
"""
Return a :class:`~.Request` instance to follow a link ``url``.
@@ -214,21 +230,21 @@ class TextResponse(Response):
def follow_all(
self,
- urls=None,
- callback=None,
- method="GET",
- headers=None,
- body=None,
- cookies=None,
- meta=None,
- encoding=None,
- priority=0,
- dont_filter=False,
- errback=None,
- cb_kwargs=None,
- flags=None,
- css=None,
- xpath=None,
+ urls: Union[Iterable[Union[str, Link]], parsel.SelectorList, None] = None,
+ callback: Optional[Callable] = None,
+ method: str = "GET",
+ headers: Union[Mapping[AnyStr, Any], Iterable[Tuple[AnyStr, Any]], None] = None,
+ body: Optional[Union[bytes, str]] = None,
+ cookies: Optional[Union[dict, List[dict]]] = None,
+ meta: Optional[Dict[str, Any]] = None,
+ encoding: Optional[str] = None,
+ priority: int = 0,
+ dont_filter: bool = False,
+ errback: Optional[Callable] = None,
+ cb_kwargs: Optional[Dict[str, Any]] = None,
+ flags: Optional[List[str]] = None,
+ css: Optional[str] = None,
+ xpath: Optional[str] = None,
) -> Generator[Request, None, None]:
"""
A generator that produces :class:`~.Request` instances to follow all
@@ -270,7 +286,7 @@ class TextResponse(Response):
with suppress(_InvalidSelector):
urls.append(_url_from_selector(sel))
return super().follow_all(
- urls=urls,
+ urls=cast(Iterable[Union[str, Link]], urls),
callback=callback,
method=method,
headers=headers,
@@ -292,8 +308,7 @@ class _InvalidSelector(ValueError):
"""
-def _url_from_selector(sel):
- # type: (parsel.Selector) -> str
+def _url_from_selector(sel: parsel.Selector) -> str:
if isinstance(sel.root, str):
# e.g. ::attr(href) result
return strip_html5_whitespace(sel.root)
From e54dc598999e913526fc6aa5cad26ee0e7ca6140 Mon Sep 17 00:00:00 2001
From: Andrey Rakhmatullin
Date: Thu, 2 Nov 2023 23:40:14 +0400
Subject: [PATCH 0065/1041] Full typing for scrapy/spidermiddlewares.
---
scrapy/spidermiddlewares/depth.py | 41 ++++++++---
scrapy/spidermiddlewares/httperror.py | 33 +++++++--
scrapy/spidermiddlewares/offsite.py | 45 ++++++++----
scrapy/spidermiddlewares/referer.py | 101 +++++++++++++++++---------
scrapy/spidermiddlewares/urllength.py | 34 ++++++---
5 files changed, 178 insertions(+), 76 deletions(-)
diff --git a/scrapy/spidermiddlewares/depth.py b/scrapy/spidermiddlewares/depth.py
index eadc7c6ab..1e96654e2 100644
--- a/scrapy/spidermiddlewares/depth.py
+++ b/scrapy/spidermiddlewares/depth.py
@@ -4,46 +4,67 @@ Depth Spider Middleware
See documentation in docs/topics/spider-middleware.rst
"""
-import logging
+from __future__ import annotations
-from scrapy.http import Request
+import logging
+from typing import TYPE_CHECKING, Any, AsyncIterable, Iterable
+
+from scrapy import Spider
+from scrapy.crawler import Crawler
+from scrapy.http import Request, Response
+from scrapy.statscollectors import StatsCollector
+
+if TYPE_CHECKING:
+ # typing.Self requires Python 3.11
+ from typing_extensions import Self
logger = logging.getLogger(__name__)
class DepthMiddleware:
- def __init__(self, maxdepth, stats, verbose_stats=False, prio=1):
+ def __init__(
+ self,
+ maxdepth: int,
+ stats: StatsCollector,
+ verbose_stats: bool = False,
+ prio: int = 1,
+ ):
self.maxdepth = maxdepth
self.stats = stats
self.verbose_stats = verbose_stats
self.prio = prio
@classmethod
- def from_crawler(cls, crawler):
+ def from_crawler(cls, crawler: Crawler) -> Self:
settings = crawler.settings
maxdepth = settings.getint("DEPTH_LIMIT")
verbose = settings.getbool("DEPTH_STATS_VERBOSE")
prio = settings.getint("DEPTH_PRIORITY")
+ assert crawler.stats
return cls(maxdepth, crawler.stats, verbose, prio)
- def process_spider_output(self, response, result, spider):
+ def process_spider_output(
+ self, response: Response, result: Iterable[Any], spider: Spider
+ ) -> Iterable[Any]:
self._init_depth(response, spider)
- return (r for r in result or () if self._filter(r, response, spider))
+ return (r for r in result if self._filter(r, response, spider))
- async def process_spider_output_async(self, response, result, spider):
+ async def process_spider_output_async(
+ self, response: Response, result: AsyncIterable[Any], spider: Spider
+ ) -> AsyncIterable[Any]:
self._init_depth(response, spider)
- async for r in result or ():
+ async for r in result:
if self._filter(r, response, spider):
yield r
- def _init_depth(self, response, spider):
+ def _init_depth(self, response: Response, spider: Spider) -> None:
# base case (depth=0)
if "depth" not in response.meta:
response.meta["depth"] = 0
if self.verbose_stats:
self.stats.inc_value("request_depth_count/0", spider=spider)
- def _filter(self, request, response, spider):
+ def _filter(self, request: Any, response: Response, spider: Spider) -> bool:
if not isinstance(request, Request):
return True
depth = response.meta["depth"] + 1
diff --git a/scrapy/spidermiddlewares/httperror.py b/scrapy/spidermiddlewares/httperror.py
index 0d3e5fe0b..94450b35b 100644
--- a/scrapy/spidermiddlewares/httperror.py
+++ b/scrapy/spidermiddlewares/httperror.py
@@ -3,9 +3,20 @@ HttpError Spider Middleware
See documentation in docs/topics/spider-middleware.rst
"""
-import logging
+from __future__ import annotations
+import logging
+from typing import TYPE_CHECKING, Any, Iterable, List, Optional
+
+from scrapy import Spider
+from scrapy.crawler import Crawler
from scrapy.exceptions import IgnoreRequest
+from scrapy.http import Response
+from scrapy.settings import BaseSettings
+
+if TYPE_CHECKING:
+ # typing.Self requires Python 3.11
+ from typing_extensions import Self
logger = logging.getLogger(__name__)
@@ -13,21 +24,23 @@ logger = logging.getLogger(__name__)
class HttpError(IgnoreRequest):
"""A non-200 response was filtered"""
- def __init__(self, response, *args, **kwargs):
+ def __init__(self, response: Response, *args: Any, **kwargs: Any):
self.response = response
super().__init__(*args, **kwargs)
class HttpErrorMiddleware:
@classmethod
- def from_crawler(cls, crawler):
+ def from_crawler(cls, crawler: Crawler) -> Self:
return cls(crawler.settings)
- def __init__(self, settings):
- self.handle_httpstatus_all = settings.getbool("HTTPERROR_ALLOW_ALL")
- self.handle_httpstatus_list = settings.getlist("HTTPERROR_ALLOWED_CODES")
+ def __init__(self, settings: BaseSettings):
+ self.handle_httpstatus_all: bool = settings.getbool("HTTPERROR_ALLOW_ALL")
+ self.handle_httpstatus_list: List[int] = settings.getlist(
+ "HTTPERROR_ALLOWED_CODES"
+ )
- def process_spider_input(self, response, spider):
+ def process_spider_input(self, response: Response, spider: Spider) -> None:
if 200 <= response.status < 300: # common case
return
meta = response.meta
@@ -45,8 +58,11 @@ class HttpErrorMiddleware:
return
raise HttpError(response, "Ignoring non-200 response")
- def process_spider_exception(self, response, exception, spider):
+ def process_spider_exception(
+ self, response: Response, exception: Exception, spider: Spider
+ ) -> Optional[Iterable[Any]]:
if isinstance(exception, HttpError):
+ assert spider.crawler.stats
spider.crawler.stats.inc_value("httperror/response_ignored_count")
spider.crawler.stats.inc_value(
f"httperror/response_ignored_status_count/{response.status}"
@@ -57,3 +73,4 @@ class HttpErrorMiddleware:
extra={"spider": spider},
)
return []
+ return None
diff --git a/scrapy/spidermiddlewares/offsite.py b/scrapy/spidermiddlewares/offsite.py
index 1a48926b3..a5214702d 100644
--- a/scrapy/spidermiddlewares/offsite.py
+++ b/scrapy/spidermiddlewares/offsite.py
@@ -3,36 +3,51 @@ Offsite Spider Middleware
See documentation in docs/topics/spider-middleware.rst
"""
+from __future__ import annotations
+
import logging
import re
import warnings
+from typing import TYPE_CHECKING, Any, AsyncIterable, Iterable, Set
-from scrapy import signals
-from scrapy.http import Request
+from scrapy import Spider, signals
+from scrapy.crawler import Crawler
+from scrapy.http import Request, Response
+from scrapy.statscollectors import StatsCollector
from scrapy.utils.httpobj import urlparse_cached
+if TYPE_CHECKING:
+ # typing.Self requires Python 3.11
+ from typing_extensions import Self
+
+
logger = logging.getLogger(__name__)
class OffsiteMiddleware:
- def __init__(self, stats):
- self.stats = stats
+ def __init__(self, stats: StatsCollector):
+ self.stats: StatsCollector = stats
@classmethod
- def from_crawler(cls, crawler):
+ def from_crawler(cls, crawler: Crawler) -> Self:
+ assert crawler.stats
o = cls(crawler.stats)
crawler.signals.connect(o.spider_opened, signal=signals.spider_opened)
return o
- def process_spider_output(self, response, result, spider):
- return (r for r in result or () if self._filter(r, spider))
+ def process_spider_output(
+ self, response: Response, result: Iterable[Any], spider: Spider
+ ) -> Iterable[Any]:
+ return (r for r in result if self._filter(r, spider))
- async def process_spider_output_async(self, response, result, spider):
- async for r in result or ():
+ async def process_spider_output_async(
+ self, response: Response, result: AsyncIterable[Any], spider: Spider
+ ) -> AsyncIterable[Any]:
+ async for r in result:
if self._filter(r, spider):
yield r
- def _filter(self, request, spider) -> bool:
+ def _filter(self, request: Any, spider: Spider) -> bool:
if not isinstance(request, Request):
return True
if request.dont_filter or self.should_follow(request, spider):
@@ -49,13 +64,13 @@ class OffsiteMiddleware:
self.stats.inc_value("offsite/filtered", spider=spider)
return False
- def should_follow(self, request, spider):
+ def should_follow(self, request: Request, spider: Spider) -> bool:
regex = self.host_regex
# hostname can be None for wrong urls (like javascript links)
host = urlparse_cached(request).hostname or ""
return bool(regex.search(host))
- def get_host_regex(self, spider):
+ def get_host_regex(self, spider: Spider) -> re.Pattern[str]:
"""Override this method to implement a different offsite policy"""
allowed_domains = getattr(spider, "allowed_domains", None)
if not allowed_domains:
@@ -83,9 +98,9 @@ class OffsiteMiddleware:
regex = rf'^(.*\.)?({"|".join(domains)})$'
return re.compile(regex)
- def spider_opened(self, spider):
- self.host_regex = self.get_host_regex(spider)
- self.domains_seen = set()
+ def spider_opened(self, spider: Spider) -> None:
+ self.host_regex: re.Pattern[str] = self.get_host_regex(spider)
+ self.domains_seen: Set[str] = set()
class URLWarning(Warning):
diff --git a/scrapy/spidermiddlewares/referer.py b/scrapy/spidermiddlewares/referer.py
index fd91e658b..a29e0ebb5 100644
--- a/scrapy/spidermiddlewares/referer.py
+++ b/scrapy/spidermiddlewares/referer.py
@@ -2,20 +2,39 @@
RefererMiddleware: populates Request referer field, based on the Response which
originated it.
"""
+from __future__ import annotations
+
import warnings
-from typing import Tuple
+from typing import (
+ TYPE_CHECKING,
+ Any,
+ AsyncIterable,
+ Dict,
+ Iterable,
+ Optional,
+ Tuple,
+ Type,
+ Union,
+ cast,
+)
from urllib.parse import urlparse
from w3lib.url import safe_url_string
-from scrapy import signals
+from scrapy import Spider, signals
+from scrapy.crawler import Crawler
from scrapy.exceptions import NotConfigured
from scrapy.http import Request, Response
+from scrapy.settings import BaseSettings
from scrapy.utils.misc import load_object
from scrapy.utils.python import to_unicode
from scrapy.utils.url import strip_url
-LOCAL_SCHEMES = (
+if TYPE_CHECKING:
+ # typing.Self requires Python 3.11
+ from typing_extensions import Self
+
+LOCAL_SCHEMES: Tuple[str, ...] = (
"about",
"blob",
"data",
@@ -37,18 +56,20 @@ class ReferrerPolicy:
NOREFERRER_SCHEMES: Tuple[str, ...] = LOCAL_SCHEMES
name: str
- def referrer(self, response_url, request_url):
+ def referrer(self, response_url: str, request_url: str) -> Optional[str]:
raise NotImplementedError()
- def stripped_referrer(self, url):
+ def stripped_referrer(self, url: str) -> Optional[str]:
if urlparse(url).scheme not in self.NOREFERRER_SCHEMES:
return self.strip_url(url)
+ return None
- def origin_referrer(self, url):
+ def origin_referrer(self, url: str) -> Optional[str]:
if urlparse(url).scheme not in self.NOREFERRER_SCHEMES:
return self.origin(url)
+ return None
- def strip_url(self, url, origin_only=False):
+ def strip_url(self, url: str, origin_only: bool = False) -> Optional[str]:
"""
https://www.w3.org/TR/referrer-policy/#strip-url
@@ -72,18 +93,18 @@ class ReferrerPolicy:
origin_only=origin_only,
)
- def origin(self, url):
+ def origin(self, url: str) -> Optional[str]:
"""Return serialized origin (scheme, host, path) for a request or response URL."""
return self.strip_url(url, origin_only=True)
- def potentially_trustworthy(self, url):
+ def potentially_trustworthy(self, url: str) -> bool:
# Note: this does not follow https://w3c.github.io/webappsec-secure-contexts/#is-url-trustworthy
parsed_url = urlparse(url)
if parsed_url.scheme in ("data",):
return False
return self.tls_protected(url)
- def tls_protected(self, url):
+ def tls_protected(self, url: str) -> bool:
return urlparse(url).scheme in ("https", "ftps")
@@ -98,7 +119,7 @@ class NoReferrerPolicy(ReferrerPolicy):
name: str = POLICY_NO_REFERRER
- def referrer(self, response_url, request_url):
+ def referrer(self, response_url: str, request_url: str) -> Optional[str]:
return None
@@ -119,9 +140,10 @@ class NoReferrerWhenDowngradePolicy(ReferrerPolicy):
name: str = POLICY_NO_REFERRER_WHEN_DOWNGRADE
- def referrer(self, response_url, request_url):
+ def referrer(self, response_url: str, request_url: str) -> Optional[str]:
if not self.tls_protected(response_url) or self.tls_protected(request_url):
return self.stripped_referrer(response_url)
+ return None
class SameOriginPolicy(ReferrerPolicy):
@@ -137,9 +159,10 @@ class SameOriginPolicy(ReferrerPolicy):
name: str = POLICY_SAME_ORIGIN
- def referrer(self, response_url, request_url):
+ def referrer(self, response_url: str, request_url: str) -> Optional[str]:
if self.origin(response_url) == self.origin(request_url):
return self.stripped_referrer(response_url)
+ return None
class OriginPolicy(ReferrerPolicy):
@@ -154,7 +177,7 @@ class OriginPolicy(ReferrerPolicy):
name: str = POLICY_ORIGIN
- def referrer(self, response_url, request_url):
+ def referrer(self, response_url: str, request_url: str) -> Optional[str]:
return self.origin_referrer(response_url)
@@ -174,13 +197,14 @@ class StrictOriginPolicy(ReferrerPolicy):
name: str = POLICY_STRICT_ORIGIN
- def referrer(self, response_url, request_url):
+ def referrer(self, response_url: str, request_url: str) -> Optional[str]:
if (
self.tls_protected(response_url)
and self.potentially_trustworthy(request_url)
or not self.tls_protected(response_url)
):
return self.origin_referrer(response_url)
+ return None
class OriginWhenCrossOriginPolicy(ReferrerPolicy):
@@ -197,7 +221,7 @@ class OriginWhenCrossOriginPolicy(ReferrerPolicy):
name: str = POLICY_ORIGIN_WHEN_CROSS_ORIGIN
- def referrer(self, response_url, request_url):
+ def referrer(self, response_url: str, request_url: str) -> Optional[str]:
origin = self.origin(response_url)
if origin == self.origin(request_url):
return self.stripped_referrer(response_url)
@@ -224,7 +248,7 @@ class StrictOriginWhenCrossOriginPolicy(ReferrerPolicy):
name: str = POLICY_STRICT_ORIGIN_WHEN_CROSS_ORIGIN
- def referrer(self, response_url, request_url):
+ def referrer(self, response_url: str, request_url: str) -> Optional[str]:
origin = self.origin(response_url)
if origin == self.origin(request_url):
return self.stripped_referrer(response_url)
@@ -234,6 +258,7 @@ class StrictOriginWhenCrossOriginPolicy(ReferrerPolicy):
or not self.tls_protected(response_url)
):
return self.origin_referrer(response_url)
+ return None
class UnsafeUrlPolicy(ReferrerPolicy):
@@ -252,7 +277,7 @@ class UnsafeUrlPolicy(ReferrerPolicy):
name: str = POLICY_UNSAFE_URL
- def referrer(self, response_url, request_url):
+ def referrer(self, response_url: str, request_url: str) -> Optional[str]:
return self.stripped_referrer(response_url)
@@ -267,7 +292,7 @@ class DefaultReferrerPolicy(NoReferrerWhenDowngradePolicy):
name: str = POLICY_SCRAPY_DEFAULT
-_policy_classes = {
+_policy_classes: Dict[str, Type[ReferrerPolicy]] = {
p.name: p
for p in (
NoReferrerPolicy,
@@ -286,14 +311,16 @@ _policy_classes = {
_policy_classes[""] = NoReferrerWhenDowngradePolicy
-def _load_policy_class(policy, warning_only=False):
+def _load_policy_class(
+ policy: str, warning_only: bool = False
+) -> Optional[Type[ReferrerPolicy]]:
"""
Expect a string for the path to the policy class,
otherwise try to interpret the string as a standard value
from https://www.w3.org/TR/referrer-policy/#referrer-policies
"""
try:
- return load_object(policy)
+ return cast(Type[ReferrerPolicy], load_object(policy))
except ValueError:
try:
return _policy_classes[policy.lower()]
@@ -307,13 +334,15 @@ def _load_policy_class(policy, warning_only=False):
class RefererMiddleware:
- def __init__(self, settings=None):
- self.default_policy = DefaultReferrerPolicy
+ def __init__(self, settings: Optional[BaseSettings] = None):
+ self.default_policy: Type[ReferrerPolicy] = DefaultReferrerPolicy
if settings is not None:
- self.default_policy = _load_policy_class(settings.get("REFERRER_POLICY"))
+ settings_policy = _load_policy_class(settings.get("REFERRER_POLICY"))
+ assert settings_policy
+ self.default_policy = settings_policy
@classmethod
- def from_crawler(cls, crawler):
+ def from_crawler(cls, crawler: Crawler) -> Self:
if not crawler.settings.getbool("REFERER_ENABLED"):
raise NotConfigured
mw = cls(crawler.settings)
@@ -323,7 +352,9 @@ class RefererMiddleware:
return mw
- def policy(self, resp_or_url, request):
+ def policy(
+ self, resp_or_url: Union[Response, str], request: Request
+ ) -> ReferrerPolicy:
"""
Determine Referrer-Policy to use from a parent Response (or URL),
and a Request to be sent.
@@ -348,21 +379,25 @@ class RefererMiddleware:
cls = _load_policy_class(policy_name, warning_only=True)
return cls() if cls else self.default_policy()
- def process_spider_output(self, response, result, spider):
- return (self._set_referer(r, response) for r in result or ())
+ def process_spider_output(
+ self, response: Response, result: Iterable[Any], spider: Spider
+ ) -> Iterable[Any]:
+ return (self._set_referer(r, response) for r in result)
- async def process_spider_output_async(self, response, result, spider):
- async for r in result or ():
+ async def process_spider_output_async(
+ self, response: Response, result: AsyncIterable[Any], spider: Spider
+ ) -> AsyncIterable[Any]:
+ async for r in result:
yield self._set_referer(r, response)
- def _set_referer(self, r, response):
+ def _set_referer(self, r: Any, response: Response) -> Any:
if isinstance(r, Request):
referrer = self.policy(response, r).referrer(response.url, r.url)
if referrer is not None:
r.headers.setdefault("Referer", referrer)
return r
- def request_scheduled(self, request, spider):
+ def request_scheduled(self, request: Request, spider: Spider) -> None:
# check redirected request to patch "Referer" header if necessary
redirected_urls = request.meta.get("redirect_urls", [])
if redirected_urls:
@@ -378,7 +413,7 @@ class RefererMiddleware:
policy_referrer = self.policy(parent_url, request).referrer(
parent_url, request.url
)
- if policy_referrer != request_referrer:
+ if policy_referrer != request_referrer.decode("latin1"):
if policy_referrer is None:
request.headers.pop("Referer")
else:
diff --git a/scrapy/spidermiddlewares/urllength.py b/scrapy/spidermiddlewares/urllength.py
index f6d92e53a..e2aa554a7 100644
--- a/scrapy/spidermiddlewares/urllength.py
+++ b/scrapy/spidermiddlewares/urllength.py
@@ -4,40 +4,54 @@ Url Length Spider Middleware
See documentation in docs/topics/spider-middleware.rst
"""
-import logging
+from __future__ import annotations
+import logging
+from typing import TYPE_CHECKING, Any, AsyncIterable, Iterable
+
+from scrapy import Spider
from scrapy.exceptions import NotConfigured
-from scrapy.http import Request
+from scrapy.http import Request, Response
+from scrapy.settings import BaseSettings
+
+if TYPE_CHECKING:
+ # typing.Self requires Python 3.11
+ from typing_extensions import Self
logger = logging.getLogger(__name__)
class UrlLengthMiddleware:
- def __init__(self, maxlength):
- self.maxlength = maxlength
+ def __init__(self, maxlength: int):
+ self.maxlength: int = maxlength
@classmethod
- def from_settings(cls, settings):
+ def from_settings(cls, settings: BaseSettings) -> Self:
maxlength = settings.getint("URLLENGTH_LIMIT")
if not maxlength:
raise NotConfigured
return cls(maxlength)
- def process_spider_output(self, response, result, spider):
- return (r for r in result or () if self._filter(r, spider))
+ def process_spider_output(
+ self, response: Response, result: Iterable[Any], spider: Spider
+ ) -> Iterable[Any]:
+ return (r for r in result if self._filter(r, spider))
- async def process_spider_output_async(self, response, result, spider):
- async for r in result or ():
+ async def process_spider_output_async(
+ self, response: Response, result: AsyncIterable[Any], spider: Spider
+ ) -> AsyncIterable[Any]:
+ async for r in result:
if self._filter(r, spider):
yield r
- def _filter(self, request, spider):
+ def _filter(self, request: Any, spider: Spider) -> bool:
if isinstance(request, Request) and len(request.url) > self.maxlength:
logger.info(
"Ignoring link (url length > %(maxlength)d): %(url)s ",
{"maxlength": self.maxlength, "url": request.url},
extra={"spider": spider},
)
+ assert spider.crawler.stats
spider.crawler.stats.inc_value(
"urllength/request_ignored_count", spider=spider
)
From dda6feb935b77bf4c7e02b5b00ec6fc0fa6f97ee Mon Sep 17 00:00:00 2001
From: Jeesang Kim
Date: Fri, 3 Nov 2023 17:24:25 +0900
Subject: [PATCH 0066/1041] Improve assert readability (#6132)
---
tests/test_linkextractors.py | 24 ++++++++++++------------
1 file changed, 12 insertions(+), 12 deletions(-)
diff --git a/tests/test_linkextractors.py b/tests/test_linkextractors.py
index e1ec19601..c90065e67 100644
--- a/tests/test_linkextractors.py
+++ b/tests/test_linkextractors.py
@@ -235,20 +235,20 @@ class Base:
url2 = "http://evenmorestuff.com/uglystuff/index"
lx = self.extractor_cls(allow=(r"stuff1",))
- self.assertEqual(lx.matches(url1), True)
- self.assertEqual(lx.matches(url2), False)
+ self.assertTrue(lx.matches(url1))
+ self.assertFalse(lx.matches(url2))
lx = self.extractor_cls(deny=(r"uglystuff",))
- self.assertEqual(lx.matches(url1), True)
- self.assertEqual(lx.matches(url2), False)
+ self.assertTrue(lx.matches(url1))
+ self.assertFalse(lx.matches(url2))
lx = self.extractor_cls(allow_domains=("evenmorestuff.com",))
- self.assertEqual(lx.matches(url1), False)
- self.assertEqual(lx.matches(url2), True)
+ self.assertFalse(lx.matches(url1))
+ self.assertTrue(lx.matches(url2))
lx = self.extractor_cls(deny_domains=("lotsofstuff.com",))
- self.assertEqual(lx.matches(url1), False)
- self.assertEqual(lx.matches(url2), True)
+ self.assertFalse(lx.matches(url1))
+ self.assertTrue(lx.matches(url2))
lx = self.extractor_cls(
allow=["blah1"],
@@ -256,10 +256,10 @@ class Base:
allow_domains=["blah1.com"],
deny_domains=["blah2.com"],
)
- self.assertEqual(lx.matches("http://blah1.com/blah1"), True)
- self.assertEqual(lx.matches("http://blah1.com/blah2"), False)
- self.assertEqual(lx.matches("http://blah2.com/blah1"), False)
- self.assertEqual(lx.matches("http://blah2.com/blah2"), False)
+ self.assertTrue(lx.matches("http://blah1.com/blah1"))
+ self.assertFalse(lx.matches("http://blah1.com/blah2"))
+ self.assertFalse(lx.matches("http://blah2.com/blah1"))
+ self.assertFalse(lx.matches("http://blah2.com/blah2"))
def test_restrict_xpaths(self):
lx = self.extractor_cls(restrict_xpaths=('//div[@id="subwrapper"]',))
From 6587556af9ba92cd1f73ea2ed172a513bf80e6a5 Mon Sep 17 00:00:00 2001
From: Jessica Allman-LaPorte
Date: Fri, 3 Nov 2023 05:02:18 -0400
Subject: [PATCH 0067/1041] 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 2ac3ef73e6208aced93fa538184f1db7f14d125c Mon Sep 17 00:00:00 2001
From: Andrey Rakhmatullin
Date: Fri, 3 Nov 2023 20:12:18 +0400
Subject: [PATCH 0068/1041] Remove obsolete setters for body and url on Request
and Response.
---
scrapy/http/common.py | 10 ----------
scrapy/http/request/__init__.py | 11 ++++-------
scrapy/http/response/__init__.py | 13 +++++--------
scrapy/utils/iterators.py | 4 ++--
4 files changed, 11 insertions(+), 27 deletions(-)
delete mode 100644 scrapy/http/common.py
diff --git a/scrapy/http/common.py b/scrapy/http/common.py
deleted file mode 100644
index a3d9d5b81..000000000
--- a/scrapy/http/common.py
+++ /dev/null
@@ -1,10 +0,0 @@
-from typing import Any, Callable, NoReturn
-
-
-def obsolete_setter(setter: Callable, attrname: str) -> Callable[[Any, Any], NoReturn]:
- def newsetter(self: Any, value: Any) -> NoReturn:
- c = self.__class__.__name__
- msg = f"{c}.{attrname} is not modifiable, use {c}.replace() instead"
- raise AttributeError(msg)
-
- return newsetter
diff --git a/scrapy/http/request/__init__.py b/scrapy/http/request/__init__.py
index 7c9a4ba95..a1c5a5e51 100644
--- a/scrapy/http/request/__init__.py
+++ b/scrapy/http/request/__init__.py
@@ -25,7 +25,6 @@ from typing import (
from w3lib.url import safe_url_string
import scrapy
-from scrapy.http.common import obsolete_setter
from scrapy.http.headers import Headers
from scrapy.utils.curl import curl_to_request_kwargs
from scrapy.utils.python import to_bytes
@@ -142,7 +141,8 @@ class Request(object_ref):
self._meta = {}
return self._meta
- def _get_url(self) -> str:
+ @property
+ def url(self) -> str:
return self._url
def _set_url(self, url: str) -> None:
@@ -159,16 +159,13 @@ class Request(object_ref):
):
raise ValueError(f"Missing scheme in request url: {self._url}")
- url = property(_get_url, obsolete_setter(_set_url, "url"))
-
- def _get_body(self) -> bytes:
+ @property
+ def body(self) -> bytes:
return self._body
def _set_body(self, body: Optional[Union[str, bytes]]) -> None:
self._body = b"" if body is None else to_bytes(body, self.encoding)
- body = property(_get_body, obsolete_setter(_set_body, "body"))
-
@property
def encoding(self) -> str:
return self._encoding
diff --git a/scrapy/http/response/__init__.py b/scrapy/http/response/__init__.py
index 61010f14f..6eae3e8b3 100644
--- a/scrapy/http/response/__init__.py
+++ b/scrapy/http/response/__init__.py
@@ -27,7 +27,6 @@ from urllib.parse import urljoin
from twisted.internet.ssl import Certificate
from scrapy.exceptions import NotSupported
-from scrapy.http.common import obsolete_setter
from scrapy.http.headers import Headers
from scrapy.http.request import Request
from scrapy.link import Link
@@ -102,7 +101,8 @@ class Response(object_ref):
"is not tied to any request"
)
- def _get_url(self) -> str:
+ @property
+ def url(self) -> str:
return self._url
def _set_url(self, url: str) -> None:
@@ -113,9 +113,8 @@ class Response(object_ref):
f"{type(self).__name__} url must be str, " f"got {type(url).__name__}"
)
- url = property(_get_url, obsolete_setter(_set_url, "url"))
-
- def _get_body(self) -> bytes:
+ @property
+ def body(self) -> bytes:
return self._body
def _set_body(self, body: Optional[bytes]) -> None:
@@ -130,8 +129,6 @@ class Response(object_ref):
else:
self._body = body
- body = property(_get_body, obsolete_setter(_set_body, "body"))
-
def __repr__(self) -> str:
return f"<{self.status} {self.url}>"
@@ -149,7 +146,7 @@ class Response(object_ref):
def urljoin(self, url: str) -> str:
"""Join this Response's url with a possible relative url to form an
absolute interpretation of the latter."""
- return urljoin(cast(str, self.url), url)
+ return urljoin(self.url, url)
@property
def text(self) -> str:
diff --git a/scrapy/utils/iterators.py b/scrapy/utils/iterators.py
index 03d779afb..55362efdf 100644
--- a/scrapy/utils/iterators.py
+++ b/scrapy/utils/iterators.py
@@ -217,10 +217,10 @@ def _body_or_str(
)
if isinstance(obj, Response):
if not unicode:
- return cast(bytes, obj.body)
+ return obj.body
if isinstance(obj, TextResponse):
return obj.text
- return cast(bytes, obj.body).decode("utf-8")
+ return obj.body.decode("utf-8")
if isinstance(obj, str):
return obj if unicode else obj.encode("utf-8")
return obj.decode("utf-8") if unicode else obj
From eafe828484d95f7e73a475c1c5d23492616ebc07 Mon Sep 17 00:00:00 2001
From: cakemd
Date: Mon, 6 Nov 2023 09:37:18 +0200
Subject: [PATCH 0069/1041] scrapy.utils.data_path type hint change (#6133)
---
scrapy/utils/project.py | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/scrapy/utils/project.py b/scrapy/utils/project.py
index a2c224b90..de3c8eaf9 100644
--- a/scrapy/utils/project.py
+++ b/scrapy/utils/project.py
@@ -1,7 +1,9 @@
import os
import warnings
from importlib import import_module
+from os import PathLike
from pathlib import Path
+from typing import Union
from scrapy.exceptions import NotConfigured
from scrapy.settings import Settings
@@ -44,7 +46,7 @@ def project_data_dir(project: str = "default") -> str:
return str(d)
-def data_path(path: str, createdir: bool = False) -> str:
+def data_path(path: Union[str, PathLike], createdir: bool = False) -> str:
"""
Return the given path joined with the .scrapy data directory.
If given an absolute path, return it unmodified.
From 7c27c22a987b7bb113d6275bf9646b74afffe552 Mon Sep 17 00:00:00 2001
From: Andrey Rakhmatullin
Date: Mon, 6 Nov 2023 11:52:14 +0400
Subject: [PATCH 0070/1041] Full typing for scrapy/downloadermiddlewares
(#6129)
---
scrapy/downloadermiddlewares/ajaxcrawl.py | 28 +++++--
scrapy/downloadermiddlewares/cookies.py | 62 +++++++++++----
.../downloadermiddlewares/defaultheaders.py | 21 ++++-
.../downloadermiddlewares/downloadtimeout.py | 24 ++++--
scrapy/downloadermiddlewares/httpauth.py | 23 ++++--
scrapy/downloadermiddlewares/httpcache.py | 28 ++++---
.../downloadermiddlewares/httpcompression.py | 27 +++++--
scrapy/downloadermiddlewares/httpproxy.py | 45 +++++++----
scrapy/downloadermiddlewares/redirect.py | 51 ++++++++----
scrapy/downloadermiddlewares/retry.py | 36 ++++++---
scrapy/downloadermiddlewares/robotstxt.py | 77 +++++++++++++------
scrapy/downloadermiddlewares/stats.py | 37 +++++++--
scrapy/downloadermiddlewares/useragent.py | 23 ++++--
scrapy/http/cookies.py | 10 ++-
scrapy/robotstxt.py | 17 +++-
setup.cfg | 3 -
16 files changed, 373 insertions(+), 139 deletions(-)
diff --git a/scrapy/downloadermiddlewares/ajaxcrawl.py b/scrapy/downloadermiddlewares/ajaxcrawl.py
index 04ae719de..0e757e4be 100644
--- a/scrapy/downloadermiddlewares/ajaxcrawl.py
+++ b/scrapy/downloadermiddlewares/ajaxcrawl.py
@@ -1,10 +1,20 @@
+from __future__ import annotations
+
import logging
import re
+from typing import TYPE_CHECKING, Union
from w3lib import html
+from scrapy import Request, Spider
+from scrapy.crawler import Crawler
from scrapy.exceptions import NotConfigured
-from scrapy.http import HtmlResponse
+from scrapy.http import HtmlResponse, Response
+from scrapy.settings import BaseSettings
+
+if TYPE_CHECKING:
+ # typing.Self requires Python 3.11
+ from typing_extensions import Self
logger = logging.getLogger(__name__)
@@ -15,7 +25,7 @@ class AjaxCrawlMiddleware:
For more info see https://developers.google.com/webmasters/ajax-crawling/docs/getting-started.
"""
- def __init__(self, settings):
+ def __init__(self, settings: BaseSettings):
if not settings.getbool("AJAXCRAWL_ENABLED"):
raise NotConfigured
@@ -23,13 +33,15 @@ class AjaxCrawlMiddleware:
# middleware parses first 4k. 4k turns out to be insufficient
# for this middleware, and parsing 100k could be slow.
# We use something in between (32K) by default.
- self.lookup_bytes = settings.getint("AJAXCRAWL_MAXSIZE", 32768)
+ self.lookup_bytes: int = settings.getint("AJAXCRAWL_MAXSIZE", 32768)
@classmethod
- def from_crawler(cls, crawler):
+ def from_crawler(cls, crawler: Crawler) -> Self:
return cls(crawler.settings)
- def process_response(self, request, response, spider):
+ def process_response(
+ self, request: Request, response: Response, spider: Spider
+ ) -> Union[Request, Response]:
if not isinstance(response, HtmlResponse) or response.status != 200:
return response
@@ -54,7 +66,7 @@ class AjaxCrawlMiddleware:
ajax_crawl_request.meta["ajax_crawlable"] = True
return ajax_crawl_request
- def _has_ajax_crawlable_variant(self, response):
+ def _has_ajax_crawlable_variant(self, response: Response) -> bool:
"""
Return True if a page without hash fragment could be "AJAX crawlable"
according to https://developers.google.com/webmasters/ajax-crawling/docs/getting-started.
@@ -64,12 +76,12 @@ class AjaxCrawlMiddleware:
# XXX: move it to w3lib?
-_ajax_crawlable_re = re.compile(
+_ajax_crawlable_re: re.Pattern[str] = re.compile(
r''
)
-def _has_ajaxcrawlable_meta(text):
+def _has_ajaxcrawlable_meta(text: str) -> bool:
"""
>>> _has_ajaxcrawlable_meta('')
True
diff --git a/scrapy/downloadermiddlewares/cookies.py b/scrapy/downloadermiddlewares/cookies.py
index 6495157d7..85781efd6 100644
--- a/scrapy/downloadermiddlewares/cookies.py
+++ b/scrapy/downloadermiddlewares/cookies.py
@@ -1,21 +1,41 @@
+from __future__ import annotations
+
import logging
from collections import defaultdict
+from http.cookiejar import Cookie
+from typing import (
+ TYPE_CHECKING,
+ Any,
+ DefaultDict,
+ Dict,
+ Iterable,
+ Optional,
+ Sequence,
+ Union,
+)
from tldextract import TLDExtract
+from scrapy import Request, Spider
+from scrapy.crawler import Crawler
from scrapy.exceptions import NotConfigured
from scrapy.http import Response
from scrapy.http.cookies import CookieJar
from scrapy.utils.httpobj import urlparse_cached
from scrapy.utils.python import to_unicode
+if TYPE_CHECKING:
+ # typing.Self requires Python 3.11
+ from typing_extensions import Self
+
+
logger = logging.getLogger(__name__)
_split_domain = TLDExtract(include_psl_private_domains=True)
-def _is_public_domain(domain):
+def _is_public_domain(domain: str) -> bool:
parts = _split_domain(domain)
return not parts.domain
@@ -23,23 +43,27 @@ def _is_public_domain(domain):
class CookiesMiddleware:
"""This middleware enables working with sites that need cookies"""
- def __init__(self, debug=False):
- self.jars = defaultdict(CookieJar)
- self.debug = debug
+ def __init__(self, debug: bool = False):
+ self.jars: DefaultDict[Any, CookieJar] = defaultdict(CookieJar)
+ self.debug: bool = debug
@classmethod
- def from_crawler(cls, crawler):
+ def from_crawler(cls, crawler: Crawler) -> Self:
if not crawler.settings.getbool("COOKIES_ENABLED"):
raise NotConfigured
return cls(crawler.settings.getbool("COOKIES_DEBUG"))
- def _process_cookies(self, cookies, *, jar, request):
+ def _process_cookies(
+ self, cookies: Iterable[Cookie], *, jar: CookieJar, request: Request
+ ) -> None:
for cookie in cookies:
cookie_domain = cookie.domain
if cookie_domain.startswith("."):
cookie_domain = cookie_domain[1:]
- request_domain = urlparse_cached(request).hostname.lower()
+ hostname = urlparse_cached(request).hostname
+ assert hostname is not None
+ request_domain = hostname.lower()
if cookie_domain and _is_public_domain(cookie_domain):
if cookie_domain != request_domain:
@@ -48,9 +72,11 @@ class CookiesMiddleware:
jar.set_cookie_if_ok(cookie, request)
- def process_request(self, request, spider):
+ def process_request(
+ self, request: Request, spider: Spider
+ ) -> Union[Request, Response, None]:
if request.meta.get("dont_merge_cookies", False):
- return
+ return None
cookiejarkey = request.meta.get("cookiejar")
jar = self.jars[cookiejarkey]
@@ -61,8 +87,11 @@ class CookiesMiddleware:
request.headers.pop("Cookie", None)
jar.add_cookie_header(request)
self._debug_cookie(request, spider)
+ return None
- def process_response(self, request, response, spider):
+ def process_response(
+ self, request: Request, response: Response, spider: Spider
+ ) -> Union[Request, Response]:
if request.meta.get("dont_merge_cookies", False):
return response
@@ -76,7 +105,7 @@ class CookiesMiddleware:
return response
- def _debug_cookie(self, request, spider):
+ def _debug_cookie(self, request: Request, spider: Spider) -> None:
if self.debug:
cl = [
to_unicode(c, errors="replace")
@@ -87,7 +116,7 @@ class CookiesMiddleware:
msg = f"Sending cookies to: {request}\n{cookies}"
logger.debug(msg, extra={"spider": spider})
- def _debug_set_cookie(self, response, spider):
+ def _debug_set_cookie(self, response: Response, spider: Spider) -> None:
if self.debug:
cl = [
to_unicode(c, errors="replace")
@@ -98,7 +127,7 @@ class CookiesMiddleware:
msg = f"Received cookies from: {response}\n{cookies}"
logger.debug(msg, extra={"spider": spider})
- def _format_cookie(self, cookie, request):
+ def _format_cookie(self, cookie: Dict[str, Any], request: Request) -> Optional[str]:
"""
Given a dict consisting of cookie components, return its string representation.
Decode from bytes if necessary.
@@ -109,7 +138,7 @@ class CookiesMiddleware:
if key in ("name", "value"):
msg = f"Invalid cookie found in request {request}: {cookie} ('{key}' is missing)"
logger.warning(msg)
- return
+ return None
continue
if isinstance(cookie[key], (bool, float, int, str)):
decoded[key] = str(cookie[key])
@@ -129,12 +158,15 @@ class CookiesMiddleware:
cookie_str += f"; {key.capitalize()}={value}"
return cookie_str
- def _get_request_cookies(self, jar, request):
+ def _get_request_cookies(
+ self, jar: CookieJar, request: Request
+ ) -> Sequence[Cookie]:
"""
Extract cookies from the Request.cookies attribute
"""
if not request.cookies:
return []
+ cookies: Iterable[Dict[str, Any]]
if isinstance(request.cookies, dict):
cookies = ({"name": k, "value": v} for k, v in request.cookies.items())
else:
diff --git a/scrapy/downloadermiddlewares/defaultheaders.py b/scrapy/downloadermiddlewares/defaultheaders.py
index cdacc7368..8aec37cf1 100644
--- a/scrapy/downloadermiddlewares/defaultheaders.py
+++ b/scrapy/downloadermiddlewares/defaultheaders.py
@@ -3,19 +3,32 @@ DefaultHeaders downloader middleware
See documentation in docs/topics/downloader-middleware.rst
"""
+from __future__ import annotations
+from typing import TYPE_CHECKING, Iterable, Tuple, Union
+
+from scrapy import Request, Spider
+from scrapy.crawler import Crawler
+from scrapy.http import Response
from scrapy.utils.python import without_none_values
+if TYPE_CHECKING:
+ # typing.Self requires Python 3.11
+ from typing_extensions import Self
+
class DefaultHeadersMiddleware:
- def __init__(self, headers):
- self._headers = headers
+ def __init__(self, headers: Iterable[Tuple[str, str]]):
+ self._headers: Iterable[Tuple[str, str]] = headers
@classmethod
- def from_crawler(cls, crawler):
+ def from_crawler(cls, crawler: Crawler) -> Self:
headers = without_none_values(crawler.settings["DEFAULT_REQUEST_HEADERS"])
return cls(headers.items())
- def process_request(self, request, spider):
+ def process_request(
+ self, request: Request, spider: Spider
+ ) -> Union[Request, Response, None]:
for k, v in self._headers:
request.headers.setdefault(k, v)
+ return None
diff --git a/scrapy/downloadermiddlewares/downloadtimeout.py b/scrapy/downloadermiddlewares/downloadtimeout.py
index a926ecf56..1c904c05b 100644
--- a/scrapy/downloadermiddlewares/downloadtimeout.py
+++ b/scrapy/downloadermiddlewares/downloadtimeout.py
@@ -3,23 +3,35 @@ Download timeout middleware
See documentation in docs/topics/downloader-middleware.rst
"""
+from __future__ import annotations
-from scrapy import signals
+from typing import TYPE_CHECKING, Union
+
+from scrapy import Request, Spider, signals
+from scrapy.crawler import Crawler
+from scrapy.http import Response
+
+if TYPE_CHECKING:
+ # typing.Self requires Python 3.11
+ from typing_extensions import Self
class DownloadTimeoutMiddleware:
- def __init__(self, timeout=180):
- self._timeout = timeout
+ def __init__(self, timeout: float = 180):
+ self._timeout: float = timeout
@classmethod
- def from_crawler(cls, crawler):
+ def from_crawler(cls, crawler: Crawler) -> Self:
o = cls(crawler.settings.getfloat("DOWNLOAD_TIMEOUT"))
crawler.signals.connect(o.spider_opened, signal=signals.spider_opened)
return o
- def spider_opened(self, spider):
+ def spider_opened(self, spider: Spider) -> None:
self._timeout = getattr(spider, "download_timeout", self._timeout)
- def process_request(self, request, spider):
+ def process_request(
+ self, request: Request, spider: Spider
+ ) -> Union[Request, Response, None]:
if self._timeout:
request.meta.setdefault("download_timeout", self._timeout)
+ return None
diff --git a/scrapy/downloadermiddlewares/httpauth.py b/scrapy/downloadermiddlewares/httpauth.py
index 5228db786..63490a37a 100644
--- a/scrapy/downloadermiddlewares/httpauth.py
+++ b/scrapy/downloadermiddlewares/httpauth.py
@@ -4,31 +4,44 @@ HTTP basic auth downloader middleware
See documentation in docs/topics/downloader-middleware.rst
"""
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Union
+
from w3lib.http import basic_auth_header
-from scrapy import signals
+from scrapy import Request, Spider, signals
+from scrapy.crawler import Crawler
+from scrapy.http import Response
from scrapy.utils.url import url_is_from_any_domain
+if TYPE_CHECKING:
+ # typing.Self requires Python 3.11
+ from typing_extensions import Self
+
class HttpAuthMiddleware:
"""Set Basic HTTP Authorization header
(http_user and http_pass spider class attributes)"""
@classmethod
- def from_crawler(cls, crawler):
+ def from_crawler(cls, crawler: Crawler) -> Self:
o = cls()
crawler.signals.connect(o.spider_opened, signal=signals.spider_opened)
return o
- def spider_opened(self, spider):
+ def spider_opened(self, spider: Spider) -> None:
usr = getattr(spider, "http_user", "")
pwd = getattr(spider, "http_pass", "")
if usr or pwd:
self.auth = basic_auth_header(usr, pwd)
- self.domain = spider.http_auth_domain
+ self.domain = spider.http_auth_domain # type: ignore[attr-defined]
- def process_request(self, request, spider):
+ def process_request(
+ self, request: Request, spider: Spider
+ ) -> Union[Request, Response, None]:
auth = getattr(self, "auth", None)
if auth and b"Authorization" not in request.headers:
if not self.domain or url_is_from_any_domain(request.url, [self.domain]):
request.headers[b"Authorization"] = auth
+ return None
diff --git a/scrapy/downloadermiddlewares/httpcache.py b/scrapy/downloadermiddlewares/httpcache.py
index a521cde7a..971473403 100644
--- a/scrapy/downloadermiddlewares/httpcache.py
+++ b/scrapy/downloadermiddlewares/httpcache.py
@@ -1,5 +1,7 @@
+from __future__ import annotations
+
from email.utils import formatdate
-from typing import Optional, Type, TypeVar
+from typing import TYPE_CHECKING, Optional, Union
from twisted.internet import defer
from twisted.internet.error import (
@@ -23,7 +25,9 @@ from scrapy.spiders import Spider
from scrapy.statscollectors import StatsCollector
from scrapy.utils.misc import load_object
-HttpCacheMiddlewareTV = TypeVar("HttpCacheMiddlewareTV", bound="HttpCacheMiddleware")
+if TYPE_CHECKING:
+ # typing.Self requires Python 3.11
+ from typing_extensions import Self
class HttpCacheMiddleware:
@@ -49,9 +53,7 @@ class HttpCacheMiddleware:
self.stats = stats
@classmethod
- def from_crawler(
- cls: Type[HttpCacheMiddlewareTV], crawler: Crawler
- ) -> HttpCacheMiddlewareTV:
+ def from_crawler(cls, crawler: Crawler) -> Self:
assert crawler.stats
o = cls(crawler.settings, crawler.stats)
crawler.signals.connect(o.spider_opened, signal=signals.spider_opened)
@@ -64,7 +66,9 @@ class HttpCacheMiddleware:
def spider_closed(self, spider: Spider) -> None:
self.storage.close_spider(spider)
- def process_request(self, request: Request, spider: Spider) -> Optional[Response]:
+ def process_request(
+ self, request: Request, spider: Spider
+ ) -> Union[Request, Response, None]:
if request.meta.get("dont_cache", False):
return None
@@ -74,7 +78,9 @@ class HttpCacheMiddleware:
return None
# Look for cached response and check if expired
- cachedresponse = self.storage.retrieve_response(spider, request)
+ cachedresponse: Optional[Response] = self.storage.retrieve_response(
+ spider, request
+ )
if cachedresponse is None:
self.stats.inc_value("httpcache/miss", spider=spider)
if self.ignore_missing:
@@ -96,7 +102,7 @@ class HttpCacheMiddleware:
def process_response(
self, request: Request, response: Response, spider: Spider
- ) -> Response:
+ ) -> Union[Request, Response]:
if request.meta.get("dont_cache", False):
return response
@@ -111,7 +117,7 @@ class HttpCacheMiddleware:
response.headers["Date"] = formatdate(usegmt=True)
# Do not validate first-hand responses
- cachedresponse = request.meta.pop("cached_response", None)
+ cachedresponse: Optional[Response] = request.meta.pop("cached_response", None)
if cachedresponse is None:
self.stats.inc_value("httpcache/firsthand", spider=spider)
self._cache_response(spider, response, request, cachedresponse)
@@ -127,8 +133,8 @@ class HttpCacheMiddleware:
def process_exception(
self, request: Request, exception: Exception, spider: Spider
- ) -> Optional[Response]:
- cachedresponse = request.meta.pop("cached_response", None)
+ ) -> Union[Request, Response, None]:
+ cachedresponse: Optional[Response] = request.meta.pop("cached_response", None)
if cachedresponse is not None and isinstance(
exception, self.DOWNLOAD_EXCEPTIONS
):
diff --git a/scrapy/downloadermiddlewares/httpcompression.py b/scrapy/downloadermiddlewares/httpcompression.py
index 7b1d3f829..56a58a750 100644
--- a/scrapy/downloadermiddlewares/httpcompression.py
+++ b/scrapy/downloadermiddlewares/httpcompression.py
@@ -1,12 +1,22 @@
+from __future__ import annotations
+
import io
import zlib
+from typing import TYPE_CHECKING, List, Optional, Union
+from scrapy import Request, Spider
+from scrapy.crawler import Crawler
from scrapy.exceptions import NotConfigured
from scrapy.http import Response, TextResponse
from scrapy.responsetypes import responsetypes
+from scrapy.statscollectors import StatsCollector
from scrapy.utils.gz import gunzip
-ACCEPTED_ENCODINGS = [b"gzip", b"deflate"]
+if TYPE_CHECKING:
+ # typing.Self requires Python 3.11
+ from typing_extensions import Self
+
+ACCEPTED_ENCODINGS: List[bytes] = [b"gzip", b"deflate"]
try:
import brotli
@@ -27,19 +37,24 @@ class HttpCompressionMiddleware:
"""This middleware allows compressed (gzip, deflate) traffic to be
sent/received from web sites"""
- def __init__(self, stats=None):
+ def __init__(self, stats: Optional[StatsCollector] = None):
self.stats = stats
@classmethod
- def from_crawler(cls, crawler):
+ def from_crawler(cls, crawler: Crawler) -> Self:
if not crawler.settings.getbool("COMPRESSION_ENABLED"):
raise NotConfigured
return cls(stats=crawler.stats)
- def process_request(self, request, spider):
+ def process_request(
+ self, request: Request, spider: Spider
+ ) -> Union[Request, Response, None]:
request.headers.setdefault("Accept-Encoding", b", ".join(ACCEPTED_ENCODINGS))
+ return None
- def process_response(self, request, response, spider):
+ def process_response(
+ self, request: Request, response: Response, spider: Spider
+ ) -> Union[Request, Response]:
if request.method == "HEAD":
return response
if isinstance(response, Response):
@@ -70,7 +85,7 @@ class HttpCompressionMiddleware:
return response
- def _decode(self, body, encoding):
+ def _decode(self, body: bytes, encoding: bytes) -> bytes:
if encoding == b"gzip" or encoding == b"x-gzip":
body = gunzip(body)
diff --git a/scrapy/downloadermiddlewares/httpproxy.py b/scrapy/downloadermiddlewares/httpproxy.py
index f74d84b69..335896ac1 100644
--- a/scrapy/downloadermiddlewares/httpproxy.py
+++ b/scrapy/downloadermiddlewares/httpproxy.py
@@ -1,16 +1,30 @@
-import base64
-from urllib.parse import unquote, urlunparse
-from urllib.request import _parse_proxy, getproxies, proxy_bypass
+from __future__ import annotations
+import base64
+from typing import TYPE_CHECKING, Dict, Optional, Tuple, Union
+from urllib.parse import unquote, urlunparse
+from urllib.request import ( # type: ignore[attr-defined]
+ _parse_proxy,
+ getproxies,
+ proxy_bypass,
+)
+
+from scrapy import Request, Spider
+from scrapy.crawler import Crawler
from scrapy.exceptions import NotConfigured
+from scrapy.http import Response
from scrapy.utils.httpobj import urlparse_cached
from scrapy.utils.python import to_bytes
+if TYPE_CHECKING:
+ # typing.Self requires Python 3.11
+ from typing_extensions import Self
+
class HttpProxyMiddleware:
- def __init__(self, auth_encoding="latin-1"):
- self.auth_encoding = auth_encoding
- self.proxies = {}
+ def __init__(self, auth_encoding: Optional[str] = "latin-1"):
+ self.auth_encoding: Optional[str] = auth_encoding
+ self.proxies: Dict[str, Tuple[Optional[bytes], str]] = {}
for type_, url in getproxies().items():
try:
self.proxies[type_] = self._get_proxy(url, type_)
@@ -20,19 +34,19 @@ class HttpProxyMiddleware:
continue
@classmethod
- def from_crawler(cls, crawler):
+ def from_crawler(cls, crawler: Crawler) -> Self:
if not crawler.settings.getbool("HTTPPROXY_ENABLED"):
raise NotConfigured
- auth_encoding = crawler.settings.get("HTTPPROXY_AUTH_ENCODING")
+ auth_encoding: Optional[str] = crawler.settings.get("HTTPPROXY_AUTH_ENCODING")
return cls(auth_encoding)
- def _basic_auth_header(self, username, password):
+ def _basic_auth_header(self, username: str, password: str) -> bytes:
user_pass = to_bytes(
f"{unquote(username)}:{unquote(password)}", encoding=self.auth_encoding
)
return base64.b64encode(user_pass)
- def _get_proxy(self, url, orig_type):
+ def _get_proxy(self, url: str, orig_type: str) -> Tuple[Optional[bytes], str]:
proxy_type, user, password, hostport = _parse_proxy(url)
proxy_url = urlunparse((proxy_type or orig_type, hostport, "", "", "", ""))
@@ -43,7 +57,9 @@ class HttpProxyMiddleware:
return creds, proxy_url
- def process_request(self, request, spider):
+ def process_request(
+ self, request: Request, spider: Spider
+ ) -> Union[Request, Response, None]:
creds, proxy_url = None, None
if "proxy" in request.meta:
if request.meta["proxy"] is not None:
@@ -54,13 +70,16 @@ class HttpProxyMiddleware:
if (
# 'no_proxy' is only supported by http schemes
scheme not in ("http", "https")
- or not proxy_bypass(parsed.hostname)
+ or (parsed.hostname and not proxy_bypass(parsed.hostname))
) and scheme in self.proxies:
creds, proxy_url = self.proxies[scheme]
self._set_proxy_and_creds(request, proxy_url, creds)
+ return None
- def _set_proxy_and_creds(self, request, proxy_url, creds):
+ def _set_proxy_and_creds(
+ self, request: Request, proxy_url: Optional[str], creds: Optional[bytes]
+ ) -> None:
if proxy_url:
request.meta["proxy"] = proxy_url
elif request.meta.get("proxy") is not None:
diff --git a/scrapy/downloadermiddlewares/redirect.py b/scrapy/downloadermiddlewares/redirect.py
index 65f1d2224..814b1a561 100644
--- a/scrapy/downloadermiddlewares/redirect.py
+++ b/scrapy/downloadermiddlewares/redirect.py
@@ -1,17 +1,29 @@
+from __future__ import annotations
+
import logging
+from typing import TYPE_CHECKING, Any, List, Union, cast
from urllib.parse import urljoin, urlparse
from w3lib.url import safe_url_string
+from scrapy import Request, Spider
+from scrapy.crawler import Crawler
from scrapy.exceptions import IgnoreRequest, NotConfigured
-from scrapy.http import HtmlResponse
+from scrapy.http import HtmlResponse, Response
+from scrapy.settings import BaseSettings
from scrapy.utils.httpobj import urlparse_cached
from scrapy.utils.response import get_meta_refresh
+if TYPE_CHECKING:
+ # typing.Self requires Python 3.11
+ from typing_extensions import Self
+
logger = logging.getLogger(__name__)
-def _build_redirect_request(source_request, *, url, **kwargs):
+def _build_redirect_request(
+ source_request: Request, *, url: str, **kwargs: Any
+) -> Request:
redirect_request = source_request.replace(
url=url,
**kwargs,
@@ -26,20 +38,22 @@ def _build_redirect_request(source_request, *, url, **kwargs):
class BaseRedirectMiddleware:
- enabled_setting = "REDIRECT_ENABLED"
+ enabled_setting: str = "REDIRECT_ENABLED"
- def __init__(self, settings):
+ def __init__(self, settings: BaseSettings):
if not settings.getbool(self.enabled_setting):
raise NotConfigured
- self.max_redirect_times = settings.getint("REDIRECT_MAX_TIMES")
- self.priority_adjust = settings.getint("REDIRECT_PRIORITY_ADJUST")
+ self.max_redirect_times: int = settings.getint("REDIRECT_MAX_TIMES")
+ self.priority_adjust: int = settings.getint("REDIRECT_PRIORITY_ADJUST")
@classmethod
- def from_crawler(cls, crawler):
+ def from_crawler(cls, crawler: Crawler) -> Self:
return cls(crawler.settings)
- def _redirect(self, redirected, request, spider, reason):
+ def _redirect(
+ self, redirected: Request, request: Request, spider: Spider, reason: Any
+ ) -> Request:
ttl = request.meta.setdefault("redirect_ttl", self.max_redirect_times)
redirects = request.meta.get("redirect_times", 0) + 1
@@ -67,7 +81,9 @@ class BaseRedirectMiddleware:
)
raise IgnoreRequest("max redirections reached")
- def _redirect_request_using_get(self, request, redirect_url):
+ def _redirect_request_using_get(
+ self, request: Request, redirect_url: str
+ ) -> Request:
redirect_request = _build_redirect_request(
request,
url=redirect_url,
@@ -85,7 +101,9 @@ class RedirectMiddleware(BaseRedirectMiddleware):
and meta-refresh html tag.
"""
- def process_response(self, request, response, spider):
+ def process_response(
+ self, request: Request, response: Response, spider: Spider
+ ) -> Union[Request, Response]:
if (
request.meta.get("dont_redirect", False)
or response.status in getattr(spider, "handle_httpstatus_list", [])
@@ -98,6 +116,7 @@ class RedirectMiddleware(BaseRedirectMiddleware):
if "Location" not in response.headers or response.status not in allowed_status:
return response
+ 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
@@ -116,12 +135,14 @@ class RedirectMiddleware(BaseRedirectMiddleware):
class MetaRefreshMiddleware(BaseRedirectMiddleware):
enabled_setting = "METAREFRESH_ENABLED"
- def __init__(self, settings):
+ def __init__(self, settings: BaseSettings):
super().__init__(settings)
- self._ignore_tags = settings.getlist("METAREFRESH_IGNORE_TAGS")
- self._maxdelay = settings.getint("METAREFRESH_MAXDELAY")
+ self._ignore_tags: List[str] = settings.getlist("METAREFRESH_IGNORE_TAGS")
+ self._maxdelay: int = settings.getint("METAREFRESH_MAXDELAY")
- def process_response(self, request, response, spider):
+ def process_response(
+ self, request: Request, response: Response, spider: Spider
+ ) -> Union[Request, Response]:
if (
request.meta.get("dont_redirect", False)
or request.method == "HEAD"
@@ -130,7 +151,7 @@ class MetaRefreshMiddleware(BaseRedirectMiddleware):
return response
interval, url = get_meta_refresh(response, ignore_tags=self._ignore_tags)
- if url and interval < self._maxdelay:
+ if url and cast(float, interval) < self._maxdelay:
redirected = self._redirect_request_using_get(request, url)
return self._redirect(redirected, request, spider, "meta refresh")
diff --git a/scrapy/downloadermiddlewares/retry.py b/scrapy/downloadermiddlewares/retry.py
index 380623cea..3c494de78 100644
--- a/scrapy/downloadermiddlewares/retry.py
+++ b/scrapy/downloadermiddlewares/retry.py
@@ -9,22 +9,30 @@ RETRY_HTTP_CODES - which HTTP response codes to retry
Failed pages are collected on the scraping process and rescheduled at the end,
once the spider has finished crawling all regular (non failed) pages.
"""
+from __future__ import annotations
+
import warnings
from logging import Logger, getLogger
-from typing import Optional, Type, Union
+from typing import TYPE_CHECKING, Any, Optional, Tuple, Type, Union
+from scrapy.crawler import Crawler
from scrapy.exceptions import NotConfigured, ScrapyDeprecationWarning
+from scrapy.http import Response
from scrapy.http.request import Request
-from scrapy.settings import Settings
+from scrapy.settings import BaseSettings, Settings
from scrapy.spiders import Spider
from scrapy.utils.misc import load_object
from scrapy.utils.python import global_object_name
from scrapy.utils.response import response_status_message
+if TYPE_CHECKING:
+ # typing.Self requires Python 3.11
+ from typing_extensions import Self
+
retry_logger = getLogger(__name__)
-def backwards_compatibility_getattr(self, name):
+def backwards_compatibility_getattr(self: Any, name: str) -> Tuple[Any, ...]:
if name == "EXCEPTIONS_TO_RETRY":
warnings.warn(
"Attribute RetryMiddleware.EXCEPTIONS_TO_RETRY is deprecated. "
@@ -54,7 +62,7 @@ def get_retry_request(
priority_adjust: Optional[int] = None,
logger: Logger = retry_logger,
stats_base_key: str = "retry",
-):
+) -> Optional[Request]:
"""
Returns a new :class:`~scrapy.Request` object to retry the specified
request, or ``None`` if retries of the specified request have been
@@ -134,7 +142,7 @@ def get_retry_request(
class RetryMiddleware(metaclass=BackwardsCompatibilityMetaclass):
- def __init__(self, settings):
+ def __init__(self, settings: BaseSettings):
if not settings.getbool("RETRY_ENABLED"):
raise NotConfigured
self.max_retry_times = settings.getint("RETRY_TIMES")
@@ -153,10 +161,12 @@ class RetryMiddleware(metaclass=BackwardsCompatibilityMetaclass):
)
@classmethod
- def from_crawler(cls, crawler):
+ def from_crawler(cls, crawler: Crawler) -> Self:
return cls(crawler.settings)
- def process_response(self, request, response, spider):
+ def process_response(
+ self, request: Request, response: Response, spider: Spider
+ ) -> Union[Request, Response]:
if request.meta.get("dont_retry", False):
return response
if response.status in self.retry_http_codes:
@@ -164,13 +174,21 @@ class RetryMiddleware(metaclass=BackwardsCompatibilityMetaclass):
return self._retry(request, reason, spider) or response
return response
- def process_exception(self, request, exception, spider):
+ def process_exception(
+ self, request: Request, exception: Exception, spider: Spider
+ ) -> Union[Request, Response, None]:
if isinstance(exception, self.exceptions_to_retry) and not request.meta.get(
"dont_retry", False
):
return self._retry(request, exception, spider)
+ return None
- def _retry(self, request, reason, spider):
+ def _retry(
+ self,
+ request: Request,
+ reason: Union[str, Exception, Type[Exception]],
+ spider: Spider,
+ ) -> Optional[Request]:
max_retry_times = request.meta.get("max_retry_times", self.max_retry_times)
priority_adjust = request.meta.get("priority_adjust", self.priority_adjust)
return get_retry_request(
diff --git a/scrapy/downloadermiddlewares/robotstxt.py b/scrapy/downloadermiddlewares/robotstxt.py
index 6cab27c5a..6a0ecb7bf 100644
--- a/scrapy/downloadermiddlewares/robotstxt.py
+++ b/scrapy/downloadermiddlewares/robotstxt.py
@@ -4,65 +4,87 @@ enable this middleware and enable the ROBOTSTXT_OBEY setting.
"""
+from __future__ import annotations
+
import logging
+from typing import TYPE_CHECKING, Any, Dict, Optional, Union
from twisted.internet.defer import Deferred, maybeDeferred
+from twisted.python.failure import Failure
+from scrapy import Spider
+from scrapy.crawler import Crawler
from scrapy.exceptions import IgnoreRequest, NotConfigured
-from scrapy.http import Request
+from scrapy.http import Request, Response
from scrapy.http.request import NO_CALLBACK
+from scrapy.robotstxt import RobotParser
from scrapy.utils.httpobj import urlparse_cached
from scrapy.utils.log import failure_to_exc_info
from scrapy.utils.misc import load_object
+if TYPE_CHECKING:
+ # typing.Self requires Python 3.11
+ from typing_extensions import Self
+
+
logger = logging.getLogger(__name__)
class RobotsTxtMiddleware:
- DOWNLOAD_PRIORITY = 1000
+ DOWNLOAD_PRIORITY: int = 1000
- def __init__(self, crawler):
+ def __init__(self, crawler: Crawler):
if not crawler.settings.getbool("ROBOTSTXT_OBEY"):
raise NotConfigured
- self._default_useragent = crawler.settings.get("USER_AGENT", "Scrapy")
- self._robotstxt_useragent = crawler.settings.get("ROBOTSTXT_USER_AGENT", None)
- self.crawler = crawler
- self._parsers = {}
- self._parserimpl = load_object(crawler.settings.get("ROBOTSTXT_PARSER"))
+ self._default_useragent: str = crawler.settings.get("USER_AGENT", "Scrapy")
+ self._robotstxt_useragent: Optional[str] = crawler.settings.get(
+ "ROBOTSTXT_USER_AGENT", None
+ )
+ self.crawler: Crawler = crawler
+ self._parsers: Dict[str, Union[RobotParser, Deferred, None]] = {}
+ self._parserimpl: RobotParser = load_object(
+ crawler.settings.get("ROBOTSTXT_PARSER")
+ )
# check if parser dependencies are met, this should throw an error otherwise.
self._parserimpl.from_crawler(self.crawler, b"")
@classmethod
- def from_crawler(cls, crawler):
+ def from_crawler(cls, crawler: Crawler) -> Self:
return cls(crawler)
- def process_request(self, request, spider):
+ def process_request(self, request: Request, spider: Spider) -> Optional[Deferred]:
if request.meta.get("dont_obey_robotstxt"):
- return
+ return None
if request.url.startswith("data:") or request.url.startswith("file:"):
- return
- d = maybeDeferred(self.robot_parser, request, spider)
+ return None
+ d: Deferred = maybeDeferred(self.robot_parser, request, spider)
d.addCallback(self.process_request_2, request, spider)
return d
- def process_request_2(self, rp, request, spider):
+ def process_request_2(
+ self, rp: Optional[RobotParser], request: Request, spider: Spider
+ ) -> None:
if rp is None:
return
- useragent = self._robotstxt_useragent
+ useragent: Union[str, bytes, None] = self._robotstxt_useragent
if not useragent:
useragent = request.headers.get(b"User-Agent", self._default_useragent)
+ assert useragent is not None
if not rp.allowed(request.url, useragent):
logger.debug(
"Forbidden by robots.txt: %(request)s",
{"request": request},
extra={"spider": spider},
)
+ assert self.crawler.stats
self.crawler.stats.inc_value("robotstxt/forbidden")
raise IgnoreRequest("Forbidden by robots.txt")
- def robot_parser(self, request, spider):
+ def robot_parser(
+ self, request: Request, spider: Spider
+ ) -> Union[RobotParser, Deferred, None]:
url = urlparse_cached(request)
netloc = url.netloc
@@ -75,24 +97,27 @@ class RobotsTxtMiddleware:
meta={"dont_obey_robotstxt": True},
callback=NO_CALLBACK,
)
+ assert self.crawler.engine
+ assert self.crawler.stats
dfd = self.crawler.engine.download(robotsreq)
dfd.addCallback(self._parse_robots, netloc, spider)
dfd.addErrback(self._logerror, robotsreq, spider)
dfd.addErrback(self._robots_error, netloc)
self.crawler.stats.inc_value("robotstxt/request_count")
- if isinstance(self._parsers[netloc], Deferred):
- d = Deferred()
+ parser = self._parsers[netloc]
+ if isinstance(parser, Deferred):
+ d: Deferred = Deferred()
- def cb(result):
+ def cb(result: Any) -> Any:
d.callback(result)
return result
- self._parsers[netloc].addCallback(cb)
+ parser.addCallback(cb)
return d
- return self._parsers[netloc]
+ return parser
- def _logerror(self, failure, request, spider):
+ def _logerror(self, failure: Failure, request: Request, spider: Spider) -> Failure:
if failure.type is not IgnoreRequest:
logger.error(
"Error downloading %(request)s: %(f_exception)s",
@@ -102,20 +127,24 @@ class RobotsTxtMiddleware:
)
return failure
- def _parse_robots(self, response, netloc, spider):
+ def _parse_robots(self, response: Response, netloc: str, spider: Spider) -> None:
+ assert self.crawler.stats
self.crawler.stats.inc_value("robotstxt/response_count")
self.crawler.stats.inc_value(
f"robotstxt/response_status_count/{response.status}"
)
rp = self._parserimpl.from_crawler(self.crawler, response.body)
rp_dfd = self._parsers[netloc]
+ assert isinstance(rp_dfd, Deferred)
self._parsers[netloc] = rp
rp_dfd.callback(rp)
- def _robots_error(self, failure, netloc):
+ def _robots_error(self, failure: Failure, netloc: str) -> None:
if failure.type is not IgnoreRequest:
key = f"robotstxt/exception_count/{failure.type}"
+ assert self.crawler.stats
self.crawler.stats.inc_value(key)
rp_dfd = self._parsers[netloc]
+ assert isinstance(rp_dfd, Deferred)
self._parsers[netloc] = None
rp_dfd.callback(None)
diff --git a/scrapy/downloadermiddlewares/stats.py b/scrapy/downloadermiddlewares/stats.py
index a0f62e262..df30e8ca4 100644
--- a/scrapy/downloadermiddlewares/stats.py
+++ b/scrapy/downloadermiddlewares/stats.py
@@ -1,11 +1,23 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Dict, Union
+
from twisted.web import http
+from scrapy import Request, Spider
+from scrapy.crawler import Crawler
from scrapy.exceptions import NotConfigured
+from scrapy.http import Response
+from scrapy.statscollectors import StatsCollector
from scrapy.utils.python import global_object_name, to_bytes
from scrapy.utils.request import request_httprepr
+if TYPE_CHECKING:
+ # typing.Self requires Python 3.11
+ from typing_extensions import Self
-def get_header_size(headers):
+
+def get_header_size(headers: Dict[str, Union[list, tuple]]) -> int:
size = 0
for key, value in headers.items():
if isinstance(value, (list, tuple)):
@@ -14,30 +26,36 @@ def get_header_size(headers):
return size + len(b"\r\n") * (len(headers.keys()) - 1)
-def get_status_size(response_status):
+def get_status_size(response_status: int) -> int:
return len(to_bytes(http.RESPONSES.get(response_status, b""))) + 15
# resp.status + b"\r\n" + b"HTTP/1.1 <100-599> "
class DownloaderStats:
- def __init__(self, stats):
- self.stats = stats
+ def __init__(self, stats: StatsCollector):
+ self.stats: StatsCollector = stats
@classmethod
- def from_crawler(cls, crawler):
+ def from_crawler(cls, crawler: Crawler) -> Self:
if not crawler.settings.getbool("DOWNLOADER_STATS"):
raise NotConfigured
+ assert crawler.stats
return cls(crawler.stats)
- def process_request(self, request, spider):
+ def process_request(
+ self, request: Request, spider: Spider
+ ) -> Union[Request, Response, None]:
self.stats.inc_value("downloader/request_count", spider=spider)
self.stats.inc_value(
f"downloader/request_method_count/{request.method}", spider=spider
)
reqlen = len(request_httprepr(request))
self.stats.inc_value("downloader/request_bytes", reqlen, spider=spider)
+ return None
- def process_response(self, request, response, spider):
+ def process_response(
+ self, request: Request, response: Response, spider: Spider
+ ) -> Union[Request, Response]:
self.stats.inc_value("downloader/response_count", spider=spider)
self.stats.inc_value(
f"downloader/response_status_count/{response.status}", spider=spider
@@ -52,9 +70,12 @@ class DownloaderStats:
self.stats.inc_value("downloader/response_bytes", reslen, spider=spider)
return response
- def process_exception(self, request, exception, spider):
+ def process_exception(
+ self, request: Request, exception: Exception, spider: Spider
+ ) -> Union[Request, Response, None]:
ex_class = global_object_name(exception.__class__)
self.stats.inc_value("downloader/exception_count", spider=spider)
self.stats.inc_value(
f"downloader/exception_type_count/{ex_class}", spider=spider
)
+ return None
diff --git a/scrapy/downloadermiddlewares/useragent.py b/scrapy/downloadermiddlewares/useragent.py
index 856a275ab..92f1ec897 100644
--- a/scrapy/downloadermiddlewares/useragent.py
+++ b/scrapy/downloadermiddlewares/useragent.py
@@ -1,23 +1,36 @@
"""Set User-Agent header per spider or use a default value from settings"""
-from scrapy import signals
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Union
+
+from scrapy import Request, Spider, signals
+from scrapy.crawler import Crawler
+from scrapy.http import Response
+
+if TYPE_CHECKING:
+ # typing.Self requires Python 3.11
+ from typing_extensions import Self
class UserAgentMiddleware:
"""This middleware allows spiders to override the user_agent"""
- def __init__(self, user_agent="Scrapy"):
+ def __init__(self, user_agent: str = "Scrapy"):
self.user_agent = user_agent
@classmethod
- def from_crawler(cls, crawler):
+ def from_crawler(cls, crawler: Crawler) -> Self:
o = cls(crawler.settings["USER_AGENT"])
crawler.signals.connect(o.spider_opened, signal=signals.spider_opened)
return o
- def spider_opened(self, spider):
+ def spider_opened(self, spider: Spider) -> None:
self.user_agent = getattr(spider, "user_agent", self.user_agent)
- def process_request(self, request, spider):
+ def process_request(
+ self, request: Request, spider: Spider
+ ) -> Union[Request, Response, None]:
if self.user_agent:
request.headers.setdefault(b"User-Agent", self.user_agent)
+ return None
diff --git a/scrapy/http/cookies.py b/scrapy/http/cookies.py
index a5329ad51..2595f328e 100644
--- a/scrapy/http/cookies.py
+++ b/scrapy/http/cookies.py
@@ -1,8 +1,12 @@
import re
import time
+from http.cookiejar import Cookie
from http.cookiejar import CookieJar as _CookieJar
from http.cookiejar import DefaultCookiePolicy
+from typing import Sequence
+from scrapy import Request
+from scrapy.http import Response
from scrapy.utils.httpobj import urlparse_cached
from scrapy.utils.python import to_unicode
@@ -24,7 +28,7 @@ class CookieJar:
wrsp = WrappedResponse(response)
return self.jar.extract_cookies(wrsp, wreq)
- def add_cookie_header(self, request):
+ def add_cookie_header(self, request: Request) -> None:
wreq = WrappedRequest(request)
self.policy._now = self.jar._now = int(time.time())
@@ -75,7 +79,7 @@ class CookieJar:
def set_policy(self, pol):
return self.jar.set_policy(pol)
- def make_cookies(self, response, request):
+ def make_cookies(self, response: Response, request: Request) -> Sequence[Cookie]:
wreq = WrappedRequest(request)
wrsp = WrappedResponse(response)
return self.jar.make_cookies(wrsp, wreq)
@@ -83,7 +87,7 @@ class CookieJar:
def set_cookie(self, cookie):
self.jar.set_cookie(cookie)
- def set_cookie_if_ok(self, cookie, request):
+ def set_cookie_if_ok(self, cookie: Cookie, request: Request) -> None:
self.jar.set_cookie_if_ok(cookie, WrappedRequest(request))
diff --git a/scrapy/robotstxt.py b/scrapy/robotstxt.py
index 5c5ac4e41..6ea2bfd97 100644
--- a/scrapy/robotstxt.py
+++ b/scrapy/robotstxt.py
@@ -1,11 +1,20 @@
+from __future__ import annotations
+
import logging
import sys
from abc import ABCMeta, abstractmethod
+from typing import TYPE_CHECKING, Union
from warnings import warn
from scrapy.exceptions import ScrapyDeprecationWarning
from scrapy.utils.python import to_unicode
+if TYPE_CHECKING:
+ # typing.Self requires Python 3.11
+ from typing_extensions import Self
+
+ from scrapy.crawler import Crawler
+
logger = logging.getLogger(__name__)
@@ -31,7 +40,7 @@ def decode_robotstxt(robotstxt_body, spider, to_native_str_type=False):
class RobotParser(metaclass=ABCMeta):
@classmethod
@abstractmethod
- def from_crawler(cls, crawler, robotstxt_body):
+ def from_crawler(cls, crawler: Crawler, robotstxt_body: bytes) -> Self:
"""Parse the content of a robots.txt_ file as bytes. This must be a class method.
It must return a new instance of the parser backend.
@@ -44,14 +53,14 @@ class RobotParser(metaclass=ABCMeta):
pass
@abstractmethod
- def allowed(self, url, user_agent):
+ def allowed(self, url: Union[str, bytes], user_agent: Union[str, bytes]) -> bool:
"""Return ``True`` if ``user_agent`` is allowed to crawl ``url``, otherwise return ``False``.
:param url: Absolute URL
- :type url: str
+ :type url: str or bytes
:param user_agent: User agent
- :type user_agent: str
+ :type user_agent: str or bytes
"""
pass
diff --git a/setup.cfg b/setup.cfg
index db79c5821..d23549f10 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -16,9 +16,6 @@ follow_imports = skip
# FIXME: remove the following sections once the issues are solved
-[mypy-scrapy.downloadermiddlewares.httpproxy]
-ignore_errors = True
-
[mypy-scrapy.interfaces]
ignore_errors = True
From b4acf5c827b710fac83fa09fc5832ac76041e5c8 Mon Sep 17 00:00:00 2001
From: Andrey Rakhmatullin
Date: Tue, 7 Nov 2023 12:34:35 +0400
Subject: [PATCH 0071/1041] Fix and remove most of the entries from the mypy
ignore list (#6137)
---
scrapy/contracts/__init__.py | 4 +-
scrapy/loader/__init__.py | 2 +-
scrapy/pipelines/images.py | 3 +-
scrapy/utils/testproc.py | 2 +-
setup.cfg | 73 ++----------------------
tests/mocks/dummydbm.py | 3 +-
tests/test_exporters.py | 9 +--
tests/test_http_request.py | 5 +-
tests/test_linkextractors.py | 3 +-
tests/test_loader.py | 3 +-
tests/test_pipeline_crawl.py | 4 +-
tests/test_pipeline_files.py | 13 ++---
tests/test_pipeline_images.py | 16 +++---
tests/test_request_cb_kwargs.py | 4 +-
tests/test_scheduler.py | 5 +-
tests/test_spidermiddleware_httperror.py | 3 +-
tests/test_spidermiddleware_referer.py | 31 +++++-----
tests/test_utils_serialize.py | 2 +-
tests/test_utils_url.py | 4 +-
19 files changed, 67 insertions(+), 122 deletions(-)
diff --git a/scrapy/contracts/__init__.py b/scrapy/contracts/__init__.py
index 2d9ddd89a..d46eb7c51 100644
--- a/scrapy/contracts/__init__.py
+++ b/scrapy/contracts/__init__.py
@@ -3,7 +3,7 @@ import sys
from functools import wraps
from inspect import getmembers
from types import CoroutineType
-from typing import AsyncGenerator, Dict
+from typing import AsyncGenerator, Dict, Optional, Type
from unittest import TestCase
from scrapy.http import Request
@@ -14,7 +14,7 @@ from scrapy.utils.spider import iterate_spider_output
class Contract:
"""Abstract class for contracts"""
- request_cls = None
+ request_cls: Optional[Type[Request]] = None
def __init__(self, method, *args):
self.testcase_pre = _create_testcase(method, f"@{self.name} pre-hook")
diff --git a/scrapy/loader/__init__.py b/scrapy/loader/__init__.py
index 91337b949..1042a3d48 100644
--- a/scrapy/loader/__init__.py
+++ b/scrapy/loader/__init__.py
@@ -78,7 +78,7 @@ class ItemLoader(itemloaders.ItemLoader):
read-only.
"""
- default_item_class = Item
+ default_item_class: type = Item
default_selector_class = Selector
def __init__(self, item=None, selector=None, response=None, parent=None, **context):
diff --git a/scrapy/pipelines/images.py b/scrapy/pipelines/images.py
index 9d18144ee..1bd9832a8 100644
--- a/scrapy/pipelines/images.py
+++ b/scrapy/pipelines/images.py
@@ -8,6 +8,7 @@ import hashlib
import warnings
from contextlib import suppress
from io import BytesIO
+from typing import Dict, Tuple
from itemadapter import ItemAdapter
@@ -48,7 +49,7 @@ class ImagesPipeline(FilesPipeline):
MIN_WIDTH = 0
MIN_HEIGHT = 0
EXPIRES = 90
- THUMBS = {}
+ THUMBS: Dict[str, Tuple[int, int]] = {}
DEFAULT_IMAGES_URLS_FIELD = "image_urls"
DEFAULT_IMAGES_RESULT_FIELD = "images"
diff --git a/scrapy/utils/testproc.py b/scrapy/utils/testproc.py
index 0688e014b..3bdffcaa7 100644
--- a/scrapy/utils/testproc.py
+++ b/scrapy/utils/testproc.py
@@ -11,7 +11,7 @@ from twisted.python.failure import Failure
class ProcessTest:
- command = None
+ command: Optional[str] = None
prefix = [sys.executable, "-m", "scrapy.cmdline"]
cwd = os.getcwd() # trial chdirs to temp dir
diff --git a/setup.cfg b/setup.cfg
index d23549f10..151e784c6 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -8,82 +8,17 @@ universal=1
ignore_missing_imports = true
# Interface classes are hard to support
+
[mypy-twisted.internet.interfaces]
follow_imports = skip
+[mypy-scrapy.interfaces]
+ignore_errors = True
+
[mypy-twisted.internet.reactor]
follow_imports = skip
# FIXME: remove the following sections once the issues are solved
-[mypy-scrapy.interfaces]
-ignore_errors = True
-
-[mypy-scrapy.pipelines.images]
-ignore_errors = True
-
[mypy-scrapy.settings.default_settings]
ignore_errors = True
-
-[mypy-tests.mocks.dummydbm]
-ignore_errors = True
-
-[mypy-tests.test_command_fetch]
-ignore_errors = True
-
-[mypy-tests.test_command_parse]
-ignore_errors = True
-
-[mypy-tests.test_command_shell]
-ignore_errors = True
-
-[mypy-tests.test_command_version]
-ignore_errors = True
-
-[mypy-tests.test_contracts]
-ignore_errors = True
-
-[mypy-tests.test_downloader_handlers]
-ignore_errors = True
-
-[mypy-tests.test_exporters]
-ignore_errors = True
-
-[mypy-tests.test_http_request]
-ignore_errors = True
-
-[mypy-tests.test_linkextractors]
-ignore_errors = True
-
-[mypy-tests.test_loader]
-ignore_errors = True
-
-[mypy-tests.test_loader_deprecated]
-ignore_errors = True
-
-[mypy-tests.test_pipeline_crawl]
-ignore_errors = True
-
-[mypy-tests.test_pipeline_files]
-ignore_errors = True
-
-[mypy-tests.test_pipeline_images]
-ignore_errors = True
-
-[mypy-tests.test_request_cb_kwargs]
-ignore_errors = True
-
-[mypy-tests.test_scheduler]
-ignore_errors = True
-
-[mypy-tests.test_spidermiddleware_httperror]
-ignore_errors = True
-
-[mypy-tests.test_spidermiddleware_referer]
-ignore_errors = True
-
-[mypy-tests.test_utils_serialize]
-ignore_errors = True
-
-[mypy-tests.test_utils_url]
-ignore_errors = True
diff --git a/tests/mocks/dummydbm.py b/tests/mocks/dummydbm.py
index e77f53666..2869ff8f7 100644
--- a/tests/mocks/dummydbm.py
+++ b/tests/mocks/dummydbm.py
@@ -1,5 +1,6 @@
"""DBM-like dummy module"""
import collections
+from typing import Any, DefaultDict
class DummyDB(dict):
@@ -12,7 +13,7 @@ class DummyDB(dict):
error = KeyError
-_DATABASES = collections.defaultdict(DummyDB)
+_DATABASES: DefaultDict[Any, DummyDB] = collections.defaultdict(DummyDB)
def open(file, flag="r", mode=0o666):
diff --git a/tests/test_exporters.py b/tests/test_exporters.py
index f4e82705a..c11913365 100644
--- a/tests/test_exporters.py
+++ b/tests/test_exporters.py
@@ -7,6 +7,7 @@ import tempfile
import unittest
from datetime import datetime
from io import BytesIO
+from typing import Any
import lxml.etree
from itemadapter import ItemAdapter
@@ -53,8 +54,8 @@ class CustomFieldDataclass:
class BaseItemExporterTest(unittest.TestCase):
- item_class = TestItem
- custom_field_item_class = CustomFieldItem
+ item_class: type = TestItem
+ custom_field_item_class: type = CustomFieldItem
def setUp(self):
self.i = self.item_class(name="John\xa3", age="22")
@@ -517,7 +518,7 @@ class XmlItemExporterDataclassTest(XmlItemExporterTest):
class JsonLinesItemExporterTest(BaseItemExporterTest):
- _expected_nested = {
+ _expected_nested: Any = {
"name": "Jesus",
"age": {"name": "Maria", "age": {"name": "Joseph", "age": "22"}},
}
@@ -665,7 +666,7 @@ class JsonItemExporterDataclassTest(JsonItemExporterTest):
class CustomExporterItemTest(unittest.TestCase):
- item_class = TestItem
+ item_class: type = TestItem
def setUp(self):
if self.item_class is None:
diff --git a/tests/test_http_request.py b/tests/test_http_request.py
index 2bd68f846..6dc9ec8b7 100644
--- a/tests/test_http_request.py
+++ b/tests/test_http_request.py
@@ -3,6 +3,7 @@ import re
import unittest
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
@@ -21,8 +22,8 @@ from scrapy.utils.python import to_bytes, to_unicode
class RequestTest(unittest.TestCase):
request_class = Request
default_method = "GET"
- default_headers = {}
- default_meta = {}
+ default_headers: Dict[bytes, List[bytes]] = {}
+ default_meta: Dict[str, Any] = {}
def test_init(self):
# Request requires url in the __init__ method
diff --git a/tests/test_linkextractors.py b/tests/test_linkextractors.py
index c90065e67..18e9608c1 100644
--- a/tests/test_linkextractors.py
+++ b/tests/test_linkextractors.py
@@ -1,6 +1,7 @@
import pickle
import re
import unittest
+from typing import Optional
from packaging.version import Version
from pytest import mark
@@ -15,7 +16,7 @@ from tests import get_testdata
# a hack to skip base class tests in pytest
class Base:
class LinkExtractorTestCase(unittest.TestCase):
- extractor_cls = None
+ extractor_cls: Optional[type] = None
def setUp(self):
body = get_testdata("link_extractor", "linkextractor.html")
diff --git a/tests/test_loader.py b/tests/test_loader.py
index 0dc2de172..b0b7f8723 100644
--- a/tests/test_loader.py
+++ b/tests/test_loader.py
@@ -1,5 +1,6 @@
import dataclasses
import unittest
+from typing import Optional
import attr
from itemadapter import ItemAdapter
@@ -87,7 +88,7 @@ class BasicItemLoaderTest(unittest.TestCase):
class InitializationTestMixin:
- item_class = None
+ item_class: Optional[type] = None
def test_keep_single_value(self):
"""Loaded item should contain values from the initial item"""
diff --git a/tests/test_pipeline_crawl.py b/tests/test_pipeline_crawl.py
index cb9464949..ed8483483 100644
--- a/tests/test_pipeline_crawl.py
+++ b/tests/test_pipeline_crawl.py
@@ -1,5 +1,6 @@
import shutil
from pathlib import Path
+from typing import Optional, Set
from testfixtures import LogCapture
from twisted.internet import defer
@@ -54,7 +55,7 @@ class FileDownloadCrawlTestCase(TestCase):
store_setting_key = "FILES_STORE"
media_key = "files"
media_urls_key = "file_urls"
- expected_checksums = {
+ expected_checksums: Optional[Set[str]] = {
"5547178b89448faf0015a13f904c936e",
"c2281c83670e31d8aaab7cb642b824db",
"ed3f6538dc15d4d9179dae57319edc5f",
@@ -193,6 +194,7 @@ class FileDownloadCrawlTestCase(TestCase):
)
+skip_pillow: Optional[str]
try:
from PIL import Image # noqa: imported just to check for the import error
except ImportError:
diff --git a/tests/test_pipeline_files.py b/tests/test_pipeline_files.py
index 468751446..e7000e314 100644
--- a/tests/test_pipeline_files.py
+++ b/tests/test_pipeline_files.py
@@ -7,6 +7,7 @@ from io import BytesIO
from pathlib import Path
from shutil import rmtree
from tempfile import mkdtemp
+from typing import Dict, List
from unittest import mock
from urllib.parse import urlparse
@@ -308,11 +309,11 @@ class FilesPipelineTestCaseFieldsDataClass(
class FilesPipelineTestAttrsItem:
name = attr.ib(default="")
# default fields
- file_urls = attr.ib(default=lambda: [])
- files = attr.ib(default=lambda: [])
+ file_urls: List[str] = attr.ib(default=lambda: [])
+ files: List[Dict[str, str]] = attr.ib(default=lambda: [])
# overridden fields
- custom_file_urls = attr.ib(default=lambda: [])
- custom_files = attr.ib(default=lambda: [])
+ custom_file_urls: List[str] = attr.ib(default=lambda: [])
+ custom_files: List[Dict[str, str]] = attr.ib(default=lambda: [])
class FilesPipelineTestCaseFieldsAttrsItem(
@@ -690,7 +691,3 @@ def _prepare_request_object(item_url, flags=None):
item_url,
meta={"response": Response(item_url, status=200, body=b"data", flags=flags)},
)
-
-
-if __name__ == "__main__":
- unittest.main()
diff --git a/tests/test_pipeline_images.py b/tests/test_pipeline_images.py
index 8924875d1..2e2e06b89 100644
--- a/tests/test_pipeline_images.py
+++ b/tests/test_pipeline_images.py
@@ -5,6 +5,7 @@ import random
import warnings
from shutil import rmtree
from tempfile import mkdtemp
+from typing import Dict, List, Optional
from unittest.mock import patch
import attr
@@ -18,6 +19,7 @@ from scrapy.pipelines.images import ImageException, ImagesPipeline, NoimagesDrop
from scrapy.settings import Settings
from scrapy.utils.python import to_bytes
+skip_pillow: Optional[str]
try:
from PIL import Image
except ImportError:
@@ -26,7 +28,7 @@ except ImportError:
)
else:
encoders = {"jpeg_encoder", "jpeg_decoder"}
- if not encoders.issubset(set(Image.core.__dict__)):
+ if not encoders.issubset(set(Image.core.__dict__)): # type: ignore[attr-defined]
skip_pillow = "Missing JPEG encoders"
else:
skip_pillow = None
@@ -404,11 +406,11 @@ class ImagesPipelineTestCaseFieldsDataClass(
class ImagesPipelineTestAttrsItem:
name = attr.ib(default="")
# default fields
- image_urls = attr.ib(default=lambda: [])
- images = attr.ib(default=lambda: [])
+ image_urls: List[str] = attr.ib(default=lambda: [])
+ images: List[Dict[str, str]] = attr.ib(default=lambda: [])
# overridden fields
- custom_image_urls = attr.ib(default=lambda: [])
- custom_images = attr.ib(default=lambda: [])
+ custom_image_urls: List[str] = attr.ib(default=lambda: [])
+ custom_images: List[Dict[str, str]] = attr.ib(default=lambda: [])
class ImagesPipelineTestCaseFieldsAttrsItem(
@@ -646,7 +648,3 @@ def _create_image(format, *a, **kw):
Image.new(*a, **kw).save(buf, format)
buf.seek(0)
return Image.open(buf), buf
-
-
-if __name__ == "__main__":
- unittest.main()
diff --git a/tests/test_request_cb_kwargs.py b/tests/test_request_cb_kwargs.py
index adce6cda7..7299972f6 100644
--- a/tests/test_request_cb_kwargs.py
+++ b/tests/test_request_cb_kwargs.py
@@ -1,3 +1,5 @@
+from typing import List
+
from testfixtures import LogCapture
from twisted.internet import defer
from twisted.trial.unittest import TestCase
@@ -62,7 +64,7 @@ class KeywordArgumentsSpider(MockServerSpider):
},
}
- checks = []
+ checks: List[bool] = []
def start_requests(self):
data = {"key": "value", "number": 123, "callback": "some_callback"}
diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py
index ef9b360c4..f8465a5ff 100644
--- a/tests/test_scheduler.py
+++ b/tests/test_scheduler.py
@@ -2,6 +2,7 @@ import collections
import shutil
import tempfile
import unittest
+from typing import Optional
from twisted.internet import defer
from twisted.trial.unittest import TestCase
@@ -59,7 +60,7 @@ class MockCrawler(Crawler):
class SchedulerHandler:
- priority_queue_cls = None
+ priority_queue_cls: Optional[str] = None
jobdir = None
def create_scheduler(self):
@@ -253,7 +254,7 @@ def _is_scheduling_fair(enqueued_slots, dequeued_slots):
class DownloaderAwareSchedulerTestMixin:
- priority_queue_cls = "scrapy.pqueues.DownloaderAwarePriorityQueue"
+ priority_queue_cls: Optional[str] = "scrapy.pqueues.DownloaderAwarePriorityQueue"
reopen = False
def test_logic(self):
diff --git a/tests/test_spidermiddleware_httperror.py b/tests/test_spidermiddleware_httperror.py
index 1d5a887cc..044455415 100644
--- a/tests/test_spidermiddleware_httperror.py
+++ b/tests/test_spidermiddleware_httperror.py
@@ -1,4 +1,5 @@
import logging
+from typing import Set
from unittest import TestCase
from testfixtures import LogCapture
@@ -16,7 +17,7 @@ from tests.spiders import MockServerSpider
class _HttpErrorSpider(MockServerSpider):
name = "httperror"
- bypass_status_codes = set()
+ bypass_status_codes: Set[int] = set()
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
diff --git a/tests/test_spidermiddleware_referer.py b/tests/test_spidermiddleware_referer.py
index 2f0dda269..afffa87fb 100644
--- a/tests/test_spidermiddleware_referer.py
+++ b/tests/test_spidermiddleware_referer.py
@@ -1,4 +1,5 @@
import warnings
+from typing import Any, Dict, List, Optional, Tuple
from unittest import TestCase
from urllib.parse import urlparse
@@ -31,10 +32,10 @@ from scrapy.spiders import Spider
class TestRefererMiddleware(TestCase):
- req_meta = {}
- resp_headers = {}
- settings = {}
- scenarii = [
+ req_meta: Dict[str, Any] = {}
+ resp_headers: Dict[str, str] = {}
+ settings: Dict[str, Any] = {}
+ scenarii: List[Tuple[str, str, Optional[bytes]]] = [
("http://scrapytest.org", "http://scrapytest.org/", b"http://scrapytest.org"),
]
@@ -64,7 +65,7 @@ class MixinDefault:
with some additional filtering of s3://
"""
- scenarii = [
+ scenarii: List[Tuple[str, str, Optional[bytes]]] = [
("https://example.com/", "https://scrapy.org/", b"https://example.com/"),
("http://example.com/", "http://scrapy.org/", b"http://example.com/"),
("http://example.com/", "https://scrapy.org/", b"http://example.com/"),
@@ -85,7 +86,7 @@ class MixinDefault:
class MixinNoReferrer:
- scenarii = [
+ scenarii: List[Tuple[str, str, Optional[bytes]]] = [
("https://example.com/page.html", "https://example.com/", None),
("http://www.example.com/", "https://scrapy.org/", None),
("http://www.example.com/", "http://scrapy.org/", None),
@@ -95,7 +96,7 @@ class MixinNoReferrer:
class MixinNoReferrerWhenDowngrade:
- scenarii = [
+ scenarii: List[Tuple[str, str, Optional[bytes]]] = [
# TLS to TLS: send non-empty referrer
(
"https://example.com/page.html",
@@ -177,7 +178,7 @@ class MixinNoReferrerWhenDowngrade:
class MixinSameOrigin:
- scenarii = [
+ scenarii: List[Tuple[str, str, Optional[bytes]]] = [
# Same origin (protocol, host, port): send referrer
(
"https://example.com/page.html",
@@ -246,7 +247,7 @@ class MixinSameOrigin:
class MixinOrigin:
- scenarii = [
+ scenarii: List[Tuple[str, str, Optional[bytes]]] = [
# TLS or non-TLS to TLS or non-TLS: referrer origin is sent (yes, even for downgrades)
(
"https://example.com/page.html",
@@ -270,7 +271,7 @@ class MixinOrigin:
class MixinStrictOrigin:
- scenarii = [
+ scenarii: List[Tuple[str, str, Optional[bytes]]] = [
# TLS or non-TLS to TLS or non-TLS: referrer origin is sent but not for downgrades
(
"https://example.com/page.html",
@@ -298,7 +299,7 @@ class MixinStrictOrigin:
class MixinOriginWhenCrossOrigin:
- scenarii = [
+ scenarii: List[Tuple[str, str, Optional[bytes]]] = [
# Same origin (protocol, host, port): send referrer
(
"https://example.com/page.html",
@@ -405,7 +406,7 @@ class MixinOriginWhenCrossOrigin:
class MixinStrictOriginWhenCrossOrigin:
- scenarii = [
+ scenarii: List[Tuple[str, str, Optional[bytes]]] = [
# Same origin (protocol, host, port): send referrer
(
"https://example.com/page.html",
@@ -517,7 +518,7 @@ class MixinStrictOriginWhenCrossOrigin:
class MixinUnsafeUrl:
- scenarii = [
+ scenarii: List[Tuple[str, str, Optional[bytes]]] = [
# TLS to TLS: send referrer
(
"https://example.com/sekrit.html",
@@ -920,7 +921,9 @@ class TestPolicyHeaderPrecedence004(
class TestReferrerOnRedirect(TestRefererMiddleware):
settings = {"REFERRER_POLICY": "scrapy.spidermiddlewares.referer.UnsafeUrlPolicy"}
- scenarii = [
+ scenarii: List[
+ Tuple[str, str, Tuple[Tuple[int, str], ...], Optional[bytes], Optional[bytes]]
+ ] = [ # type: ignore[assignment]
(
"http://scrapytest.org/1", # parent
"http://scrapytest.org/2", # target
diff --git a/tests/test_utils_serialize.py b/tests/test_utils_serialize.py
index 5cdcc7f7c..055db4e5b 100644
--- a/tests/test_utils_serialize.py
+++ b/tests/test_utils_serialize.py
@@ -58,7 +58,7 @@ class JsonEncoderTestCase(unittest.TestCase):
self.assertIn(r.url, rs)
self.assertIn(str(r.status), rs)
- def test_encode_dataclass_item(self):
+ def test_encode_dataclass_item(self) -> None:
@dataclasses.dataclass
class TestDataClass:
name: str
diff --git a/tests/test_utils_url.py b/tests/test_utils_url.py
index 65522f0fd..59a95b0e2 100644
--- a/tests/test_utils_url.py
+++ b/tests/test_utils_url.py
@@ -364,7 +364,7 @@ for k, args in enumerate(
setattr(GuessSchemeTest, t_method.__name__, t_method)
# TODO: the following tests do not pass with current implementation
-for k, args in enumerate(
+for k, skip_args in enumerate(
[
(
r"C:\absolute\path\to\a\file.html",
@@ -374,7 +374,7 @@ for k, args in enumerate(
],
start=1,
):
- t_method = create_skipped_scheme_t(args)
+ t_method = create_skipped_scheme_t(skip_args)
t_method.__name__ = f"test_uri_skipped_{k:03}"
setattr(GuessSchemeTest, t_method.__name__, t_method)
From a6cee787dd45fabba3f39dbb1752baeef649f5b7 Mon Sep 17 00:00:00 2001
From: Andrey Rakhmatullin
Date: Sat, 11 Nov 2023 20:00:12 +0400
Subject: [PATCH 0072/1041] Improve type hints for copy() and replace() in
Request and Response.
---
.../downloadermiddlewares/httpcompression.py | 6 +--
scrapy/downloadermiddlewares/redirect.py | 1 +
scrapy/http/request/__init__.py | 37 +++++++++++++++----
scrapy/http/request/json_request.py | 26 +++++++++++--
scrapy/http/response/__init__.py | 31 +++++++++++++---
5 files changed, 81 insertions(+), 20 deletions(-)
diff --git a/scrapy/downloadermiddlewares/httpcompression.py b/scrapy/downloadermiddlewares/httpcompression.py
index 56a58a750..d44eb933a 100644
--- a/scrapy/downloadermiddlewares/httpcompression.py
+++ b/scrapy/downloadermiddlewares/httpcompression.py
@@ -2,7 +2,7 @@ from __future__ import annotations
import io
import zlib
-from typing import TYPE_CHECKING, List, Optional, Union
+from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union
from scrapy import Request, Spider
from scrapy.crawler import Crawler
@@ -74,12 +74,12 @@ class HttpCompressionMiddleware:
respcls = responsetypes.from_args(
headers=response.headers, url=response.url, body=decoded_body
)
- kwargs = dict(cls=respcls, body=decoded_body)
+ kwargs: Dict[str, Any] = dict(body=decoded_body)
if issubclass(respcls, TextResponse):
# force recalculating the encoding until we make sure the
# responsetypes guessing is reliable
kwargs["encoding"] = None
- response = response.replace(**kwargs)
+ response = response.replace(cls=respcls, **kwargs)
if not content_encoding:
del response.headers["Content-Encoding"]
diff --git a/scrapy/downloadermiddlewares/redirect.py b/scrapy/downloadermiddlewares/redirect.py
index 814b1a561..7b1401ac8 100644
--- a/scrapy/downloadermiddlewares/redirect.py
+++ b/scrapy/downloadermiddlewares/redirect.py
@@ -27,6 +27,7 @@ def _build_redirect_request(
redirect_request = source_request.replace(
url=url,
**kwargs,
+ cls=None,
cookies=None,
)
if "Cookie" in redirect_request.headers:
diff --git a/scrapy/http/request/__init__.py b/scrapy/http/request/__init__.py
index a1c5a5e51..4effc2178 100644
--- a/scrapy/http/request/__init__.py
+++ b/scrapy/http/request/__init__.py
@@ -4,8 +4,11 @@ requests in Scrapy.
See documentation in docs/topics/request-response.rst
"""
+from __future__ import annotations
+
import inspect
from typing import (
+ TYPE_CHECKING,
Any,
AnyStr,
Callable,
@@ -19,7 +22,7 @@ from typing import (
Type,
TypeVar,
Union,
- cast,
+ overload,
)
from w3lib.url import safe_url_string
@@ -31,6 +34,11 @@ from scrapy.utils.python import to_bytes
from scrapy.utils.trackref import object_ref
from scrapy.utils.url import escape_ajax
+if TYPE_CHECKING:
+ # typing.Self requires Python 3.11
+ from typing_extensions import Self
+
+
RequestTypeVar = TypeVar("RequestTypeVar", bound="Request")
@@ -173,23 +181,36 @@ class Request(object_ref):
def __repr__(self) -> str:
return f"<{self.method} {self.url}>"
- def copy(self) -> "Request":
+ def copy(self) -> Self:
return self.replace()
- def replace(self, *args: Any, **kwargs: Any) -> "Request":
+ @overload
+ def replace(
+ self, *args: Any, cls: Type[RequestTypeVar], **kwargs: Any
+ ) -> RequestTypeVar:
+ ...
+
+ @overload
+ def replace(self, *args: Any, cls: None = None, **kwargs: Any) -> Self:
+ ...
+
+ def replace(
+ self, *args: Any, cls: Optional[Type[Request]] = None, **kwargs: Any
+ ) -> Request:
"""Create a new Request with the same attributes except for those given new values"""
for x in self.attributes:
kwargs.setdefault(x, getattr(self, x))
- cls = kwargs.pop("cls", self.__class__)
- return cast(Request, cls(*args, **kwargs))
+ if cls is None:
+ cls = self.__class__
+ return cls(*args, **kwargs)
@classmethod
def from_curl(
- cls: Type[RequestTypeVar],
+ cls,
curl_command: str,
ignore_unknown_options: bool = True,
**kwargs: Any,
- ) -> RequestTypeVar:
+ ) -> Self:
"""Create a Request object from a string containing a `cURL
`_ command. It populates the HTTP method, the
URL, the headers, the cookies and the body. It accepts the same
@@ -221,7 +242,7 @@ class Request(object_ref):
request_kwargs.update(kwargs)
return cls(**request_kwargs)
- def to_dict(self, *, spider: Optional["scrapy.Spider"] = None) -> Dict[str, Any]:
+ def to_dict(self, *, spider: Optional[scrapy.Spider] = None) -> Dict[str, Any]:
"""Return a dictionary containing the Request's data.
Use :func:`~scrapy.utils.request.request_from_dict` to convert back into a :class:`~scrapy.Request` object.
diff --git a/scrapy/http/request/json_request.py b/scrapy/http/request/json_request.py
index 1dd9e6c87..5c09835e4 100644
--- a/scrapy/http/request/json_request.py
+++ b/scrapy/http/request/json_request.py
@@ -5,12 +5,18 @@ This module implements the JsonRequest class which is a more convenient class
See documentation in docs/topics/request-response.rst
"""
+from __future__ import annotations
+
import copy
import json
import warnings
-from typing import Any, Optional, Tuple
+from typing import TYPE_CHECKING, Any, Optional, Tuple, Type, overload
-from scrapy.http.request import Request
+from scrapy.http.request import Request, RequestTypeVar
+
+if TYPE_CHECKING:
+ # typing.Self requires Python 3.11
+ from typing_extensions import Self
class JsonRequest(Request):
@@ -44,7 +50,19 @@ class JsonRequest(Request):
def dumps_kwargs(self) -> dict:
return self._dumps_kwargs
- def replace(self, *args: Any, **kwargs: Any) -> Request:
+ @overload
+ def replace(
+ self, *args: Any, cls: Type[RequestTypeVar], **kwargs: Any
+ ) -> RequestTypeVar:
+ ...
+
+ @overload
+ def replace(self, *args: Any, cls: None = None, **kwargs: Any) -> Self:
+ ...
+
+ def replace(
+ self, *args: Any, cls: Optional[Type[Request]] = None, **kwargs: Any
+ ) -> Request:
body_passed = kwargs.get("body", None) is not None
data = kwargs.pop("data", None)
data_passed = data is not None
@@ -54,7 +72,7 @@ class JsonRequest(Request):
elif not body_passed and data_passed:
kwargs["body"] = self._dumps(data)
- return super().replace(*args, **kwargs)
+ return super().replace(*args, cls=cls, **kwargs)
def _dumps(self, data: dict) -> str:
"""Convert to JSON"""
diff --git a/scrapy/http/response/__init__.py b/scrapy/http/response/__init__.py
index 6eae3e8b3..e889a6460 100644
--- a/scrapy/http/response/__init__.py
+++ b/scrapy/http/response/__init__.py
@@ -19,8 +19,10 @@ from typing import (
Mapping,
Optional,
Tuple,
+ Type,
+ TypeVar,
Union,
- cast,
+ overload,
)
from urllib.parse import urljoin
@@ -33,9 +35,15 @@ from scrapy.link import Link
from scrapy.utils.trackref import object_ref
if TYPE_CHECKING:
+ # typing.Self requires Python 3.11
+ from typing_extensions import Self
+
from scrapy.selector import SelectorList
+ResponseTypeVar = TypeVar("ResponseTypeVar", bound="Response")
+
+
class Response(object_ref):
"""An object that represents an HTTP response, which is usually
downloaded (by the Downloader) and fed to the Spiders for processing.
@@ -132,16 +140,29 @@ class Response(object_ref):
def __repr__(self) -> str:
return f"<{self.status} {self.url}>"
- def copy(self) -> Response:
+ def copy(self) -> Self:
"""Return a copy of this Response"""
return self.replace()
- def replace(self, *args: Any, **kwargs: Any) -> Response:
+ @overload
+ def replace(
+ self, *args: Any, cls: Type[ResponseTypeVar], **kwargs: Any
+ ) -> ResponseTypeVar:
+ ...
+
+ @overload
+ def replace(self, *args: Any, cls: None = None, **kwargs: Any) -> Self:
+ ...
+
+ def replace(
+ self, *args: Any, cls: Optional[Type[Response]] = None, **kwargs: Any
+ ) -> Response:
"""Create a new Response with the same attributes except for those given new values"""
for x in self.attributes:
kwargs.setdefault(x, getattr(self, x))
- cls = kwargs.pop("cls", self.__class__)
- return cast(Response, cls(*args, **kwargs))
+ if cls is None:
+ cls = self.__class__
+ return cls(*args, **kwargs)
def urljoin(self, url: str) -> str:
"""Join this Response's url with a possible relative url to form an
From 5d55e4f56b77168b961db15e0f03d608fad69e7d Mon Sep 17 00:00:00 2001
From: Andrey Rakhmatullin
Date: Sun, 12 Nov 2023 20:15:06 +0400
Subject: [PATCH 0073/1041] Add mypy tests.
---
tests_typing/test_http_request.mypy-testing | 66 ++++++++++++++++++++
tests_typing/test_http_response.mypy-testing | 45 +++++++++++++
tox.ini | 8 +++
3 files changed, 119 insertions(+)
create mode 100644 tests_typing/test_http_request.mypy-testing
create mode 100644 tests_typing/test_http_response.mypy-testing
diff --git a/tests_typing/test_http_request.mypy-testing b/tests_typing/test_http_request.mypy-testing
new file mode 100644
index 000000000..a306b15fe
--- /dev/null
+++ b/tests_typing/test_http_request.mypy-testing
@@ -0,0 +1,66 @@
+import pytest
+
+from scrapy import Request
+from scrapy.http import JsonRequest
+
+
+class MyRequest(Request):
+ pass
+
+
+class MyRequest2(Request):
+ pass
+
+
+@pytest.mark.mypy_testing
+def mypy_test_headers():
+ Request("data:,", headers=1) # E: Argument "headers" to "Request" has incompatible type "int"; expected "Mapping[str, Any] | Iterable[tuple[str, Any]] | None"
+ Request("data:,", headers=None)
+ Request("data:,", headers={})
+ Request("data:,", headers=[])
+ Request("data:,", headers={"foo": "bar"})
+ Request("data:,", headers={b"foo": "bar"})
+ Request("data:,", headers={"foo": b"bar"})
+ Request("data:,", headers=[("foo", "bar")])
+ Request("data:,", headers=[(b"foo", "bar")])
+ Request("data:,", headers=[("foo", b"bar")])
+
+
+@pytest.mark.mypy_testing
+def mypy_test_copy():
+ req = Request("data:,")
+ reveal_type(req) # R: scrapy.http.request.Request
+ req_copy = req.copy()
+ reveal_type(req_copy) # R: scrapy.http.request.Request
+
+ req = MyRequest("data:,")
+ reveal_type(req) # R: __main__.MyRequest
+ req_copy = req.copy()
+ reveal_type(req_copy) # R: __main__.MyRequest
+
+
+@pytest.mark.mypy_testing
+def mypy_test_replace():
+ req = Request("data:,")
+ reveal_type(req) # R: scrapy.http.request.Request
+ req_copy = req.replace(body=b"a")
+ reveal_type(req_copy) # R: scrapy.http.request.Request
+
+ req = MyRequest("data:,")
+ reveal_type(req) # R: __main__.MyRequest
+ req_copy = req.replace(body=b"a")
+ reveal_type(req_copy) # R: __main__.MyRequest
+ req_copy2 = req.replace(body=b"a", cls=MyRequest2)
+ reveal_type(req_copy2) # R: __main__.MyRequest2
+
+
+@pytest.mark.mypy_testing
+def mypy_test_jsonrequest_copy_replace():
+ req = JsonRequest("data:,")
+ reveal_type(req) # R: scrapy.http.request.json_request.JsonRequest
+ req_copy = req.copy()
+ reveal_type(req_copy) # R: scrapy.http.request.json_request.JsonRequest
+ req_copy = req.replace(body=b"a")
+ reveal_type(req_copy) # R: scrapy.http.request.json_request.JsonRequest
+ req_copy_my = req.replace(body=b"a", cls=MyRequest)
+ reveal_type(req_copy_my) # R: __main__.MyRequest
diff --git a/tests_typing/test_http_response.mypy-testing b/tests_typing/test_http_response.mypy-testing
new file mode 100644
index 000000000..66ac6ad1d
--- /dev/null
+++ b/tests_typing/test_http_response.mypy-testing
@@ -0,0 +1,45 @@
+import pytest
+
+from scrapy.http import HtmlResponse, Response, TextResponse
+
+
+@pytest.mark.mypy_testing
+def mypy_test_headers():
+ Response("data:,", headers=1) # E: Argument "headers" to "Response" has incompatible type "int"; expected "Mapping[str, Any] | Iterable[tuple[str, Any]] | None"
+ Response("data:,", headers=None)
+ Response("data:,", headers={})
+ Response("data:,", headers=[])
+ Response("data:,", headers={"foo": "bar"})
+ Response("data:,", headers={b"foo": "bar"})
+ Response("data:,", headers={"foo": b"bar"})
+ Response("data:,", headers=[("foo", "bar")])
+ Response("data:,", headers=[(b"foo", "bar")])
+ Response("data:,", headers=[("foo", b"bar")])
+
+
+@pytest.mark.mypy_testing
+def mypy_test_copy():
+ resp = Response("data:,")
+ reveal_type(resp) # R: scrapy.http.response.Response
+ resp_copy = resp.copy()
+ reveal_type(resp_copy) # R: scrapy.http.response.Response
+
+ resp = HtmlResponse("data:,")
+ reveal_type(resp) # R: scrapy.http.response.html.HtmlResponse
+ resp_copy = resp.copy()
+ reveal_type(resp_copy) # R: scrapy.http.response.html.HtmlResponse
+
+
+@pytest.mark.mypy_testing
+def mypy_test_replace():
+ resp = Response("data:,")
+ reveal_type(resp) # R: scrapy.http.response.Response
+ resp_copy = resp.replace(body=b"a")
+ reveal_type(resp_copy) # R: scrapy.http.response.Response
+
+ resp = HtmlResponse("data:,")
+ reveal_type(resp) # R: scrapy.http.response.html.HtmlResponse
+ resp_copy = resp.replace(body=b"a")
+ reveal_type(resp_copy) # R: scrapy.http.response.html.HtmlResponse
+ resp_copy2 = resp.replace(body=b"a", cls=TextResponse)
+ reveal_type(resp_copy2) # R: scrapy.http.response.text.TextResponse
diff --git a/tox.ini b/tox.ini
index 932c0b805..c3fa54339 100644
--- a/tox.ini
+++ b/tox.ini
@@ -46,6 +46,14 @@ deps =
commands =
mypy {posargs: scrapy tests}
+[testenv:typing-tests]
+deps =
+ {[testenv]deps}
+ {[testenv:typing]deps}
+ pytest-mypy-testing==0.1.1
+commands =
+ pytest {posargs: tests_typing}
+
[testenv:pre-commit]
basepython = python3
deps =
From 204d6e180a7c8bc59f188230fb001339a5a43476 Mon Sep 17 00:00:00 2001
From: Andrey Rakhmatullin
Date: Sun, 12 Nov 2023 20:47:52 +0400
Subject: [PATCH 0074/1041] Enable typing-tests in CI.
---
.github/workflows/checks.yml | 3 +++
1 file changed, 3 insertions(+)
diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml
index d6fc0f6c5..ed1629b67 100644
--- a/.github/workflows/checks.yml
+++ b/.github/workflows/checks.yml
@@ -18,6 +18,9 @@ jobs:
- python-version: 3.8
env:
TOXENV: typing
+ - python-version: 3.8
+ env:
+ TOXENV: typing-tests
- python-version: "3.11" # Keep in sync with .readthedocs.yml
env:
TOXENV: docs
From 8776b4a6fb64e87c7baf96ae256e04a09246e360 Mon Sep 17 00:00:00 2001
From: Andrey Rakhmatullin
Date: Sun, 12 Nov 2023 20:52:29 +0400
Subject: [PATCH 0075/1041] Fix env deps for typing-tests.
---
tox.ini | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/tox.ini b/tox.ini
index c3fa54339..21ac4c3ff 100644
--- a/tox.ini
+++ b/tox.ini
@@ -48,7 +48,7 @@ commands =
[testenv:typing-tests]
deps =
- {[testenv]deps}
+ -rtests/requirements.txt
{[testenv:typing]deps}
pytest-mypy-testing==0.1.1
commands =
From 492584ec07e2b41f80db86c84215208e04e10d0f Mon Sep 17 00:00:00 2001
From: Kiran <75929997+Kiran1689@users.noreply.github.com>
Date: Tue, 14 Nov 2023 00:43:10 +0530
Subject: [PATCH 0076/1041] 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 db5a73f7bb44704b1751a3d005f53cbcd9846415 Mon Sep 17 00:00:00 2001
From: Andrey Rakhmatullin
Date: Wed, 15 Nov 2023 12:02:39 +0400
Subject: [PATCH 0077/1041] Update the expected mypy output to match the old
Python one.
---
tests_typing/test_http_request.mypy-testing | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/tests_typing/test_http_request.mypy-testing b/tests_typing/test_http_request.mypy-testing
index a306b15fe..636e6895f 100644
--- a/tests_typing/test_http_request.mypy-testing
+++ b/tests_typing/test_http_request.mypy-testing
@@ -14,7 +14,7 @@ class MyRequest2(Request):
@pytest.mark.mypy_testing
def mypy_test_headers():
- Request("data:,", headers=1) # E: Argument "headers" to "Request" has incompatible type "int"; expected "Mapping[str, Any] | Iterable[tuple[str, Any]] | None"
+ Request("data:,", headers=1) # E: Argument "headers" to "Request" has incompatible type "int"; expected "Union[Mapping[str, Any], Iterable[Tuple[str, Any]], None]"
Request("data:,", headers=None)
Request("data:,", headers={})
Request("data:,", headers=[])
From ebdea4037a38bb207f90658b9380fda7a2e3e825 Mon Sep 17 00:00:00 2001
From: Andrey Rakhmatullin
Date: Wed, 15 Nov 2023 12:31:31 +0400
Subject: [PATCH 0078/1041] Update another output line.
---
tests_typing/test_http_response.mypy-testing | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/tests_typing/test_http_response.mypy-testing b/tests_typing/test_http_response.mypy-testing
index 66ac6ad1d..2e58b4fbc 100644
--- a/tests_typing/test_http_response.mypy-testing
+++ b/tests_typing/test_http_response.mypy-testing
@@ -5,7 +5,7 @@ from scrapy.http import HtmlResponse, Response, TextResponse
@pytest.mark.mypy_testing
def mypy_test_headers():
- Response("data:,", headers=1) # E: Argument "headers" to "Response" has incompatible type "int"; expected "Mapping[str, Any] | Iterable[tuple[str, Any]] | None"
+ Response("data:,", headers=1) # E: Argument "headers" to "Response" has incompatible type "int"; expected "Union[Mapping[str, Any], Iterable[Tuple[str, Any]], None]"
Response("data:,", headers=None)
Response("data:,", headers={})
Response("data:,", headers=[])
From 5fccf370b87378fe2db6bdd52b98c1e2a951df3b Mon Sep 17 00:00:00 2001
From: Andrey Rakhmatullin
Date: Wed, 15 Nov 2023 15:38:13 +0100
Subject: [PATCH 0079/1041] 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 0080/1041] Drop the Authorization header on cross-domain
redirect
---
docs/news.rst | 27 ++++++++++++++++++
scrapy/downloadermiddlewares/redirect.py | 10 +++++--
tests/test_downloadermiddleware_redirect.py | 31 +++++++++++++++++++++
3 files changed, 66 insertions(+), 2 deletions(-)
diff --git a/docs/news.rst b/docs/news.rst
index fd8fa3ea3..b19ec2e99 100644
--- a/docs/news.rst
+++ b/docs/news.rst
@@ -3,6 +3,20 @@
Release notes
=============
+.. _release-2.11.1:
+
+Scrapy 2.11.1 (unreleased)
+--------------------------
+
+**Security bug fix:**
+
+- The ``Authorization`` header is now dropped on redirects to a different
+ domain. Please, see the `cw9j-q3vf-hrrv security advisory`_ for more
+ information.
+
+ .. _cw9j-q3vf-hrrv security advisory: https://github.com/scrapy/scrapy/security/advisories/GHSA-cw9j-q3vf-hrrv
+
+
.. _release-2.11.0:
Scrapy 2.11.0 (2023-09-18)
@@ -2869,6 +2883,19 @@ affect subclasses:
(:issue:`3884`)
+.. _release-1.8.4:
+
+Scrapy 1.8.4 (unreleased)
+-------------------------
+
+**Security bug fix:**
+
+- The ``Authorization`` header is now dropped on redirects to a different
+ domain. Please, see the `cw9j-q3vf-hrrv security advisory`_ for more
+ information.
+
+ .. _cw9j-q3vf-hrrv security advisory: https://github.com/scrapy/scrapy/security/advisories/GHSA-cw9j-q3vf-hrrv
+
.. _release-1.8.3:
diff --git a/scrapy/downloadermiddlewares/redirect.py b/scrapy/downloadermiddlewares/redirect.py
index 65f1d2224..3176ed930 100644
--- a/scrapy/downloadermiddlewares/redirect.py
+++ b/scrapy/downloadermiddlewares/redirect.py
@@ -17,11 +17,17 @@ def _build_redirect_request(source_request, *, url, **kwargs):
**kwargs,
cookies=None,
)
- if "Cookie" in redirect_request.headers:
+ has_cookie_header = "Cookie" in redirect_request.headers
+ has_authorization_header = "Authorization" in redirect_request.headers
+ if has_cookie_header or has_authorization_header:
source_request_netloc = urlparse_cached(source_request).netloc
redirect_request_netloc = urlparse_cached(redirect_request).netloc
if source_request_netloc != redirect_request_netloc:
- del redirect_request.headers["Cookie"]
+ if has_cookie_header:
+ del redirect_request.headers["Cookie"]
+ # https://fetch.spec.whatwg.org/#ref-for-cors-non-wildcard-request-header-name
+ if has_authorization_header:
+ del redirect_request.headers["Authorization"]
return redirect_request
diff --git a/tests/test_downloadermiddleware_redirect.py b/tests/test_downloadermiddleware_redirect.py
index dc15b672c..10b8ca9af 100644
--- a/tests/test_downloadermiddleware_redirect.py
+++ b/tests/test_downloadermiddleware_redirect.py
@@ -247,6 +247,37 @@ class RedirectMiddlewareTest(unittest.TestCase):
perc_encoded_utf8_url = "http://scrapytest.org/a%C3%A7%C3%A3o"
self.assertEqual(perc_encoded_utf8_url, req_result.url)
+ def test_cross_domain_header_dropping(self):
+ safe_headers = {"A": "B"}
+ original_request = Request(
+ "https://example.com",
+ headers={"Cookie": "a=b", "Authorization": "a", **safe_headers},
+ )
+
+ internal_response = Response(
+ "https://example.com",
+ headers={"Location": "https://example.com/a"},
+ status=301,
+ )
+ internal_redirect_request = self.mw.process_response(
+ original_request, internal_response, self.spider
+ )
+ self.assertIsInstance(internal_redirect_request, Request)
+ self.assertEqual(original_request.headers, internal_redirect_request.headers)
+
+ external_response = Response(
+ "https://example.com",
+ headers={"Location": "https://example.org/a"},
+ status=301,
+ )
+ external_redirect_request = self.mw.process_response(
+ original_request, external_response, self.spider
+ )
+ self.assertIsInstance(external_redirect_request, Request)
+ self.assertEqual(
+ safe_headers, external_redirect_request.headers.to_unicode_dict()
+ )
+
class MetaRefreshMiddlewareTest(unittest.TestCase):
def setUp(self):
From 603aa4924afc740f5cc41ca40c8eeca4b17bbe2e Mon Sep 17 00:00:00 2001
From: Andrey Rakhmatullin
Date: Wed, 15 Nov 2023 19:51:04 +0400
Subject: [PATCH 0081/1041] Improve the docs about Crawler attributes and
settings initialization.
---
docs/news.rst | 6 ++++--
docs/topics/spiders.rst | 10 ++++++++--
2 files changed, 12 insertions(+), 4 deletions(-)
diff --git a/docs/news.rst b/docs/news.rst
index 5db37969c..65d9c5181 100644
--- a/docs/news.rst
+++ b/docs/news.rst
@@ -32,8 +32,10 @@ Backward-incompatible changes
:meth:`scrapy.crawler.Crawler.__init__` and before the settings are
finalized and frozen. This change was needed to allow changing the settings
in :meth:`scrapy.Spider.from_crawler`. If you want to access the final
- setting values in the spider code as early as possible you can do this in
- :meth:`~scrapy.Spider.start_requests`. (:issue:`6038`)
+ setting values and the initialized :class:`~scrapy.crawler.Crawler`
+ attributes in the spider code as early as possible you can do this in
+ :meth:`~scrapy.Spider.start_requests` or in a handler of the
+ :signal:`engine_started` signal. (:issue:`6038`)
- The :meth:`TextResponse.json ` method now
requires the response to be in a valid JSON encoding (UTF-8, UTF-16, or
diff --git a/docs/topics/spiders.rst b/docs/topics/spiders.rst
index 20452d558..30677fe74 100644
--- a/docs/topics/spiders.rst
+++ b/docs/topics/spiders.rst
@@ -142,8 +142,14 @@ scrapy.Spider
method, which is handy if you want to modify them based on
arguments. As a consequence, these settings aren't the final values
as they can be modified later by e.g. :ref:`add-ons
- `. The final settings are available in the
- :meth:`start_requests` method and later.
+ `. For the same reason, most of the
+ :class:`~scrapy.crawler.Crawler` attributes aren't initialized at
+ this point.
+
+ The final settings and the initialized
+ :class:`~scrapy.crawler.Crawler` attributes are available in the
+ :meth:`start_requests` method, handlers of the
+ :signal:`engine_started` signal and later.
:param crawler: crawler to which the spider will be bound
:type crawler: :class:`~scrapy.crawler.Crawler` instance
From 75e99c75b3c6219df50546877e02f7bbb37324c3 Mon Sep 17 00:00:00 2001
From: Andrey Rakhmatullin
Date: Wed, 15 Nov 2023 19:51:04 +0400
Subject: [PATCH 0082/1041] 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 0083/1041] 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 0084/1041] 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 0085/1041] 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 0086/1041] 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 0087/1041] 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 0088/1041] 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 0089/1041] 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 0090/1041] 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 0091/1041] 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 0092/1041] 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 0093/1041] 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 0094/1041] 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 0095/1041] 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 0096/1041] 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 0097/1041] 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 0098/1041] 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 0099/1041] 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 0100/1041] 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 0101/1041] Fix bad message
---
scrapy/downloadermiddlewares/httpcompression.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/scrapy/downloadermiddlewares/httpcompression.py b/scrapy/downloadermiddlewares/httpcompression.py
index 6c8b659bd..f03294d65 100644
--- a/scrapy/downloadermiddlewares/httpcompression.py
+++ b/scrapy/downloadermiddlewares/httpcompression.py
@@ -91,7 +91,7 @@ class HttpCompressionMiddleware:
raise IgnoreRequest(
f"Ignored response {response} because its body "
f"({len(response.body)} B) exceeded DOWNLOAD_MAXSIZE "
- f"({self._max_size} B) during decompression."
+ f"({max_size} B) during decompression."
)
if len(response.body) < warn_size <= len(decoded_body):
logger.warning(
From 09ce0ef52681e2e8344b6848b28bc222016362ef Mon Sep 17 00:00:00 2001
From: Laerte Pereira
Date: Thu, 23 Nov 2023 10:50:46 -0300
Subject: [PATCH 0102/1041] Remove test_download_gzip_response test
---
tests/test_downloader_handlers.py | 27 ---------------------------
1 file changed, 27 deletions(-)
diff --git a/tests/test_downloader_handlers.py b/tests/test_downloader_handlers.py
index f12243e1d..924ece6f9 100644
--- a/tests/test_downloader_handlers.py
+++ b/tests/test_downloader_handlers.py
@@ -726,33 +726,6 @@ class Http11MockServerTestCase(unittest.TestCase):
reason = crawler.spider.meta["close_reason"]
self.assertTrue(reason, "finished")
- @defer.inlineCallbacks
- def test_download_gzip_response(self):
- crawler = get_crawler(SingleRequestSpider, self.settings_dict)
- body = b"1" * 100 # PayloadResource requires body length to be 100
- request = Request(
- self.mockserver.url("/payload"),
- method="POST",
- body=body,
- meta={"download_maxsize": 50},
- )
- yield crawler.crawl(seed=request)
- failure = crawler.spider.meta["failure"]
- # download_maxsize < 100, hence the CancelledError
- self.assertIsInstance(failure.value, defer.CancelledError)
-
- # See issue https://twistedmatrix.com/trac/ticket/8175
- raise unittest.SkipTest("xpayload fails on PY3")
- crawler = get_crawler(SingleRequestSpider, self.settings_dict)
- request.headers.setdefault(b"Accept-Encoding", b"gzip,deflate")
- request = request.replace(url=self.mockserver.url("/xpayload"))
- yield crawler.crawl(seed=request)
- # download_maxsize = 50 is enough for the gzipped response
- failure = crawler.spider.meta.get("failure")
- self.assertIsNone(failure)
- reason = crawler.spider.meta["close_reason"]
- self.assertTrue(reason, "finished")
-
class UriResource(resource.Resource):
"""Return the full uri that was requested"""
From 5f2827efe7b069e514a87bd0f7a9589f49f0a97a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Adri=C3=A1n=20Chaves?=
Date: Fri, 24 Nov 2023 10:02:56 +0100
Subject: [PATCH 0103/1041] 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 0104/1041] =?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 0105/1041] =?UTF-8?q?CHUNK=5FSIZE:=208=20KiB=20=E2=86=92?=
=?UTF-8?q?=2032=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 0106/1041] 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 0107/1041] 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 0108/1041] 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 0109/1041] 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 0110/1041] Remove open_in_browser from the 1.8.4 release notes
---
docs/news.rst | 5 ++---
1 file changed, 2 insertions(+), 3 deletions(-)
diff --git a/docs/news.rst b/docs/news.rst
index 2bbe833a5..c14815d06 100644
--- a/docs/news.rst
+++ b/docs/news.rst
@@ -2893,9 +2893,8 @@ Scrapy 1.8.4 (unreleased)
**Security bug fix:**
- Fixed regular expressions susceptible to a `ReDoS attack`_ affecting the
- ``iternodes`` node iterator of :class:`~scrapy.spiders.XMLFeedSpider` and
- the :func:`~scrapy.utils.response.open_in_browser` function. Please, see
- the `cc65-xxvf-f7r9 security advisory`_ for more information.
+ ``iternodes`` node iterator of :class:`~scrapy.spiders.XMLFeedSpider`.
+ Please, see the `cc65-xxvf-f7r9 security advisory`_ for more information.
.. _release-1.8.3:
From 731f7495563591f1b76b1b2ae83f07d2239b7897 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Adri=C3=A1n=20Chaves?=
Date: Thu, 30 Nov 2023 10:54:09 +0100
Subject: [PATCH 0111/1041] Extend Request.meta documentation (#5565)
---
docs/topics/request-response.rst | 47 ++++++++++++++++++++++++++------
1 file changed, 38 insertions(+), 9 deletions(-)
diff --git a/docs/topics/request-response.rst b/docs/topics/request-response.rst
index adf3d0f4a..8edf710bc 100644
--- a/docs/topics/request-response.rst
+++ b/docs/topics/request-response.rst
@@ -193,18 +193,47 @@ Request objects
:meth:`replace`.
.. attribute:: Request.meta
+ :value: {}
- A dict that contains arbitrary metadata for this request. This dict is
- empty for new Requests, and is usually populated by different Scrapy
- components (extensions, middlewares, etc). So the data contained in this
- dict depends on the extensions you have enabled.
+ A dictionary of arbitrary metadata for the request.
- See :ref:`topics-request-meta` for a list of special meta keys
- recognized by Scrapy.
+ You may extend request metadata as you see fit.
- This dict is :doc:`shallow copied ` when the request is
- cloned using the ``copy()`` or ``replace()`` methods, and can also be
- accessed, in your spider, from the ``response.meta`` attribute.
+ Request metadata can also be accessed through the
+ :attr:`~scrapy.http.Response.meta` attribute of a response.
+
+ To pass data from one spider callback to another, consider using
+ :attr:`cb_kwargs` instead. However, request metadata may be the right
+ choice in certain scenarios, such as to maintain some debugging data
+ across all follow-up requests (e.g. the source URL).
+
+ A common use of request metadata is to define request-specific
+ parameters for Scrapy components (extensions, middlewares, etc.). For
+ example, if you set ``dont_retry`` to ``True``,
+ :class:`~scrapy.downloadermiddlewares.retry.RetryMiddleware` will never
+ retry that request, even if it fails. See :ref:`topics-request-meta`.
+
+ You may also use request metadata in your custom Scrapy components, for
+ example, to keep request state information relevant to your component.
+ For example,
+ :class:`~scrapy.downloadermiddlewares.retry.RetryMiddleware` uses the
+ ``retry_times`` metadata key to keep track of how many times a request
+ has been retried so far.
+
+ Copying all the metadata of a previous request into a new, follow-up
+ request in a spider callback is a bad practice, because request
+ metadata may include metadata set by Scrapy components that is not
+ meant to be copied into other requests. For example, copying the
+ ``retry_times`` metadata key into follow-up requests can lower the
+ amount of retries allowed for those follow-up requests.
+
+ You should only copy all request metadata from one request to another
+ if the new request is meant to replace the old request, as is often the
+ case when returning a request from a :ref:`downloader middleware
+ ` method.
+
+ Also mind that the :meth:`copy` and :meth:`replace` request methods
+ :doc:`shallow-copy ` request metadata.
.. attribute:: Request.cb_kwargs
From 70ba3a0868dd6d9b996beba7ff5e8ec62773b206 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Adri=C3=A1n=20Chaves?=
Date: Thu, 30 Nov 2023 11:01:22 +0100
Subject: [PATCH 0112/1041] =?UTF-8?q?SPM=20=E2=86=92=20Zyte=20API=20(#6163?=
=?UTF-8?q?)?=
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 c66b51770637d3d72347ab41a201f672618aeaf2 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Adri=C3=A1n=20Chaves?=
Date: Fri, 1 Dec 2023 10:36:27 +0100
Subject: [PATCH 0113/1041] Add Python 3.13 alpha to CI
---
.github/workflows/checks.yml | 4 ++--
.github/workflows/publish.yml | 2 +-
.github/workflows/tests-macos.yml | 2 +-
.github/workflows/tests-ubuntu.yml | 9 ++++++---
.github/workflows/tests-windows.yml | 5 ++++-
conftest.py | 2 --
setup.py | 1 +
7 files changed, 15 insertions(+), 10 deletions(-)
diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml
index d6fc0f6c5..7a380a7a5 100644
--- a/.github/workflows/checks.yml
+++ b/.github/workflows/checks.yml
@@ -12,7 +12,7 @@ jobs:
fail-fast: false
matrix:
include:
- - python-version: "3.12"
+ - python-version: "3.13.0-alpha.2"
env:
TOXENV: pylint
- python-version: 3.8
@@ -21,7 +21,7 @@ jobs:
- python-version: "3.11" # Keep in sync with .readthedocs.yml
env:
TOXENV: docs
- - python-version: "3.12"
+ - python-version: "3.13.0-alpha.2"
env:
TOXENV: twinecheck
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index affaa32a5..456c0ffdd 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -15,7 +15,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-python@v4
with:
- python-version: 3.12
+ python-version: "3.13.0-alpha.2"
- run: |
pip install --upgrade build twine
python -m build
diff --git a/.github/workflows/tests-macos.yml b/.github/workflows/tests-macos.yml
index 252176464..6b110b5d7 100644
--- a/.github/workflows/tests-macos.yml
+++ b/.github/workflows/tests-macos.yml
@@ -11,7 +11,7 @@ jobs:
strategy:
fail-fast: false
matrix:
- python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
+ python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13.0-alpha.2"]
steps:
- uses: actions/checkout@v4
diff --git a/.github/workflows/tests-ubuntu.yml b/.github/workflows/tests-ubuntu.yml
index f50a4d104..fd08247e4 100644
--- a/.github/workflows/tests-ubuntu.yml
+++ b/.github/workflows/tests-ubuntu.yml
@@ -24,7 +24,10 @@ jobs:
- python-version: "3.12"
env:
TOXENV: py
- - python-version: "3.12"
+ - python-version: "3.13.0-alpha.2"
+ env:
+ TOXENV: py
+ - python-version: "3.13.0-alpha.2"
env:
TOXENV: asyncio
- python-version: pypy3.9
@@ -51,10 +54,10 @@ jobs:
env:
TOXENV: botocore-pinned
- - python-version: "3.12"
+ - python-version: "3.13.0-alpha.2"
env:
TOXENV: extra-deps
- - python-version: "3.12"
+ - python-version: "3.13.0-alpha.2"
env:
TOXENV: botocore
diff --git a/.github/workflows/tests-windows.yml b/.github/workflows/tests-windows.yml
index 757d62285..be082393e 100644
--- a/.github/workflows/tests-windows.yml
+++ b/.github/workflows/tests-windows.yml
@@ -27,7 +27,10 @@ jobs:
- python-version: "3.12"
env:
TOXENV: py
- - python-version: "3.12"
+ - python-version: "3.13.0-alpha.2"
+ env:
+ TOXENV: py
+ - python-version: "3.13.0-alpha.2"
env:
TOXENV: asyncio
diff --git a/conftest.py b/conftest.py
index 2bfa46f5a..68921f119 100644
--- a/conftest.py
+++ b/conftest.py
@@ -91,8 +91,6 @@ def requires_uvloop(request):
pytest.skip("uvloop does not support Windows")
if twisted_version == Version("twisted", 21, 2, 0):
pytest.skip("https://twistedmatrix.com/trac/ticket/10106")
- if sys.version_info >= (3, 12):
- pytest.skip("uvloop doesn't support Python 3.12 yet")
def pytest_configure(config):
diff --git a/setup.py b/setup.py
index 405633f55..d6ba4765e 100644
--- a/setup.py
+++ b/setup.py
@@ -63,6 +63,7 @@ setup(
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
+ "Programming Language :: Python :: 3.13",
"Programming Language :: Python :: Implementation :: CPython",
"Programming Language :: Python :: Implementation :: PyPy",
"Topic :: Internet :: WWW/HTTP",
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 0114/1041] =?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 0115/1041] 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 0116/1041] 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 0117/1041] 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 0118/1041] 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 0119/1041] 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 0120/1041] 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 0121/1041] 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 0122/1041] 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 0123/1041] 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 0124/1041] 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 0125/1041] 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 0126/1041] 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 0127/1041] 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 0128/1041] 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 0129/1041] =?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 0130/1041] =?UTF-8?q?Link=20to=20Zyte=E2=80=99s=20export?=
=?UTF-8?q?=20guides?=
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 1fab844f7dd5fe622899c41ad8a0d28dd27c5089 Mon Sep 17 00:00:00 2001
From: Andrey Rakhmatullin
Date: Wed, 20 Dec 2023 15:57:51 +0400
Subject: [PATCH 0131/1041] Pin the Python version for typing-tests.
---
tox.ini | 1 +
1 file changed, 1 insertion(+)
diff --git a/tox.ini b/tox.ini
index 21ac4c3ff..f0788c0af 100644
--- a/tox.ini
+++ b/tox.ini
@@ -47,6 +47,7 @@ commands =
mypy {posargs: scrapy tests}
[testenv:typing-tests]
+basepython = python3.8
deps =
-rtests/requirements.txt
{[testenv:typing]deps}
From a72394a388a8c41ab07f4511b096d85e6de168fe Mon Sep 17 00:00:00 2001
From: Andrey Rakhmatullin
Date: Wed, 20 Dec 2023 16:14:53 +0400
Subject: [PATCH 0132/1041] Add tests for replace() with kwargs.
---
tests_typing/test_http_request.mypy-testing | 14 ++++++++++++++
tests_typing/test_http_response.mypy-testing | 14 ++++++++++++++
2 files changed, 28 insertions(+)
diff --git a/tests_typing/test_http_request.mypy-testing b/tests_typing/test_http_request.mypy-testing
index 636e6895f..665db9088 100644
--- a/tests_typing/test_http_request.mypy-testing
+++ b/tests_typing/test_http_request.mypy-testing
@@ -1,3 +1,5 @@
+from typing import Any, Dict
+
import pytest
from scrapy import Request
@@ -33,6 +35,9 @@ def mypy_test_copy():
req_copy = req.copy()
reveal_type(req_copy) # R: scrapy.http.request.Request
+
+@pytest.mark.mypy_testing
+def mypy_test_copy_subclass():
req = MyRequest("data:,")
reveal_type(req) # R: __main__.MyRequest
req_copy = req.copy()
@@ -45,13 +50,22 @@ def mypy_test_replace():
reveal_type(req) # R: scrapy.http.request.Request
req_copy = req.replace(body=b"a")
reveal_type(req_copy) # R: scrapy.http.request.Request
+ kwargs: Dict[str, Any] = {}
+ req_copy2 = req.replace(body=b"a", **kwargs)
+ reveal_type(req_copy2) # R: Any
+
+@pytest.mark.mypy_testing
+def mypy_test_replace_subclass():
req = MyRequest("data:,")
reveal_type(req) # R: __main__.MyRequest
req_copy = req.replace(body=b"a")
reveal_type(req_copy) # R: __main__.MyRequest
req_copy2 = req.replace(body=b"a", cls=MyRequest2)
reveal_type(req_copy2) # R: __main__.MyRequest2
+ kwargs: Dict[str, Any] = {}
+ req_copy3 = req.replace(body=b"a", cls=MyRequest2, **kwargs)
+ reveal_type(req_copy3) # R: __main__.MyRequest2
@pytest.mark.mypy_testing
diff --git a/tests_typing/test_http_response.mypy-testing b/tests_typing/test_http_response.mypy-testing
index 2e58b4fbc..d58ac1027 100644
--- a/tests_typing/test_http_response.mypy-testing
+++ b/tests_typing/test_http_response.mypy-testing
@@ -1,3 +1,5 @@
+from typing import Any, Dict
+
import pytest
from scrapy.http import HtmlResponse, Response, TextResponse
@@ -24,6 +26,9 @@ def mypy_test_copy():
resp_copy = resp.copy()
reveal_type(resp_copy) # R: scrapy.http.response.Response
+
+@pytest.mark.mypy_testing
+def mypy_test_copy_subclass():
resp = HtmlResponse("data:,")
reveal_type(resp) # R: scrapy.http.response.html.HtmlResponse
resp_copy = resp.copy()
@@ -36,10 +41,19 @@ def mypy_test_replace():
reveal_type(resp) # R: scrapy.http.response.Response
resp_copy = resp.replace(body=b"a")
reveal_type(resp_copy) # R: scrapy.http.response.Response
+ kwargs: Dict[str, Any] = {}
+ resp_copy2 = resp.replace(body=b"a", **kwargs)
+ reveal_type(resp_copy2) # R: Any
+
+@pytest.mark.mypy_testing
+def mypy_test_replace_subclass():
resp = HtmlResponse("data:,")
reveal_type(resp) # R: scrapy.http.response.html.HtmlResponse
resp_copy = resp.replace(body=b"a")
reveal_type(resp_copy) # R: scrapy.http.response.html.HtmlResponse
resp_copy2 = resp.replace(body=b"a", cls=TextResponse)
reveal_type(resp_copy2) # R: scrapy.http.response.text.TextResponse
+ kwargs: Dict[str, Any] = {}
+ resp_copy3 = resp.replace(body=b"a", cls=TextResponse, **kwargs)
+ reveal_type(resp_copy3) # R: scrapy.http.response.text.TextResponse
From f56b5fc39ef3b322b8d0ad17fb424440bd79da0b Mon Sep 17 00:00:00 2001
From: Andrey Rakhmatullin
Date: Wed, 20 Dec 2023 16:19:11 +0400
Subject: [PATCH 0133/1041] Bump typing deps.
---
tox.ini | 10 +++++-----
1 file changed, 5 insertions(+), 5 deletions(-)
diff --git a/tox.ini b/tox.ini
index f0788c0af..25b30d759 100644
--- a/tox.ini
+++ b/tox.ini
@@ -33,14 +33,14 @@ install_command =
[testenv:typing]
basepython = python3
deps =
- mypy==1.6.1
- typing-extensions==4.8.0
+ mypy==1.7.1
+ typing-extensions==4.9.0
types-attrs==19.1.0
types-lxml==2023.10.21
- types-Pillow==10.1.0.0
- types-Pygments==2.16.0.0
+ types-Pillow==10.1.0.2
+ types-Pygments==2.17.0.0
types-pyOpenSSL==23.3.0.0
- types-setuptools==68.2.0.0
+ types-setuptools==69.0.0.0
# 2.1.2 fixes a typing bug: https://github.com/scrapy/w3lib/pull/211
w3lib >= 2.1.2
commands =
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 0134/1041] 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 0135/1041] =?UTF-8?q?SPM=20=E2=86=92=20Zyte=20API=20(#6163?=
=?UTF-8?q?)?=
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 0136/1041] =?UTF-8?q?Link=20to=20Zyte=E2=80=99s=20export?=
=?UTF-8?q?=20guides?=
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 0137/1041] 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 0138/1041] 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 2534a28ef032ae03e567859a498307b07ad34f64 Mon Sep 17 00:00:00 2001
From: Andrey Rakhmatullin
Date: Mon, 25 Dec 2023 15:03:08 +0400
Subject: [PATCH 0139/1041] Bump mypy.
---
tox.ini | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/tox.ini b/tox.ini
index 25b30d759..8996b12a4 100644
--- a/tox.ini
+++ b/tox.ini
@@ -33,7 +33,7 @@ install_command =
[testenv:typing]
basepython = python3
deps =
- mypy==1.7.1
+ mypy==1.8.0
typing-extensions==4.9.0
types-attrs==19.1.0
types-lxml==2023.10.21
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 0140/1041] 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 0141/1041] 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 0142/1041] 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 0143/1041] 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 0144/1041] 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 0145/1041] 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 0146/1041] 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 0147/1041] 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 0148/1041] 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 0149/1041] 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 0150/1041] 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 0151/1041] 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 0152/1041] 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 0153/1041] 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 0154/1041] 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 0155/1041] 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 0156/1041] 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 0157/1041] 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 0158/1041] 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 0159/1041] 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 0160/1041] 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 0161/1041] 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 0162/1041] 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 0163/1041] 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 0164/1041] 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 0165/1041] 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 0166/1041] 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 0167/1041] 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 0168/1041] 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 0169/1041] 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 0170/1041] =?UTF-8?q?Bump=20version:=202.11.0=20=E2=86=92?=
=?UTF-8?q?=202.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 0171/1041] 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 0172/1041] 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 0173/1041] 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 0174/1041] 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 0175/1041] 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 0176/1041] 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 0177/1041] 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 0178/1041] 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 0179/1041] 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 0180/1041] 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 0181/1041] 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 0182/1041] 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