Async API for download handlers. (#7164)

This commit is contained in:
Andrey Rakhmatullin 2025-12-31 23:03:16 +05:00 committed by GitHub
parent a2463325db
commit d1bd8eb49f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 896 additions and 666 deletions

View File

@ -229,6 +229,7 @@ Extending Scrapy
topics/signals
topics/scheduler
topics/exporters
topics/download-handlers
topics/components
topics/api
@ -257,6 +258,9 @@ Extending Scrapy
:doc:`topics/exporters`
Quickly export your scraped items to a file (XML, CSV, etc).
:doc:`topics/download-handlers`
Customize how requests are downloaded or add support for new URL schemes.
:doc:`topics/components`
Learn the common API and some good practices when building custom Scrapy
components.

View File

@ -74,6 +74,12 @@ Backward-incompatible changes
:class:`~scrapy.downloadermiddlewares.robotstxt.RobotsTxtMiddleware` or
calls its methods directly.
- The built-in :ref:`download handlers <download-handlers-ref>` were
refactored, changing signatures of their methods. This change should only
affect user code that subclasses any of these handlers or calls their
methods directly.
(:issue:`6778`, :issue:`7164`)
- :meth:`scrapy.pipelines.media.MediaPipeline.process_item` now returns a
coroutine, previously it returned a
:class:`~twisted.internet.defer.Deferred` object. This
@ -2616,7 +2622,7 @@ Modified requirements
~~~~~~~~~~~~~~~~~~~~~
- The h2_ dependency is now optional, only needed to
:ref:`enable HTTP/2 support <http2>`. (:issue:`5113`)
:ref:`enable HTTP/2 support <twisted-http2-handler>`. (:issue:`5113`)
.. _h2: https://pypi.org/project/h2/
@ -2998,7 +3004,7 @@ Highlights:
- Official Python 3.9 support
- Experimental :ref:`HTTP/2 support <http2>`
- Experimental :ref:`HTTP/2 support <twisted-http2-handler>`
- New :func:`~scrapy.downloadermiddlewares.retry.get_retry_request` function
to retry requests from spider callbacks
@ -3029,7 +3035,7 @@ Deprecations
New features
~~~~~~~~~~~~
- Experimental :ref:`HTTP/2 support <http2>` through a new download handler
- Experimental :ref:`HTTP/2 support <twisted-http2-handler>` through a new download handler
that can be assigned to the ``https`` protocol in the
:setting:`DOWNLOAD_HANDLERS` setting.
(:issue:`1854`, :issue:`4769`, :issue:`5058`, :issue:`5059`, :issue:`5066`)

View File

@ -1,3 +1,4 @@
h2==4.3.0
pydantic==2.12.3
scrapy-spider-metadata==0.2.0
sphinx==8.1.3

View File

@ -175,16 +175,19 @@ Use a fallback component:
class MyHandler:
lazy = False
def __init__(self, settings, crawler):
dhcls = load_object(settings.get(FALLBACK_SETTING))
def __init__(self, crawler):
dhcls = load_object(crawler.settings.get(FALLBACK_SETTING))
self._fallback_handler = build_from_crawler(dhcls, crawler)
def download_request(self, request, spider):
async def download_request(self, request):
if request.meta.get("my_params"):
# handle the request
...
else:
return self._fallback_handler.download_request(request, spider)
return await self._fallback_handler.download_request(request)
async def close(self):
pass
class MyAddon:

View File

@ -55,6 +55,10 @@ hence use coroutine syntax (e.g. ``await``, ``async for``, ``async with``):
- :ref:`Signal handlers that support deferreds <signal-deferred>`.
- Methods of :ref:`download handlers <topics-download-handlers>`.
.. versionadded:: VERSION
.. _coroutine-deferred-apis:
@ -95,12 +99,6 @@ The following user-supplied methods can return
:class:`~twisted.internet.defer.Deferred` objects (the methods that can also
return coroutines are listed in :ref:`coroutine-support`):
- Custom download handlers (see :setting:`DOWNLOAD_HANDLERS`):
- ``download_request()``
- ``close()``
- Custom downloader implementations (see :setting:`DOWNLOADER`):
- ``fetch()``
@ -146,11 +144,11 @@ For example:
email is sent. You can use this object directly in Deferred-based code or
convert it into a :class:`~asyncio.Future` object with
:func:`~scrapy.utils.defer.maybe_deferred_to_future`.
- A custom download handler needs to define a ``download_request()`` method
that returns a :class:`~twisted.internet.defer.Deferred` object. You can
write a method that works with Deferreds and returns one directly, or you
can write a coroutine and convert it into a function that returns a
Deferred with :func:`~scrapy.utils.defer.deferred_f_from_coro_f`.
- A custom scheduler needs to define an ``open()`` method that can return a
:class:`~twisted.internet.defer.Deferred` object. You can write a method
that works with Deferreds and returns one directly, or you can write a
coroutine and convert it into a function that returns a Deferred with
:func:`~scrapy.utils.defer.deferred_f_from_coro_f`.
General usage

View File

@ -0,0 +1,219 @@
.. _topics-download-handlers:
=================
Download handlers
=================
Download handlers are Scrapy :ref:`components <topics-components>` used to
download :ref:`requests <topics-request-response>` and produce responses from
them.
Using download handlers
=======================
The :setting:`DOWNLOAD_HANDLERS_BASE` and :setting:`DOWNLOAD_HANDLERS` settings
tell Scrapy which handler is responsible for a given URL scheme. Their values
are merged into a mapping from scheme names to handler classes. When Scrapy
initializes it creates instances of all configured download handlers (except
for :ref:`lazy ones <lazy-download-handlers>`) and stores them in a similar
mapping. When Scrapy needs to download a request it extracts the scheme from
its URL, finds the handler for this scheme, passes the request to it and gets a
response from it. If there is no handler for the scheme, the request is not
downloaded and a :exc:`~scrapy.exceptions.NotSupported` exception is raised.
The :setting:`DOWNLOAD_HANDLERS_BASE` setting contains the default mapping of
handlers. You can use the :setting:`DOWNLOAD_HANDLERS` setting to add handlers
for additional schemes and to replace or disable default ones:
.. code-block:: python
DOWNLOAD_HANDLERS = {
# disable support for ftp:// requests
"ftp": None,
# replace the default one for http://
"http": "my.download_handlers.HttpHandler",
# http:// and https:// are different schemes,
# even though they may use the same handler
"https": "my.download_handlers.HttpHandler",
# support for any custom scheme can be added
"sftp": "my.download_handlers.SftpHandler",
}
Replacing HTTP(S) download handlers
-----------------------------------
While Scrapy provides a default handler for ``http`` and ``https`` schemes,
users may want to use a different handler, provided by Scrapy or by some
3rd-party package. There are several considerations to keep in mind related to
this.
First of all, as ``http`` and ``https`` are separate schemes, they need
separate entries in the :setting:`DOWNLOAD_HANDLERS` setting, even though it's
likely that the same handler class will be used for both schemes.
Additionally, some of the Scrapy settings, like :setting:`DOWNLOAD_MAXSIZE`,
are honored by the default HTTP(S) handler but not necessarily by alternative
ones. The same may apply to other Scrapy features, e.g. the
:signal:`bytes_received` and :signal:`headers_received` signals.
.. _lazy-download-handlers:
Lazy instantiation of download handlers
---------------------------------------
A download handler can be marked as "lazy" by setting its ``lazy`` class
attribute to ``True``. Such handlers are only instantiated when they need to
download their first request. This may be useful when the instantiation is slow
or requires dependencies that are not always available, and the handler is not
needed on every spider run. For example, :class:`the built-in S3 handler
<.S3DownloadHandler>` is lazy.
Writing your own download handler
=================================
A download handler is a :ref:`component <topics-components>` that defines
the following methods:
.. class:: SampleDownloadHandler
.. attribute:: lazy
:type: bool
If ``False``, the handler will be instantiated when Scrapy is
initialized.
If ``True``, the handler will only be instantiated when the first
request handled by it needs to be downloaded.
.. method:: download_request(request: Request) -> Response:
:async:
Download the given request and return a response.
.. method:: close() -> None
:async:
Clean up any resources used by the handler.
An optional base class for custom handlers is provided:
.. autoclass:: scrapy.core.downloader.handlers.base.BaseDownloadHandler
:members:
:undoc-members:
:member-order: bysource
.. _download-handlers-ref:
Built-in download handlers reference
====================================
DataURIDownloadHandler
----------------------
.. autoclass:: scrapy.core.downloader.handlers.datauri.DataURIDownloadHandler
| Supported scheme: ``data``.
| Lazy: no.
This handler supports RFC 2397 ``data:content/type;base64,`` data URIs.
FileDownloadHandler
-------------------
.. autoclass:: scrapy.core.downloader.handlers.file.FileDownloadHandler
| Supported scheme: ``file``.
| Lazy: no.
This handler supports ``file:///path`` local file URIs. It doesn't
support remote files.
FTPDownloadHandler
------------------
.. autoclass:: scrapy.core.downloader.handlers.ftp.FTPDownloadHandler
| Supported scheme: ``ftp``.
| Lazy: no.
This handler supports ``ftp://host/path`` FTP URIs.
It's implemented using :mod:`twisted.protocols.ftp`.
.. _twisted-http2-handler:
H2DownloadHandler
-----------------
.. autoclass:: scrapy.core.downloader.handlers.http2.H2DownloadHandler
| Supported scheme: ``https``.
| Lazy: yes.
This handler supports ``https://host/path`` URLs and uses the HTTP/2 protocol
for them.
It's implemented using :mod:`twisted.web.client` and the ``h2`` library.
For this handler to work you need to install the ``Twisted[http2]`` extra
dependency.
If you want to use this handler you need to replace the default one for the
``https`` scheme:
.. code-block:: python
DOWNLOAD_HANDLERS = {
"https": "scrapy.core.downloader.handlers.http2.H2DownloadHandler",
}
.. warning::
This handler is experimental, and not yet recommended for production
environments. Future Scrapy versions may introduce related changes without
a deprecation period or warning.
.. note::
Known limitations of the HTTP/2 implementation in this handler include:
- No support for HTTP/2 Cleartext (h2c), since no major browser supports
HTTP/2 unencrypted (refer `http2 faq`_).
- No setting to specify a maximum `frame size`_ larger than the default
value, 16384. Connections to servers that send a larger frame will
fail.
- No support for `server pushes`_, which are ignored.
- No support for the :signal:`bytes_received` and
:signal:`headers_received` signals.
.. _frame size: https://datatracker.ietf.org/doc/html/rfc7540#section-4.2
.. _http2 faq: https://http2.github.io/faq/#does-http2-require-encryption
.. _server pushes: https://datatracker.ietf.org/doc/html/rfc7540#section-8.2
HTTP11DownloadHandler
---------------------
.. autoclass:: scrapy.core.downloader.handlers.http11.HTTP11DownloadHandler
| Supported schemes: ``http``, ``https``.
| Lazy: no.
This handler supports ``http://host/path`` and ``https://host/path`` URLs and
uses the HTTP/1.1 protocol for them.
It's implemented using :mod:`twisted.web.client`.
S3DownloadHandler
-----------------
.. autoclass:: scrapy.core.downloader.handlers.s3.S3DownloadHandler
| Supported scheme: ``s3``.
| Lazy: yes.
This handler supports ``s3://bucket/path`` S3 URIs.
It's implemented using the ``botocore`` library and needs it to be installed.

View File

@ -129,9 +129,10 @@ defines one or more of these methods:
.. method:: process_exception(request, exception)
Scrapy calls :meth:`process_exception` when a download handler
or a :meth:`process_request` (from a downloader middleware) raises an
exception (including an :exc:`~scrapy.exceptions.IgnoreRequest` exception)
Scrapy calls :meth:`process_exception` when a :ref:`download handler
<topics-download-handlers>` or a :meth:`process_request` (from a
downloader middleware) raises an exception (including an
:exc:`~scrapy.exceptions.IgnoreRequest` exception).
:meth:`process_exception` should return: either ``None``,
a :class:`~scrapy.http.Response` object, or a :class:`~scrapy.Request` object.

View File

@ -691,24 +691,6 @@ Default: ``'scrapy.core.downloader.Downloader'``
The downloader to use for crawling.
.. setting:: DOWNLOADER_HTTPCLIENTFACTORY
DOWNLOADER_HTTPCLIENTFACTORY
----------------------------
Default: ``'scrapy.core.downloader.webclient.ScrapyHTTPClientFactory'``
Defines a Twisted ``protocol.ClientFactory`` class to use for HTTP/1.0
connections (for ``HTTP10DownloadHandler``).
.. note::
HTTP/1.0 is rarely used nowadays and its Scrapy support is deprecated,
so you can safely ignore this setting,
unless you really want to use HTTP/1.0 and override
:setting:`DOWNLOAD_HANDLERS` for ``http(s)`` scheme accordingly,
i.e. to ``'scrapy.core.downloader.handlers.http10.HTTP10DownloadHandler'``.
.. setting:: DOWNLOADER_CLIENTCONTEXTFACTORY
DOWNLOADER_CLIENTCONTEXTFACTORY
@ -738,6 +720,12 @@ accepts a ``method`` parameter (this is the ``OpenSSL.SSL`` method mapping
parameter (``bool``) and a ``tls_ciphers`` parameter (see
:setting:`DOWNLOADER_CLIENT_TLS_CIPHERS`).
.. note::
This setting is specific to the built-in Twisted-based download handlers:
:class:`scrapy.core.downloader.handlers.http11.HTTP11DownloadHandler` and
:class:`scrapy.core.downloader.handlers.http2.H2DownloadHandler`.
.. setting:: DOWNLOADER_CLIENT_TLS_CIPHERS
DOWNLOADER_CLIENT_TLS_CIPHERS
@ -745,8 +733,8 @@ DOWNLOADER_CLIENT_TLS_CIPHERS
Default: ``'DEFAULT'``
Use this setting to customize the TLS/SSL ciphers used by the default
HTTP/1.1 downloader.
Use this setting to customize the TLS/SSL ciphers used by the HTTPS download
handler.
The setting should contain a string in the `OpenSSL cipher list format`_,
these ciphers will be used as client ciphers. Changing this setting may be
@ -756,6 +744,16 @@ specific cipher that is not included in ``DEFAULT`` if a website requires it.
.. _OpenSSL cipher list format: https://docs.openssl.org/master/man1/openssl-ciphers/#cipher-list-format
.. note::
Handling of this setting 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. Moreover, for the built-in Twisted-based
download handlers
(:class:`scrapy.core.downloader.handlers.http11.HTTP11DownloadHandler` and
:class:`scrapy.core.downloader.handlers.http2.H2DownloadHandler`) it needs
to be implemented in the :setting:`DOWNLOADER_CLIENTCONTEXTFACTORY` class.
.. setting:: DOWNLOADER_CLIENT_TLS_METHOD
DOWNLOADER_CLIENT_TLS_METHOD
@ -763,8 +761,8 @@ DOWNLOADER_CLIENT_TLS_METHOD
Default: ``'TLS'``
Use this setting to customize the TLS/SSL method used by the default
HTTP/1.1 downloader.
Use this setting to customize the TLS/SSL method used by the HTTPS download
handler.
This setting must be one of these string values:
@ -776,6 +774,15 @@ This setting must be one of these string values:
- ``'TLSv1.1'``: forces TLS version 1.1
- ``'TLSv1.2'``: forces TLS version 1.2
.. note::
Handling of this setting 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. Moreover, for the built-in Twisted-based
download handlers
(:class:`scrapy.core.downloader.handlers.http11.HTTP11DownloadHandler` and
:class:`scrapy.core.downloader.handlers.http2.H2DownloadHandler`) it needs
to be implemented in the :setting:`DOWNLOADER_CLIENTCONTEXTFACTORY` class.
.. setting:: DOWNLOADER_CLIENT_TLS_VERBOSE_LOGGING
@ -786,10 +793,18 @@ Default: ``False``
Setting this to ``True`` will enable DEBUG level messages about TLS connection
parameters after establishing HTTPS connections. The kind of information logged
depends on the versions of OpenSSL and pyOpenSSL.
depends on the implementation of the download handler and the versions of
the TLS-related libraries.
This setting is only used for the default
:setting:`DOWNLOADER_CLIENTCONTEXTFACTORY`.
.. note::
Handling of this setting 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. Moreover, for the built-in Twisted-based
download handlers
(:class:`scrapy.core.downloader.handlers.http11.HTTP11DownloadHandler` and
:class:`scrapy.core.downloader.handlers.http2.H2DownloadHandler`) it needs
to be implemented in the :setting:`DOWNLOADER_CLIENTCONTEXTFACTORY` class.
.. setting:: DOWNLOADER_MIDDLEWARES
@ -882,7 +897,6 @@ It is also possible to change this setting per domain, although it requires
non-trivial code. See the implementation of the :ref:`AutoThrottle
<topics-autothrottle>` extension for an example.
.. setting:: DOWNLOAD_HANDLERS
DOWNLOAD_HANDLERS
@ -890,7 +904,9 @@ DOWNLOAD_HANDLERS
Default: ``{}``
A dict containing the request downloader handlers enabled in your project.
A dict containing the :ref:`download handlers <topics-download-handlers>`
enabled in your project.
See :setting:`DOWNLOAD_HANDLERS_BASE` for example format.
.. setting:: DOWNLOAD_HANDLERS_BASE
@ -912,9 +928,9 @@ Default:
}
A dict containing the request download handlers enabled by default in Scrapy.
You should never modify this setting in your project, modify
:setting:`DOWNLOAD_HANDLERS` instead.
A dict containing the :ref:`download handlers <topics-download-handlers>`
enabled by default in Scrapy. You should never modify this setting in your
project, modify :setting:`DOWNLOAD_HANDLERS` instead.
You can disable any of these download handlers by assigning ``None`` to their
URI scheme in :setting:`DOWNLOAD_HANDLERS`. E.g., to disable the built-in FTP
@ -926,46 +942,6 @@ handler (without replacement), place this in your ``settings.py``:
"ftp": None,
}
.. _http2:
The default HTTPS handler uses HTTP/1.1. To use HTTP/2:
#. Install ``Twisted[http2]>=17.9.0`` to install the packages required to
enable HTTP/2 support in Twisted.
#. Update :setting:`DOWNLOAD_HANDLERS` as follows:
.. code-block:: python
DOWNLOAD_HANDLERS = {
"https": "scrapy.core.downloader.handlers.http2.H2DownloadHandler",
}
.. warning::
HTTP/2 support in Scrapy is experimental, and not yet recommended for
production environments. Future Scrapy versions may introduce related
changes without a deprecation period or warning.
.. note::
Known limitations of the current HTTP/2 implementation of Scrapy include:
- No support for HTTP/2 Cleartext (h2c), since no major browser supports
HTTP/2 unencrypted (refer `http2 faq`_).
- No setting to specify a maximum `frame size`_ larger than the default
value, 16384. Connections to servers that send a larger frame will
fail.
- No support for `server pushes`_, which are ignored.
- No support for the :signal:`bytes_received` and
:signal:`headers_received` signals.
.. _frame size: https://datatracker.ietf.org/doc/html/rfc7540#section-4.2
.. _http2 faq: https://http2.github.io/faq/#does-http2-require-encryption
.. _server pushes: https://datatracker.ietf.org/doc/html/rfc7540#section-8.2
.. setting:: DOWNLOAD_SLOTS
@ -1006,6 +982,12 @@ The amount of time (in secs) that the downloader will wait before timing out.
This timeout can be per-request using the :reqmeta:`download_timeout`
:attr:`.Request.meta` key.
.. note::
Handling of this setting 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.
.. setting:: DOWNLOAD_MAXSIZE
.. reqmeta:: download_maxsize
@ -1028,6 +1010,12 @@ Use ``0`` to disable this limit.
This limit can be set per-request using the :reqmeta:`download_maxsize`
:attr:`.Request.meta` key.
.. note::
Checking responses before decompressing them 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.
.. setting:: DOWNLOAD_WARNSIZE
.. reqmeta:: download_warnsize
@ -1046,6 +1034,12 @@ Use ``0`` to disable this limit.
This limit can be set per-request using the :reqmeta:`download_warnsize`
:attr:`.Request.meta` key.
.. note::
Checking responses before decompressing them 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.
.. setting:: DOWNLOAD_FAIL_ON_DATALOSS
DOWNLOAD_FAIL_ON_DATALOSS
@ -1072,11 +1066,17 @@ Optionally, this can be set per-request basis by using the
If :setting:`RETRY_ENABLED` is ``True`` and this setting is set to ``True``,
the ``ResponseFailed([_DataLoss])`` failure will be retried as usual.
.. note::
Handling of this setting 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.
.. warning::
This setting is ignored by the
:class:`~scrapy.core.downloader.handlers.http2.H2DownloadHandler`
download handler (see :setting:`DOWNLOAD_HANDLERS`). In case of a data loss
:ref:`download handler <topics-download-handlers>`. In case of a data loss
error, the corresponding HTTP/2 connection may be corrupted, affecting other
requests that use the same connection; hence, a ``ResponseFailed([InvalidBodyLengthError])``
failure is always raised for every request that was using that connection.
@ -1269,6 +1269,12 @@ Default: ``True``
Whether or not to use passive mode when initiating FTP transfers.
.. note::
Handling of this setting 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.
.. reqmeta:: ftp_password
.. setting:: FTP_PASSWORD
@ -1288,6 +1294,12 @@ in ``Request`` meta.
.. _RFC 1635: https://datatracker.ietf.org/doc/html/rfc1635
.. note::
Handling of this setting 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.
.. reqmeta:: ftp_user
.. setting:: FTP_USER
@ -1299,6 +1311,12 @@ Default: ``"anonymous"``
The username to use for FTP connections when there is no ``"ftp_user"``
in ``Request`` meta.
.. note::
Handling of this setting 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.
.. setting:: GCS_PROJECT_ID
GCS_PROJECT_ID

