Merge remote-tracking branch 'origin/master' into faster-shutdown-2

This commit is contained in:
Adrian Chaves 2026-04-29 11:52:12 +02:00
commit 2b397afe52
22 changed files with 121 additions and 99 deletions

View File

@ -27,6 +27,6 @@ repos:
hooks:
- id: sphinx-lint
- repo: https://github.com/scrapy/sphinx-scrapy
rev: 0.8.3
rev: 0.8.4
hooks:
- id: sphinx-scrapy

View File

@ -3,6 +3,16 @@
Release notes
=============
.. _release-2.15.2:
Scrapy 2.15.2 (2026-04-28)
--------------------------
Bug fixes
~~~~~~~~~
- Fixed links in https://docs.scrapy.org/llms.txt (:issue:`7467`)
.. _release-2.15.1:
Scrapy 2.15.1 (2026-04-23)

View File

@ -5,4 +5,4 @@ sphinx
sphinx-notfound-page
sphinx-rtd-theme
sphinx-rtd-dark-mode
sphinx-scrapy @ git+https://github.com/scrapy/sphinx-scrapy.git@0.8.3
sphinx-scrapy @ git+https://github.com/scrapy/sphinx-scrapy.git@0.8.4

View File

@ -153,7 +153,7 @@ sphinx-rtd-theme==3.1.0
# via
# -r requirements.in
# sphinx-rtd-dark-mode
sphinx-scrapy @ git+https://github.com/scrapy/sphinx-scrapy.git@f20366277f2598d0c8a60e55fe282aff2da40dcf
sphinx-scrapy @ git+https://github.com/scrapy/sphinx-scrapy.git@eef1f8c3ab3b74b6891752b8f4624373345bae26
# via -r requirements.in
sphinx-sitemap==2.9.0
# via sphinx-scrapy

View File

