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
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"')
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_not_asyncio: marks tests that require the asyncio loop to not be used",
"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_botocore: marks tests that need botocore (but not boto3)",
"requires_boto3: marks tests that need botocore and boto3",
"requires_mitmproxy: marks tests that need mitmproxy",
]
filterwarnings = [
"ignore::DeprecationWarning:twisted.web.static"
"ignore::DeprecationWarning:twisted.web.static",
]
[tool.ruff.lint]

View File

@ -199,7 +199,12 @@ class TestAddonManager:
settings = Settings()
settings.setdict(get_reactor_settings())
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)
assert crawler.settings.get("KEY") == "default"
yield crawler.crawl()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -200,7 +200,7 @@ class TestHttps2Proxy(H2DownloadHandlerMixin, TestHttpProxyBase):
) -> None:
with pytest.raises(NotImplementedError):
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
@ -209,5 +209,5 @@ class TestHttps2Proxy(H2DownloadHandlerMixin, TestHttpProxyBase):
) -> None:
with pytest.raises(UnsupportedURLSchemeError):
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):
self.runner = CrawlerRunner()
@pytest.mark.requires_http_handler
@inlineCallbacks
def test_delay(self):
crawler = get_crawler(DownloaderSlotsSettingsTestSpider)
@ -128,6 +129,7 @@ def test_get_slot_deprecated_spider_arg():
assert slot1 == slot2
@pytest.mark.requires_http_handler
@pytest.mark.parametrize(
"priority_queue_class",
[

View File

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

View File

@ -4,6 +4,7 @@ from collections import deque
from logging import ERROR
from typing import TYPE_CHECKING
import pytest
from twisted.internet.defer import Deferred
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
if TYPE_CHECKING:
import pytest
from scrapy.http import Response
@ -28,6 +27,7 @@ async def sleep(seconds: float = 0.001) -> None:
class TestMain:
@pytest.mark.requires_reactor # TODO
@deferred_f_from_coro_f
async def test_sleep(self):
"""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
# spiders.
@pytest.mark.requires_http_handler
@deferred_f_from_coro_f
async def test_lazy(self):
start_nums = [1, 2, 4]

View File

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

View File

@ -2,6 +2,8 @@ from __future__ import annotations
from typing import TYPE_CHECKING
import pytest
from scrapy.exceptions import StopDownload
from tests.test_engine import (
AttrsItemsSpider,
@ -14,8 +16,6 @@ from tests.test_engine import (
from tests.utils.decorators import deferred_f_from_coro_f
if TYPE_CHECKING:
import pytest
from tests.mockserver.http import MockServer
@ -26,6 +26,7 @@ class HeadersReceivedCrawlerRun(CrawlerRun):
class TestHeadersReceivedEngine(TestEngineBase):
@pytest.mark.requires_http_handler
@deferred_f_from_coro_f
async def test_crawler(
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 tests.utils.decorators import inlineCallbacks
pytestmark = pytest.mark.requires_reactor
class TestTelnetExtension:
def _get_console_and_portal(self, settings=None):

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -8,6 +8,7 @@ import logging
import pytest
from scrapy.utils.log import LogCounterHandler
from scrapy.utils.reactor import is_asyncio_reactor_installed, is_reactor_installed
def test_counter_handler() -> None:
@ -35,3 +36,14 @@ def test_stderr_log_handler() -> None:
def test_pending_asyncio_tasks() -> None:
"""Test that there are no pending asyncio 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 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:
from collections.abc import Awaitable, Callable, Generator
@ -18,28 +19,48 @@ _P = ParamSpec("_P")
def inlineCallbacks(
f: Callable[_P, Generator[Deferred[Any], Any, None]],
) -> Callable[_P, Deferred[None]]:
"""Like :func:`twisted.internet.defer.inlineCallbacks`, but marks the
decorated function with ``@pytest.mark.requires_reactor``."""
) -> Callable[_P, Awaitable[None]]:
"""Mark a test function written in a :func:`twisted.internet.defer.inlineCallbacks` style.
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)
@inlineCallbacks_orig
def wrapper(
def wrapper_dfd(
*args: _P.args, **kwargs: _P.kwargs
) -> Generator[Deferred[Any], Any, None]:
return f(*args, **kwargs)
return wrapper
return wrapper_dfd
def deferred_f_from_coro_f(
coro_f: Callable[_P, Awaitable[None]],
) -> Callable[_P, Deferred[None]]:
"""Like :func:`scrapy.utils.defer.deferred_f_from_coro_f`, but marks the
decorated function with ``@pytest.mark.requires_reactor``."""
) -> Callable[_P, Awaitable[None]]:
"""Mark a test function that returns a coroutine.
* 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)
def f(*coro_args: _P.args, **coro_kwargs: _P.kwargs) -> Deferred[None]:
return deferred_from_coro(coro_f(*coro_args, **coro_kwargs))

View File

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