scrapy/tests/test_engine.py

233 lines
7.4 KiB
Python

from __future__ import annotations
import asyncio
import logging
import subprocess
import sys
from typing import TYPE_CHECKING, Any
from unittest.mock import Mock
import pytest
from scrapy import signals
from scrapy.core.engine import ExecutionEngine, _Slot
from scrapy.core.scheduler import BaseScheduler
from scrapy.exceptions import CloseSpider, IgnoreRequest
from scrapy.http import Request
from scrapy.spiders import Spider
from scrapy.utils.defer import _schedule_coro, deferred_from_coro
from scrapy.utils.spider import DefaultSpider
from scrapy.utils.test import get_crawler
from tests.utils.bases.engine import TestEngineBase
from tests.utils.decorators import coroutine_test, inline_callbacks_test
from tests.utils.engine import (
AttrsItemsSpider,
CrawlerRun,
DataClassItemsSpider,
DictItemsSpider,
MySpider,
)
if TYPE_CHECKING:
from collections.abc import AsyncIterator
from tests.mockserver.http import MockServer
class DupeFilterSpider(MySpider):
async def start(self):
for url in self.start_urls:
yield Request(url) # no dont_filter=True
class ItemZeroDivisionErrorSpider(MySpider):
custom_settings = {
"ITEM_PIPELINES": {
"tests.pipelines.ProcessWithZeroDivisionErrorPipeline": 300,
}
}
class ChangeCloseReasonSpider(MySpider):
@classmethod
def from_crawler(cls, crawler, *args, **kwargs):
spider = cls(*args, **kwargs)
spider._set_crawler(crawler)
crawler.signals.connect(spider.spider_idle, signals.spider_idle)
return spider
def spider_idle(self):
raise CloseSpider(reason="custom_reason")
class TestEngine(TestEngineBase):
@coroutine_test
async def test_crawler(self, mockserver: MockServer) -> None:
for spider in (
MySpider,
DictItemsSpider,
AttrsItemsSpider,
DataClassItemsSpider,
):
run = CrawlerRun(spider)
await run.run(mockserver)
self._assert_visited_urls(run)
self._assert_scheduled_requests(run, count=9)
self._assert_downloaded_responses(run, count=9)
self._assert_scraped_items(run)
self._assert_signals_caught(run)
self._assert_headers_received(run)
self._assert_bytes_received(run)
@coroutine_test
async def test_crawler_dupefilter(self, mockserver: MockServer) -> None:
run = CrawlerRun(DupeFilterSpider)
await run.run(mockserver)
self._assert_scheduled_requests(run, count=8)
self._assert_dropped_requests(run)
@coroutine_test
async def test_crawler_itemerror(self, mockserver: MockServer) -> None:
run = CrawlerRun(ItemZeroDivisionErrorSpider)
await run.run(mockserver)
self._assert_items_error(run)
@coroutine_test
async def test_crawler_change_close_reason_on_idle(
self, mockserver: MockServer
) -> None:
run = CrawlerRun(ChangeCloseReasonSpider)
await run.run(mockserver)
assert {
"spider": run.crawler.spider,
"reason": "custom_reason",
} == run.signals_caught[signals.spider_closed]
@coroutine_test
async def test_close_downloader(self):
e = ExecutionEngine(get_crawler(MySpider), lambda _: None)
await e.close_async()
def test_close_without_downloader(self):
class CustomException(Exception):
pass
class BadDownloader:
def __init__(self, crawler):
raise CustomException
with pytest.raises(CustomException):
ExecutionEngine(
get_crawler(MySpider, {"DOWNLOADER": BadDownloader}), lambda _: None
)
@inline_callbacks_test
def test_start_already_running_exception(self):
crawler = get_crawler(DefaultSpider)
crawler.spider = crawler._create_spider()
e = ExecutionEngine(crawler, lambda _: None)
crawler.engine = e
yield deferred_from_coro(e.open_spider_async())
_schedule_coro(e.start_async())
with pytest.raises(RuntimeError, match="Engine already running"):
yield deferred_from_coro(e.start_async())
yield deferred_from_coro(e.stop_async())
@pytest.mark.only_asyncio
@coroutine_test
async def test_start_already_running_exception_asyncio(self):
crawler = get_crawler(DefaultSpider)
crawler.spider = crawler._create_spider()
e = ExecutionEngine(crawler, lambda _: None)
crawler.engine = e
await e.open_spider_async()
with pytest.raises(RuntimeError, match="Engine already running"):
await asyncio.gather(e.start_async(), e.start_async())
await e.stop_async()
@coroutine_test
async def test_start_request_processing_exception(
self, caplog: pytest.LogCaptureFixture
) -> None:
class BadRequestFingerprinter:
def fingerprint(self, request):
raise ValueError # to make Scheduler.enqueue_request() fail
class SimpleSpider(Spider):
name = "simple"
async def start(self):
yield Request("data:,")
crawler = get_crawler(
SimpleSpider, {"REQUEST_FINGERPRINTER_CLASS": BadRequestFingerprinter}
)
with caplog.at_level(logging.DEBUG):
await crawler.crawl_async()
assert "Error while processing requests from start()" in caplog.text
assert "Spider closed (shutdown)" in caplog.text
def test_short_timeout(self):
args = (
sys.executable,
"-m",
"scrapy.cmdline",
"fetch",
"-s",
"CLOSESPIDER_TIMEOUT=0.001",
"-s",
"LOG_LEVEL=DEBUG",
"http://toscrape.com",
)
p = subprocess.Popen(
args,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
)
try:
_, stderr = p.communicate(timeout=15)
except subprocess.TimeoutExpired:
p.kill()
p.communicate()
pytest.fail("Command took too much time to complete")
stderr_str = stderr.decode("utf-8")
assert "AttributeError" not in stderr_str, stderr_str
assert "AssertionError" not in stderr_str, stderr_str
@coroutine_test
async def test_request_scheduled_signal():
class TestScheduler(BaseScheduler):
def __init__(self) -> None:
self.enqueued: list[Request] = []
def enqueue_request(self, request: Request) -> bool:
self.enqueued.append(request)
return True
def signal_handler(request: Request, spider: Spider) -> None:
if "drop" in request.url:
raise IgnoreRequest
crawler = get_crawler(MySpider)
engine = ExecutionEngine(crawler, lambda _: None)
scheduler = TestScheduler() # type: ignore[abstract]
async def start() -> AsyncIterator[Any]:
return
yield
engine._start = start()
engine._slot = _Slot(False, Mock(), scheduler)
crawler.signals.connect(signal_handler, signals.request_scheduled)
keep_request = Request("https://keep.example")
engine._schedule_request(keep_request)
drop_request = Request("https://drop.example")
engine._schedule_request(drop_request)
assert scheduler.enqueued == [keep_request], (
f"{scheduler.enqueued!r} != [{keep_request!r}]"
)
crawler.signals.disconnect(signal_handler, signals.request_scheduled)