@ -154,7 +154,7 @@ module = [
ignore_missing_imports = true
[tool.bumpversion]
current_version = "2.15.1"
current_version = "2.15.2"
commit = true
tag = true
tag_name = "{new_version}"

View File

@ -1 +1 @@
2.15.1
2.15.2

View File

@ -4,7 +4,7 @@ import random
from collections import deque
from dataclasses import dataclass, field
from datetime import datetime
from time import time
from time import monotonic
from typing import TYPE_CHECKING, Any
from twisted.internet.defer import Deferred, inlineCallbacks
@ -203,7 +203,7 @@ class Downloader:
return
# Delay queue processing if a download_delay is configured
now = time()
now = monotonic()
delay = slot.download_delay()
if delay:
penalty = delay - now + slot.lastseen
@ -319,7 +319,7 @@ class Downloader:
slot.close()
def _slot_gc(self, age: float = 60) -> None:
mintime = time() - age
mintime = monotonic() - age
for key, slot in list(self.slots.items()):
if not slot.active and slot.lastseen + slot.delay < mintime:
self.slots.pop(key).close()

View File

@ -7,7 +7,7 @@ import logging
import re
from contextlib import suppress
from io import BytesIO
from time import time
from time import monotonic
from typing import TYPE_CHECKING, Any, TypedDict, TypeVar, cast
from urllib.parse import urldefrag, urlparse
@ -460,7 +460,7 @@ class ScrapyAgent:
if isinstance(agent, self._TunnelingAgent):
headers.removeHeader(b"Proxy-Authorization")
bodyproducer = _RequestBodyProducer(request.body) if request.body else None
start_time = time()
start_time = monotonic()
d: Deferred[IResponse] = agent.request(
method,
to_bytes(url, encoding="ascii"),
@ -489,7 +489,7 @@ class ScrapyAgent:
raise DownloadTimeoutError(f"Getting {url} took longer than {timeout} seconds.")
def _cb_latency(self, result: _T, request: Request, start_time: float) -> _T:
request.meta["download_latency"] = time() - start_time
request.meta["download_latency"] = monotonic() - start_time
return result
@staticmethod

View File

@ -1,6 +1,6 @@
from __future__ import annotations
from time import time
from time import monotonic
from typing import TYPE_CHECKING
from urllib.parse import urldefrag
@ -113,7 +113,7 @@ class ScrapyH2Agent:
timeout = request.meta.get("download_timeout") or self._connect_timeout
agent = self._get_agent(request, timeout)
start_time = time()
start_time = monotonic()
d = agent.request(request, spider)
d.addCallback(self._cb_latency, request, start_time)
@ -125,7 +125,7 @@ class ScrapyH2Agent:
def _cb_latency(
response: Response, request: Request, start_time: float
) -> Response:
request.meta["download_latency"] = time() - start_time
request.meta["download_latency"] = monotonic() - start_time
return response
@staticmethod

View File

@ -3,7 +3,7 @@
from __future__ import annotations
import warnings
from time import time
from time import monotonic, time
from typing import TYPE_CHECKING
from urllib.parse import urldefrag, urlparse, urlunparse
@ -100,7 +100,9 @@ class ScrapyHTTPClientFactory(ClientFactory):
afterFoundGet = False
def _build_response(self, body, request):
request.meta["download_latency"] = self.headers_time - self.start_time
request.meta["download_latency"] = (
self._headers_time_mono - self._start_time_mono
)
status = int(self.status)
headers = Headers(self.response_headers)
respcls = responsetypes.from_args(headers=headers, url=self._url, body=body)
@ -153,6 +155,7 @@ class ScrapyHTTPClientFactory(ClientFactory):
self.response_headers: Headers | None = None
self.timeout: float = request.meta.get("download_timeout") or timeout
self.start_time: float = time()
self._start_time_mono: float = monotonic()
self.deferred: defer.Deferred[Response] = defer.Deferred().addCallback(
self._build_response, request
)
@ -200,6 +203,7 @@ class ScrapyHTTPClientFactory(ClientFactory):
def gotHeaders(self, headers):
self.headers_time = time()
self._headers_time_mono = monotonic()
self.response_headers = headers
def gotStatus(self, version, status, message):

View File

@ -5,6 +5,7 @@ Extension for collecting core stats like items scraped and start/finish times
from __future__ import annotations
from datetime import datetime, timezone
from time import monotonic
from typing import TYPE_CHECKING, Any
from scrapy import Spider, signals
@ -21,6 +22,7 @@ class CoreStats:
def __init__(self, stats: StatsCollector):
self.stats: StatsCollector = stats
self.start_time: datetime | None = None
self._start_time_mono: float | None = None
@classmethod
def from_crawler(cls, crawler: Crawler) -> Self:
@ -35,13 +37,14 @@ class CoreStats:
def spider_opened(self, spider: Spider) -> None:
self.start_time = datetime.now(tz=timezone.utc)
self._start_time_mono = monotonic()
self.stats.set_value("start_time", self.start_time)
def spider_closed(self, spider: Spider, reason: str) -> None:
assert self.start_time is not None
finish_time = datetime.now(tz=timezone.utc)
elapsed_time = finish_time - self.start_time
elapsed_time_seconds = elapsed_time.total_seconds()
assert self._start_time_mono is not None
finish_time, finish_time_mono = datetime.now(tz=timezone.utc), monotonic()
elapsed_time_seconds = finish_time_mono - self._start_time_mono
self.stats.set_value("elapsed_time_seconds", elapsed_time_seconds)
self.stats.set_value("finish_time", finish_time)
self.stats.set_value("finish_reason", reason)

View File

@ -167,7 +167,7 @@ class StdoutFeedStorage:
@implementer(IFeedStorage)
class FileFeedStorage:
def __init__(self, uri: str, *, feed_options: dict[str, Any] | None = None):
self.path: str = file_uri_to_path(uri) if uri.startswith("file://") else uri
self.path: str = file_uri_to_path(uri) if uri.startswith("file:") else uri
feed_options = feed_options or {}
self.write_mode: OpenBinaryMode = (
"wb" if feed_options.get("overwrite", False) else "ab"

View File

@ -172,7 +172,7 @@ class AsyncioLoopingCall:
raise ValueError("Interval must be greater than 0")
self.interval = interval
self._start_time = time.time()
self._start_time = time.monotonic()
if now:
self._call()
loop = asyncio.get_event_loop()
@ -182,7 +182,7 @@ class AsyncioLoopingCall:
"""Return the time to sleep until the next call."""
assert self.interval is not None
assert self._start_time is not None
now = time.time()
now = time.monotonic()
running_for = now - self._start_time
return self.interval - (running_for % self.interval)

View File

@ -4,7 +4,7 @@ import sys
from io import StringIO
from typing import TYPE_CHECKING
from unittest import TestCase
from unittest.mock import Mock, PropertyMock, call, patch
from unittest.mock import MagicMock, Mock, PropertyMock, call, patch
from scrapy.commands.check import Command, TextTestResult
from tests.test_commands import TestProjectBase
@ -133,7 +133,7 @@ class CheckSpider(scrapy.Spider):
def test_printSummary_with_unsuccessful_test_result_without_errors_and_without_failures(
self,
) -> None:
result = TextTestResult(Mock(), descriptions=False, verbosity=1)
result = TextTestResult(MagicMock(), descriptions=False, verbosity=1)
start_time = 1.0
stop_time = 2.0
result.testsRun = 5
@ -147,7 +147,7 @@ class CheckSpider(scrapy.Spider):
def test_printSummary_with_unsuccessful_test_result_with_only_failures(
self,
) -> None:
result = TextTestResult(Mock(), descriptions=False, verbosity=1)
result = TextTestResult(MagicMock(), descriptions=False, verbosity=1)
start_time = 1.0
stop_time = 2.0
result.testsRun = 5
@ -158,7 +158,7 @@ class CheckSpider(scrapy.Spider):
mock_write.assert_called_with(" (failures=1)")
def test_printSummary_with_unsuccessful_test_result_with_only_errors(self) -> None:
result = TextTestResult(Mock(), descriptions=False, verbosity=1)
result = TextTestResult(MagicMock(), descriptions=False, verbosity=1)
start_time = 1.0
stop_time = 2.0
result.testsRun = 5
@ -171,7 +171,7 @@ class CheckSpider(scrapy.Spider):
def test_printSummary_with_unsuccessful_test_result_with_both_failures_and_errors(
self,
) -> None:
result = TextTestResult(Mock(), descriptions=False, verbosity=1)
result = TextTestResult(MagicMock(), descriptions=False, verbosity=1)
start_time = 1.0
stop_time = 2.0
result.testsRun = 5

View File

@ -2,7 +2,6 @@
from __future__ import annotations
import json
from typing import TYPE_CHECKING, Any
import pytest
@ -11,9 +10,13 @@ from twisted.web.http import H2_ENABLED
from scrapy import Spider
from scrapy.crawler import Crawler
from scrapy.exceptions import NotConfigured, UnsupportedURLSchemeError
from scrapy.exceptions import (
DownloadFailedError,
NotConfigured,
UnsupportedURLSchemeError,
)
from scrapy.http import Request
from scrapy.utils.defer import deferred_f_from_coro_f, maybe_deferred_to_future
from scrapy.utils.defer import maybe_deferred_to_future
from tests.test_downloader_handlers_http_base import (
TestHttpProxyBase,
TestHttps11Base,
@ -23,6 +26,7 @@ from tests.test_downloader_handlers_http_base import (
TestHttpsWrongHostnameBase,
TestHttpWithCrawlerBase,
)
from tests.utils.decorators import coroutine_test
if TYPE_CHECKING:
from scrapy.core.downloader.handlers import DownloadHandlerProtocol
@ -58,27 +62,16 @@ def test_not_configured_without_reactor() -> None:
class TestHttps2(H2DownloadHandlerMixin, TestHttps11Base):
HTTP2_DATALOSS_SKIP_REASON = "Content-Length mismatch raises InvalidBodyLengthError"
http2 = True
handler_supports_http2_dataloss = False
@deferred_f_from_coro_f
@coroutine_test
async def test_protocol(self, mockserver: MockServer) -> None:
request = Request(mockserver.url("/host", is_secure=self.is_secure))
async with self.get_dh() as download_handler:
response = await download_handler.download_request(request)
assert response.protocol == "h2"
def test_download_cause_data_loss(self) -> None: # type: ignore[override]
pytest.skip(self.HTTP2_DATALOSS_SKIP_REASON)
def test_download_cause_data_loss_double_warning(self) -> None: # type: ignore[override]
pytest.skip(self.HTTP2_DATALOSS_SKIP_REASON)
def test_download_allow_data_loss(self) -> None: # type: ignore[override]
pytest.skip(self.HTTP2_DATALOSS_SKIP_REASON)
def test_download_allow_data_loss_via_setting(self) -> None: # type: ignore[override]
pytest.skip(self.HTTP2_DATALOSS_SKIP_REASON)
def test_download_conn_failed(self) -> None: # type: ignore[override]
# Unlike HTTP11DownloadHandler which raises it from download_request()
# (without any special handling), here ConnectionRefusedError (raised in
@ -87,12 +80,6 @@ class TestHttps2(H2DownloadHandlerMixin, TestHttps11Base):
# DOWNLOAD_TIMEOUT.
pytest.skip("The handler doesn't properly reraise ConnectionRefusedError")
def test_download_conn_lost(self) -> None: # type: ignore[override]
pytest.skip(self.HTTP2_DATALOSS_SKIP_REASON)
def test_download_conn_aborted(self) -> None: # type: ignore[override]
pytest.skip(self.HTTP2_DATALOSS_SKIP_REASON)
def test_download_dns_error(self) -> None: # type: ignore[override]
# Unlike HTTP11DownloadHandler which raises it from download_request()
# (without any special handling), here DNSLookupError (raised in
@ -101,7 +88,7 @@ class TestHttps2(H2DownloadHandlerMixin, TestHttps11Base):
# DOWNLOAD_TIMEOUT.
pytest.skip("The handler doesn't properly reraise DNSLookupError")
@deferred_f_from_coro_f
@coroutine_test
async def test_concurrent_requests_same_domain(
self, mockserver: MockServer
) -> None:
@ -116,7 +103,7 @@ class TestHttps2(H2DownloadHandlerMixin, TestHttps11Base):
assert response2.headers["Content-Length"] == b"79"
@pytest.mark.xfail(reason="https://github.com/python-hyper/h2/issues/1247")
@deferred_f_from_coro_f
@coroutine_test
async def test_connect_request(self, mockserver: MockServer) -> None:
request = Request(
mockserver.url("/file", is_secure=self.is_secure), method="CONNECT"
@ -125,7 +112,7 @@ class TestHttps2(H2DownloadHandlerMixin, TestHttps11Base):
response = await download_handler.download_request(request)
assert response.body == b""
@deferred_f_from_coro_f
@coroutine_test
async def test_custom_content_length_good(self, mockserver: MockServer) -> None:
request = Request(mockserver.url("/contentlength", is_secure=self.is_secure))
custom_content_length = str(len(request.body))
@ -134,7 +121,7 @@ class TestHttps2(H2DownloadHandlerMixin, TestHttps11Base):
response = await download_handler.download_request(request)
assert response.text == custom_content_length
@deferred_f_from_coro_f
@coroutine_test
async def test_custom_content_length_bad(self, mockserver: MockServer) -> None:
request = Request(mockserver.url("/contentlength", is_secure=self.is_secure))
actual_content_length = str(len(request.body))
@ -154,15 +141,12 @@ class TestHttps2(H2DownloadHandlerMixin, TestHttps11Base):
)
)
@deferred_f_from_coro_f
async def test_duplicate_header(self, mockserver: MockServer) -> None:
request = Request(mockserver.url("/echo", is_secure=self.is_secure))
header, value1, value2 = "Custom-Header", "foo", "bar"
request.headers.appendlist(header, value1)
request.headers.appendlist(header, value2)
@coroutine_test
async def test_data_loss_handling(self, mockserver: MockServer) -> None:
request = Request(mockserver.url("/broken", is_secure=self.is_secure))
async with self.get_dh() as download_handler:
response = await download_handler.download_request(request)
assert json.loads(response.text)["headers"][header] == [value1, value2]
with pytest.raises(DownloadFailedError):
await download_handler.download_request(request)
class TestHttps2WrongHostname(H2DownloadHandlerMixin, TestHttpsWrongHostnameBase):
@ -214,7 +198,7 @@ class TestHttps2Proxy(H2DownloadHandlerMixin, TestHttpProxyBase):
is_secure = True
expected_http_proxy_request_body = b"/"
@deferred_f_from_coro_f
@coroutine_test
async def test_download_with_proxy_https_timeout(
self, proxy_mockserver: ProxyEchoMockServer
) -> None:
@ -223,7 +207,7 @@ class TestHttps2Proxy(H2DownloadHandlerMixin, TestHttpProxyBase):
super().test_download_with_proxy_https_timeout(proxy_mockserver) # type: ignore[arg-type]
)
@deferred_f_from_coro_f
@coroutine_test
async def test_download_with_proxy_without_http_scheme(
self, proxy_mockserver: ProxyEchoMockServer
) -> None:

View File

@ -521,7 +521,14 @@ class TestHttpBase(ABC):
class TestHttp11Base(TestHttpBase):
"""HTTP 1.1 test case"""
http2: bool = False
# RFC 9113 §8.1.1 explicitly says that a Content-Length mismatch is a
# stream error (of type PROTOCOL_ERROR) so the client will send
# RST_STREAM. Some libraries do only this while e.g. h2 also closes the
# connection (see handling of ProtocolError in
# h2.connection.H2Connection.receive_data()), thus closing all streams that
# were using it, and we handle this as a normal exception.
handler_supports_http2_dataloss: bool = True
@coroutine_test
async def test_download_without_maxsize_limit(self, mockserver: MockServer) -> None:
@ -638,12 +645,11 @@ class TestHttp11Base(TestHttpBase):
response = await download_handler.download_request(request)
assert response.body == b"chunked content\n"
@pytest.mark.parametrize("url", ["broken", "broken-chunked"])
@coroutine_test
async def test_download_cause_data_loss(
self, url: str, mockserver: MockServer
) -> None:
request = Request(mockserver.url(f"/{url}", is_secure=self.is_secure))
async def test_download_cause_data_loss(self, mockserver: MockServer) -> None:
if self.http2 and not self.handler_supports_http2_dataloss:
pytest.skip("This handler doesn't support dataloss on HTTP/2")
request = Request(mockserver.url("/broken", is_secure=self.is_secure))
async with self.get_dh() as download_handler:
with pytest.raises(ResponseDataLossError):
await download_handler.download_request(request)
@ -652,6 +658,8 @@ class TestHttp11Base(TestHttpBase):
async def test_download_cause_data_loss_double_warning(
self, caplog: pytest.LogCaptureFixture, mockserver: MockServer
) -> None:
if self.http2 and not self.handler_supports_http2_dataloss:
pytest.skip("This handler doesn't support dataloss on HTTP/2")
request = Request(mockserver.url("/broken", is_secure=self.is_secure))
async with self.get_dh() as download_handler:
with pytest.raises(ResponseDataLossError):
@ -663,25 +671,43 @@ class TestHttp11Base(TestHttpBase):
# no repeated warning
assert "Got data loss" not in caplog.text
@pytest.mark.parametrize("url", ["broken", "broken-chunked"])
@coroutine_test
async def test_download_allow_data_loss(
self, url: str, mockserver: MockServer
async def test_download_allow_data_loss_broken(
self, mockserver: MockServer
) -> None:
if self.http2 and not self.handler_supports_http2_dataloss:
pytest.skip("This handler doesn't support dataloss on HTTP/2")
request = Request(
mockserver.url(f"/{url}", is_secure=self.is_secure),
mockserver.url("/broken", is_secure=self.is_secure),
meta={"download_fail_on_dataloss": False},
)
async with self.get_dh() as download_handler:
response = await download_handler.download_request(request)
assert response.flags == ["dataloss"]
assert response.text == "partial"
@coroutine_test
async def test_download_allow_data_loss_broken_chunked(
self, mockserver: MockServer
) -> None:
if self.http2:
pytest.skip("Chunked encoding is specific to HTTP/1.1")
request = Request(
mockserver.url("/broken-chunked", is_secure=self.is_secure),
meta={"download_fail_on_dataloss": False},
)
async with self.get_dh() as download_handler:
response = await download_handler.download_request(request)
assert response.flags == ["dataloss"]
assert response.text == "chunked content\n"
@pytest.mark.parametrize("url", ["broken", "broken-chunked"])
@coroutine_test
async def test_download_allow_data_loss_via_setting(
self, url: str, mockserver: MockServer
self, mockserver: MockServer
) -> None:
request = Request(mockserver.url(f"/{url}", is_secure=self.is_secure))
if self.http2 and not self.handler_supports_http2_dataloss:
pytest.skip("This handler doesn't support dataloss on HTTP/2")
request = Request(mockserver.url("/broken", is_secure=self.is_secure))
async with self.get_dh(
{"DOWNLOAD_FAIL_ON_DATALOSS": False}
) as download_handler:
@ -708,6 +734,12 @@ class TestHttp11Base(TestHttpBase):
@coroutine_test
async def test_download_conn_aborted(self, mockserver: MockServer) -> None:
# copy of TestCrawl.test_retry_conn_aborted()
if self.http2:
# it may be possible to write a separate resource that does something
# suitable on HTTP/2 without sending Content-Length
pytest.skip(
"On HTTP/2 this triggers a Content-Length mismatch error instead."
)
request = Request(mockserver.url("/drop?abort=1", is_secure=self.is_secure))
async with self.get_dh() as download_handler:
with pytest.raises(DownloadFailedError):

View File

@ -44,7 +44,7 @@ if TYPE_CHECKING:
from collections.abc import Callable, Iterable
def path_to_url(path: Path) -> str:
def path_to_url(path: str | Path) -> str:
return urljoin("file:", pathname2url(str(path)))
@ -1293,7 +1293,7 @@ class TestFeedExporterSignals:
with tempfile.NamedTemporaryFile(suffix="json") as tmp:
settings = {
"FEEDS": {
f"file:///{tmp.name}": {
printf_escape(path_to_url(tmp.name)): {
"format": "json",
},
},

View File

@ -115,8 +115,8 @@ class TestFilesPipeline:
req1 = Request("http://foo.bar/baz.txt?fizz")
assert file_path(req1) == "full/a2b4913a62f65445aeae2bac08cd8c3b41d7195e.txt"
req2 = Request("http://foo.bar/get_img.php?file=photo.jpg")
assert file_path(req2) == "full/118230fd648f1080c81c234d5e2463ea496f8c05.jpg"
req2 = Request("http://foo.bar/get_img.foo?file=photo.jpg")
assert file_path(req2) == "full/7fc9461c9fd836515bea6983373097203a7d748e.jpg"
def test_file_path(self):
file_path = self.pipeline.file_path
@ -141,10 +141,10 @@ class TestFilesPipeline:
assert (
file_path(
Request(
"http://www.dfsonline.co.uk/get_prod_image.php?img=status_0907_mdm.jpg"
"http://www.dfsonline.co.uk/get_prod_image?img=status_0907_mdm.jpg"
)
)
== "full/4507be485f38b0da8a0be9eb2e1dfab8a19223f2.jpg"
== "full/c67f916ff9d542e822dedf38f9fcb146d1faba78.jpg"
)
assert (
file_path(Request("http://www.dorma.co.uk/images/product_details/2532/"))
@ -165,10 +165,10 @@ class TestFilesPipeline:
assert (
file_path(
Request(
"http://www.dfsonline.co.uk/get_prod_image.php?img=status_0907_mdm.jpg.bohaha"
"http://www.dfsonline.co.uk/get_prod_image?img=status_0907_mdm.jpg.bohaha"
)
)
== "full/76c00cef2ef669ae65052661f68d451162829507"
== "full/e75f2fa260521b56f6b6a867447b8002d00b5841"
)
assert (
file_path(

View File

@ -120,7 +120,7 @@ class PickleFifoDiskQueueTest(t.FifoDiskQueueTest, FifoDiskQueueTestMixin):
match=r"Can't (get|pickle) local object|Can't pickle .*: it's not found as",
) as exc_info:
q.push(lambda x: x)
if hasattr(sys, "pypy_version_info"):
if sys.version_info >= (3, 14) or hasattr(sys, "pypy_version_info"):
assert isinstance(exc_info.value.__context__, pickle.PicklingError)
else:
assert isinstance(exc_info.value.__context__, AttributeError)

View File

@ -29,6 +29,7 @@ def spider(crawler: Crawler) -> Spider:
class TestCoreStatsExtension:
@mock.patch("scrapy.extensions.corestats.monotonic", return_value=0)
@mock.patch("scrapy.extensions.corestats.datetime")
def test_core_stats_default_stats_collector(
self, mock_datetime: mock.Mock, crawler: Crawler, spider: Spider

View File

@ -1,5 +1,4 @@
import asyncio
import warnings
import pytest
@ -18,17 +17,6 @@ class TestAsyncio:
# the result should depend only on the pytest --reactor argument
assert is_asyncio_reactor_installed() == (reactor_pytest == "asyncio")
@pytest.mark.requires_reactor # installs a reactor
def test_install_asyncio_reactor(self):
from twisted.internet import reactor as original_reactor
with warnings.catch_warnings(record=True) as w:
install_reactor(_asyncio_reactor_path)
assert len(w) == 0, [str(warning) for warning in w]
from twisted.internet import reactor # pylint: disable=reimported
assert original_reactor == reactor
@pytest.mark.requires_reactor # installs a reactor
@pytest.mark.only_asyncio
@coroutine_test

View File

@ -5,7 +5,7 @@
[tox]
requires =
sphinx-scrapy[tox] @ git+https://github.com/scrapy/sphinx-scrapy.git@0.8.3
sphinx-scrapy[tox] @ git+https://github.com/scrapy/sphinx-scrapy.git@0.8.4
envlist = pre-commit,pylint,typing,py,docs
minversion = 1.7.0