Improve test coverage for scrapy.utils

This commit is contained in:
Adrian Chaves 2026-07-31 14:22:17 +02:00
parent aa5ded2539
commit f2d6c16007
33 changed files with 829 additions and 121 deletions

View File

@ -47,6 +47,7 @@ jobs:
- python-version: pypy3.11-7.3.20
env:
TOXENV: pypy3
coverage: true
# min deps
- python-version: "3.10.19"

View File

@ -8,7 +8,7 @@ dynamic = ["version"]
description = "A high-level Web Crawling and Web Scraping framework"
dependencies = [
"Twisted>=21.7.0",
"cryptography>=37.0.0",
"cryptography>=41.0.5",
"cssselect>=0.9.1",
"defusedxml>=0.7.1",
"itemadapter>=0.1.0",
@ -17,9 +17,9 @@ dependencies = [
"packaging",
"parsel>=1.5.0",
"protego>=0.1.15",
"pyOpenSSL>=22.0.0",
"pyOpenSSL>=24.3.0",
"queuelib>=1.4.2",
"service_identity>=23.1.0",
"service_identity>=24.2.0",
"tldextract",
"w3lib>=1.17.0",
"zope.interface>=5.1.0",

View File

@ -45,17 +45,15 @@ def _inflate(data: bytes, *, max_size: int = 0) -> bytes:
_check_max_size(decompressed_size, max_size)
output_stream = BytesIO()
output_stream.write(first_chunk)
while decompressor.unconsumed_tail:
# Anything left in unconsumed_tail once the stream has ended is not part of
# it, and feeding it back would neither consume it nor produce output.
while decompressor.unconsumed_tail and not decompressor.eof:
output_chunk = decompressor.decompress(
decompressor.unconsumed_tail, max_length=_CHUNK_SIZE
)
decompressed_size += len(output_chunk)
_check_max_size(decompressed_size, max_size)
output_stream.write(output_chunk)
if tail := decompressor.flush():
decompressed_size += len(tail)
_check_max_size(decompressed_size, max_size)
output_stream.write(tail)
return output_stream.getvalue()

View File

@ -18,8 +18,6 @@ TWISTED_TLS_NEW_IMPL = TWISTED_VERSION >= TxVersion("twisted", 26, 4, 0)
TWISTED_TLS_LIMITS_OFFBY1 = TWISTED_VERSION < TxVersion("twisted", 26, 4, 0)
PYOPENSSL_VERSION = Version(PYOPENSSL_VERSION_STRING)
# pyOpenSSL X.509 APIs are deprecated and cryptography-based ones are preferred
PYOPENSSL_X509_DEPRECATED = PYOPENSSL_VERSION >= Version("24.3.0")
# SSL.Context.set_cipher_list() creates a temporary connection, making the context immutable
PYOPENSSL_SET_CIPHER_LIST_TMP_CONN = PYOPENSSL_VERSION < Version("25.2.0")

View File

@ -101,7 +101,9 @@ def to_bytes(
return text.encode(encoding, errors)
def _chunk_iter(text: str, chunk_size: int) -> Iterable[tuple[str, int]]:
def _chunk_iter(
text: str, chunk_size: int
) -> Iterable[tuple[str, int]]: # pragma: no cover
offset = len(text)
while True:
offset -= chunk_size * 1024
@ -213,17 +215,15 @@ def get_func_args_dict(
except ValueError:
return {}
if isinstance(func, partial):
partial_args = func.args
partial_kw = func.keywords
args = {}
for name, param in sig.parameters.items():
if name in partial_args:
continue
if partial_kw and name in partial_kw:
continue
args[name] = param
if isinstance(func, partial) and func.keywords:
# The signature of a partial already omits the parameters bound to
# positional arguments, but it keeps those bound to keyword arguments,
# turned into keyword-only parameters with a default.
args = {
name: param
for name, param in sig.parameters.items()
if name not in func.keywords
}
else:
args = sig.parameters

View File

@ -11,10 +11,7 @@ import OpenSSL.version
from twisted.internet.ssl import CertificateOptions, TLSVersion
from scrapy.exceptions import ScrapyDeprecationWarning
from scrapy.utils._deps_compat import (
PYOPENSSL_X509_DEPRECATED,
TWISTED_TLS_LIMITS_OFFBY1,
)
from scrapy.utils._deps_compat import TWISTED_TLS_LIMITS_OFFBY1
from scrapy.utils.python import to_unicode
if TYPE_CHECKING:
@ -125,18 +122,17 @@ def _ffi_buf_to_string(buf: Any) -> str:
return to_unicode(pyOpenSSLutil.ffi.string(buf))
def ffi_buf_to_string(buf: Any) -> str: # pragma: no cover
def ffi_buf_to_string(buf: Any) -> str:
warnings.warn(
"ffi_buf_to_string() is deprecated.",
ScrapyDeprecationWarning,
stacklevel=2,
)
return ffi_buf_to_string(buf)
return _ffi_buf_to_string(buf)
def _x509name_to_string(x509name: X509Name) -> str:
# from OpenSSL.crypto.X509Name.__repr__
# only used on pyOpenSSL < 24.3.0
result_buffer: Any = pyOpenSSLutil.ffi.new("char[]", 512)
pyOpenSSLutil.lib.X509_NAME_oneline(
x509name._name, result_buffer, len(result_buffer)
@ -144,7 +140,7 @@ def _x509name_to_string(x509name: X509Name) -> str:
return _ffi_buf_to_string(result_buffer)
def x509name_to_string(x509name: X509Name) -> str: # pragma: no cover
def x509name_to_string(x509name: X509Name) -> str:
warnings.warn(
"x509name_to_string() is deprecated.",
ScrapyDeprecationWarning,
@ -153,48 +149,15 @@ def x509name_to_string(x509name: X509Name) -> str: # pragma: no cover
return _x509name_to_string(x509name)
def _get_temp_key_info(ssl_object: Any) -> str | None:
# adapted from OpenSSL apps/s_cb.c::ssl_print_tmp_key()
if not hasattr(pyOpenSSLutil.lib, "SSL_get_server_tmp_key"):
# removed in cryptography 40.0.0 (required starting from pyOpenSSL 23.1.0)
return None
temp_key_p = pyOpenSSLutil.ffi.new("EVP_PKEY **")
if not pyOpenSSLutil.lib.SSL_get_server_tmp_key(ssl_object, temp_key_p):
return None
temp_key = temp_key_p[0]
if temp_key == pyOpenSSLutil.ffi.NULL:
return None
temp_key = pyOpenSSLutil.ffi.gc(temp_key, pyOpenSSLutil.lib.EVP_PKEY_free)
key_info = []
key_type = pyOpenSSLutil.lib.EVP_PKEY_id(temp_key)
if key_type == pyOpenSSLutil.lib.EVP_PKEY_RSA:
key_info.append("RSA")
elif key_type == pyOpenSSLutil.lib.EVP_PKEY_DH:
key_info.append("DH")
elif key_type == pyOpenSSLutil.lib.EVP_PKEY_EC:
key_info.append("ECDH")
ec_key = pyOpenSSLutil.lib.EVP_PKEY_get1_EC_KEY(temp_key)
ec_key = pyOpenSSLutil.ffi.gc(ec_key, pyOpenSSLutil.lib.EC_KEY_free)
nid = pyOpenSSLutil.lib.EC_GROUP_get_curve_name(
pyOpenSSLutil.lib.EC_KEY_get0_group(ec_key)
)
cname = pyOpenSSLutil.lib.EC_curve_nid2nist(nid)
if cname == pyOpenSSLutil.ffi.NULL:
cname = pyOpenSSLutil.lib.OBJ_nid2sn(nid)
key_info.append(_ffi_buf_to_string(cname))
else:
key_info.append(_ffi_buf_to_string(pyOpenSSLutil.lib.OBJ_nid2sn(key_type)))
key_info.append(f"{pyOpenSSLutil.lib.EVP_PKEY_bits(temp_key)} bits")
return ", ".join(key_info)
def get_temp_key_info(ssl_object: Any) -> str | None: # pragma: no cover
def get_temp_key_info(ssl_object: Any) -> str | None:
# A no-op: it read the negotiated ephemeral key through a binding that
# cryptography 40.0.0 removed.
warnings.warn(
"get_temp_key_info() is deprecated. It's also a no-op with cryptography 40.0.0+.",
ScrapyDeprecationWarning,
stacklevel=2,
)
return _get_temp_key_info(ssl_object)
return None
def get_openssl_version() -> str:
@ -210,23 +173,12 @@ def _log_ssl_conn_debug_info(hostname: str, connection: OpenSSL.SSL.Connection)
connection.get_protocol_version_name(),
connection.get_cipher_name(),
)
if PYOPENSSL_X509_DEPRECATED:
if server_cert := connection.get_peer_certificate(as_cryptography=True):
logger.debug(
'SSL connection certificate: issuer "%s", subject "%s"',
server_cert.issuer.rfc4514_string(),
server_cert.subject.rfc4514_string(),
)
else: # noqa: PLR5501
if server_cert_pyopenssl := connection.get_peer_certificate():
logger.debug(
'SSL connection certificate: issuer "%s", subject "%s"',
_x509name_to_string(server_cert_pyopenssl.get_issuer()),
_x509name_to_string(server_cert_pyopenssl.get_subject()),
)
key_info = _get_temp_key_info(connection._ssl)
if key_info:
logger.debug("SSL temp key: %s", key_info)
if server_cert := connection.get_peer_certificate(as_cryptography=True):
logger.debug(
'SSL connection certificate: issuer "%s", subject "%s"',
server_cert.issuer.rfc4514_string(),
server_cert.subject.rfc4514_string(),
)
# Twisted-specific

View File

@ -7,11 +7,9 @@ from typing import TYPE_CHECKING, cast
from cryptography.hazmat.primitives.serialization import load_pem_private_key
from cryptography.x509 import load_pem_x509_certificate
from OpenSSL import SSL
from OpenSSL.crypto import FILETYPE_PEM, load_certificate, load_privatekey
from twisted.internet.ssl import CertificateOptions, ContextFactory
from scrapy.core.downloader.tls import _TWISTED_VERSION_MAP
from scrapy.utils._deps_compat import PYOPENSSL_X509_DEPRECATED
from scrapy.utils.python import to_bytes
from scrapy.utils.ssl import _get_cert_options_version_kwargs
@ -37,12 +35,8 @@ def ssl_context_factory(
keyfile_path = Path(__file__).parent.parent / keyfile
certfile_path = Path(__file__).parent.parent / certfile
if PYOPENSSL_X509_DEPRECATED:
cert = load_pem_x509_certificate(certfile_path.read_bytes())
key = load_pem_private_key(keyfile_path.read_bytes(), password=None)
else:
cert = load_certificate(FILETYPE_PEM, certfile_path.read_bytes()) # type: ignore[assignment]
key = load_privatekey(FILETYPE_PEM, keyfile_path.read_bytes()) # type: ignore[assignment]
cert = load_pem_x509_certificate(certfile_path.read_bytes())
key = load_pem_private_key(keyfile_path.read_bytes(), password=None)
tls_min = _TWISTED_VERSION_MAP.get(tls_min_version) if tls_min_version else None
tls_max = _TWISTED_VERSION_MAP.get(tls_max_version) if tls_max_version else None

View File

@ -276,6 +276,29 @@ class TestInteractiveShell:
self._isolate_config(env, config_home)
assert "Traceback" not in self._run_interactive_shell(env)
@pytest.mark.skipif(
importlib.util.find_spec("IPython") is None, reason="IPython is not installed"
)
def test_shell_ipython(self, tmp_path: Path) -> None:
# Reaching the embedded IPython shell requires selecting it explicitly,
# since ptpython takes precedence when both are installed.
config_home = tmp_path / "config"
config_home.mkdir()
env = os.environ.copy()
self._isolate_config(env, config_home)
env["SCRAPY_PYTHON_SHELL"] = "ipython"
args = (sys.executable, "-m", "scrapy.cmdline", "shell")
logfile = BytesIO()
p = PopenSpawn(args, env=env, timeout=30)
p.logfile_read = logfile
p.expect_exact("Available Scrapy objects")
p.sendline("import sys; print('IPYMODULE', 'IPython' in sys.modules)")
p.expect_exact("IPYMODULE True")
p.sendeof()
p.wait() # type: ignore[no-untyped-call]
logfile.seek(0)
assert "Traceback" not in logfile.read().decode()
@pytest.fixture
def restore_sigint():

View File

@ -2,6 +2,7 @@ from __future__ import annotations
import json
import logging
import re
from ipaddress import IPv4Address
from socket import gethostbyname
from typing import TYPE_CHECKING, Any
@ -18,7 +19,11 @@ from scrapy.exceptions import CloseSpider, ScrapyDeprecationWarning, StopDownloa
from scrapy.http import Request
from scrapy.http.response import Response
from scrapy.utils.defer import maybe_deferred_to_future
from scrapy.utils.engine import format_engine_status, get_engine_status
from scrapy.utils.engine import (
format_engine_status,
get_engine_status,
print_engine_status,
)
from scrapy.utils.python import to_unicode
from scrapy.utils.test import get_crawler
from tests import NON_EXISTING_RESOLVABLE
@ -372,6 +377,23 @@ with multiples lines
assert s["engine.spider.name"] == crawler.spider.name
assert s["len(engine.scraper.slot.active)"] == 1
@coroutine_test
async def test_print_engine_status(
self, mockserver: MockServer, capsys: pytest.CaptureFixture[str]
) -> None:
def cb(response):
assert crawler.engine
print_engine_status(crawler.engine)
crawler = get_crawler(SingleRequestSpider)
await crawler.crawl_async(
seed=mockserver.url("/"), callback_func=cb, mockserver=mockserver
)
assert isinstance(crawler.spider, SingleRequestSpider)
out = capsys.readouterr().out
assert out.startswith("Execution engine status")
assert re.search(rf"engine\.spider\.name +: {crawler.spider.name}\n", out)
@coroutine_test
async def test_format_engine_status(self, mockserver: MockServer) -> None:
est: list[str] = []

View File

@ -20,6 +20,7 @@ from tests.utils.bases.download_handlers_http import (
TestHttpProxyBase,
TestHttpsBase,
TestHttpsCustomCiphersBase,
TestHttpsDefaultCiphersBase,
TestHttpsInvalidDNSIdBase,
TestHttpsInvalidDNSPatternBase,
TestHttpsTLSVersionBase,
@ -133,6 +134,10 @@ class TestHttpsCustomCiphers(HttpxDownloadHandlerMixin, TestHttpsCustomCiphersBa
pass
class TestHttpsDefaultCiphers(HttpxDownloadHandlerMixin, TestHttpsDefaultCiphersBase):
pass
class TestHttpsTLSVersion(HttpxDownloadHandlerMixin, TestHttpsTLSVersionBase):
pass

View File

@ -9,15 +9,31 @@ from tempfile import mkdtemp, mkstemp
from unittest import mock
import pytest
from twisted.internet.defer import CancelledError
from twisted.internet.error import ConnectionRefusedError as TxConnectionRefusedError
from twisted.internet.error import DNSLookupError
from twisted.internet.error import TimeoutError as TxTimeoutError
from twisted.web.client import ResponseFailed
from twisted.web.error import SchemeNotSupported
from w3lib.url import path_to_file_uri
from scrapy.core.downloader.handlers import DownloadHandlers
from scrapy.core.downloader.handlers.datauri import DataURIDownloadHandler
from scrapy.core.downloader.handlers.file import FileDownloadHandler
from scrapy.core.downloader.handlers.s3 import S3DownloadHandler
from scrapy.exceptions import NotConfigured, ScrapyDeprecationWarning
from scrapy.exceptions import (
CannotResolveHostError,
DownloadCancelledError,
DownloadConnectionRefusedError,
DownloadFailedError,
DownloadTimeoutError,
NotConfigured,
ScrapyDeprecationWarning,
UnsupportedURLSchemeError,
)
from scrapy.http import Request, TextResponse
from scrapy.responsetypes import responsetypes
from scrapy.utils._download_handlers import wrap_twisted_exceptions
from scrapy.utils.boto import is_botocore_available
from scrapy.utils.misc import build_from_crawler
from scrapy.utils.test import get_crawler
@ -407,3 +423,38 @@ class TestDataURI:
request = Request("data:,")
response = await self.download_request(request)
assert response.protocol is None
class TestWrapTwistedExceptions:
"""Every Twisted exception the HTTP handlers can raise has a Scrapy equivalent.
Most mappings are also covered end to end by the HTTP handler tests, but a
Twisted ``TimeoutError`` needs a connection attempt that is silently dropped
rather than refused, which no mock server can offer reliably.
"""
@pytest.mark.parametrize(
("twisted_exception", "scrapy_exception"),
[
(SchemeNotSupported, UnsupportedURLSchemeError),
(CancelledError, DownloadCancelledError),
(TxConnectionRefusedError, DownloadConnectionRefusedError),
(DNSLookupError, CannotResolveHostError),
(ResponseFailed, DownloadFailedError),
(TxTimeoutError, DownloadTimeoutError),
],
)
def test_mapping(
self,
twisted_exception: type[Exception],
scrapy_exception: type[Exception],
) -> None:
original = twisted_exception("some message")
with pytest.raises(scrapy_exception) as exc_info, wrap_twisted_exceptions():
raise original
assert exc_info.value.__cause__ is original
assert str(exc_info.value) == str(original)
def test_other_exceptions_pass_through(self) -> None:
with pytest.raises(ZeroDivisionError), wrap_twisted_exceptions():
1 / 0

View File

@ -1,8 +1,10 @@
import zlib
from gzip import GzipFile
from importlib.util import find_spec
from io import BytesIO
from logging import WARNING
from pathlib import Path
from threading import Thread
import pytest
from w3lib.encoding import resolve_encoding
@ -15,6 +17,7 @@ from scrapy.exceptions import IgnoreRequest, NotConfigured, ScrapyDeprecationWar
from scrapy.http import HtmlResponse, Request, Response
from scrapy.responsetypes import responsetypes
from scrapy.spiders import Spider
from scrapy.utils._compression import _CHUNK_SIZE
from scrapy.utils.gz import gunzip
from scrapy.utils.test import get_crawler
from tests import tests_datadir
@ -513,6 +516,57 @@ class TestHttpCompression:
self.assertStatsEqual("httpcompression/response_count", None)
self.assertStatsEqual("httpcompression/response_bytes", None)
@pytest.mark.parametrize(
"extra",
[
pytest.param(b"garbage", id="trailing-garbage"),
pytest.param(zlib.compress(b"b" * 100_000), id="second-zlib-stream"),
],
)
def test_deflate_extra_data_after_stream(self, extra: bytes) -> None:
"""Bytes after the end of a deflate stream must not stall decompression.
Such bytes stay in ``unconsumed_tail`` and produce no output, so feeding
them back to the decompressor never makes progress. Decompressing in a
thread lets the test fail instead of hanging if that ever regresses.
"""
# The body must decompress to more than _CHUNK_SIZE, or the whole stream
# is consumed by the first decompress() call and no loop is entered.
plain = b"a" * 100_000
response = Response(
"http://example.com",
body=zlib.compress(plain) + extra,
headers={"Content-Encoding": "deflate"},
)
result: list[Response] = []
thread = Thread(
target=lambda: result.append(
self.mw.process_response(Request("http://example.com"), response)
),
daemon=True,
)
thread.start()
thread.join(60)
assert not thread.is_alive(), "decompression did not terminate"
assert result[0].body == plain
def test_deflate_chunked_output_leaves_nothing_to_flush(self) -> None:
"""Guard the zlib behaviour that lets ``_inflate()`` skip ``flush()``.
``decompress(max_length=...)`` leaves the input it could not turn into
output in ``unconsumed_tail``, so once that is empty every complete
stream has been fully emitted. If a future zlib buffers output instead,
this fails and ``_inflate()`` needs to flush the remainder again.
"""
for size in (_CHUNK_SIZE - 1, _CHUNK_SIZE, _CHUNK_SIZE + 1, 4 * _CHUNK_SIZE):
decompressor = zlib.decompressobj()
decompressor.decompress(zlib.compress(b"a" * size), max_length=_CHUNK_SIZE)
while decompressor.unconsumed_tail and not decompressor.eof:
decompressor.decompress(
decompressor.unconsumed_tail, max_length=_CHUNK_SIZE
)
assert decompressor.flush() == b"", f"pending output for size {size}"
def _test_compression_bomb_setting(self, compression_id):
settings = {"DOWNLOAD_MAXSIZE": 1_000_000}
crawler = get_crawler(Spider, settings_dict=settings)
@ -523,7 +577,12 @@ class TestHttpCompression:
response = self._getresponse(f"bomb-{compression_id}") # 11_511_612 B
with pytest.raises(IgnoreRequest) as exc_info:
mw.process_response(response.request, response)
assert exc_info.value.__cause__.decompressed_size < 1_100_000
cause = exc_info.value.__cause__
assert cause.decompressed_size < 1_100_000
assert str(cause) == (
f"The number of bytes decompressed so far ({cause.decompressed_size} B) "
"exceeded the specified maximum (1000000 B)."
)
def test_compression_bomb_setting_br(self):
_skip_if_no_br()

View File

@ -162,6 +162,13 @@ class TestFTPFeedStorage:
await self._store(url, b"bar", settings=settings)
self._assert_stored(ftp_server.path / filename, b"bar")
@coroutine_test
async def test_missing_parent_directories(self):
with MockFTPServer() as ftp_server:
path = "missing/parent/dirs/file"
await self._store(ftp_server.url(path), b"foo")
self._assert_stored(ftp_server.path / path, b"foo")
def test_uri_auth_quote(self):
# RFC3986: 3.2.1. User Information
pw_quoted = quote(string.punctuation, safe="")

View File

@ -9,8 +9,10 @@ from scrapy.settings import BaseSettings, Settings
from scrapy.utils.conf import (
arglist_to_dict,
build_component_list,
closest_scrapy_cfg,
feed_complete_default_values_from_settings,
feed_process_params_from_cli,
get_sources,
)
@ -39,6 +41,20 @@ class TestBuildComponentList:
):
build_component_list(duplicate_bs, convert=lambda x: x.lower())
def test_duplicate_components_in_dict(self):
d = {"one": 1, "ONE": 2}
with pytest.raises(
ValueError, match=r"Some paths in .* convert to the same object"
):
build_component_list(d, convert=lambda x: x.lower())
def test_invalid_value(self):
d = {"one": "1"}
with pytest.raises(
ValueError, match=r"Invalid value 1 for component one, please provide"
):
build_component_list(d, convert=lambda x: x)
def test_valid_numbers(self):
# work well with None and numeric values
d = {"a": 10, "b": None, "c": 15, "d": 5.0}
@ -51,6 +67,10 @@ class TestBuildComponentList:
assert build_component_list(d, convert=lambda x: x) == ["b", "c", "a"]
def test_get_sources():
assert get_sources() == [*get_sources(use_closest=False), closest_scrapy_cfg()]
def test_arglist_to_dict():
assert arglist_to_dict(["arg1=val1", "arg2=val2"]) == {
"arg1": "val1",

View File

@ -16,6 +16,15 @@ def test_get_shell_embed_func():
assert shell.__name__ == "_embed_standard_shell"
def test_get_shell_embed_func_known_shells():
def embed(namespace: dict[str, object] | None = None, banner: str = "") -> None:
pass
known_shells = {"custom": lambda: embed}
assert get_shell_embed_func(["custom"], known_shells) is embed
assert get_shell_embed_func(["python"], known_shells) is None
def test_get_shell_embed_func_python():
# the standard shell is always available
shell = get_shell_embed_func(["python"])

View File

@ -41,6 +41,26 @@ class TestCurlToRequestKwargs:
}
self._test_command(curl_command, expected_result)
def test_get_cookie_option(self):
curl_command = 'curl "http://example.org/" -b "a=1; b=2"'
expected_result = {
"method": "GET",
"url": "http://example.org/",
"cookies": {"a": "1", "b": "2"},
}
self._test_command(curl_command, expected_result)
def test_get_cookie_file_option(self):
# curl reads cookies from a file when the value is not a key-value
# pair; Scrapy ignores it.
curl_command = 'curl "http://example.org/" -b cookies.txt -b "a=1"'
expected_result = {
"method": "GET",
"url": "http://example.org/",
"cookies": {"a": "1"},
}
self._test_command(curl_command, expected_result)
def test_get_complex(self):
curl_command = (
"curl 'http://httpbin.org/get' -H 'Accept-Encoding: gzip, deflate'"

View File

@ -357,6 +357,15 @@ class TestDeferredToFuture:
assert future_result == 42
@pytest.mark.only_not_asyncio
class TestDeferredToFutureNotAsyncio:
def test_deferred(self):
with pytest.raises(
RuntimeError, match=r"deferred_to_future\(\) requires an installed asyncio"
):
deferred_to_future(Deferred())
@pytest.mark.only_asyncio
class TestMaybeDeferredToFutureAsyncio:
@coroutine_test

View File

@ -7,7 +7,7 @@ from unittest import mock
import pytest
from scrapy.exceptions import ScrapyDeprecationWarning
from scrapy.utils.deprecate import create_deprecated_class, update_classpath
from scrapy.utils.deprecate import attribute, create_deprecated_class, update_classpath
class MyWarning(UserWarning):
@ -284,3 +284,20 @@ class TestUpdateClassPath:
def test_returns_nonstring(self):
for notastring in [None, True, [1, 2, 3], object()]:
assert update_classpath(notastring) == notastring
class TestAttribute:
class MyClass:
pass
def test_default_version(self):
with pytest.warns(
ScrapyDeprecationWarning,
match=r"MyClass\.old attribute is deprecated and will be no longer "
r"supported in Scrapy 0\.12, use MyClass\.new attribute instead",
):
attribute(self.MyClass(), "old", "new")
def test_custom_version(self):
with pytest.warns(ScrapyDeprecationWarning, match="in Scrapy 3.0"):
attribute(self.MyClass(), "old", "new", "3.0")

View File

@ -1,11 +1,14 @@
from __future__ import annotations
import builtins
import sys
from io import StringIO
from typing import TYPE_CHECKING
from unittest import mock
from scrapy.utils.display import pformat, pprint
import pytest
from scrapy.utils.display import _enable_windows_terminal_processing, pformat, pprint
if TYPE_CHECKING:
from collections.abc import Mapping, Sequence
@ -81,6 +84,14 @@ def test_pformat_windows(
assert pformat(value) in colorized_strings
@pytest.mark.skipif(sys.platform != "win32", reason="Windows-only console API")
def test_enable_windows_terminal_processing() -> None:
# The tests above mock this out, so this is the only place the real
# kernel32 calls run. Whether they succeed depends on whether stdout is a
# console, which pytest does not guarantee.
assert isinstance(_enable_windows_terminal_processing(), bool)
@mock.patch("sys.platform", "linux")
@mock.patch("sys.stdout.isatty")
def test_pformat_no_pygments(isatty: mock.Mock) -> None:

View File

@ -210,6 +210,31 @@ class TestXmliter:
with pytest.raises(StopIteration):
next(my_iter)
def test_xmliter_namespaced_nodename_other_prefixes(self):
body = b"""<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"
xmlns:a="http://example.com/ns/a"
xmlns:g="http://base.google.com/ns/1.0">
<item>
<a:id>A_1</a:id>
<g:id>ITEM_1</g:id>
</item>
</rss>
"""
response = XmlResponse(url="http://example.com", body=body)
node = next(xmliter_lxml(response, "g:id"))
assert node.xpath("text()").getall() == ["ITEM_1"]
def test_xmliter_non_text_response(self):
body = (
b'<?xml version="1.0" encoding="UTF-8"?>'
b"<products><product>one</product></products>"
)
response = Response(url="http://example.com", body=body)
assert [
node.xpath("text()").get() for node in xmliter_lxml(response, "product")
] == ["one"]
def test_xmliter_exception(self):
body = (
'<?xml version="1.0" encoding="UTF-8"?>'
@ -421,6 +446,9 @@ class TestUtilsCsv:
{"id": "4", "name": "empty", "value": ""},
]
def test_csviter_empty(self):
assert not list(csviter(""))
def test_csviter_exception(self):
body = get_testdata("feeds", "feed-sample3.csv")
@ -481,6 +509,12 @@ class TestBodyOrStr:
assert type(r1) is type(r2)
assert type(r1) is not type(r3) # type: ignore[comparison-overlap]
def test_body_or_str_unsupported_type(self) -> None:
with pytest.raises(
TypeError, match="must be Response or str or bytes, not int"
):
_body_or_str(42) # type: ignore[call-overload]
@staticmethod
def _assert_type_and_value(
a: str | bytes, b: str | bytes, obj: Response | str | bytes

24
tests/test_utils_job.py Normal file
View File

@ -0,0 +1,24 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from scrapy.settings import Settings
from scrapy.utils.job import job_dir
if TYPE_CHECKING:
from pathlib import Path
def test_no_jobdir() -> None:
assert job_dir(Settings()) is None
assert job_dir(Settings({"JOBDIR": ""})) is None
def test_existing_jobdir(tmp_path: Path) -> None:
assert job_dir(Settings({"JOBDIR": str(tmp_path)})) == str(tmp_path)
def test_missing_jobdir(tmp_path: Path) -> None:
jobdir = tmp_path / "missing" / "jobdir"
assert job_dir(Settings({"JOBDIR": str(jobdir)})) == str(jobdir)
assert jobdir.is_dir()

View File

@ -4,18 +4,25 @@ import json
import logging
import re
import sys
import warnings
from io import StringIO
from typing import TYPE_CHECKING, Any
import pytest
from twisted.python import log as twisted_log
from twisted.python.failure import Failure
from scrapy.settings import Settings
from scrapy.utils.log import (
LogCounterHandler,
SpiderLoggerAdapter,
StreamLogger,
TopLevelFormatter,
_uninstall_scrapy_root_handler,
configure_logging,
failure_to_exc_info,
get_scrapy_root_handler,
install_scrapy_root_handler,
)
from scrapy.utils.test import get_crawler
from tests.spiders import LogSpider
@ -118,6 +125,70 @@ class TestStreamLogger:
sys.stdout = old_stdout
def test_flush(self) -> None:
class FlushCountingHandler(logging.Handler):
flushes = 0
def flush(self) -> None:
self.flushes += 1
handler = FlushCountingHandler()
logger = logging.getLogger("test_flush")
logger.addHandler(handler)
try:
StreamLogger(logger).flush()
finally:
logger.removeHandler(handler)
assert handler.flushes == 1
class TestConfigureLogging:
@pytest.fixture(autouse=True)
def restore_logging(self) -> Generator[None]:
root_handlers = logging.root.handlers[:]
# configure_logging() adds a Twisted log observer without giving any way
# to remove it afterwards.
observers = twisted_log.theLogPublisher.observers[:]
old_stdout = sys.stdout
old_showwarning = warnings.showwarning
try:
yield
finally:
warnings.showwarning = old_showwarning
sys.stdout = old_stdout
twisted_log.theLogPublisher.observers[:] = observers
logging.root.handlers[:] = root_handlers
_uninstall_scrapy_root_handler()
@staticmethod
def _warnings_are_captured() -> bool:
return warnings.showwarning.__module__ == "logging"
def test_log_stdout(self) -> None:
configure_logging(settings={"LOG_STDOUT": True}, install_root_handler=False)
assert isinstance(sys.stdout, StreamLogger)
def test_captures_warnings(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(sys, "warnoptions", [])
logging.captureWarnings(False)
configure_logging(install_root_handler=False)
assert self._warnings_are_captured()
def test_keeps_warnoptions(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(sys, "warnoptions", ["default"])
logging.captureWarnings(False)
configure_logging(install_root_handler=False)
assert not self._warnings_are_captured()
def test_reinstall_root_handler_removed_from_root(self) -> None:
install_scrapy_root_handler(Settings())
handler = get_scrapy_root_handler()
assert handler is not None
# Something else removed the handler from the root logger.
logging.root.removeHandler(handler)
install_scrapy_root_handler(Settings())
assert get_scrapy_root_handler() is not handler
@pytest.mark.parametrize(
("base_extra", "log_extra", "expected_extra"),

View File

@ -0,0 +1,54 @@
from __future__ import annotations
import signal
from typing import TYPE_CHECKING, Any
import pytest
from scrapy.utils.ossignal import install_shutdown_handlers
if TYPE_CHECKING:
from collections.abc import Generator
from types import FrameType
SHUTDOWN_SIGNALS = [
getattr(signal, name)
for name in ("SIGTERM", "SIGINT", "SIGBREAK")
if hasattr(signal, name)
]
@pytest.fixture(autouse=True)
def restore_handlers() -> Generator[None]:
handlers = {sig: signal.getsignal(sig) for sig in SHUTDOWN_SIGNALS}
try:
yield
finally:
for sig, handler in handlers.items():
signal.signal(sig, handler)
def shutdown(signum: int, frame: FrameType | None) -> Any:
pass
def test_install() -> None:
install_shutdown_handlers(shutdown)
for sig in SHUTDOWN_SIGNALS:
assert signal.getsignal(sig) is shutdown
def test_keeps_custom_sigint_handler() -> None:
def custom(signum: int, frame: FrameType | None) -> Any:
pass
signal.signal(signal.SIGINT, custom)
install_shutdown_handlers(shutdown, override_sigint=False)
assert signal.getsignal(signal.SIGINT) is custom
assert signal.getsignal(signal.SIGTERM) is shutdown
def test_overrides_default_sigint_handler() -> None:
signal.signal(signal.SIGINT, signal.default_int_handler)
install_shutdown_handlers(shutdown, override_sigint=False)
assert signal.getsignal(signal.SIGINT) is shutdown

View File

@ -6,8 +6,14 @@ from typing import TYPE_CHECKING
import pytest
from scrapy.exceptions import NotConfigured
from scrapy.utils.misc import set_environ
from scrapy.utils.project import data_path, get_project_settings
from scrapy.utils.project import (
data_path,
get_project_settings,
inside_project,
project_data_dir,
)
if TYPE_CHECKING:
from collections.abc import Generator
@ -27,6 +33,19 @@ def proj_path(tmp_path: Path) -> Generator[Path]:
os.chdir(prev_dir)
@pytest.fixture
def no_proj_path(tmp_path: Path) -> Generator[Path]:
"""A working directory without a scrapy.cfg file, also isolated from the
user-wide and system-wide Scrapy configuration files."""
prev_dir = Path.cwd()
try:
os.chdir(tmp_path)
with set_environ(HOME=str(tmp_path), XDG_CONFIG_HOME=str(tmp_path)):
yield tmp_path
finally:
os.chdir(prev_dir)
def test_data_path_outside_project() -> None:
assert str(Path(".scrapy", "somepath")) == data_path("somepath")
abspath = str(Path(os.path.sep, "absolute", "path"))
@ -40,6 +59,51 @@ def test_data_path_inside_project(proj_path: Path) -> None:
assert abspath == data_path(abspath)
def test_data_path_createdir(no_proj_path: Path) -> None:
path = Path(data_path("somepath", createdir=True))
assert path.is_dir()
# An existing directory is left alone.
assert Path(data_path("somepath", createdir=True)) == path
def test_inside_project_unimportable_settings_module(no_proj_path: Path) -> None:
with (
set_environ(SCRAPY_SETTINGS_MODULE="tests.no_such_settings_module"),
pytest.warns(
UserWarning, match="Cannot import scrapy settings module tests.no_such"
),
):
assert inside_project() is False
def test_project_data_dir_outside_project(no_proj_path: Path) -> None:
with pytest.raises(NotConfigured, match="Not inside a project"):
project_data_dir()
def test_project_data_dir_without_scrapy_cfg(no_proj_path: Path) -> None:
with (
set_environ(SCRAPY_SETTINGS_MODULE="tests.test_cmdline.settings"),
pytest.raises(NotConfigured, match=r"Unable to find scrapy\.cfg file"),
):
project_data_dir()
def test_project_data_dir_default(proj_path: Path) -> None:
expected = (proj_path / ".scrapy").resolve()
assert Path(project_data_dir()) == expected
assert expected.is_dir()
# A second call finds the directory already created.
assert Path(project_data_dir()) == expected
def test_project_data_dir_from_scrapy_cfg(proj_path: Path) -> None:
datadir = proj_path / "custom-datadir"
Path("scrapy.cfg").write_text(f"[datadir]\ndefault = {datadir}\n")
assert Path(project_data_dir()) == datadir
assert datadir.is_dir()
class TestGetProjectSettings:
def test_valid_envvar(self):
value = "tests.test_cmdline.settings"

View File

@ -12,8 +12,10 @@ from scrapy.utils.asyncgen import as_async_generator, collect_asyncgen
from scrapy.utils.defer import aiter_errback
from scrapy.utils.python import (
MutableAsyncChain,
_looks_like_import_path,
binary_is_text,
get_func_args,
get_spec,
memoizemethod_noargs,
to_bytes,
to_unicode,
@ -138,6 +140,11 @@ def test_binaryistext(value: bytes, expected: bool) -> None:
assert binary_is_text(value) is expected
def test_binaryistext_not_bytes() -> None:
with pytest.raises(TypeError, match="data must be bytes, got 'str'"):
binary_is_text("hello") # type: ignore[arg-type]
def test_get_func_args():
def f1(a, b, c):
pass
@ -164,6 +171,8 @@ def test_get_func_args():
partial_f1 = functools.partial(f1, None)
partial_f2 = functools.partial(f1, b=None)
partial_f3 = functools.partial(partial_f2, None)
# a positional value that happens to match the name of a free parameter
partial_f4 = functools.partial(f1, "b")
assert get_func_args(f1) == ["a", "b", "c"]
assert get_func_args(f2) == ["a", "b", "c"]
@ -173,6 +182,7 @@ def test_get_func_args():
assert get_func_args(partial_f1) == ["b", "c"]
assert get_func_args(partial_f2) == ["a", "c"]
assert get_func_args(partial_f3) == ["c"]
assert get_func_args(partial_f4) == ["b", "c"]
assert get_func_args(cal) == ["a", "b", "c"]
assert get_func_args(object) == []
assert get_func_args(str.split, stripself=True) == ["sep", "maxsplit"]
@ -209,6 +219,35 @@ def test_get_func_args_unresolvable_annotations():
assert get_func_args(namespace["f"]) == ["a", "b"]
def test_get_func_args_not_callable() -> None:
with pytest.raises(TypeError, match="func must be callable, got 'int'"):
get_func_args(1) # type: ignore[arg-type]
def test_get_spec_not_callable() -> None:
with pytest.raises(TypeError, match="is not callable"):
get_spec(1) # type: ignore[arg-type]
@pytest.mark.parametrize(
("value", "expected"),
[
("scrapy.Spider", True),
("scrapy", True),
("", False),
("scrapy Spider", False),
("scrapy.Spider\n", False),
("scrapy-Spider", False),
(".scrapy", False),
("scrapy.", False),
("scrapy..Spider", False),
("scrapy.1Spider", False),
],
)
def test_looks_like_import_path(value: str, expected: bool) -> None:
assert _looks_like_import_path(value) is expected
@pytest.mark.parametrize(
("value", "expected"),
[

View File

@ -1,17 +1,29 @@
from __future__ import annotations
import asyncio
import sys
from typing import TYPE_CHECKING
import pytest
from twisted.internet.error import CannotListenError
from twisted.internet.protocol import ServerFactory
from scrapy.utils.reactor import (
_asyncio_reactor_path,
install_reactor,
is_asyncio_reactor_installed,
listen_tcp,
set_asyncio_event_loop,
verify_installed_asyncio_event_loop,
verify_installed_reactor,
)
from tests.utils.decorators import coroutine_test
if TYPE_CHECKING:
from collections.abc import Generator
from twisted.internet.tcp import Port
class TestAsyncio:
@pytest.mark.requires_reactor # needs a reactor
@ -25,3 +37,64 @@ class TestAsyncio:
async def test_set_asyncio_event_loop(self):
install_reactor(_asyncio_reactor_path)
assert set_asyncio_event_loop(None) is asyncio.get_running_loop()
class TestVerifyWithoutReactor:
@pytest.fixture(autouse=True)
def no_reactor(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delitem(sys.modules, "twisted.internet.reactor", raising=False)
def test_verify_installed_reactor(self) -> None:
with pytest.raises(
RuntimeError, match=r"verify_installed_reactor\(\) called without"
):
verify_installed_reactor(_asyncio_reactor_path)
def test_verify_installed_asyncio_event_loop(self) -> None:
with pytest.raises(
RuntimeError,
match=r"verify_installed_asyncio_event_loop\(\) called without",
):
verify_installed_asyncio_event_loop("asyncio.SelectorEventLoop")
@pytest.mark.requires_reactor # needs a reactor
class TestListenTcp:
@pytest.fixture
def ports(self) -> Generator[list[Port]]:
opened: list[Port] = []
yield opened
for port in opened:
port.stopListening()
@pytest.mark.parametrize("portrange", [[1, 2, 3], [8000, 7000]])
def test_invalid_portrange(self, portrange: list[int]) -> None:
with pytest.raises(ValueError, match="invalid portrange"):
listen_tcp(portrange, "127.0.0.1", ServerFactory())
def test_empty_portrange(self, ports: list[Port]) -> None:
port = listen_tcp([], "127.0.0.1", ServerFactory())
ports.append(port)
assert port.getHost().port > 0
def test_single_port(self, ports: list[Port]) -> None:
port = listen_tcp([0], "127.0.0.1", ServerFactory())
ports.append(port)
assert port.getHost().port > 0
def test_skips_used_ports(self, ports: list[Port]) -> None:
used = listen_tcp([], "127.0.0.1", ServerFactory())
ports.append(used)
used_number = used.getHost().port
port = listen_tcp([used_number, used_number + 50], "127.0.0.1", ServerFactory())
ports.append(port)
assert used_number < port.getHost().port <= used_number + 50
def test_no_free_port(self, ports: list[Port]) -> None:
used = listen_tcp([], "127.0.0.1", ServerFactory())
ports.append(used)
used_number = used.getHost().port
with pytest.raises(CannotListenError):
listen_tcp([used_number, used_number], "127.0.0.1", ServerFactory())

View File

@ -53,6 +53,32 @@ class TestSendCatchLog:
dispatcher.disconnect(self.error_handler, signal=test_signal)
dispatcher.disconnect(self.ok_handler, signal=test_signal)
@coroutine_test
async def test_send_catch_log_dont_log(
self, caplog: pytest.LogCaptureFixture
) -> None:
test_signal = object()
handlers_called: set[Callable[..., None]] = set()
dispatcher.connect(self.error_handler, signal=test_signal)
caplog.clear()
result = await ensure_awaitable(
self._get_result(
test_signal,
arg="test",
handlers_called=handlers_called,
dont_log=ZeroDivisionError,
)
)
assert self.error_handler in handlers_called
assert not caplog.records
assert isinstance(
result[0][1], Exception if self.returns_exceptions else Failure
)
dispatcher.disconnect(self.error_handler, signal=test_signal)
def _get_result(self, signal: Any, *a: Any, **kw: Any) -> Any:
return send_catch_log(signal, *a, **kw)

View File

@ -41,6 +41,21 @@ def test_sitemap():
]
def test_sitemap_str():
xmltext = """<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.google.com/schemas/sitemap/0.84">
<url><loc>http://www.example.com/</loc></url>
</urlset>"""
with pytest.warns(
ScrapyDeprecationWarning,
match="Passing `str` type as `xmltext` is deprecated",
):
s = Sitemap(xmltext)
assert s.type == "urlset"
assert list(s) == [{"loc": "http://www.example.com/"}]
def test_sitemap_index():
s = Sitemap(
b"""<?xml version="1.0" encoding="UTF-8"?>

View File

@ -1,17 +1,38 @@
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
import pytest
from scrapy import Spider
from scrapy.http import Request
from scrapy.item import Item
from scrapy.utils.spider import iter_spider_classes, iterate_spider_output
from scrapy.settings import Settings
from scrapy.spiderloader import get_spider_loader
from scrapy.utils.spider import (
iter_spider_classes,
iterate_spider_output,
spidercls_for_request,
)
if TYPE_CHECKING:
from scrapy.spiderloader import SpiderLoaderProtocol
class MySpider1(Spider):
name = "myspider1"
allowed_domains = ["example.com", "myspider1.example"]
class MySpider2(Spider):
name = "myspider2"
allowed_domains = ["example.com"]
@pytest.fixture
def spider_loader() -> SpiderLoaderProtocol:
return get_spider_loader(Settings({"SPIDER_MODULES": ["tests.test_utils_spider"]}))
def test_iterate_spider_output():
@ -30,3 +51,38 @@ def test_iter_spider_classes():
it = iter_spider_classes(tests.test_utils_spider)
assert set(it) == {MySpider1, MySpider2}
class TestSpiderclsForRequest:
def test_single_match(self, spider_loader: SpiderLoaderProtocol) -> None:
request = Request("http://myspider1.example/")
assert spidercls_for_request(spider_loader, request) is MySpider1
def test_no_match(self, spider_loader: SpiderLoaderProtocol) -> None:
request = Request("http://toscrape.com/")
assert spidercls_for_request(spider_loader, request) is None
assert spidercls_for_request(spider_loader, request, MySpider1) is MySpider1
def test_multiple_matches(self, spider_loader: SpiderLoaderProtocol) -> None:
request = Request("http://example.com/")
assert spidercls_for_request(spider_loader, request) is None
assert spidercls_for_request(spider_loader, request, MySpider2) is MySpider2
def test_log_none(
self, spider_loader: SpiderLoaderProtocol, caplog: pytest.LogCaptureFixture
) -> None:
request = Request("http://toscrape.com/")
with caplog.at_level(logging.ERROR):
assert spidercls_for_request(spider_loader, request, log_none=True) is None
assert "Unable to find spider that handles" in caplog.text
def test_log_multiple(
self, spider_loader: SpiderLoaderProtocol, caplog: pytest.LogCaptureFixture
) -> None:
request = Request("http://example.com/")
with caplog.at_level(logging.ERROR):
assert (
spidercls_for_request(spider_loader, request, log_multiple=True) is None
)
assert "More than one spider can handle" in caplog.text
assert "myspider1, myspider2" in caplog.text

49
tests/test_utils_ssl.py Normal file
View File

@ -0,0 +1,49 @@
from __future__ import annotations
import warnings
import OpenSSL._util as pyOpenSSLutil
import pytest
from OpenSSL import crypto
from scrapy.exceptions import ScrapyDeprecationWarning
from scrapy.utils.ssl import (
ffi_buf_to_string,
get_openssl_version,
get_temp_key_info,
x509name_to_string,
)
def test_ffi_buf_to_string() -> None:
buf = pyOpenSSLutil.ffi.new("char[]", b"some text")
with pytest.warns(
ScrapyDeprecationWarning, match=r"ffi_buf_to_string\(\) is deprecated"
):
assert ffi_buf_to_string(buf) == "some text"
def test_x509name_to_string() -> None:
with warnings.catch_warnings():
# X509 itself is deprecated in pyOpenSSL, but it is the only way to build
# the X509Name that the deprecated function under test takes.
warnings.simplefilter("ignore", DeprecationWarning)
subject = crypto.X509().get_subject()
subject.C = "IE"
subject.O = "Scrapy"
subject.CN = "localhost"
with pytest.warns(
ScrapyDeprecationWarning, match=r"x509name_to_string\(\) is deprecated"
):
assert x509name_to_string(subject) == "/C=IE/O=Scrapy/CN=localhost"
def test_get_temp_key_info() -> None:
with pytest.warns(
ScrapyDeprecationWarning, match=r"get_temp_key_info\(\) is deprecated"
):
assert get_temp_key_info(object()) is None
def test_get_openssl_version() -> None:
assert "OpenSSL" in get_openssl_version()

View File

@ -119,8 +119,18 @@ def test_get_oldest():
assert trackref.get_oldest("Foo") is o3
def test_get_oldest_all_dead():
o1 = Foo()
del o1
if _IS_PYPY:
garbage_collect()
# Foo is still a key of live_refs, but it no longer tracks any instance.
assert trackref.get_oldest("Foo") is None
def test_iter_all():
o1 = Foo()
o2 = Bar() # noqa: F841
o3 = Foo()
assert set(trackref.iter_all("Foo")) == {o1, o3}
assert list(trackref.iter_all("XXX")) == []

View File

@ -31,10 +31,7 @@ from scrapy.exceptions import (
UnsupportedURLSchemeError,
)
from scrapy.http import Headers, HtmlResponse, Request, Response, TextResponse
from scrapy.utils._deps_compat import (
PYOPENSSL_X509_DEPRECATED,
TWISTED_TLS_LIMITS_OFFBY1,
)
from scrapy.utils._deps_compat import TWISTED_TLS_LIMITS_OFFBY1
from scrapy.utils.defer import deferred_from_coro, maybe_deferred_to_future
from scrapy.utils.misc import build_from_crawler
from scrapy.utils.spider import DefaultSpider
@ -832,15 +829,8 @@ class TestHttpsBase(TestHttpBase):
is_secure = True
tls_log_message = (
(
'SSL connection certificate: issuer "CN=localhost,O=Scrapy,C=IE", '
'subject "CN=localhost,O=Scrapy,C=IE"'
)
if PYOPENSSL_X509_DEPRECATED
else (
'SSL connection certificate: issuer "/C=IE/O=Scrapy/CN=localhost", '
'subject "/C=IE/O=Scrapy/CN=localhost"'
)
'SSL connection certificate: issuer "CN=localhost,O=Scrapy,C=IE", '
'subject "CN=localhost,O=Scrapy,C=IE"'
)
def test_download_conn_lost(self) -> None: # type: ignore[override]
@ -902,6 +892,9 @@ class TestSimpleHttpsBase(ABC):
certfile = "keys/localhost.crt"
host = "localhost"
cipher_string: str | None = None
# Crawler settings for the download handler. When unset, the client is asked
# for the same ciphers the server is restricted to.
client_settings: dict[str, Any] | None = None
@pytest.fixture(scope="class")
@classmethod
@ -923,10 +916,9 @@ class TestSimpleHttpsBase(ABC):
@asynccontextmanager
async def get_dh(self) -> AsyncGenerator[DownloadHandlerProtocol]:
if self.cipher_string is not None:
settings_dict = self.client_settings
if settings_dict is None and self.cipher_string is not None:
settings_dict = {"DOWNLOADER_CLIENT_TLS_CIPHERS": self.cipher_string}
else:
settings_dict = None
crawler = get_crawler(DefaultSpider, settings_dict=settings_dict)
crawler.spider = crawler._create_spider()
dh = build_from_crawler(self.download_handler_cls, crawler)
@ -970,6 +962,12 @@ class TestHttpsCustomCiphersBase(TestSimpleHttpsBase):
cipher_string = "CAMELLIA256-SHA"
class TestHttpsDefaultCiphersBase(TestSimpleHttpsBase):
"""A ``None`` cipher list leaves the TLS library defaults in place."""
client_settings: dict[str, Any] | None = {"DOWNLOADER_CLIENT_TLS_CIPHERS": None}
class TestHttpsTLSVersionBase(ABC):
keyfile = "keys/localhost.key"
certfile = "keys/localhost.crt"

11
tox.ini
View File

@ -134,15 +134,15 @@ deps =
pytest==8.4.0
Protego==0.1.15
Twisted==21.7.0
cryptography==37.0.0
cryptography==41.0.5
cssselect==0.9.1
httpx2==2.0.0
itemadapter==0.1.0
lxml==4.6.4
parsel==1.5.0
pyOpenSSL==22.0.0
pyOpenSSL==24.3.0
queuelib==1.4.2
service_identity==23.1.0
service_identity==24.2.0
w3lib==1.17.0
zope.interface==5.1.0
{[test-requirements]deps}
@ -236,8 +236,7 @@ setenv =
[testenv:pypy3]
basepython = pypy3
commands =
; not enabling coverage as it significantly increases the run time
pytest {posargs:--durations=10 scrapy tests}
pytest {posargs:--cov-config=pyproject.toml --cov=scrapy --cov-report= --cov-report=term-missing --cov-report=xml --junitxml=pypy3.junit.xml -o junit_family=legacy --durations=10 scrapy tests}
[testenv:pypy3-extra-deps]
basepython = pypy3
@ -260,7 +259,7 @@ deps =
parsel==1.5.0
pyOpenSSL==24.3.0
queuelib==1.4.2
service_identity==23.1.0
service_identity==24.2.0
# w3lib 1.17 fails to import on PyPy 3.11 because its encoding regex uses
# an inline flag placement that Python 3.11 treats as an error: global
# flags not at the start of the expression. w3lib 1.18 stopped encoding []