from __future__ import annotations from collections import deque from logging import ERROR from typing import TYPE_CHECKING from scrapy import Request, Spider, signals from scrapy.utils.asyncio import call_later from scrapy.utils.test import get_crawler from tests.mockserver.http import MockServer from tests.test_scheduler import MemoryScheduler from tests.utils import async_sleep from tests.utils.decorators import coroutine_test if TYPE_CHECKING: import pytest from scrapy.http import Response class TestMain: @coroutine_test async def test_sleep(self): """Neither asynchronous sleeps on Spider.start() nor the equivalent on the scheduler (returning no requests while also returning True from the has_pending_requests() method) should cause the spider to miss the processing of any later requests.""" seconds = 2 class TestSpider(Spider): name = "test" async def start(self): yield Request("data:,a") await async_sleep(seconds) self.crawler.engine._slot.scheduler.pause() self.crawler.engine._slot.scheduler.enqueue_request(Request("data:,b")) # During this time, the scheduler reports having requests but # returns None. await async_sleep(seconds) self.crawler.engine._slot.scheduler.unpause() # The scheduler request is processed. await async_sleep(seconds) yield Request("data:,c") await async_sleep(seconds) self.crawler.engine._slot.scheduler.pause() self.crawler.engine._slot.scheduler.enqueue_request(Request("data:,d")) # The last start request is processed during the time until the # delayed call below, proving that the start iteration can # finish before a scheduler “sleep” without causing the # scheduler to finish. call_later(seconds, self.crawler.engine._slot.scheduler.unpause) def parse(self, response): pass actual_urls = [] def track_url(request, spider): actual_urls.append(request.url) settings = {"SCHEDULER": MemoryScheduler} crawler = get_crawler(TestSpider, settings_dict=settings) crawler.signals.connect(track_url, signals.request_reached_downloader) await crawler.crawl_async() assert crawler.stats.get_value("finish_reason") == "finished" expected_urls = ["data:,a", "data:,b", "data:,c", "data:,d"] assert actual_urls == expected_urls, f"{actual_urls=} != {expected_urls=}" @coroutine_test async def test_close_during_start_iteration( self, caplog: pytest.LogCaptureFixture ) -> None: class TestSpider(Spider): name = "test" async def start(self): assert self.crawler.engine is not None await self.crawler.engine.close_async() yield Request("data:,a") def parse(self, response): pass actual_urls = [] def track_url(request, spider): actual_urls.append(request.url) settings = {"SCHEDULER": MemoryScheduler} crawler = get_crawler(TestSpider, settings_dict=settings) crawler.signals.connect(track_url, signals.request_reached_downloader) caplog.clear() with caplog.at_level(ERROR): await crawler.crawl_async() assert not caplog.records assert crawler.stats assert crawler.stats.get_value("finish_reason") == "shutdown" assert not actual_urls class TestRequestSendOrder: seconds = 0.1 # increase if flaky @classmethod def setup_class(cls): cls.mockserver = MockServer() cls.mockserver.__enter__() @classmethod def teardown_class(cls): cls.mockserver.__exit__(None, None, None) # increase if flaky def request(self, num, response_seconds, download_slots, priority=0): url = self.mockserver.url(f"/delay?n={response_seconds}&{num}") meta = {"download_slot": str(num % download_slots)} return Request(url, meta=meta, priority=priority) def get_num(self, request_or_response: Request | Response): return int(request_or_response.url.rsplit("&", maxsplit=1)[1]) async def _test_request_order( self, start_nums, cb_nums=None, settings=None, response_seconds=None, download_slots=1, start_fn=None, parse_fn=None, ): cb_nums = cb_nums or [] settings = settings or {} response_seconds = response_seconds or self.seconds cb_requests = deque( [self.request(num, response_seconds, download_slots) for num in cb_nums] ) if start_fn is None: async def start_fn(spider): for num in start_nums: yield self.request(num, response_seconds, download_slots) if parse_fn is None: def parse_fn(spider, response): while cb_requests: yield cb_requests.popleft() class TestSpider(Spider): name = "test" start = start_fn parse = parse_fn actual_nums = [] def track_num(request, spider): actual_nums.append(self.get_num(request)) crawler = get_crawler(TestSpider, settings_dict=settings) crawler.signals.connect(track_num, signals.request_reached_downloader) await crawler.crawl_async() assert crawler.stats.get_value("finish_reason") == "finished" expected_nums = sorted(start_nums + cb_nums) assert actual_nums == expected_nums, f"{actual_nums=} != {expected_nums=}" @coroutine_test async def test_default(self): """By default, callback requests take priority over start requests and are sent in order. Priority matters, but given the same priority, a callback request takes precedence.""" nums = [1, 2, 3, 4, 5, 6] response_seconds = 0 download_slots = 1 def _request(num, priority=0): return self.request( num, response_seconds, download_slots, priority=priority ) async def start(spider): # The first CONCURRENT_REQUESTS start requests are sent # immediately. yield _request(1) for request in ( _request(2, priority=1), _request(5), ): spider.crawler.engine._slot.scheduler.enqueue_request(request) yield _request(6) yield _request(3, priority=1) yield _request(4, priority=1) def parse(spider, response): return yield await self._test_request_order( start_nums=nums, settings={"CONCURRENT_REQUESTS": 1}, response_seconds=response_seconds, start_fn=start, parse_fn=parse, ) @coroutine_test async def test_lifo_start(self): """Changing the queues of start requests to LIFO, matching the queues of non-start requests, does not cause all requests to be stored in the same queue objects, it only affects the order of start requests.""" nums = [1, 2, 3, 4, 5, 6] response_seconds = 0 download_slots = 1 def _request(num, priority=0): return self.request( num, response_seconds, download_slots, priority=priority ) async def start(spider): # The first CONCURRENT_REQUESTS start requests are sent # immediately. yield _request(1) for request in ( _request(2, priority=1), _request(5), ): spider.crawler.engine._slot.scheduler.enqueue_request(request) yield _request(6) yield _request(4, priority=1) yield _request(3, priority=1) def parse(spider, response): return yield await self._test_request_order( start_nums=nums, settings={ "CONCURRENT_REQUESTS": 1, "SCHEDULER_START_MEMORY_QUEUE": "scrapy.squeues.LifoMemoryQueue", }, response_seconds=response_seconds, start_fn=start, parse_fn=parse, ) @coroutine_test async def test_shared_queues(self): """If SCHEDULER_START_*_QUEUE is falsy, start requests and other requests share the same queue, i.e. start requests are not prioritized over other requests if their priority matches.""" nums = list(range(1, 14)) response_seconds = 0 download_slots = 1 def _request(num, priority=0): return self.request( num, response_seconds, download_slots, priority=priority ) async def start(spider): # The first CONCURRENT_REQUESTS start requests are sent # immediately. yield _request(1) # Below, priority 1 requests are sent first, and requests are sent # in LIFO order. for request in ( _request(7, priority=1), _request(6, priority=1), _request(13), _request(12), ): spider.crawler.engine._slot.scheduler.enqueue_request(request) yield _request(11) yield _request(10) yield _request(5, priority=1) yield _request(4, priority=1) for request in ( _request(3, priority=1), _request(2, priority=1), _request(9), _request(8), ): spider.crawler.engine._slot.scheduler.enqueue_request(request) def parse(spider, response): return yield await self._test_request_order( start_nums=nums, settings={ "CONCURRENT_REQUESTS": 1, "SCHEDULER_START_MEMORY_QUEUE": None, }, response_seconds=response_seconds, start_fn=start, parse_fn=parse, ) # Examples from the “Start requests” section of the documentation about # spiders. @coroutine_test async def test_lazy(self): start_nums = [1, 2, 4] cb_nums = [3] response_seconds = self.seconds * 2**1 # increase if flaky download_slots = 1 async def start(spider): for num in start_nums: if spider.crawler.engine.needs_backout(): await spider.crawler.signals.wait_for(signals.scheduler_empty) request = self.request(num, response_seconds, download_slots) yield request await self._test_request_order( start_nums=start_nums, cb_nums=cb_nums, settings={ "CONCURRENT_REQUESTS": 1, }, response_seconds=response_seconds, start_fn=start, )