mirror of https://github.com/scrapy/scrapy.git
349 lines
12 KiB
Python
349 lines
12 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from contextlib import asynccontextmanager
|
|
from gzip import BadGzipFile
|
|
from typing import TYPE_CHECKING
|
|
from unittest import mock
|
|
|
|
import pytest
|
|
from twisted.internet.defer import Deferred, succeed
|
|
|
|
from scrapy.core.downloader.middleware import DownloaderMiddlewareManager
|
|
from scrapy.exceptions import ScrapyDeprecationWarning, _InvalidOutput
|
|
from scrapy.http import Request, Response
|
|
from scrapy.spiders import Spider
|
|
from scrapy.utils.defer import maybe_deferred_to_future
|
|
from scrapy.utils.python import to_bytes
|
|
from scrapy.utils.test import get_crawler, get_from_asyncio_queue
|
|
from tests.utils.decorators import coroutine_test
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import AsyncGenerator
|
|
|
|
|
|
class TestManagerBase:
|
|
settings_dict = None
|
|
|
|
# should be a fixture but async fixtures that use Futures are problematic with pytest-twisted
|
|
@asynccontextmanager
|
|
async def get_mwman(self) -> AsyncGenerator[DownloaderMiddlewareManager]:
|
|
crawler = get_crawler(Spider, self.settings_dict)
|
|
crawler.spider = crawler._create_spider("foo")
|
|
mwman = DownloaderMiddlewareManager.from_crawler(crawler)
|
|
crawler.engine = crawler._create_engine()
|
|
await crawler.engine.open_spider_async()
|
|
try:
|
|
yield mwman
|
|
finally:
|
|
await crawler.engine.close_spider_async()
|
|
|
|
@staticmethod
|
|
async def _download(
|
|
mwman: DownloaderMiddlewareManager,
|
|
request: Request,
|
|
response: Response | None = None,
|
|
) -> Response | Request:
|
|
"""Executes downloader mw manager's download method and returns
|
|
the result (Request or Response) or raises exception in case of
|
|
failure.
|
|
"""
|
|
if not response:
|
|
response = Response(request.url)
|
|
|
|
async def download_func(request: Request) -> Response:
|
|
return response
|
|
|
|
return await mwman.download_async(download_func, request)
|
|
|
|
|
|
class TestDefaults(TestManagerBase):
|
|
"""Tests default behavior with default settings"""
|
|
|
|
@coroutine_test
|
|
async def test_request_response(self):
|
|
req = Request("http://example.com/index.html")
|
|
resp = Response(req.url, status=200)
|
|
async with self.get_mwman() as mwman:
|
|
ret = await self._download(mwman, req, resp)
|
|
assert isinstance(ret, Response), "Non-response returned"
|
|
|
|
@coroutine_test
|
|
async def test_3xx_and_invalid_gzipped_body_must_redirect(self):
|
|
"""Regression test for a failure when redirecting a compressed
|
|
request.
|
|
|
|
This happens when httpcompression middleware is executed before redirect
|
|
middleware and attempts to decompress a non-compressed body.
|
|
In particular when some website returns a 30x response with header
|
|
'Content-Encoding: gzip' giving as result the error below:
|
|
|
|
BadGzipFile: Not a gzipped file (...)
|
|
|
|
"""
|
|
req = Request("http://example.com")
|
|
body = b"<p>You are being redirected</p>"
|
|
resp = Response(
|
|
req.url,
|
|
status=302,
|
|
body=body,
|
|
headers={
|
|
"Content-Length": str(len(body)),
|
|
"Content-Type": "text/html",
|
|
"Content-Encoding": "gzip",
|
|
"Location": "http://example.com/login",
|
|
},
|
|
)
|
|
async with self.get_mwman() as mwman:
|
|
ret = await self._download(mwman, req, resp)
|
|
assert isinstance(ret, Request), f"Not redirected: {ret!r}"
|
|
assert to_bytes(ret.url) == resp.headers["Location"], (
|
|
"Not redirected to location header"
|
|
)
|
|
|
|
@coroutine_test
|
|
async def test_200_and_invalid_gzipped_body_must_fail(self):
|
|
req = Request("http://example.com")
|
|
body = b"<p>You are being redirected</p>"
|
|
resp = Response(
|
|
req.url,
|
|
status=200,
|
|
body=body,
|
|
headers={
|
|
"Content-Length": str(len(body)),
|
|
"Content-Type": "text/html",
|
|
"Content-Encoding": "gzip",
|
|
"Location": "http://example.com/login",
|
|
},
|
|
)
|
|
with pytest.raises(BadGzipFile):
|
|
async with self.get_mwman() as mwman:
|
|
await self._download(mwman, req, resp)
|
|
|
|
|
|
class TestResponseFromProcessRequest(TestManagerBase):
|
|
"""Tests middleware returning a response from process_request."""
|
|
|
|
@coroutine_test
|
|
async def test_download_func_not_called(self):
|
|
req = Request("http://example.com/index.html")
|
|
resp = Response("http://example.com/index.html")
|
|
download_func = mock.MagicMock()
|
|
|
|
class ResponseMiddleware:
|
|
def process_request(self, request):
|
|
return resp
|
|
|
|
async with self.get_mwman() as mwman:
|
|
mwman._add_middleware(ResponseMiddleware())
|
|
result = await mwman.download_async(download_func, req)
|
|
assert result is resp
|
|
assert not download_func.called
|
|
|
|
|
|
class TestResponseFromProcessException(TestManagerBase):
|
|
"""Tests middleware returning a response from process_exception."""
|
|
|
|
@coroutine_test
|
|
async def test_process_response_called(self):
|
|
req = Request("http://example.com/index.html")
|
|
resp = Response("http://example.com/index.html")
|
|
calls = []
|
|
|
|
def download_func(request):
|
|
raise ValueError("test")
|
|
|
|
class ResponseMiddleware:
|
|
def process_response(self, request, response):
|
|
calls.append("process_response")
|
|
return resp
|
|
|
|
def process_exception(self, request, exception):
|
|
calls.append("process_exception")
|
|
return resp
|
|
|
|
async with self.get_mwman() as mwman:
|
|
mwman._add_middleware(ResponseMiddleware())
|
|
result = await mwman.download_async(download_func, req)
|
|
assert result is resp
|
|
assert calls == [
|
|
"process_exception",
|
|
"process_response",
|
|
]
|
|
|
|
|
|
class TestInvalidOutput(TestManagerBase):
|
|
@coroutine_test
|
|
async def test_invalid_process_request(self):
|
|
"""Invalid return value for process_request method should raise an exception"""
|
|
req = Request("http://example.com/index.html")
|
|
|
|
class InvalidProcessRequestMiddleware:
|
|
def process_request(self, request):
|
|
return 1
|
|
|
|
async with self.get_mwman() as mwman:
|
|
mwman._add_middleware(InvalidProcessRequestMiddleware())
|
|
with pytest.raises(_InvalidOutput):
|
|
await self._download(mwman, req)
|
|
|
|
@coroutine_test
|
|
async def test_invalid_process_response(self):
|
|
"""Invalid return value for process_response method should raise an exception"""
|
|
req = Request("http://example.com/index.html")
|
|
|
|
class InvalidProcessResponseMiddleware:
|
|
def process_response(self, request, response):
|
|
return 1
|
|
|
|
async with self.get_mwman() as mwman:
|
|
mwman._add_middleware(InvalidProcessResponseMiddleware())
|
|
with pytest.raises(_InvalidOutput):
|
|
await self._download(mwman, req)
|
|
|
|
@coroutine_test
|
|
async def test_invalid_process_exception(self):
|
|
"""Invalid return value for process_exception method should raise an exception"""
|
|
req = Request("http://example.com/index.html")
|
|
|
|
class InvalidProcessExceptionMiddleware:
|
|
def process_request(self, request):
|
|
raise RuntimeError
|
|
|
|
def process_exception(self, request, exception):
|
|
return 1
|
|
|
|
async with self.get_mwman() as mwman:
|
|
mwman._add_middleware(InvalidProcessExceptionMiddleware())
|
|
with pytest.raises(_InvalidOutput):
|
|
await self._download(mwman, req)
|
|
|
|
|
|
class TestMiddlewareUsingDeferreds(TestManagerBase):
|
|
"""Middlewares using Deferreds (deprecated) should work"""
|
|
|
|
@coroutine_test
|
|
async def test_deferred(self):
|
|
req = Request("http://example.com/index.html")
|
|
resp = Response("http://example.com/index.html")
|
|
download_func = mock.MagicMock()
|
|
|
|
class DeferredMiddleware:
|
|
def cb(self, result):
|
|
return result
|
|
|
|
def process_request(self, request):
|
|
d: Deferred[Response] = Deferred()
|
|
d.addCallback(self.cb)
|
|
d.callback(resp)
|
|
return d
|
|
|
|
async with self.get_mwman() as mwman:
|
|
mwman._add_middleware(DeferredMiddleware())
|
|
with pytest.warns(
|
|
ScrapyDeprecationWarning,
|
|
match="returned a Deferred, this is deprecated",
|
|
):
|
|
result = await mwman.download_async(download_func, req)
|
|
assert result is resp
|
|
assert not download_func.called
|
|
|
|
|
|
class TestMiddlewareUsingCoro(TestManagerBase):
|
|
"""Middlewares using asyncio coroutines should work"""
|
|
|
|
@coroutine_test
|
|
async def test_asyncdef(self):
|
|
req = Request("http://example.com/index.html")
|
|
resp = Response("http://example.com/index.html")
|
|
download_func = mock.MagicMock()
|
|
|
|
class CoroMiddleware:
|
|
async def process_request(self, request):
|
|
await succeed(42)
|
|
return resp
|
|
|
|
async with self.get_mwman() as mwman:
|
|
mwman._add_middleware(CoroMiddleware())
|
|
result = await mwman.download_async(download_func, req)
|
|
assert result is resp
|
|
assert not download_func.called
|
|
|
|
@pytest.mark.only_asyncio
|
|
@coroutine_test
|
|
async def test_asyncdef_asyncio(self):
|
|
req = Request("http://example.com/index.html")
|
|
resp = Response("http://example.com/index.html")
|
|
download_func = mock.MagicMock()
|
|
|
|
class CoroMiddleware:
|
|
async def process_request(self, request):
|
|
await asyncio.sleep(0.1)
|
|
return await get_from_asyncio_queue(resp)
|
|
|
|
async with self.get_mwman() as mwman:
|
|
mwman._add_middleware(CoroMiddleware())
|
|
result = await mwman.download_async(download_func, req)
|
|
assert result is resp
|
|
assert not download_func.called
|
|
|
|
|
|
class TestDownloadDeprecated(TestManagerBase):
|
|
@coroutine_test
|
|
async def test_mwman_download(self):
|
|
req = Request("http://example.com/index.html")
|
|
resp = Response(req.url, status=200)
|
|
|
|
def download_func(request: Request, spider: Spider) -> Deferred[Response]:
|
|
return succeed(resp)
|
|
|
|
async with self.get_mwman() as mwman:
|
|
assert mwman.crawler
|
|
assert mwman.crawler.spider
|
|
with pytest.warns(
|
|
ScrapyDeprecationWarning,
|
|
match=r"DownloaderMiddlewareManager.download\(\) is deprecated, use download_async\(\) instead",
|
|
):
|
|
ret = await maybe_deferred_to_future(
|
|
mwman.download(download_func, req, mwman.crawler.spider)
|
|
)
|
|
assert isinstance(ret, Response)
|
|
|
|
|
|
class TestDeprecatedSpiderArg(TestManagerBase):
|
|
@coroutine_test
|
|
async def test_deprecated_spider_arg(self):
|
|
req = Request("http://example.com/index.html")
|
|
resp = Response("http://example.com/index.html")
|
|
download_func = mock.MagicMock()
|
|
|
|
class DeprecatedSpiderArgMiddleware:
|
|
def process_request(self, request, spider):
|
|
1 / 0
|
|
|
|
def process_response(self, request, response, spider):
|
|
return response
|
|
|
|
def process_exception(self, request, exception, spider):
|
|
return resp
|
|
|
|
async with self.get_mwman() as mwman:
|
|
with (
|
|
pytest.warns(
|
|
ScrapyDeprecationWarning,
|
|
match=r"process_request\(\) requires a spider argument",
|
|
),
|
|
pytest.warns(
|
|
ScrapyDeprecationWarning,
|
|
match=r"process_response\(\) requires a spider argument",
|
|
),
|
|
pytest.warns(
|
|
ScrapyDeprecationWarning,
|
|
match=r"process_exception\(\) requires a spider argument",
|
|
),
|
|
):
|
|
mwman._add_middleware(DeprecatedSpiderArgMiddleware())
|
|
result = await mwman.download_async(download_func, req)
|
|
assert result is resp
|
|
assert not download_func.called
|