Enable in-process HTTP tests without a reactor. (#7254)

This commit is contained in:
Andrey Rakhmatullin 2026-02-13 21:08:06 +03:00 committed by GitHub
parent 6e0a0e476a
commit ccfa052fa1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 51 additions and 71 deletions

View File

@ -93,10 +93,7 @@ def pytest_runtest_setup(item):
# Skip tests based on reactor markers
reactor = item.config.getoption("--reactor")
if (
item.get_closest_marker("requires_reactor")
or item.get_closest_marker("requires_http_handler")
) and reactor == "none":
if item.get_closest_marker("requires_reactor") 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,7 +230,6 @@ 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",

View File

@ -131,8 +131,8 @@ def get_reactor_settings() -> dict[str, Any]:
settings["TWISTED_ENABLED"] = False
settings["DOWNLOAD_HANDLERS"] = {
"ftp": None,
"http": None,
"https": None,
"http": "scrapy.core.downloader.handlers._httpx.HttpxDownloadHandler",
"https": "scrapy.core.downloader.handlers._httpx.HttpxDownloadHandler",
}
return settings
@ -208,7 +208,14 @@ def mock_google_cloud_storage() -> tuple[Any, Any, Any]: # pragma: no cover
return (client_mock, bucket_mock, blob_mock)
def get_web_client_agent_req(url: str) -> Deferred[TxResponse]:
def get_web_client_agent_req(url: str) -> Deferred[TxResponse]: # pragma: no cover
warnings.warn(
"The get_web_client_agent_req() function is deprecated"
" and will be removed in a future version of Scrapy.",
category=ScrapyDeprecationWarning,
stacklevel=2,
)
from twisted.internet import reactor
agent = Agent(reactor)

View File

@ -18,7 +18,7 @@ from scrapy.linkextractors import LinkExtractor
from scrapy.spiders import Spider
from scrapy.spiders.crawl import CrawlSpider, Rule
from scrapy.utils.defer import deferred_to_future, maybe_deferred_to_future
from scrapy.utils.test import get_from_asyncio_queue, get_web_client_agent_req
from scrapy.utils.test import get_from_asyncio_queue
class MockServerSpider(Spider):
@ -199,28 +199,24 @@ class AsyncDefDeferredDirectSpider(SimpleSpider):
name = "asyncdef_deferred_direct"
async def parse(self, response):
resp = await get_web_client_agent_req(self.mockserver.url("/status?n=200"))
yield {"code": resp.code}
await defer.succeed(None)
yield {"code": 200}
class AsyncDefDeferredWrappedSpider(SimpleSpider):
name = "asyncdef_deferred_wrapped"
async def parse(self, response):
resp = await deferred_to_future(
get_web_client_agent_req(self.mockserver.url("/status?n=200"))
)
yield {"code": resp.code}
await deferred_to_future(defer.succeed(None))
yield {"code": 200}
class AsyncDefDeferredMaybeWrappedSpider(SimpleSpider):
name = "asyncdef_deferred_wrapped"
async def parse(self, response):
resp = await maybe_deferred_to_future(
get_web_client_agent_req(self.mockserver.url("/status?n=200"))
)
yield {"code": resp.code}
await maybe_deferred_to_future(defer.succeed(None))
yield {"code": 200}
class AsyncDefAsyncioGenSpider(SimpleSpider):

View File

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

View File

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

View File

@ -2,17 +2,16 @@ 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 coroutine_test
if TYPE_CHECKING:
import pytest
from tests.mockserver.http import MockServer
@pytest.mark.requires_http_handler
@coroutine_test
async def test_scraper_exception(
mockserver: MockServer,

View File

@ -14,11 +14,11 @@ from twisted.internet.ssl import Certificate
from twisted.python.failure import Failure
from scrapy import Spider, signals
from scrapy.crawler import CrawlerRunner
from scrapy.crawler import AsyncCrawlerRunner, CrawlerRunner
from scrapy.exceptions import CloseSpider, ScrapyDeprecationWarning, StopDownload
from scrapy.http import Request
from scrapy.http.response import Response
from scrapy.utils.defer import maybe_deferred_to_future
from scrapy.utils.defer import ensure_awaitable, maybe_deferred_to_future
from scrapy.utils.engine import format_engine_status, get_engine_status
from scrapy.utils.python import to_unicode
from scrapy.utils.test import get_crawler, get_reactor_settings
@ -61,7 +61,6 @@ if TYPE_CHECKING:
from scrapy.statscollectors import StatsCollector
@pytest.mark.requires_http_handler # easier than marking many individual tests
class TestCrawl:
mockserver: MockServer
@ -402,9 +401,15 @@ with multiples lines
)
assert "Got response 200" in str(log)
@inline_callbacks_test
def test_crawl_multiple(self, caplog: pytest.LogCaptureFixture):
runner = CrawlerRunner(get_reactor_settings())
@coroutine_test
async def test_crawl_multiple(self, caplog: pytest.LogCaptureFixture) -> None:
settings_dict = get_reactor_settings()
runner_cls = (
CrawlerRunner
if settings_dict.get("TWISTED_ENABLED", True)
else AsyncCrawlerRunner
)
runner = runner_cls(settings_dict)
runner.crawl(
SimpleSpider,
self.mockserver.url("/status?n=200"),
@ -417,7 +422,7 @@ with multiples lines
)
with caplog.at_level(logging.DEBUG):
yield runner.join()
await ensure_awaitable(runner.join())
self._assert_retried(caplog.text)
assert "Got response 200" in caplog.text
@ -429,7 +434,6 @@ with multiples lines
assert "NotSupported: Unsupported URL scheme 'foo'" in caplog.text
@pytest.mark.requires_http_handler
class TestCrawlSpider:
mockserver: MockServer
@ -636,6 +640,11 @@ class TestCrawlSpider:
yield crawler.crawl(seed=url, mockserver=self.mockserver)
assert crawler.spider.meta["responses"][0].certificate is None
@pytest.mark.xfail(
'config.getoption("--reactor") == "none"',
reason="Not implemented in HttpxDownloadHandler",
strict=True,
)
@pytest.mark.parametrize(
"url",
[
@ -643,6 +652,7 @@ class TestCrawlSpider:
pytest.param(
"/status?n=200",
marks=pytest.mark.xfail(
'config.getoption("--reactor") != "none"',
reason="With HTTP11DownloadHandler, responses with no body are returned early and contain no certificate",
strict=True,
),
@ -669,6 +679,7 @@ class TestCrawlSpider:
pytest.param(
"/status?n=200",
marks=pytest.mark.xfail(
'config.getoption("--reactor") != "none"',
reason="With HTTP11DownloadHandler, responses with no body are returned early and contain no ip_address",
strict=True,
),

View File

@ -93,10 +93,11 @@ class TestLoad:
crawler = get_crawler(settings_dict={"DOWNLOAD_HANDLERS": handlers})
dh = DownloadHandlers(crawler)
assert "scheme" not in dh._schemes
for scheme in handlers: # force load handlers
dh._get_handler(scheme)
assert dh._get_handler("scheme") is None
assert "scheme" not in dh._handlers
assert "scheme" in dh._notconfigured
# get the handler again to cover the code that gets it from dh._notconfigured
assert dh._get_handler("scheme") is None
def test_lazy_handlers(self):
handlers = {"scheme": DummyLazyDH}
@ -108,8 +109,8 @@ class TestLoad:
dh = DownloadHandlers(crawler)
assert "scheme" in dh._schemes
assert "scheme" not in dh._handlers
for scheme in handlers: # force load lazy handler
dh._get_handler(scheme)
handler = dh._get_handler("scheme") # force load lazy handler
assert handler
assert "scheme" in dh._handlers
assert "scheme" not in dh._notconfigured

View File

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

View File

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

View File

@ -332,7 +332,6 @@ class TestRequestSendOrder:
# Examples from the “Start requests” section of the documentation about
# spiders.
@pytest.mark.requires_http_handler
@coroutine_test
async def test_lazy(self):
start_nums = [1, 2, 4]

View File

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

View File

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

View File

@ -849,7 +849,6 @@ 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]
@ -1703,6 +1702,7 @@ class TestFeedExport(TestFeedExportBase):
data = await self.exported_no_data(settings)
assert data["csv"] == b""
@pytest.mark.requires_reactor # needs a reactor for BlockingFeedStorage
@coroutine_test
async def test_multiple_feeds_success_logs_blocking_feed_storage(self):
settings = {
@ -1834,7 +1834,6 @@ 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"
@ -2353,7 +2352,6 @@ 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,7 +253,6 @@ class DropSomeItemsPipeline:
self.drop = True
@pytest.mark.requires_http_handler
class TestShowOrSkipMessages:
@classmethod
def setup_class(cls):

View File

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

View File

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

View File

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

View File

@ -1,4 +1,3 @@
import pytest
from testfixtures import LogCapture
from scrapy.http import Request
@ -149,7 +148,6 @@ 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,7 +369,6 @@ class TestIntegrationWithDownloaderAwareInMemory:
},
)
@pytest.mark.requires_http_handler
@inline_callbacks_test
def test_integration_downloader_aware_priority_queue(self):
with MockServer() as mockserver:

View File

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

View File

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

View File

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

View File

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

View File

@ -168,6 +168,7 @@ commands =
[testenv:no-reactor]
deps =
{[testenv]deps}
httpx
pytest-asyncio
commands =
{[testenv]commands} -p no:twisted --reactor=none
@ -183,6 +184,7 @@ setenv =
basepython = {[pinned]basepython}
deps =
{[testenv:pinned]deps}
httpx==0.26.0
pytest-asyncio
commands = {[pinned]commands} -p no:twisted --reactor=none
setenv =