Reactorless tests 2: enable pytest-asyncio. (#7233)

* Move no-reactor to pytest-asyncio.

* Unify the style.

* Skip a test that installs a reactor in no-reactor.

* Fix pinned env deps.

* Strict pytest-asyncio mode.
This commit is contained in:
Andrey Rakhmatullin 2026-02-02 23:36:23 +05:00 committed by GitHub
parent 3186ccf5d5
commit c8aa429c9b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
36 changed files with 129 additions and 28 deletions

View File

@ -88,7 +88,10 @@ def pytest_runtest_setup(item):
# Skip tests based on reactor markers # Skip tests based on reactor markers
reactor = item.config.getoption("--reactor") reactor = item.config.getoption("--reactor")
if item.get_closest_marker("requires_reactor") and reactor == "none": if (
item.get_closest_marker("requires_reactor")
or item.get_closest_marker("requires_http_handler")
) and reactor == "none":
pytest.skip('This test is only run when the --reactor value is not "none"') pytest.skip('This test is only run when the --reactor value is not "none"')
if item.get_closest_marker("only_asyncio") and reactor not in {"asyncio", "none"}: if item.get_closest_marker("only_asyncio") and reactor not in {"asyncio", "none"}:

View File

@ -230,13 +230,14 @@ markers = [
"only_asyncio: marks tests that require the asyncio loop to be used", "only_asyncio: marks tests that require the asyncio loop to be used",
"only_not_asyncio: marks tests that require the asyncio loop to not be used", "only_not_asyncio: marks tests that require the asyncio loop to not be used",
"requires_reactor: marks tests that require a reactor", "requires_reactor: marks tests that require a reactor",
"requires_http_handler: marks tests that require a HTTP handler",
"requires_uvloop: marks tests as only enabled when uvloop is known to be working", "requires_uvloop: marks tests as only enabled when uvloop is known to be working",
"requires_botocore: marks tests that need botocore (but not boto3)", "requires_botocore: marks tests that need botocore (but not boto3)",
"requires_boto3: marks tests that need botocore and boto3", "requires_boto3: marks tests that need botocore and boto3",
"requires_mitmproxy: marks tests that need mitmproxy", "requires_mitmproxy: marks tests that need mitmproxy",
] ]
filterwarnings = [ filterwarnings = [
"ignore::DeprecationWarning:twisted.web.static" "ignore::DeprecationWarning:twisted.web.static",
] ]
[tool.ruff.lint] [tool.ruff.lint]

View File

@ -199,7 +199,12 @@ class TestAddonManager:
settings = Settings() settings = Settings()
settings.setdict(get_reactor_settings()) settings.setdict(get_reactor_settings())
settings.set("KEY", "default", priority="default") settings.set("KEY", "default", priority="default")
runner = CrawlerRunner(settings) runner_cls = (
CrawlerRunner
if settings.getbool("TWISTED_ENABLED", True)
else AsyncCrawlerRunner
)
runner = runner_cls(settings)
crawler = runner.create_crawler(MySpider) crawler = runner.create_crawler(MySpider)
assert crawler.settings.get("KEY") == "default" assert crawler.settings.get("KEY") == "default"
yield crawler.crawl() yield crawler.crawl()

View File

