mirror of https://github.com/scrapy/scrapy.git
233 lines
7.4 KiB
Python
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)
|