Merge remote-tracking branch 'origin/master' into force-url

This commit is contained in:
Adrian Chaves 2026-06-05 10:39:14 +02:00
commit 39d75d2275
164 changed files with 3333 additions and 4734 deletions

View File

@ -12,13 +12,13 @@ Key takeaways:
> Note: What follows is based on
> https://raw.githubusercontent.com/jackyzha0/quartz/acfaa472253a432d350e9b6904c0cde14f8c487f/.github/pull_request_template.md
We more than welcome contributions, and are OK with the use of LLMs tools. How
We more than welcome contributions, and are OK with the use of LLM tools. How
you use those tools depends on whether or not they make you more productive.
But one thing that bugs us a lot are PRs that are made entirely with these
tools, without any revision or any effort trying to refine their output
whatsoever. This is just pure laziness, and unacceptable. Doing so will just
end up wasting everyone time (ours and yours).
end up wasting everyone's time (ours and yours).
So to be the most productive for all parties, we would encourage any
contributors to, at the very least, pay attention to what the model is doing,

View File

@ -17,7 +17,7 @@ jobs:
fail-fast: false
matrix:
include:
- python-version: "3.13"
- python-version: "3.14"
env:
TOXENV: pylint
- python-version: "3.10"
@ -27,13 +27,13 @@ jobs:
env:
TOXENV: typing-tests
# Keep in sync with pyproject.toml tool.sphinx-scrapy.python-version.
- python-version: "3.13"
- python-version: "3.14"
env:
TOXENV: docs
- python-version: "3.13"
env:
TOXENV: docs-tests
- python-version: "3.13"
- python-version: "3.14"
env:
TOXENV: twinecheck

View File

@ -21,7 +21,7 @@ jobs:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: "3.13"
python-version: "3.14"
- run: |
python -m pip install --upgrade build
python -m build

View File

@ -18,11 +18,11 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13"]
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
env:
- TOXENV: py
include:
- python-version: '3.13'
- python-version: '3.14'
env:
TOXENV: no-reactor

View File

@ -31,10 +31,13 @@ jobs:
- python-version: "3.13"
env:
TOXENV: py
- python-version: "3.13"
- python-version: "3.14"
env:
TOXENV: py
- python-version: "3.14"
env:
TOXENV: default-reactor
- python-version: "3.13"
- python-version: "3.14"
env:
TOXENV: no-reactor
# pinned due to https://github.com/pypy/pypy/issues/5388
@ -63,20 +66,20 @@ jobs:
env:
TOXENV: botocore-pinned
- python-version: "3.13"
- python-version: "3.14"
env:
TOXENV: extra-deps
- python-version: "3.13"
- python-version: "3.14"
env:
TOXENV: no-reactor-extra-deps
# pinned due to https://github.com/pypy/pypy/issues/5388
- python-version: pypy3.11-7.3.20
env:
TOXENV: pypy3-extra-deps
- python-version: "3.13"
- python-version: "3.14"
env:
TOXENV: botocore
- python-version: "3.13"
- python-version: "3.14"
env:
TOXENV: mitmproxy

View File

@ -31,10 +31,13 @@ jobs:
- python-version: "3.13"
env:
TOXENV: py
- python-version: "3.13"
- python-version: "3.14"
env:
TOXENV: py
- python-version: "3.14"
env:
TOXENV: default-reactor
- python-version: "3.13"
- python-version: "3.14"
env:
TOXENV: no-reactor
@ -46,7 +49,7 @@ jobs:
env:
TOXENV: extra-deps-pinned
- python-version: "3.13"
- python-version: "3.14"
env:
TOXENV: extra-deps

View File

@ -27,6 +27,6 @@ repos:
hooks:
- id: sphinx-lint
- repo: https://github.com/scrapy/sphinx-scrapy
rev: 0.8.5
rev: 0.8.6
hooks:
- id: sphinx-scrapy

View File

@ -2,7 +2,7 @@ version: 2
build:
os: ubuntu-24.04
tools:
python: "3.13"
python: "3.14"
commands:
- pip install tox
- tox -e docs

6
CITATION.cff Normal file
View File

@ -0,0 +1,6 @@
cff-version: 1.2.0
message: If you use Scrapy in published research, please cite it as below.
title: Scrapy
authors:
- name: Scrapy contributors
url: https://scrapy.org

View File

@ -4,8 +4,8 @@
| Version | Supported |
| ------- | ------------------ |
| 2.15.x | :white_check_mark: |
| < 2.15.x | :x: |
| 2.16.x | :white_check_mark: |
| < 2.16.x | :x: |
## Reporting a Vulnerability

View File

