This commit is contained in:
Adrian 2026-08-15 11:16:48 -05:00 committed by GitHub
commit 3f9604a495
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 67 additions and 3 deletions

View File

@ -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,42 @@ 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):
# unittest requires a test method, but this one is only reported, never run.
runTest = staticmethod(lambda: None)
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)
else:
assert 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 +140,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:

View File

@ -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,27 @@ class CheckSpider(scrapy.Spider):
"""
self._test_contract(proj_path, contracts, parse_def, use_reactor=False)
@pytest.mark.parametrize(
"settings",
[
"",
"TWISTED_REACTOR_ENABLED = False\n",
"FORCE_CRAWLER_PROCESS = True\n",
],
ids=["async", "no_reactor", "crawler_process"],
)
def test_check_crawl_error(self, proj_path: Path, settings: str) -> None:
self._write_contract(proj_path, "@returns requests 0", "pass")
self._append_settings(
proj_path / self.project_name,
f"\nITEM_PIPELINES = {{'nonexistent.module.Pipeline': 300}}\n{settings}",
)
ret, _, err = proc("check", 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