mirror of https://github.com/scrapy/scrapy.git
444 lines
16 KiB
Python
444 lines
16 KiB
Python
from __future__ import annotations
|
|
|
|
import logging
|
|
import warnings
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
import pytest
|
|
|
|
from scrapy.exceptions import ScrapyDeprecationWarning
|
|
from scrapy.http import Request, Response
|
|
from scrapy.spidermiddlewares.httperror import HttpError, HttpErrorMiddleware
|
|
from scrapy.utils.datatypes import SequenceExclude
|
|
from scrapy.utils.misc import build_from_crawler
|
|
from scrapy.utils.spider import DefaultSpider
|
|
from scrapy.utils.test import get_crawler
|
|
from tests.spiders import MockServerSpider
|
|
from tests.utils.decorators import coroutine_test
|
|
|
|
if TYPE_CHECKING:
|
|
from scrapy import Spider
|
|
from tests.mockserver.http import MockServer
|
|
|
|
|
|
class _HttpErrorSpider(MockServerSpider):
|
|
name = "httperror"
|
|
bypass_status_codes: set[int] = set()
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
assert self.mockserver
|
|
self.start_urls = [
|
|
self.mockserver.url("/status?n=200"),
|
|
self.mockserver.url("/status?n=404"),
|
|
self.mockserver.url("/status?n=402"),
|
|
self.mockserver.url("/status?n=500"),
|
|
]
|
|
self.failed = set()
|
|
self.skipped = set()
|
|
self.parsed = set()
|
|
|
|
async def start(self):
|
|
for url in self.start_urls:
|
|
yield Request(url, self.parse, errback=self.on_error)
|
|
|
|
def parse(self, response: Response) -> None:
|
|
self.parsed.add(response.url[-3:])
|
|
|
|
def on_error(self, failure):
|
|
if isinstance(failure.value, HttpError):
|
|
response = failure.value.response
|
|
if response.status in self.bypass_status_codes:
|
|
self.skipped.add(response.url[-3:])
|
|
return self.parse(response)
|
|
|
|
# it assumes there is a response attached to failure
|
|
self.failed.add(failure.value.response.url[-3:])
|
|
return failure
|
|
|
|
|
|
req = Request("http://scrapytest.org")
|
|
|
|
|
|
def _response(request: Request, status_code: int) -> Response:
|
|
return Response(request.url, status=status_code, request=request)
|
|
|
|
|
|
@pytest.fixture
|
|
def res200() -> Response:
|
|
return _response(req, 200)
|
|
|
|
|
|
@pytest.fixture
|
|
def res402() -> Response:
|
|
return _response(req, 402)
|
|
|
|
|
|
@pytest.fixture
|
|
def res404() -> Response:
|
|
return _response(req, 404)
|
|
|
|
|
|
def _mw(
|
|
settings: dict[str, Any] | None = None, spidercls: type[Spider] = DefaultSpider
|
|
) -> HttpErrorMiddleware:
|
|
crawler = get_crawler(spidercls, settings)
|
|
crawler.spider = crawler._create_spider()
|
|
mw = build_from_crawler(HttpErrorMiddleware, crawler)
|
|
mw.spider_opened(crawler.spider)
|
|
return mw
|
|
|
|
|
|
class TestHttpErrorMiddleware:
|
|
@pytest.fixture
|
|
def mw(self) -> HttpErrorMiddleware:
|
|
return _mw()
|
|
|
|
def test_process_spider_input(
|
|
self, mw: HttpErrorMiddleware, res200: Response, res404: Response
|
|
) -> None:
|
|
mw.process_spider_input(res200)
|
|
with pytest.raises(HttpError):
|
|
mw.process_spider_input(res404)
|
|
|
|
def test_process_spider_exception(
|
|
self, mw: HttpErrorMiddleware, res404: Response
|
|
) -> None:
|
|
assert mw.process_spider_exception(res404, HttpError(res404)) == ()
|
|
assert mw.process_spider_exception(res404, Exception()) is None
|
|
|
|
def test_meta(self, mw: HttpErrorMiddleware, res404: Response) -> None:
|
|
request = Request("http://example.com", meta={"handle_http_codes": [404]})
|
|
mw.process_spider_input(_response(request, 404))
|
|
with pytest.raises(HttpError):
|
|
mw.process_spider_input(_response(request, 402))
|
|
with pytest.raises(HttpError):
|
|
mw.process_spider_input(res404)
|
|
|
|
|
|
class TestHttpErrorMiddlewareSettings:
|
|
"""Similar test, but with settings"""
|
|
|
|
@pytest.fixture
|
|
def mw(self) -> HttpErrorMiddleware:
|
|
return _mw({"HANDLE_HTTP_CODES": (402,)})
|
|
|
|
def test_process_spider_input(
|
|
self,
|
|
mw: HttpErrorMiddleware,
|
|
res200: Response,
|
|
res402: Response,
|
|
res404: Response,
|
|
) -> None:
|
|
mw.process_spider_input(res200)
|
|
with pytest.raises(HttpError):
|
|
mw.process_spider_input(res404)
|
|
mw.process_spider_input(res402)
|
|
|
|
def test_meta_overrides_settings(self, mw: HttpErrorMiddleware) -> None:
|
|
request = Request("http://example.com", meta={"handle_http_codes": [404]})
|
|
mw.process_spider_input(_response(request, 404))
|
|
with pytest.raises(HttpError):
|
|
mw.process_spider_input(_response(request, 402))
|
|
|
|
def test_meta_false_overrides_settings(self, mw: HttpErrorMiddleware) -> None:
|
|
request = Request("http://example.com", meta={"handle_http_codes": False})
|
|
with pytest.raises(HttpError):
|
|
mw.process_spider_input(_response(request, 402))
|
|
|
|
def test_meta_none_ignored(self, mw: HttpErrorMiddleware) -> None:
|
|
request = Request("http://example.com", meta={"handle_http_codes": None})
|
|
mw.process_spider_input(_response(request, 402))
|
|
|
|
|
|
class TestHttpErrorMiddlewareHandleAll:
|
|
@pytest.fixture
|
|
def mw(self) -> HttpErrorMiddleware:
|
|
return _mw({"HANDLE_HTTP_CODES": True})
|
|
|
|
def test_process_spider_input(
|
|
self,
|
|
mw: HttpErrorMiddleware,
|
|
res200: Response,
|
|
res404: Response,
|
|
) -> None:
|
|
mw.process_spider_input(res200)
|
|
mw.process_spider_input(res404)
|
|
|
|
def test_meta_overrides_settings(self, mw: HttpErrorMiddleware) -> None:
|
|
request = Request("http://example.com", meta={"handle_http_codes": [404]})
|
|
mw.process_spider_input(_response(request, 404))
|
|
with pytest.raises(HttpError):
|
|
mw.process_spider_input(_response(request, 402))
|
|
|
|
def test_meta_false_overrides_settings(self, mw: HttpErrorMiddleware) -> None:
|
|
request = Request("http://example.com", meta={"handle_http_codes": False})
|
|
with pytest.raises(HttpError):
|
|
mw.process_spider_input(_response(request, 402))
|
|
|
|
|
|
class TestHttpErrorMiddlewareValues:
|
|
"""Every supported way to express a HANDLE_HTTP_CODES value."""
|
|
|
|
@pytest.mark.parametrize(
|
|
("value", "handled"),
|
|
[
|
|
(True, True),
|
|
(False, False),
|
|
(None, False),
|
|
(402, True),
|
|
("402", True),
|
|
("402,404", True),
|
|
("True", True),
|
|
("true", True),
|
|
("1", True),
|
|
("False", False),
|
|
("false", False),
|
|
("0", False),
|
|
("", False),
|
|
([402], True),
|
|
(["402"], True),
|
|
((402,), True),
|
|
({402}, True),
|
|
(frozenset({402}), True),
|
|
([], False),
|
|
([404], False),
|
|
(SequenceExclude(range(300, 400)), True),
|
|
],
|
|
)
|
|
def test_setting(self, value: Any, handled: bool) -> None:
|
|
mw = _mw({"HANDLE_HTTP_CODES": value})
|
|
res402 = _response(Request("http://example.com"), 402)
|
|
if handled:
|
|
mw.process_spider_input(res402)
|
|
else:
|
|
with pytest.raises(HttpError):
|
|
mw.process_spider_input(res402)
|
|
|
|
@pytest.mark.parametrize(
|
|
("value", "handled"),
|
|
[
|
|
(True, True),
|
|
(False, False),
|
|
(402, True),
|
|
("402", True),
|
|
([402], True),
|
|
([404], False),
|
|
(SequenceExclude(range(300, 400)), True),
|
|
],
|
|
)
|
|
def test_meta(self, value: Any, handled: bool) -> None:
|
|
mw = _mw()
|
|
request = Request("http://example.com", meta={"handle_http_codes": value})
|
|
if handled:
|
|
mw.process_spider_input(_response(request, 402))
|
|
else:
|
|
with pytest.raises(HttpError):
|
|
mw.process_spider_input(_response(request, 402))
|
|
|
|
def test_unsupported_value(self) -> None:
|
|
with pytest.raises(ValueError, match="Unsupported HANDLE_HTTP_CODES value"):
|
|
_mw({"HANDLE_HTTP_CODES": object()})
|
|
|
|
|
|
class TestHttpErrorMiddlewareDeprecated:
|
|
def test_settings(self) -> None:
|
|
with pytest.warns(
|
|
ScrapyDeprecationWarning, match="HTTPERROR_ALLOWED_CODES setting"
|
|
):
|
|
mw = _mw({"HTTPERROR_ALLOWED_CODES": (402,)})
|
|
res402 = _response(Request("http://example.com"), 402)
|
|
res404 = _response(Request("http://example.com"), 404)
|
|
mw.process_spider_input(res402)
|
|
with pytest.raises(HttpError):
|
|
mw.process_spider_input(res404)
|
|
|
|
def test_allow_all_setting(self) -> None:
|
|
with pytest.warns(
|
|
ScrapyDeprecationWarning, match="HTTPERROR_ALLOW_ALL setting"
|
|
):
|
|
mw = _mw({"HTTPERROR_ALLOW_ALL": True})
|
|
mw.process_spider_input(_response(Request("http://example.com"), 404))
|
|
|
|
def test_new_setting_wins(self) -> None:
|
|
mw = _mw({"HANDLE_HTTP_CODES": False, "HTTPERROR_ALLOW_ALL": True})
|
|
with pytest.raises(HttpError):
|
|
mw.process_spider_input(_response(Request("http://example.com"), 404))
|
|
|
|
def test_spider_attribute(self) -> None:
|
|
class HandlingSpider(DefaultSpider):
|
|
handle_httpstatus_list = [404]
|
|
|
|
with pytest.warns(
|
|
ScrapyDeprecationWarning, match="'handle_httpstatus_list' spider attribute"
|
|
):
|
|
mw = _mw(spidercls=HandlingSpider)
|
|
mw.process_spider_input(_response(Request("http://example.com"), 404))
|
|
with pytest.raises(HttpError):
|
|
mw.process_spider_input(_response(Request("http://example.com"), 402))
|
|
|
|
def test_spider_attribute_overrides_settings(self) -> None:
|
|
class HandlingSpider(DefaultSpider):
|
|
handle_httpstatus_list = [404]
|
|
|
|
with pytest.warns(ScrapyDeprecationWarning):
|
|
mw = _mw({"HANDLE_HTTP_CODES": [402]}, spidercls=HandlingSpider)
|
|
mw.process_spider_input(_response(Request("http://example.com"), 404))
|
|
with pytest.raises(HttpError):
|
|
mw.process_spider_input(_response(Request("http://example.com"), 402))
|
|
|
|
def test_meta_overrides_spider_attribute(self) -> None:
|
|
"""Unlike RedirectMiddleware, this middleware never combined the
|
|
deprecated meta key with the deprecated spider attribute."""
|
|
|
|
class HandlingSpider(DefaultSpider):
|
|
handle_httpstatus_list = [404]
|
|
|
|
with pytest.warns(ScrapyDeprecationWarning):
|
|
mw = _mw(spidercls=HandlingSpider)
|
|
request = Request("http://example.com", meta={"handle_httpstatus_list": [402]})
|
|
with pytest.warns(ScrapyDeprecationWarning), pytest.raises(HttpError):
|
|
mw.process_spider_input(_response(request, 404))
|
|
|
|
def test_meta_list(self) -> None:
|
|
mw = _mw()
|
|
request = Request("http://example.com", meta={"handle_httpstatus_list": [404]})
|
|
with pytest.warns(
|
|
ScrapyDeprecationWarning, match="'handle_httpstatus_list' request meta key"
|
|
):
|
|
mw.process_spider_input(_response(request, 404))
|
|
with pytest.raises(HttpError):
|
|
mw.process_spider_input(_response(request, 402))
|
|
|
|
def test_meta_all(self) -> None:
|
|
mw = _mw()
|
|
request = Request("http://example.com", meta={"handle_httpstatus_all": True})
|
|
with pytest.warns(
|
|
ScrapyDeprecationWarning, match="'handle_httpstatus_all' request meta key"
|
|
):
|
|
mw.process_spider_input(_response(request, 404))
|
|
|
|
def test_meta_all_false(self) -> None:
|
|
mw = _mw({"HANDLE_HTTP_CODES": True})
|
|
request = Request("http://example.com", meta={"handle_httpstatus_all": False})
|
|
with pytest.warns(ScrapyDeprecationWarning), pytest.raises(HttpError):
|
|
mw.process_spider_input(_response(request, 404))
|
|
|
|
def test_meta_all_true_beats_meta_list(self) -> None:
|
|
mw = _mw()
|
|
request = Request(
|
|
"http://example.com",
|
|
meta={"handle_httpstatus_all": True, "handle_httpstatus_list": [404]},
|
|
)
|
|
with pytest.warns(ScrapyDeprecationWarning):
|
|
mw.process_spider_input(_response(request, 402))
|
|
|
|
def test_meta_all_false_defers_to_meta_list(self) -> None:
|
|
mw = _mw()
|
|
request = Request(
|
|
"http://example.com",
|
|
meta={"handle_httpstatus_all": False, "handle_httpstatus_list": [404]},
|
|
)
|
|
with pytest.warns(ScrapyDeprecationWarning):
|
|
mw.process_spider_input(_response(request, 404))
|
|
|
|
def test_new_meta_key_wins(self) -> None:
|
|
mw = _mw()
|
|
request = Request(
|
|
"http://example.com",
|
|
meta={"handle_http_codes": False, "handle_httpstatus_all": True},
|
|
)
|
|
with pytest.raises(HttpError):
|
|
mw.process_spider_input(_response(request, 404))
|
|
|
|
def test_meta_key_warning_once_per_crawl(self) -> None:
|
|
mw = _mw()
|
|
request = Request("http://example.com", meta={"handle_httpstatus_all": True})
|
|
with pytest.warns(ScrapyDeprecationWarning):
|
|
mw.process_spider_input(_response(request, 404))
|
|
with warnings.catch_warnings():
|
|
warnings.simplefilter("error")
|
|
mw.process_spider_input(_response(request, 404))
|
|
|
|
|
|
class TestHttpErrorMiddlewareIntegrational:
|
|
@coroutine_test
|
|
async def test_middleware_works(self, mockserver: MockServer) -> None:
|
|
crawler = get_crawler(_HttpErrorSpider)
|
|
await crawler.crawl_async(mockserver=mockserver)
|
|
assert isinstance(crawler.spider, _HttpErrorSpider)
|
|
assert not crawler.spider.skipped
|
|
assert crawler.spider.parsed == {"200"}
|
|
assert crawler.spider.failed == {"404", "402", "500"}
|
|
|
|
assert crawler.stats
|
|
get_value = crawler.stats.get_value
|
|
assert get_value("httperror/response_ignored_count") == 3
|
|
assert get_value("httperror/response_ignored_status_count/404") == 1
|
|
assert get_value("httperror/response_ignored_status_count/402") == 1
|
|
assert get_value("httperror/response_ignored_status_count/500") == 1
|
|
|
|
@coroutine_test
|
|
async def test_setting(self, mockserver: MockServer) -> None:
|
|
crawler = get_crawler(_HttpErrorSpider, {"HANDLE_HTTP_CODES": [402, 404]})
|
|
await crawler.crawl_async(mockserver=mockserver)
|
|
assert isinstance(crawler.spider, _HttpErrorSpider)
|
|
assert crawler.spider.parsed == {"200", "402", "404"}
|
|
assert crawler.spider.failed == {"500"}
|
|
|
|
@coroutine_test
|
|
async def test_setting_all(self, mockserver: MockServer) -> None:
|
|
crawler = get_crawler(_HttpErrorSpider, {"HANDLE_HTTP_CODES": True})
|
|
await crawler.crawl_async(mockserver=mockserver)
|
|
assert isinstance(crawler.spider, _HttpErrorSpider)
|
|
assert crawler.spider.parsed == {"200", "402", "404", "500"}
|
|
assert not crawler.spider.failed
|
|
|
|
@coroutine_test
|
|
async def test_logging(
|
|
self, caplog: pytest.LogCaptureFixture, mockserver: MockServer
|
|
) -> None:
|
|
crawler = get_crawler(_HttpErrorSpider)
|
|
with caplog.at_level(logging.INFO):
|
|
await crawler.crawl_async(mockserver=mockserver, bypass_status_codes={402})
|
|
assert isinstance(crawler.spider, _HttpErrorSpider)
|
|
assert crawler.spider.parsed == {"200", "402"}
|
|
assert crawler.spider.skipped == {"402"}
|
|
assert crawler.spider.failed == {"404", "500"}
|
|
|
|
assert "Ignoring response <404" in caplog.text
|
|
assert "Ignoring response <500" in caplog.text
|
|
assert "Ignoring response <200" not in caplog.text
|
|
assert "Ignoring response <402" not in caplog.text
|
|
|
|
@coroutine_test
|
|
async def test_logging_level(
|
|
self, caplog: pytest.LogCaptureFixture, mockserver: MockServer
|
|
) -> None:
|
|
# HttpError logs ignored responses with level INFO
|
|
crawler = get_crawler(_HttpErrorSpider)
|
|
with caplog.at_level(logging.INFO):
|
|
await crawler.crawl_async(mockserver=mockserver)
|
|
assert isinstance(crawler.spider, _HttpErrorSpider)
|
|
assert crawler.spider.parsed == {"200"}
|
|
assert crawler.spider.failed == {"404", "402", "500"}
|
|
|
|
assert "Ignoring response <402" in caplog.text
|
|
assert "Ignoring response <404" in caplog.text
|
|
assert "Ignoring response <500" in caplog.text
|
|
assert "Ignoring response <200" not in caplog.text
|
|
|
|
# with level WARNING, we shouldn't capture anything from HttpError
|
|
caplog.clear()
|
|
crawler = get_crawler(_HttpErrorSpider)
|
|
with caplog.at_level(logging.WARNING):
|
|
await crawler.crawl_async(mockserver=mockserver)
|
|
assert isinstance(crawler.spider, _HttpErrorSpider)
|
|
assert crawler.spider.parsed == {"200"}
|
|
assert crawler.spider.failed == {"404", "402", "500"}
|
|
|
|
assert "Ignoring response <402" not in caplog.text
|
|
assert "Ignoring response <404" not in caplog.text
|
|
assert "Ignoring response <500" not in caplog.text
|
|
assert "Ignoring response <200" not in caplog.text
|