View File

@ -236,9 +236,7 @@ class Downloader:
slot.transferring.add(request)
try:
# 1. Download the response
response: Response = await maybe_deferred_to_future(
self.handlers.download_request(request)
)
response: Response = await self.handlers.download_request_async(request)
# 2. Notify response_downloaded listeners about the recent download
# before querying queue for next request
self.signals.send_catch_log(

View File

@ -2,16 +2,21 @@
from __future__ import annotations
import inspect
import logging
import warnings
from typing import TYPE_CHECKING, Any, Protocol, cast
from scrapy import Request, Spider, signals
from scrapy.exceptions import NotConfigured, NotSupported
from scrapy.utils.decorators import _warn_spider_arg
from scrapy.utils.defer import ensure_awaitable
from scrapy.exceptions import NotConfigured, NotSupported, ScrapyDeprecationWarning
from scrapy.utils.defer import (
deferred_from_coro,
ensure_awaitable,
maybe_deferred_to_future,
)
from scrapy.utils.httpobj import urlparse_cached
from scrapy.utils.misc import build_from_crawler, load_object
from scrapy.utils.python import without_none_values
from scrapy.utils.python import global_object_name, without_none_values
if TYPE_CHECKING:
from collections.abc import Callable
@ -25,10 +30,20 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
# This is the official API but we temporarily support the old deprecated one:
# * lazy is not mandatory (defaults to True).
# * download_request() can return a Deferred[Response] instead of a coroutine,
# and takes a spider argument in this case.
# * close() can return None or Deferred[None] instead of a coroutine.
# * close() is not mandatory.
class DownloadHandlerProtocol(Protocol):
def download_request(
self, request: Request, spider: Spider
) -> Deferred[Response]: ...
lazy: bool
async def download_request(self, request: Request) -> Response: ...
async def close(self) -> None: ...
class DownloadHandlers:
@ -40,6 +55,8 @@ class DownloadHandlers:
self._handlers: dict[str, DownloadHandlerProtocol] = {}
# remembers failed handlers
self._notconfigured: dict[str, str] = {}
# remembers handlers with Deferred-based download_request()
self._old_style_handlers: set[str] = set()
handlers: dict[str, str | Callable[..., Any]] = without_none_values(
cast(
"dict[str, str | Callable[..., Any]]",
@ -72,8 +89,17 @@ class DownloadHandlers:
path = self._schemes[scheme]
try:
dhcls: type[DownloadHandlerProtocol] = load_object(path)
if skip_lazy and getattr(dhcls, "lazy", True):
return None
if skip_lazy:
if not hasattr(dhcls, "lazy"):
warnings.warn(
f"{global_object_name(dhcls)} doesn't define a 'lazy' attribute."
f" This is deprecated, please add 'lazy = True' (which is the current"
f" default value) to the class definition.",
category=ScrapyDeprecationWarning,
stacklevel=1,
)
if getattr(dhcls, "lazy", True):
return None
dh = build_from_crawler(
dhcls,
self._crawler,
@ -91,12 +117,28 @@ class DownloadHandlers:
self._notconfigured[scheme] = str(ex)
return None
self._handlers[scheme] = dh
if not inspect.iscoroutinefunction(dh.download_request): # pragma: no cover
warnings.warn(
f"{global_object_name(dh.download_request)} is not a coroutine function."
f" This is deprecated, please rewrite it to return a coroutine and remove"
f" the 'spider' argument.",
category=ScrapyDeprecationWarning,
stacklevel=1,
)
self._old_style_handlers.add(scheme)
return dh
@_warn_spider_arg
def download_request(
self, request: Request, spider: Spider | None = None
) -> Deferred[Response]:
) -> Deferred[Response]: # pragma: no cover
warnings.warn(
"DownloadHandlers.download_request() is deprecated, use download_request_async() instead",
category=ScrapyDeprecationWarning,
stacklevel=2,
)
return deferred_from_coro(self.download_request_async(request))
async def download_request_async(self, request: Request) -> Response:
scheme = urlparse_cached(request).scheme
handler = self._get_handler(scheme)
if not handler:
@ -104,11 +146,33 @@ class DownloadHandlers:
f"Unsupported URL scheme '{scheme}': {self._notconfigured[scheme]}"
)
assert self._crawler.spider
return handler.download_request(request, self._crawler.spider)
if scheme in self._old_style_handlers: # pragma: no cover
return await maybe_deferred_to_future(
cast(
"Deferred[Response]",
handler.download_request(request, self._crawler.spider), # type: ignore[call-arg]
)
)
return await handler.download_request(request)
async def _close(self) -> None:
for dh in self._handlers.values():
if not hasattr(dh, "close"):
if not hasattr(dh, "close"): # pragma: no cover
warnings.warn(
f"{global_object_name(dh)} doesn't define a close() method."
f" This is deprecated, please add an empty 'async def close()' method.",
category=ScrapyDeprecationWarning,
stacklevel=1,
)
continue
await ensure_awaitable(dh.close())
if inspect.iscoroutinefunction(dh.close):
await dh.close()
else: # pragma: no cover
warnings.warn(
f"{global_object_name(dh.close)} is not a coroutine function."
f" This is deprecated, please rewrite it to return a coroutine.",
category=ScrapyDeprecationWarning,
stacklevel=1,
)
await ensure_awaitable(dh.close())

View File

@ -0,0 +1,32 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING
if TYPE_CHECKING:
# typing.Self requires Python 3.11
from typing_extensions import Self
from scrapy import Request
from scrapy.crawler import Crawler
from scrapy.http import Response
class BaseDownloadHandler(ABC):
"""Optional base class for download handlers."""
lazy: bool = False
def __init__(self, crawler: Crawler):
self.crawler = crawler
@classmethod
def from_crawler(cls, crawler: Crawler) -> Self:
return cls(crawler)
@abstractmethod
async def download_request(self, request: Request) -> Response:
raise NotImplementedError
async def close(self) -> None: # noqa: B027
pass

View File

@ -4,19 +4,16 @@ from typing import TYPE_CHECKING, Any
from w3lib.url import parse_data_uri
from scrapy.core.downloader.handlers.base import BaseDownloadHandler
from scrapy.http import Response, TextResponse
from scrapy.responsetypes import responsetypes
from scrapy.utils.decorators import defers
if TYPE_CHECKING:
from scrapy import Request, Spider
from scrapy import Request
class DataURIDownloadHandler:
lazy = False
@defers
def download_request(self, request: Request, spider: Spider) -> Response:
class DataURIDownloadHandler(BaseDownloadHandler):
async def download_request(self, request: Request) -> Response:
uri = parse_data_uri(request.url)
respcls = responsetypes.from_mimetype(uri.media_type)

View File

@ -5,19 +5,16 @@ from typing import TYPE_CHECKING
from w3lib.url import file_uri_to_path
from scrapy.core.downloader.handlers.base import BaseDownloadHandler
from scrapy.responsetypes import responsetypes
from scrapy.utils.decorators import defers
if TYPE_CHECKING:
from scrapy import Request, Spider
from scrapy import Request
from scrapy.http import Response
class FileDownloadHandler:
lazy = False
@defers
def download_request(self, request: Request, spider: Spider) -> Response:
class FileDownloadHandler(BaseDownloadHandler):
async def download_request(self, request: Request) -> Response:
filepath = file_uri_to_path(request.url)
body = Path(filepath).read_bytes()
respcls = responsetypes.from_args(filename=filepath, body=body)

View File

@ -33,27 +33,22 @@ from __future__ import annotations
import re
from io import BytesIO
from pathlib import Path
from typing import TYPE_CHECKING, Any, BinaryIO
from typing import TYPE_CHECKING, BinaryIO
from urllib.parse import unquote
from twisted.internet.protocol import ClientCreator, Protocol
from scrapy.core.downloader.handlers.base import BaseDownloadHandler
from scrapy.http import Response
from scrapy.responsetypes import responsetypes
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.defer import Deferred
from twisted.protocols.ftp import FTPClient
from twisted.python.failure import Failure
# typing.Self requires Python 3.11
from typing_extensions import Self
from scrapy import Request, Spider
from scrapy import Request
from scrapy.crawler import Crawler
from scrapy.settings import BaseSettings
class ReceivedDataProtocol(Protocol):
@ -82,26 +77,21 @@ class ReceivedDataProtocol(Protocol):
_CODE_RE = re.compile(r"\d+")
class FTPDownloadHandler:
lazy = False
class FTPDownloadHandler(BaseDownloadHandler):
CODE_MAPPING: dict[str, int] = {
"550": 404,
"default": 503,
}
def __init__(self, settings: BaseSettings):
self.default_user = settings["FTP_USER"]
self.default_password = settings["FTP_PASSWORD"]
self.passive_mode = settings["FTP_PASSIVE_MODE"]
def __init__(self, crawler: Crawler):
super().__init__(crawler)
self.default_user = crawler.settings["FTP_USER"]
self.default_password = crawler.settings["FTP_PASSWORD"]
self.passive_mode = crawler.settings["FTP_PASSIVE_MODE"]
@classmethod
def from_crawler(cls, crawler: Crawler) -> Self:
return cls(crawler.settings)
def download_request(self, request: Request, spider: Spider) -> Deferred[Response]:
async def download_request(self, request: Request) -> Response:
from twisted.internet import reactor
from twisted.protocols.ftp import FTPClient
from twisted.protocols.ftp import CommandFailed, FTPClient
parsed_url = urlparse_cached(request)
user = request.meta.get("ftp_user", self.default_user)
@ -112,43 +102,23 @@ class FTPDownloadHandler:
creator = ClientCreator(
reactor, FTPClient, user, password, passive=passive_mode
)
dfd: Deferred[FTPClient] = creator.connectTCP(
parsed_url.hostname, parsed_url.port or 21
client: FTPClient = await maybe_deferred_to_future(
creator.connectTCP(parsed_url.hostname, parsed_url.port or 21)
)
return dfd.addCallback(self.gotClient, request, unquote(parsed_url.path))
def gotClient(
self, client: FTPClient, request: Request, filepath: str
) -> Deferred[Response]:
self.client = client
filepath = unquote(parsed_url.path)
protocol = ReceivedDataProtocol(request.meta.get("ftp_local_filename"))
d = client.retrieveFile(filepath, protocol)
d.addCallback(self._build_response, request, protocol)
d.addErrback(self._failed, request)
return d
def _build_response(
self, result: Any, request: Request, protocol: ReceivedDataProtocol
) -> Response:
self.result = result
try:
await maybe_deferred_to_future(client.retrieveFile(filepath, protocol))
except CommandFailed as e:
message = str(e)
if m := _CODE_RE.search(message):
ftpcode = m.group()
httpcode = self.CODE_MAPPING.get(ftpcode, self.CODE_MAPPING["default"])
return Response(url=request.url, status=httpcode, body=message.encode())
raise
protocol.close()
headers = {"local filename": protocol.filename or b"", "size": protocol.size}
body = protocol.filename or protocol.body.read()
respcls = responsetypes.from_args(url=request.url, body=body)
# hints for Headers-related types may need to be fixed to not use AnyStr
return respcls(url=request.url, status=200, body=body, headers=headers) # type: ignore[arg-type]
def _failed(self, result: Failure, request: Request) -> Response:
from twisted.protocols.ftp import CommandFailed
message = result.getErrorMessage()
if result.type == CommandFailed:
m = _CODE_RE.search(message)
if m:
ftpcode = m.group()
httpcode = self.CODE_MAPPING.get(ftpcode, self.CODE_MAPPING["default"])
return Response(
url=request.url, status=httpcode, body=to_bytes(message)
)
assert result.type
raise result.type(result.value)

View File

@ -6,17 +6,17 @@ import warnings
from typing import TYPE_CHECKING
from scrapy.exceptions import 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.defer import Deferred
from twisted.internet.interfaces import IConnector
# typing.Self requires Python 3.11
from typing_extensions import Self
from scrapy import Request, Spider
from scrapy import Request
from scrapy.core.downloader.contextfactory import ScrapyClientContextFactory
from scrapy.core.downloader.webclient import ScrapyHTTPClientFactory
from scrapy.crawler import Crawler
@ -46,11 +46,10 @@ class HTTP10DownloadHandler:
def from_crawler(cls, crawler: Crawler) -> Self:
return cls(crawler.settings, crawler)
def download_request(self, request: Request, spider: Spider) -> Deferred[Response]:
"""Return a deferred for the HTTP download"""
async def download_request(self, request: Request) -> Response:
factory = self.HTTPClientFactory(request)
self._connect(factory)
return factory.deferred
return await maybe_deferred_to_future(factory.deferred)
def _connect(self, factory: ScrapyHTTPClientFactory) -> IConnector:
from twisted.internet import reactor
@ -63,3 +62,6 @@ class HTTP10DownloadHandler:
)
return reactor.connectSSL(host, port, factory, client_context_factory)
return reactor.connectTCP(host, port, factory)
async def close(self) -> None:
pass

View File

@ -30,11 +30,13 @@ from twisted.web.http_headers import Headers as TxHeaders
from twisted.web.iweb import UNKNOWN_LENGTH, IBodyProducer, IPolicyForHTTPS, IResponse
from zope.interface import implementer
from scrapy import Request, Spider, signals
from scrapy import Request, signals
from scrapy.core.downloader.contextfactory import load_context_factory_from_settings
from scrapy.core.downloader.handlers.base import BaseDownloadHandler
from scrapy.exceptions import StopDownload
from scrapy.http import Headers, Response
from scrapy.responsetypes import responsetypes
from scrapy.utils.defer import maybe_deferred_to_future
from scrapy.utils.deprecate import warn_on_deprecated_spider_attribute
from scrapy.utils.httpobj import urlparse_cached
from scrapy.utils.python import to_bytes, to_unicode
@ -44,11 +46,10 @@ if TYPE_CHECKING:
from twisted.internet.base import ReactorBase
from twisted.internet.interfaces import IConsumer
# typing.NotRequired and typing.Self require Python 3.11
from typing_extensions import NotRequired, Self
# typing.NotRequired requires Python 3.11
from typing_extensions import NotRequired
from scrapy.crawler import Crawler
from scrapy.settings import BaseSettings
logger = logging.getLogger(__name__)
@ -65,37 +66,34 @@ class _ResultT(TypedDict):
failure: NotRequired[Failure | None]
class HTTP11DownloadHandler:
lazy = False
def __init__(self, settings: BaseSettings, crawler: Crawler):
class HTTP11DownloadHandler(BaseDownloadHandler):
def __init__(self, crawler: Crawler):
super().__init__(crawler)
self._crawler = crawler
from twisted.internet import reactor
self._pool: HTTPConnectionPool = HTTPConnectionPool(reactor, persistent=True)
self._pool.maxPersistentPerHost = settings.getint(
self._pool.maxPersistentPerHost = crawler.settings.getint(
"CONCURRENT_REQUESTS_PER_DOMAIN"
)
self._pool._factory.noisy = False
self._contextFactory: IPolicyForHTTPS = load_context_factory_from_settings(
settings, crawler
crawler.settings, 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._default_maxsize: int = settings.getint("DOWNLOAD_MAXSIZE")
self._default_warnsize: int = settings.getint("DOWNLOAD_WARNSIZE")
self._fail_on_dataloss: bool = settings.getbool("DOWNLOAD_FAIL_ON_DATALOSS")
self._disconnect_timeout: int = 1
@classmethod
def from_crawler(cls, crawler: Crawler) -> Self:
return cls(crawler.settings, crawler)
def download_request(self, request: Request, spider: Spider) -> Deferred[Response]:
async def download_request(self, request: Request) -> Response:
"""Return a deferred for the HTTP download"""
if hasattr(spider, "download_maxsize"): # pragma: no cover
if hasattr(self._crawler.spider, "download_maxsize"): # pragma: no cover
warn_on_deprecated_spider_attribute("download_maxsize", "DOWNLOAD_MAXSIZE")
if hasattr(spider, "download_warnsize"): # pragma: no cover
if hasattr(self._crawler.spider, "download_warnsize"): # pragma: no cover
warn_on_deprecated_spider_attribute(
"download_warnsize", "DOWNLOAD_WARNSIZE"
)
@ -103,14 +101,18 @@ class HTTP11DownloadHandler:
agent = ScrapyAgent(
contextFactory=self._contextFactory,
pool=self._pool,
maxsize=getattr(spider, "download_maxsize", self._default_maxsize),
warnsize=getattr(spider, "download_warnsize", self._default_warnsize),
maxsize=getattr(
self._crawler.spider, "download_maxsize", self._default_maxsize
),
warnsize=getattr(
self._crawler.spider, "download_warnsize", self._default_warnsize
),
fail_on_dataloss=self._fail_on_dataloss,
crawler=self._crawler,
)
return agent.download_request(request)
return await maybe_deferred_to_future(agent.download_request(request))
def close(self) -> Deferred[None]:
async def close(self) -> None:
from twisted.internet import reactor
d: Deferred[None] = self._pool.closeCachedConnections()
@ -118,19 +120,19 @@ class HTTP11DownloadHandler:
# we'll manually timeout the deferred.
#
# Twisted issue addressing this problem can be found here:
# https://twistedmatrix.com/trac/ticket/7738.
# https://github.com/twisted/twisted/issues/7738
#
# closeCachedConnections doesn't handle external errbacks, so we'll
# issue a callback after `_disconnect_timeout` seconds.
#
# See also https://github.com/scrapy/scrapy/issues/2653
delayed_call = reactor.callLater(self._disconnect_timeout, d.callback, [])
def cancel_delayed_call(result: _T) -> _T:
try:
await maybe_deferred_to_future(d)
finally:
if delayed_call.active():
delayed_call.cancel()
return result
d.addBoth(cancel_delayed_call)
return d
class TunnelError(Exception):

View File

@ -8,7 +8,9 @@ from twisted.internet.error import TimeoutError as TxTimeoutError
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.utils.defer import maybe_deferred_to_future
from scrapy.utils.httpobj import urlparse_cached
from scrapy.utils.python import to_bytes
@ -17,37 +19,37 @@ if TYPE_CHECKING:
from twisted.internet.defer import Deferred
from twisted.web.iweb import IPolicyForHTTPS
# typing.Self requires Python 3.11
from typing_extensions import Self
from scrapy.crawler import Crawler
from scrapy.http import Request, Response
from scrapy.settings import Settings
from scrapy.spiders import Spider
class H2DownloadHandler:
def __init__(self, settings: Settings, crawler: Crawler):
class H2DownloadHandler(BaseDownloadHandler):
lazy = True
def __init__(self, crawler: Crawler):
super().__init__(crawler)
self._crawler = crawler
from twisted.internet import reactor
self._pool = H2ConnectionPool(reactor, settings)
self._context_factory = load_context_factory_from_settings(settings, crawler)
self._pool = H2ConnectionPool(reactor, crawler.settings)
self._context_factory = load_context_factory_from_settings(
crawler.settings, crawler
)
@classmethod
def from_crawler(cls, crawler: Crawler) -> Self:
return cls(crawler.settings, crawler)
def download_request(self, request: Request, spider: Spider) -> Deferred[Response]:
async def download_request(self, request: Request) -> Response:
agent = ScrapyH2Agent(
context_factory=self._context_factory,
pool=self._pool,
crawler=self._crawler,
)
return agent.download_request(request, spider)
assert self._crawler.spider
return await maybe_deferred_to_future(
agent.download_request(request, self._crawler.spider)
)
def close(self) -> None:
async def close(self) -> None:
self._pool.close_connections()

View File

@ -1,7 +1,8 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING
from scrapy.core.downloader.handlers.base import BaseDownloadHandler
from scrapy.core.downloader.handlers.http11 import HTTP11DownloadHandler
from scrapy.exceptions import NotConfigured
from scrapy.utils.boto import is_botocore_available
@ -9,57 +10,28 @@ from scrapy.utils.httpobj import urlparse_cached
from scrapy.utils.misc import build_from_crawler
if TYPE_CHECKING:
from twisted.internet.defer import Deferred
# typing.Self requires Python 3.11
from typing_extensions import Self
from scrapy import Request, Spider
from scrapy import Request
from scrapy.crawler import Crawler
from scrapy.http import Response
from scrapy.settings import BaseSettings
class S3DownloadHandler:
def __init__(
self,
settings: BaseSettings,
*,
crawler: Crawler,
aws_access_key_id: str | None = None,
aws_secret_access_key: str | None = None,
aws_session_token: str | None = None,
httpdownloadhandler: type[HTTP11DownloadHandler] = HTTP11DownloadHandler,
**kw: Any,
):
class S3DownloadHandler(BaseDownloadHandler):
lazy = True
def __init__(self, crawler: Crawler):
if not is_botocore_available():
raise NotConfigured("missing botocore library")
if not aws_access_key_id:
aws_access_key_id = settings["AWS_ACCESS_KEY_ID"]
if not aws_secret_access_key:
aws_secret_access_key = settings["AWS_SECRET_ACCESS_KEY"]
if not aws_session_token:
aws_session_token = settings["AWS_SESSION_TOKEN"]
# If no credentials could be found anywhere,
# consider this an anonymous connection request by default;
# unless 'anon' was set explicitly (True/False).
anon = kw.get("anon")
if anon is None and not aws_access_key_id and not aws_secret_access_key:
kw["anon"] = True
self.anon = kw.get("anon")
super().__init__(crawler)
aws_access_key_id = crawler.settings["AWS_ACCESS_KEY_ID"]
aws_secret_access_key = crawler.settings["AWS_SECRET_ACCESS_KEY"]
aws_session_token = crawler.settings["AWS_SESSION_TOKEN"]
self.anon = not aws_access_key_id and not aws_secret_access_key
self._signer = None
import botocore.auth # noqa: PLC0415
import botocore.credentials # noqa: PLC0415
kw.pop("anon", None)
if kw:
raise TypeError(f"Unexpected keyword arguments: {kw}")
if not self.anon:
assert aws_access_key_id is not None
assert aws_secret_access_key is not None
import botocore.auth # noqa: PLC0415
import botocore.credentials # noqa: PLC0415
SignerCls = botocore.auth.AUTH_TYPE_MAPS["s3"]
# botocore.auth.BaseSigner doesn't have an __init__() with args, only subclasses do
self._signer = SignerCls( # type: ignore[call-arg]
@ -68,17 +40,10 @@ class S3DownloadHandler:
)
)
_http_handler = build_from_crawler(
httpdownloadhandler,
crawler,
)
_http_handler = build_from_crawler(HTTP11DownloadHandler, crawler)
self._download_http = _http_handler.download_request
@classmethod
def from_crawler(cls, crawler: Crawler, **kwargs: Any) -> Self:
return cls(crawler.settings, crawler=crawler, **kwargs)
def download_request(self, request: Request, spider: Spider) -> Deferred[Response]:
async def download_request(self, request: Request) -> Response:
p = urlparse_cached(request)
scheme = "https" if request.meta.get("is_secure") else "http"
bucket = p.hostname
@ -98,4 +63,4 @@ class S3DownloadHandler:
assert self._signer
self._signer.add_auth(awsrequest)
request = request.replace(url=url, headers=awsrequest.headers.items())
return self._download_http(request, spider)
return await self._download_http(request)

View File

@ -15,10 +15,10 @@ from typing import TYPE_CHECKING, Any
from twisted.conch import telnet
from twisted.conch.insults import insults
from twisted.internet import protocol
from twisted.internet.defer import fail, succeed
from scrapy import signals
from scrapy.exceptions import NotConfigured
from scrapy.utils.decorators import defers
from scrapy.utils.engine import print_engine_status
from scrapy.utils.reactor import listen_tcp
from scrapy.utils.trackref import print_live_refs
@ -80,20 +80,19 @@ class TelnetConsole(protocol.ServerFactory):
class Portal:
"""An implementation of IPortal"""
@defers
def login(self_, credentials, mind, *interfaces): # pylint: disable=no-self-argument
if not (
credentials.username == self.username.encode("utf8")
and credentials.checkPassword(self.password.encode("utf8"))
):
raise ValueError("Invalid credentials")
return fail(ValueError("Invalid credentials"))
from twisted.conch import manhole
protocol = telnet.TelnetBootstrapProtocol(
insults.ServerProtocol, manhole.Manhole, self._get_telnet_vars()
)
return (interfaces[0], protocol, lambda: None)
return succeed((interfaces[0], protocol, lambda: None))
return telnet.TelnetTransport(telnet.AuthenticatingTelnetProtocol, Portal())

View File

@ -42,8 +42,13 @@ def deprecated(
return deco
def defers(func: Callable[_P, _T]) -> Callable[_P, Deferred[_T]]:
def defers(func: Callable[_P, _T]) -> Callable[_P, Deferred[_T]]: # pragma: no cover
"""Decorator to make sure a function always returns a deferred"""
warnings.warn(
"@defers is deprecated, you can use maybeDeferred() directly if needed.",
category=ScrapyDeprecationWarning,
stacklevel=2,
)
@wraps(func)
def wrapped(*a: _P.args, **kw: _P.kwargs) -> Deferred[_T]:

View File

@ -420,6 +420,12 @@ with multiples lines
self._assert_retried(caplog.text)
assert "Got response 200" in caplog.text
@deferred_f_from_coro_f
async def test_unknown_url_scheme(self, caplog: pytest.LogCaptureFixture) -> None:
crawler = get_crawler(SimpleSpider)
await maybe_deferred_to_future(crawler.crawl("foo://bar"))
assert "NotSupported: Unsupported URL scheme 'foo'" in caplog.text
class TestCrawlSpider:
mockserver: MockServer

View File

@ -2,6 +2,7 @@ from __future__ import annotations
import os
import sys
from abc import ABC, abstractmethod
from pathlib import Path
from tempfile import mkstemp
from typing import TYPE_CHECKING, Any
@ -13,17 +14,18 @@ from twisted.cred import checkers, credentials, portal
from scrapy.core.downloader.handlers.ftp import FTPDownloadHandler
from scrapy.http import HtmlResponse, Request, Response
from scrapy.http.response.text import TextResponse
from scrapy.utils.defer import deferred_f_from_coro_f, maybe_deferred_to_future
from scrapy.utils.defer import deferred_f_from_coro_f
from scrapy.utils.misc import build_from_crawler
from scrapy.utils.python import to_bytes
from scrapy.utils.spider import DefaultSpider
from scrapy.utils.test import get_crawler
if TYPE_CHECKING:
from collections.abc import AsyncGenerator, Generator
from twisted.protocols.ftp import FTPFactory
class TestFTPBase:
class TestFTPBase(ABC):
username = "scrapy"
password = "passwd"
req_meta: dict[str, Any] = {"ftp_user": username, "ftp_password": password}
@ -34,21 +36,13 @@ class TestFTPBase:
("html-file-without-extension", b"<!DOCTYPE html>\n<title>.</title>"),
)
@abstractmethod
def _create_files(self, root: Path) -> None:
userdir = root / self.username
userdir.mkdir()
for filename, content in self.test_files:
(userdir / filename).write_bytes(content)
raise NotImplementedError
def _get_factory(self, root):
from twisted.protocols.ftp import FTPFactory, FTPRealm
realm = FTPRealm(anonymousRoot=str(root), userHome=str(root))
p = portal.Portal(realm)
users_checker = checkers.InMemoryUsernamePasswordDatabaseDontUse()
users_checker.addUser(self.username, self.password)
p.registerChecker(users_checker, credentials.IUsernamePassword)
return FTPFactory(portal=p)
@abstractmethod
def _get_factory(self, tmp_path: Path) -> FTPFactory:
raise NotImplementedError
@async_yield_fixture
async def server_url(self, tmp_path: Path) -> AsyncGenerator[str]:
@ -76,18 +70,12 @@ class TestFTPBase:
assert dh.client.transport
dh.client.transport.loseConnection()
@staticmethod
async def download_request(dh: FTPDownloadHandler, request: Request) -> Response:
return await maybe_deferred_to_future(
dh.download_request(request, DefaultSpider())
)
@deferred_f_from_coro_f
async def test_ftp_download_success(
self, server_url: str, dh: FTPDownloadHandler
) -> None:
request = Request(url=server_url + "file.txt", meta=self.req_meta)
r = await self.download_request(dh, request)
r = await dh.download_request(request)
assert r.status == 200
assert r.body == b"I have the power!"
assert r.headers == {b"Local Filename": [b""], b"Size": [b"17"]}
@ -101,7 +89,7 @@ class TestFTPBase:
url=server_url + "file with spaces.txt",
meta=self.req_meta,
)
r = await self.download_request(dh, request)
r = await dh.download_request(request)
assert r.status == 200
assert r.body == b"Moooooooooo power!"
assert r.headers == {b"Local Filename": [b""], b"Size": [b"18"]}
@ -111,8 +99,9 @@ class TestFTPBase:
self, server_url: str, dh: FTPDownloadHandler
) -> None:
request = Request(url=server_url + "nonexistent.txt", meta=self.req_meta)
r = await self.download_request(dh, request)
r = await dh.download_request(request)
assert r.status == 404
assert r.body == b"['550 nonexistent.txt: No such file or directory.']"
@deferred_f_from_coro_f
async def test_ftp_local_filename(
@ -125,7 +114,7 @@ class TestFTPBase:
meta = {"ftp_local_filename": fname_bytes}
meta.update(self.req_meta)
request = Request(url=server_url + "file.txt", meta=meta)
r = await self.download_request(dh, request)
r = await dh.download_request(request)
assert r.body == fname_bytes
assert r.headers == {b"Local Filename": [fname_bytes], b"Size": [b"17"]}
assert local_path.exists()
@ -153,12 +142,28 @@ class TestFTPBase:
meta = {}
meta.update(self.req_meta)
request = Request(url=server_url + filename, meta=meta)
r = await self.download_request(dh, request)
r = await dh.download_request(request)
assert type(r) is response_class # pylint: disable=unidiomatic-typecheck
local_fname_path.unlink()
class TestFTP(TestFTPBase):
def _create_files(self, root: Path) -> None:
userdir = root / self.username
userdir.mkdir()
for filename, content in self.test_files:
(userdir / filename).write_bytes(content)
def _get_factory(self, root):
from twisted.protocols.ftp import FTPFactory, FTPRealm
realm = FTPRealm(anonymousRoot=str(root), userHome=str(root))
p = portal.Portal(realm)
users_checker = checkers.InMemoryUsernamePasswordDatabaseDontUse()
users_checker.addUser(self.username, self.password)
p.registerChecker(users_checker, credentials.IUsernamePassword)
return FTPFactory(portal=p)
@deferred_f_from_coro_f
async def test_invalid_credentials(
self, server_url: str, dh: FTPDownloadHandler, reactor_pytest: str
@ -174,7 +179,7 @@ class TestFTP(TestFTPBase):
meta.update({"ftp_password": "invalid"})
request = Request(url=server_url + "file.txt", meta=meta)
with pytest.raises(ConnectionLost):
await self.download_request(dh, request)
await dh.download_request(request)
class TestAnonymousFTP(TestFTPBase):

View File

@ -9,11 +9,7 @@ import pytest
from scrapy.core.downloader.handlers.http10 import HTTP10DownloadHandler
from scrapy.http import Request
from scrapy.utils.defer import deferred_f_from_coro_f
from tests.test_downloader_handlers_http_base import (
TestHttpBase,
TestHttpProxyBase,
download_request,
)
from tests.test_downloader_handlers_http_base import TestHttpBase, TestHttpProxyBase
if TYPE_CHECKING:
from scrapy.core.downloader.handlers import DownloadHandlerProtocol
@ -31,13 +27,12 @@ class TestHttp10(HTTP10DownloadHandlerMixin, TestHttpBase):
"""HTTP 1.0 test case"""
@deferred_f_from_coro_f
async def test_protocol(
self, mockserver: MockServer, download_handler: DownloadHandlerProtocol
) -> None:
async def test_protocol(self, mockserver: MockServer) -> None:
request = Request(
mockserver.url("/host", is_secure=self.is_secure), method="GET"
)
response = await download_request(download_handler, request)
async with self.get_dh() as download_handler:
response = await download_handler.download_request(request)
assert response.protocol == "HTTP/1.0"

View File

@ -13,10 +13,7 @@ from twisted.web.error import SchemeNotSupported
from twisted.web.http import H2_ENABLED
from scrapy.http import Request
from scrapy.spiders import Spider
from scrapy.utils.defer import deferred_f_from_coro_f, maybe_deferred_to_future
from scrapy.utils.misc import build_from_crawler
from scrapy.utils.test import get_crawler
from tests.test_downloader_handlers_http_base import (
TestHttpProxyBase,
TestHttps11Base,
@ -25,7 +22,6 @@ from tests.test_downloader_handlers_http_base import (
TestHttpsInvalidDNSPatternBase,
TestHttpsWrongHostnameBase,
TestHttpWithCrawlerBase,
download_request,
)
if TYPE_CHECKING:
@ -53,13 +49,12 @@ class TestHttps2(H2DownloadHandlerMixin, TestHttps11Base):
HTTP2_DATALOSS_SKIP_REASON = "Content-Length mismatch raises InvalidBodyLengthError"
@deferred_f_from_coro_f
async def test_protocol(
self, mockserver: MockServer, download_handler: DownloadHandlerProtocol
) -> None:
async def test_protocol(self, mockserver: MockServer) -> None:
request = Request(
mockserver.url("/host", is_secure=self.is_secure), method="GET"
)
response = await download_request(download_handler, request)
async with self.get_dh() as download_handler:
response = await download_handler.download_request(request)
assert response.protocol == "h2"
@deferred_f_from_coro_f
@ -68,9 +63,6 @@ class TestHttps2(H2DownloadHandlerMixin, TestHttps11Base):
) -> None:
from twisted.internet import reactor
crawler = get_crawler(settings_dict={"DOWNLOAD_MAXSIZE": 1_500})
download_handler = build_from_crawler(self.download_handler_cls, crawler)
with mock.patch("scrapy.core.http2.stream.logger") as logger:
request = Request(
mockserver.url("/largechunkedfile", is_secure=self.is_secure)
@ -79,8 +71,9 @@ class TestHttps2(H2DownloadHandlerMixin, TestHttps11Base):
def check(logger: mock.Mock) -> None:
logger.error.assert_called_once_with(mock.ANY)
with pytest.raises((defer.CancelledError, error.ConnectionAborted)):
await download_request(download_handler, request, Spider("foo"))
async with self.get_dh({"DOWNLOAD_MAXSIZE": 1_500}) as download_handler:
with pytest.raises((defer.CancelledError, error.ConnectionAborted)):
await download_handler.download_request(request)
# As the error message is logged in the dataReceived callback, we
# have to give a bit of time to the reactor to process the queue
@ -91,12 +84,11 @@ class TestHttps2(H2DownloadHandlerMixin, TestHttps11Base):
await maybe_deferred_to_future(d)
@deferred_f_from_coro_f
async def test_unsupported_scheme(
self, download_handler: DownloadHandlerProtocol
) -> None:
async def test_unsupported_scheme(self) -> None:
request = Request("ftp://unsupported.scheme")
with pytest.raises(SchemeNotSupported):
await download_request(download_handler, request)
async with self.get_dh() as download_handler:
with pytest.raises(SchemeNotSupported):
await download_handler.download_request(request)
def test_download_cause_data_loss(self) -> None: # type: ignore[override]
pytest.skip(self.HTTP2_DATALOSS_SKIP_REASON)
@ -109,49 +101,46 @@ class TestHttps2(H2DownloadHandlerMixin, TestHttps11Base):
@deferred_f_from_coro_f
async def test_concurrent_requests_same_domain(
self, mockserver: MockServer, download_handler: DownloadHandlerProtocol
self, mockserver: MockServer
) -> None:
request1 = Request(mockserver.url("/text", is_secure=self.is_secure))
response1 = await download_request(download_handler, request1)
assert response1.body == b"Works"
request2 = Request(
mockserver.url("/echo", is_secure=self.is_secure), method="POST"
)
response2 = await download_request(download_handler, request2)
assert response2.headers["Content-Length"] == b"79"
async with self.get_dh() as download_handler:
response1 = await download_handler.download_request(request1)
assert response1.body == b"Works"
response2 = await download_handler.download_request(request2)
assert response2.headers["Content-Length"] == b"79"
@pytest.mark.xfail(reason="https://github.com/python-hyper/h2/issues/1247")
@deferred_f_from_coro_f
async def test_connect_request(
self, mockserver: MockServer, download_handler: DownloadHandlerProtocol
) -> None:
async def test_connect_request(self, mockserver: MockServer) -> None:
request = Request(
mockserver.url("/file", is_secure=self.is_secure), method="CONNECT"
)
response = await download_request(download_handler, request)
async with self.get_dh() as download_handler:
response = await download_handler.download_request(request)
assert response.body == b""
@deferred_f_from_coro_f
async def test_custom_content_length_good(
self, mockserver: MockServer, download_handler: DownloadHandlerProtocol
) -> None:
async def test_custom_content_length_good(self, mockserver: MockServer) -> None:
request = Request(mockserver.url("/contentlength", is_secure=self.is_secure))
custom_content_length = str(len(request.body))
request.headers["Content-Length"] = custom_content_length
response = await download_request(download_handler, request)
async with self.get_dh() as download_handler:
response = await download_handler.download_request(request)
assert response.text == custom_content_length
@deferred_f_from_coro_f
async def test_custom_content_length_bad(
self, mockserver: MockServer, download_handler: DownloadHandlerProtocol
) -> None:
async def test_custom_content_length_bad(self, mockserver: MockServer) -> None:
request = Request(mockserver.url("/contentlength", is_secure=self.is_secure))
actual_content_length = str(len(request.body))
bad_content_length = str(len(request.body) + 1)
request.headers["Content-Length"] = bad_content_length
with LogCapture() as log:
response = await download_request(download_handler, request)
async with self.get_dh() as download_handler:
with LogCapture() as log:
response = await download_handler.download_request(request)
assert response.text == actual_content_length
log.check_present(
(
@ -164,14 +153,13 @@ class TestHttps2(H2DownloadHandlerMixin, TestHttps11Base):
)
@deferred_f_from_coro_f
async def test_duplicate_header(
self, mockserver: MockServer, download_handler: DownloadHandlerProtocol
) -> None:
async def test_duplicate_header(self, mockserver: MockServer) -> None:
request = Request(mockserver.url("/echo", is_secure=self.is_secure))
header, value1, value2 = "Custom-Header", "foo", "bar"
request.headers.appendlist(header, value1)
request.headers.appendlist(header, value2)
response = await download_request(download_handler, request)
async with self.get_dh() as download_handler:
response = await download_handler.download_request(request)
assert json.loads(response.text)["headers"][header] == [value1, value2]
@ -213,26 +201,18 @@ class TestHttps2Proxy(H2DownloadHandlerMixin, TestHttpProxyBase):
@deferred_f_from_coro_f
async def test_download_with_proxy_https_timeout(
self,
proxy_mockserver: ProxyEchoMockServer,
download_handler: DownloadHandlerProtocol,
self, proxy_mockserver: ProxyEchoMockServer
) -> None:
with pytest.raises(NotImplementedError):
await maybe_deferred_to_future(
super().test_download_with_proxy_https_timeout(
proxy_mockserver, download_handler
)
super().test_download_with_proxy_https_timeout(proxy_mockserver)
)
@deferred_f_from_coro_f
async def test_download_with_proxy_without_http_scheme(
self,
proxy_mockserver: ProxyEchoMockServer,
download_handler: DownloadHandlerProtocol,
self, proxy_mockserver: ProxyEchoMockServer
) -> None:
with pytest.raises(SchemeNotSupported):
await maybe_deferred_to_future(
super().test_download_with_proxy_without_http_scheme(
proxy_mockserver, download_handler
)
super().test_download_with_proxy_without_http_scheme(proxy_mockserver)
)

View File

@ -15,22 +15,26 @@ from scrapy.core.downloader.handlers import DownloadHandlers
from scrapy.core.downloader.handlers.datauri import DataURIDownloadHandler
from scrapy.core.downloader.handlers.file import FileDownloadHandler
from scrapy.core.downloader.handlers.s3 import S3DownloadHandler
from scrapy.exceptions import NotConfigured
from scrapy.http import Request, Response
from scrapy.exceptions import NotConfigured, ScrapyDeprecationWarning
from scrapy.http import Request
from scrapy.responsetypes import responsetypes
from scrapy.utils.defer import deferred_f_from_coro_f, maybe_deferred_to_future
from scrapy.utils.boto import is_botocore_available
from scrapy.utils.defer import deferred_f_from_coro_f
from scrapy.utils.misc import build_from_crawler
from scrapy.utils.spider import DefaultSpider
from scrapy.utils.test import get_crawler
class DummyDH:
lazy = False
async def download_request(self, request):
pass
class DummyLazyDH:
# Default is lazy for backward compatibility
pass
# Default (but deprecated) is lazy for backward compatibility
async def download_request(self, request):
pass
class OffDH:
@ -44,6 +48,17 @@ class OffDH:
return cls(crawler)
class BuggyDH:
lazy = False
def __init__(self, crawler):
raise ValueError
@classmethod
def from_crawler(cls, crawler):
return cls(crawler)
class TestLoad:
def test_enabled_handler(self):
handlers = {"scheme": DummyDH}
@ -61,6 +76,18 @@ class TestLoad:
assert "scheme" not in dh._handlers
assert "scheme" in dh._notconfigured
def test_buggy_handler(self, caplog: pytest.LogCaptureFixture) -> None:
handlers = {"scheme": BuggyDH}
crawler = get_crawler(settings_dict={"DOWNLOAD_HANDLERS": handlers})
dh = DownloadHandlers(crawler)
assert "scheme" in dh._schemes
assert "scheme" not in dh._handlers
assert "scheme" in dh._notconfigured
assert (
'Loading "<class \'tests.test_downloader_handlers.BuggyDH\'>" for scheme "scheme"'
in caplog.text
)
def test_disabled_handler(self):
handlers = {"scheme": None}
crawler = get_crawler(settings_dict={"DOWNLOAD_HANDLERS": handlers})
@ -74,7 +101,11 @@ class TestLoad:
def test_lazy_handlers(self):
handlers = {"scheme": DummyLazyDH}
crawler = get_crawler(settings_dict={"DOWNLOAD_HANDLERS": handlers})
dh = DownloadHandlers(crawler)
with pytest.warns(
ScrapyDeprecationWarning,
match="DummyLazyDH doesn't define a 'lazy' attribute",
):
dh = DownloadHandlers(crawler)
assert "scheme" in dh._schemes
assert "scheme" not in dh._handlers
for scheme in handlers: # force load lazy handler
@ -88,17 +119,13 @@ class TestFile:
# add a special char to check that they are handled correctly
self.fd, self.tmpname = mkstemp(suffix="^")
Path(self.tmpname).write_text("0123456789", encoding="utf-8")
self.download_handler = build_from_crawler(FileDownloadHandler, get_crawler())
download_handler = build_from_crawler(FileDownloadHandler, get_crawler())
self.download_request = download_handler.download_request
def teardown_method(self):
os.close(self.fd)
Path(self.tmpname).unlink()
async def download_request(self, request: Request) -> Response:
return await maybe_deferred_to_future(
self.download_handler.download_request(request, DefaultSpider())
)
@deferred_f_from_coro_f
async def test_download(self):
request = Request(path_to_file_uri(self.tmpname))
@ -121,7 +148,7 @@ class HttpDownloadHandlerMock:
def __init__(self, *args, **kwargs):
pass
def download_request(self, request, spider):
async def download_request(self, request):
return request
@ -129,18 +156,17 @@ class HttpDownloadHandlerMock:
class TestS3Anon:
def setup_method(self):
crawler = get_crawler()
self.s3reqh = build_from_crawler(
S3DownloadHandler,
crawler,
httpdownloadhandler=HttpDownloadHandlerMock,
# anon=True, # implicit
)
with mock.patch(
"scrapy.core.downloader.handlers.s3.HTTP11DownloadHandler",
HttpDownloadHandlerMock,
):
self.s3reqh = build_from_crawler(S3DownloadHandler, crawler)
self.download_request = self.s3reqh.download_request
self.spider = DefaultSpider()
def test_anon_request(self):
@deferred_f_from_coro_f
async def test_anon_request(self):
req = Request("s3://aws-publicdatasets/")
httpreq = self.download_request(req, self.spider)
httpreq = await self.download_request(req)
assert hasattr(self.s3reqh, "anon")
assert self.s3reqh.anon
assert httpreq.url == "http://aws-publicdatasets.s3.amazonaws.com/"
@ -148,26 +174,22 @@ class TestS3Anon:
@pytest.mark.requires_botocore
class TestS3:
download_handler_cls: type = S3DownloadHandler
# test use same example keys than amazon developer guide
# http://s3.amazonaws.com/awsdocs/S3/20060301/s3-dg-20060301.pdf
# and the tests described here are the examples from that manual
AWS_ACCESS_KEY_ID = "0PN5J17HBGZHT7JJ3X82"
AWS_SECRET_ACCESS_KEY = "uV3F3YluFJax1cknvbcGwgjvx4QpvB+leU8dUj2o"
def setup_method(self):
crawler = get_crawler()
s3reqh = build_from_crawler(
S3DownloadHandler,
crawler,
aws_access_key_id=self.AWS_ACCESS_KEY_ID,
aws_secret_access_key=self.AWS_SECRET_ACCESS_KEY,
httpdownloadhandler=HttpDownloadHandlerMock,
# test use same example keys than amazon developer guide
# http://s3.amazonaws.com/awsdocs/S3/20060301/s3-dg-20060301.pdf
# and the tests described here are the examples from that manual
crawler = get_crawler(
settings_dict={
"AWS_ACCESS_KEY_ID": "0PN5J17HBGZHT7JJ3X82",
"AWS_SECRET_ACCESS_KEY": "uV3F3YluFJax1cknvbcGwgjvx4QpvB+leU8dUj2o",
}
)
with mock.patch(
"scrapy.core.downloader.handlers.s3.HTTP11DownloadHandler",
HttpDownloadHandlerMock,
):
s3reqh = build_from_crawler(S3DownloadHandler, crawler)
self.download_request = s3reqh.download_request
self.spider = DefaultSpider()
@contextlib.contextmanager
def _mocked_date(self, date):
@ -183,27 +205,20 @@ class TestS3:
mock_formatdate.return_value = date
yield
def test_extra_kw(self):
crawler = get_crawler()
with pytest.raises((TypeError, NotConfigured)):
build_from_crawler(
S3DownloadHandler,
crawler,
extra_kw=True,
)
def test_request_signing1(self):
@deferred_f_from_coro_f
async def test_request_signing1(self):
# gets an object from the johnsmith bucket.
date = "Tue, 27 Mar 2007 19:36:42 +0000"
req = Request("s3://johnsmith/photos/puppy.jpg", headers={"Date": date})
with self._mocked_date(date):
httpreq = self.download_request(req, self.spider)
httpreq = await self.download_request(req)
assert (
httpreq.headers["Authorization"]
== b"AWS 0PN5J17HBGZHT7JJ3X82:xXjDGYUmKxnwqr5KXNPGldn5LbA="
)
def test_request_signing2(self):
@deferred_f_from_coro_f
async def test_request_signing2(self):
# puts an object into the johnsmith bucket.
date = "Tue, 27 Mar 2007 21:15:45 +0000"
req = Request(
@ -216,13 +231,14 @@ class TestS3:
},
)
with self._mocked_date(date):
httpreq = self.download_request(req, self.spider)
httpreq = await self.download_request(req)
assert (
httpreq.headers["Authorization"]
== b"AWS 0PN5J17HBGZHT7JJ3X82:hcicpDDvL9SsO6AkvxqmIWkmOuQ="
)
def test_request_signing3(self):
@deferred_f_from_coro_f
async def test_request_signing3(self):
# lists the content of the johnsmith bucket.
date = "Tue, 27 Mar 2007 19:42:41 +0000"
req = Request(
@ -234,24 +250,26 @@ class TestS3:
},
)
with self._mocked_date(date):
httpreq = self.download_request(req, self.spider)
httpreq = await self.download_request(req)
assert (
httpreq.headers["Authorization"]
== b"AWS 0PN5J17HBGZHT7JJ3X82:jsRt/rhG+Vtp88HrYL706QhE4w4="
)
def test_request_signing4(self):
@deferred_f_from_coro_f
async def test_request_signing4(self):
# fetches the access control policy sub-resource for the 'johnsmith' bucket.
date = "Tue, 27 Mar 2007 19:44:46 +0000"
req = Request("s3://johnsmith/?acl", method="GET", headers={"Date": date})
with self._mocked_date(date):
httpreq = self.download_request(req, self.spider)
httpreq = await self.download_request(req)
assert (
httpreq.headers["Authorization"]
== b"AWS 0PN5J17HBGZHT7JJ3X82:thdUi9VAkzhkniLj96JIrOPGi0g="
)
def test_request_signing6(self):
@deferred_f_from_coro_f
async def test_request_signing6(self):
# uploads an object to a CNAME style virtual hosted bucket with metadata.
date = "Tue, 27 Mar 2007 21:06:08 +0000"
req = Request(
@ -273,13 +291,14 @@ class TestS3:
},
)
with self._mocked_date(date):
httpreq = self.download_request(req, self.spider)
httpreq = await self.download_request(req)
assert (
httpreq.headers["Authorization"]
== b"AWS 0PN5J17HBGZHT7JJ3X82:C0FlOtU8Ylb9KDTpZqYkZPX91iI="
)
def test_request_signing7(self):
@deferred_f_from_coro_f
async def test_request_signing7(self):
# ensure that spaces are quoted properly before signing
date = "Tue, 27 Mar 2007 19:42:41 +0000"
req = Request(
@ -288,22 +307,25 @@ class TestS3:
headers={"Date": date},
)
with self._mocked_date(date):
httpreq = self.download_request(req, self.spider)
httpreq = await self.download_request(req)
assert (
httpreq.headers["Authorization"]
== b"AWS 0PN5J17HBGZHT7JJ3X82:+CfvG8EZ3YccOrRVMXNaK2eKZmM="
)
@pytest.mark.skipif(is_botocore_available(), reason="Requires not having botocore")
def test_s3_no_botocore() -> None:
crawler = get_crawler()
with pytest.raises(NotConfigured, match="missing botocore library"):
build_from_crawler(S3DownloadHandler, crawler)
class TestDataURI:
def setup_method(self):
crawler = get_crawler()
self.download_handler = build_from_crawler(DataURIDownloadHandler, crawler)
async def download_request(self, request: Request) -> Response:
return await maybe_deferred_to_future(
self.download_handler.download_request(request, DefaultSpider())
)
download_handler = build_from_crawler(DataURIDownloadHandler, crawler)
self.download_request = download_handler.download_request
@deferred_f_from_coro_f
async def test_response_attrs(self):

View File

@ -6,19 +6,18 @@ import gzip
import json
import sys
from abc import ABC, abstractmethod
from contextlib import asynccontextmanager
from http import HTTPStatus
from typing import TYPE_CHECKING, Any
from unittest import mock
import pytest
from pytest_twisted import async_yield_fixture
from testfixtures import LogCapture
from twisted.internet import defer, error
from twisted.web._newclient import ResponseFailed
from twisted.web.http import _DataLoss
from scrapy.http import Headers, HtmlResponse, Request, Response, TextResponse
from scrapy.spiders import Spider
from scrapy.utils.asyncio import call_later
from scrapy.utils.defer import (
deferred_f_from_coro_f,
@ -40,27 +39,6 @@ if TYPE_CHECKING:
from tests.mockserver.http import MockServer
async def download_request(
download_handler: DownloadHandlerProtocol,
request: Request,
spider: Spider = DefaultSpider(),
) -> Response:
return await maybe_deferred_to_future(
download_handler.download_request(request, spider)
)
async def close_dh(dh: DownloadHandlerProtocol) -> None:
# needed because the interface of close() is not clearly defined
if not hasattr(dh, "close"):
return
c = dh.close()
if c is None:
return
# covers coroutines and Deferreds; won't work if close() uses Futures inside
await c
class TestHttpBase(ABC):
is_secure = False
@ -69,30 +47,32 @@ class TestHttpBase(ABC):
def download_handler_cls(self) -> type[DownloadHandlerProtocol]:
raise NotImplementedError
@async_yield_fixture
async def download_handler(self) -> AsyncGenerator[DownloadHandlerProtocol]:
dh = build_from_crawler(self.download_handler_cls, get_crawler())
yield dh
await close_dh(dh)
@asynccontextmanager
async def get_dh(
self, settings_dict: dict[str, Any] | None = None
) -> AsyncGenerator[DownloadHandlerProtocol]:
crawler = get_crawler(DefaultSpider, settings_dict)
crawler.spider = crawler._create_spider()
dh = build_from_crawler(self.download_handler_cls, crawler)
try:
yield dh
finally:
await dh.close()
@deferred_f_from_coro_f
async def test_download(
self, mockserver: MockServer, download_handler: DownloadHandlerProtocol
) -> None:
async def test_download(self, mockserver: MockServer) -> None:
request = Request(mockserver.url("/text", is_secure=self.is_secure))
response = await download_request(download_handler, request)
async with self.get_dh() as download_handler:
response = await download_handler.download_request(request)
assert response.body == b"Works"
@deferred_f_from_coro_f
async def test_download_head(
self, mockserver: MockServer, download_handler: DownloadHandlerProtocol
) -> None:
async def test_download_head(self, mockserver: MockServer) -> None:
request = Request(
mockserver.url("/text", is_secure=self.is_secure), method="HEAD"
)
response = await download_request(download_handler, request)
async with self.get_dh() as download_handler:
response = await download_handler.download_request(request)
assert response.body == b""
@pytest.mark.parametrize(
@ -105,22 +85,18 @@ class TestHttpBase(ABC):
)
@deferred_f_from_coro_f
async def test_download_has_correct_http_status_code(
self,
mockserver: MockServer,
download_handler: DownloadHandlerProtocol,
http_status: HTTPStatus,
self, mockserver: MockServer, http_status: HTTPStatus
) -> None:
request = Request(
mockserver.url(f"/status?n={http_status.value}", is_secure=self.is_secure)
)
response = await download_request(download_handler, request)
async with self.get_dh() as download_handler:
response = await download_handler.download_request(request)
assert response.status == http_status.value
@deferred_f_from_coro_f
async def test_server_receives_correct_request_headers(
self,
mockserver: MockServer,
download_handler: DownloadHandlerProtocol,
self, mockserver: MockServer
) -> None:
request_headers = {
# common request headers
@ -136,7 +112,8 @@ class TestHttpBase(ABC):
mockserver.url("/echo", is_secure=self.is_secure),
headers=request_headers,
)
response = await download_request(download_handler, request)
async with self.get_dh() as download_handler:
response = await download_handler.download_request(request)
assert response.status == HTTPStatus.OK
body = json.loads(response.body.decode("utf-8"))
assert "headers" in body
@ -146,9 +123,7 @@ class TestHttpBase(ABC):
@deferred_f_from_coro_f
async def test_server_receives_correct_request_body(
self,
mockserver: MockServer,
download_handler: DownloadHandlerProtocol,
self, mockserver: MockServer
) -> None:
request_body = {
"message": "It works!",
@ -157,16 +132,15 @@ class TestHttpBase(ABC):
mockserver.url("/echo", is_secure=self.is_secure),
body=json.dumps(request_body),
)
response = await download_request(download_handler, request)
async with self.get_dh() as download_handler:
response = await download_handler.download_request(request)
assert response.status == HTTPStatus.OK
body = json.loads(response.body.decode("utf-8"))
assert json.loads(body["body"]) == request_body
@deferred_f_from_coro_f
async def test_download_has_correct_response_headers(
self,
mockserver: MockServer,
download_handler: DownloadHandlerProtocol,
self, mockserver: MockServer
) -> None:
# these headers will be set on the response in the resource and returned
response_headers = {
@ -192,7 +166,8 @@ class TestHttpBase(ABC):
headers={"content-type": "application/json"},
body=json.dumps(response_headers),
)
response = await download_request(download_handler, request)
async with self.get_dh() as download_handler:
response = await download_handler.download_request(request)
assert response.status == 200
for header_name, header_value in response_headers.items():
assert header_name in response.headers, (
@ -203,29 +178,24 @@ class TestHttpBase(ABC):
)
@deferred_f_from_coro_f
async def test_redirect_status(
self, mockserver: MockServer, download_handler: DownloadHandlerProtocol
) -> None:
async def test_redirect_status(self, mockserver: MockServer) -> None:
request = Request(mockserver.url("/redirect", is_secure=self.is_secure))
response = await download_request(download_handler, request)
async with self.get_dh() as download_handler:
response = await download_handler.download_request(request)
assert response.status == 302
@deferred_f_from_coro_f
async def test_redirect_status_head(
self, mockserver: MockServer, download_handler: DownloadHandlerProtocol
) -> None:
async def test_redirect_status_head(self, mockserver: MockServer) -> None:
request = Request(
mockserver.url("/redirect", is_secure=self.is_secure), method="HEAD"
)
response = await download_request(download_handler, request)
async with self.get_dh() as download_handler:
response = await download_handler.download_request(request)
assert response.status == 302
@deferred_f_from_coro_f
async def test_timeout_download_from_spider_nodata_rcvd(
self,
mockserver: MockServer,
download_handler: DownloadHandlerProtocol,
reactor_pytest: str,
self, mockserver: MockServer, reactor_pytest: str
) -> None:
if reactor_pytest == "asyncio" and sys.platform == "win32":
# https://twistedmatrix.com/trac/ticket/10279
@ -236,15 +206,15 @@ class TestHttpBase(ABC):
# client connects but no data is received
meta = {"download_timeout": 0.5}
request = Request(mockserver.url("/wait", is_secure=self.is_secure), meta=meta)
d = deferred_from_coro(download_request(download_handler, request))
with pytest.raises((defer.TimeoutError, error.TimeoutError)):
await maybe_deferred_to_future(d)
async with self.get_dh() as download_handler:
d = deferred_from_coro(download_handler.download_request(request))
with pytest.raises((defer.TimeoutError, error.TimeoutError)):
await maybe_deferred_to_future(d)
@deferred_f_from_coro_f
async def test_timeout_download_from_spider_server_hangs(
self,
mockserver: MockServer,
download_handler: DownloadHandlerProtocol,
reactor_pytest: str,
) -> None:
if reactor_pytest == "asyncio" and sys.platform == "win32":
@ -257,24 +227,21 @@ class TestHttpBase(ABC):
request = Request(
mockserver.url("/hang-after-headers", is_secure=self.is_secure), meta=meta
)
d = deferred_from_coro(download_request(download_handler, request))
with pytest.raises((defer.TimeoutError, error.TimeoutError)):
await maybe_deferred_to_future(d)
async with self.get_dh() as download_handler:
d = deferred_from_coro(download_handler.download_request(request))
with pytest.raises((defer.TimeoutError, error.TimeoutError)):
await maybe_deferred_to_future(d)
@pytest.mark.parametrize("send_header", [True, False])
@deferred_f_from_coro_f
async def test_host_header(
self,
send_header: bool,
mockserver: MockServer,
download_handler: DownloadHandlerProtocol,
) -> None:
async def test_host_header(self, send_header: bool, mockserver: MockServer) -> None:
host_port = f"{mockserver.host}:{mockserver.port(is_secure=self.is_secure)}"
request = Request(
mockserver.url("/host", is_secure=self.is_secure),
headers={"Host": host_port} if send_header else {},
)
response = await download_request(download_handler, request)
async with self.get_dh() as download_handler:
response = await download_handler.download_request(request)
assert response.body == host_port.encode()
if send_header:
assert request.headers.get("Host") == host_port.encode()
@ -283,7 +250,7 @@ class TestHttpBase(ABC):
@deferred_f_from_coro_f
async def test_content_length_zero_bodyless_post_request_headers(
self, mockserver: MockServer, download_handler: DownloadHandlerProtocol
self, mockserver: MockServer
) -> None:
"""Tests if "Content-Length: 0" is sent for bodyless POST requests.
@ -298,43 +265,43 @@ class TestHttpBase(ABC):
request = Request(
mockserver.url("/contentlength", is_secure=self.is_secure), method="POST"
)
response = await download_request(download_handler, request)
async with self.get_dh() as download_handler:
response = await download_handler.download_request(request)
assert response.body == b"0"
@deferred_f_from_coro_f
async def test_content_length_zero_bodyless_post_only_one(
self, mockserver: MockServer, download_handler: DownloadHandlerProtocol
self, mockserver: MockServer
) -> None:
request = Request(
mockserver.url("/echo", is_secure=self.is_secure), method="POST"
)
response = await download_request(download_handler, request)
async with self.get_dh() as download_handler:
response = await download_handler.download_request(request)
headers = Headers(json.loads(response.text)["headers"])
contentlengths = headers.getlist("Content-Length")
assert len(contentlengths) == 1
assert contentlengths == [b"0"]
@deferred_f_from_coro_f
async def test_payload(
self, mockserver: MockServer, download_handler: DownloadHandlerProtocol
) -> None:
async def test_payload(self, mockserver: MockServer) -> None:
body = b"1" * 100 # PayloadResource requires body length to be 100
request = Request(
mockserver.url("/payload", is_secure=self.is_secure),
method="POST",
body=body,
)
response = await download_request(download_handler, request)
async with self.get_dh() as download_handler:
response = await download_handler.download_request(request)
assert response.body == body
@deferred_f_from_coro_f
async def test_response_header_content_length(
self, mockserver: MockServer, download_handler: DownloadHandlerProtocol
) -> None:
async def test_response_header_content_length(self, mockserver: MockServer) -> None:
request = Request(
mockserver.url("/text", is_secure=self.is_secure), method="GET"
)
response = await download_request(download_handler, request)
async with self.get_dh() as download_handler:
response = await download_handler.download_request(request)
assert response.headers[b"content-length"] == b"5"
@pytest.mark.parametrize(
@ -351,25 +318,24 @@ class TestHttpBase(ABC):
body: bytes,
response_class: type[Response],
mockserver: MockServer,
download_handler: DownloadHandlerProtocol,
) -> None:
request = Request(
mockserver.url(f"/{filename}", is_secure=self.is_secure), body=body
)
response = await download_request(download_handler, request)
async with self.get_dh() as download_handler:
response = await download_handler.download_request(request)
assert type(response) is response_class # pylint: disable=unidiomatic-typecheck
@deferred_f_from_coro_f
async def test_get_duplicate_header(
self, mockserver: MockServer, download_handler: DownloadHandlerProtocol
) -> None:
async def test_get_duplicate_header(self, mockserver: MockServer) -> None:
request = Request(mockserver.url("/duplicate-header", is_secure=self.is_secure))
response = await download_request(download_handler, request)
async with self.get_dh() as download_handler:
response = await download_handler.download_request(request)
assert response.headers.getlist(b"Set-Cookie") == [b"a=b", b"c=d"]
@deferred_f_from_coro_f
async def test_download_is_not_automatically_gzip_decoded(
self, download_handler: DownloadHandlerProtocol, mockserver: MockServer
self, mockserver: MockServer
) -> None:
"""Test download handler does not automatically decode content using the scheme provided in Content-Encoding header"""
@ -382,7 +348,8 @@ class TestHttpBase(ABC):
"accept-encoding": "gzip",
},
)
response = await download_request(download_handler, request)
async with self.get_dh() as download_handler:
response = await download_handler.download_request(request)
assert response.status == 200
@ -402,7 +369,7 @@ class TestHttpBase(ABC):
@deferred_f_from_coro_f
async def test_no_cookie_processing_or_persistence(
self, mockserver: MockServer, download_handler: DownloadHandlerProtocol
self, mockserver: MockServer
) -> None:
cookie_name = "foo"
cookie_value = "bar"
@ -413,34 +380,34 @@ class TestHttpBase(ABC):
f"/set-cookie?{cookie_name}={cookie_value}", is_secure=self.is_secure
)
)
response = await download_request(download_handler, request)
assert response.status == 200
set_cookie = response.headers.get(b"Set-Cookie")
assert set_cookie == f"{cookie_name}={cookie_value}".encode()
async with self.get_dh() as download_handler:
response = await download_handler.download_request(request)
assert response.status == 200
set_cookie = response.headers.get(b"Set-Cookie")
assert set_cookie == f"{cookie_name}={cookie_value}".encode()
# check that cookies are not sent in the next request
request = Request(mockserver.url("/echo", is_secure=self.is_secure))
response = await download_request(download_handler, request)
assert response.status == 200
headers = Headers(json.loads(response.text)["headers"])
assert "Cookie" not in headers
assert "cookie" not in headers
# check that cookies are not sent in the next request
request = Request(mockserver.url("/echo", is_secure=self.is_secure))
response = await download_handler.download_request(request)
assert response.status == 200
headers = Headers(json.loads(response.text)["headers"])
assert "Cookie" not in headers
assert "cookie" not in headers
class TestHttp11Base(TestHttpBase):
"""HTTP 1.1 test case"""
@deferred_f_from_coro_f
async def test_download_without_maxsize_limit(
self, mockserver: MockServer, download_handler: DownloadHandlerProtocol
) -> None:
async def test_download_without_maxsize_limit(self, mockserver: MockServer) -> None:
request = Request(mockserver.url("/text", is_secure=self.is_secure))
response = await download_request(download_handler, request)
async with self.get_dh() as download_handler:
response = await download_handler.download_request(request)
assert response.body == b"Works"
@deferred_f_from_coro_f
async def test_response_class_choosing_request(
self, mockserver: MockServer, download_handler: DownloadHandlerProtocol
self, mockserver: MockServer
) -> None:
"""Tests choosing of correct response type
in case of Content-Type is empty but body contains text.
@ -449,7 +416,8 @@ class TestHttp11Base(TestHttpBase):
request = Request(
mockserver.url("/nocontenttype", is_secure=self.is_secure), body=body
)
response = await download_request(download_handler, request)
async with self.get_dh() as download_handler:
response = await download_handler.download_request(request)
assert type(response) is TextResponse # pylint: disable=unidiomatic-typecheck
@deferred_f_from_coro_f
@ -458,24 +426,19 @@ class TestHttp11Base(TestHttpBase):
# 10 is minimal size for this request and the limit is only counted on
# response body. (regardless of headers)
crawler = get_crawler(settings_dict={"DOWNLOAD_MAXSIZE": 5})
download_handler = build_from_crawler(self.download_handler_cls, crawler)
response = await download_request(download_handler, request, Spider("foo"))
async with self.get_dh({"DOWNLOAD_MAXSIZE": 5}) as download_handler:
response = await download_handler.download_request(request)
assert response.body == b"Works"
crawler = get_crawler(settings_dict={"DOWNLOAD_MAXSIZE": 4})
download_handler = build_from_crawler(self.download_handler_cls, crawler)
with pytest.raises((defer.CancelledError, error.ConnectionAborted)):
await download_request(download_handler, request, Spider("foo"))
async with self.get_dh({"DOWNLOAD_MAXSIZE": 4}) as download_handler:
with pytest.raises((defer.CancelledError, error.ConnectionAborted)):
await download_handler.download_request(request)
@deferred_f_from_coro_f
async def test_download_with_maxsize_very_large_file(
self, mockserver: MockServer
) -> None:
# TODO: the logger check is specific to scrapy.core.downloader.handlers.http11
crawler = get_crawler(settings_dict={"DOWNLOAD_MAXSIZE": 1_500})
download_handler = build_from_crawler(self.download_handler_cls, crawler)
with mock.patch("scrapy.core.downloader.handlers.http11.logger") as logger:
request = Request(
mockserver.url("/largechunkedfile", is_secure=self.is_secure)
@ -484,8 +447,9 @@ class TestHttp11Base(TestHttpBase):
def check(logger: mock.Mock) -> None:
logger.warning.assert_called_once_with(mock.ANY, mock.ANY)
with pytest.raises((defer.CancelledError, error.ConnectionAborted)):
await download_request(download_handler, request, Spider("foo"))
async with self.get_dh({"DOWNLOAD_MAXSIZE": 1_500}) as download_handler:
with pytest.raises((defer.CancelledError, error.ConnectionAborted)):
await download_handler.download_request(request)
# As the error message is logged in the dataReceived callback, we
# have to give a bit of time to the reactor to process the queue
@ -496,69 +460,61 @@ class TestHttp11Base(TestHttpBase):
await maybe_deferred_to_future(d)
@deferred_f_from_coro_f
async def test_download_with_maxsize_per_req(
self, mockserver: MockServer, download_handler: DownloadHandlerProtocol
) -> None:
async def test_download_with_maxsize_per_req(self, mockserver: MockServer) -> None:
meta = {"download_maxsize": 2}
request = Request(mockserver.url("/text", is_secure=self.is_secure), meta=meta)
with pytest.raises((defer.CancelledError, error.ConnectionAborted)):
await download_request(download_handler, request)
async with self.get_dh() as download_handler:
with pytest.raises((defer.CancelledError, error.ConnectionAborted)):
await download_handler.download_request(request)
@deferred_f_from_coro_f
async def test_download_with_small_maxsize_via_setting(
self, mockserver: MockServer
) -> None:
crawler = get_crawler(settings_dict={"DOWNLOAD_MAXSIZE": 2})
download_handler = build_from_crawler(self.download_handler_cls, crawler)
request = Request(mockserver.url("/text", is_secure=self.is_secure))
with pytest.raises((defer.CancelledError, error.ConnectionAborted)):
await download_request(download_handler, request, Spider("foo"))
async with self.get_dh({"DOWNLOAD_MAXSIZE": 2}) as download_handler:
with pytest.raises((defer.CancelledError, error.ConnectionAborted)):
await download_handler.download_request(request)
@deferred_f_from_coro_f
async def test_download_with_large_maxsize_via_setting(
self, mockserver: MockServer
) -> None:
crawler = get_crawler(settings_dict={"DOWNLOAD_MAXSIZE": 5})
download_handler = build_from_crawler(self.download_handler_cls, crawler)
request = Request(mockserver.url("/text", is_secure=self.is_secure))
response = await download_request(download_handler, request, Spider("foo"))
async with self.get_dh({"DOWNLOAD_MAXSIZE": 100}) as download_handler:
response = await download_handler.download_request(request)
assert response.body == b"Works"
@deferred_f_from_coro_f
async def test_download_chunked_content(
self, mockserver: MockServer, download_handler: DownloadHandlerProtocol
) -> None:
async def test_download_chunked_content(self, mockserver: MockServer) -> None:
request = Request(mockserver.url("/chunked", is_secure=self.is_secure))
response = await download_request(download_handler, request)
async with self.get_dh() as download_handler:
response = await download_handler.download_request(request)
assert response.body == b"chunked content\n"
@pytest.mark.parametrize("url", ["broken", "broken-chunked"])
@deferred_f_from_coro_f
async def test_download_cause_data_loss(
self,
url: str,
mockserver: MockServer,
download_handler: DownloadHandlerProtocol,
self, url: str, mockserver: MockServer
) -> None:
# TODO: this one checks for Twisted-specific exceptions
request = Request(mockserver.url(f"/{url}", is_secure=self.is_secure))
with pytest.raises(ResponseFailed) as exc_info:
await download_request(download_handler, request)
async with self.get_dh() as download_handler:
with pytest.raises(ResponseFailed) as exc_info:
await download_handler.download_request(request)
assert any(r.check(_DataLoss) for r in exc_info.value.reasons)
@pytest.mark.parametrize("url", ["broken", "broken-chunked"])
@deferred_f_from_coro_f
async def test_download_allow_data_loss(
self,
url: str,
mockserver: MockServer,
download_handler: DownloadHandlerProtocol,
self, url: str, mockserver: MockServer
) -> None:
request = Request(
mockserver.url(f"/{url}", is_secure=self.is_secure),
meta={"download_fail_on_dataloss": False},
)
response = await download_request(download_handler, request)
async with self.get_dh() as download_handler:
response = await download_handler.download_request(request)
assert response.flags == ["dataloss"]
@pytest.mark.parametrize("url", ["broken", "broken-chunked"])
@ -566,27 +522,20 @@ class TestHttp11Base(TestHttpBase):
async def test_download_allow_data_loss_via_setting(
self, url: str, mockserver: MockServer
) -> None:
crawler = get_crawler(settings_dict={"DOWNLOAD_FAIL_ON_DATALOSS": False})
download_handler = build_from_crawler(self.download_handler_cls, crawler)
request = Request(mockserver.url(f"/{url}", is_secure=self.is_secure))
try:
response = await maybe_deferred_to_future(
download_handler.download_request(request, DefaultSpider())
)
finally:
d = download_handler.close() # type: ignore[attr-defined]
if d is not None:
await maybe_deferred_to_future(d)
async with self.get_dh(
{"DOWNLOAD_FAIL_ON_DATALOSS": False}
) as download_handler:
response = await download_handler.download_request(request)
assert response.flags == ["dataloss"]
@deferred_f_from_coro_f
async def test_protocol(
self, mockserver: MockServer, download_handler: DownloadHandlerProtocol
) -> None:
async def test_protocol(self, mockserver: MockServer) -> None:
request = Request(
mockserver.url("/host", is_secure=self.is_secure), method="GET"
)
response = await download_request(download_handler, request)
async with self.get_dh() as download_handler:
response = await download_handler.download_request(request)
assert response.protocol == "HTTP/1.1"
@ -600,24 +549,16 @@ class TestHttps11Base(TestHttp11Base):
@deferred_f_from_coro_f
async def test_tls_logging(self, mockserver: MockServer) -> None:
crawler = get_crawler(
settings_dict={"DOWNLOADER_CLIENT_TLS_VERBOSE_LOGGING": True}
)
download_handler = build_from_crawler(self.download_handler_cls, crawler)
try:
request = Request(mockserver.url("/text", is_secure=self.is_secure))
async with self.get_dh(
{"DOWNLOADER_CLIENT_TLS_VERBOSE_LOGGING": True}
) as download_handler:
with LogCapture() as log_capture:
request = Request(mockserver.url("/text", is_secure=self.is_secure))
response = await maybe_deferred_to_future(
download_handler.download_request(request, DefaultSpider())
)
assert response.body == b"Works"
log_capture.check_present(
("scrapy.core.downloader.tls", "DEBUG", self.tls_log_message)
)
finally:
d = download_handler.close() # type: ignore[attr-defined]
if d is not None:
await maybe_deferred_to_future(d)
response = await download_handler.download_request(request)
assert response.body == b"Works"
log_capture.check_present(
("scrapy.core.downloader.tls", "DEBUG", self.tls_log_message)
)
class TestSimpleHttpsBase(ABC):
@ -645,25 +586,25 @@ class TestSimpleHttpsBase(ABC):
def download_handler_cls(self) -> type[DownloadHandlerProtocol]:
raise NotImplementedError
@async_yield_fixture
async def download_handler(self) -> AsyncGenerator[DownloadHandlerProtocol]:
@asynccontextmanager
async def get_dh(self) -> AsyncGenerator[DownloadHandlerProtocol]:
if self.cipher_string is not None:
settings_dict = {"DOWNLOADER_CLIENT_TLS_CIPHERS": self.cipher_string}
else:
settings_dict = None
crawler = get_crawler(settings_dict=settings_dict)
crawler = get_crawler(DefaultSpider, settings_dict=settings_dict)
crawler.spider = crawler._create_spider()
dh = build_from_crawler(self.download_handler_cls, crawler)
yield dh
await close_dh(dh)
try:
yield dh
finally:
await dh.close()
@deferred_f_from_coro_f
async def test_download(
self, url: str, download_handler: DownloadHandlerProtocol
) -> None:
async def test_download(self, url: str) -> None:
request = Request(url)
response = await download_request(download_handler, request)
async with self.get_dh() as download_handler:
response = await download_handler.download_request(request)
assert response.body == b"0123456789"
@ -748,65 +689,63 @@ class TestHttpProxyBase(ABC):
with ProxyEchoMockServer() as proxy:
yield proxy
@async_yield_fixture
async def download_handler(self) -> AsyncGenerator[DownloadHandlerProtocol]:
dh = build_from_crawler(self.download_handler_cls, get_crawler())
yield dh
await close_dh(dh)
@asynccontextmanager
async def get_dh(self) -> AsyncGenerator[DownloadHandlerProtocol]:
crawler = get_crawler(DefaultSpider)
crawler.spider = crawler._create_spider()
dh = build_from_crawler(self.download_handler_cls, crawler)
try:
yield dh
finally:
await dh.close()
@deferred_f_from_coro_f
async def test_download_with_proxy(
self,
proxy_mockserver: ProxyEchoMockServer,
download_handler: DownloadHandlerProtocol,
self, proxy_mockserver: ProxyEchoMockServer
) -> None:
http_proxy = proxy_mockserver.url("", is_secure=self.is_secure)
request = Request("http://example.com", meta={"proxy": http_proxy})
response = await download_request(download_handler, request)
async with self.get_dh() as download_handler:
response = await download_handler.download_request(request)
assert response.status == 200
assert response.url == request.url
assert response.body == self.expected_http_proxy_request_body
@deferred_f_from_coro_f
async def test_download_without_proxy(
self,
proxy_mockserver: ProxyEchoMockServer,
download_handler: DownloadHandlerProtocol,
self, proxy_mockserver: ProxyEchoMockServer
) -> None:
request = Request(
proxy_mockserver.url("/path/to/resource", is_secure=self.is_secure)
)
response = await download_request(download_handler, request)
async with self.get_dh() as download_handler:
response = await download_handler.download_request(request)
assert response.status == 200
assert response.url == request.url
assert response.body == b"/path/to/resource"
@deferred_f_from_coro_f
async def test_download_with_proxy_https_timeout(
self,
proxy_mockserver: ProxyEchoMockServer,
download_handler: DownloadHandlerProtocol,
self, proxy_mockserver: ProxyEchoMockServer
) -> None:
if NON_EXISTING_RESOLVABLE:
pytest.skip("Non-existing hosts are resolvable")
http_proxy = proxy_mockserver.url("", is_secure=self.is_secure)
domain = "https://no-such-domain.nosuch"
request = Request(domain, meta={"proxy": http_proxy, "download_timeout": 0.2})
with pytest.raises(error.TimeoutError) as exc_info:
await download_request(download_handler, request)
async with self.get_dh() as download_handler:
with pytest.raises(error.TimeoutError) as exc_info:
await download_handler.download_request(request)
assert domain in exc_info.value.osError
@deferred_f_from_coro_f
async def test_download_with_proxy_without_http_scheme(
self,
proxy_mockserver: ProxyEchoMockServer,
download_handler: DownloadHandlerProtocol,
self, proxy_mockserver: ProxyEchoMockServer
) -> None:
http_proxy = f"{proxy_mockserver.host}:{proxy_mockserver.port()}"
request = Request("http://example.com", meta={"proxy": http_proxy})
response = await download_request(download_handler, request)
async with self.get_dh() as download_handler:
response = await download_handler.download_request(request)
assert response.status == 200
assert response.url == request.url
assert response.body == self.expected_http_proxy_request_body