@ -1,3 +1,5 @@
import pytest
from scrapy.utils.test import get_crawler from scrapy.utils.test import get_crawler
from tests.mockserver.http import MockServer from tests.mockserver.http import MockServer
from tests.spiders import ( from tests.spiders import (
@ -10,6 +12,7 @@ from tests.spiders import (
from tests.utils.decorators import inlineCallbacks from tests.utils.decorators import inlineCallbacks
@pytest.mark.requires_http_handler
class TestCloseSpider: class TestCloseSpider:
@classmethod @classmethod
def setup_class(cls): def setup_class(cls):

View File

@ -501,6 +501,7 @@ class TestContractsManager:
assert not self.results.failures assert not self.results.failures
assert self.results.errors assert self.results.errors
@pytest.mark.requires_http_handler
@inlineCallbacks @inlineCallbacks
def test_same_url(self): def test_same_url(self):
class TestSameUrlSpider(Spider): class TestSameUrlSpider(Spider):

View File

@ -38,6 +38,7 @@ class TestSlot:
assert repr(slot) == "Slot(concurrency=8, delay=0.10, randomize_delay=True)" assert repr(slot) == "Slot(concurrency=8, delay=0.10, randomize_delay=True)"
@pytest.mark.requires_reactor
class TestContextFactoryBase: class TestContextFactoryBase:
context_factory = None context_factory = None

View File

@ -2,16 +2,17 @@ from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
import pytest
from scrapy.utils.test import get_crawler from scrapy.utils.test import get_crawler
from tests.spiders import SimpleSpider from tests.spiders import SimpleSpider
from tests.utils.decorators import deferred_f_from_coro_f from tests.utils.decorators import deferred_f_from_coro_f
if TYPE_CHECKING: if TYPE_CHECKING:
import pytest
from tests.mockserver.http import MockServer from tests.mockserver.http import MockServer
@pytest.mark.requires_http_handler
@deferred_f_from_coro_f @deferred_f_from_coro_f
async def test_scraper_exception( async def test_scraper_exception(
mockserver: MockServer, mockserver: MockServer,

View File

@ -61,6 +61,7 @@ if TYPE_CHECKING:
from scrapy.statscollectors import StatsCollector from scrapy.statscollectors import StatsCollector
@pytest.mark.requires_http_handler # easier than marking many individual tests
class TestCrawl: class TestCrawl:
mockserver: MockServer mockserver: MockServer
@ -428,6 +429,7 @@ with multiples lines
assert "NotSupported: Unsupported URL scheme 'foo'" in caplog.text assert "NotSupported: Unsupported URL scheme 'foo'" in caplog.text
@pytest.mark.requires_http_handler
class TestCrawlSpider: class TestCrawlSpider:
mockserver: MockServer mockserver: MockServer

View File

@ -646,12 +646,16 @@ class TestCrawlerProcess(TestBaseCrawler):
@pytest.mark.only_asyncio @pytest.mark.only_asyncio
class TestAsyncCrawlerProcess(TestBaseCrawler): class TestAsyncCrawlerProcess(TestBaseCrawler):
def test_crawler_process_accepts_dict(self): def test_crawler_process_accepts_dict(self, reactor_pytest: str) -> None:
runner = AsyncCrawlerProcess({"foo": "bar"}, install_root_handler=False) runner = AsyncCrawlerProcess(
{"foo": "bar", "TWISTED_ENABLED": reactor_pytest != "none"},
install_root_handler=False,
)
assert runner.settings["foo"] == "bar" assert runner.settings["foo"] == "bar"
self.assertOptionIsDefault(runner.settings, "RETRY_ENABLED") self.assertOptionIsDefault(runner.settings, "RETRY_ENABLED")
def test_crawler_process_accepts_None(self): @pytest.mark.requires_reactor # can't pass TWISTED_ENABLED=False
def test_crawler_process_accepts_None(self) -> None:
runner = AsyncCrawlerProcess(install_root_handler=False) runner = AsyncCrawlerProcess(install_root_handler=False)
self.assertOptionIsDefault(runner.settings, "RETRY_ENABLED") self.assertOptionIsDefault(runner.settings, "RETRY_ENABLED")
@ -672,6 +676,7 @@ class NoRequestsSpider(scrapy.Spider):
yield yield
@pytest.mark.requires_reactor
class TestCrawlerRunnerHasSpider: class TestCrawlerRunnerHasSpider:
@staticmethod @staticmethod
def _runner(): def _runner():

View File

@ -200,7 +200,7 @@ class TestHttps2Proxy(H2DownloadHandlerMixin, TestHttpProxyBase):
) -> None: ) -> None:
with pytest.raises(NotImplementedError): with pytest.raises(NotImplementedError):
await maybe_deferred_to_future( await maybe_deferred_to_future(
super().test_download_with_proxy_https_timeout(proxy_mockserver) super().test_download_with_proxy_https_timeout(proxy_mockserver) # type: ignore[arg-type]
) )
@deferred_f_from_coro_f @deferred_f_from_coro_f
@ -209,5 +209,5 @@ class TestHttps2Proxy(H2DownloadHandlerMixin, TestHttpProxyBase):
) -> None: ) -> None:
with pytest.raises(UnsupportedURLSchemeError): with pytest.raises(UnsupportedURLSchemeError):
await maybe_deferred_to_future( await maybe_deferred_to_future(
super().test_download_with_proxy_without_http_scheme(proxy_mockserver) super().test_download_with_proxy_without_http_scheme(proxy_mockserver) # type: ignore[arg-type]
) )

View File

@ -67,6 +67,7 @@ class TestCrawl:
def setup_method(self): def setup_method(self):
self.runner = CrawlerRunner() self.runner = CrawlerRunner()
@pytest.mark.requires_http_handler
@inlineCallbacks @inlineCallbacks
def test_delay(self): def test_delay(self):
crawler = get_crawler(DownloaderSlotsSettingsTestSpider) crawler = get_crawler(DownloaderSlotsSettingsTestSpider)
@ -128,6 +129,7 @@ def test_get_slot_deprecated_spider_arg():
assert slot1 == slot2 assert slot1 == slot2
@pytest.mark.requires_http_handler
@pytest.mark.parametrize( @pytest.mark.parametrize(
"priority_queue_class", "priority_queue_class",
[ [

View File

@ -373,6 +373,7 @@ class TestEngineBase:
class TestEngine(TestEngineBase): class TestEngine(TestEngineBase):
@pytest.mark.requires_http_handler
@deferred_f_from_coro_f @deferred_f_from_coro_f
async def test_crawler(self, mockserver: MockServer) -> None: async def test_crawler(self, mockserver: MockServer) -> None:
for spider in ( for spider in (
@ -390,6 +391,7 @@ class TestEngine(TestEngineBase):
self._assert_signals_caught(run) self._assert_signals_caught(run)
self._assert_bytes_received(run) self._assert_bytes_received(run)
@pytest.mark.requires_http_handler
@deferred_f_from_coro_f @deferred_f_from_coro_f
async def test_crawler_dupefilter(self, mockserver: MockServer) -> None: async def test_crawler_dupefilter(self, mockserver: MockServer) -> None:
run = CrawlerRun(DupeFilterSpider) run = CrawlerRun(DupeFilterSpider)
@ -397,12 +399,14 @@ class TestEngine(TestEngineBase):
self._assert_scheduled_requests(run, count=8) self._assert_scheduled_requests(run, count=8)
self._assert_dropped_requests(run) self._assert_dropped_requests(run)
@pytest.mark.requires_http_handler
@deferred_f_from_coro_f @deferred_f_from_coro_f
async def test_crawler_itemerror(self, mockserver: MockServer) -> None: async def test_crawler_itemerror(self, mockserver: MockServer) -> None:
run = CrawlerRun(ItemZeroDivisionErrorSpider) run = CrawlerRun(ItemZeroDivisionErrorSpider)
await run.run(mockserver) await run.run(mockserver)
self._assert_items_error(run) self._assert_items_error(run)
@pytest.mark.requires_http_handler
@deferred_f_from_coro_f @deferred_f_from_coro_f
async def test_crawler_change_close_reason_on_idle( async def test_crawler_change_close_reason_on_idle(
self, mockserver: MockServer self, mockserver: MockServer

View File

@ -4,6 +4,7 @@ from collections import deque
from logging import ERROR from logging import ERROR
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
import pytest
from twisted.internet.defer import Deferred from twisted.internet.defer import Deferred
from scrapy import Request, Spider, signals from scrapy import Request, Spider, signals
@ -14,8 +15,6 @@ from tests.test_scheduler import MemoryScheduler
from tests.utils.decorators import deferred_f_from_coro_f from tests.utils.decorators import deferred_f_from_coro_f
if TYPE_CHECKING: if TYPE_CHECKING:
import pytest
from scrapy.http import Response from scrapy.http import Response
@ -28,6 +27,7 @@ async def sleep(seconds: float = 0.001) -> None:
class TestMain: class TestMain:
@pytest.mark.requires_reactor # TODO
@deferred_f_from_coro_f @deferred_f_from_coro_f
async def test_sleep(self): async def test_sleep(self):
"""Neither asynchronous sleeps on Spider.start() nor the equivalent on """Neither asynchronous sleeps on Spider.start() nor the equivalent on
@ -332,6 +332,7 @@ class TestRequestSendOrder:
# Examples from the “Start requests” section of the documentation about # Examples from the “Start requests” section of the documentation about
# spiders. # spiders.
@pytest.mark.requires_http_handler
@deferred_f_from_coro_f @deferred_f_from_coro_f
async def test_lazy(self): async def test_lazy(self):
start_nums = [1, 2, 4] start_nums = [1, 2, 4]

View File

@ -2,6 +2,8 @@ from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
import pytest
from scrapy.exceptions import StopDownload from scrapy.exceptions import StopDownload
from tests.test_engine import ( from tests.test_engine import (
AttrsItemsSpider, AttrsItemsSpider,
@ -14,8 +16,6 @@ from tests.test_engine import (
from tests.utils.decorators import deferred_f_from_coro_f from tests.utils.decorators import deferred_f_from_coro_f
if TYPE_CHECKING: if TYPE_CHECKING:
import pytest
from tests.mockserver.http import MockServer from tests.mockserver.http import MockServer
@ -26,6 +26,7 @@ class BytesReceivedCrawlerRun(CrawlerRun):
class TestBytesReceivedEngine(TestEngineBase): class TestBytesReceivedEngine(TestEngineBase):
@pytest.mark.requires_http_handler
@deferred_f_from_coro_f @deferred_f_from_coro_f
async def test_crawler( async def test_crawler(
self, mockserver: MockServer, caplog: pytest.LogCaptureFixture self, mockserver: MockServer, caplog: pytest.LogCaptureFixture

View File

@ -2,6 +2,8 @@ from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
import pytest
from scrapy.exceptions import StopDownload from scrapy.exceptions import StopDownload
from tests.test_engine import ( from tests.test_engine import (
AttrsItemsSpider, AttrsItemsSpider,
@ -14,8 +16,6 @@ from tests.test_engine import (
from tests.utils.decorators import deferred_f_from_coro_f from tests.utils.decorators import deferred_f_from_coro_f
if TYPE_CHECKING: if TYPE_CHECKING:
import pytest
from tests.mockserver.http import MockServer from tests.mockserver.http import MockServer
@ -26,6 +26,7 @@ class HeadersReceivedCrawlerRun(CrawlerRun):
class TestHeadersReceivedEngine(TestEngineBase): class TestHeadersReceivedEngine(TestEngineBase):
@pytest.mark.requires_http_handler
@deferred_f_from_coro_f @deferred_f_from_coro_f
async def test_crawler( async def test_crawler(
self, mockserver: MockServer, caplog: pytest.LogCaptureFixture self, mockserver: MockServer, caplog: pytest.LogCaptureFixture

View File

@ -6,6 +6,8 @@ from scrapy.extensions.telnet import TelnetConsole
from scrapy.utils.test import get_crawler from scrapy.utils.test import get_crawler
from tests.utils.decorators import inlineCallbacks from tests.utils.decorators import inlineCallbacks
pytestmark = pytest.mark.requires_reactor
class TestTelnetExtension: class TestTelnetExtension:
def _get_console_and_portal(self, settings=None): def _get_console_and_portal(self, settings=None):

View File

@ -163,6 +163,7 @@ class TestFileFeedStorage:
assert storage.path == path assert storage.path == path
@pytest.mark.requires_reactor # needs a reactor for BlockingFeedStorage
class TestFTPFeedStorage: class TestFTPFeedStorage:
def get_test_spider(self, settings=None): def get_test_spider(self, settings=None):
class TestSpider(scrapy.Spider): class TestSpider(scrapy.Spider):
@ -277,6 +278,7 @@ class TestBlockingFeedStorage:
@pytest.mark.requires_boto3 @pytest.mark.requires_boto3
@pytest.mark.requires_reactor # needs a reactor for BlockingFeedStorage
class TestS3FeedStorage: class TestS3FeedStorage:
def test_parse_credentials(self): def test_parse_credentials(self):
aws_credentials = { aws_credentials = {
@ -506,6 +508,7 @@ class TestS3FeedStorage:
assert "S3 does not support appending to files" in str(log) assert "S3 does not support appending to files" in str(log)
@pytest.mark.requires_reactor # needs a reactor for BlockingFeedStorage
class TestGCSFeedStorage: class TestGCSFeedStorage:
def test_parse_settings(self): def test_parse_settings(self):
try: try:
@ -846,6 +849,7 @@ class ExceptionJsonItemExporter(JsonItemExporter):
raise RuntimeError("foo") raise RuntimeError("foo")
@pytest.mark.requires_http_handler
class TestFeedExport(TestFeedExportBase): class TestFeedExport(TestFeedExportBase):
async def run_and_export( async def run_and_export(
self, spider_cls: type[Spider], settings: dict[str, Any] self, spider_cls: type[Spider], settings: dict[str, Any]
@ -1830,6 +1834,7 @@ class TestFeedExport(TestFeedExportBase):
assert not Storage.file_was_closed assert not Storage.file_was_closed
@pytest.mark.requires_http_handler
class TestFeedPostProcessedExports(TestFeedExportBase): class TestFeedPostProcessedExports(TestFeedExportBase):
items = [{"foo": "bar"}] items = [{"foo": "bar"}]
expected = b"foo\r\nbar\r\n" expected = b"foo\r\nbar\r\n"
@ -2348,6 +2353,7 @@ class TestFeedPostProcessedExports(TestFeedExportBase):
assert result == expected assert result == expected
@pytest.mark.requires_http_handler
class TestBatchDeliveries(TestFeedExportBase): class TestBatchDeliveries(TestFeedExportBase):
_file_mark = "_%(batch_time)s_#%(batch_id)02d_" _file_mark = "_%(batch_time)s_#%(batch_id)02d_"

View File

@ -253,6 +253,7 @@ class DropSomeItemsPipeline:
self.drop = True self.drop = True
@pytest.mark.requires_http_handler
class TestShowOrSkipMessages: class TestShowOrSkipMessages:
@classmethod @classmethod
def setup_class(cls): def setup_class(cls):

View File

@ -57,6 +57,7 @@ class RedirectedMediaDownloadSpider(MediaDownloadSpider):
) )
@pytest.mark.requires_http_handler
class TestFileDownloadCrawl: class TestFileDownloadCrawl:
pipeline_class = "scrapy.pipelines.files.FilesPipeline" pipeline_class = "scrapy.pipelines.files.FilesPipeline"
store_setting_key = "FILES_STORE" store_setting_key = "FILES_STORE"

View File

@ -691,6 +691,7 @@ class TestGCSFilesStore:
store.bucket.get_blob.assert_called_with(expected_blob_path) store.bucket.get_blob.assert_called_with(expected_blob_path)
@pytest.mark.requires_reactor # needs a reactor for FTPFilesStore
class TestFTPFileStore: class TestFTPFileStore:
@inlineCallbacks @inlineCallbacks
def test_persist(self): def test_persist(self):

View File

@ -127,6 +127,7 @@ class ItemSpider(Spider):
return {"field": 42} return {"field": 42}
@pytest.mark.requires_http_handler
class TestPipeline: class TestPipeline:
def _on_item_scraped(self, item): def _on_item_scraped(self, item):
assert isinstance(item, dict) assert isinstance(item, dict)
@ -265,6 +266,7 @@ class TestCustomPipelineManager:
): ):
itemproc.process_item({}, crawler.spider) itemproc.process_item({}, crawler.spider)
@pytest.mark.requires_http_handler
@deferred_f_from_coro_f @deferred_f_from_coro_f
async def test_integration_recommended(self, mockserver: MockServer) -> None: async def test_integration_recommended(self, mockserver: MockServer) -> None:
class CustomPipelineManager(ItemPipelineManager): class CustomPipelineManager(ItemPipelineManager):
@ -291,6 +293,7 @@ class TestCustomPipelineManager:
assert len(items) == 1 assert len(items) == 1
@pytest.mark.requires_http_handler
@deferred_f_from_coro_f @deferred_f_from_coro_f
async def test_integration_no_async_subclass(self, mockserver: MockServer) -> None: async def test_integration_no_async_subclass(self, mockserver: MockServer) -> None:
class CustomPipelineManager(ItemPipelineManager): class CustomPipelineManager(ItemPipelineManager):
@ -349,6 +352,7 @@ class TestCustomPipelineManager:
assert len(items) == 1 assert len(items) == 1
@pytest.mark.requires_http_handler
@deferred_f_from_coro_f @deferred_f_from_coro_f
async def test_integration_no_async_not_subclass( async def test_integration_no_async_not_subclass(
self, mockserver: MockServer self, mockserver: MockServer

View File

@ -61,6 +61,7 @@ def _wrong_credentials(proxy_url):
return urlunsplit(bad_auth_proxy) return urlunsplit(bad_auth_proxy)
@pytest.mark.requires_http_handler
@pytest.mark.requires_mitmproxy @pytest.mark.requires_mitmproxy
class TestProxyConnect: class TestProxyConnect:
@classmethod @classmethod

View File

@ -1,3 +1,4 @@
import pytest
from testfixtures import LogCapture from testfixtures import LogCapture
from scrapy import Request, signals from scrapy import Request, signals
@ -62,6 +63,7 @@ class AlternativeCallbacksMiddleware:
return response.replace(request=new_request) return response.replace(request=new_request)
@pytest.mark.requires_http_handler
class TestCrawl: class TestCrawl:
@classmethod @classmethod
def setup_class(cls): def setup_class(cls):

View File

@ -1,3 +1,4 @@
import pytest
from testfixtures import LogCapture from testfixtures import LogCapture
from scrapy.http import Request from scrapy.http import Request
@ -148,6 +149,7 @@ class KeywordArgumentsSpider(MockServerSpider):
self.crawler.stats.inc_value("boolean_checks", 1) self.crawler.stats.inc_value("boolean_checks", 1)
@pytest.mark.requires_http_handler
class TestCallbackKeywordArguments: class TestCallbackKeywordArguments:
@classmethod @classmethod
def setup_class(cls): def setup_class(cls):

View File

@ -369,6 +369,7 @@ class TestIntegrationWithDownloaderAwareInMemory:
}, },
) )
@pytest.mark.requires_http_handler
@inlineCallbacks @inlineCallbacks
def test_integration_downloader_aware_priority_queue(self): def test_integration_downloader_aware_priority_queue(self):
with MockServer() as mockserver: with MockServer() as mockserver:

View File

@ -144,6 +144,7 @@ class TestSimpleScheduler(InterfaceCheckMixin):
assert close_result == "close" assert close_result == "close"
@pytest.mark.requires_http_handler
class TestMinimalSchedulerCrawl: class TestMinimalSchedulerCrawl:
scheduler_cls = MinimalScheduler scheduler_cls = MinimalScheduler

View File

@ -50,6 +50,7 @@ class TestMockServer:
item = await get_from_asyncio_queue(item) item = await get_from_asyncio_queue(item)
self.items.append(item) self.items.append(item)
@pytest.mark.requires_http_handler
@pytest.mark.only_asyncio @pytest.mark.only_asyncio
@inlineCallbacks @inlineCallbacks
def test_simple_pipeline(self): def test_simple_pipeline(self):

View File

@ -162,6 +162,7 @@ class TestMain:
await self._test_start(start, [ITEM_A]) await self._test_start(start, [ITEM_A])
@pytest.mark.requires_reactor # needs a reactor for twisted_sleep()
@deferred_f_from_coro_f @deferred_f_from_coro_f
async def test_twisted_delayed(self): async def test_twisted_delayed(self):
async def start(spider): async def start(spider):

View File

@ -191,6 +191,7 @@ class TestHttpErrorMiddlewareHandleAll:
mw.process_spider_input(res402) mw.process_spider_input(res402)
@pytest.mark.requires_http_handler
class TestHttpErrorMiddlewareIntegrational: class TestHttpErrorMiddlewareIntegrational:
@classmethod @classmethod
def setup_class(cls): def setup_class(cls):

View File

@ -1,3 +1,4 @@
import pytest
from testfixtures import LogCapture from testfixtures import LogCapture
from scrapy import Request, Spider from scrapy import Request, Spider
@ -318,6 +319,7 @@ class NotGeneratorOutputChainSpider(Spider):
# ================================================================================ # ================================================================================
@pytest.mark.requires_http_handler
class TestSpiderMiddleware: class TestSpiderMiddleware:
mockserver: MockServer mockserver: MockServer

View File

@ -341,10 +341,12 @@ class TestMain:
[NoOpSpiderMiddleware, AsyncioSleepSpiderMiddleware, NoOpSpiderMiddleware] [NoOpSpiderMiddleware, AsyncioSleepSpiderMiddleware, NoOpSpiderMiddleware]
) )
@pytest.mark.requires_reactor
@deferred_f_from_coro_f @deferred_f_from_coro_f
async def test_twisted_sleep_single(self): async def test_twisted_sleep_single(self):
await self._test_sleep([TwistedSleepSpiderMiddleware]) await self._test_sleep([TwistedSleepSpiderMiddleware])
@pytest.mark.requires_reactor
@deferred_f_from_coro_f @deferred_f_from_coro_f
async def test_twisted_sleep_multiple(self): async def test_twisted_sleep_multiple(self):
await self._test_sleep( await self._test_sleep(

View File

@ -26,6 +26,7 @@ if TYPE_CHECKING:
from collections.abc import AsyncGenerator, Awaitable, Callable, Generator from collections.abc import AsyncGenerator, Awaitable, Callable, Generator
@pytest.mark.requires_reactor
@pytest.mark.filterwarnings("ignore::scrapy.exceptions.ScrapyDeprecationWarning") @pytest.mark.filterwarnings("ignore::scrapy.exceptions.ScrapyDeprecationWarning")
class TestMustbeDeferred: class TestMustbeDeferred:
@inlineCallbacks @inlineCallbacks
@ -153,6 +154,7 @@ class TestAsyncDefTestsuite:
raise RuntimeError("This is expected to be raised") raise RuntimeError("This is expected to be raised")
@pytest.mark.requires_reactor
class TestParallelAsync: class TestParallelAsync:
"""This tests _AsyncCooperatorAdapter by testing parallel_async which is its only usage. """This tests _AsyncCooperatorAdapter by testing parallel_async which is its only usage.

View File

@ -29,6 +29,7 @@ class TestAsyncio:
assert original_reactor == reactor assert original_reactor == reactor
@pytest.mark.requires_reactor
@pytest.mark.only_asyncio @pytest.mark.only_asyncio
@deferred_f_from_coro_f @deferred_f_from_coro_f
async def test_set_asyncio_event_loop(self): async def test_set_asyncio_event_loop(self):

View File

@ -8,6 +8,7 @@ import logging
import pytest import pytest
from scrapy.utils.log import LogCounterHandler from scrapy.utils.log import LogCounterHandler
from scrapy.utils.reactor import is_asyncio_reactor_installed, is_reactor_installed
def test_counter_handler() -> None: def test_counter_handler() -> None:
@ -35,3 +36,14 @@ def test_stderr_log_handler() -> None:
def test_pending_asyncio_tasks() -> None: def test_pending_asyncio_tasks() -> None:
"""Test that there are no pending asyncio tasks.""" """Test that there are no pending asyncio tasks."""
assert not asyncio.all_tasks() assert not asyncio.all_tasks()
def test_installed_reactor(reactor_pytest: str) -> None:
"""Test that the correct reactor is installed."""
match reactor_pytest:
case "asyncio":
assert is_asyncio_reactor_installed()
case "default":
assert not is_asyncio_reactor_installed()
case "none":
assert not is_reactor_installed()

View File

@ -7,7 +7,8 @@ import pytest
from twisted.internet.defer import Deferred from twisted.internet.defer import Deferred
from twisted.internet.defer import inlineCallbacks as inlineCallbacks_orig from twisted.internet.defer import inlineCallbacks as inlineCallbacks_orig
from scrapy.utils.defer import deferred_from_coro from scrapy.utils.defer import deferred_from_coro, deferred_to_future
from scrapy.utils.reactor import is_reactor_installed
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Awaitable, Callable, Generator from collections.abc import Awaitable, Callable, Generator
@ -18,28 +19,48 @@ _P = ParamSpec("_P")
def inlineCallbacks( def inlineCallbacks(
f: Callable[_P, Generator[Deferred[Any], Any, None]], f: Callable[_P, Generator[Deferred[Any], Any, None]],
) -> Callable[_P, Deferred[None]]: ) -> Callable[_P, Awaitable[None]]:
"""Like :func:`twisted.internet.defer.inlineCallbacks`, but marks the """Mark a test function written in a :func:`twisted.internet.defer.inlineCallbacks` style.
decorated function with ``@pytest.mark.requires_reactor``."""
This calls :func:`twisted.internet.defer.inlineCallbacks` and then:
* with ``pytest-twisted`` this returns the resulting Deferred
* with ``pytest-asyncio`` this converts the resulting Deferred into a
coroutine
"""
if not is_reactor_installed():
@pytest.mark.asyncio
@wraps(f)
async def wrapper_coro(*args: _P.args, **kwargs: _P.kwargs) -> None:
await deferred_to_future(inlineCallbacks_orig(f)(*args, **kwargs))
return wrapper_coro
@pytest.mark.requires_reactor
@wraps(f) @wraps(f)
@inlineCallbacks_orig @inlineCallbacks_orig
def wrapper( def wrapper_dfd(
*args: _P.args, **kwargs: _P.kwargs *args: _P.args, **kwargs: _P.kwargs
) -> Generator[Deferred[Any], Any, None]: ) -> Generator[Deferred[Any], Any, None]:
return f(*args, **kwargs) return f(*args, **kwargs)
return wrapper return wrapper_dfd
def deferred_f_from_coro_f( def deferred_f_from_coro_f(
coro_f: Callable[_P, Awaitable[None]], coro_f: Callable[_P, Awaitable[None]],
) -> Callable[_P, Deferred[None]]: ) -> Callable[_P, Awaitable[None]]:
"""Like :func:`scrapy.utils.defer.deferred_f_from_coro_f`, but marks the """Mark a test function that returns a coroutine.
decorated function with ``@pytest.mark.requires_reactor``."""
* with ``pytest-twisted`` this converts a coroutine into a
:class:`twisted.internet.defer.Deferred`
* with ``pytest-asyncio`` this is a no-op
"""
if not is_reactor_installed():
return pytest.mark.asyncio(coro_f)
@pytest.mark.requires_reactor
@wraps(coro_f) @wraps(coro_f)
def f(*coro_args: _P.args, **coro_kwargs: _P.kwargs) -> Deferred[None]: def f(*coro_args: _P.args, **coro_kwargs: _P.kwargs) -> Deferred[None]:
return deferred_from_coro(coro_f(*coro_args, **coro_kwargs)) return deferred_from_coro(coro_f(*coro_args, **coro_kwargs))

View File

@ -162,6 +162,9 @@ commands =
{[testenv]commands} --reactor=default {[testenv]commands} --reactor=default
[testenv:no-reactor] [testenv:no-reactor]
deps =
{[testenv]deps}
pytest-asyncio
commands = commands =
{[testenv]commands} -p no:twisted --reactor=none {[testenv]commands} -p no:twisted --reactor=none
@ -174,7 +177,9 @@ setenv =
[testenv:no-reactor-pinned] [testenv:no-reactor-pinned]
basepython = {[pinned]basepython} basepython = {[pinned]basepython}
deps = {[testenv:pinned]deps} deps =
{[testenv:pinned]deps}
pytest-asyncio
commands = {[pinned]commands} -p no:twisted --reactor=none commands = {[pinned]commands} -p no:twisted --reactor=none
setenv = setenv =
{[pinned]setenv} {[pinned]setenv}