From 2bfcff563820e1208e0f124bb4067dd40c493b13 Mon Sep 17 00:00:00 2001 From: Adrian Chaves Date: Wed, 12 Aug 2026 20:29:34 +0200 Subject: [PATCH] Report crawl errors in scrapy check --- scrapy/commands/check.py | 46 ++++++++++++++++++++++++++++++++++--- tests/test_command_check.py | 18 +++++++++++++++ 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/scrapy/commands/check.py b/scrapy/commands/check.py index 2113c19d2..b8af0d55b 100644 --- a/scrapy/commands/check.py +++ b/scrapy/commands/check.py @@ -1,10 +1,14 @@ import argparse +import asyncio import time from collections import defaultdict -from collections.abc import AsyncIterator +from collections.abc import AsyncIterator, Awaitable from typing import Any, ClassVar +from unittest import TestCase, TextTestRunner from unittest import TextTestResult as _TextTestResult -from unittest import TextTestRunner + +from twisted.internet.defer import Deferred +from twisted.python.failure import Failure from scrapy import Spider from scrapy.commands import ScrapyCommand @@ -42,6 +46,41 @@ class TextTestResult(_TextTestResult): write("\n") +def _report_crawl_errors( + crawl: Awaitable[None], spidername: str, result: TextTestResult +) -> None: + """Make an exception that stops *crawl* before its contracts can run show + up as an error in *result*, instead of being silently discarded.""" + + class CrawlTestCase(TestCase): + def runTest(self) -> None: + pass + + def __str__(self) -> str: + return f"[{spidername}] crawl" + + def report(exception: BaseException) -> None: + result.addError( + CrawlTestCase(), + (type(exception), exception, exception.__traceback__), # type: ignore[arg-type] + ) + + if isinstance(crawl, Deferred): + + def on_failure(failure: Failure) -> None: + assert failure.value is not None + report(failure.value) + + crawl.addErrback(on_failure) + elif isinstance(crawl, asyncio.Task): + + def on_done(task: asyncio.Task[None]) -> None: + if not task.cancelled() and (exception := task.exception()) is not None: + report(exception) + + crawl.add_done_callback(on_done) + + class Command(ScrapyCommand): requires_project = True default_settings: ClassVar[dict[str, Any]] = {"LOG_ENABLED": False} @@ -100,7 +139,8 @@ class Command(ScrapyCommand): for method in tested_methods: contract_reqs[spidercls.name].append(method) elif tested_methods: - self.crawler_process.crawl(spidercls) + crawl = self.crawler_process.crawl(spidercls) + _report_crawl_errors(crawl, spidercls.name, result) # start checks if opts.list: diff --git a/tests/test_command_check.py b/tests/test_command_check.py index 240f44584..ffa787c5f 100644 --- a/tests/test_command_check.py +++ b/tests/test_command_check.py @@ -6,6 +6,8 @@ from typing import TYPE_CHECKING from unittest import TestCase from unittest.mock import MagicMock, Mock, PropertyMock, call, patch +import pytest + from scrapy.commands.check import Command, TextTestResult from tests.utils.bases.commands import TestProjectBase from tests.utils.cmdline import proc @@ -79,6 +81,22 @@ class CheckSpider(scrapy.Spider): """ self._test_contract(proj_path, contracts, parse_def, use_reactor=False) + @pytest.mark.parametrize("use_reactor", [True, False]) + def test_check_crawl_error(self, proj_path: Path, use_reactor: bool) -> None: + self._write_contract(proj_path, "@returns requests 0", "pass") + self._append_settings( + proj_path / self.project_name, + "\nITEM_PIPELINES = {'nonexistent.module.Pipeline': 300}\n", + ) + args = ["check"] + if not use_reactor: + args += ["-s", "TWISTED_REACTOR_ENABLED=False"] + ret, _, err = proc(*args, cwd=proj_path) + assert f"[{self.spider_name}] crawl" in err + assert "ModuleNotFoundError" in err + assert "FAILED (errors=1)" in err + assert ret == 1 + def test_check_returns_items_contract(self, proj_path: Path) -> None: contracts = """ @returns items 1