@ -11,6 +11,7 @@ from scrapy.utils.reactor import set_asyncio_event_loop_policy
from scrapy.utils.reactorless import install_reactor_import_hook
from tests.keys import generate_keys
from tests.mockserver.http import MockServer
from tests.mockserver.mitm_proxy import MitmProxy
if TYPE_CHECKING:
from collections.abc import Generator
@ -23,9 +24,6 @@ def _py_files(folder):
collect_ignore = [
# may need extra deps
"docs/_ext",
# not a test, but looks like a test
"scrapy/utils/testproc.py",
"scrapy/utils/testsite.py",
# contains scripts to be run by tests/test_crawler.py::AsyncCrawlerProcessSubprocess
*_py_files("tests/AsyncCrawlerProcess"),
# contains scripts to be run by tests/test_crawler.py::AsyncCrawlerRunnerSubprocess
@ -75,6 +73,32 @@ def mockserver() -> Generator[MockServer]:
yield mockserver
@pytest.fixture # function scope because it modifies os.environ
def mitm_proxy_server(monkeypatch: pytest.MonkeyPatch) -> Generator[MitmProxy]:
proxy = MitmProxy()
url = proxy.start()
monkeypatch.setenv("http_proxy", url)
monkeypatch.setenv("https_proxy", url)
try:
yield proxy
finally:
proxy.stop()
@pytest.fixture # function scope because it modifies os.environ
def mitm_proxy_server_https(monkeypatch: pytest.MonkeyPatch) -> Generator[MitmProxy]:
proxy = MitmProxy()
url = proxy.start().replace("http://", "https://")
monkeypatch.setenv("http_proxy", url)
monkeypatch.setenv("https_proxy", url)
try:
yield proxy
finally:
proxy.stop()
@pytest.fixture(scope="session")
def reactor_pytest(request) -> str:
return request.config.getoption("--reactor")

View File

@ -11,7 +11,7 @@ from sphinx.application import Sphinx
def maybe_skip_member(app: Sphinx, what, name: str, obj, skip: bool, options) -> bool:
if not skip:
# autodocs was generating a text "alias of" for the following members
# autodoc was generating the text "alias of" for the following members
return name in {"default_item_class", "default_selector_class"}
return skip

View File

@ -1,6 +1,6 @@
{% extends "!layout.html" %}
{# Overriden to include a link to scrapy.org, not just to the docs root #}
{# Overridden to include a link to scrapy.org, not just to the docs root #}
{%- block sidebartitle %}
{# the logo helper function was removed in Sphinx 6 and deprecated since Sphinx 4 #}

View File

@ -154,6 +154,7 @@ scrapy_intersphinx_enable = [
"coverage",
"cryptography",
"cssselect",
"form2request",
"itemloaders",
"parsel",
"pytest",

View File

@ -3,6 +3,284 @@
Release notes
=============
.. _release-2.16.0:
Scrapy 2.16.0 (2026-05-19)
--------------------------
Highlights:
- Official support for Python 3.14
- Support for Twisted 26.4.0+
Modified requirements
~~~~~~~~~~~~~~~~~~~~~
- Increased the minimum versions of the following dependencies:
- service_identity_: 18.1.0 → 23.1.0
(:issue:`7347`)
- Added support for Twisted 26.4.0+.
(:issue:`7347`, :issue:`7505`, :issue:`7520`)
- Added support for Python 3.14.
(:issue:`6604`, :issue:`7460`)
Backward-incompatible changes
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
- The following classes and functions, intended for internal use by
:class:`~scrapy.core.downloader.handlers.http11.HTTP11DownloadHandler`
and :class:`~scrapy.core.downloader.handlers.http2.H2DownloadHandler`, have
been made private:
- ``scrapy.core.downloader.handlers.http11.ScrapyAgent``
- ``scrapy.core.downloader.handlers.http11.ScrapyProxyAgent``
- ``scrapy.core.downloader.handlers.http11.TunnelingAgent``
- ``scrapy.core.downloader.handlers.http11.TunnelingTCP4ClientEndpoint``
- ``scrapy.core.downloader.handlers.http11.tunnel_request_data()``
- ``scrapy.core.downloader.handlers.http2.ScrapyH2Agent``
(:issue:`7496`, :issue:`7510`)
Deprecations
~~~~~~~~~~~~
- ``scrapy.FormRequest`` is deprecated. You can use the :doc:`form2request
<form2request:index>` library instead, see :ref:`form`.
(:issue:`6438`)
- ``scrapy.utils.python.MutableChain`` is deprecated.
(:issue:`7504`)
Deprecation removals
~~~~~~~~~~~~~~~~~~~~
- The ``start_requests()`` method of :class:`~scrapy.Spider`, deprecated in
2.13.0, is removed and no longer called. Use :meth:`~scrapy.Spider.start`
instead, or both to maintain support for lower Scrapy versions.
(:issue:`7490`)
- Support for ``process_start_requests()`` methods of :ref:`spider middlewares
<topics-spider-middleware>`, deprecated in 2.13.0, is removed. Use
:meth:`~scrapy.spidermiddlewares.SpiderMiddleware.process_start` instead,
or both to maintain support for lower Scrapy versions.
(:issue:`7490`)
- Support for synchronous ``process_spider_output()`` methods of spider
middlewares, deprecated in Scrapy 2.13.0, is removed. You should upgrade
the affected middlewares to have asynchronous ``process_spider_output()``
methods.
(:issue:`7504`)
- The ``spider`` arguments of the following methods of
:class:`~scrapy.core.scraper.Scraper`, deprecated in Scrapy 2.13.0, are
removed:
- ``close_spider()``
- ``enqueue_scrape()``
- ``handle_spider_error()``
- ``handle_spider_output()``
(:issue:`7487`)
- HTTP/1.0 support code, deprecated in Scrapy 2.13.0, is removed. This
includes:
- ``scrapy.core.downloader.handlers.http10.HTTP10DownloadHandler``
- The ``scrapy.core.downloader.webclient`` module.
- The ``DOWNLOADER_HTTPCLIENTFACTORY`` setting.
(:issue:`7486`)
- The following functions, deprecated in Scrapy 2.13.0, are removed, you
should import them from :mod:`w3lib.url` directly instead:
- ``scrapy.utils.url.add_or_replace_parameter()``
- ``scrapy.utils.url.add_or_replace_parameters()``
- ``scrapy.utils.url.any_to_uri()``
- ``scrapy.utils.url.canonicalize_url()``
- ``scrapy.utils.url.file_uri_to_path()``
- ``scrapy.utils.url.is_url()``
- ``scrapy.utils.url.parse_data_uri()``
- ``scrapy.utils.url.parse_url()``
- ``scrapy.utils.url.path_to_file_uri()``
- ``scrapy.utils.url.safe_download_url()``
- ``scrapy.utils.url.safe_url_string()``
- ``scrapy.utils.url.url_query_cleaner()``
- ``scrapy.utils.url.url_query_parameter()``
(:issue:`7487`)
- The following test-related code, deprecated in Scrapy 2.13.0, is removed:
- the ``scrapy.utils.testproc`` module
- the ``scrapy.utils.testsite`` module
- ``scrapy.utils.test.assert_gcs_environ()``
- ``scrapy.utils.test.get_ftp_content_and_delete()``
- ``scrapy.utils.test.get_gcs_content_and_delete()``
- ``scrapy.utils.test.mock_google_cloud_storage()``
- ``scrapy.utils.test.skip_if_no_boto()``
- ``scrapy.utils.test.TestSpider``
(:issue:`7487`)
- ``scrapy.utils.versions.scrapy_components_versions()``, deprecated in
Scrapy 2.13.0, is removed, you can use
:func:`scrapy.utils.versions.get_versions` instead.
(:issue:`7487`)
- ``scrapy.downloadermiddlewares.ajaxcrawl.AjaxCrawlMiddleware`` and
``scrapy.utils.url.escape_ajax()``, deprecated in Scrapy 2.13.0, are
removed.
(:issue:`7487`)
- The ``__init__()`` method of priority queue classes (see
:setting:`SCHEDULER_PRIORITY_QUEUE`) now needs to support a keyword-only
``start_queue_cls`` parameter, not supporting it was deprecated in Scrapy
2.13.0.
(:issue:`7487`)
- ``scrapy.spiders.init.InitSpider``, deprecated in Scrapy 2.13.0, is
removed.
(:issue:`7487`)
New features
~~~~~~~~~~~~
- New features and improvements for
:class:`~scrapy.core.downloader.handlers._httpx.HttpxDownloadHandler`:
- Support for proxies.
- Support for the :reqmeta:`download_latency` meta key.
- Support for :attr:`Response.certificate
<scrapy.http.Response.certificate>`.
- Default headers set by the ``httpx`` library are no longer added to
requests.
(:issue:`7441`, :issue:`7524`)
- :class:`~scrapy.core.downloader.handlers.http11.HTTP11DownloadHandler` now
skips HTTPS proxy certificate verification when the
:setting:`DOWNLOAD_VERIFY_CERTIFICATES` setting is set to ``False``.
(:issue:`7496`)
Improvements
~~~~~~~~~~~~
- :func:`time.monotonic` is used instead of :func:`time.time` to calculate
elapsed time in various places.
(:issue:`7377`)
- Improved extraction of the file extension from the URL in
:class:`~scrapy.pipelines.files.FilesPipeline`.
(:issue:`4225`, :issue:`7414`)
- Other code refactoring and improvements.
(:issue:`7401`)
Bug fixes
~~~~~~~~~
- :class:`~scrapy.core.downloader.handlers.http11.HTTP11DownloadHandler` now
raises an exception when a request has an ``https://`` destination and an
``https://`` proxy, which is not supported by this handler. Previously it
tried to connect to the proxy via HTTP in this case.
(:issue:`7496`)
- :class:`~scrapy.core.downloader.handlers.http2.H2DownloadHandler` now
raises an exception for requests with ``http://`` URLs instead of trying to
connect, which is not supported by this handler.
(:issue:`7496`)
- :class:`~scrapy.core.downloader.handlers.http2.H2DownloadHandler` no longer
adds the ``:status`` pseudo-header to :attr:`Response.headers
<scrapy.http.Response.headers>`.
(:issue:`7441`)
- Fixed :func:`scrapy.utils.response.open_in_browser` removing the ``<head>``
tag when adding the ``<base>`` tag.
(:issue:`7459`)
Documentation
~~~~~~~~~~~~~
- Documented that
:class:`~scrapy.core.downloader.handlers.http11.HTTP11DownloadHandler`
doesn't support HTTPS proxies for HTTPS destinations and that
:class:`~scrapy.core.downloader.handlers.http2.H2DownloadHandler` doesn't
support proxies at all.
(:issue:`7496`)
- Added an example of using
:class:`logging.handlers.TimedRotatingFileHandler` to rotate Scrapy logs.
(:issue:`3628`, :issue:`7501`)
- Added a ``CITATION.cff`` file.
(:issue:`7502`, :issue:`7519`)
- Mentioned :setting:`DOWNLOADER_CLIENT_TLS_METHOD` in :ref:`bans`.
(:issue:`5232`, :issue:`7518`)
- Other documentation improvements and fixes.
(:issue:`7417`,
:issue:`7463`,
:issue:`7472`,
:issue:`7480`,
:issue:`7489`,
:issue:`7503`,
:issue:`7507`)
Quality assurance
~~~~~~~~~~~~~~~~~
- Added tests that connect to https://books.toscrape.com/ to test the
behavior with a real website. These tests are marked with the
``requires_internet`` pytest mark and can be skipped with e.g.
``-m 'not requires_internet'`` if you cannot or don't want to run them.
(:issue:`7520`)
- Type hints improvements and fixes.
(:issue:`7492`, :issue:`7532`)
- CI and test improvements and fixes.
(:issue:`7441`, :issue:`7466`, :issue:`7491`, :issue:`7496`)
.. _release-2.15.2:
Scrapy 2.15.2 (2026-04-28)
@ -1355,8 +1633,8 @@ Highlights:
- Added the :reqmeta:`allow_offsite` request meta key
- :ref:`Spider middlewares that don't support asynchronous spider output
<sync-async-spider-middleware>` are deprecated
- Spider middlewares that don't support asynchronous spider output are
deprecated
- Added a base class for :ref:`universal spider middlewares
<universal-spider-middleware>`
@ -1494,13 +1772,11 @@ Deprecations
``start_queue_cls`` parameter.
(:issue:`6752`)
- :ref:`Spider middlewares that don't support asynchronous spider output
<sync-async-spider-middleware>` are deprecated. The async iterable
downgrading feature, needed for using such middlewares with asynchronous
callbacks and with other spider middlewares that produce asynchronous
iterables, is also deprecated. Please update all such middlewares to
support asynchronous spider output.
(:issue:`6664`)
- Spider middlewares that don't support asynchronous spider output are
deprecated. The async iterable downgrading feature, needed for using such
middlewares with asynchronous callbacks and with other spider middlewares
that produce asynchronous iterables, is also deprecated. Please update all
such middlewares to support asynchronous spider output. (:issue:`6664`)
- Functions that were imported from :mod:`w3lib.url` and re-exported in
:mod:`scrapy.utils.url` are now deprecated, you should import them from
@ -1759,9 +2035,8 @@ Documentation
- Documented the setting values set in the default project template.
(:issue:`6762`, :issue:`6775`)
- Improved the :ref:`docs <sync-async-spider-middleware>` about asynchronous
iterable support in spider middlewares.
(:issue:`6688`)
- Improved the docs about asynchronous iterable support in spider
middlewares. (:issue:`6688`)
- Improved the :ref:`docs <coroutine-deferred-apis>` about using
:class:`~twisted.internet.defer.Deferred`-based APIs in coroutine-based
@ -1925,7 +2200,7 @@ Backward-incompatible changes
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
- User-defined cookies for HTTPS requests will have the ``secure`` flag set
to ``True`` unless it's set to ``False`` explictly. This is important when
to ``True`` unless it's set to ``False`` explicitly. This is important when
these cookies are reused in HTTP requests, e.g. after a redirect to an HTTP
URL.
(:issue:`6357`)
@ -1960,7 +2235,7 @@ Backward-incompatible changes
``crawler.settings`` instead. When they call ``__init__()`` of the base
class they should pass the ``crawler`` argument to it too.
- A ``from_settings()`` method shouldn't be defined. Class-specific
initialization code should go into either an overriden ``from_crawler()``
initialization code should go into either an overridden ``from_crawler()``
method or into ``__init__()``.
- It's now possible to override ``from_crawler()`` and it's not necessary
to call ``MediaPipeline.from_crawler()`` in it if other recommendations

View File

@ -5,4 +5,4 @@ sphinx
sphinx-notfound-page
sphinx-rtd-theme
sphinx-rtd-dark-mode
sphinx-scrapy @ git+https://github.com/scrapy/sphinx-scrapy.git@0.8.5
sphinx-scrapy @ git+https://github.com/scrapy/sphinx-scrapy.git@0.8.6

View File

@ -153,7 +153,7 @@ sphinx-rtd-theme==3.1.0
# via
# -r docs/requirements.in
# sphinx-rtd-dark-mode
sphinx-scrapy @ git+https://github.com/scrapy/sphinx-scrapy.git@96826815002921f27a2e369b12c0c25af7a1f8b2
sphinx-scrapy @ git+https://github.com/scrapy/sphinx-scrapy.git@b1d55db4d16a5425fc68576d63519bbfe26dd9c0
# via -r docs/requirements.in
sphinx-sitemap==2.9.0
# via sphinx-scrapy

View File

@ -23,9 +23,6 @@ hence use coroutine syntax (e.g. ``await``, ``async for``, ``async with``):
- :class:`~scrapy.Request` callbacks.
If you are using any custom or third-party :ref:`spider middleware
<topics-spider-middleware>`, see :ref:`sync-async-spider-middleware`.
- The :meth:`process_item` method of
:ref:`item pipelines <topics-item-pipeline>`.
@ -39,13 +36,9 @@ hence use coroutine syntax (e.g. ``await``, ``async for``, ``async with``):
- The
:meth:`~scrapy.spidermiddlewares.SpiderMiddleware.process_spider_output`
method of :ref:`spider middlewares <topics-spider-middleware>`.
If defined as a coroutine, it must be an :term:`asynchronous generator`.
The input ``result`` parameter is an :term:`asynchronous iterable`.
See also :ref:`sync-async-spider-middleware` and
:ref:`universal-spider-middleware`.
method of :ref:`spider middlewares <topics-spider-middleware>`, which
*must* be defined as an :term:`asynchronous generator` except in
:ref:`universal spider middlewares <universal-spider-middleware>`.
- The :meth:`~scrapy.spidermiddlewares.SpiderMiddleware.process_start` method
of :ref:`spider middlewares <custom-spider-middleware>`, which *must* be
@ -277,136 +270,3 @@ You can also send multiple requests in parallel:
"price": responses[0][1].css(".price::text").get(),
"price2": responses[1][1].css(".color::text").get(),
}
.. _sync-async-spider-middleware:
Mixing synchronous and asynchronous spider middlewares
======================================================
The output of a :class:`~scrapy.Request` callback is passed as the ``result``
parameter to the
:meth:`~scrapy.spidermiddlewares.SpiderMiddleware.process_spider_output` method
of the first :ref:`spider middleware <topics-spider-middleware>` from the
:ref:`list of active spider middlewares <topics-spider-middleware-setting>`.
Then the output of that ``process_spider_output`` method is passed to the
``process_spider_output`` method of the next spider middleware, and so on for
every active spider middleware.
Scrapy supports mixing :ref:`coroutine methods <async>` and synchronous methods
in this chain of calls.
However, if any of the ``process_spider_output`` methods is defined as a
synchronous method, and the previous ``Request`` callback or
``process_spider_output`` method is a coroutine, there are some drawbacks to
the asynchronous-to-synchronous conversion that Scrapy does so that the
synchronous ``process_spider_output`` method gets a synchronous iterable as its
``result`` parameter:
- The whole output of the previous ``Request`` callback or
``process_spider_output`` method is awaited at this point.
- If an exception raises while awaiting the output of the previous
``Request`` callback or ``process_spider_output`` method, none of that
output will be processed.
This contrasts with the regular behavior, where all items yielded before
an exception raises are processed.
Asynchronous-to-synchronous conversions are supported for backward
compatibility, but they are deprecated and will stop working in a future
version of Scrapy.
To avoid asynchronous-to-synchronous conversions, when defining ``Request``
callbacks as coroutine methods or when using spider middlewares whose
``process_spider_output`` method is an :term:`asynchronous generator`, all
active spider middlewares must either have their ``process_spider_output``
method defined as an asynchronous generator or :ref:`define a
process_spider_output_async method <universal-spider-middleware>`.
.. _sync-async-spider-middleware-users:
For middleware users
--------------------
If you have asynchronous callbacks or use asynchronous-only spider middlewares
you should make sure the asynchronous-to-synchronous conversions
:ref:`described above <sync-async-spider-middleware>` don't happen. To do this,
make sure all spider middlewares you use support asynchronous spider output.
Even if you don't have asynchronous callbacks and don't use asynchronous-only
spider middlewares in your project, it's still a good idea to make sure all
middlewares you use support asynchronous spider output, so that it will be easy
to start using asynchronous callbacks in the future. Because of this, Scrapy
logs a warning when it detects a synchronous-only spider middleware.
If you want to update middlewares you wrote, see the :ref:`following section
<sync-async-spider-middleware-authors>`. If you have 3rd-party middlewares that
aren't yet updated by their authors, you can :ref:`subclass <tut-inheritance>`
them to make them :ref:`universal <universal-spider-middleware>` and use the
subclasses in your projects.
.. _sync-async-spider-middleware-authors:
For middleware authors
----------------------
If you have a spider middleware that defines a synchronous
``process_spider_output`` method, you should update it to support asynchronous
spider output for :ref:`better compatibility <sync-async-spider-middleware>`,
even if you don't yet use it with asynchronous callbacks, especially if you
publish this middleware for other people to use. You have two options for this:
1. Make the middleware asynchronous, by making the ``process_spider_output``
method an :term:`asynchronous generator`.
2. Make the middleware universal, as described in the :ref:`next section
<universal-spider-middleware>`.
If your middleware won't be used in projects with synchronous-only middlewares,
e.g. because it's an internal middleware and you know that all other
middlewares in your projects are already updated, it's safe to choose the first
option. Otherwise, it's better to choose the second option.
.. _universal-spider-middleware:
Universal spider middlewares
----------------------------
To allow writing a spider middleware that supports asynchronous execution of
its ``process_spider_output`` method in Scrapy 2.7 and later (avoiding
:ref:`asynchronous-to-synchronous conversions <sync-async-spider-middleware>`)
while maintaining support for older Scrapy versions, you may define
``process_spider_output`` as a synchronous method and define an
:term:`asynchronous generator` version of that method with an alternative name:
``process_spider_output_async``.
For example:
.. code-block:: python
class UniversalSpiderMiddleware:
def process_spider_output(self, response, result):
for r in result:
# ... do something with r
yield r
async def process_spider_output_async(self, response, result):
async for r in result:
# ... do something with r
yield r
.. note:: This is an interim measure to allow, for a time, to write code that
works in Scrapy 2.7 and later without requiring
asynchronous-to-synchronous conversions, and works in earlier Scrapy
versions as well.
In some future version of Scrapy, however, this feature will be
deprecated and, eventually, in a later version of Scrapy, this
feature will be removed, and all spider middlewares will be expected
to define their ``process_spider_output`` method as an asynchronous
generator.
Since 2.13.0, Scrapy provides a base class,
:class:`~scrapy.spidermiddlewares.base.BaseSpiderMiddleware`, which implements
the ``process_spider_output()`` and ``process_spider_output_async()`` methods,
so instead of duplicating the processing code you can override the
``get_processed_request()`` and/or the ``get_processed_item()`` method.

View File

@ -206,6 +206,8 @@ If you want to use this handler you need to replace the default one for the
Known limitations of the HTTP/2 implementation in this handler include:
- No support for proxies.
- No support for HTTP/2 Cleartext (h2c), since no major browser supports
HTTP/2 unencrypted (refer `http2 faq`_).
@ -277,14 +279,11 @@ If you want to use this handler you need to replace the default ones for the
some websites may be different. Additionally, these are the Scrapy features
that are explicitly not supported when using it:
- Proxy support (the :reqmeta:`proxy` meta key).
- Per-request bind address support (the :reqmeta:`bindaddress` meta key).
The global :setting:`DOWNLOAD_BIND_ADDRESS` setting is supported but the
port number, if specified, will be ignored.
- The :setting:`DOWNLOADER_CLIENT_TLS_CIPHERS` and
:setting:`DOWNLOADER_CLIENT_TLS_METHOD` settings.
- The :setting:`DOWNLOADER_CLIENT_TLS_METHOD` setting.
- Settings specific to the Twisted networking or HTTP implementation, like
:setting:`DNS_RESOLVER`.

View File

@ -745,8 +745,19 @@ HttpProxyMiddleware
Handling of this meta key needs to be implemented inside the :ref:`download
handler <topics-download-handlers>`, so it's not guaranteed to be supported
by all 3rd-party handlers. It's currently unsupported by
:class:`~scrapy.core.downloader.handlers.http2.H2DownloadHandler` and
:class:`~scrapy.core.downloader.handlers._httpx.HttpxDownloadHandler`.
.. note::
Usually a proxy URL uses the ``http://`` scheme. More rarely, it uses the
``https://`` one. While both kinds of proxy URLs can be used with both HTTP
and HTTPS destination URLs, the specifics of the network exchange are
different for all 4 cases and it's possible that HTTPS proxies are fully or
partially unsupported by a given download handler. Currently,
:class:`~scrapy.core.downloader.handlers.http11.HTTP11DownloadHandler`
supports HTTPS proxies only for HTTP destinations.
HttpProxyMiddleware settings
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@ -1141,9 +1152,9 @@ Based on :class:`~urllib.robotparser.RobotFileParser`:
* is compliant with `Martijn Koster's 1996 draft specification
<https://www.robotstxt.org/norobots-rfc.txt>`_
* lacks support for wildcard matching
* lacks support for wildcard matching (before Python 3.14.5)
* doesn't use the length based rule
* doesn't use the length based rule (before Python 3.14.5)
It is faster than Protego and backward-compatible with versions of Scrapy before 1.8.0.

View File

@ -83,7 +83,7 @@ request with Scrapy.
It might be enough to yield a :class:`~scrapy.Request` with the same HTTP
method and URL. However, you may also need to reproduce the body, headers and
form parameters (see :class:`~scrapy.FormRequest`) of that request.
form parameters (see :ref:`form`) of that request.
As all major browsers allow to export the requests in curl_ format, Scrapy
incorporates the method :meth:`~scrapy.Request.from_curl` to generate an equivalent

View File

@ -136,6 +136,19 @@ Core Stats extension
Enable the collection of core statistics, provided the stats collection is
enabled (see :ref:`topics-stats`).
The following stats are collected:
* ``start_time``: start date/time of the crawl (:class:`~datetime.datetime`).
* ``finish_time``: end date/time of the crawl (:class:`~datetime.datetime`).
* ``elapsed_time_seconds``: total crawl duration in seconds (:class:`float`).
* ``finish_reason``: the closing reason string (e.g. ``"finished"``,
``"closespider_timeout"``).
* ``item_scraped_count``: total number of items that passed all pipelines.
* ``item_dropped_count``: total number of items dropped by a pipeline.
* ``item_dropped_reasons_count/<ExceptionName>``: per-exception drop count
(e.g. ``item_dropped_reasons_count/DropItem``).
* ``response_received_count``: total number of HTTP responses received.
.. _topics-extensions-ref-telnetconsole:
Log Count extension
@ -247,6 +260,7 @@ settings:
* :setting:`CLOSESPIDER_TIMEOUT_NO_ITEM`
* :setting:`CLOSESPIDER_ITEMCOUNT`
* :setting:`CLOSESPIDER_PAGECOUNT`
* :setting:`CLOSESPIDER_PAGECOUNT_NO_ITEM`
* :setting:`CLOSESPIDER_ERRORCOUNT`
.. note::
@ -260,12 +274,11 @@ settings:
CLOSESPIDER_TIMEOUT
"""""""""""""""""""
Default: ``0``
Default: ``0.0``
An integer which specifies a number of seconds. If the spider remains open for
more than that number of seconds, it will be automatically closed with the
reason ``closespider_timeout``. If zero (or non set), spiders won't be closed by
timeout.
If the spider remains open for more than this number of seconds, it will be
automatically closed with the reason ``closespider_timeout``. If zero (or non
set), spiders won't be closed by timeout.
.. setting:: CLOSESPIDER_TIMEOUT_NO_ITEM

View File

@ -68,19 +68,21 @@ Response, Item, Spider and Selector objects.
You can enter the telnet console and inspect how many objects (of the classes
mentioned above) are currently alive using the ``prefs()`` function which is an
alias to the :func:`~scrapy.utils.trackref.print_live_refs` function::
alias to the :func:`~scrapy.utils.trackref.print_live_refs` function:
.. code-block:: bash
telnet localhost 6023
.. code-block:: pycon
.. code-block:: pycon
>>> prefs()
Live References
>>> prefs()
Live References
ExampleSpider 1 oldest: 15s ago
HtmlResponse 10 oldest: 1s ago
Selector 2 oldest: 0s ago
FormRequest 878 oldest: 7s ago
ExampleSpider 1 oldest: 15s ago
HtmlResponse 10 oldest: 1s ago
Selector 2 oldest: 0s ago
Request 878 oldest: 7s ago
As you can see, that report also shows the "age" of the oldest object in each
class. If you're running multiple spiders per process chances are you can

View File

@ -194,6 +194,48 @@ If :setting:`LOG_SHORT_NAMES` is set, then the logs will not display the Scrapy
component that prints the log. It is unset by default, hence logs contain the
Scrapy component responsible for that log output.
Rotating log files
------------------
Scrapy's :setting:`LOG_FILE` setting writes logs to a single file. It does not
rotate log files automatically, but you can use Python's standard
:mod:`logging.handlers` module when running Scrapy from a script.
For example, to rotate the log file every day:
.. skip: next
.. code-block:: python
import logging
from logging.handlers import TimedRotatingFileHandler
from scrapy.crawler import CrawlerProcess
from scrapy.utils.project import get_project_settings
from myproject.spiders.myspider import MySpider
settings = get_project_settings()
process = CrawlerProcess(settings, install_root_handler=False)
handler = TimedRotatingFileHandler(
"scrapy.log",
when="midnight",
backupCount=7,
encoding=settings.get("LOG_ENCODING"),
)
handler.setFormatter(
logging.Formatter(settings.get("LOG_FORMAT"), settings.get("LOG_DATEFORMAT"))
)
root_logger = logging.getLogger()
root_logger.setLevel(settings.get("LOG_LEVEL"))
root_logger.addHandler(handler)
process.crawl(MySpider)
process.start()
Command-line options
--------------------

View File

@ -409,6 +409,10 @@ Here are some tips to keep in mind when dealing with these kinds of sites:
* use a pool of rotating IPs. For example, the free `Tor project`_ or paid
services like `ProxyMesh`_. An open source alternative is `scrapoxy`_, a
super proxy that you can attach your own proxies to.
* for HTTPS websites, if blocking appears related to TLS behavior, consider
adjusting the :setting:`DOWNLOADER_CLIENT_TLS_METHOD` setting, since some
websites may respond differently depending on the TLS method used by the
client.
* use a ban avoidance service, such as `Zyte API`_, which provides a `Scrapy
plugin <https://github.com/scrapy-plugins/scrapy-zyte-api>`__ and additional
features, like `AI web scraping <https://www.zyte.com/ai-web-scraping/>`__

View File

@ -253,6 +253,78 @@ Request objects
.. automethod:: to_dict
.. _form:
Creating requests that submit HTML forms
----------------------------------------
Use :doc:`form2request <form2request:index>` to build request data from an HTML
``<form>`` element and convert it to a :class:`~scrapy.Request`.
Install it with pip:
.. code-block:: bash
pip install form2request
Select the desired form with CSS or XPath, then build and convert request
data:
.. code-block:: python
from form2request import form2request
def parse(self, response):
form = response.css("form#search")
request_data = form2request(form, data={"q": "scrapy"})
yield request_data.to_scrapy(callback=self.parse_results)
Use ``data`` to override field values. To drop a field from the resulting
request, set its value to ``None``.
By default, form2request simulates clicking the first submit button. To submit
without clicking any button, pass ``click=False``. To click a specific submit
button, pass its element:
.. code-block:: python
def parse(self, response):
form = response.css("form#checkout")
submit = form.css('button[name="pay"]')
request_data = form2request(form, click=submit)
.. _topics-request-response-ref-request-userlogin:
Using form2request to simulate a user login
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
It is usual for web sites to provide pre-populated form fields through ``<input
type="hidden">`` elements, such as session related data or authentication
tokens (for login pages). Build the request from the form and only override the
credentials:
.. code-block:: python
import scrapy
from form2request import form2request
class LoginSpider(scrapy.Spider):
name = "example.com"
start_urls = ["http://www.example.com/users/login.php"]
def parse(self, response):
form = response.css("form")
request_data = form2request(
form,
data={"username": "john", "password": "secret"},
)
yield request_data.to_scrapy(callback=self.after_login)
def after_login(self, response): ...
Other functions related to requests
-----------------------------------
@ -799,159 +871,6 @@ Request subclasses
Here is the list of built-in :class:`~scrapy.Request` subclasses. You can also subclass
it to implement your own custom functionality.
FormRequest objects
-------------------
The FormRequest class extends the base :class:`~scrapy.Request` with functionality for
dealing with HTML forms. It uses `lxml.html forms`_ to pre-populate form
fields with form data from :class:`Response` objects.
.. _lxml.html forms: https://lxml.de/lxmlhtml.html#forms
.. currentmodule:: None
.. class:: scrapy.FormRequest(url, [formdata, ...])
:canonical: scrapy.http.request.form.FormRequest
The :class:`~scrapy.FormRequest` class adds a new keyword parameter to the ``__init__()`` method. The
remaining arguments are the same as for the :class:`~scrapy.Request` class and are
not documented here.
:param formdata: is a dictionary (or iterable of (key, value) tuples)
containing HTML Form data which will be url-encoded and assigned to the
body of the request.
:type formdata: dict or collections.abc.Iterable
The :class:`~scrapy.FormRequest` objects support the following class method in
addition to the standard :class:`~scrapy.Request` methods:
.. classmethod:: from_response(response, [formname=None, formid=None, formnumber=0, formdata=None, formxpath=None, formcss=None, clickdata=None, dont_click=False, ...])
Returns a new :class:`~scrapy.FormRequest` object with its form field values
pre-populated with those found in the HTML ``<form>`` element contained
in the given response. For an example see
:ref:`topics-request-response-ref-request-userlogin`.
The policy is to automatically simulate a click, by default, on any form
control that looks clickable, like a ``<input type="submit">``. Even
though this is quite convenient, and often the desired behaviour,
sometimes it can cause problems which could be hard to debug. For
example, when working with forms that are filled and/or submitted using
javascript, the default :meth:`from_response` behaviour may not be the
most appropriate. To disable this behaviour you can set the
``dont_click`` argument to ``True``. Also, if you want to change the
control clicked (instead of disabling it) you can also use the
``clickdata`` argument.
.. caution:: Using this method with select elements which have leading
or trailing whitespace in the option values will not work due to a
`bug in lxml`_, which should be fixed in lxml 3.8 and above.
:param response: the response containing a HTML form which will be used
to pre-populate the form fields
:type response: :class:`~scrapy.http.Response` object
:param formname: if given, the form with name attribute set to this value will be used.
:type formname: str
:param formid: if given, the form with id attribute set to this value will be used.
:type formid: str
:param formxpath: if given, the first form that matches the xpath will be used.
:type formxpath: str
:param formcss: if given, the first form that matches the css selector will be used.
:type formcss: str
:param formnumber: the number of form to use, when the response contains
multiple forms. The first one (and also the default) is ``0``.
:type formnumber: int
:param formdata: fields to override in the form data. If a field was
already present in the response ``<form>`` element, its value is
overridden by the one passed in this parameter. If a value passed in
this parameter is ``None``, the field will not be included in the
request, even if it was present in the response ``<form>`` element.
:type formdata: dict
:param clickdata: attributes to lookup the control clicked. If it's not
given, the form data will be submitted simulating a click on the
first clickable element. In addition to html attributes, the control
can be identified by its zero-based index relative to other
submittable inputs inside the form, via the ``nr`` attribute.
:type clickdata: dict
:param dont_click: If True, the form data will be submitted without
clicking in any element.
:type dont_click: bool
The other parameters of this class method are passed directly to the
:class:`~scrapy.FormRequest` ``__init__()`` method.
.. currentmodule:: scrapy.http
Request usage examples
----------------------
Using FormRequest to send data via HTTP POST
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
If you want to simulate a HTML Form POST in your spider and send a couple of
key-value fields, you can return a :class:`~scrapy.FormRequest` object (from your
spider) like this:
.. skip: next
.. code-block:: python
return [
FormRequest(
url="http://www.example.com/post/action",
formdata={"name": "John Doe", "age": "27"},
callback=self.after_post,
)
]
.. _topics-request-response-ref-request-userlogin:
Using FormRequest.from_response() to simulate a user login
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
It is usual for web sites to provide pre-populated form fields through ``<input
type="hidden">`` elements, such as session related data or authentication
tokens (for login pages). When scraping, you'll want these fields to be
automatically pre-populated and only override a couple of them, such as the
user name and password. You can use the :meth:`.FormRequest.from_response`
method for this job. Here's an example spider which uses it:
.. code-block:: python
import scrapy
def authentication_failed(response):
# TODO: Check the contents of the response and return True if it failed
# or False if it succeeded.
pass
class LoginSpider(scrapy.Spider):
name = "example.com"
start_urls = ["http://www.example.com/users/login.php"]
def parse(self, response):
return scrapy.FormRequest.from_response(
response,
formdata={"username": "john", "password": "secret"},
callback=self.after_login,
)
def after_login(self, response):
if authentication_failed(response):
self.logger.error("Login failed")
return
# continue scraping with authenticated session...
JsonRequest
-----------
@ -1026,7 +945,7 @@ Response objects
:type request: scrapy.Request
:param certificate: an object representing the server's SSL certificate.
:type certificate: twisted.internet.ssl.Certificate
:type certificate: typing.Any
:param ip_address: The IP address of the server from which the Response originated.
:type ip_address: :class:`ipaddress.IPv4Address` or :class:`ipaddress.IPv6Address`
@ -1118,8 +1037,8 @@ Response objects
.. attribute:: Response.certificate
A :class:`twisted.internet.ssl.Certificate` object representing
the server's SSL certificate.
An object representing the server's SSL certificate. Its type and
contents depend on the download handler that produced the response.
Only populated for ``https`` responses, ``None`` otherwise.

View File

@ -809,7 +809,6 @@ Default:
"scrapy.downloadermiddlewares.defaultheaders.DefaultHeadersMiddleware": 400,
"scrapy.downloadermiddlewares.useragent.UserAgentMiddleware": 500,
"scrapy.downloadermiddlewares.retry.RetryMiddleware": 550,
"scrapy.downloadermiddlewares.ajaxcrawl.AjaxCrawlMiddleware": 560,
"scrapy.downloadermiddlewares.redirect.MetaRefreshMiddleware": 580,
"scrapy.downloadermiddlewares.httpcompression.HttpCompressionMiddleware": 590,
"scrapy.downloadermiddlewares.redirect.RedirectMiddleware": 600,

View File

@ -117,36 +117,28 @@ one or more of these methods:
:type response: :class:`~scrapy.http.Response` object
.. method:: process_spider_output(response, result)
:async:
This method is called with the results returned from the Spider, after
it has processed the response.
This method is an :term:`asynchronous generator` called with the
results from the spider after the spider has processed the response.
:meth:`process_spider_output` must return an iterable of
:class:`~scrapy.Request` objects and :ref:`item objects
<topics-items>`.
Consider defining this method as an :term:`asynchronous generator`,
which will be a requirement in a future version of Scrapy. However, if
you plan on sharing your spider middleware with other people, consider
either :ref:`enforcing Scrapy 2.7 <enforce-component-requirements>`
as a minimum requirement of your spider middleware, or :ref:`making
your spider middleware universal <universal-spider-middleware>` so that
it works with Scrapy versions earlier than Scrapy 2.7.
.. seealso:: :ref:`universal-spider-middleware`.
:param response: the response which generated this output from the
spider
:type response: :class:`~scrapy.http.Response` object
:param result: the result returned by the spider
:type result: an iterable of :class:`~scrapy.Request` objects and
:ref:`item objects <topics-items>`
:param result: the results from the spider
:type result: an :term:`asynchronous iterable` of
:class:`~scrapy.Request` objects and :ref:`item objects
<topics-items>`
.. method:: process_spider_output_async(response, result)
:async:
If defined, this method must be an :term:`asynchronous generator`,
which will be called instead of :meth:`process_spider_output` if
``result`` is an :term:`asynchronous iterable`.
Alternative name for :meth:`process_spider_output` used when
implementing a :ref:`universal spider middleware
<universal-spider-middleware>`.
.. method:: process_spider_exception(response, exception)
@ -174,13 +166,40 @@ one or more of these methods:
:type exception: :exc:`Exception` object
.. _universal-spider-middleware:
Universal spider middlewares
----------------------------
In Scrapy 2.6.3 and lower, ``process_spider_output()`` must be a *synchronous*
generator.
To support those versions and higher Scrapy versions in the same middleware,
rename your asynchronous :meth:`~SpiderMiddleware.process_spider_output`
method to :meth:`~SpiderMiddleware.process_spider_output_async`, and define a
synchronous ``process_spider_output()`` method to be used by 2.6.3 and lower
versions.
For example:
.. code-block:: python
class UniversalSpiderMiddleware:
async def process_spider_output_async(self, response, result):
async for r in result:
# ... do something with r
yield r
def process_spider_output(self, response, result):
for r in result:
# ... do something with r
yield r
Base class for custom spider middlewares
----------------------------------------
Scrapy provides a base class for custom spider middlewares. It's not required
to use it but it can help with simplifying middleware implementations and
reducing the amount of boilerplate code in :ref:`universal middlewares
<universal-spider-middleware>`.
to use it but it can help with simplifying middleware implementations.
.. module:: scrapy.spidermiddlewares.base

View File

@ -46,12 +46,12 @@ the console you need to type::
Password:
>>>
By default Username is ``scrapy`` and Password is autogenerated. The
autogenerated Password can be seen on Scrapy logs like the example below::
By default, the username is ``scrapy`` and the password is autogenerated. The
autogenerated password can be seen on Scrapy logs like the example below::
2018-10-16 14:35:21 [scrapy.extensions.telnet] INFO: Telnet Password: 16f92501e8a59326
Default Username and Password can be overridden by the settings
The default username and password can be overridden by the settings
:setting:`TELNETCONSOLE_USERNAME` and :setting:`TELNETCONSOLE_PASSWORD`.
.. warning::

View File

@ -18,7 +18,7 @@ class Root(Resource):
self.tail.clear()
self.start = self.lastmark = self.lasttime = time()
def getChild(self, request, name):
def getChild(self, path, request):
return self
def render(self, request):

View File

@ -35,10 +35,6 @@ class QPSSpider(Spider):
self.download_delay = float(self.download_delay)
async def start(self):
for item_or_request in self.start_requests():
yield item_or_request
def start_requests(self):
url = self.benchurl
if self.latency is not None:
url += f"?latency={self.latency}"

View File

@ -7,8 +7,7 @@ name = "Scrapy"
dynamic = ["version"]
description = "A high-level Web Crawling and Web Scraping framework"
dependencies = [
# Twisted pinned until Scrapy is updated for its internal TLS API changes
"Twisted>=21.7.0,<=25.5.0",
"Twisted>=21.7.0",
"cryptography>=37.0.0",
"cssselect>=0.9.1",
"defusedxml>=0.7.1",
@ -20,7 +19,7 @@ dependencies = [
"protego>=0.1.15",
"pyOpenSSL>=22.0.0",
"queuelib>=1.4.2",
"service_identity>=18.1.0",
"service_identity>=23.1.0",
"tldextract",
"w3lib>=1.17.0",
"zope.interface>=5.1.0",
@ -40,6 +39,7 @@ classifiers = [
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Programming Language :: Python :: Implementation :: CPython",
"Programming Language :: Python :: Implementation :: PyPy",
"Topic :: Internet :: WWW/HTTP",
@ -86,12 +86,10 @@ pattern = "^(?P<version>.+)$"
[tool.mypy]
strict = true
allow_any_generics = true # 67 errors
extra_checks = false # weird addErrback() errors
untyped_calls_exclude = [
"twisted",
]
warn_return_any = false # 37 errors
[[tool.mypy.overrides]]
module = "tests.*"
@ -122,24 +120,9 @@ implicit_reexport = true
module = "scrapy.settings.default_settings"
ignore_errors = true
# deprecated modules
[[tool.mypy.overrides]]
module = [
"scrapy.core.downloader.webclient",
"scrapy.spiders.init",
"scrapy.utils.testsite",
"tests.test_webclient",
]
allow_any_generics = true
allow_untyped_calls = true
allow_untyped_defs = true
check_untyped_defs = false
warn_return_any = false
# usually no type hints
[[tool.mypy.overrides]]
module = [
# "IPython.*",
"bpython",
"brotli",
"brotlicffi",
@ -154,7 +137,7 @@ module = [
ignore_missing_imports = true
[tool.bumpversion]
current_version = "2.15.2"
current_version = "2.16.0"
commit = true
tag = true
tag_name = "{new_version}"
@ -200,6 +183,7 @@ jobs = 1 # >1 hides results
extension-pkg-allow-list=[
"lxml",
]
load-plugins = ["pylint_per_file_ignores"]
[tool.pylint."MESSAGES CONTROL"]
enable = [
@ -241,6 +225,7 @@ disable = [
"too-many-positional-arguments",
"too-many-public-methods",
"too-many-return-statements",
"undefined-variable",
"unused-argument",
"unused-variable",
"useless-import-alias", # used as a hint to mypy
@ -259,15 +244,13 @@ disable = [
"unused-import",
# Ones that we may want to address (fix, ignore per-line or move to "don't want to fix")
"abstract-method",
"arguments-differ",
"arguments-renamed",
"dangerous-default-value",
"keyword-arg-before-vararg",
"pointless-statement",
"raise-missing-from",
"unnecessary-dunder-call",
"used-before-assignment",
]
# requires `pylint_per_file_ignores` plugin
per-file-ignores = [
# Extended list of ones that we may want to address, only for tests
"./tests/*:abstract-method,arguments-renamed,dangerous-default-value,pointless-statement,raise-missing-from,unnecessary-dunder-call,used-before-assignment",
]
[tool.pytest.ini_options]
@ -284,6 +267,7 @@ markers = [
"requires_botocore: marks tests that need botocore (but not boto3)",
"requires_boto3: marks tests that need botocore and boto3",
"requires_mitmproxy: marks tests that need mitmproxy",
"requires_internet: marks tests that need real Internet access",
]
filterwarnings = [
"ignore::DeprecationWarning:twisted.web.static",
@ -466,4 +450,4 @@ split-on-trailing-comma = false
convention = "pep257"
[tool.sphinx-scrapy]
python-version = "3.13" # Keep in sync with .github/workflows/checks.yml.
python-version = "3.14" # Keep in sync with .github/workflows/checks.yml.

View File

@ -1 +1 @@
2.15.2
2.16.0

View File

@ -147,7 +147,7 @@ class Command(ScrapyCommand):
name: str,
url: str,
template_name: str,
template_file: str | os.PathLike,
template_file: str | os.PathLike[str],
) -> None:
"""Generate the spider module, based on the given template"""
assert self.settings is not None

View File

@ -144,17 +144,16 @@ class Command(BaseRunSpiderCommand):
def iterate_spider_output(self, result: _T) -> Iterable[Any]: ...
def iterate_spider_output(self, result: Any) -> Iterable[Any] | Deferred[Any]:
d: Deferred[Any]
if inspect.isasyncgen(result):
d = deferred_from_coro(
collect_asyncgen(aiter_errback(result, self.handle_exception))
)
d.addCallback(self.iterate_spider_output)
return d
return d.addCallback(self.iterate_spider_output)
d = deferred_from_coro(result)
if inspect.iscoroutine(result):
d = deferred_from_coro(result)
d.addCallback(self.iterate_spider_output)
return d
return arg_to_iter(deferred_from_coro(result))
return d.addCallback(self.iterate_spider_output)
return arg_to_iter(d)
def add_items(self, lvl: int, new_items: list[Any]) -> None:
old_items = self.items.get(lvl, [])

View File

@ -27,7 +27,7 @@ class Contract:
request_cls: type[Request] | None = None
name: str
def __init__(self, method: Callable, *args: Any):
def __init__(self, method: Callable[..., Any], *args: Any):
self.testcase_pre = _create_testcase(method, f"@{self.name} pre-hook")
self.testcase_post = _create_testcase(method, f"@{self.name} post-hook")
self.args: tuple[Any, ...] = args
@ -105,7 +105,7 @@ class ContractsManager:
return methods
def extract_contracts(self, method: Callable) -> list[Contract]:
def extract_contracts(self, method: Callable[..., Any]) -> list[Contract]:
contracts: list[Contract] = []
assert method.__doc__ is not None
for line_ in method.__doc__.split("\n"):
@ -125,7 +125,7 @@ class ContractsManager:
def from_spider(self, spider: Spider, results: TestResult) -> list[Request | None]:
requests: list[Request | None] = []
for method in self.tested_methods_from_spidercls(type(spider)):
bound_method = spider.__getattribute__(method)
bound_method = getattr(spider, method)
try:
requests.append(self.from_method(bound_method, results))
except Exception:
@ -134,7 +134,9 @@ class ContractsManager:
return requests
def from_method(self, method: Callable, results: TestResult) -> Request | None:
def from_method(
self, method: Callable[..., Any], results: TestResult
) -> Request | None:
contracts = self.extract_contracts(method)
if contracts:
request_cls = Request
@ -170,7 +172,7 @@ class ContractsManager:
return None
def _clean_req(
self, request: Request, method: Callable, results: TestResult
self, request: Request, method: Callable[..., Any], results: TestResult
) -> None:
"""stop the request from returning objects and records any errors"""
@ -195,7 +197,7 @@ class ContractsManager:
request.errback = eb_wrapper
def _create_testcase(method: Callable, desc: str) -> TestCase:
def _create_testcase(method: Callable[..., Any], desc: str) -> TestCase:
spider = method.__self__.name # type: ignore[attr-defined]
class ContractTestCase(TestCase):

View File

@ -128,11 +128,12 @@ class Downloader:
) -> Generator[Deferred[Any], Any, Response | Request]:
self.active.add(request)
try:
return (
yield deferred_from_coro(
result: Response | Request = yield (
deferred_from_coro(
self.middleware.download_async(self._enqueue_request, request)
)
)
return result
finally:
self.active.remove(request)
@ -163,7 +164,8 @@ class Downloader:
return key, self.slots[key]
def get_slot_key(self, request: Request) -> str:
if (meta_slot := request.meta.get(self.DOWNLOAD_SLOT)) is not None:
meta_slot: str | None = request.meta.get(self.DOWNLOAD_SLOT)
if meta_slot is not None:
return meta_slot
key = urlparse_cached(request).hostname or ""

View File

@ -5,7 +5,6 @@ from contextlib import contextmanager
from typing import TYPE_CHECKING, Any, cast
from OpenSSL import SSL
from twisted.internet._sslverify import _setAcceptableProtocols
from twisted.internet.ssl import (
AcceptableCiphers,
CertificateOptions,
@ -19,9 +18,11 @@ from zope.interface.verify import verifyObject
from scrapy.core.downloader.tls import (
DEFAULT_CIPHERS,
_ScrapyClientTLSOptions,
_ScrapyClientTLSOptions26,
openssl_methods,
)
from scrapy.exceptions import ScrapyDeprecationWarning
from scrapy.utils._deps_compat import TWISTED_TLS_NEW_IMPL
from scrapy.utils.deprecate import create_deprecated_class
from scrapy.utils.misc import build_from_crawler, load_object
@ -108,34 +109,37 @@ class _ScrapyClientContextFactory(BrowserLikePolicyForHTTPS):
def _get_cert_options(self) -> CertificateOptions:
with _filter_method_warning():
return CertificateOptions(
return _ScrapyCertificateOptions(
method=self._ssl_method,
fixBrokenPeers=True,
acceptableCiphers=self.tls_ciphers,
)
# kept for old-style HTTP/1.0 downloader context twisted calls,
# e.g. connectSSL()
# should be removed together with ScrapyClientContextFactory
def getContext(self, hostname: Any = None, port: Any = None) -> SSL.Context:
def getContext(
self, hostname: Any = None, port: Any = None
) -> SSL.Context: # pragma: no cover
return self._get_context()
def _get_context(self) -> SSL.Context:
cert_options = self._get_cert_options()
ctx = cert_options.getContext()
ctx.set_options(0x4) # OP_LEGACY_SERVER_CONNECT
return ctx
return self._get_cert_options().getContext()
def creatorForNetloc(self, hostname: bytes, port: int) -> ClientTLSOptions:
if not self._verify_certificates:
# _ScrapyClientTLSOptions is needed to skip verification errors
# Our options class is needed to skip verification errors
if TWISTED_TLS_NEW_IMPL:
return _ScrapyClientTLSOptions26(
self._get_cert_options()._makeTLSConnection,
hostname.decode("ascii"),
)
return _ScrapyClientTLSOptions(
hostname.decode("ascii"), self._get_context()
) # type: ignore[no-untyped-call]
hostname.decode("ascii"), # type: ignore[arg-type]
self._get_context(), # type: ignore[arg-type]
)
# Otherwise use the normal Twisted function.
# Note that this doesn't use self._get_context().
with _filter_method_warning():
return optionsForClientTLS(
return optionsForClientTLS( # type: ignore[no-any-return]
hostname=hostname.decode("ascii"),
extraCertificateOptions={
"method": self._ssl_method,
@ -186,7 +190,7 @@ class BrowserLikeContextFactory(_ScrapyClientContextFactory):
def creatorForNetloc(self, hostname: bytes, port: int) -> ClientTLSOptions:
with _filter_method_warning():
return optionsForClientTLS(
return optionsForClientTLS( # type: ignore[no-any-return]
hostname=hostname.decode("ascii"),
extraCertificateOptions={"method": self._ssl_method},
)
@ -202,8 +206,25 @@ class _AcceptableProtocolsContextFactory:
the acceptable protocols on the :class:`.ClientTLSOptions` instance
returned by it. It's only needed because we support custom factories via
:setting:`DOWNLOADER_CLIENTCONTEXTFACTORY`.
It's a no-op on Twisted 26.4.0+, though using it with custom
factories on those Twisted versions may be not enough for HTTP/2 support.
"""
# Something needs to call set_alpn_protos() for ALPN to work.
#
# Twisted < 26.4.0 does it in OpenSSLCertificateOptions._makeContext()
# (requires passing acceptableProtocols from the factory to
# OpenSSLCertificateOptions) and in TLSMemoryBIOFactory._createConnection()
# based on H2ClientFactory.acceptableProtocols (too late, it seems).
#
# Newer Twisted does it in OpenSSLCertificateOptions._makeContext() as
# well, and in OpenSSLCertificateOptions._makeTLSConnection() based on
# H2ClientFactory.acceptableProtocols (which now works).
#
# When we drop DOWNLOADER_CLIENTCONTEXTFACTORY it looks like we can replace
# all of this with _ScrapyClientContextFactory.acceptableProtocols.
def __init__(self, context_factory: Any, acceptable_protocols: list[bytes]):
verifyObject(IPolicyForHTTPS, context_factory)
self._wrapped_context_factory: Any = context_factory
@ -213,7 +234,12 @@ class _AcceptableProtocolsContextFactory:
options: ClientTLSOptions = self._wrapped_context_factory.creatorForNetloc(
hostname, port
)
_setAcceptableProtocols(options._ctx, self._acceptable_protocols)
if not TWISTED_TLS_NEW_IMPL:
from twisted.internet._sslverify import ( # type: ignore[attr-defined] # noqa: PLC0415 # pylint: disable=no-name-in-module
_setAcceptableProtocols,
)
_setAcceptableProtocols(options._ctx, self._acceptable_protocols) # type: ignore[attr-defined]
return options
@ -225,6 +251,18 @@ AcceptableProtocolsContextFactory = create_deprecated_class(
)
class _ScrapyCertificateOptions(CertificateOptions):
"""A wrapper needed to add flags to the SSL context before it's used."""
def _makeContext(self, skipCiphers: bool = False) -> SSL.Context:
if TWISTED_TLS_NEW_IMPL:
ctx = super()._makeContext(skipCiphers)
else:
ctx = super()._makeContext()
ctx.set_options(0x4) # OP_LEGACY_SERVER_CONNECT
return ctx
def _load_context_factory_from_settings(crawler: Crawler) -> IPolicyForHTTPS:
"""Create an instance of :setting:`DOWNLOADER_CLIENTCONTEXTFACTORY`.

View File

@ -0,0 +1,25 @@
from __future__ import annotations
from abc import ABC
from typing import TYPE_CHECKING
from .base import BaseDownloadHandler
if TYPE_CHECKING:
from scrapy.crawler import Crawler
class BaseHttpDownloadHandler(BaseDownloadHandler, ABC):
"""Base class for built-in HTTP download handlers."""
def __init__(self, crawler: Crawler):
super().__init__(crawler)
self._default_maxsize: int = crawler.settings.getint("DOWNLOAD_MAXSIZE")
self._default_warnsize: int = crawler.settings.getint("DOWNLOAD_WARNSIZE")
self._fail_on_dataloss: bool = crawler.settings.getbool(
"DOWNLOAD_FAIL_ON_DATALOSS"
)
self._tls_verbose_logging: bool = crawler.settings.getbool(
"DOWNLOADER_CLIENT_TLS_VERBOSE_LOGGING"
)
self._fail_on_dataloss_warned: bool = False

View File

@ -0,0 +1,307 @@
from __future__ import annotations
import base64
import logging
import time
from abc import ABC, abstractmethod
from io import BytesIO
from typing import TYPE_CHECKING, Any, ClassVar, Generic, NoReturn, TypedDict, TypeVar
from urllib.parse import quote, urlsplit
from scrapy import Request, signals
from scrapy.exceptions import (
DownloadCancelledError,
NotConfigured,
ResponseDataLossError,
)
from scrapy.utils._download_handlers import (
check_stop_download,
get_dataloss_msg,
get_maxsize_msg,
get_warnsize_msg,
make_response,
normalize_bind_address,
)
from scrapy.utils.asyncio import is_asyncio_available
from scrapy.utils.url import add_http_if_no_scheme
from ._base_http import BaseHttpDownloadHandler
if TYPE_CHECKING:
from collections.abc import AsyncIterable
from contextlib import AbstractAsyncContextManager
from ipaddress import IPv4Address, IPv6Address
from _typeshed import SizedBuffer
# typing.NotRequired requires Python 3.11
from typing_extensions import NotRequired
from scrapy.crawler import Crawler
from scrapy.http import Headers, Response
logger = logging.getLogger(__name__)
_ResponseT = TypeVar("_ResponseT")
class _BaseResponseArgs(TypedDict):
status: int
url: str
headers: Headers
certificate: NotRequired[Any]
ip_address: NotRequired[IPv4Address | IPv6Address | None]
protocol: str | None
class BaseStreamingDownloadHandler(BaseHttpDownloadHandler, ABC, Generic[_ResponseT]):
"""A base class for HTTP download handlers that follow the streaming logic flow."""
_DEFAULT_CONNECT_TIMEOUT: ClassVar[float] = 10
experimental: ClassVar[bool] = False
requires_asyncio: ClassVar[bool] = True
# require subclasses to disable proxies explicitly with an explanation
supports_proxies: ClassVar[bool] = True
supports_per_request_bindaddress: ClassVar[bool] = False
def __init__(self, crawler: Crawler):
if self.requires_asyncio and not is_asyncio_available(): # pragma: no cover
raise NotConfigured(
f"{type(self).__name__} requires the asyncio support. Make"
f" sure that you have either enabled the asyncio Twisted"
f" reactor in the TWISTED_REACTOR setting or disabled the"
f" TWISTED_REACTOR_ENABLED setting. See the asyncio documentation"
f" of Scrapy for more information."
)
self._check_deps_installed()
super().__init__(crawler)
if self.experimental:
logger.warning(
f"{type(self).__name__} is experimental and is not recommended for production use."
)
self._bind_address = normalize_bind_address(
crawler.settings.get("DOWNLOAD_BIND_ADDRESS")
)
self._proxy_auth_encoding: str = crawler.settings.get("HTTPPROXY_AUTH_ENCODING")
# these are useful for many handlers but used in different ways by them
self._pool_size_total: int = crawler.settings.getint("CONCURRENT_REQUESTS")
self._pool_size_per_host: int = crawler.settings.getint(
"CONCURRENT_REQUESTS_PER_DOMAIN"
)
@staticmethod
@abstractmethod
def _check_deps_installed() -> None:
"""Raise NotConfigured if the required deps are not installed."""
raise NotImplementedError
@abstractmethod
def _make_request(
self, request: Request, timeout: float
) -> AbstractAsyncContextManager[_ResponseT]:
"""Return an async context manager yielding the library-specific response.
Exceptions raised by the library should be reraised as Scrapy-specific ones.
"""
raise NotImplementedError
@staticmethod
@abstractmethod
def _extract_headers(response: _ResponseT) -> Headers:
"""Convert library-specific response headers to a
:class:`~scrapy.http.headers.Headers` object."""
raise NotImplementedError
@staticmethod
@abstractmethod
def _build_base_response_args(
response: _ResponseT, request: Request, headers: Headers
) -> _BaseResponseArgs:
"""Build kwargs for :func:`scrapy.utils._download_handlers.make_response`."""
raise NotImplementedError
@staticmethod
@abstractmethod
def _iter_body_chunks(response: _ResponseT) -> AsyncIterable[SizedBuffer]:
"""Return an async iterable yielding body chunks from the response."""
raise NotImplementedError
@staticmethod
@abstractmethod
def _is_dataloss_exception(exc: Exception) -> bool:
"""Return True if ``exc`` represents dataloss."""
raise NotImplementedError
def _log_tls_info(self, response: _ResponseT, request: Request) -> None:
"""Log TLS connection details, if possible."""
async def download_request(self, request: Request) -> Response:
if not self.supports_proxies and request.meta.get("proxy"):
raise NotImplementedError(f"{type(self).__name__} doesn't support proxies.")
if not self.supports_per_request_bindaddress and request.meta.get(
"bindaddress"
):
logger.error(
f"The 'bindaddress' request meta key is not supported by"
f" {type(self).__name__} and will be ignored."
)
timeout: float = request.meta.get(
"download_timeout", self._DEFAULT_CONNECT_TIMEOUT
)
start_time = time.monotonic()
async with self._make_request(request, timeout) as response:
request.meta["download_latency"] = time.monotonic() - start_time
return await self._read_response(response, request)
async def _read_response(self, response: _ResponseT, request: Request) -> Response:
maxsize: int = request.meta.get("download_maxsize", self._default_maxsize)
warnsize: int = request.meta.get("download_warnsize", self._default_warnsize)
headers = self._extract_headers(response)
content_length = headers.get("Content-Length")
expected_size = int(content_length) if content_length is not None else None
if maxsize and expected_size and expected_size > maxsize:
self._cancel_maxsize(expected_size, maxsize, request, expected=True)
reached_warnsize = False
if warnsize and expected_size and expected_size > warnsize:
reached_warnsize = True
logger.warning(
get_warnsize_msg(expected_size, warnsize, request, expected=True)
)
make_response_base_args = self._build_base_response_args(
response, request, headers
)
if self._tls_verbose_logging:
self._log_tls_info(response, request)
if stop_download := check_stop_download(
signals.headers_received,
self.crawler,
request,
headers=headers,
body_length=expected_size,
):
return make_response(
**make_response_base_args,
stop_download=stop_download,
)
response_body = BytesIO()
bytes_received = 0
try:
async for chunk in self._iter_body_chunks(response):
response_body.write(chunk)
bytes_received += len(chunk)
if stop_download := check_stop_download(
signals.bytes_received, self.crawler, request, data=chunk
):
return make_response(
**make_response_base_args,
body=response_body.getvalue(),
stop_download=stop_download,
)
if maxsize and bytes_received > maxsize:
response_body.truncate(0)
self._cancel_maxsize(
bytes_received, maxsize, request, expected=False
)
if warnsize and bytes_received > warnsize and not reached_warnsize:
reached_warnsize = True
logger.warning(
get_warnsize_msg(
bytes_received, warnsize, request, expected=False
)
)
except Exception as e:
if not self._is_dataloss_exception(e):
raise
fail_on_dataloss: bool = request.meta.get(
"download_fail_on_dataloss", self._fail_on_dataloss
)
if not fail_on_dataloss:
return make_response(
**make_response_base_args,
body=response_body.getvalue(),
flags=["dataloss"],
)
if not self._fail_on_dataloss_warned:
logger.warning(get_dataloss_msg(request.url))
self._fail_on_dataloss_warned = True
raise ResponseDataLossError(str(e)) from e
return make_response(
**make_response_base_args,
body=response_body.getvalue(),
)
def _get_bind_address_host(self) -> str | None:
"""Return the host portion of the bind address.
Needed for handlers that don't support the bind port.
"""
if self._bind_address is None:
return None
host, port = self._bind_address
if port != 0:
logger.warning(
"DOWNLOAD_BIND_ADDRESS specifies a port (%s), but %s does not "
"support binding to a specific local port. Ignoring the port "
"and binding only to %r.",
port,
type(self).__name__,
host,
)
return host
@staticmethod
def _cancel_maxsize(
size: int, limit: int, request: Request, *, expected: bool
) -> NoReturn:
warning_msg = get_maxsize_msg(size, limit, request, expected=expected)
logger.warning(warning_msg)
raise DownloadCancelledError(warning_msg)
@staticmethod
def _extract_proxy(request: Request) -> tuple[str | None, str | None]:
"""Return a tuple of the proxy URL with a scheme and the value of the
Proxy-Authorization header.
This is useful for handlers that take the proxy headers separately.
"""
proxy: str | None = request.meta.get("proxy")
if not proxy:
return None, None
proxy = add_http_if_no_scheme(proxy)
auth_header: list[bytes] | None = request.headers.pop(
b"Proxy-Authorization", None
)
return proxy, auth_header[0].decode("ascii") if auth_header else None
def _extract_proxy_url_with_creds(self, request: Request) -> str | None:
"""Return the proxy URL with the userinfo added based on the
Proxy-Authorization header.
This is useful for handlers that cannot take the proxy headers
separately.
"""
proxy_url, auth_header = self._extract_proxy(request)
if proxy_url is None or auth_header is None:
return proxy_url
scheme, token = auth_header.split(" ", 1)
if scheme != "Basic":
raise ValueError(
f"Expected Basic auth in Proxy-Authorization, got {scheme}"
)
user, password = (
base64.b64decode(token).decode(self._proxy_auth_encoding).split(":", 1)
)
parts = urlsplit(proxy_url)
netloc = f"{quote(user)}:{quote(password)}@{parts.netloc}"
return parts._replace(netloc=netloc).geturl()

View File

@ -3,45 +3,34 @@
from __future__ import annotations
import ipaddress
import logging
import ssl
import time
from http.cookiejar import Cookie, CookieJar
from io import BytesIO
from typing import TYPE_CHECKING, Any, NoReturn, TypedDict
from contextlib import asynccontextmanager
from typing import TYPE_CHECKING, ClassVar
from scrapy import Request, signals
from scrapy.exceptions import (
CannotResolveHostError,
DownloadCancelledError,
DownloadConnectionRefusedError,
DownloadFailedError,
DownloadTimeoutError,
NotConfigured,
ResponseDataLossError,
UnsupportedURLSchemeError,
)
from scrapy.http import Headers, Response
from scrapy.utils._download_handlers import (
BaseHttpDownloadHandler,
check_stop_download,
get_dataloss_msg,
get_maxsize_msg,
get_warnsize_msg,
make_response,
normalize_bind_address,
from scrapy.http import Headers
from scrapy.utils._download_handlers import NullCookieJar
from scrapy.utils.ssl import (
_log_sslobj_debug_info,
_make_insecure_ssl_ctx,
_make_ssl_context,
)
from scrapy.utils.asyncio import is_asyncio_available
from scrapy.utils.ssl import _log_sslobj_debug_info, _make_ssl_context
from ._base_streaming import BaseStreamingDownloadHandler, _BaseResponseArgs
if TYPE_CHECKING:
from contextlib import AbstractAsyncContextManager
from http.client import HTTPResponse
from ipaddress import IPv4Address, IPv6Address
from urllib.request import Request as ULRequest
from collections.abc import AsyncIterator
from httpcore import AsyncNetworkStream
from scrapy import Request
from scrapy.crawler import Crawler
@ -50,89 +39,90 @@ try:
except ImportError:
httpx = None # type: ignore[assignment]
logger = logging.getLogger(__name__)
if TYPE_CHECKING:
_Base = BaseStreamingDownloadHandler[httpx.Response]
else:
_Base = BaseStreamingDownloadHandler
class _BaseResponseArgs(TypedDict):
status: int
url: str
headers: Headers
ip_address: IPv4Address | IPv6Address
protocol: str
# workaround for (and from) https://github.com/encode/httpx/issues/2992
class _NullCookieJar(CookieJar): # pragma: no cover
"""A CookieJar that rejects all cookies."""
def extract_cookies(self, response: HTTPResponse, request: ULRequest) -> None:
pass
def set_cookie(self, cookie: Cookie) -> None:
pass
class HttpxDownloadHandler(BaseHttpDownloadHandler):
_DEFAULT_CONNECT_TIMEOUT = 10
class HttpxDownloadHandler(_Base):
experimental: ClassVar[bool] = True
def __init__(self, crawler: Crawler):
# we skip HttpxDownloadHandler tests with the non-asyncio reactor
if not is_asyncio_available(): # pragma: no cover
raise NotConfigured(
f"{type(self).__name__} requires the asyncio support. Make"
f" sure that you have either enabled the asyncio Twisted"
f" reactor in the TWISTED_REACTOR setting or disabled the"
f" TWISTED_REACTOR_ENABLED setting. See the asyncio"
f" documentation of Scrapy for more information."
)
super().__init__(crawler)
self._verify_certificates: bool = crawler.settings.getbool(
"DOWNLOAD_VERIFY_CERTIFICATES"
)
self._ssl_context: ssl.SSLContext = _make_ssl_context(crawler.settings)
self._bind_host: str | None = self._get_bind_address_host()
self._limits: httpx.Limits = httpx.Limits(
# hard limit on simultaneous connections
max_connections=self._pool_size_total,
# total number of idle connections in the pool (extra ones are closed)
max_keepalive_connections=self._pool_size_total,
)
self._default_client: httpx.AsyncClient = self._make_client()
# httpx doesn't support per-request proxies: https://github.com/encode/httpx/discussions/3183,
# so we keep a pool of clients per proxy URL. LRU eviction can be added here if needed.
self._proxy_clients: dict[str, httpx.AsyncClient] = {}
@staticmethod
def _check_deps_installed() -> None:
if httpx is None: # pragma: no cover
raise NotConfigured(
f"{type(self).__name__} requires the httpx library to be installed."
"HttpxDownloadHandler requires the httpx library to be installed."
)
super().__init__(crawler)
logger.warning(
"HttpxDownloadHandler is experimental and is not recommended for production use."
)
bind_address = crawler.settings.get("DOWNLOAD_BIND_ADDRESS")
bind_address = normalize_bind_address(bind_address)
self._bind_address: str | None = None
def _make_client(self, proxy_url: str | None = None) -> httpx.AsyncClient:
if proxy_url:
if proxy_url.startswith("https:") and not self._verify_certificates:
proxy_ssl_context = _make_insecure_ssl_ctx()
else:
proxy_ssl_context = None
proxy = httpx.Proxy(proxy_url, ssl_context=proxy_ssl_context)
else:
proxy = None
if bind_address is not None:
host, port = bind_address
if port != 0:
logger.warning(
"DOWNLOAD_BIND_ADDRESS specifies a port (%s), but %s does not "
"support binding to a specific local port. Ignoring the port "
"and binding only to %r.",
port,
type(self).__name__,
host,
)
self._bind_address = host
self._client = httpx.AsyncClient(
cookies=_NullCookieJar(),
client = httpx.AsyncClient(
cookies=NullCookieJar(),
transport=httpx.AsyncHTTPTransport(
verify=_make_ssl_context(crawler.settings),
local_address=self._bind_address,
verify=self._ssl_context,
local_address=self._bind_host,
limits=self._limits,
trust_env=False,
proxy=proxy,
),
)
# https://github.com/encode/httpx/discussions/1566
for header_name in ("accept", "accept-encoding", "user-agent"):
self._client.headers.pop(header_name, None)
client.headers.pop(header_name, None)
return client
async def download_request(self, request: Request) -> Response:
self._warn_unsupported_meta(request.meta)
def _get_client(self, proxy_url: str | None) -> httpx.AsyncClient:
if proxy_url is None:
return self._default_client
if cached := self._proxy_clients.get(proxy_url):
return cached
client = self._make_client(proxy_url)
self._proxy_clients[proxy_url] = client
return client
timeout: float = request.meta.get(
"download_timeout", self._DEFAULT_CONNECT_TIMEOUT
)
start_time = time.monotonic()
@asynccontextmanager
async def _make_request(
self, request: Request, timeout: float
) -> AsyncIterator[httpx.Response]:
client = self._get_client(self._extract_proxy_url_with_creds(request))
try:
async with self._get_httpx_response(request, timeout) as httpx_response:
request.meta["download_latency"] = time.monotonic() - start_time
return await self._read_response(httpx_response, request)
async with client.stream(
request.method,
request.url,
content=request.body,
headers=request.headers.to_tuple_list(),
timeout=timeout,
) as response:
yield response
except httpx.TimeoutException as e:
raise DownloadTimeoutError(
f"Getting {request.url} took longer than {timeout} seconds."
@ -149,159 +139,55 @@ class HttpxDownloadHandler(BaseHttpDownloadHandler):
):
raise CannotResolveHostError(error_message) from e
raise DownloadConnectionRefusedError(str(e)) from e
except httpx.NetworkError as e:
except httpx.ProxyError as e:
raise DownloadConnectionRefusedError(str(e)) from e
except (httpx.NetworkError, httpx.RemoteProtocolError) as e:
raise DownloadFailedError(str(e)) from e
except httpx.RemoteProtocolError as e:
raise DownloadFailedError(str(e)) from e
def _warn_unsupported_meta(self, meta: dict[str, Any]) -> None:
if meta.get("bindaddress"):
# configurable only per-client:
# https://github.com/encode/httpx/issues/755#issuecomment-2746121794
logger.error(
f"The 'bindaddress' request meta key is not supported by"
f" {type(self).__name__} and will be ignored."
)
if meta.get("proxy"):
# configurable only per-client:
# https://github.com/encode/httpx/issues/486
logger.error(
f"The 'proxy' request meta key is not supported by"
f" {type(self).__name__} and will be ignored."
)
def _get_httpx_response(
self, request: Request, timeout: float
) -> AbstractAsyncContextManager[httpx.Response]:
return self._client.stream(
request.method,
request.url,
content=request.body,
headers=request.headers.to_tuple_list(),
timeout=timeout,
)
async def _read_response(
self, httpx_response: httpx.Response, request: Request
) -> Response:
maxsize: int = request.meta.get("download_maxsize", self._default_maxsize)
warnsize: int = request.meta.get("download_warnsize", self._default_warnsize)
content_length = httpx_response.headers.get("Content-Length")
expected_size = int(content_length) if content_length is not None else None
if maxsize and expected_size and expected_size > maxsize:
self._cancel_maxsize(expected_size, maxsize, request, expected=True)
reached_warnsize = False
if warnsize and expected_size and expected_size > warnsize:
reached_warnsize = True
logger.warning(
get_warnsize_msg(expected_size, warnsize, request, expected=True)
)
headers = Headers(httpx_response.headers.multi_items())
network_stream: AsyncNetworkStream = httpx_response.extensions["network_stream"]
make_response_base_args: _BaseResponseArgs = {
"status": httpx_response.status_code,
"url": request.url,
"headers": headers,
"ip_address": self._get_server_ip(network_stream),
"protocol": httpx_response.http_version,
}
self._log_tls_info(network_stream)
if stop_download := check_stop_download(
signals.headers_received,
self.crawler,
request,
headers=headers,
body_length=expected_size,
):
return make_response(
**make_response_base_args,
stop_download=stop_download,
)
response_body = BytesIO()
bytes_received = 0
try:
async for chunk in httpx_response.aiter_raw():
response_body.write(chunk)
bytes_received += len(chunk)
if stop_download := check_stop_download(
signals.bytes_received, self.crawler, request, data=chunk
):
return make_response(
**make_response_base_args,
body=response_body.getvalue(),
stop_download=stop_download,
)
if maxsize and bytes_received > maxsize:
response_body.truncate(0)
self._cancel_maxsize(
bytes_received, maxsize, request, expected=False
)
if warnsize and bytes_received > warnsize and not reached_warnsize:
reached_warnsize = True
logger.warning(
get_warnsize_msg(
bytes_received, warnsize, request, expected=False
)
)
except httpx.RemoteProtocolError as e:
# special handling of the dataloss case
if (
"peer closed connection without sending complete message body"
not in str(e)
):
raise
fail_on_dataloss: bool = request.meta.get(
"download_fail_on_dataloss", self._fail_on_dataloss
)
if not fail_on_dataloss:
return make_response(
**make_response_base_args,
body=response_body.getvalue(),
flags=["dataloss"],
)
self._log_dataloss_warning(request.url)
raise ResponseDataLossError(str(e)) from e
return make_response(
**make_response_base_args,
body=response_body.getvalue(),
)
@staticmethod
def _get_server_ip(network_stream: AsyncNetworkStream) -> IPv4Address | IPv6Address:
extra_server_addr = network_stream.get_extra_info("server_addr")
return ipaddress.ip_address(extra_server_addr[0])
def _extract_headers(response: httpx.Response) -> Headers:
return Headers(response.headers.multi_items())
def _log_tls_info(self, network_stream: AsyncNetworkStream) -> None:
if not self._tls_verbose_logging:
return
@staticmethod
def _build_base_response_args(
response: httpx.Response,
request: Request,
headers: Headers,
) -> _BaseResponseArgs:
network_stream: AsyncNetworkStream = response.extensions["network_stream"]
server_addr = network_stream.get_extra_info("server_addr")
ip_address = ipaddress.ip_address(server_addr[0])
ssl_object = network_stream.get_extra_info("ssl_object")
if isinstance(ssl_object, ssl.SSLObject):
cert = ssl_object.getpeercert(binary_form=True)
else:
cert = None
return {
"status": response.status_code,
"url": request.url,
"headers": headers,
"certificate": cert,
"ip_address": ip_address,
"protocol": response.http_version,
}
@staticmethod
def _iter_body_chunks(response: httpx.Response) -> AsyncIterator[bytes]:
return response.aiter_raw()
@staticmethod
def _is_dataloss_exception(exc: Exception) -> bool:
return isinstance(
exc, httpx.RemoteProtocolError
) and "peer closed connection without sending complete message body" in str(exc)
def _log_tls_info(self, response: httpx.Response, request: Request) -> None:
network_stream: AsyncNetworkStream = response.extensions["network_stream"]
extra_ssl_object = network_stream.get_extra_info("ssl_object")
if isinstance(extra_ssl_object, ssl.SSLObject):
_log_sslobj_debug_info(extra_ssl_object)
def _log_dataloss_warning(self, url: str) -> None:
if self._fail_on_dataloss_warned:
return
logger.warning(get_dataloss_msg(url))
self._fail_on_dataloss_warned = True
@staticmethod
def _cancel_maxsize(
size: int, limit: int, request: Request, *, expected: bool
) -> NoReturn:
warning_msg = get_maxsize_msg(size, limit, request, expected=expected)
logger.warning(warning_msg)
raise DownloadCancelledError(warning_msg)
async def close(self) -> None:
await self._client.aclose()
await self._default_client.aclose()
for client in self._proxy_clients.values():
await client.aclose()

View File

@ -1,7 +1,6 @@
# pragma: no file cover
import warnings
from scrapy.core.downloader.handlers.http10 import HTTP10DownloadHandler
from scrapy.core.downloader.handlers.http11 import (
HTTP11DownloadHandler as HTTPDownloadHandler,
)
@ -16,6 +15,5 @@ warnings.warn(
)
__all__ = [
"HTTP10DownloadHandler",
"HTTPDownloadHandler",
]

View File

@ -1,79 +0,0 @@
"""Download handlers for http and https schemes"""
from __future__ import annotations
import warnings
from typing import TYPE_CHECKING
from scrapy.core.downloader.contextfactory import _ScrapyClientContextFactory
from scrapy.exceptions import NotConfigured, ScrapyDeprecationWarning
from scrapy.utils.defer import maybe_deferred_to_future
from scrapy.utils.misc import build_from_crawler, load_object
from scrapy.utils.python import to_unicode
if TYPE_CHECKING:
from twisted.internet.interfaces import IConnector
# typing.Self requires Python 3.11
from typing_extensions import Self
from scrapy import Request
from scrapy.core.downloader.webclient import ScrapyHTTPClientFactory
from scrapy.crawler import Crawler
from scrapy.http import Response
from scrapy.settings import BaseSettings
class HTTP10DownloadHandler:
lazy = False
def __init__(self, settings: BaseSettings, crawler: Crawler):
warnings.warn(
"HTTP10DownloadHandler is deprecated and will be removed in a future Scrapy version.",
category=ScrapyDeprecationWarning,
stacklevel=2,
)
if not crawler.settings.getbool("TWISTED_REACTOR_ENABLED"): # pragma: no cover
raise NotConfigured(f"{type(self).__name__} requires a Twisted reactor.")
self.HTTPClientFactory: type[ScrapyHTTPClientFactory] = load_object(
settings["DOWNLOADER_HTTPCLIENTFACTORY"]
)
if settings["DOWNLOADER_CLIENTCONTEXTFACTORY"] == "SENTINEL":
self.ClientContextFactory: type[_ScrapyClientContextFactory] = (
_ScrapyClientContextFactory
)
else: # pragma: no cover
warnings.warn(
"The 'DOWNLOADER_CLIENTCONTEXTFACTORY' setting is deprecated.",
category=ScrapyDeprecationWarning,
stacklevel=2,
)
self.ClientContextFactory = load_object(
settings["DOWNLOADER_CLIENTCONTEXTFACTORY"]
)
self._settings: BaseSettings = settings
self._crawler: Crawler = crawler
@classmethod
def from_crawler(cls, crawler: Crawler) -> Self:
return cls(crawler.settings, crawler)
async def download_request(self, request: Request) -> Response:
factory = self.HTTPClientFactory(request)
self._connect(factory)
return await maybe_deferred_to_future(factory.deferred)
def _connect(self, factory: ScrapyHTTPClientFactory) -> IConnector:
from twisted.internet import reactor
host, port = to_unicode(factory.host), factory.port
if factory.scheme == b"https":
client_context_factory = build_from_crawler(
self.ClientContextFactory,
self._crawler,
)
return reactor.connectSSL(host, port, factory, client_context_factory)
return reactor.connectTCP(host, port, factory)
async def close(self) -> None:
pass

View File

@ -6,6 +6,7 @@ import ipaddress
import logging
import re
from contextlib import suppress
from functools import partial
from io import BytesIO
from time import monotonic
from typing import TYPE_CHECKING, Any, TypedDict, TypeVar, cast
@ -40,7 +41,6 @@ from scrapy.exceptions import (
)
from scrapy.http import Headers, Response
from scrapy.utils._download_handlers import (
BaseHttpDownloadHandler,
check_stop_download,
get_dataloss_msg,
get_maxsize_msg,
@ -56,6 +56,8 @@ from scrapy.utils.python import to_bytes, to_unicode
from scrapy.utils.ssl import _log_ssl_conn_debug_info
from scrapy.utils.url import add_http_if_no_scheme
from ._base_http import BaseHttpDownloadHandler
if TYPE_CHECKING:
from twisted.internet.base import ReactorBase
from twisted.internet.interfaces import IConsumer
@ -110,7 +112,7 @@ class HTTP11DownloadHandler(BaseHttpDownloadHandler):
"download_warnsize", "DOWNLOAD_WARNSIZE"
)
agent = ScrapyAgent(
agent = _ScrapyAgent(
contextFactory=self._contextFactory,
bindAddress=self._bind_address,
pool=self._pool,
@ -160,7 +162,7 @@ class TunnelError(Exception):
"""An HTTP CONNECT tunnel could not be established by the proxy."""
class TunnelingTCP4ClientEndpoint(TCP4ClientEndpoint):
class _TunnelingTCP4ClientEndpoint(TCP4ClientEndpoint):
"""An endpoint that tunnels through proxies to allow HTTPS downloads. To
accomplish that, this endpoint sends an HTTP CONNECT to the proxy.
The HTTP CONNECT is always sent when using this endpoint, I think this could
@ -196,7 +198,7 @@ class TunnelingTCP4ClientEndpoint(TCP4ClientEndpoint):
def requestTunnel(self, protocol: Protocol) -> Protocol:
"""Asks the proxy to open a tunnel."""
assert protocol.transport
tunnelReq = tunnel_request_data(
tunnelReq = _tunnel_request_data(
self._tunneledHost, self._tunneledPort, self._proxyAuthHeader
)
protocol.transport.write(tunnelReq)
@ -220,11 +222,12 @@ class TunnelingTCP4ClientEndpoint(TCP4ClientEndpoint):
if b"\r\n\r\n" not in self._connectBuffer:
return
self._protocol.dataReceived = self._protocolDataReceived # type: ignore[method-assign]
respm = TunnelingTCP4ClientEndpoint._responseMatcher.match(self._connectBuffer)
respm = _TunnelingTCP4ClientEndpoint._responseMatcher.match(self._connectBuffer)
if respm and int(respm.group("status")) == 200:
# set proper Server Name Indication extension
sslOptions = self._contextFactory.creatorForNetloc( # type: ignore[call-arg,misc]
self._tunneledHost, self._tunneledPort
self._tunneledHost, # type: ignore[arg-type]
self._tunneledPort,
)
self._protocol.transport.startTLS(sslOptions, self._protocolFactory)
self._tunnelReadyDeferred.callback(self._protocol)
@ -256,18 +259,18 @@ class TunnelingTCP4ClientEndpoint(TCP4ClientEndpoint):
return self._tunnelReadyDeferred
def tunnel_request_data(
def _tunnel_request_data(
host: str, port: int, proxy_auth_header: bytes | None = None
) -> bytes:
r"""
Return binary content of a CONNECT request.
>>> from scrapy.utils.python import to_unicode as s
>>> s(tunnel_request_data("example.com", 8080))
>>> s(_tunnel_request_data("example.com", 8080))
'CONNECT example.com:8080 HTTP/1.1\r\nHost: example.com:8080\r\n\r\n'
>>> s(tunnel_request_data("example.com", 8080, b"123"))
>>> s(_tunnel_request_data("example.com", 8080, b"123"))
'CONNECT example.com:8080 HTTP/1.1\r\nHost: example.com:8080\r\nProxy-Authorization: 123\r\n\r\n'
>>> s(tunnel_request_data(b"example.com", "8090"))
>>> s(_tunnel_request_data(b"example.com", "8090"))
'CONNECT example.com:8090 HTTP/1.1\r\nHost: example.com:8090\r\n\r\n'
"""
host_value = to_bytes(host, encoding="ascii") + b":" + to_bytes(str(port))
@ -279,7 +282,7 @@ def tunnel_request_data(
return tunnel_req
class TunnelingAgent(Agent):
class _TunnelingAgent(Agent):
"""An agent that uses a L{TunnelingTCP4ClientEndpoint} to make HTTPS
downloads. It may look strange that we have chosen to subclass Agent and not
ProxyAgent but consider that after the tunnel is opened the proxy is
@ -301,8 +304,8 @@ class TunnelingAgent(Agent):
self._proxyConf: tuple[str, int, bytes | None] = proxyConf
self._contextFactory: IPolicyForHTTPS = contextFactory
def _getEndpoint(self, uri: URI) -> TunnelingTCP4ClientEndpoint:
return TunnelingTCP4ClientEndpoint(
def _getEndpoint(self, uri: URI) -> _TunnelingTCP4ClientEndpoint:
return _TunnelingTCP4ClientEndpoint(
reactor=self._reactor,
host=uri.host,
port=uri.port,
@ -337,17 +340,19 @@ class TunnelingAgent(Agent):
)
class ScrapyProxyAgent(Agent):
class _ScrapyProxyAgent(Agent):
def __init__(
self,
reactor: ReactorBase,
proxyURI: bytes,
contextFactory: IPolicyForHTTPS,
connectTimeout: float | None = None,
bindAddress: tuple[str, int] | None = None,
pool: HTTPConnectionPool | None = None,
):
super().__init__( # type: ignore[no-untyped-call]
reactor=reactor,
contextFactory=contextFactory,
connectTimeout=connectTimeout,
bindAddress=bindAddress,
pool=pool,
@ -377,11 +382,7 @@ class ScrapyProxyAgent(Agent):
)
class ScrapyAgent:
_Agent = Agent
_ProxyAgent = ScrapyProxyAgent
_TunnelingAgent = TunnelingAgent
class _ScrapyAgent:
def __init__(
self,
*,
@ -420,10 +421,14 @@ class ScrapyAgent:
if not proxy_port:
proxy_port = 443 if proxy_parsed.scheme == "https" else 80
if urlparse_cached(request).scheme == "https":
if proxy_parsed.scheme == "https": # pragma: no cover
raise NotImplementedError(
"HTTPS proxies for HTTPS destinations are not supported"
)
assert proxy_host is not None
proxyAuth = request.headers.get(b"Proxy-Authorization", None)
proxyConf = (proxy_host, proxy_port, proxyAuth)
return self._TunnelingAgent(
return _TunnelingAgent(
reactor=reactor,
proxyConf=proxyConf,
contextFactory=self._contextFactory,
@ -431,15 +436,16 @@ class ScrapyAgent:
bindAddress=bindaddress,
pool=self._pool,
)
return self._ProxyAgent(
return _ScrapyProxyAgent(
reactor=reactor,
proxyURI=to_bytes(proxy, encoding="ascii"),
contextFactory=self._contextFactory,
connectTimeout=timeout,
bindAddress=bindaddress,
pool=self._pool,
)
return self._Agent( # type: ignore[no-untyped-call]
return Agent(
reactor=reactor,
contextFactory=self._contextFactory,
connectTimeout=timeout,
@ -457,7 +463,7 @@ class ScrapyAgent:
url = urldefrag(request.url)[0]
method = to_bytes(request.method)
headers = TxHeaders(request.headers)
if isinstance(agent, self._TunnelingAgent):
if isinstance(agent, _TunnelingAgent):
headers.removeHeader(b"Proxy-Authorization")
bodyproducer = _RequestBodyProducer(request.body) if request.body else None
start_time = monotonic()
@ -547,11 +553,7 @@ class ScrapyAgent:
get_warnsize_msg(expected_size, warnsize, request, expected=True)
)
def _cancel(_: Any) -> None:
# Abort connection immediately.
txresponse._transport._producer.abortConnection()
d: Deferred[_ResultT] = Deferred(_cancel)
d: Deferred[_ResultT] = Deferred(partial(self._cancel, txresponse=txresponse))
txresponse.deliverBody(
_ResponseReader(
finished=d,
@ -570,6 +572,11 @@ class ScrapyAgent:
return d
@staticmethod
def _cancel(_: Any, txresponse: TxResponse) -> None:
# Abort connection immediately.
txresponse._transport._producer.abortConnection()
def _cb_bodydone(self, result: _ResultT, url: str) -> Response:
headers = self._headers_from_twisted_response(result["txresponse"])
try:
@ -667,17 +674,17 @@ class _ResponseReader(Protocol):
assert hostname is not None
_log_ssl_conn_debug_info(hostname, connection)
def dataReceived(self, bodyBytes: bytes) -> None:
def dataReceived(self, data: bytes) -> None:
# This maybe called several times after cancel was called with buffered data.
if self._finished.called:
return
assert self.transport
self._bodybuf.write(bodyBytes)
self._bytes_received += len(bodyBytes)
self._bodybuf.write(data)
self._bytes_received += len(data)
if stop_download := check_stop_download(
signals.bytes_received, self._crawler, self._request, data=bodyBytes
signals.bytes_received, self._crawler, self._request, data=data
):
self.transport.stopProducing()
self.transport.loseConnection()

View File

@ -4,19 +4,20 @@ from time import monotonic
from typing import TYPE_CHECKING
from urllib.parse import urldefrag
from twisted.web.client import URI
from scrapy.core.downloader.contextfactory import _load_context_factory_from_settings
from scrapy.core.downloader.handlers.base import BaseDownloadHandler
from scrapy.core.http2.agent import H2Agent, H2ConnectionPool, ScrapyProxyH2Agent
from scrapy.exceptions import DownloadTimeoutError, NotConfigured
from scrapy.core.http2.agent import H2Agent, H2ConnectionPool
from scrapy.exceptions import (
DownloadTimeoutError,
NotConfigured,
UnsupportedURLSchemeError,
)
from scrapy.utils._download_handlers import (
normalize_bind_address,
wrap_twisted_exceptions,
)
from scrapy.utils.defer import maybe_deferred_to_future
from scrapy.utils.httpobj import urlparse_cached
from scrapy.utils.python import to_bytes
if TYPE_CHECKING:
from twisted.internet.base import DelayedCall
@ -44,7 +45,11 @@ class H2DownloadHandler(BaseDownloadHandler):
self._bind_address = crawler.settings.get("DOWNLOAD_BIND_ADDRESS")
async def download_request(self, request: Request) -> Response:
agent = ScrapyH2Agent(
if urlparse_cached(request).scheme == "http": # pragma: no cover
raise UnsupportedURLSchemeError(
f"{type(self).__name__} doesn't support plain HTTP."
)
agent = _ScrapyH2Agent(
context_factory=self._context_factory,
pool=self._pool,
bind_address=self._bind_address,
@ -60,10 +65,7 @@ class H2DownloadHandler(BaseDownloadHandler):
self._pool.close_connections()
class ScrapyH2Agent:
_Agent = H2Agent
_ProxyAgent = ScrapyProxyH2Agent
class _ScrapyH2Agent:
def __init__(
self,
context_factory: IPolicyForHTTPS,
@ -81,25 +83,11 @@ class ScrapyH2Agent:
def _get_agent(self, request: Request, timeout: float | None) -> H2Agent:
from twisted.internet import reactor
if request.meta.get("proxy"): # pragma: no cover
raise NotImplementedError(f"{type(self).__name__} doesn't support proxies.")
bind_address = request.meta.get("bindaddress") or self._bind_address
bind_address = normalize_bind_address(bind_address)
proxy = request.meta.get("proxy")
if proxy:
if urlparse_cached(request).scheme == "https":
# ToDo
raise NotImplementedError(
"Tunneling via CONNECT method using HTTP/2.0 is not yet supported"
)
return self._ProxyAgent(
reactor=reactor,
context_factory=self._context_factory,
proxy_uri=URI.fromBytes(to_bytes(proxy, encoding="ascii")),
connect_timeout=timeout,
bind_address=bind_address,
pool=self._pool,
)
return self._Agent(
return H2Agent(
reactor=reactor,
context_factory=self._context_factory,
connect_timeout=timeout,

View File

@ -1,6 +1,6 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any, cast
from scrapy.core.downloader.handlers.base import BaseDownloadHandler
from scrapy.exceptions import NotConfigured
@ -9,6 +9,8 @@ from scrapy.utils.httpobj import urlparse_cached
from scrapy.utils.misc import build_from_crawler, load_object
if TYPE_CHECKING:
from collections.abc import Mapping
from scrapy import Request
from scrapy.crawler import Crawler
from scrapy.http import Response
@ -39,7 +41,7 @@ class S3DownloadHandler(BaseDownloadHandler):
)
)
_http_handler = build_from_crawler(
_http_handler: BaseDownloadHandler = build_from_crawler(
load_object(crawler.settings.getwithbase("DOWNLOAD_HANDLERS")["https"]),
crawler,
)
@ -59,7 +61,7 @@ class S3DownloadHandler(BaseDownloadHandler):
awsrequest = botocore.awsrequest.AWSRequest(
method=request.method,
url=f"{scheme}://s3.amazonaws.com/{bucket}{path}",
headers=request.headers.to_unicode_dict(),
headers=cast("Mapping[str, Any]", request.headers.to_unicode_dict()),
data=request.body,
)
assert self._signer

View File

@ -8,7 +8,7 @@ from __future__ import annotations
import warnings
from functools import wraps
from typing import TYPE_CHECKING, Any, cast
from typing import TYPE_CHECKING, Any
from scrapy.exceptions import ScrapyDeprecationWarning, _InvalidOutput
from scrapy.http import Request, Response
@ -75,87 +75,82 @@ class DownloaderMiddlewareManager(MiddlewareManager):
download_func: Callable[[Request], Coroutine[Any, Any, Response]],
request: Request,
) -> Response | Request:
async def process_request(request: Request) -> Response | Request:
for method in self.methods["process_request"]:
method = cast("Callable", method)
if method in self._mw_methods_requiring_spider:
response = await ensure_awaitable(
method(request=request, spider=self._spider),
_warn=global_object_name(method),
)
else:
response = await ensure_awaitable(
method(request=request), _warn=global_object_name(method)
)
if response is not None and not isinstance(
response, (Response, Request)
):
raise _InvalidOutput(
f"Middleware {method.__qualname__} must return None, Response or "
f"Request, got {response.__class__.__name__}"
)
if response:
return response
return await download_func(request)
async def process_response(response: Response | Request) -> Response | Request:
if response is None:
raise TypeError("Received None in process_response")
if isinstance(response, Request):
return response
for method in self.methods["process_response"]:
method = cast("Callable", method)
if method in self._mw_methods_requiring_spider:
response = await ensure_awaitable(
method(request=request, response=response, spider=self._spider),
_warn=global_object_name(method),
)
else:
response = await ensure_awaitable(
method(request=request, response=response),
_warn=global_object_name(method),
)
if not isinstance(response, (Response, Request)):
raise _InvalidOutput(
f"Middleware {method.__qualname__} must return Response or Request, "
f"got {type(response)}"
)
if isinstance(response, Request):
return response
return response
async def process_exception(exception: Exception) -> Response | Request:
for method in self.methods["process_exception"]:
method = cast("Callable", method)
if method in self._mw_methods_requiring_spider:
response = await ensure_awaitable(
method(
request=request, exception=exception, spider=self._spider
),
_warn=global_object_name(method),
)
else:
response = await ensure_awaitable(
method(request=request, exception=exception),
_warn=global_object_name(method),
)
if response is not None and not isinstance(
response, (Response, Request)
):
raise _InvalidOutput(
f"Middleware {method.__qualname__} must return None, Response or "
f"Request, got {type(response)}"
)
if response:
return response
raise exception
try:
result: Response | Request = await process_request(request)
result: Response | Request = await self._process_request(
request, download_func
)
except Exception as ex:
await _defer_sleep_async()
# either returns a request or response (which we pass to process_response())
# or reraises the exception
result = await process_exception(ex)
return await process_response(result)
result = await self._process_exception(ex, request)
return await self._process_response(result, request)
def _handle_mw_method(self, method: Callable[..., Any], **kwargs: Any) -> Any:
if method in self._mw_methods_requiring_spider:
kwargs["spider"] = self._spider
return method(**kwargs)
async def _process_request(
self,
request: Request,
download_func: Callable[[Request], Coroutine[Any, Any, Response]],
) -> Response | Request:
for method in self.methods["process_request"]:
assert method is not None
response = await ensure_awaitable(
self._handle_mw_method(method, request=request),
_warn=global_object_name(method),
)
if response is not None and not isinstance(response, (Response, Request)):
raise _InvalidOutput(
f"Middleware {method.__qualname__} must return None, Response or "
f"Request, got {response.__class__.__name__}"
)
if response:
return response
return await download_func(request)
async def _process_response(
self, response: Response | Request, request: Request
) -> Response | Request:
if response is None:
raise TypeError("Received None in process_response")
if isinstance(response, Request):
return response
for method in self.methods["process_response"]:
assert method is not None
response = await ensure_awaitable(
self._handle_mw_method(method, request=request, response=response),
_warn=global_object_name(method),
)
if not isinstance(response, (Response, Request)):
raise _InvalidOutput(
f"Middleware {method.__qualname__} must return Response or Request, "
f"got {type(response)}"
)
if isinstance(response, Request):
return response
return response
async def _process_exception(
self, exception: Exception, request: Request | Response
) -> Response | Request:
for method in self.methods["process_exception"]:
assert method is not None
response = await ensure_awaitable(
self._handle_mw_method(method, request=request, exception=exception),
_warn=global_object_name(method),
)
if response is not None and not isinstance(response, (Response, Request)):
raise _InvalidOutput(
f"Middleware {method.__qualname__} must return None, Response or "
f"Request, got {type(response)}"
)
if response:
return response
raise exception

View File

@ -1,15 +1,34 @@
from __future__ import annotations
import logging
from typing import Any
from typing import TYPE_CHECKING, Any
from OpenSSL import SSL
from service_identity import VerificationError
from service_identity.exceptions import CertificateError
from service_identity.pyopenssl import verify_hostname, verify_ip_address
from service_identity.hazmat import (
DNS_ID,
IPAddress_ID,
ServiceID,
verify_service_identity,
)
from service_identity.pyopenssl import (
extract_patterns,
verify_hostname,
verify_ip_address,
)
from twisted.internet._sslverify import ClientTLSOptions
from twisted.internet.ssl import AcceptableCiphers
from scrapy.utils.deprecate import create_deprecated_class
if TYPE_CHECKING:
from collections.abc import Callable
from OpenSSL.crypto import X509
from twisted.protocols.tls import TLSMemoryBIOProtocol
logger = logging.getLogger(__name__)
@ -39,6 +58,8 @@ class _ScrapyClientTLSOptions(ClientTLSOptions):
Instances of this class are returned from
:class:`._ScrapyClientContextFactory`.
This class is used on Twisted older than 26.4.0.
"""
def _identityVerifyingInfoCallback(
@ -64,7 +85,7 @@ class _ScrapyClientTLSOptions(ClientTLSOptions):
e,
)
else:
super()._identityVerifyingInfoCallback(connection, where, ret) # type: ignore[no-untyped-call]
super()._identityVerifyingInfoCallback(connection, where, ret) # type: ignore[misc]
ScrapyClientTLSOptions = create_deprecated_class(
@ -75,6 +96,67 @@ ScrapyClientTLSOptions = create_deprecated_class(
)
class _ScrapyClientTLSOptions26(ClientTLSOptions):
"""
SSL Client connection creator ignoring certificate verification errors
(for genuinely invalid certificates or bugs in verification code).
Same as Twisted's private _sslverify.ClientTLSOptions,
except that VerificationError, CertificateError and ValueError
exceptions are caught, so that the connection is not closed, only
logging warnings.
Instances of this class are returned from
:class:`._ScrapyClientContextFactory`.
This class is used on Twisted 26.4.0 and newer.
"""
def clientConnectionForTLS(
self, tlsProtocol: TLSMemoryBIOProtocol
) -> SSL.Connection:
"""This method is needed to override the verify callback."""
conn = super().clientConnectionForTLS(tlsProtocol)
callback = self._verifyCB(self._hostnameIsDnsName, self._hostnameASCII)
conn.set_verify(SSL.VERIFY_PEER | SSL.VERIFY_FAIL_IF_NO_PEER_CERT, callback)
return conn
@staticmethod
def _verifyCB(
hostIsDNS: bool, hostnameASCII: str
) -> Callable[[SSL.Connection, X509, int, int, int], bool]:
svcid: ServiceID = (
DNS_ID(hostnameASCII) if hostIsDNS else IPAddress_ID(hostnameASCII)
)
def verifyCallback(
conn: SSL.Connection, cert: X509, err: int, depth: int, ok: int
) -> bool:
if depth != 0:
# We are only verifying the leaf certificate.
return True
try:
verify_service_identity(extract_patterns(cert), [svcid], [])
except (CertificateError, VerificationError) as e:
logger.warning(
'Remote certificate is not valid for hostname "%s"; %s',
hostnameASCII,
e,
)
except ValueError as e:
logger.warning(
"Ignoring error while verifying certificate "
'from host "%s" (exception: %r)',
hostnameASCII,
e,
)
return True
return verifyCallback
DEFAULT_CIPHERS: AcceptableCiphers = AcceptableCiphers.fromOpenSSLCipherString(
"DEFAULT"
)

View File

@ -1,243 +0,0 @@
"""Deprecated HTTP/1.0 helper classes used by HTTP10DownloadHandler."""
from __future__ import annotations
import warnings
from time import monotonic, time
from typing import TYPE_CHECKING
from urllib.parse import urldefrag, urlparse, urlunparse
from twisted.internet import defer
from twisted.internet.protocol import ClientFactory
from twisted.web.http import HTTPClient
from scrapy.exceptions import DownloadTimeoutError, ScrapyDeprecationWarning
from scrapy.http import Headers, Response
from scrapy.responsetypes import responsetypes
from scrapy.utils.httpobj import urlparse_cached
from scrapy.utils.python import to_bytes, to_unicode
if TYPE_CHECKING:
from scrapy import Request
class ScrapyHTTPPageGetter(HTTPClient):
delimiter = b"\n"
def __init__(self):
warnings.warn(
"ScrapyHTTPPageGetter is deprecated and will be removed in a future Scrapy version.",
category=ScrapyDeprecationWarning,
stacklevel=2,
)
super().__init__()
def connectionMade(self):
self.headers = Headers() # bucket for response headers
# Method command
self.sendCommand(self.factory.method, self.factory.path)
# Headers
for key, values in self.factory.headers.items():
for value in values:
self.sendHeader(key, value)
self.endHeaders()
# Body
if self.factory.body is not None:
self.transport.write(self.factory.body)
def lineReceived(self, line):
return HTTPClient.lineReceived(self, line.rstrip())
def handleHeader(self, key, value):
self.headers.appendlist(key, value)
def handleStatus(self, version, status, message):
self.factory.gotStatus(version, status, message)
def handleEndHeaders(self):
self.factory.gotHeaders(self.headers)
def connectionLost(self, reason):
self._connection_lost_reason = reason
HTTPClient.connectionLost(self, reason)
self.factory.noPage(reason)
def handleResponse(self, response):
if self.factory.method.upper() == b"HEAD":
self.factory.page(b"")
elif self.length is not None and self.length > 0:
self.factory.noPage(self._connection_lost_reason)
else:
self.factory.page(response)
self.transport.loseConnection()
def timeout(self):
self.transport.loseConnection()
# transport cleanup needed for HTTPS connections
if self.factory.url.startswith(b"https"):
self.transport.stopProducing()
self.factory.noPage(
DownloadTimeoutError(
f"Getting {self.factory.url} took longer "
f"than {self.factory.timeout} seconds."
)
)
# This class used to inherit from Twisteds
# twisted.web.client.HTTPClientFactory. When that class was deprecated in
# Twisted (https://github.com/twisted/twisted/pull/643), we merged its
# non-overridden code into this class.
class ScrapyHTTPClientFactory(ClientFactory):
protocol = ScrapyHTTPPageGetter
waiting = 1
noisy = False
followRedirect = False
afterFoundGet = False
def _build_response(self, body, request):
request.meta["download_latency"] = (
self._headers_time_mono - self._start_time_mono
)
status = int(self.status)
headers = Headers(self.response_headers)
respcls = responsetypes.from_args(headers=headers, url=self._url, body=body)
return respcls(
url=self._url,
status=status,
headers=headers,
body=body,
protocol=to_unicode(self.version),
)
def _set_connection_attributes(self, request):
proxy = request.meta.get("proxy")
if proxy:
proxy_parsed = urlparse(to_bytes(proxy, encoding="ascii"))
self.scheme = proxy_parsed.scheme
self.host = proxy_parsed.hostname
self.port = proxy_parsed.port
self.netloc = proxy_parsed.netloc
if self.port is None:
self.port = 443 if proxy_parsed.scheme == b"https" else 80
self.path = self.url
else:
parsed = urlparse_cached(request)
path_str = urlunparse(
("", "", parsed.path or "/", parsed.params, parsed.query, "")
)
self.path = to_bytes(path_str, encoding="ascii")
assert parsed.hostname is not None
self.host = to_bytes(parsed.hostname, encoding="ascii")
self.port = parsed.port
self.scheme = to_bytes(parsed.scheme, encoding="ascii")
self.netloc = to_bytes(parsed.netloc, encoding="ascii")
if self.port is None:
self.port = 443 if self.scheme == b"https" else 80
def __init__(self, request: Request, timeout: float = 180):
warnings.warn(
"ScrapyHTTPClientFactory is deprecated and will be removed in a future Scrapy version.",
category=ScrapyDeprecationWarning,
stacklevel=2,
)
self._url: str = urldefrag(request.url)[0]
# converting to bytes to comply to Twisted interface
self.url: bytes = to_bytes(self._url, encoding="ascii")
self.method: bytes = to_bytes(request.method, encoding="ascii")
self.body: bytes | None = request.body or None
self.headers: Headers = Headers(request.headers)
self.response_headers: Headers | None = None
self.timeout: float = request.meta.get("download_timeout") or timeout
self.start_time: float = time()
self._start_time_mono: float = monotonic()
self.deferred: defer.Deferred[Response] = defer.Deferred().addCallback(
self._build_response, request
)
# Fixes Twisted 11.1.0+ support as HTTPClientFactory is expected
# to have _disconnectedDeferred. See Twisted r32329.
# As Scrapy implements it's own logic to handle redirects is not
# needed to add the callback _waitForDisconnect.
# Specifically this avoids the AttributeError exception when
# clientConnectionFailed method is called.
self._disconnectedDeferred: defer.Deferred[None] = defer.Deferred()
self._set_connection_attributes(request)
# set Host header based on url
self.headers.setdefault("Host", self.netloc)
# set Content-Length based len of body
if self.body is not None:
self.headers["Content-Length"] = len(self.body)
# just in case a broken http/1.1 decides to keep connection alive
self.headers.setdefault("Connection", "close")
# Content-Length must be specified in POST method even with no body
elif self.method == b"POST":
self.headers["Content-Length"] = 0
def __repr__(self) -> str:
return f"<{self.__class__.__name__}: {self._url}>"
def _cancelTimeout(self, result, timeoutCall):
if timeoutCall.active():
timeoutCall.cancel()
return result
def buildProtocol(self, addr):
p = ClientFactory.buildProtocol(self, addr)
p.followRedirect = self.followRedirect
p.afterFoundGet = self.afterFoundGet
if self.timeout:
from twisted.internet import reactor
timeoutCall = reactor.callLater(self.timeout, p.timeout)
self.deferred.addBoth(self._cancelTimeout, timeoutCall)
return p
def gotHeaders(self, headers):
self.headers_time = time()
self._headers_time_mono = monotonic()
self.response_headers = headers
def gotStatus(self, version, status, message):
"""
Set the status of the request on us.
@param version: The HTTP version.
@type version: L{bytes}
@param status: The HTTP status code, an integer represented as a
bytestring.
@type status: L{bytes}
@param message: The HTTP status message.
@type message: L{bytes}
"""
self.version, self.status, self.message = version, status, message
def page(self, page):
if self.waiting:
self.waiting = 0
self.deferred.callback(page)
def noPage(self, reason):
if self.waiting:
self.waiting = 0
self.deferred.errback(reason)
def clientConnectionFailed(self, _, reason):
"""
When a connection attempt fails, the request cannot be issued. If no
result has yet been provided to the result Deferred, provide the
connection failure reason as an error result.
"""
if self.waiting:
self.waiting = 0
# If the connection attempt failed, there is nothing more to
# disconnect, so just fire that Deferred now.
self._disconnectedDeferred.callback(None)
self.deferred.errback(reason)

View File

@ -11,6 +11,7 @@ import asyncio
import contextlib
import logging
import warnings
from functools import partial
from time import time
from traceback import format_exc
from typing import TYPE_CHECKING, Any
@ -273,7 +274,7 @@ class ExecutionEngine:
"""
assert self._start is not None
try:
item_or_request = await self._start.__anext__()
item_or_request = await anext(self._start)
except StopAsyncIteration:
self._start = None
except Exception as exception:
@ -352,6 +353,10 @@ class ExecutionEngine:
or self.scraper.slot.needs_backout()
)
def _remove_request(self, _: Any, request: Request) -> None:
assert self._slot
self._slot.remove_request(request)
def _start_scheduled_request(self) -> bool:
assert self._slot is not None # typing
assert self.spider is not None # typing
@ -371,11 +376,7 @@ class ExecutionEngine:
)
)
def _remove_request(_: Any) -> None:
assert self._slot
self._slot.remove_request(request)
d2: Deferred[None] = d.addBoth(_remove_request)
d2: Deferred[None] = d.addBoth(partial(self._remove_request, request=request))
d2.addErrback(
lambda f: logger.info(
"Error while removing request from slot",
@ -612,30 +613,33 @@ class ExecutionEngine:
"Closing spider (%(reason)s)", {"reason": reason}, extra={"spider": spider}
)
def log_failure(msg: str) -> None:
logger.error(msg, exc_info=True, extra={"spider": spider}) # noqa: LOG014
try:
await self._slot.close()
except Exception:
log_failure("Slot close failure")
logger.error("Slot close failure", exc_info=True, extra={"spider": spider})
try:
self.downloader.close()
except Exception:
log_failure("Downloader close failure")
logger.error(
"Downloader close failure", exc_info=True, extra={"spider": spider}
)
try:
await self.scraper.close_spider_async()
except Exception:
log_failure("Scraper close failure")
logger.error(
"Scraper close failure", exc_info=True, extra={"spider": spider}
)
if hasattr(self._slot.scheduler, "close"):
try:
if (d := self._slot.scheduler.close(reason)) is not None:
await maybe_deferred_to_future(d)
except Exception:
log_failure("Scheduler close failure")
logger.error(
"Scheduler close failure", exc_info=True, extra={"spider": spider}
)
try:
await self.signals.send_catch_log_async(
@ -644,7 +648,11 @@ class ExecutionEngine:
reason=reason,
)
except Exception:
log_failure("Error while sending spider_close signal")
logger.error(
"Error while sending spider_close signal",
exc_info=True,
extra={"spider": spider},
)
assert self.crawler.stats
try:
@ -661,7 +669,7 @@ class ExecutionEngine:
else:
self.crawler.stats.close_spider(reason=reason)
except Exception:
log_failure("Stats close failure")
logger.error("Stats close failure")
logger.info(
"Spider closed (%(reason)s)",
@ -675,4 +683,4 @@ class ExecutionEngine:
try:
await ensure_awaitable(self._spider_closed_callback(spider))
except Exception:
log_failure("Error running spider_closed_callback")
logger.error("Error running spider_closed_callback")

View File

@ -143,7 +143,7 @@ class H2Agent:
)
def get_endpoint(self, uri: URI) -> HostnameEndpoint:
return self.endpoint_factory.endpointForURI(uri)
return self.endpoint_factory.endpointForURI(uri) # type: ignore[no-any-return]
def get_key(self, uri: URI) -> ConnectionKeyT:
"""
@ -165,30 +165,3 @@ class H2Agent:
lambda conn: conn.request(request, spider)
)
return d2
class ScrapyProxyH2Agent(H2Agent):
def __init__(
self,
reactor: ReactorBase,
proxy_uri: URI,
pool: H2ConnectionPool,
context_factory: BrowserLikePolicyForHTTPS = BrowserLikePolicyForHTTPS(), # noqa: B008
connect_timeout: float | None = None,
bind_address: tuple[str, int] | None = None,
) -> None:
super().__init__(
reactor=reactor,
pool=pool,
context_factory=context_factory,
connect_timeout=connect_timeout,
bind_address=bind_address,
)
self._proxy_uri = proxy_uri
def get_endpoint(self, uri: URI) -> HostnameEndpoint:
return self.endpoint_factory.endpointForURI(self._proxy_uri)
def get_key(self, uri: URI) -> ConnectionKeyT:
"""We use the proxy uri instead of uri obtained from request url"""
return b"http-proxy", self._proxy_uri.host, self._proxy_uri.port

View File

@ -155,16 +155,16 @@ class Stream:
"status": None,
}
def _cancel(_: Any) -> None:
# Close this stream as gracefully as possible
# If the associated request is initiated we reset this stream
# else we directly call close() method
if self.metadata["request_sent"]:
self.reset_stream(StreamCloseReason.CANCELLED)
else:
self.close(StreamCloseReason.CANCELLED)
self._deferred_response: Deferred[Response] = Deferred(self._cancel)
self._deferred_response: Deferred[Response] = Deferred(_cancel)
def _cancel(self, _: Any) -> None:
# Close this stream as gracefully as possible
# If the associated request is initiated we reset this stream
# else we directly call close() method
if self.metadata["request_sent"]:
self.reset_stream(StreamCloseReason.CANCELLED)
else:
self.close(StreamCloseReason.CANCELLED)
def __repr__(self) -> str:
return f"Stream(id={self.stream_id!r})"

View File

@ -4,17 +4,14 @@ import json
import logging
from abc import abstractmethod
from pathlib import Path
from typing import TYPE_CHECKING, Any
from warnings import warn
from typing import TYPE_CHECKING, Any, cast
# working around https://github.com/sphinx-doc/sphinx/issues/10400
from twisted.internet.defer import Deferred # noqa: TC002
from scrapy.exceptions import ScrapyDeprecationWarning
from scrapy.spiders import Spider # noqa: TC001
from scrapy.utils.job import job_dir
from scrapy.utils.misc import build_from_crawler, load_object
from scrapy.utils.python import global_object_name
if TYPE_CHECKING:
# requires queuelib >= 1.6.2
@ -337,7 +334,7 @@ class Scheduler(BaseScheduler):
cls = crawler.settings[f"SCHEDULER_START_{queue}_QUEUE"]
if not cls:
return None
return load_object(cls)
return cast("type[BaseQueue]", load_object(cls))
def has_pending_requests(self) -> bool:
return len(self) > 0
@ -450,28 +447,13 @@ class Scheduler(BaseScheduler):
"""Create a new priority queue instance, with in-memory storage"""
assert self.crawler
assert self.pqclass
try:
return build_from_crawler(
self.pqclass,
self.crawler,
downstream_queue_cls=self.mqclass,
key="",
start_queue_cls=self._smqclass,
)
except TypeError: # pragma: no cover
warn(
f"The __init__ method of {global_object_name(self.pqclass)} "
"does not support a `start_queue_cls` keyword-only "
"parameter.",
ScrapyDeprecationWarning,
stacklevel=2,
)
return build_from_crawler(
self.pqclass,
self.crawler,
downstream_queue_cls=self.mqclass,
key="",
)
return build_from_crawler(
self.pqclass,
self.crawler,
downstream_queue_cls=self.mqclass,
key="",
start_queue_cls=self._smqclass,
)
def _dq(self) -> ScrapyPriorityQueue:
"""Create a new priority queue instance, with disk storage"""
@ -479,30 +461,14 @@ class Scheduler(BaseScheduler):
assert self.dqdir
assert self.pqclass
state = self._read_dqs_state(self.dqdir)
try:
q = build_from_crawler(
self.pqclass,
self.crawler,
downstream_queue_cls=self.dqclass,
key=self.dqdir,
startprios=state,
start_queue_cls=self._sdqclass,
)
except TypeError: # pragma: no cover
warn(
f"The __init__ method of {global_object_name(self.pqclass)} "
"does not support a `start_queue_cls` keyword-only "
"parameter.",
ScrapyDeprecationWarning,
stacklevel=2,
)
q = build_from_crawler(
self.pqclass,
self.crawler,
downstream_queue_cls=self.dqclass,
key=self.dqdir,
startprios=state,
)
q = build_from_crawler(
self.pqclass,
self.crawler,
downstream_queue_cls=self.dqclass,
key=self.dqdir,
startprios=state,
start_queue_cls=self._sdqclass,
)
if q:
logger.info(
"Resuming crawl (%(queuesize)d requests scheduled)",

View File

@ -23,7 +23,6 @@ from scrapy.exceptions import (
from scrapy.http import Request, Response
from scrapy.pipelines import ItemPipelineManager
from scrapy.utils.asyncio import _parallel_asyncio, is_asyncio_available
from scrapy.utils.decorators import _warn_spider_arg
from scrapy.utils.defer import (
_defer_sleep_async,
_schedule_coro,
@ -178,9 +177,7 @@ class Scraper:
self.itemproc.open_spider(self.crawler.spider)
)
def close_spider(
self, spider: Spider | None = None
) -> Deferred[None]: # pragma: no cover
def close_spider(self) -> Deferred[None]: # pragma: no cover
warnings.warn(
"Scraper.close_spider() is deprecated, use close_spider_async() instead",
ScrapyDeprecationWarning,
@ -217,9 +214,8 @@ class Scraper:
self.slot.closing.callback(self.crawler.spider)
@inlineCallbacks
@_warn_spider_arg
def enqueue_scrape(
self, result: Response | Failure, request: Request, spider: Spider | None = None
self, result: Response | Failure, request: Request
) -> Generator[Deferred[Any], Any, None]:
if self.slot is None:
raise RuntimeError("Scraper slot not assigned")
@ -349,13 +345,11 @@ class Scraper:
)
return await ensure_awaitable(iterate_spider_output(output))
@_warn_spider_arg
def handle_spider_error(
self,
_failure: Failure,
request: Request,
response: Response | Failure,
spider: Spider | None = None,
) -> None:
"""Handle an exception raised by a spider callback or errback."""
assert self.crawler.spider
@ -391,7 +385,6 @@ class Scraper:
result: Iterable[_T] | AsyncIterator[_T],
request: Request,
response: Response | Failure,
spider: Spider | None = None,
) -> Deferred[None]: # pragma: no cover
"""Pass items/requests produced by a callback to ``_process_spidermw_output()`` in parallel."""
warnings.warn(

View File

@ -9,31 +9,29 @@ from __future__ import annotations
import logging
from collections.abc import AsyncIterator, Callable, Coroutine, Iterable
from functools import wraps
from inspect import isasyncgenfunction, iscoroutine
from inspect import isasyncgenfunction
from itertools import islice
from typing import TYPE_CHECKING, Any, TypeAlias, TypeVar, cast
from typing import TYPE_CHECKING, Any, TypeAlias, TypeVar
from warnings import warn
from twisted.internet.defer import Deferred, inlineCallbacks
from twisted.python.failure import Failure
from scrapy import Request, Spider
from scrapy.exceptions import ScrapyDeprecationWarning, _InvalidOutput
from scrapy.http import Response
from scrapy.middleware import MiddlewareManager
from scrapy.utils.asyncgen import as_async_generator, collect_asyncgen
from scrapy.utils.asyncgen import as_async_generator
from scrapy.utils.conf import build_component_list
from scrapy.utils.defer import (
_defer_sleep_async,
deferred_from_coro,
maybe_deferred_to_future,
)
from scrapy.utils.python import MutableAsyncChain, MutableChain, global_object_name
from scrapy.utils.python import MutableAsyncChain, global_object_name
if TYPE_CHECKING:
from collections.abc import Generator
from twisted.internet.defer import Deferred
from scrapy.crawler import Crawler
from scrapy.settings import BaseSettings
@ -47,10 +45,6 @@ ScrapeFunc: TypeAlias = Callable[
]
def _isiterable(o: Any) -> bool:
return isinstance(o, (Iterable, AsyncIterator))
class SpiderMiddlewareManager(MiddlewareManager):
component_name = "spider middleware"
@ -60,85 +54,18 @@ class SpiderMiddlewareManager(MiddlewareManager):
settings.get_component_priority_dict_with_base("SPIDER_MIDDLEWARES")
)
def __init__(self, *middlewares: Any, crawler: Crawler | None = None) -> None:
self._check_deprecated_process_start_requests_use(middlewares)
super().__init__(*middlewares, crawler=crawler)
def _check_deprecated_process_start_requests_use(
self, middlewares: tuple[Any, ...]
) -> None:
deprecated_middlewares = [
middleware
for middleware in middlewares
if hasattr(middleware, "process_start_requests")
and not hasattr(middleware, "process_start")
]
modern_middlewares = [
middleware
for middleware in middlewares
if not hasattr(middleware, "process_start_requests")
and hasattr(middleware, "process_start")
]
if deprecated_middlewares and modern_middlewares:
raise ValueError(
"You are trying to combine spider middlewares that only "
"define the deprecated process_start_requests() method () "
"with spider middlewares that only define the "
"process_start() method (). This is not possible. You must "
"either disable or make universal 1 of those 2 sets of "
"spider middlewares. Making a spider middleware universal "
"means having it define both methods. See the release notes "
"of Scrapy 2.13 for details: "
"https://docs.scrapy.org/en/2.13/news.html"
)
self._use_start_requests = bool(deprecated_middlewares)
if self._use_start_requests:
deprecated_middleware_list = ", ".join(
global_object_name(middleware.__class__)
for middleware in deprecated_middlewares
)
warn(
f"The following enabled spider middlewares, directly or "
f"through their parent classes, define the deprecated "
f"process_start_requests() method: "
f"{deprecated_middleware_list}. process_start_requests() has "
f"been deprecated in favor of a new method, process_start(), "
f"to support asynchronous code execution. "
f"process_start_requests() will stop being called in a future "
f"version of Scrapy. If you use Scrapy 2.13 or higher "
f"only, replace process_start_requests() with "
f"process_start(); note that process_start() is a coroutine "
f"(async def). If you need to maintain compatibility with "
f"lower Scrapy versions, when defining "
f"process_start_requests() in a spider middleware class, "
f"define process_start() as well. See the release notes of "
f"Scrapy 2.13 for details: "
f"https://docs.scrapy.org/en/2.13/news.html",
ScrapyDeprecationWarning,
stacklevel=2,
)
def _add_middleware(self, mw: Any) -> None:
if hasattr(mw, "process_spider_input"):
self.methods["process_spider_input"].append(mw.process_spider_input)
self._check_mw_method_spider_arg(mw.process_spider_input)
if self._use_start_requests:
if hasattr(mw, "process_start_requests"):
self.methods["process_start_requests"].appendleft(
mw.process_start_requests
)
elif hasattr(mw, "process_start"):
if hasattr(mw, "process_start"):
self.methods["process_start"].appendleft(mw.process_start)
process_spider_output = self._get_async_method_pair(mw, "process_spider_output")
process_spider_output = self._get_process_spider_output(mw)
self.methods["process_spider_output"].appendleft(process_spider_output)
if callable(process_spider_output):
if process_spider_output is not None:
self._check_mw_method_spider_arg(process_spider_output)
elif isinstance(process_spider_output, tuple):
for m in process_spider_output:
self._check_mw_method_spider_arg(m)
process_spider_exception = getattr(mw, "process_spider_exception", None)
self.methods["process_spider_exception"].appendleft(process_spider_exception)
@ -152,7 +79,7 @@ class SpiderMiddlewareManager(MiddlewareManager):
request: Request,
) -> Iterable[_T] | AsyncIterator[_T]:
for method in self.methods["process_spider_input"]:
method = cast("Callable", method)
assert method is not None
try:
if method in self._mw_methods_requiring_spider:
result = method(response=response, spider=self._spider)
@ -170,54 +97,28 @@ class SpiderMiddlewareManager(MiddlewareManager):
return await scrape_func(Failure(), request)
return await scrape_func(response, request)
def _evaluate_iterable(
async def _evaluate_iterable(
self,
response: Response,
iterable: Iterable[_T] | AsyncIterator[_T],
iterable: AsyncIterator[_T],
exception_processor_index: int,
recover_to: MutableChain[_T] | MutableAsyncChain[_T],
) -> Iterable[_T] | AsyncIterator[_T]:
def process_sync(iterable: Iterable[_T]) -> Iterable[_T]:
try:
yield from iterable
except Exception as ex:
exception_result = cast(
"Failure | MutableChain[_T]",
self._process_spider_exception(
response, ex, exception_processor_index
),
)
if isinstance(exception_result, Failure):
raise
assert isinstance(recover_to, MutableChain)
recover_to.extend(exception_result)
async def process_async(iterable: AsyncIterator[_T]) -> AsyncIterator[_T]:
try:
async for r in iterable:
yield r
except Exception as ex:
exception_result = cast(
"Failure | MutableAsyncChain[_T]",
self._process_spider_exception(
response, ex, exception_processor_index
),
)
if isinstance(exception_result, Failure):
raise
assert isinstance(recover_to, MutableAsyncChain)
recover_to.extend(exception_result)
if isinstance(iterable, AsyncIterator):
return process_async(iterable)
return process_sync(iterable)
recover_to: MutableAsyncChain[_T],
) -> AsyncIterator[_T]:
try:
async for r in iterable:
yield r
except Exception as ex:
exception_result: MutableAsyncChain[_T] = self._process_spider_exception(
response, ex, exception_processor_index
)
recover_to.extend(exception_result)
def _process_spider_exception(
self,
response: Response,
exception: Exception,
start_index: int = 0,
) -> MutableChain[_T] | MutableAsyncChain[_T]:
) -> MutableAsyncChain[_T]:
# don't handle _InvalidOutput exception
if isinstance(exception, _InvalidOutput):
raise exception
@ -227,28 +128,18 @@ class SpiderMiddlewareManager(MiddlewareManager):
for method_index, method in enumerate(method_list, start=start_index):
if method is None:
continue
method = cast("Callable", method)
if method in self._mw_methods_requiring_spider:
result = method(
response=response, exception=exception, spider=self._spider
)
else:
result = method(response=response, exception=exception)
if _isiterable(result):
if isinstance(result, (Iterable, AsyncIterator)):
# stop exception handling by handing control over to the
# process_spider_output chain if an iterable has been returned
dfd: Deferred[MutableChain[_T] | MutableAsyncChain[_T]] = (
self._process_spider_output(response, result, method_index + 1)
)
# _process_spider_output() returns a Deferred only because of downgrading so this can be
# simplified when downgrading is removed.
if dfd.called:
# the result is available immediately if _process_spider_output didn't do downgrading
return cast("MutableChain[_T] | MutableAsyncChain[_T]", dfd.result)
# we forbid waiting here because otherwise we would need to return a deferred from
# _process_spider_exception too, which complicates the architecture
msg = f"Async iterable returned from {global_object_name(method)} cannot be downgraded"
raise _InvalidOutput(msg)
if isinstance(result, Iterable):
result = as_async_generator(result)
return self._process_spider_output(response, result, method_index + 1)
if result is None:
continue
msg = (
@ -258,124 +149,35 @@ class SpiderMiddlewareManager(MiddlewareManager):
raise _InvalidOutput(msg)
raise exception
# This method cannot be made async def, as _process_spider_exception relies on the Deferred result
# being available immediately which doesn't work when it's a wrapped coroutine.
# It also needs @inlineCallbacks only because of downgrading so it can be removed when downgrading is removed.
@inlineCallbacks
def _process_spider_output( # noqa: PLR0912
def _process_spider_output(
self,
response: Response,
result: Iterable[_T] | AsyncIterator[_T],
result: AsyncIterator[_T],
start_index: int = 0,
) -> Generator[Deferred[Any], Any, MutableChain[_T] | MutableAsyncChain[_T]]:
) -> MutableAsyncChain[_T]:
# items in this iterable do not need to go through the process_spider_output
# chain, they went through it already from the process_spider_exception method
recovered: MutableChain[_T] | MutableAsyncChain[_T]
last_result_is_async = isinstance(result, AsyncIterator)
recovered = MutableAsyncChain() if last_result_is_async else MutableChain()
# There are three cases for the middleware: def foo, async def foo, def foo + async def foo_async.
# 1. def foo. Sync iterables are passed as is, async ones are downgraded.
# 2. async def foo. Sync iterables are upgraded, async ones are passed as is.
# 3. def foo + async def foo_async. Iterables are passed to the respective method.
# Storing methods and method tuples in the same list is weird but we should be able to roll this back
# when we drop this compatibility feature.
recovered: MutableAsyncChain[_T] = MutableAsyncChain()
method_list = islice(self.methods["process_spider_output"], start_index, None)
for method_index, method_pair in enumerate(method_list, start=start_index):
if method_pair is None:
for method_index, method in enumerate(method_list, start=start_index):
if method is None:
continue
need_upgrade = need_downgrade = False
if isinstance(method_pair, tuple):
# This tuple handling is only needed until _async compatibility methods are removed.
method_sync, method_async = method_pair
method = method_async if last_result_is_async else method_sync
if method in self._mw_methods_requiring_spider:
result = method(response=response, result=result, spider=self._spider)
else:
method = method_pair
if not last_result_is_async and isasyncgenfunction(method):
need_upgrade = True
elif last_result_is_async and not isasyncgenfunction(method):
need_downgrade = True
try:
if need_upgrade:
# Iterable -> AsyncIterator
result = as_async_generator(result)
elif need_downgrade:
logger.warning(
f"Async iterable passed to {global_object_name(method)} was"
f" downgraded to a non-async one. This is deprecated and will"
f" stop working in a future version of Scrapy. Please see"
f" https://docs.scrapy.org/en/latest/topics/coroutines.html#for-middleware-users"
f" for more information."
)
assert isinstance(result, AsyncIterator)
# AsyncIterator -> Iterable
result = yield deferred_from_coro(collect_asyncgen(result))
if isinstance(recovered, AsyncIterator):
recovered_collected = yield deferred_from_coro(
collect_asyncgen(recovered)
)
recovered = MutableChain(recovered_collected)
# might fail directly if the output value is not a generator
if method in self._mw_methods_requiring_spider:
result = method(
response=response, result=result, spider=self._spider
)
else:
result = method(response=response, result=result)
except Exception as ex:
exception_result: Failure | MutableChain[_T] | MutableAsyncChain[_T] = (
self._process_spider_exception(response, ex, method_index + 1)
)
if isinstance(exception_result, Failure):
raise
return exception_result
if _isiterable(result):
result = self._evaluate_iterable(
response, result, method_index + 1, recovered
)
else:
if iscoroutine(result):
result.close() # Silence warning about not awaiting
msg = (
f"{global_object_name(method)} must be an asynchronous "
f"generator (i.e. use yield)"
)
else:
msg = (
f"{global_object_name(method)} must return an iterable, got "
f"{type(result)}"
)
raise _InvalidOutput(msg)
last_result_is_async = isinstance(result, AsyncIterator)
if last_result_is_async:
return MutableAsyncChain(result, recovered)
return MutableChain(result, recovered) # type: ignore[arg-type]
result = method(response=response, result=result)
result = self._evaluate_iterable(
response, result, method_index + 1, recovered
)
return MutableAsyncChain(result, recovered)
async def _process_callback_output(
self,
response: Response,
result: Iterable[_T] | AsyncIterator[_T],
) -> MutableChain[_T] | MutableAsyncChain[_T]:
recovered: MutableChain[_T] | MutableAsyncChain[_T]
if isinstance(result, AsyncIterator):
recovered = MutableAsyncChain()
else:
recovered = MutableChain()
self, response: Response, result: AsyncIterator[_T]
) -> MutableAsyncChain[_T]:
recovered: MutableAsyncChain[_T] = MutableAsyncChain()
result = self._evaluate_iterable(response, result, 0, recovered)
result = await maybe_deferred_to_future(
cast(
"Deferred[Iterable[_T] | AsyncIterator[_T]]",
self._process_spider_output(response, result),
)
)
if isinstance(result, AsyncIterator):
return MutableAsyncChain(result, recovered)
if isinstance(recovered, AsyncIterator):
recovered_collected = await collect_asyncgen(recovered)
recovered = MutableChain(recovered_collected)
return MutableChain(result, recovered)
result = self._process_spider_output(response, result)
return MutableAsyncChain(result, recovered)
def scrape_response(
self,
@ -386,7 +188,7 @@ class SpiderMiddlewareManager(MiddlewareManager):
response: Response,
request: Request,
spider: Spider,
) -> Deferred[MutableChain[_T] | MutableAsyncChain[_T]]: # pragma: no cover
) -> Deferred[MutableAsyncChain[_T]]: # pragma: no cover
warn(
"SpiderMiddlewareManager.scrape_response() is deprecated, use scrape_response_async() instead",
ScrapyDeprecationWarning,
@ -409,31 +211,21 @@ class SpiderMiddlewareManager(MiddlewareManager):
scrape_func: ScrapeFunc[_T],
response: Response,
request: Request,
) -> MutableChain[_T] | MutableAsyncChain[_T]:
) -> MutableAsyncChain[_T]:
if not self.crawler:
raise RuntimeError(
"scrape_response_async() called on a SpiderMiddlewareManager"
" instance created without a crawler."
)
async def process_callback_output(
result: Iterable[_T] | AsyncIterator[_T],
) -> MutableChain[_T] | MutableAsyncChain[_T]:
return await self._process_callback_output(response, result)
def process_spider_exception(
exception: Exception,
) -> MutableChain[_T] | MutableAsyncChain[_T]:
return self._process_spider_exception(response, exception)
try:
it: Iterable[_T] | AsyncIterator[_T] = await self._process_spider_input(
scrape_func, response, request
)
return await process_callback_output(it)
ait = it if isinstance(it, AsyncIterator) else as_async_generator(it)
return await self._process_callback_output(response, ait)
except Exception as ex:
await _defer_sleep_async()
return process_spider_exception(ex)
return self._process_spider_exception(response, ex)
async def process_start(
self, spider: Spider | None = None
@ -451,112 +243,32 @@ class SpiderMiddlewareManager(MiddlewareManager):
)
warn(msg, category=ScrapyDeprecationWarning, stacklevel=2)
self._set_compat_spider(spider)
self._check_deprecated_start_requests_use()
if self._use_start_requests:
sync_start = iter(self._spider.start_requests())
sync_start = await self._process_chain(
"process_start_requests", sync_start, always_add_spider=True
)
start: AsyncIterator[Any] = as_async_generator(sync_start)
else:
start = self._spider.start()
start = await self._process_chain("process_start", start)
return start
def _check_deprecated_start_requests_use(self) -> None:
start_requests_cls = None
start_cls = None
spidercls = self._spider.__class__
mro = spidercls.__mro__
for cls in mro:
cls_dict = cls.__dict__
if start_requests_cls is None and "start_requests" in cls_dict:
start_requests_cls = cls
if start_cls is None and "start" in cls_dict:
start_cls = cls
if start_requests_cls is not None and start_cls is not None:
break
# Spider defines both, start_requests and start.
assert start_requests_cls is not None
assert start_cls is not None
if (
start_requests_cls is not Spider
and start_cls is not start_requests_cls
and mro.index(start_requests_cls) < mro.index(start_cls)
):
src = global_object_name(start_requests_cls)
if start_requests_cls is not spidercls:
src += f" (inherited by {global_object_name(spidercls)})"
warn(
f"{src} defines the deprecated start_requests() method. "
f"start_requests() has been deprecated in favor of a new "
f"method, start(), to support asynchronous code "
f"execution. start_requests() will stop being called in a "
f"future version of Scrapy. If you use Scrapy 2.13 or "
f"higher only, replace start_requests() with start(); "
f"note that start() is a coroutine (async def). If you "
f"need to maintain compatibility with lower Scrapy versions, "
f"when overriding start_requests() in a spider class, "
f"override start() as well; you can use super() to "
f"reuse the inherited start() implementation without "
f"copy-pasting. See the release notes of Scrapy 2.13 for "
f"details: https://docs.scrapy.org/en/2.13/news.html",
ScrapyDeprecationWarning,
stacklevel=2,
)
if (
self._use_start_requests
and start_cls is not Spider
and start_requests_cls is not start_cls
and mro.index(start_cls) < mro.index(start_requests_cls)
):
src = global_object_name(start_cls)
if start_cls is not spidercls:
src += f" (inherited by {global_object_name(spidercls)})"
raise ValueError(
f"{src} does not define the deprecated start_requests() "
f"method. However, one or more of your enabled spider "
f"middlewares (reported in an earlier deprecation warning) "
f"define the process_start_requests() method, and not the "
f"process_start() method, making them only compatible with "
f"(deprecated) spiders that define the start_requests() "
f"method. To solve this issue, disable the offending spider "
f"middlewares, upgrade them as described in that earlier "
f"deprecation warning, or make your spider compatible with "
f"deprecated spider middlewares (and earlier Scrapy versions) "
f"by defining a sync start_requests() method that works "
f"similarly to its existing start() method. See the "
f"release notes of Scrapy 2.13 for details: "
f"https://docs.scrapy.org/en/2.13/news.html"
)
start = self._spider.start()
return await self._process_chain("process_start", start)
# This method is only needed until _async compatibility methods are removed.
@staticmethod
def _get_async_method_pair(
mw: Any, methodname: str
) -> Callable | tuple[Callable, Callable] | None:
normal_method: Callable | None = getattr(mw, methodname, None)
methodname_async = methodname + "_async"
async_method: Callable | None = getattr(mw, methodname_async, None)
def _get_process_spider_output(mw: Any) -> Callable[..., Any] | None:
normal_method: Callable[..., Any] | None = getattr(
mw, "process_spider_output", None
)
async_method: Callable[..., Any] | None = getattr(
mw, "process_spider_output_async", None
)
if not async_method:
if normal_method and not isasyncgenfunction(normal_method):
logger.warning(
raise TypeError(
f"Middleware {global_object_name(mw.__class__)} doesn't support"
f" asynchronous spider output, this is deprecated and will stop"
f" working in a future version of Scrapy. The middleware should"
f" be updated to support it. Please see"
f" https://docs.scrapy.org/en/latest/topics/coroutines.html#for-middleware-users"
f" for more information."
f" asynchronous spider output. Its process_spider_output() method"
f" should be an async generator function or it should additionally"
f" define a process_spider_output_async() method."
)
return normal_method
if not normal_method:
logger.error(
f"Middleware {global_object_name(mw.__class__)} has {methodname_async} "
f"without {methodname}, skipping this method."
f"Middleware {global_object_name(mw.__class__)} has"
f" process_spider_output_async() without process_spider_output(),"
f" skipping this method. Please rename it to process_spider_output()."
)
return None
if not isasyncgenfunction(async_method):
@ -568,8 +280,8 @@ class SpiderMiddlewareManager(MiddlewareManager):
if isasyncgenfunction(normal_method):
logger.error(
f"{global_object_name(normal_method)} is an async "
f"generator function while {methodname_async} exists, "
f"skipping both methods."
f"generator function while process_spider_output_async() exists, "
f"skipping both methods. Please remove process_spider_output_async()."
)
return None
return normal_method, async_method
return async_method

View File

@ -7,6 +7,7 @@ import pprint
import signal
import warnings
from abc import ABC, abstractmethod
from functools import partial
from typing import TYPE_CHECKING, Any, TypeVar
from twisted.internet.defer import Deferred, DeferredList, inlineCallbacks
@ -568,28 +569,30 @@ class AsyncCrawlerRunner(CrawlerRunnerBase):
crawler = self.create_crawler(crawler_or_spidercls)
return self._crawl(crawler, *args, **kwargs)
async def _crawl_and_track(
self, crawler: Crawler, *args: Any, **kwargs: Any
) -> None:
try:
await crawler.crawl_async(*args, **kwargs)
except Exception:
self.bootstrap_failed = True
raise # re-raise so asyncio still logs it to stderr naturally
def _done(self, task: asyncio.Task[None], crawler: Crawler) -> None:
self._active.discard(task)
self.crawlers.discard(crawler)
self.bootstrap_failed |= not getattr(crawler, "spider", None)
def _crawl(self, crawler: Crawler, *args: Any, **kwargs: Any) -> asyncio.Task[None]:
# At this point the asyncio loop has been installed either by the user
# or by AsyncCrawlerProcess (but it isn't running yet, so no asyncio.create_task()).
loop = asyncio.get_event_loop()
self.crawlers.add(crawler)
async def _crawl_and_track() -> None:
try:
await crawler.crawl_async(*args, **kwargs)
except Exception:
self.bootstrap_failed = True
raise # re-raise so asyncio still logs it to stderr naturally
task = loop.create_task(_crawl_and_track())
task = loop.create_task(self._crawl_and_track(crawler, *args, **kwargs))
self._active.add(task)
task.add_done_callback(partial(self._done, crawler=crawler))
def _done(_: asyncio.Task[None]) -> None:
self.crawlers.discard(crawler)
self._active.discard(task)
self.bootstrap_failed |= not getattr(crawler, "spider", None)
task.add_done_callback(_done)
return task
async def stop(self) -> None:
@ -1007,14 +1010,15 @@ class AsyncCrawlerProcess(CrawlerProcessBase, AsyncCrawlerRunner):
if (loop := self._reactorless_loop) is None:
return
def _create_shutdown_task() -> None:
coro = self._shutdown_graceful_reactorless()
try:
loop.create_task(coro)
except RuntimeError:
coro.close()
loop.call_soon_threadsafe(self._create_shutdown_task)
loop.call_soon_threadsafe(_create_shutdown_task)
def _create_shutdown_task(self) -> None:
assert self._reactorless_loop
coro = self._shutdown_graceful_reactorless()
try:
self._reactorless_loop.create_task(coro)
except RuntimeError:
coro.close()
async def _shutdown_graceful_reactorless(self) -> None:
await self.stop()

View File

@ -1,114 +0,0 @@
from __future__ import annotations
import logging
import re
from typing import TYPE_CHECKING
from warnings import warn
from w3lib import html
from scrapy.exceptions import NotConfigured, ScrapyDeprecationWarning
from scrapy.http import HtmlResponse, Response
from scrapy.utils.url import escape_ajax
if TYPE_CHECKING:
# typing.Self requires Python 3.11
from typing_extensions import Self
from scrapy import Request, Spider
from scrapy.crawler import Crawler
from scrapy.settings import BaseSettings
logger = logging.getLogger(__name__)
class AjaxCrawlMiddleware:
"""
Handle 'AJAX crawlable' pages marked as crawlable via meta tag.
"""
def __init__(self, settings: BaseSettings):
if not settings.getbool("AJAXCRAWL_ENABLED"):
raise NotConfigured
warn(
"scrapy.downloadermiddlewares.ajaxcrawl.AjaxCrawlMiddleware is deprecated"
" and will be removed in a future Scrapy version.",
ScrapyDeprecationWarning,
stacklevel=2,
)
# XXX: Google parses at least first 100k bytes; scrapy's redirect
# middleware parses first 4k. 4k turns out to be insufficient
# for this middleware, and parsing 100k could be slow.
# We use something in between (32K) by default.
self.lookup_bytes: int = settings.getint("AJAXCRAWL_MAXSIZE")
@classmethod
def from_crawler(cls, crawler: Crawler) -> Self:
return cls(crawler.settings)
def process_response(
self, request: Request, response: Response, spider: Spider
) -> Request | Response:
if not isinstance(response, HtmlResponse) or response.status != 200:
return response
if request.method != "GET":
# other HTTP methods are either not safe or don't have a body
return response
if "ajax_crawlable" in request.meta: # prevent loops
return response
if not self._has_ajax_crawlable_variant(response):
return response
ajax_crawl_request = request.replace(url=escape_ajax(request.url + "#!"))
logger.debug(
"Downloading AJAX crawlable %(ajax_crawl_request)s instead of %(request)s",
{"ajax_crawl_request": ajax_crawl_request, "request": request},
extra={"spider": spider},
)
ajax_crawl_request.meta["ajax_crawlable"] = True
return ajax_crawl_request
def _has_ajax_crawlable_variant(self, response: Response) -> bool:
"""
Return True if a page without hash fragment could be "AJAX crawlable".
"""
body = response.text[: self.lookup_bytes]
return _has_ajaxcrawlable_meta(body)
_ajax_crawlable_re: re.Pattern[str] = re.compile(
r'<meta\s+name=["\']fragment["\']\s+content=["\']!["\']/?>'
)
def _has_ajaxcrawlable_meta(text: str) -> bool:
"""
>>> _has_ajaxcrawlable_meta('<html><head><meta name="fragment" content="!"/></head><body></body></html>')
True
>>> _has_ajaxcrawlable_meta("<html><head><meta name='fragment' content='!'></head></html>")
True
>>> _has_ajaxcrawlable_meta('<html><head><!--<meta name="fragment" content="!"/>--></head><body></body></html>')
False
>>> _has_ajaxcrawlable_meta('<html></html>')
False
"""
# Stripping scripts and comments is slow (about 20x slower than
# just checking if a string is in text); this is a quick fail-fast
# path that should work for most pages.
if "fragment" not in text:
return False
if "content" not in text:
return False
text = html.remove_tags_with_content(text, ("script", "noscript"))
text = html.replace_entities(text)
text = html.remove_comments(text)
return _ajax_crawlable_re.search(text) is not None

View File

@ -306,12 +306,15 @@ class GCSFeedStorage(BlockingFeedStorage):
def _store_in_thread(self, file: IO[bytes]) -> None:
file.seek(0)
from google.cloud.storage import Client # noqa: PLC0415
try:
from google.cloud.storage import Client # noqa: PLC0415
client = Client(project=self.project_id)
bucket = client.get_bucket(self.bucket_name)
blob = bucket.blob(self.blob_name)
blob.upload_from_file(file, predefined_acl=self.acl)
client = Client(project=self.project_id)
bucket = client.get_bucket(self.bucket_name)
blob = bucket.blob(self.blob_name)
blob.upload_from_file(file, predefined_acl=self.acl)
finally:
file.close()
class FTPFeedStorage(BlockingFeedStorage):
@ -534,13 +537,15 @@ class FeedExporter:
# Send FEED_EXPORTER_CLOSED signal
await self.crawler.signals.send_catch_log_async(signals.feed_exporter_closed)
@staticmethod
def _get_file(slot_: FeedSlot) -> IO[bytes]:
assert slot_.file
if isinstance(slot_.file, PostProcessingManager):
slot_.file.close()
return slot_.file.file
return slot_.file
async def _close_slot(self, slot: FeedSlot, spider: Spider) -> None:
def get_file(slot_: FeedSlot) -> IO[bytes]:
assert slot_.file
if isinstance(slot_.file, PostProcessingManager):
slot_.file.close()
return slot_.file.file
return slot_.file
if slot.itemcount:
# Normal case
@ -557,7 +562,7 @@ class FeedExporter:
slot_type = type(slot.storage).__name__
assert self.crawler.stats
try:
await ensure_awaitable(slot.storage.store(get_file(slot)))
await ensure_awaitable(slot.storage.store(self._get_file(slot)))
except Exception:
logger.error(
"Error storing %s",

View File

@ -313,7 +313,9 @@ class FilesystemCacheStorage:
self.expiration_secs: int = settings.getint("HTTPCACHE_EXPIRATION_SECS")
self.use_gzip: bool = settings.getbool("HTTPCACHE_GZIP")
# https://github.com/python/mypy/issues/10740
self._open: Callable[Concatenate[str | os.PathLike, str, ...], IO[bytes]] = (
self._open: Callable[
Concatenate[str | os.PathLike[str], str, ...], IO[bytes]
] = (
gzip.open if self.use_gzip else open # type: ignore[assignment]
)

View File

@ -5,9 +5,11 @@ Use this module (instead of the more specific ones) when importing Headers,
Request and Response outside this module.
"""
from warnings import catch_warnings, filterwarnings
from scrapy.exceptions import ScrapyDeprecationWarning
from scrapy.http.headers import Headers
from scrapy.http.request import Request
from scrapy.http.request.form import FormRequest
from scrapy.http.request.json_request import JsonRequest
from scrapy.http.request.rpc import XmlRpcRequest
from scrapy.http.response import Response
@ -15,6 +17,19 @@ from scrapy.http.response.html import HtmlResponse
from scrapy.http.response.json import JsonResponse
from scrapy.http.response.text import TextResponse
from scrapy.http.response.xml import XmlResponse
from scrapy.utils.deprecate import create_deprecated_class
with catch_warnings():
filterwarnings("ignore", category=ScrapyDeprecationWarning)
from scrapy.http.request.form import FormRequest as _FormRequest
FormRequest = create_deprecated_class(
name="FormRequest",
new_class=_FormRequest,
subclass_warn_message="{cls} inherits from deprecated class {old}, use the form2request library instead.",
instance_warn_message="{cls} is deprecated, use the form2request library instead.",
)
__all__ = [
"FormRequest",

View File

@ -74,7 +74,7 @@ class CookieJar:
@property
def _cookies(self) -> dict[str, dict[str, dict[str, Cookie]]]:
return self.jar._cookies # type: ignore[attr-defined]
return self.jar._cookies # type: ignore[attr-defined,no-any-return]
def clear_session_cookies(self) -> None:
return self.jar.clear_session_cookies()

View File

@ -10,10 +10,12 @@ from __future__ import annotations
from collections.abc import Iterable
from typing import TYPE_CHECKING, Any, ClassVar, TypeAlias, cast
from urllib.parse import urlencode, urljoin, urlsplit, urlunsplit
from warnings import warn
from parsel.csstranslator import HTMLTranslator
from w3lib.html import strip_html5_whitespace
from scrapy.exceptions import ScrapyDeprecationWarning
from scrapy.http.request import Request
from scrapy.utils.python import is_listlike, to_bytes
@ -30,6 +32,12 @@ if TYPE_CHECKING:
from scrapy.http.response.text import TextResponse
warn(
"The entire scrapy.http.request.form module is deprecated. Use the "
"form2request library instead.",
ScrapyDeprecationWarning,
stacklevel=2,
)
FormdataVType: TypeAlias = str | Iterable[str]
FormdataKVType: TypeAlias = tuple[str, FormdataVType]

View File

@ -20,7 +20,6 @@ if TYPE_CHECKING:
from collections.abc import Callable, Iterable, Mapping
from ipaddress import IPv4Address, IPv6Address
from twisted.internet.ssl import Certificate
from twisted.python.failure import Failure
# typing.Self requires Python 3.11
@ -77,7 +76,7 @@ class Response(object_ref):
body: bytes = b"",
flags: list[str] | None = None,
request: Request | None = None,
certificate: Certificate | None = None,
certificate: Any = None,
ip_address: IPv4Address | IPv6Address | None = None,
protocol: str | None = None,
):
@ -87,7 +86,7 @@ class Response(object_ref):
self._set_url(url)
self.request: Request | None = request
self._flags: list[str] | None = list(flags) if flags else None
self.certificate: Certificate | None = certificate
self.certificate: Any = certificate
self.ip_address: IPv4Address | IPv6Address | None = ip_address
self.protocol: str | None = protocol

View File

@ -220,7 +220,7 @@ class TextResponse(Response):
def follow_all(
self,
urls: Iterable[str | Link] | parsel.SelectorList | None = None,
urls: Iterable[str | Link] | parsel.SelectorList[Any] | None = None,
callback: CallbackT | None = None,
method: str = "GET",
headers: Mapping[AnyStr, Any] | Iterable[tuple[AnyStr, Any]] | None = None,

View File

@ -50,11 +50,8 @@ class MiddlewareManager(ABC):
)
self.middlewares: tuple[Any, ...] = middlewares
# Only process_spider_output and process_spider_exception can be None.
# Only process_spider_output can be a tuple, and only until _async compatibility methods are removed.
self.methods: dict[str, deque[Callable | tuple[Callable, Callable] | None]] = (
defaultdict(deque)
)
self._mw_methods_requiring_spider: set[Callable] = set()
self.methods: dict[str, deque[Callable[..., Any] | None]] = defaultdict(deque)
self._mw_methods_requiring_spider: set[Callable[..., Any]] = set()
for mw in middlewares:
self._add_middleware(mw)
@ -119,7 +116,7 @@ class MiddlewareManager(ABC):
def _add_middleware(self, mw: Any) -> None: # noqa: B027
pass
def _check_mw_method_spider_arg(self, method: Callable) -> None:
def _check_mw_method_spider_arg(self, method: Callable[..., Any]) -> None:
if argument_is_required(method, "spider"):
warnings.warn(
f"{method.__qualname__}() requires a spider argument,"

View File

@ -37,16 +37,16 @@ class ItemPipelineManager(MiddlewareManager):
settings.get_component_priority_dict_with_base("ITEM_PIPELINES")
)
def _add_middleware(self, pipe: Any) -> None:
if hasattr(pipe, "open_spider"):
self.methods["open_spider"].append(pipe.open_spider)
self._check_mw_method_spider_arg(pipe.open_spider)
if hasattr(pipe, "close_spider"):
self.methods["close_spider"].appendleft(pipe.close_spider)
self._check_mw_method_spider_arg(pipe.close_spider)
if hasattr(pipe, "process_item"):
self.methods["process_item"].append(pipe.process_item)
self._check_mw_method_spider_arg(pipe.process_item)
def _add_middleware(self, mw: Any) -> None:
if hasattr(mw, "open_spider"):
self.methods["open_spider"].append(mw.open_spider)
self._check_mw_method_spider_arg(mw.open_spider)
if hasattr(mw, "close_spider"):
self.methods["close_spider"].appendleft(mw.close_spider)
self._check_mw_method_spider_arg(mw.close_spider)
if hasattr(mw, "process_item"):
self.methods["process_item"].append(mw.process_item)
self._check_mw_method_spider_arg(mw.process_item)
def process_item(self, item: Any, spider: Spider) -> Deferred[Any]:
warnings.warn(
@ -62,32 +62,44 @@ class ItemPipelineManager(MiddlewareManager):
"process_item", item, add_spider=True, warn_deferred=True
)
def _get_dfd(
self,
method: Callable[..., Coroutine[Any, Any, None] | Deferred[None] | None],
) -> Deferred[None]:
if method in self._mw_methods_requiring_spider:
return _maybeDeferred_coro(method, True, self._spider)
return _maybeDeferred_coro(method, True)
@staticmethod
def _eb(failure: Failure) -> Failure:
assert isinstance(failure.value, FirstError)
return failure.value.subFailure
def _process_parallel_dfd(self, methodname: str) -> Deferred[list[None]]:
methods = cast(
"Iterable[Callable[..., Coroutine[Any, Any, None] | Deferred[None] | None]]",
self.methods[methodname],
)
def get_dfd(
method: Callable[..., Coroutine[Any, Any, None] | Deferred[None] | None],
) -> Deferred[None]:
if method in self._mw_methods_requiring_spider:
return _maybeDeferred_coro(method, True, self._spider)
return _maybeDeferred_coro(method, True)
dfds = [get_dfd(m) for m in methods]
dfds = [self._get_dfd(m) for m in methods]
d: Deferred[list[tuple[bool, None]]] = DeferredList(
dfds, fireOnOneErrback=True, consumeErrors=True
)
d2: Deferred[list[None]] = d.addCallback(lambda r: [x[1] for x in r])
def eb(failure: Failure) -> Failure:
assert isinstance(failure.value, FirstError)
return failure.value.subFailure
d2.addErrback(eb)
d2.addErrback(self._eb)
return d2
def get_awaitable(
self,
method: Callable[..., Coroutine[Any, Any, None] | Deferred[None] | None],
) -> Awaitable[None]:
if method in self._mw_methods_requiring_spider:
result = method(self._spider)
else:
result = method()
return ensure_awaitable(result, _warn=global_object_name(method))
async def _process_parallel_asyncio(self, methodname: str) -> list[None]:
methods = cast(
"Iterable[Callable[..., Coroutine[Any, Any, None] | Deferred[None] | None]]",
@ -96,16 +108,7 @@ class ItemPipelineManager(MiddlewareManager):
if not methods:
return []
def get_awaitable(
method: Callable[..., Coroutine[Any, Any, None] | Deferred[None] | None],
) -> Awaitable[None]:
if method in self._mw_methods_requiring_spider:
result = method(self._spider)
else:
result = method()
return ensure_awaitable(result, _warn=global_object_name(method))
awaitables = [get_awaitable(m) for m in methods]
awaitables = [self.get_awaitable(m) for m in methods]
await asyncio.gather(*awaitables)
return [None for _ in methods]

View File

@ -186,16 +186,18 @@ class S3FilesStore:
raise ValueError(f"Incorrect URI scheme in {uri}, expected 's3'")
self.bucket, self.prefix = uri[5:].split("/", 1)
@staticmethod
def _onsuccess(boto_key: dict[str, Any]) -> StatInfo:
checksum = boto_key["ETag"].strip('"')
last_modified = boto_key["LastModified"]
modified_stamp = time.mktime(last_modified.timetuple())
return {"checksum": checksum, "last_modified": modified_stamp}
def stat_file(
self, path: str, info: MediaPipeline.SpiderInfo
) -> Deferred[StatInfo]:
def _onsuccess(boto_key: dict[str, Any]) -> StatInfo:
checksum = boto_key["ETag"].strip('"')
last_modified = boto_key["LastModified"]
modified_stamp = time.mktime(last_modified.timetuple())
return {"checksum": checksum, "last_modified": modified_stamp}
return self._get_boto_key(path).addCallback(_onsuccess)
return self._get_boto_key(path).addCallback(self._onsuccess)
def _get_boto_key(self, path: str) -> Deferred[dict[str, Any]]:
key_name = f"{self.prefix}{path}"
@ -308,20 +310,23 @@ class GCSFilesStore:
{"bucket": bucket},
)
@staticmethod
def _onsuccess(blob: Any) -> StatInfo:
if blob:
checksum = base64.b64decode(blob.md5_hash).hex()
last_modified = time.mktime(blob.updated.timetuple())
return {"checksum": checksum, "last_modified": last_modified}
return {}
def stat_file(
self, path: str, info: MediaPipeline.SpiderInfo
) -> Deferred[StatInfo]:
def _onsuccess(blob: Any) -> StatInfo:
if blob:
checksum = base64.b64decode(blob.md5_hash).hex()
last_modified = time.mktime(blob.updated.timetuple())
return {"checksum": checksum, "last_modified": last_modified}
return {}
blob_path = self._get_blob_path(path)
return deferred_from_coro(
d: Deferred[Any] = deferred_from_coro(
run_in_thread(self.bucket.get_blob, blob_path)
).addCallback(_onsuccess)
)
return d.addCallback(self._onsuccess)
def _get_content_type(self, headers: dict[str, str] | None) -> str:
if headers and "Content-Type" in headers:
@ -395,26 +400,26 @@ class FTPFilesStore:
)
)
def _stat_file(self, path: str) -> StatInfo:
try:
with FTP() as ftp:
ftp.connect(self.host, self.port)
ftp.login(self.username, self.password)
if self.USE_ACTIVE_MODE:
ftp.set_pasv(False)
file_path = f"{self.basedir}/{path}"
last_modified = float(ftp.voidcmd(f"MDTM {file_path}")[4:].strip())
m = hashlib.md5() # noqa: S324
ftp.retrbinary(f"RETR {file_path}", m.update)
return {"last_modified": last_modified, "checksum": m.hexdigest()}
# The file doesn't exist
except Exception:
return {}
def stat_file(
self, path: str, info: MediaPipeline.SpiderInfo
) -> Deferred[StatInfo]:
def _stat_file(path: str) -> StatInfo:
try:
with FTP() as ftp:
ftp.connect(self.host, self.port)
ftp.login(self.username, self.password)
if self.USE_ACTIVE_MODE:
ftp.set_pasv(False)
file_path = f"{self.basedir}/{path}"
last_modified = float(ftp.voidcmd(f"MDTM {file_path}")[4:].strip())
m = hashlib.md5() # noqa: S324
ftp.retrbinary(f"RETR {file_path}", m.update)
return {"last_modified": last_modified, "checksum": m.hexdigest()}
# The file doesn't exist
except Exception:
return {}
return deferred_from_coro(run_in_thread(_stat_file, path))
return deferred_from_coro(run_in_thread(self._stat_file, path))
class FilesPipeline(MediaPipeline):
@ -534,43 +539,51 @@ class FilesPipeline(MediaPipeline):
store_cls = self.STORE_SCHEMES[scheme]
return store_cls(uri)
def _onsuccess(
self,
result: StatInfo,
request: Request,
info: MediaPipeline.SpiderInfo,
path: str,
) -> FileInfo | None:
if not result:
return None # returning None force download
last_modified = result.get("last_modified", None)
if not last_modified:
return None # returning None force download
age_seconds = time.time() - last_modified
age_days = age_seconds / 60 / 60 / 24
if age_days > self.expires:
return None # returning None force download
referer = referer_str(request)
logger.debug(
"File (uptodate): Downloaded %(medianame)s from %(request)s "
"referred in <%(referer)s>",
{"medianame": self.MEDIA_NAME, "request": request, "referer": referer},
extra={"spider": info.spider},
)
self.inc_stats("uptodate")
checksum = result.get("checksum", None)
return {
"url": request.url,
"path": path,
"checksum": checksum,
"status": "uptodate",
}
def media_to_download(
self, request: Request, info: MediaPipeline.SpiderInfo, *, item: Any = None
) -> Deferred[FileInfo | None] | None:
def _onsuccess(result: StatInfo) -> FileInfo | None:
if not result:
return None # returning None force download
last_modified = result.get("last_modified", None)
if not last_modified:
return None # returning None force download
age_seconds = time.time() - last_modified
age_days = age_seconds / 60 / 60 / 24
if age_days > self.expires:
return None # returning None force download
referer = referer_str(request)
logger.debug(
"File (uptodate): Downloaded %(medianame)s from %(request)s "
"referred in <%(referer)s>",
{"medianame": self.MEDIA_NAME, "request": request, "referer": referer},
extra={"spider": info.spider},
)
self.inc_stats("uptodate")
checksum = result.get("checksum", None)
return {
"url": request.url,
"path": path,
"checksum": checksum,
"status": "uptodate",
}
path = self.file_path(request, info=info, item=item)
# maybeDeferred() overloads don't seem to support a Union[_T, Deferred[_T]] return type
dfd: Deferred[StatInfo] = maybeDeferred(self.store.stat_file, path, info) # type: ignore[call-overload]
dfd2: Deferred[FileInfo | None] = dfd.addCallback(_onsuccess)
dfd2: Deferred[FileInfo | None] = dfd.addCallback(
functools.partial(self._onsuccess, request=request, info=info, path=path)
)
dfd2.addErrback(lambda _: None)
dfd2.addErrback(
lambda f: logger.error(

View File

@ -29,9 +29,9 @@ from scrapy.utils.misc import arg_to_iter
from scrapy.utils.python import global_object_name
if TYPE_CHECKING:
# typing.Self requires Python 3.11
from collections.abc import Awaitable
from collections.abc import Awaitable, Callable
# typing.Self requires Python 3.11
from typing_extensions import Self
from scrapy import Spider
@ -153,7 +153,7 @@ class MediaPipeline(ABC):
) -> FileInfo:
fp = self._fingerprinter.fingerprint(request)
eb = request.errback
eb: Callable[[Failure], FileInfo] | None = request.errback
request.callback = NO_CALLBACK
request.errback = None

View File

@ -3,7 +3,7 @@ from __future__ import annotations
import logging
import sys
from abc import ABCMeta, abstractmethod
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, cast
from urllib.robotparser import RobotFileParser
from protego import Protego
@ -103,7 +103,7 @@ class RerpRobotParser(RobotParser):
def allowed(self, url: str | bytes, user_agent: str | bytes) -> bool:
user_agent = to_unicode(user_agent)
url = to_unicode(url)
return self.rp.is_allowed(user_agent, url)
return cast("bool", self.rp.is_allowed(user_agent, url))
class ProtegoRobotParser(RobotParser):

View File

@ -143,7 +143,7 @@ class BaseSettings(MutableMapping[_SettingsKey, Any]):
raise ValueError(f"{item!r} not found in the {name} setting ({value!r}).")
self.set(name, [v for v in value if v != item], self.getpriority(name) or 0)
def get(self, name: _SettingsKey, default: Any = None) -> Any:
def get(self, name: _SettingsKey, default: Any = None) -> Any: # pylint: disable=arguments-renamed
"""
Get a setting value without affecting its original type.
@ -510,7 +510,7 @@ class BaseSettings(MutableMapping[_SettingsKey, Any]):
component_priority_dict[cls] = priority
self.set(name, component_priority_dict, self.getpriority(name) or 0)
def setdefault(
def setdefault( # pylint: disable=arguments-renamed
self,
name: _SettingsKey,
default: Any = None,
@ -691,14 +691,14 @@ class BaseSettings(MutableMapping[_SettingsKey, Any]):
else:
p.text(pformat(self.copy_to_dict()))
def pop(self, name: _SettingsKey, default: Any = __default) -> Any:
def pop(self, name: _SettingsKey, default: Any = __default) -> Any: # pylint: disable=arguments-renamed
try:
value = self.attributes[name].value
except KeyError:
if default is self.__default:
raise
return default
self.__delitem__(name)
del self[name]
return value

View File

@ -54,7 +54,6 @@ __all__ = [
"DOWNLOADER_CLIENT_TLS_CIPHERS",
"DOWNLOADER_CLIENT_TLS_METHOD",
"DOWNLOADER_CLIENT_TLS_VERBOSE_LOGGING",
"DOWNLOADER_HTTPCLIENTFACTORY",
"DOWNLOADER_MIDDLEWARES",
"DOWNLOADER_MIDDLEWARES_BASE",
"DOWNLOADER_STATS",
@ -275,10 +274,6 @@ DOWNLOADER_CLIENT_TLS_CIPHERS = "DEFAULT"
DOWNLOADER_CLIENT_TLS_METHOD = "TLS"
DOWNLOADER_CLIENT_TLS_VERBOSE_LOGGING = False
DOWNLOADER_HTTPCLIENTFACTORY = (
"scrapy.core.downloader.webclient.ScrapyHTTPClientFactory"
)
DOWNLOADER_MIDDLEWARES = {}
DOWNLOADER_MIDDLEWARES_BASE = {
# Engine side
@ -289,7 +284,6 @@ DOWNLOADER_MIDDLEWARES_BASE = {
"scrapy.downloadermiddlewares.defaultheaders.DefaultHeadersMiddleware": 400,
"scrapy.downloadermiddlewares.useragent.UserAgentMiddleware": 500,
"scrapy.downloadermiddlewares.retry.RetryMiddleware": 550,
"scrapy.downloadermiddlewares.ajaxcrawl.AjaxCrawlMiddleware": 560,
"scrapy.downloadermiddlewares.redirect.MetaRefreshMiddleware": 580,
"scrapy.downloadermiddlewares.httpcompression.HttpCompressionMiddleware": 590,
"scrapy.downloadermiddlewares.redirect.RedirectMiddleware": 600,

View File

@ -241,7 +241,10 @@ class Shell:
with contextlib.suppress(IgnoreRequest):
response = threads.blockingCallFromThread(
reactor, deferred_f_from_coro_f(self._schedule), request, spider
reactor,
deferred_f_from_coro_f(self._schedule), # type: ignore[arg-type]
request,
spider,
)
else:
assert self._loop

View File

@ -41,13 +41,6 @@ class BaseSpiderMiddleware:
def from_crawler(cls, crawler: Crawler) -> Self:
return cls(crawler)
def process_start_requests(
self, start: Iterable[Any], spider: Spider
) -> Iterable[Any]:
for o in start:
if (o := self._get_processed(o, None)) is not None:
yield o
async def process_start(self, start: AsyncIterator[Any]) -> AsyncIterator[Any]:
async for o in start:
if (o := self._get_processed(o, None)) is not None:

View File

@ -7,17 +7,15 @@ See documentation in docs/topics/spiders.rst
from __future__ import annotations
import logging
import warnings
from typing import TYPE_CHECKING, Any, cast
from scrapy import signals
from scrapy.exceptions import ScrapyDeprecationWarning
from scrapy.http import Request, Response
from scrapy.utils.trackref import object_ref
from scrapy.utils.url import url_is_from_spider
if TYPE_CHECKING:
from collections.abc import AsyncIterator, Iterable
from collections.abc import AsyncIterator
from twisted.internet.defer import Deferred
@ -127,24 +125,6 @@ class Spider(object_ref):
.. seealso:: :ref:`start-requests`
"""
with warnings.catch_warnings():
warnings.filterwarnings(
"ignore", category=ScrapyDeprecationWarning, module=r"^scrapy\.spiders$"
)
for item_or_request in self.start_requests():
yield item_or_request
def start_requests(self) -> Iterable[Any]:
warnings.warn(
(
"The Spider.start_requests() method is deprecated, use "
"Spider.start() instead. If you are calling "
"super().start_requests() from a Spider.start() override, "
"iterate super().start() instead."
),
ScrapyDeprecationWarning,
stacklevel=2,
)
if not self.start_urls and hasattr(self, "start_url"):
raise AttributeError(
"Crawling could not start: 'start_urls' not found "

View File

@ -46,7 +46,9 @@ def _identity_process_request(request: Request, response: Response) -> Request |
return request
def _get_method(method: Callable | str | None, spider: Spider) -> Callable | None:
def _get_method(
method: Callable[..., Any] | str | None, spider: Spider
) -> Callable[..., Any] | None:
if callable(method):
return method
if isinstance(method, str):

View File

@ -1,64 +0,0 @@
from __future__ import annotations
import warnings
from typing import TYPE_CHECKING, Any, cast
from scrapy.exceptions import ScrapyDeprecationWarning
from scrapy.spiders import Spider
from scrapy.utils.spider import iterate_spider_output
if TYPE_CHECKING:
from collections.abc import AsyncIterator, Iterable
from scrapy import Request
from scrapy.http import Response
class InitSpider(Spider):
"""Base Spider with initialization facilities
.. warning:: This class is deprecated. Copy its code into your project if needed.
It will be removed in a future Scrapy version.
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
warnings.warn(
"InitSpider is deprecated. Copy its code from Scrapy's source if needed. "
"Will be removed in a future version.",
ScrapyDeprecationWarning,
stacklevel=2,
)
async def start(self) -> AsyncIterator[Any]:
with warnings.catch_warnings():
warnings.filterwarnings(
"ignore", category=ScrapyDeprecationWarning, module=r"^scrapy\.spiders$"
)
for item_or_request in self.start_requests():
yield item_or_request
def start_requests(self) -> Iterable[Request]:
self._postinit_reqs: Iterable[Request] = super().start_requests()
return cast("Iterable[Request]", iterate_spider_output(self.init_request()))
def initialized(self, response: Response | None = None) -> Any:
"""This method must be set as the callback of your last initialization
request. See self.init_request() docstring for more info.
"""
return self.__dict__.pop("_postinit_reqs")
def init_request(self) -> Any:
"""This function should return one initialization request, with the
self.initialized method as callback. When the self.initialized method
is called this spider is considered initialized. If you need to perform
several requests for initializing your spider, you can do so by using
different callbacks. The only requirement is that the final callback
(of the last initialization request) must be self.initialized.
The default implementation calls self.initialized immediately, and
means that no initialization is needed. This method should be
overridden only when you need to perform requests to initialize your
spider
"""
return self.initialized()

View File

@ -54,10 +54,6 @@ class SitemapSpider(Spider):
self._follow: list[re.Pattern[str]] = [regex(x) for x in self.sitemap_follow]
async def start(self) -> AsyncIterator[Any]:
for item_or_request in self.start_requests():
yield item_or_request
def start_requests(self) -> Iterable[Request]:
for url in self.sitemap_urls:
yield Request(url, self._parse_sitemap)

View File

@ -26,7 +26,7 @@ if TYPE_CHECKING:
def _with_mkdir(queue_class: type[queue.BaseQueue]) -> type[queue.BaseQueue]:
class DirectoriesCreated(queue_class): # type: ignore[valid-type,misc]
def __init__(self, path: str | PathLike, *args: Any, **kwargs: Any):
def __init__(self, path: str | PathLike[str], *args: Any, **kwargs: Any):
dirname = Path(path).parent
if not dirname.exists():
dirname.mkdir(parents=True, exist_ok=True)

View File

@ -4,6 +4,8 @@ from twisted import version as TWISTED_VERSION
from twisted.python.versions import Version as TxVersion
TWISTED_FAILURE_HAS_STACK = TWISTED_VERSION < TxVersion("twisted", 24, 10, 0)
# changes to private _sslverify code, https://github.com/twisted/twisted/pull/12506
TWISTED_TLS_NEW_IMPL = TWISTED_VERSION >= TxVersion("twisted", 26, 4, 0)
PYOPENSSL_VERSION = Version(PYOPENSSL_VERSION_STRING)
# SSL.Context.use_certificate() wants an X509 object, SSL.Context.use_privatekey() wants a PKey object

View File

@ -2,8 +2,8 @@
from __future__ import annotations
from abc import ABC
from contextlib import contextmanager
from http.cookiejar import CookieJar
from typing import TYPE_CHECKING, Any
from twisted.internet.defer import CancelledError
@ -15,7 +15,6 @@ from twisted.web.client import ResponseFailed
from twisted.web.error import SchemeNotSupported
from scrapy import responsetypes
from scrapy.core.downloader.handlers.base import BaseDownloadHandler
from scrapy.exceptions import (
CannotResolveHostError,
DownloadCancelledError,
@ -29,29 +28,24 @@ from scrapy.utils.log import logger
if TYPE_CHECKING:
from collections.abc import Iterator
from http.client import HTTPResponse
from http.cookiejar import Cookie
from ipaddress import IPv4Address, IPv6Address
from twisted.internet.ssl import Certificate
from urllib.request import Request as ULRequest
from scrapy import Request
from scrapy.crawler import Crawler
from scrapy.http import Headers, Response
class BaseHttpDownloadHandler(BaseDownloadHandler, ABC):
"""Base class for built-in HTTP download handlers."""
class NullCookieJar(CookieJar): # pragma: no cover
"""A CookieJar that rejects all cookies."""
def __init__(self, crawler: Crawler):
super().__init__(crawler)
self._default_maxsize: int = crawler.settings.getint("DOWNLOAD_MAXSIZE")
self._default_warnsize: int = crawler.settings.getint("DOWNLOAD_WARNSIZE")
self._fail_on_dataloss: bool = crawler.settings.getbool(
"DOWNLOAD_FAIL_ON_DATALOSS"
)
self._tls_verbose_logging: bool = crawler.settings.getbool(
"DOWNLOADER_CLIENT_TLS_VERBOSE_LOGGING"
)
self._fail_on_dataloss_warned: bool = False
def extract_cookies(self, response: HTTPResponse, request: ULRequest) -> None:
pass
def set_cookie(self, cookie: Cookie) -> None:
pass
@contextmanager
@ -103,7 +97,7 @@ def make_response(
headers: Headers,
body: bytes = b"",
flags: list[str] | None = None,
certificate: Certificate | None = None,
certificate: Any = None,
ip_address: IPv4Address | IPv6Address | None = None,
protocol: str | None = None,
stop_download: StopDownload | None = None,

View File

@ -148,7 +148,7 @@ class AsyncioLoopingCall:
self._func: Callable[_P, _T] = func
self._args: tuple[Any, ...] = args
self._kwargs: dict[str, Any] = kwargs
self._task: asyncio.Task | None = None
self._task: asyncio.Task[None] | None = None
self.interval: float | None = None
self._start_time: float | None = None

View File

@ -9,7 +9,7 @@ from twisted.web.server import Request, Site
class Root(Resource):
isLeaf = True
def getChild(self, name: str, request: Request) -> Resource:
def getChild(self, path: str, request: Request) -> Resource:
return self
def render(self, request: Request) -> bytes:
@ -30,7 +30,7 @@ class Root(Resource):
def _getarg(
request: Request, name: bytes, default: Any = None, type_: type = str
) -> Any:
return type_(request.args[name][0]) if name in request.args else default # type: ignore[index,operator]
return type_(request.args[name][0]) if name in request.args else default
if __name__ == "__main__":

View File

@ -71,8 +71,8 @@ def arglist_to_dict(arglist: list[str]) -> dict[str, str]:
def closest_scrapy_cfg(
path: str | os.PathLike = ".",
prevpath: str | os.PathLike | None = None,
path: str | os.PathLike[str] = ".",
prevpath: str | os.PathLike[str] | None = None,
) -> str:
"""Return the path to the closest scrapy.cfg file by traversing the current
directory and its parents

View File

@ -13,7 +13,7 @@ import warnings
import weakref
from collections import OrderedDict
from collections.abc import Mapping
from typing import TYPE_CHECKING, Any, AnyStr, TypeVar
from typing import TYPE_CHECKING, Any, AnyStr, TypeVar, cast
from scrapy.exceptions import ScrapyDeprecationWarning
@ -28,7 +28,7 @@ _KT = TypeVar("_KT")
_VT = TypeVar("_VT")
class CaselessDict(dict):
class CaselessDict(dict): # type: ignore[type-arg]
__slots__ = ()
def __new__(cls, *args: Any, **kwargs: Any) -> Self:
@ -99,20 +99,20 @@ class CaselessDict(dict):
return dict.pop(self, self.normkey(key), *args)
class CaseInsensitiveDict(collections.UserDict):
class CaseInsensitiveDict(collections.UserDict[str | bytes, Any]):
"""A dict-like structure that accepts strings or bytes
as keys and allows case-insensitive lookups.
"""
def __init__(self, *args: Any, **kwargs: Any) -> None:
self._keys: dict = {}
self._keys: dict[str | bytes, Any] = {}
super().__init__(*args, **kwargs)
def __getitem__(self, key: AnyStr) -> Any:
def __getitem__(self, key: str | bytes) -> Any:
normalized_key = self._normkey(key)
return super().__getitem__(self._keys[normalized_key.lower()])
def __setitem__(self, key: AnyStr, value: Any) -> None:
def __setitem__(self, key: str | bytes, value: Any) -> None:
normalized_key = self._normkey(key)
try:
lower_key = self._keys[normalized_key.lower()]
@ -122,19 +122,19 @@ class CaseInsensitiveDict(collections.UserDict):
super().__setitem__(normalized_key, self._normvalue(value))
self._keys[normalized_key.lower()] = normalized_key
def __delitem__(self, key: AnyStr) -> None:
def __delitem__(self, key: str | bytes) -> None:
normalized_key = self._normkey(key)
stored_key = self._keys.pop(normalized_key.lower())
super().__delitem__(stored_key)
def __contains__(self, key: AnyStr) -> bool: # type: ignore[override]
def __contains__(self, key: str | bytes) -> bool: # type: ignore[override]
normalized_key = self._normkey(key)
return normalized_key.lower() in self._keys
def __repr__(self) -> str:
return f"<{self.__class__.__name__}: {super().__repr__()}>"
def _normkey(self, key: AnyStr) -> AnyStr:
def _normkey(self, key: str | bytes) -> str | bytes:
return key
def _normvalue(self, value: Any) -> Any:
@ -158,7 +158,7 @@ class LocalCache(OrderedDict[_KT, _VT]):
super().__setitem__(key, value)
class LocalWeakReferencedCache(weakref.WeakKeyDictionary):
class LocalWeakReferencedCache(weakref.WeakKeyDictionary[_KT, _VT | None]):
"""
A weakref.WeakKeyDictionary implementation that uses LocalCache as its
underlying data structure, making it ordered and capable of being size-limited.
@ -172,16 +172,16 @@ class LocalWeakReferencedCache(weakref.WeakKeyDictionary):
def __init__(self, limit: int | None = None):
super().__init__()
self.data: LocalCache = LocalCache(limit=limit)
self.data: LocalCache[_KT, _VT] = LocalCache(limit=limit)
def __setitem__(self, key: _KT, value: _VT) -> None:
def __setitem__(self, key: _KT, value: _VT | None) -> None:
# if raised, key is not weak-referenceable, skip caching
with contextlib.suppress(TypeError):
super().__setitem__(key, value)
def __getitem__(self, key: _KT) -> _VT | None:
try:
return super().__getitem__(key)
return cast("_VT", super().__getitem__(key))
except (TypeError, KeyError):
return None # key is either not weak-referenceable or not cached

View File

@ -3,7 +3,7 @@ from __future__ import annotations
import inspect
import warnings
from functools import wraps
from typing import TYPE_CHECKING, Any, ParamSpec, TypeVar, overload
from typing import TYPE_CHECKING, Any, ParamSpec, TypeVar, cast, overload
from twisted.internet.defer import Deferred, maybeDeferred
@ -115,7 +115,7 @@ def _warn_spider_arg(
@wraps(func)
async def async_inner(*args: _P.args, **kwargs: _P.kwargs) -> _T:
check_args(*args, **kwargs)
return await func(*args, **kwargs)
return cast("_T", await func(*args, **kwargs))
return async_inner

View File

@ -173,7 +173,7 @@ def parallel(
return DeferredList([coop.coiterate(work) for _ in range(count)])
class _AsyncCooperatorAdapter(Iterator, Generic[_T]):
class _AsyncCooperatorAdapter(Iterator[Deferred[Any]], Generic[_T]):
"""A class that wraps an async iterable into a normal iterator suitable
for using in Cooperator.coiterate(). As it's only needed for parallel_async(),
it calls the callable directly in the callback, instead of providing a more
@ -262,7 +262,7 @@ class _AsyncCooperatorAdapter(Iterator, Generic[_T]):
def _call_anext(self) -> None:
# This starts waiting for the next result from aiterator.
# If aiterator is exhausted, _errback will be called.
self.anext_deferred = deferred_from_coro(self.aiterator.__anext__())
self.anext_deferred = deferred_from_coro(anext(self.aiterator))
self.anext_deferred.addCallbacks(self._callback, self._errback)
def __next__(self) -> Deferred[Any]:
@ -370,10 +370,10 @@ async def aiter_errback(
"""Wrap an async iterable calling an errback if an error is caught while
iterating it. Similar to :func:`scrapy.utils.defer.iter_errback`.
"""
it = aiterable.__aiter__()
it = aiter(aiterable)
while True:
try:
yield await it.__anext__()
yield await anext(it)
except StopAsyncIteration:
break
except Exception:

View File

@ -4,7 +4,7 @@ from __future__ import annotations
import inspect
import warnings
from typing import TYPE_CHECKING, Any, overload
from typing import TYPE_CHECKING, Any, cast, overload
from scrapy.exceptions import ScrapyDeprecationWarning
from scrapy.utils.python import get_func_args_dict
@ -68,7 +68,7 @@ def create_deprecated_class(
def __new__( # pylint: disable=bad-classmethod-argument
metacls, name: str, bases: tuple[type, ...], clsdict_: dict[str, Any]
) -> type:
cls = super().__new__(metacls, name, bases, clsdict_)
cls: type = super().__new__(metacls, name, bases, clsdict_)
if metacls.deprecated_class is None:
metacls.deprecated_class = cls
return cls
@ -100,7 +100,7 @@ def create_deprecated_class(
# is the deprecated class itself - subclasses of the
# deprecated class should not use custom `__subclasscheck__`
# method.
return super().__subclasscheck__(sub)
return cast("bool", super().__subclasscheck__(sub))
if not inspect.isclass(sub):
raise TypeError("issubclass() arg 1 must be a class")

View File

@ -260,7 +260,8 @@ def logformatter_adapter(
return (level, message, args)
class SpiderLoggerAdapter(logging.LoggerAdapter):
# LoggerAdapter is only parameterized since Python 3.11
class SpiderLoggerAdapter(logging.LoggerAdapter): # type: ignore[type-arg]
def process(
self, msg: str, kwargs: MutableMapping[str, Any]
) -> tuple[str, MutableMapping[str, Any]]:

View File

@ -13,7 +13,7 @@ from contextlib import contextmanager
from functools import partial
from importlib import import_module
from pkgutil import iter_modules
from typing import IO, TYPE_CHECKING, Any, ParamSpec, Protocol, TypeVar, overload
from typing import IO, TYPE_CHECKING, Any, ParamSpec, Protocol, TypeVar, cast, overload
from scrapy.exceptions import ScrapyDeprecationWarning
from scrapy.item import Item
@ -28,7 +28,7 @@ if TYPE_CHECKING:
_ITERABLE_SINGLE_VALUES = dict, Item, str, bytes
_ITER_T = TypeVar("_ITER_T", bound=dict | Item | str | bytes)
_ITER_T = TypeVar("_ITER_T", bound=dict[Any, Any] | Item | str | bytes)
_T = TypeVar("_T")
_T_co = TypeVar("_T_co", covariant=True)
_P = ParamSpec("_P")
@ -51,7 +51,7 @@ def arg_to_iter(arg: Any) -> Iterable[Any]:
if arg is None:
return ()
if not isinstance(arg, _ITERABLE_SINGLE_VALUES) and hasattr(arg, "__iter__"):
return arg
return cast("Iterable[Any]", arg)
return [arg]
@ -252,7 +252,14 @@ def walk_callable(node: ast.AST) -> Iterable[ast.AST]:
yield node
_generator_callbacks_cache = LocalWeakReferencedCache(limit=128)
_generator_callbacks_cache: LocalWeakReferencedCache[Callable[..., Any], bool] = (
LocalWeakReferencedCache(limit=128)
)
def _returns_none(return_node: ast.Return) -> bool:
value = return_node.value
return value is None or (isinstance(value, ast.Constant) and value.value is None)
def is_generator_with_return_value(callable: Callable[..., Any]) -> bool: # noqa: A002
@ -263,12 +270,6 @@ def is_generator_with_return_value(callable: Callable[..., Any]) -> bool: # noq
if callable in _generator_callbacks_cache:
return bool(_generator_callbacks_cache[callable])
def returns_none(return_node: ast.Return) -> bool:
value = return_node.value
return value is None or (
isinstance(value, ast.Constant) and value.value is None
)
if inspect.isgeneratorfunction(callable):
func = callable
while isinstance(func, partial):
@ -284,7 +285,7 @@ def is_generator_with_return_value(callable: Callable[..., Any]) -> bool: # noq
tree = ast.parse(code)
for node in walk_callable(tree):
if isinstance(node, ast.Return) and not returns_none(node):
if isinstance(node, ast.Return) and not _returns_none(node):
_generator_callbacks_cache[callable] = True
return bool(_generator_callbacks_cache[callable])

View File

@ -8,12 +8,14 @@ import gc
import inspect
import re
import sys
import warnings
import weakref
from collections.abc import AsyncIterator, Iterable, Mapping
from functools import partial, wraps
from itertools import chain
from typing import TYPE_CHECKING, Any, Concatenate, ParamSpec, TypeVar, overload
from scrapy.exceptions import ScrapyDeprecationWarning
from scrapy.utils.asyncgen import as_async_generator
if TYPE_CHECKING:
@ -99,6 +101,16 @@ def to_bytes(
return text.encode(encoding, errors)
def _chunk_iter(text: str, chunk_size: int) -> Iterable[tuple[str, int]]:
offset = len(text)
while True:
offset -= chunk_size * 1024
if offset <= 0:
break
yield (text[offset:], offset)
yield (text, 0)
def re_rsearch(
pattern: str | Pattern[str], text: str, chunk_size: int = 1024
) -> tuple[int, int] | None:
@ -115,19 +127,10 @@ def re_rsearch(
the start position of the match, and the ending (regarding the entire text).
"""
def _chunk_iter() -> Iterable[tuple[str, int]]:
offset = len(text)
while True:
offset -= chunk_size * 1024
if offset <= 0:
break
yield (text[offset:], offset)
yield (text, 0)
if isinstance(pattern, str):
pattern = re.compile(pattern)
for chunk, offset in _chunk_iter():
for chunk, offset in _chunk_iter(text, chunk_size):
matches = list(pattern.finditer(chunk))
if matches:
start, end = matches[-1].span()
@ -293,12 +296,13 @@ else:
gc.collect()
class MutableChain(Iterable[_T]):
"""
Thin wrapper around itertools.chain, allowing to add iterables "in-place"
"""
class MutableChain(Iterable[_T]): # pragma: no cover
def __init__(self, *args: Iterable[_T]):
warnings.warn(
"MutableChain is deprecated and will be removed in a future Scrapy version.",
category=ScrapyDeprecationWarning,
stacklevel=2,
)
self.data: Iterator[_T] = chain.from_iterable(args)
def extend(self, *iterables: Iterable[_T]) -> None:

View File

@ -2,6 +2,7 @@ from __future__ import annotations
import asyncio
import sys
import warnings
from contextlib import suppress
from typing import TYPE_CHECKING, Any, Generic, ParamSpec, TypeVar
from warnings import catch_warnings, filterwarnings
@ -33,12 +34,12 @@ def listen_tcp(portrange: list[int], host: str, factory: ServerFactory) -> Port:
if len(portrange) > 2:
raise ValueError(f"invalid portrange: {portrange}")
if not portrange:
return reactor.listenTCP(0, factory, interface=host)
return reactor.listenTCP(0, factory, interface=host) # type: ignore[no-any-return]
if len(portrange) == 1:
return reactor.listenTCP(portrange[0], factory, interface=host)
return reactor.listenTCP(portrange[0], factory, interface=host) # type: ignore[no-any-return]
for x in range(portrange[0], portrange[1] + 1):
try:
return reactor.listenTCP(x, factory, interface=host)
return reactor.listenTCP(x, factory, interface=host) # type: ignore[no-any-return]
except error.CannotListenError:
if x == portrange[1]:
raise
@ -54,7 +55,7 @@ class CallLaterOnce(Generic[_T]):
self._a: tuple[Any, ...] = a
self._kw: dict[str, Any] = kw
self._call: CallLaterResult | None = None
self._deferreds: list[Deferred] = []
self._deferreds: list[Deferred[None]] = []
def schedule(self, delay: float = 0) -> None:
# circular import
@ -93,16 +94,19 @@ _asyncio_reactor_path = "twisted.internet.asyncioreactor.AsyncioSelectorReactor"
def set_asyncio_event_loop_policy() -> None:
"""The policy functions from asyncio often behave unexpectedly,
so we restrict their use to the absolutely essential case.
This should only be used to install the reactor.
"""
policy = asyncio.get_event_loop_policy()
if sys.platform == "win32" and not isinstance(
policy, asyncio.WindowsSelectorEventLoopPolicy
):
policy = asyncio.WindowsSelectorEventLoopPolicy()
asyncio.set_event_loop_policy(policy)
"""Needed due to https://github.com/twisted/twisted/issues/12527."""
if sys.platform != "win32":
return
with warnings.catch_warnings():
warnings.filterwarnings(
"ignore",
message=r"'asyncio\.(get_event_loop_policy|WindowsSelectorEventLoopPolicy)' is deprecated",
category=DeprecationWarning,
)
policy = asyncio.get_event_loop_policy()
if not isinstance(policy, asyncio.WindowsSelectorEventLoopPolicy):
policy = asyncio.WindowsSelectorEventLoopPolicy() # pylint: disable=deprecated-class
asyncio.set_event_loop_policy(policy)
def install_reactor(reactor_path: str, event_loop_path: str | None = None) -> None:

View File

@ -97,8 +97,7 @@ def open_in_browser(
# XXX: this implementation is a bit dirty and could be improved
body = response.body
if isinstance(response, HtmlResponse):
if b"<base" not in body:
_remove_html_comments(body)
if b"<base" not in _remove_html_comments(body):
repl = rf'\g<0><base href="{response.url}">'
body = re.sub(rb"<head(?:[^<>]*?>)", to_bytes(repl), body, count=1)
ext = ".html"

View File

@ -7,6 +7,7 @@ import logging
import warnings
from collections.abc import Awaitable, Callable, Generator, Sequence
from typing import Any as TypingAny
from typing import cast
from pydispatch.dispatcher import (
Anonymous,
@ -185,7 +186,9 @@ async def _send_catch_log_asyncio(
handlers: list[Awaitable[TypingAny]] = []
for receiver in liveReceivers(getAllReceivers(sender, signal)):
async def handler(receiver: Callable) -> TypingAny:
async def handler(
receiver: Callable[..., Any],
) -> tuple[Callable[..., Any], TypingAny]:
result: TypingAny
try:
result = await ensure_awaitable(
@ -208,7 +211,10 @@ async def _send_catch_log_asyncio(
handlers.append(handler(receiver))
return await asyncio.gather(*handlers, return_exceptions=True)
return cast(
"list[tuple[TypingAny, TypingAny]]",
await asyncio.gather(*handlers, return_exceptions=True),
)
def disconnect_all(signal: TypingAny = Any, sender: TypingAny = Any) -> None:

View File

@ -41,11 +41,10 @@ def iterate_spider_output(
) -> Iterable[Any] | AsyncGenerator[_T] | Deferred[_T]:
if inspect.isasyncgen(result):
return result
d: Deferred[_T] = deferred_from_coro(result)
if inspect.iscoroutine(result):
d = deferred_from_coro(result)
d.addCallback(iterate_spider_output)
return d
return arg_to_iter(deferred_from_coro(result))
return d.addCallback(iterate_spider_output)
return arg_to_iter(d)
def iter_spider_classes(module: ModuleType) -> Iterable[type[Spider]]:

View File

@ -54,6 +54,18 @@ def _make_ssl_context(settings: BaseSettings) -> ssl.SSLContext:
return ctx
def _make_insecure_ssl_ctx() -> ssl.SSLContext:
"""Create an SSL context that doesn't verify certificates.
Compared to :func:`~scrapy.utils.ssl._make_ssl_context` this is much more
simple.
"""
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
return ctx
def _log_sslobj_debug_info(sslobj: ssl.SSLObject) -> None:
cipher = sslobj.cipher()
logger.debug(
@ -61,8 +73,11 @@ def _log_sslobj_debug_info(sslobj: ssl.SSLObject) -> None:
f" using protocol {sslobj.version()},"
f" cipher {cipher[0] if cipher else None}"
)
# The peer certificate is unavailable on SSLObject unless peer
# certificate verification is enabled, which we don't want.
if cert := sslobj.getpeercert():
# Not available without certificate verification
logger.debug(
f'SSL connection certificate: issuer "{cert["issuer"]}", subject "{cert["subject"]}"'
)
# pyOpenSSL utils

View File

@ -11,7 +11,7 @@ if TYPE_CHECKING:
from os import PathLike
def render_templatefile(path: str | PathLike, **kwargs: Any) -> None:
def render_templatefile(path: str | PathLike[str], **kwargs: Any) -> None:
path_obj = Path(path)
raw = path_obj.read_text("utf8")

View File

@ -7,20 +7,14 @@ from __future__ import annotations
import asyncio
import os
import warnings
from ftplib import FTP
from importlib import import_module
from pathlib import Path
from posixpath import split
from typing import TYPE_CHECKING, Any, TypeVar, cast
from unittest import mock
from twisted.trial.unittest import SkipTest
from twisted.web.client import Agent
from scrapy.crawler import AsyncCrawlerRunner, CrawlerRunner, CrawlerRunnerBase
from scrapy.exceptions import ScrapyDeprecationWarning
from scrapy.utils.boto import is_botocore_available
from scrapy.utils.deprecate import create_deprecated_class
from scrapy.utils.reactor import is_asyncio_reactor_installed, is_reactor_installed
from scrapy.utils.spider import DefaultSpider
@ -37,80 +31,6 @@ if TYPE_CHECKING:
_T = TypeVar("_T")
def assert_gcs_environ() -> None: # pragma: no cover
warnings.warn(
"The assert_gcs_environ() function is deprecated and will be removed in a future version of Scrapy."
" Check GCS_PROJECT_ID directly.",
category=ScrapyDeprecationWarning,
stacklevel=2,
)
if "GCS_PROJECT_ID" not in os.environ:
raise SkipTest("GCS_PROJECT_ID not found")
def skip_if_no_boto() -> None: # pragma: no cover
warnings.warn(
"The skip_if_no_boto() function is deprecated and will be removed in a future version of Scrapy."
" Check scrapy.utils.boto.is_botocore_available() directly.",
category=ScrapyDeprecationWarning,
stacklevel=2,
)
if not is_botocore_available():
raise SkipTest("missing botocore library")
def get_gcs_content_and_delete(
bucket: Any, path: str
) -> tuple[bytes, list[dict[str, str]], Any]: # pragma: no cover
from google.cloud import storage # noqa: PLC0415
warnings.warn(
"The get_gcs_content_and_delete() function is deprecated and will be removed in a future version of Scrapy.",
category=ScrapyDeprecationWarning,
stacklevel=2,
)
client = storage.Client(project=os.environ.get("GCS_PROJECT_ID"))
bucket = client.get_bucket(bucket)
blob = bucket.get_blob(path)
content = blob.download_as_string()
acl = list(blob.acl) # loads acl before it will be deleted
bucket.delete_blob(path)
return content, acl, blob
def get_ftp_content_and_delete(
path: str,
host: str,
port: int,
username: str,
password: str,
use_active_mode: bool = False,
) -> bytes: # pragma: no cover
warnings.warn(
"The get_ftp_content_and_delete() function is deprecated and will be removed in a future version of Scrapy.",
category=ScrapyDeprecationWarning,
stacklevel=2,
)
ftp = FTP()
ftp.connect(host, port)
ftp.login(username, password)
if use_active_mode:
ftp.set_pasv(False)
ftp_data: list[bytes] = []
def buffer_data(data: bytes) -> None:
ftp_data.append(data)
ftp.retrbinary(f"RETR {path}", buffer_data)
dirname, filename = split(path)
ftp.cwd(dirname)
ftp.delete(filename)
return b"".join(ftp_data)
TestSpider = create_deprecated_class("TestSpider", DefaultSpider)
def get_reactor_settings() -> dict[str, Any]:
"""Return a settings dict that works with the installed reactor.
@ -185,29 +105,6 @@ def get_from_asyncio_queue(value: _T) -> Awaitable[_T]:
return getter
def mock_google_cloud_storage() -> tuple[Any, Any, Any]: # pragma: no cover
"""Creates autospec mocks for google-cloud-storage Client, Bucket and Blob
classes and set their proper return values.
"""
from google.cloud.storage import Blob, Bucket, Client # noqa: PLC0415
warnings.warn(
"The mock_google_cloud_storage() function is deprecated and will be removed in a future version of Scrapy.",
category=ScrapyDeprecationWarning,
stacklevel=2,
)
client_mock = mock.create_autospec(Client)
bucket_mock = mock.create_autospec(Bucket)
client_mock.get_bucket.return_value = bucket_mock
blob_mock = mock.create_autospec(Blob)
bucket_mock.blob.return_value = blob_mock
return (client_mock, bucket_mock, blob_mock)
def get_web_client_agent_req(url: str) -> Deferred[TxResponse]: # pragma: no cover
warnings.warn(
"The get_web_client_agent_req() function is deprecated"

View File

@ -1,78 +0,0 @@
# pragma: no file cover
from __future__ import annotations
import os
import sys
import warnings
from typing import TYPE_CHECKING, ClassVar, cast
from twisted.internet.defer import Deferred
from twisted.internet.protocol import ProcessProtocol
from scrapy.exceptions import ScrapyDeprecationWarning
if TYPE_CHECKING:
from collections.abc import Iterable
from twisted.internet.error import ProcessTerminated
from twisted.python.failure import Failure
warnings.warn(
"The scrapy.utils.testproc module is deprecated.",
ScrapyDeprecationWarning,
stacklevel=2,
)
class ProcessTest:
command: str | None = None
prefix: ClassVar[list[str]] = [sys.executable, "-m", "scrapy.cmdline"]
cwd = os.getcwd() # trial chdirs to temp dir # noqa: PTH109
def execute(
self,
args: Iterable[str],
check_code: bool = True,
settings: str | None = None,
) -> Deferred[TestProcessProtocol]:
from twisted.internet import reactor
env = os.environ.copy()
if settings is not None:
env["SCRAPY_SETTINGS_MODULE"] = settings
assert self.command
cmd = [*self.prefix, self.command, *args]
pp = TestProcessProtocol()
pp.deferred.addCallback(self._process_finished, cmd, check_code)
reactor.spawnProcess(pp, cmd[0], cmd, env=env, path=self.cwd)
return pp.deferred
def _process_finished(
self, pp: TestProcessProtocol, cmd: list[str], check_code: bool
) -> tuple[int, bytes, bytes]:
if pp.exitcode and check_code:
msg = f"process {cmd} exit with code {pp.exitcode}"
msg += f"\n>>> stdout <<<\n{pp.out.decode()}"
msg += "\n"
msg += f"\n>>> stderr <<<\n{pp.err.decode()}"
raise RuntimeError(msg)
return cast("int", pp.exitcode), pp.out, pp.err
class TestProcessProtocol(ProcessProtocol):
def __init__(self) -> None:
self.deferred: Deferred[TestProcessProtocol] = Deferred()
self.out: bytes = b""
self.err: bytes = b""
self.exitcode: int | None = None
def outReceived(self, data: bytes) -> None:
self.out += data
def errReceived(self, data: bytes) -> None:
self.err += data
def processEnded(self, status: Failure) -> None:
self.exitcode = cast("ProcessTerminated", status.value).exitCode
self.deferred.callback(self)

View File

@ -1,65 +0,0 @@
# pragma: no file cover
import warnings
from urllib.parse import urljoin
from twisted.web import resource, server, static, util
from scrapy.exceptions import ScrapyDeprecationWarning
warnings.warn(
"The scrapy.utils.testsite module is deprecated.",
ScrapyDeprecationWarning,
stacklevel=2,
)
class SiteTest:
def setUp(self):
from twisted.internet import reactor
super().setUp()
self.site = reactor.listenTCP(0, test_site(), interface="127.0.0.1")
self.baseurl = f"http://localhost:{self.site.getHost().port}/"
def tearDown(self):
super().tearDown()
self.site.stopListening()
def url(self, path: str) -> str:
return urljoin(self.baseurl, path)
class NoMetaRefreshRedirect(util.Redirect):
def render(self, request: server.Request) -> bytes:
content = util.Redirect.render(self, request)
return content.replace(
b'http-equiv="refresh"', b'http-no-equiv="do-not-refresh-me"'
)
def test_site():
r = resource.Resource()
r.putChild(b"text", static.Data(b"Works", "text/plain"))
r.putChild(
b"html",
static.Data(
b"<body><p class='one'>Works</p><p class='two'>World</p></body>",
"text/html",
),
)
r.putChild(
b"enc-gb18030",
static.Data(b"<p>gb18030 encoding</p>", "text/html; charset=gb18030"),
)
r.putChild(b"redirect", util.Redirect(b"/redirected"))
r.putChild(b"redirect-no-meta-refresh", NoMetaRefreshRedirect(b"/redirected"))
r.putChild(b"redirected", static.Data(b"Redirected here", "text/plain"))
return server.Site(r)
if __name__ == "__main__":
from twisted.internet import reactor # pylint: disable=ungrouped-imports
port = reactor.listenTCP(0, test_site(), interface="127.0.0.1")
print(f"http://localhost:{port.getHost().port}/")
reactor.run()

View File

@ -30,7 +30,9 @@ if TYPE_CHECKING:
from typing_extensions import Self
live_refs: defaultdict[type, WeakKeyDictionary] = defaultdict(WeakKeyDictionary)
live_refs: defaultdict[type, WeakKeyDictionary[object, float]] = defaultdict(
WeakKeyDictionary
)
class object_ref:

Some files were not shown because too many files have changed in this diff Show More