Extract in-process test HTTP servers into separate processes (#6960)

* Switch TestEngineBase to mockserver.

* Split mockserver.py.

* Stop using WrappingFactory.

* Remove keyfile/certfile fields from TestHttpBase.

* Move webclient-only resources to test_webclient.py.

* Refactor MockServer.url().

* Migrate TestHttpBase to mockserver.

* Migrate TestSimpleHttpsBase to a separate mockserver.

* Rename TestHttpMockServerBase.

* Migrate TestHttpProxyBase to a separate mockserver.

* Refactor HTTP mockservers.
This commit is contained in:
Andrey Rakhmatullin 2025-07-25 12:42:27 +05:00 committed by GitHub
parent 9e83a58643
commit 8c8f4ff033
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
47 changed files with 1124 additions and 949 deletions

View File

@ -1,10 +1,17 @@
from __future__ import annotations
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING
import pytest import pytest
from twisted.web.http import H2_ENABLED from twisted.web.http import H2_ENABLED
from scrapy.utils.reactor import set_asyncio_event_loop_policy from scrapy.utils.reactor import set_asyncio_event_loop_policy
from tests.keys import generate_keys from tests.keys import generate_keys
from tests.mockserver.http import MockServer
if TYPE_CHECKING:
from collections.abc import Generator
def _py_files(folder): def _py_files(folder):
@ -48,6 +55,12 @@ if not H2_ENABLED:
) )
@pytest.fixture(scope="session")
def mockserver() -> Generator[MockServer]:
with MockServer() as mockserver:
yield mockserver
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
def reactor_pytest(request) -> str: def reactor_pytest(request) -> str:
return request.config.getoption("--reactor") return request.config.getoption("--reactor")

View File

@ -1,10 +1,11 @@
# ruff: noqa: E402 # ruff: noqa: E402
from scrapy.utils.reactor import install_reactor from scrapy.utils.reactor import install_reactor
from tests.mockserver.dns import MockDNSServer
from tests.mockserver.http import MockServer
install_reactor("twisted.internet.asyncioreactor.AsyncioSelectorReactor") install_reactor("twisted.internet.asyncioreactor.AsyncioSelectorReactor")
from urllib.parse import urlparse
from twisted.names import cache, resolve from twisted.names import cache, resolve
from twisted.names import hosts as hostsModule from twisted.names import hosts as hostsModule
@ -15,7 +16,6 @@ from scrapy import Request, Spider
from scrapy.crawler import CrawlerRunner from scrapy.crawler import CrawlerRunner
from scrapy.utils.httpobj import urlparse_cached from scrapy.utils.httpobj import urlparse_cached
from scrapy.utils.log import configure_logging from scrapy.utils.log import configure_logging
from tests.mockserver import MockDNSServer, MockServer
# https://stackoverflow.com/a/32784190 # https://stackoverflow.com/a/32784190
@ -46,7 +46,7 @@ if __name__ == "__main__":
from twisted.internet import reactor from twisted.internet import reactor
with MockServer() as mock_http_server, MockDNSServer() as mock_dns_server: with MockServer() as mock_http_server, MockDNSServer() as mock_dns_server:
port = urlparse(mock_http_server.http_address).port port = mock_http_server.http_port
url = f"http://not.a.real.domain:{port}/echo" url = f"http://not.a.real.domain:{port}/echo"
servers = [(mock_dns_server.host, mock_dns_server.port)] servers = [(mock_dns_server.host, mock_dns_server.port)]

View File

@ -1,24 +0,0 @@
from argparse import ArgumentParser
from pyftpdlib.authorizers import DummyAuthorizer
from pyftpdlib.handlers import FTPHandler
from pyftpdlib.servers import FTPServer
def main():
parser = ArgumentParser()
parser.add_argument("-d", "--directory")
args = parser.parse_args()
authorizer = DummyAuthorizer()
full_permissions = "elradfmwMT"
authorizer.add_anonymous(args.directory, perm=full_permissions)
handler = FTPHandler
handler.authorizer = authorizer
address = ("127.0.0.1", 2121)
server = FTPServer(address, handler)
server.serve_forever()
if __name__ == "__main__":
main()

View File

@ -1,436 +0,0 @@
from __future__ import annotations
import argparse
import json
import os
import random
import sys
from pathlib import Path
from shutil import rmtree
from subprocess import PIPE, Popen
from tempfile import mkdtemp
from typing import TYPE_CHECKING
from urllib.parse import urlencode
from OpenSSL import SSL
from twisted.internet import defer, ssl
from twisted.internet.task import deferLater
from twisted.names import dns, error
from twisted.names.server import DNSServerFactory
from twisted.web import resource, server
from twisted.web.server import NOT_DONE_YET, Site
from twisted.web.static import Data, File
from twisted.web.util import Redirect, redirectTo
from scrapy.utils.python import to_bytes, to_unicode
from tests import tests_datadir
if TYPE_CHECKING:
from twisted.internet.protocol import ServerFactory
def getarg(request, name, default=None, type_=None):
if name in request.args:
value = request.args[name][0]
if type_ is not None:
value = type_(value)
return value
return default
def get_mockserver_env() -> dict[str, str]:
"""Return a OS environment dict suitable to run mockserver processes."""
tests_path = Path(__file__).parent.parent
pythonpath = str(tests_path) + os.pathsep + os.environ.get("PYTHONPATH", "")
env = os.environ.copy()
env["PYTHONPATH"] = pythonpath
return env
# most of the following resources are copied from twisted.web.test.test_webclient
class ForeverTakingResource(resource.Resource):
"""
L{ForeverTakingResource} is a resource which never finishes responding
to requests.
"""
def __init__(self, write=False):
resource.Resource.__init__(self)
self._write = write
def render(self, request):
if self._write:
request.write(b"some bytes")
return server.NOT_DONE_YET
class ErrorResource(resource.Resource):
def render(self, request):
request.setResponseCode(401)
if request.args.get(b"showlength"):
request.setHeader(b"content-length", b"0")
return b""
class NoLengthResource(resource.Resource):
def render(self, request):
return b"nolength"
class HostHeaderResource(resource.Resource):
"""
A testing resource which renders itself as the value of the host header
from the request.
"""
def render(self, request):
return request.requestHeaders.getRawHeaders(b"host")[0]
class PayloadResource(resource.Resource):
"""
A testing resource which renders itself as the contents of the request body
as long as the request body is 100 bytes long, otherwise which renders
itself as C{"ERROR"}.
"""
def render(self, request):
data = request.content.read()
contentLength = request.requestHeaders.getRawHeaders(b"content-length")[0]
if len(data) != 100 or int(contentLength) != 100:
return b"ERROR"
return data
class BrokenDownloadResource(resource.Resource):
def render(self, request):
# only sends 3 bytes even though it claims to send 5
request.setHeader(b"content-length", b"5")
request.write(b"abc")
return b""
class LeafResource(resource.Resource):
isLeaf = True
def deferRequest(self, request, delay, f, *a, **kw):
from twisted.internet import reactor
def _cancelrequest(_):
# silence CancelledError
d.addErrback(lambda _: None)
d.cancel()
d = deferLater(reactor, delay, f, *a, **kw)
request.notifyFinish().addErrback(_cancelrequest)
return d
class Follow(LeafResource):
def render(self, request):
total = getarg(request, b"total", 100, type_=int)
show = getarg(request, b"show", 1, type_=int)
order = getarg(request, b"order", b"desc")
maxlatency = getarg(request, b"maxlatency", 0, type_=float)
n = getarg(request, b"n", total, type_=int)
if order == b"rand":
nlist = [random.randint(1, total) for _ in range(show)]
else: # order == "desc"
nlist = range(n, max(n - show, 0), -1)
lag = random.random() * maxlatency
self.deferRequest(request, lag, self.renderRequest, request, nlist)
return NOT_DONE_YET
def renderRequest(self, request, nlist):
s = """<html> <head></head> <body>"""
args = request.args.copy()
for nl in nlist:
args[b"n"] = [to_bytes(str(nl))]
argstr = urlencode(args, doseq=True)
s += f"<a href='/follow?{argstr}'>follow {nl}</a><br>"
s += """</body>"""
request.write(to_bytes(s))
request.finish()
class Delay(LeafResource):
def render_GET(self, request):
n = getarg(request, b"n", 1, type_=float)
b = getarg(request, b"b", 1, type_=int)
if b:
# send headers now and delay body
request.write("")
self.deferRequest(request, n, self._delayedRender, request, n)
return NOT_DONE_YET
def _delayedRender(self, request, n):
request.write(to_bytes(f"Response delayed for {n:.3f} seconds\n"))
request.finish()
class Status(LeafResource):
def render_GET(self, request):
n = getarg(request, b"n", 200, type_=int)
request.setResponseCode(n)
return b""
class Raw(LeafResource):
def render_GET(self, request):
request.startedWriting = 1
self.deferRequest(request, 0, self._delayedRender, request)
return NOT_DONE_YET
render_POST = render_GET
def _delayedRender(self, request):
raw = getarg(request, b"raw", b"HTTP 1.1 200 OK\n")
request.startedWriting = 1
request.write(raw)
request.channel.transport.loseConnection()
request.finish()
class Echo(LeafResource):
def render_GET(self, request):
output = {
"headers": {
to_unicode(k): [to_unicode(v) for v in vs]
for k, vs in request.requestHeaders.getAllRawHeaders()
},
"body": to_unicode(request.content.read()),
}
return to_bytes(json.dumps(output))
render_POST = render_GET
class RedirectTo(LeafResource):
def render(self, request):
goto = getarg(request, b"goto", b"/")
# we force the body content, otherwise Twisted redirectTo()
# returns HTML with <meta http-equiv="refresh"
redirectTo(goto, request)
return b"redirecting..."
class Partial(LeafResource):
def render_GET(self, request):
request.setHeader(b"Content-Length", b"1024")
self.deferRequest(request, 0, self._delayedRender, request)
return NOT_DONE_YET
def _delayedRender(self, request):
request.write(b"partial content\n")
request.finish()
class Drop(Partial):
def _delayedRender(self, request):
abort = getarg(request, b"abort", 0, type_=int)
request.write(b"this connection will be dropped\n")
tr = request.channel.transport
try:
if abort and hasattr(tr, "abortConnection"):
tr.abortConnection()
else:
tr.loseConnection()
finally:
request.finish()
class ArbitraryLengthPayloadResource(LeafResource):
def render(self, request):
return request.content.read()
class NoMetaRefreshRedirect(Redirect):
def render(self, request: server.Request) -> bytes:
content = Redirect.render(self, request)
return content.replace(
b'http-equiv="refresh"', b'http-no-equiv="do-not-refresh-me"'
)
class Root(resource.Resource):
def __init__(self):
resource.Resource.__init__(self)
self.putChild(b"status", Status())
self.putChild(b"follow", Follow())
self.putChild(b"delay", Delay())
self.putChild(b"partial", Partial())
self.putChild(b"drop", Drop())
self.putChild(b"raw", Raw())
self.putChild(b"echo", Echo())
self.putChild(b"payload", PayloadResource())
self.putChild(b"alpayload", ArbitraryLengthPayloadResource())
self.putChild(b"files", File(str(Path(tests_datadir, "test_site/files/"))))
self.putChild(b"redirect-to", RedirectTo())
self.putChild(b"text", Data(b"Works", "text/plain"))
self.putChild(
b"html",
Data(
b"<body><p class='one'>Works</p><p class='two'>World</p></body>",
"text/html",
),
)
self.putChild(
b"enc-gb18030",
Data(b"<p>gb18030 encoding</p>", "text/html; charset=gb18030"),
)
self.putChild(b"redirect", Redirect(b"/redirected"))
self.putChild(
b"redirect-no-meta-refresh", NoMetaRefreshRedirect(b"/redirected")
)
self.putChild(b"redirected", Data(b"Redirected here", "text/plain"))
def getChild(self, name, request):
return self
def render(self, request):
return b"Scrapy mock HTTP server\n"
class MockServer:
def __enter__(self):
self.proc = Popen(
[sys.executable, "-u", "-m", "tests.mockserver", "-t", "http"],
stdout=PIPE,
env=get_mockserver_env(),
)
http_address = self.proc.stdout.readline().strip().decode("ascii")
https_address = self.proc.stdout.readline().strip().decode("ascii")
self.http_address = http_address
self.https_address = https_address
return self
def __exit__(self, exc_type, exc_value, traceback):
self.proc.kill()
self.proc.communicate()
def url(self, path, is_secure=False):
host = self.https_address if is_secure else self.http_address
host = host.replace("0.0.0.0", "127.0.0.1")
return host + path
class MockDNSResolver:
"""
Implements twisted.internet.interfaces.IResolver partially
"""
def _resolve(self, name):
record = dns.Record_A(address=b"127.0.0.1")
answer = dns.RRHeader(name=name, payload=record)
return [answer], [], []
def query(self, query, timeout=None):
if query.type == dns.A:
return defer.succeed(self._resolve(query.name.name))
return defer.fail(error.DomainError())
def lookupAllRecords(self, name, timeout=None):
return defer.succeed(self._resolve(name))
class MockDNSServer:
def __enter__(self):
self.proc = Popen(
[sys.executable, "-u", "-m", "tests.mockserver", "-t", "dns"],
stdout=PIPE,
env=get_mockserver_env(),
)
self.host = "127.0.0.1"
self.port = int(
self.proc.stdout.readline().strip().decode("ascii").split(":")[1]
)
return self
def __exit__(self, exc_type, exc_value, traceback):
self.proc.kill()
self.proc.communicate()
class MockFTPServer:
"""Creates an FTP server on port 2121 with a default passwordless user
(anonymous) and a temporary root path that you can read from the
:attr:`path` attribute."""
def __enter__(self):
self.path = Path(mkdtemp())
self.proc = Popen(
[sys.executable, "-u", "-m", "tests.ftpserver", "-d", str(self.path)],
stderr=PIPE,
env=get_mockserver_env(),
)
for line in self.proc.stderr:
if b"starting FTP server" in line:
break
return self
def __exit__(self, exc_type, exc_value, traceback):
rmtree(str(self.path))
self.proc.kill()
self.proc.communicate()
def url(self, path):
return "ftp://127.0.0.1:2121/" + path
def ssl_context_factory(
keyfile="keys/localhost.key", certfile="keys/localhost.crt", cipher_string=None
):
factory = ssl.DefaultOpenSSLContextFactory(
str(Path(__file__).parent / keyfile),
str(Path(__file__).parent / certfile),
)
if cipher_string:
ctx = factory.getContext()
# disabling TLS1.3 because it unconditionally enables some strong ciphers
ctx.set_options(SSL.OP_CIPHER_SERVER_PREFERENCE | SSL.OP_NO_TLSv1_3)
ctx.set_cipher_list(to_bytes(cipher_string))
return factory
if __name__ == "__main__":
from twisted.internet import reactor
parser = argparse.ArgumentParser()
parser.add_argument(
"-t", "--type", type=str, choices=("http", "dns"), default="http"
)
args = parser.parse_args()
factory: ServerFactory
if args.type == "http":
root = Root()
factory = Site(root)
httpPort = reactor.listenTCP(0, factory)
contextFactory = ssl_context_factory()
httpsPort = reactor.listenSSL(0, factory, contextFactory)
def print_listening():
httpHost = httpPort.getHost()
httpsHost = httpsPort.getHost()
httpAddress = f"http://{httpHost.host}:{httpHost.port}"
httpsAddress = f"https://{httpsHost.host}:{httpsHost.port}"
print(httpAddress)
print(httpsAddress)
elif args.type == "dns":
clients = [MockDNSResolver()]
factory = DNSServerFactory(clients=clients)
protocol = dns.DNSDatagramProtocol(controller=factory)
listener = reactor.listenUDP(0, protocol)
def print_listening():
host = listener.getHost()
print(f"{host.host}:{host.port}")
reactor.callWhenRunning(print_listening)
reactor.run()

View File

67
tests/mockserver/dns.py Normal file
View File

@ -0,0 +1,67 @@
from __future__ import annotations
import sys
from subprocess import PIPE, Popen
from twisted.internet import defer
from twisted.names import dns, error
from twisted.names.server import DNSServerFactory
from tests.utils import get_script_run_env
class MockDNSResolver:
"""
Implements twisted.internet.interfaces.IResolver partially
"""
def _resolve(self, name):
record = dns.Record_A(address=b"127.0.0.1")
answer = dns.RRHeader(name=name, payload=record)
return [answer], [], []
def query(self, query, timeout=None):
if query.type == dns.A:
return defer.succeed(self._resolve(query.name.name))
return defer.fail(error.DomainError())
def lookupAllRecords(self, name, timeout=None):
return defer.succeed(self._resolve(name))
class MockDNSServer:
def __enter__(self):
self.proc = Popen(
[sys.executable, "-u", "-m", "tests.mockserver.dns"],
stdout=PIPE,
env=get_script_run_env(),
)
self.host = "127.0.0.1"
self.port = int(
self.proc.stdout.readline().strip().decode("ascii").split(":")[1]
)
return self
def __exit__(self, exc_type, exc_value, traceback):
self.proc.kill()
self.proc.communicate()
def main() -> None:
from twisted.internet import reactor
clients = [MockDNSResolver()]
factory = DNSServerFactory(clients=clients)
protocol = dns.DNSDatagramProtocol(controller=factory)
listener = reactor.listenUDP(0, protocol)
def print_listening():
host = listener.getHost()
print(f"{host.host}:{host.port}")
reactor.callWhenRunning(print_listening)
reactor.run()
if __name__ == "__main__":
main()

59
tests/mockserver/ftp.py Normal file
View File

@ -0,0 +1,59 @@
from __future__ import annotations
import sys
from argparse import ArgumentParser
from pathlib import Path
from shutil import rmtree
from subprocess import PIPE, Popen
from tempfile import mkdtemp
from pyftpdlib.authorizers import DummyAuthorizer
from pyftpdlib.handlers import FTPHandler
from pyftpdlib.servers import FTPServer
from tests.utils import get_script_run_env
class MockFTPServer:
"""Creates an FTP server on port 2121 with a default passwordless user
(anonymous) and a temporary root path that you can read from the
:attr:`path` attribute."""
def __enter__(self):
self.path = Path(mkdtemp())
self.proc = Popen(
[sys.executable, "-u", "-m", "tests.mockserver.ftp", "-d", str(self.path)],
stderr=PIPE,
env=get_script_run_env(),
)
for line in self.proc.stderr:
if b"starting FTP server" in line:
break
return self
def __exit__(self, exc_type, exc_value, traceback):
rmtree(str(self.path))
self.proc.kill()
self.proc.communicate()
def url(self, path):
return "ftp://127.0.0.1:2121/" + path
def main() -> None:
parser = ArgumentParser()
parser.add_argument("-d", "--directory")
args = parser.parse_args()
authorizer = DummyAuthorizer()
full_permissions = "elradfmwMT"
authorizer.add_anonymous(args.directory, perm=full_permissions)
handler = FTPHandler
handler.authorizer = authorizer
address = ("127.0.0.1", 2121)
server = FTPServer(address, handler)
server.serve_forever()
if __name__ == "__main__":
main()

95
tests/mockserver/http.py Normal file
View File

@ -0,0 +1,95 @@
from __future__ import annotations
from pathlib import Path
from twisted.web import resource
from twisted.web.static import Data, File
from twisted.web.util import Redirect
from tests import tests_datadir
from .http_base import BaseMockServer, main_factory
from .http_resources import (
ArbitraryLengthPayloadResource,
BrokenChunkedResource,
BrokenDownloadResource,
ChunkedResource,
ContentLengthHeaderResource,
Delay,
Drop,
DuplicateHeaderResource,
Echo,
EmptyContentTypeHeaderResource,
Follow,
ForeverTakingResource,
HostHeaderResource,
LargeChunkedFileResource,
NoMetaRefreshRedirect,
Partial,
PayloadResource,
Raw,
RedirectTo,
Status,
)
class Root(resource.Resource):
def __init__(self):
super().__init__()
self.putChild(b"status", Status())
self.putChild(b"follow", Follow())
self.putChild(b"delay", Delay())
self.putChild(b"partial", Partial())
self.putChild(b"drop", Drop())
self.putChild(b"raw", Raw())
self.putChild(b"echo", Echo())
self.putChild(b"payload", PayloadResource())
self.putChild(b"alpayload", ArbitraryLengthPayloadResource())
self.putChild(b"static", File(str(Path(tests_datadir, "test_site/"))))
self.putChild(b"redirect-to", RedirectTo())
self.putChild(b"text", Data(b"Works", "text/plain"))
self.putChild(
b"html",
Data(
b"<body><p class='one'>Works</p><p class='two'>World</p></body>",
"text/html",
),
)
self.putChild(
b"enc-gb18030",
Data(b"<p>gb18030 encoding</p>", "text/html; charset=gb18030"),
)
self.putChild(b"redirect", Redirect(b"/redirected"))
self.putChild(
b"redirect-no-meta-refresh", NoMetaRefreshRedirect(b"/redirected")
)
self.putChild(b"redirected", Data(b"Redirected here", "text/plain"))
numbers = [str(x).encode("utf8") for x in range(2**18)]
self.putChild(b"numbers", Data(b"".join(numbers), "text/plain"))
self.putChild(b"wait", ForeverTakingResource())
self.putChild(b"hang-after-headers", ForeverTakingResource(write=True))
self.putChild(b"host", HostHeaderResource())
self.putChild(b"broken", BrokenDownloadResource())
self.putChild(b"chunked", ChunkedResource())
self.putChild(b"broken-chunked", BrokenChunkedResource())
self.putChild(b"contentlength", ContentLengthHeaderResource())
self.putChild(b"nocontenttype", EmptyContentTypeHeaderResource())
self.putChild(b"largechunkedfile", LargeChunkedFileResource())
self.putChild(b"duplicate-header", DuplicateHeaderResource())
def getChild(self, name, request):
return self
def render(self, request):
return b"Scrapy mock HTTP server\n"
class MockServer(BaseMockServer):
module_name = "tests.mockserver.http"
main = main_factory(Root)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,132 @@
"""Base classes and functions for HTTP mockservers."""
from __future__ import annotations
import argparse
import sys
from abc import ABC, abstractmethod
from subprocess import PIPE, Popen
from typing import TYPE_CHECKING
from urllib.parse import urlparse
from twisted.web.server import Site
from tests.utils import get_script_run_env
from .utils import ssl_context_factory
if TYPE_CHECKING:
from collections.abc import Callable
from twisted.web import resource
class BaseMockServer(ABC):
listen_http: bool = True
listen_https: bool = True
@property
@abstractmethod
def module_name(self) -> str:
raise NotImplementedError
def __init__(self) -> None:
if not self.listen_http and not self.listen_https:
raise ValueError("At least one of listen_http and listen_https must be set")
self.proc: Popen | None = None
self.host: str = "127.0.0.1"
self.http_port: int | None = None
self.https_port: int | None = None
def __enter__(self):
self.proc = Popen(
[sys.executable, "-u", "-m", self.module_name, *self.get_additional_args()],
stdout=PIPE,
env=get_script_run_env(),
)
if self.listen_http:
http_address = self.proc.stdout.readline().strip().decode("ascii")
http_parsed = urlparse(http_address)
self.http_port = http_parsed.port
if self.listen_https:
https_address = self.proc.stdout.readline().strip().decode("ascii")
https_parsed = urlparse(https_address)
self.https_port = https_parsed.port
return self
def __exit__(self, exc_type, exc_value, traceback):
if self.proc:
self.proc.kill()
self.proc.communicate()
def get_additional_args(self) -> list[str]:
return []
def port(self, is_secure: bool = False) -> int:
if not is_secure and not self.listen_http:
raise ValueError("This server doesn't provide HTTP")
if is_secure and not self.listen_https:
raise ValueError("This server doesn't provide HTTPS")
port = self.https_port if is_secure else self.http_port
assert port is not None
return port
def url(self, path: str, is_secure: bool = False) -> str:
port = self.port(is_secure)
scheme = "https" if is_secure else "http"
return f"{scheme}://{self.host}:{port}{path}"
def main_factory(
resource_class: type[resource.Resource],
*,
listen_http: bool = True,
listen_https: bool = True,
) -> Callable[[], None]:
if not listen_http and not listen_https:
raise ValueError("At least one of listen_http and listen_https must be set")
def main() -> None:
from twisted.internet import reactor
root = resource_class()
factory = Site(root)
if listen_http:
http_port = reactor.listenTCP(0, factory)
if listen_https:
parser = argparse.ArgumentParser()
parser.add_argument("--keyfile", help="SSL key file")
parser.add_argument("--certfile", help="SSL certificate file")
parser.add_argument(
"--cipher-string",
default=None,
help="SSL cipher string (optional)",
)
args = parser.parse_args()
context_factory_kw = {}
if args.keyfile:
context_factory_kw["keyfile"] = args.keyfile
if args.certfile:
context_factory_kw["certfile"] = args.certfile
if args.cipher_string:
context_factory_kw["cipher_string"] = args.cipher_string
context_factory = ssl_context_factory(**context_factory_kw)
https_port = reactor.listenSSL(0, factory, context_factory)
def print_listening():
if listen_http:
http_host = http_port.getHost()
http_address = f"http://{http_host.host}:{http_host.port}"
print(http_address)
if listen_https:
https_host = https_port.getHost()
https_address = f"https://{https_host.host}:{https_host.port}"
print(https_address)
reactor.callWhenRunning(print_listening)
reactor.run()
return main

View File

@ -0,0 +1,309 @@
from __future__ import annotations
import json
import random
from urllib.parse import urlencode
from twisted.internet.task import deferLater
from twisted.web import resource, server
from twisted.web.server import NOT_DONE_YET
from twisted.web.util import Redirect, redirectTo
from scrapy.utils.python import to_bytes, to_unicode
def getarg(request, name, default=None, type_=None):
if name in request.args:
value = request.args[name][0]
if type_ is not None:
value = type_(value)
return value
return default
def close_connection(request):
# We have to force a disconnection for HTTP/1.1 clients. Otherwise
# client keeps the connection open waiting for more data.
request.channel.loseConnection()
request.finish()
# most of the following resources are copied from twisted.web.test.test_webclient
class ForeverTakingResource(resource.Resource):
"""
L{ForeverTakingResource} is a resource which never finishes responding
to requests.
"""
def __init__(self, write=False):
resource.Resource.__init__(self)
self._write = write
def render(self, request):
if self._write:
request.write(b"some bytes")
return server.NOT_DONE_YET
class HostHeaderResource(resource.Resource):
"""
A testing resource which renders itself as the value of the host header
from the request.
"""
def render(self, request):
return request.requestHeaders.getRawHeaders(b"host")[0]
class PayloadResource(resource.Resource):
"""
A testing resource which renders itself as the contents of the request body
as long as the request body is 100 bytes long, otherwise which renders
itself as C{"ERROR"}.
"""
def render(self, request):
data = request.content.read()
contentLength = request.requestHeaders.getRawHeaders(b"content-length")[0]
if len(data) != 100 or int(contentLength) != 100:
return b"ERROR"
return data
class LeafResource(resource.Resource):
isLeaf = True
def deferRequest(self, request, delay, f, *a, **kw):
from twisted.internet import reactor
def _cancelrequest(_):
# silence CancelledError
d.addErrback(lambda _: None)
d.cancel()
d = deferLater(reactor, delay, f, *a, **kw)
request.notifyFinish().addErrback(_cancelrequest)
return d
class Follow(LeafResource):
def render(self, request):
total = getarg(request, b"total", 100, type_=int)
show = getarg(request, b"show", 1, type_=int)
order = getarg(request, b"order", b"desc")
maxlatency = getarg(request, b"maxlatency", 0, type_=float)
n = getarg(request, b"n", total, type_=int)
if order == b"rand":
nlist = [random.randint(1, total) for _ in range(show)]
else: # order == "desc"
nlist = range(n, max(n - show, 0), -1)
lag = random.random() * maxlatency
self.deferRequest(request, lag, self.renderRequest, request, nlist)
return NOT_DONE_YET
def renderRequest(self, request, nlist):
s = """<html> <head></head> <body>"""
args = request.args.copy()
for nl in nlist:
args[b"n"] = [to_bytes(str(nl))]
argstr = urlencode(args, doseq=True)
s += f"<a href='/follow?{argstr}'>follow {nl}</a><br>"
s += """</body>"""
request.write(to_bytes(s))
request.finish()
class Delay(LeafResource):
def render_GET(self, request):
n = getarg(request, b"n", 1, type_=float)
b = getarg(request, b"b", 1, type_=int)
if b:
# send headers now and delay body
request.write("")
self.deferRequest(request, n, self._delayedRender, request, n)
return NOT_DONE_YET
def _delayedRender(self, request, n):
request.write(to_bytes(f"Response delayed for {n:.3f} seconds\n"))
request.finish()
class Status(LeafResource):
def render_GET(self, request):
n = getarg(request, b"n", 200, type_=int)
request.setResponseCode(n)
return b""
class Raw(LeafResource):
def render_GET(self, request):
request.startedWriting = 1
self.deferRequest(request, 0, self._delayedRender, request)
return NOT_DONE_YET
render_POST = render_GET
def _delayedRender(self, request):
raw = getarg(request, b"raw", b"HTTP 1.1 200 OK\n")
request.startedWriting = 1
request.write(raw)
request.channel.transport.loseConnection()
request.finish()
class Echo(LeafResource):
def render_GET(self, request):
output = {
"headers": {
to_unicode(k): [to_unicode(v) for v in vs]
for k, vs in request.requestHeaders.getAllRawHeaders()
},
"body": to_unicode(request.content.read()),
}
return to_bytes(json.dumps(output))
render_POST = render_GET
class RedirectTo(LeafResource):
def render(self, request):
goto = getarg(request, b"goto", b"/")
# we force the body content, otherwise Twisted redirectTo()
# returns HTML with <meta http-equiv="refresh"
redirectTo(goto, request)
return b"redirecting..."
class Partial(LeafResource):
def render_GET(self, request):
request.setHeader(b"Content-Length", b"1024")
self.deferRequest(request, 0, self._delayedRender, request)
return NOT_DONE_YET
def _delayedRender(self, request):
request.write(b"partial content\n")
request.finish()
class Drop(Partial):
def _delayedRender(self, request):
abort = getarg(request, b"abort", 0, type_=int)
request.write(b"this connection will be dropped\n")
tr = request.channel.transport
try:
if abort and hasattr(tr, "abortConnection"):
tr.abortConnection()
else:
tr.loseConnection()
finally:
request.finish()
class ArbitraryLengthPayloadResource(LeafResource):
def render(self, request):
return request.content.read()
class NoMetaRefreshRedirect(Redirect):
def render(self, request: server.Request) -> bytes:
content = Redirect.render(self, request)
return content.replace(
b'http-equiv="refresh"', b'http-no-equiv="do-not-refresh-me"'
)
class ContentLengthHeaderResource(resource.Resource):
"""
A testing resource which renders itself as the value of the Content-Length
header from the request.
"""
def render(self, request):
return request.requestHeaders.getRawHeaders(b"content-length")[0]
class ChunkedResource(resource.Resource):
def render(self, request):
from twisted.internet import reactor
def response():
request.write(b"chunked ")
request.write(b"content\n")
request.finish()
reactor.callLater(0, response)
return server.NOT_DONE_YET
class BrokenChunkedResource(resource.Resource):
def render(self, request):
from twisted.internet import reactor
def response():
request.write(b"chunked ")
request.write(b"content\n")
# Disable terminating chunk on finish.
request.chunked = False
close_connection(request)
reactor.callLater(0, response)
return server.NOT_DONE_YET
class BrokenDownloadResource(resource.Resource):
def render(self, request):
from twisted.internet import reactor
def response():
request.setHeader(b"Content-Length", b"20")
request.write(b"partial")
close_connection(request)
reactor.callLater(0, response)
return server.NOT_DONE_YET
class EmptyContentTypeHeaderResource(resource.Resource):
"""
A testing resource which renders itself as the value of request body
without content-type header in response.
"""
def render(self, request):
request.setHeader("content-type", "")
return request.content.read()
class LargeChunkedFileResource(resource.Resource):
def render(self, request):
from twisted.internet import reactor
def response():
for i in range(1024):
request.write(b"x" * 1024)
request.finish()
reactor.callLater(0, response)
return server.NOT_DONE_YET
class DuplicateHeaderResource(resource.Resource):
def render(self, request):
request.responseHeaders.setRawHeaders(b"Set-Cookie", [b"a=b", b"c=d"])
return b""
class UriResource(resource.Resource):
"""Return the full uri that was requested"""
def getChild(self, path, request):
return self
def render(self, request):
# Note: this is an ugly hack for CONNECT request timeout test.
# Returning some data here fail SSL/TLS handshake
# ToDo: implement proper HTTPS proxy tests, not faking them.
if request.method != b"CONNECT":
return request.uri
return b""

View File

@ -0,0 +1,17 @@
# This is only used by tests.test_downloader_handlers_http_base.TestHttpProxyBase
from __future__ import annotations
from .http_base import BaseMockServer, main_factory
from .http_resources import UriResource
class ProxyEchoMockServer(BaseMockServer):
module_name = "tests.mockserver.proxy_echo"
main = main_factory(UriResource)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,46 @@
# This is only used by tests.test_downloader_handlers_http_base.TestSimpleHttpsBase
from __future__ import annotations
from twisted.web import resource
from twisted.web.static import Data
from .http_base import BaseMockServer, main_factory
class Root(resource.Resource):
def __init__(self):
resource.Resource.__init__(self)
self.putChild(b"file", Data(b"0123456789", "text/plain"))
def getChild(self, name, request):
return self
class SimpleMockServer(BaseMockServer):
listen_http = False
module_name = "tests.mockserver.simple_https"
def __init__(self, keyfile: str, certfile: str, cipher_string: str | None):
super().__init__()
self.keyfile = keyfile
self.certfile = certfile
self.cipher_string = cipher_string or ""
def get_additional_args(self) -> list[str]:
args = [
"--keyfile",
self.keyfile,
"--certfile",
self.certfile,
]
if self.cipher_string is not None:
args.extend(["--cipher-string", self.cipher_string])
return args
main = main_factory(Root, listen_http=False)
if __name__ == "__main__":
main()

23
tests/mockserver/utils.py Normal file
View File

@ -0,0 +1,23 @@
from __future__ import annotations
from pathlib import Path
from OpenSSL import SSL
from twisted.internet import ssl
from scrapy.utils.python import to_bytes
def ssl_context_factory(
keyfile="keys/localhost.key", certfile="keys/localhost.crt", cipher_string=None
):
factory = ssl.DefaultOpenSSLContextFactory(
str(Path(__file__).parent.parent / keyfile),
str(Path(__file__).parent.parent / certfile),
)
if cipher_string:
ctx = factory.getContext()
# disabling TLS1.3 because it unconditionally enables some strong ciphers
ctx.set_options(SSL.OP_CIPHER_SERVER_PREFERENCE | SSL.OP_NO_TLSv1_3)
ctx.set_cipher_list(to_bytes(cipher_string))
return factory

View File

@ -1,7 +1,7 @@
from twisted.internet.defer import inlineCallbacks from twisted.internet.defer import inlineCallbacks
from scrapy.utils.test import get_crawler from scrapy.utils.test import get_crawler
from tests.mockserver import MockServer from tests.mockserver.http import MockServer
from tests.spiders import ( from tests.spiders import (
ErrorSpider, ErrorSpider,
FollowAllSpider, FollowAllSpider,

View File

@ -1,4 +1,4 @@
from tests.mockserver import MockServer from tests.mockserver.http import MockServer
from tests.test_commands import TestProjectBase from tests.test_commands import TestProjectBase

View File

@ -4,7 +4,7 @@ from pathlib import Path
from scrapy.commands import parse from scrapy.commands import parse
from scrapy.settings import Settings from scrapy.settings import Settings
from tests.mockserver import MockServer from tests.mockserver.http import MockServer
from tests.test_commands import TestCommandBase from tests.test_commands import TestCommandBase

View File

@ -8,7 +8,7 @@ from pexpect.popen_spawn import PopenSpawn
from scrapy.utils.reactor import _asyncio_reactor_path from scrapy.utils.reactor import _asyncio_reactor_path
from tests import NON_EXISTING_RESOLVABLE, tests_datadir from tests import NON_EXISTING_RESOLVABLE, tests_datadir
from tests.mockserver import MockServer from tests.mockserver.http import MockServer
from tests.test_commands import TestProjectBase from tests.test_commands import TestProjectBase

View File

@ -18,7 +18,7 @@ from scrapy.item import Field, Item
from scrapy.spidermiddlewares.httperror import HttpError from scrapy.spidermiddlewares.httperror import HttpError
from scrapy.spiders import Spider from scrapy.spiders import Spider
from scrapy.utils.test import get_crawler from scrapy.utils.test import get_crawler
from tests.mockserver import MockServer from tests.mockserver.http import MockServer
class DemoItem(Item): class DemoItem(Item):

View File

@ -6,7 +6,6 @@ from typing import TYPE_CHECKING, Any, cast
import OpenSSL.SSL import OpenSSL.SSL
import pytest import pytest
from pytest_twisted import async_yield_fixture from pytest_twisted import async_yield_fixture
from twisted.protocols.policies import WrappingFactory
from twisted.web import server, static from twisted.web import server, static
from twisted.web.client import Agent, BrowserLikePolicyForHTTPS, readBody from twisted.web.client import Agent, BrowserLikePolicyForHTTPS, readBody
from twisted.web.client import Response as TxResponse from twisted.web.client import Response as TxResponse
@ -22,7 +21,8 @@ from scrapy.utils.defer import deferred_f_from_coro_f, maybe_deferred_to_future
from scrapy.utils.misc import build_from_crawler from scrapy.utils.misc import build_from_crawler
from scrapy.utils.python import to_bytes from scrapy.utils.python import to_bytes
from scrapy.utils.test import get_crawler from scrapy.utils.test import get_crawler
from tests.mockserver import PayloadResource, ssl_context_factory from tests.mockserver.http_resources import PayloadResource
from tests.mockserver.utils import ssl_context_factory
if TYPE_CHECKING: if TYPE_CHECKING:
from twisted.internet.defer import Deferred from twisted.internet.defer import Deferred
@ -44,8 +44,7 @@ class TestContextFactoryBase:
r = static.File(str(tmp_path)) r = static.File(str(tmp_path))
r.putChild(b"payload", PayloadResource()) r.putChild(b"payload", PayloadResource())
site = server.Site(r, timeout=None) site = server.Site(r, timeout=None)
wrapper = WrappingFactory(site) port = self._listen(site)
port = self._listen(wrapper)
portno = port.getHost().port portno = port.getHost().port
yield f"https://127.0.0.1:{portno}/" yield f"https://127.0.0.1:{portno}/"

View File

@ -23,7 +23,7 @@ from scrapy.utils.engine import format_engine_status, get_engine_status
from scrapy.utils.python import to_unicode from scrapy.utils.python import to_unicode
from scrapy.utils.test import get_crawler, get_reactor_settings from scrapy.utils.test import get_crawler, get_reactor_settings
from tests import NON_EXISTING_RESOLVABLE from tests import NON_EXISTING_RESOLVABLE
from tests.mockserver import MockServer from tests.mockserver.http import MockServer
from tests.spiders import ( from tests.spiders import (
AsyncDefAsyncioGenComplexSpider, AsyncDefAsyncioGenComplexSpider,
AsyncDefAsyncioGenExcSpider, AsyncDefAsyncioGenExcSpider,

View File

@ -34,7 +34,8 @@ from scrapy.utils.defer import deferred_f_from_coro_f, deferred_from_coro
from scrapy.utils.log import configure_logging, get_scrapy_root_handler from scrapy.utils.log import configure_logging, get_scrapy_root_handler
from scrapy.utils.spider import DefaultSpider from scrapy.utils.spider import DefaultSpider
from scrapy.utils.test import get_crawler, get_reactor_settings from scrapy.utils.test import get_crawler, get_reactor_settings
from tests.mockserver import MockServer, get_mockserver_env from tests.mockserver.http import MockServer
from tests.utils import get_script_run_env
BASE_SETTINGS: dict[str, Any] = {} BASE_SETTINGS: dict[str, Any] = {}
@ -753,7 +754,7 @@ class ScriptRunnerMixin(ABC):
args = self.get_script_args(script_name, *script_args) args = self.get_script_args(script_name, *script_args)
p = subprocess.Popen( p = subprocess.Popen(
args, args,
env=get_mockserver_env(), env=get_script_run_env(),
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, stderr=subprocess.PIPE,
) )
@ -830,14 +831,14 @@ class TestCrawlerProcessSubprocessBase(ScriptRunnerMixin):
assert "Spider closed (finished)" in log assert "Spider closed (finished)" in log
assert "twisted.internet.error.DNSLookupError" not in log assert "twisted.internet.error.DNSLookupError" not in log
def test_caching_hostname_resolver_finite_execution(self): def test_caching_hostname_resolver_finite_execution(
with MockServer() as mock_server: self, mockserver: MockServer
http_address = mock_server.http_address.replace("0.0.0.0", "127.0.0.1") ) -> None:
log = self.run_script("caching_hostname_resolver.py", http_address) log = self.run_script("caching_hostname_resolver.py", mockserver.url("/"))
assert "Spider closed (finished)" in log assert "Spider closed (finished)" in log
assert "ERROR: Error downloading" not in log assert "ERROR: Error downloading" not in log
assert "TimeoutError" not in log assert "TimeoutError" not in log
assert "twisted.internet.error.DNSLookupError" not in log assert "twisted.internet.error.DNSLookupError" not in log
def test_twisted_reactor_asyncio(self): def test_twisted_reactor_asyncio(self):
log = self.run_script("twisted_reactor_asyncio.py") log = self.run_script("twisted_reactor_asyncio.py")

View File

@ -17,6 +17,7 @@ from tests.test_downloader_handlers_http_base import (
if TYPE_CHECKING: if TYPE_CHECKING:
from scrapy.core.downloader.handlers import DownloadHandlerProtocol from scrapy.core.downloader.handlers import DownloadHandlerProtocol
from tests.mockserver.http import MockServer
class HTTP10DownloadHandlerMixin: class HTTP10DownloadHandlerMixin:
@ -31,15 +32,17 @@ class TestHttp10(HTTP10DownloadHandlerMixin, TestHttpBase):
@deferred_f_from_coro_f @deferred_f_from_coro_f
async def test_protocol( async def test_protocol(
self, server_port: int, download_handler: DownloadHandlerProtocol self, mockserver: MockServer, download_handler: DownloadHandlerProtocol
) -> None: ) -> None:
request = Request(self.getURL(server_port, "host"), method="GET") request = Request(
mockserver.url("/host", is_secure=self.is_secure), method="GET"
)
response = await download_request(download_handler, request) response = await download_request(download_handler, request)
assert response.protocol == "HTTP/1.0" assert response.protocol == "HTTP/1.0"
class TestHttps10(TestHttp10): class TestHttps10(TestHttp10):
scheme = "https" is_secure = True
@pytest.mark.filterwarnings("ignore::scrapy.exceptions.ScrapyDeprecationWarning") @pytest.mark.filterwarnings("ignore::scrapy.exceptions.ScrapyDeprecationWarning")

View File

@ -7,13 +7,13 @@ from typing import TYPE_CHECKING, Any
from scrapy.core.downloader.handlers.http11 import HTTP11DownloadHandler from scrapy.core.downloader.handlers.http11 import HTTP11DownloadHandler
from tests.test_downloader_handlers_http_base import ( from tests.test_downloader_handlers_http_base import (
TestHttp11Base, TestHttp11Base,
TestHttpMockServerBase,
TestHttpProxyBase, TestHttpProxyBase,
TestHttps11Base, TestHttps11Base,
TestHttpsCustomCiphersBase, TestHttpsCustomCiphersBase,
TestHttpsInvalidDNSIdBase, TestHttpsInvalidDNSIdBase,
TestHttpsInvalidDNSPatternBase, TestHttpsInvalidDNSPatternBase,
TestHttpsWrongHostnameBase, TestHttpsWrongHostnameBase,
TestHttpWithCrawlerBase,
TestSimpleHttpsBase, TestSimpleHttpsBase,
) )
@ -57,7 +57,7 @@ class TestHttps11CustomCiphers(HTTP11DownloadHandlerMixin, TestHttpsCustomCipher
pass pass
class TestHttp11MockServer(TestHttpMockServerBase): class TestHttp11WithCrawler(TestHttpWithCrawlerBase):
@property @property
def settings_dict(self) -> dict[str, Any] | None: def settings_dict(self) -> dict[str, Any] | None:
return None # default handler settings return None # default handler settings

View File

@ -7,33 +7,29 @@ from typing import TYPE_CHECKING, Any
from unittest import mock from unittest import mock
import pytest import pytest
from pytest_twisted import async_yield_fixture
from testfixtures import LogCapture from testfixtures import LogCapture
from twisted.internet import defer, error from twisted.internet import defer, error
from twisted.web import server
from twisted.web.error import SchemeNotSupported from twisted.web.error import SchemeNotSupported
from twisted.web.http import H2_ENABLED from twisted.web.http import H2_ENABLED
from scrapy.http import Request from scrapy.http import Request
from scrapy.spiders import Spider from scrapy.spiders import Spider
from scrapy.utils.defer import deferred_f_from_coro_f, maybe_deferred_to_future from scrapy.utils.defer import deferred_f_from_coro_f, maybe_deferred_to_future
from tests.mockserver import ssl_context_factory
from tests.test_downloader_handlers_http_base import ( from tests.test_downloader_handlers_http_base import (
TestHttpMockServerBase,
TestHttpProxyBase, TestHttpProxyBase,
TestHttps11Base, TestHttps11Base,
TestHttpsCustomCiphersBase, TestHttpsCustomCiphersBase,
TestHttpsInvalidDNSIdBase, TestHttpsInvalidDNSIdBase,
TestHttpsInvalidDNSPatternBase, TestHttpsInvalidDNSPatternBase,
TestHttpsWrongHostnameBase, TestHttpsWrongHostnameBase,
UriResource, TestHttpWithCrawlerBase,
download_request, download_request,
) )
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import AsyncGenerator
from scrapy.core.downloader.handlers import DownloadHandlerProtocol from scrapy.core.downloader.handlers import DownloadHandlerProtocol
from tests.mockserver.http import MockServer
from tests.mockserver.proxy_echo import ProxyEchoMockServer
pytestmark = pytest.mark.skipif( pytestmark = pytest.mark.skipif(
@ -57,20 +53,24 @@ class TestHttps2(H2DownloadHandlerMixin, TestHttps11Base):
@deferred_f_from_coro_f @deferred_f_from_coro_f
async def test_protocol( async def test_protocol(
self, server_port: int, download_handler: DownloadHandlerProtocol self, mockserver: MockServer, download_handler: DownloadHandlerProtocol
) -> None: ) -> None:
request = Request(self.getURL(server_port, "host"), method="GET") request = Request(
mockserver.url("/host", is_secure=self.is_secure), method="GET"
)
response = await download_request(download_handler, request) response = await download_request(download_handler, request)
assert response.protocol == "h2" assert response.protocol == "h2"
@deferred_f_from_coro_f @deferred_f_from_coro_f
async def test_download_with_maxsize_very_large_file( async def test_download_with_maxsize_very_large_file(
self, server_port: int, download_handler: DownloadHandlerProtocol self, mockserver: MockServer, download_handler: DownloadHandlerProtocol
) -> None: ) -> None:
from twisted.internet import reactor from twisted.internet import reactor
with mock.patch("scrapy.core.http2.stream.logger") as logger: with mock.patch("scrapy.core.http2.stream.logger") as logger:
request = Request(self.getURL(server_port, "largechunkedfile")) request = Request(
mockserver.url("/largechunkedfile", is_secure=self.is_secure)
)
def check(logger: mock.Mock) -> None: def check(logger: mock.Mock) -> None:
logger.error.assert_called_once_with(mock.ANY) logger.error.assert_called_once_with(mock.ANY)
@ -107,30 +107,34 @@ class TestHttps2(H2DownloadHandlerMixin, TestHttps11Base):
@deferred_f_from_coro_f @deferred_f_from_coro_f
async def test_concurrent_requests_same_domain( async def test_concurrent_requests_same_domain(
self, server_port: int, download_handler: DownloadHandlerProtocol self, mockserver: MockServer, download_handler: DownloadHandlerProtocol
) -> None: ) -> None:
request1 = Request(self.getURL(server_port, "file")) request1 = Request(mockserver.url("/text", is_secure=self.is_secure))
response1 = await download_request(download_handler, request1) response1 = await download_request(download_handler, request1)
assert response1.body == b"0123456789" assert response1.body == b"Works"
request2 = Request(self.getURL(server_port, "echo"), method="POST") request2 = Request(
mockserver.url("/echo", is_secure=self.is_secure), method="POST"
)
response2 = await download_request(download_handler, request2) response2 = await download_request(download_handler, request2)
assert response2.headers["Content-Length"] == b"79" assert response2.headers["Content-Length"] == b"79"
@pytest.mark.xfail(reason="https://github.com/python-hyper/h2/issues/1247") @pytest.mark.xfail(reason="https://github.com/python-hyper/h2/issues/1247")
@deferred_f_from_coro_f @deferred_f_from_coro_f
async def test_connect_request( async def test_connect_request(
self, server_port: int, download_handler: DownloadHandlerProtocol self, mockserver: MockServer, download_handler: DownloadHandlerProtocol
) -> None: ) -> None:
request = Request(self.getURL(server_port, "file"), method="CONNECT") request = Request(
mockserver.url("/file", is_secure=self.is_secure), method="CONNECT"
)
response = await download_request(download_handler, request) response = await download_request(download_handler, request)
assert response.body == b"" assert response.body == b""
@deferred_f_from_coro_f @deferred_f_from_coro_f
async def test_custom_content_length_good( async def test_custom_content_length_good(
self, server_port: int, download_handler: DownloadHandlerProtocol self, mockserver: MockServer, download_handler: DownloadHandlerProtocol
) -> None: ) -> None:
request = Request(self.getURL(server_port, "contentlength")) request = Request(mockserver.url("/contentlength", is_secure=self.is_secure))
custom_content_length = str(len(request.body)) custom_content_length = str(len(request.body))
request.headers["Content-Length"] = custom_content_length request.headers["Content-Length"] = custom_content_length
response = await download_request(download_handler, request) response = await download_request(download_handler, request)
@ -138,9 +142,9 @@ class TestHttps2(H2DownloadHandlerMixin, TestHttps11Base):
@deferred_f_from_coro_f @deferred_f_from_coro_f
async def test_custom_content_length_bad( async def test_custom_content_length_bad(
self, server_port: int, download_handler: DownloadHandlerProtocol self, mockserver: MockServer, download_handler: DownloadHandlerProtocol
) -> None: ) -> None:
request = Request(self.getURL(server_port, "contentlength")) request = Request(mockserver.url("/contentlength", is_secure=self.is_secure))
actual_content_length = str(len(request.body)) actual_content_length = str(len(request.body))
bad_content_length = str(len(request.body) + 1) bad_content_length = str(len(request.body) + 1)
request.headers["Content-Length"] = bad_content_length request.headers["Content-Length"] = bad_content_length
@ -159,9 +163,9 @@ class TestHttps2(H2DownloadHandlerMixin, TestHttps11Base):
@deferred_f_from_coro_f @deferred_f_from_coro_f
async def test_duplicate_header( async def test_duplicate_header(
self, server_port: int, download_handler: DownloadHandlerProtocol self, mockserver: MockServer, download_handler: DownloadHandlerProtocol
) -> None: ) -> None:
request = Request(self.getURL(server_port, "echo")) request = Request(mockserver.url("/echo", is_secure=self.is_secure))
header, value1, value2 = "Custom-Header", "foo", "bar" header, value1, value2 = "Custom-Header", "foo", "bar"
request.headers.appendlist(header, value1) request.headers.appendlist(header, value1)
request.headers.appendlist(header, value2) request.headers.appendlist(header, value2)
@ -187,7 +191,7 @@ class TestHttps2CustomCiphers(H2DownloadHandlerMixin, TestHttpsCustomCiphersBase
pass pass
class TestHttp2MockServer(TestHttpMockServerBase): class TestHttp2WithCrawler(TestHttpWithCrawlerBase):
"""HTTP 2.0 test case with MockServer""" """HTTP 2.0 test case with MockServer"""
@property @property
@ -202,35 +206,31 @@ class TestHttp2MockServer(TestHttpMockServerBase):
class TestHttps2Proxy(H2DownloadHandlerMixin, TestHttpProxyBase): class TestHttps2Proxy(H2DownloadHandlerMixin, TestHttpProxyBase):
# only used for HTTPS tests is_secure = True
keyfile = "keys/localhost.key"
certfile = "keys/localhost.crt"
scheme = "https"
expected_http_proxy_request_body = b"/" expected_http_proxy_request_body = b"/"
@async_yield_fixture
async def server_port(self) -> AsyncGenerator[int]:
from twisted.internet import reactor
site = server.Site(UriResource(), timeout=None)
port = reactor.listenSSL(
0,
site,
ssl_context_factory(self.keyfile, self.certfile),
interface=self.host,
)
yield port.getHost().port
await port.stopListening()
@deferred_f_from_coro_f @deferred_f_from_coro_f
async def test_download_with_proxy_https_timeout( async def test_download_with_proxy_https_timeout(
self, server_port: int, download_handler: DownloadHandlerProtocol self,
proxy_mockserver: ProxyEchoMockServer,
download_handler: DownloadHandlerProtocol,
) -> None: ) -> None:
with pytest.raises(NotImplementedError): with pytest.raises(NotImplementedError):
await maybe_deferred_to_future( await maybe_deferred_to_future(
super().test_download_with_proxy_https_timeout( super().test_download_with_proxy_https_timeout(
server_port, download_handler proxy_mockserver, download_handler
)
)
@deferred_f_from_coro_f
async def test_download_with_proxy_without_http_scheme(
self,
proxy_mockserver: ProxyEchoMockServer,
download_handler: DownloadHandlerProtocol,
) -> None:
with pytest.raises(SchemeNotSupported):
await maybe_deferred_to_future(
super().test_download_with_proxy_without_http_scheme(
proxy_mockserver, download_handler
) )
) )

View File

@ -12,8 +12,6 @@ import pytest
from pytest_twisted import async_yield_fixture from pytest_twisted import async_yield_fixture
from testfixtures import LogCapture from testfixtures import LogCapture
from twisted.internet import defer, error from twisted.internet import defer, error
from twisted.protocols.policies import WrappingFactory
from twisted.web import resource, server, static, util
from twisted.web._newclient import ResponseFailed from twisted.web._newclient import ResponseFailed
from twisted.web.http import _DataLoss from twisted.web.http import _DataLoss
@ -25,114 +23,18 @@ from scrapy.utils.defer import (
maybe_deferred_to_future, maybe_deferred_to_future,
) )
from scrapy.utils.misc import build_from_crawler from scrapy.utils.misc import build_from_crawler
from scrapy.utils.python import to_bytes
from scrapy.utils.spider import DefaultSpider from scrapy.utils.spider import DefaultSpider
from scrapy.utils.test import get_crawler from scrapy.utils.test import get_crawler
from tests import NON_EXISTING_RESOLVABLE from tests import NON_EXISTING_RESOLVABLE
from tests.mockserver import ( from tests.mockserver.proxy_echo import ProxyEchoMockServer
Echo, from tests.mockserver.simple_https import SimpleMockServer
ForeverTakingResource,
HostHeaderResource,
MockServer,
NoLengthResource,
PayloadResource,
ssl_context_factory,
)
from tests.spiders import SingleRequestSpider from tests.spiders import SingleRequestSpider
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import AsyncGenerator from collections.abc import AsyncGenerator, Generator
from pathlib import Path
from scrapy.core.downloader.handlers import DownloadHandlerProtocol from scrapy.core.downloader.handlers import DownloadHandlerProtocol
from tests.mockserver.http import MockServer
class ContentLengthHeaderResource(resource.Resource):
"""
A testing resource which renders itself as the value of the Content-Length
header from the request.
"""
def render(self, request):
return request.requestHeaders.getRawHeaders(b"content-length")[0]
class ChunkedResource(resource.Resource):
def render(self, request):
from twisted.internet import reactor
def response():
request.write(b"chunked ")
request.write(b"content\n")
request.finish()
reactor.callLater(0, response)
return server.NOT_DONE_YET
class BrokenChunkedResource(resource.Resource):
def render(self, request):
from twisted.internet import reactor
def response():
request.write(b"chunked ")
request.write(b"content\n")
# Disable terminating chunk on finish.
request.chunked = False
closeConnection(request)
reactor.callLater(0, response)
return server.NOT_DONE_YET
class BrokenDownloadResource(resource.Resource):
def render(self, request):
from twisted.internet import reactor
def response():
request.setHeader(b"Content-Length", b"20")
request.write(b"partial")
closeConnection(request)
reactor.callLater(0, response)
return server.NOT_DONE_YET
def closeConnection(request):
# We have to force a disconnection for HTTP/1.1 clients. Otherwise
# client keeps the connection open waiting for more data.
request.channel.loseConnection()
request.finish()
class EmptyContentTypeHeaderResource(resource.Resource):
"""
A testing resource which renders itself as the value of request body
without content-type header in response.
"""
def render(self, request):
request.setHeader("content-type", "")
return request.content.read()
class LargeChunkedFileResource(resource.Resource):
def render(self, request):
from twisted.internet import reactor
def response():
for i in range(1024):
request.write(b"x" * 1024)
request.finish()
reactor.callLater(0, response)
return server.NOT_DONE_YET
class DuplicateHeaderResource(resource.Resource):
def render(self, request):
request.responseHeaders.setRawHeaders(b"Set-Cookie", [b"a=b", b"c=d"])
return b""
async def download_request( async def download_request(
@ -157,59 +59,13 @@ async def close_dh(dh: DownloadHandlerProtocol) -> None:
class TestHttpBase(ABC): class TestHttpBase(ABC):
scheme = "http" is_secure = False
host = "localhost"
# only used for HTTPS tests
keyfile = "keys/localhost.key"
certfile = "keys/localhost.crt"
@property @property
@abstractmethod @abstractmethod
def download_handler_cls(self) -> type[DownloadHandlerProtocol]: def download_handler_cls(self) -> type[DownloadHandlerProtocol]:
raise NotImplementedError raise NotImplementedError
@pytest.fixture
def site(self, tmp_path):
(tmp_path / "file").write_bytes(b"0123456789")
r = static.File(str(tmp_path))
r.putChild(b"redirect", util.Redirect(b"/file"))
r.putChild(b"wait", ForeverTakingResource())
r.putChild(b"hang-after-headers", ForeverTakingResource(write=True))
r.putChild(b"nolength", NoLengthResource())
r.putChild(b"host", HostHeaderResource())
r.putChild(b"payload", PayloadResource())
r.putChild(b"broken", BrokenDownloadResource())
r.putChild(b"chunked", ChunkedResource())
r.putChild(b"broken-chunked", BrokenChunkedResource())
r.putChild(b"contentlength", ContentLengthHeaderResource())
r.putChild(b"nocontenttype", EmptyContentTypeHeaderResource())
r.putChild(b"largechunkedfile", LargeChunkedFileResource())
r.putChild(b"duplicate-header", DuplicateHeaderResource())
r.putChild(b"echo", Echo())
return server.Site(r, timeout=None)
@async_yield_fixture
async def server_port(self, site: server.Site) -> AsyncGenerator[int]:
from twisted.internet import reactor
if self.scheme == "https":
# Using WrappingFactory do not enable HTTP/2 failing all the
# tests with H2DownloadHandler
port = reactor.listenSSL(
0,
site,
ssl_context_factory(self.keyfile, self.certfile),
interface=self.host,
)
else:
wrapper = WrappingFactory(site)
port = reactor.listenTCP(0, wrapper, interface=self.host)
yield port.getHost().port
await port.stopListening()
@async_yield_fixture @async_yield_fixture
async def download_handler(self) -> AsyncGenerator[DownloadHandlerProtocol]: async def download_handler(self) -> AsyncGenerator[DownloadHandlerProtocol]:
dh = build_from_crawler(self.download_handler_cls, get_crawler()) dh = build_from_crawler(self.download_handler_cls, get_crawler())
@ -218,45 +74,46 @@ class TestHttpBase(ABC):
await close_dh(dh) await close_dh(dh)
def getURL(self, portno: int, path: str) -> str:
return f"{self.scheme}://{self.host}:{portno}/{path}"
@deferred_f_from_coro_f @deferred_f_from_coro_f
async def test_download( async def test_download(
self, server_port: int, download_handler: DownloadHandlerProtocol self, mockserver: MockServer, download_handler: DownloadHandlerProtocol
) -> None: ) -> None:
request = Request(self.getURL(server_port, "file")) request = Request(mockserver.url("/text", is_secure=self.is_secure))
response = await download_request(download_handler, request) response = await download_request(download_handler, request)
assert response.body == b"0123456789" assert response.body == b"Works"
@deferred_f_from_coro_f @deferred_f_from_coro_f
async def test_download_head( async def test_download_head(
self, server_port: int, download_handler: DownloadHandlerProtocol self, mockserver: MockServer, download_handler: DownloadHandlerProtocol
) -> None: ) -> None:
request = Request(self.getURL(server_port, "file"), method="HEAD") request = Request(
mockserver.url("/text", is_secure=self.is_secure), method="HEAD"
)
response = await download_request(download_handler, request) response = await download_request(download_handler, request)
assert response.body == b"" assert response.body == b""
@deferred_f_from_coro_f @deferred_f_from_coro_f
async def test_redirect_status( async def test_redirect_status(
self, server_port: int, download_handler: DownloadHandlerProtocol self, mockserver: MockServer, download_handler: DownloadHandlerProtocol
) -> None: ) -> None:
request = Request(self.getURL(server_port, "redirect")) request = Request(mockserver.url("/redirect", is_secure=self.is_secure))
response = await download_request(download_handler, request) response = await download_request(download_handler, request)
assert response.status == 302 assert response.status == 302
@deferred_f_from_coro_f @deferred_f_from_coro_f
async def test_redirect_status_head( async def test_redirect_status_head(
self, server_port: int, download_handler: DownloadHandlerProtocol self, mockserver: MockServer, download_handler: DownloadHandlerProtocol
) -> None: ) -> None:
request = Request(self.getURL(server_port, "redirect"), method="HEAD") request = Request(
mockserver.url("/redirect", is_secure=self.is_secure), method="HEAD"
)
response = await download_request(download_handler, request) response = await download_request(download_handler, request)
assert response.status == 302 assert response.status == 302
@deferred_f_from_coro_f @deferred_f_from_coro_f
async def test_timeout_download_from_spider_nodata_rcvd( async def test_timeout_download_from_spider_nodata_rcvd(
self, self,
server_port: int, mockserver: MockServer,
download_handler: DownloadHandlerProtocol, download_handler: DownloadHandlerProtocol,
reactor_pytest: str, reactor_pytest: str,
) -> None: ) -> None:
@ -268,7 +125,7 @@ class TestHttpBase(ABC):
# client connects but no data is received # client connects but no data is received
meta = {"download_timeout": 0.5} meta = {"download_timeout": 0.5}
request = Request(self.getURL(server_port, "wait"), meta=meta) request = Request(mockserver.url("/wait", is_secure=self.is_secure), meta=meta)
d = deferred_from_coro(download_request(download_handler, request)) d = deferred_from_coro(download_request(download_handler, request))
with pytest.raises((defer.TimeoutError, error.TimeoutError)): with pytest.raises((defer.TimeoutError, error.TimeoutError)):
await maybe_deferred_to_future(d) await maybe_deferred_to_future(d)
@ -276,7 +133,7 @@ class TestHttpBase(ABC):
@deferred_f_from_coro_f @deferred_f_from_coro_f
async def test_timeout_download_from_spider_server_hangs( async def test_timeout_download_from_spider_server_hangs(
self, self,
server_port: int, mockserver: MockServer,
download_handler: DownloadHandlerProtocol, download_handler: DownloadHandlerProtocol,
reactor_pytest: str, reactor_pytest: str,
) -> None: ) -> None:
@ -287,33 +144,36 @@ class TestHttpBase(ABC):
) )
# client connects, server send headers and some body bytes but hangs # client connects, server send headers and some body bytes but hangs
meta = {"download_timeout": 0.5} meta = {"download_timeout": 0.5}
request = Request(self.getURL(server_port, "hang-after-headers"), meta=meta) request = Request(
mockserver.url("/hang-after-headers", is_secure=self.is_secure), meta=meta
)
d = deferred_from_coro(download_request(download_handler, request)) d = deferred_from_coro(download_request(download_handler, request))
with pytest.raises((defer.TimeoutError, error.TimeoutError)): with pytest.raises((defer.TimeoutError, error.TimeoutError)):
await maybe_deferred_to_future(d) await maybe_deferred_to_future(d)
@pytest.mark.parametrize("send_header", [True, False])
@deferred_f_from_coro_f @deferred_f_from_coro_f
async def test_host_header_not_in_request_headers( async def test_host_header(
self, server_port: int, download_handler: DownloadHandlerProtocol self,
send_header: bool,
mockserver: MockServer,
download_handler: DownloadHandlerProtocol,
) -> None: ) -> None:
request = Request(self.getURL(server_port, "host")) host_port = f"{mockserver.host}:{mockserver.port(is_secure=self.is_secure)}"
request = Request(
mockserver.url("/host", is_secure=self.is_secure),
headers={"Host": host_port} if send_header else {},
)
response = await download_request(download_handler, request) response = await download_request(download_handler, request)
assert response.body == to_bytes(f"{self.host}:{server_port}") assert response.body == host_port.encode()
assert not request.headers if send_header:
assert request.headers.get("Host") == host_port.encode()
@deferred_f_from_coro_f else:
async def test_host_header_set_in_request_headers( assert not request.headers
self, server_port: int, download_handler: DownloadHandlerProtocol
) -> None:
host = f"{self.host}:{server_port}"
request = Request(self.getURL(server_port, "host"), headers={"Host": host})
response = await download_request(download_handler, request)
assert response.body == host.encode()
assert request.headers.get("Host") == host.encode()
@deferred_f_from_coro_f @deferred_f_from_coro_f
async def test_content_length_zero_bodyless_post_request_headers( async def test_content_length_zero_bodyless_post_request_headers(
self, server_port: int, download_handler: DownloadHandlerProtocol self, mockserver: MockServer, download_handler: DownloadHandlerProtocol
) -> None: ) -> None:
"""Tests if "Content-Length: 0" is sent for bodyless POST requests. """Tests if "Content-Length: 0" is sent for bodyless POST requests.
@ -325,15 +185,19 @@ class TestHttpBase(ABC):
https://github.com/kennethreitz/requests/issues/405 https://github.com/kennethreitz/requests/issues/405
https://bugs.python.org/issue14721 https://bugs.python.org/issue14721
""" """
request = Request(self.getURL(server_port, "contentlength"), method="POST") request = Request(
mockserver.url("/contentlength", is_secure=self.is_secure), method="POST"
)
response = await download_request(download_handler, request) response = await download_request(download_handler, request)
assert response.body == b"0" assert response.body == b"0"
@deferred_f_from_coro_f @deferred_f_from_coro_f
async def test_content_length_zero_bodyless_post_only_one( async def test_content_length_zero_bodyless_post_only_one(
self, server_port: int, download_handler: DownloadHandlerProtocol self, mockserver: MockServer, download_handler: DownloadHandlerProtocol
) -> None: ) -> None:
request = Request(self.getURL(server_port, "echo"), method="POST") request = Request(
mockserver.url("/echo", is_secure=self.is_secure), method="POST"
)
response = await download_request(download_handler, request) response = await download_request(download_handler, request)
headers = Headers(json.loads(response.text)["headers"]) headers = Headers(json.loads(response.text)["headers"])
contentlengths = headers.getlist("Content-Length") contentlengths = headers.getlist("Content-Length")
@ -342,20 +206,26 @@ class TestHttpBase(ABC):
@deferred_f_from_coro_f @deferred_f_from_coro_f
async def test_payload( async def test_payload(
self, server_port: int, download_handler: DownloadHandlerProtocol self, mockserver: MockServer, download_handler: DownloadHandlerProtocol
) -> None: ) -> None:
body = b"1" * 100 # PayloadResource requires body length to be 100 body = b"1" * 100 # PayloadResource requires body length to be 100
request = Request(self.getURL(server_port, "payload"), method="POST", body=body) request = Request(
mockserver.url("/payload", is_secure=self.is_secure),
method="POST",
body=body,
)
response = await download_request(download_handler, request) response = await download_request(download_handler, request)
assert response.body == body assert response.body == body
@deferred_f_from_coro_f @deferred_f_from_coro_f
async def test_response_header_content_length( async def test_response_header_content_length(
self, server_port: int, download_handler: DownloadHandlerProtocol self, mockserver: MockServer, download_handler: DownloadHandlerProtocol
) -> None: ) -> None:
request = Request(self.getURL(server_port, "file"), method="GET") request = Request(
mockserver.url("/text", is_secure=self.is_secure), method="GET"
)
response = await download_request(download_handler, request) response = await download_request(download_handler, request)
assert response.headers[b"content-length"] == b"10" assert response.headers[b"content-length"] == b"5"
@pytest.mark.parametrize( @pytest.mark.parametrize(
("filename", "body", "response_class"), ("filename", "body", "response_class"),
@ -370,18 +240,20 @@ class TestHttpBase(ABC):
filename: str, filename: str,
body: bytes, body: bytes,
response_class: type[Response], response_class: type[Response],
server_port: int, mockserver: MockServer,
download_handler: DownloadHandlerProtocol, download_handler: DownloadHandlerProtocol,
) -> None: ) -> None:
request = Request(self.getURL(server_port, filename), body=body) request = Request(
mockserver.url(f"/{filename}", is_secure=self.is_secure), body=body
)
response = await download_request(download_handler, request) response = await download_request(download_handler, request)
assert type(response) is response_class # pylint: disable=unidiomatic-typecheck assert type(response) is response_class # pylint: disable=unidiomatic-typecheck
@deferred_f_from_coro_f @deferred_f_from_coro_f
async def test_get_duplicate_header( async def test_get_duplicate_header(
self, server_port: int, download_handler: DownloadHandlerProtocol self, mockserver: MockServer, download_handler: DownloadHandlerProtocol
) -> None: ) -> None:
request = Request(self.getURL(server_port, "duplicate-header")) request = Request(mockserver.url("/duplicate-header", is_secure=self.is_secure))
response = await download_request(download_handler, request) response = await download_request(download_handler, request)
assert response.headers.getlist(b"Set-Cookie") == [b"a=b", b"c=d"] assert response.headers.getlist(b"Set-Cookie") == [b"a=b", b"c=d"]
@ -391,51 +263,55 @@ class TestHttp11Base(TestHttpBase):
@deferred_f_from_coro_f @deferred_f_from_coro_f
async def test_download_without_maxsize_limit( async def test_download_without_maxsize_limit(
self, server_port: int, download_handler: DownloadHandlerProtocol self, mockserver: MockServer, download_handler: DownloadHandlerProtocol
) -> None: ) -> None:
request = Request(self.getURL(server_port, "file")) request = Request(mockserver.url("/text", is_secure=self.is_secure))
response = await download_request(download_handler, request) response = await download_request(download_handler, request)
assert response.body == b"0123456789" assert response.body == b"Works"
@deferred_f_from_coro_f @deferred_f_from_coro_f
async def test_response_class_choosing_request( async def test_response_class_choosing_request(
self, server_port: int, download_handler: DownloadHandlerProtocol self, mockserver: MockServer, download_handler: DownloadHandlerProtocol
) -> None: ) -> None:
"""Tests choosing of correct response type """Tests choosing of correct response type
in case of Content-Type is empty but body contains text. in case of Content-Type is empty but body contains text.
""" """
body = b"Some plain text\ndata with tabs\t and null bytes\0" body = b"Some plain text\ndata with tabs\t and null bytes\0"
request = Request(self.getURL(server_port, "nocontenttype"), body=body) request = Request(
mockserver.url("/nocontenttype", is_secure=self.is_secure), body=body
)
response = await download_request(download_handler, request) response = await download_request(download_handler, request)
assert type(response) is TextResponse # pylint: disable=unidiomatic-typecheck assert type(response) is TextResponse # pylint: disable=unidiomatic-typecheck
@deferred_f_from_coro_f @deferred_f_from_coro_f
async def test_download_with_maxsize( async def test_download_with_maxsize(
self, server_port: int, download_handler: DownloadHandlerProtocol self, mockserver: MockServer, download_handler: DownloadHandlerProtocol
) -> None: ) -> None:
request = Request(self.getURL(server_port, "file")) request = Request(mockserver.url("/text", is_secure=self.is_secure))
# 10 is minimal size for this request and the limit is only counted on # 10 is minimal size for this request and the limit is only counted on
# response body. (regardless of headers) # response body. (regardless of headers)
response = await download_request( response = await download_request(
download_handler, request, Spider("foo", download_maxsize=10) download_handler, request, Spider("foo", download_maxsize=5)
) )
assert response.body == b"0123456789" assert response.body == b"Works"
with pytest.raises((defer.CancelledError, error.ConnectionAborted)): with pytest.raises((defer.CancelledError, error.ConnectionAborted)):
await download_request( await download_request(
download_handler, request, Spider("foo", download_maxsize=9) download_handler, request, Spider("foo", download_maxsize=4)
) )
@deferred_f_from_coro_f @deferred_f_from_coro_f
async def test_download_with_maxsize_very_large_file( async def test_download_with_maxsize_very_large_file(
self, server_port: int, download_handler: DownloadHandlerProtocol self, mockserver: MockServer, download_handler: DownloadHandlerProtocol
) -> None: ) -> None:
from twisted.internet import reactor from twisted.internet import reactor
# TODO: the logger check is specific to scrapy.core.downloader.handlers.http11 # TODO: the logger check is specific to scrapy.core.downloader.handlers.http11
with mock.patch("scrapy.core.downloader.handlers.http11.logger") as logger: with mock.patch("scrapy.core.downloader.handlers.http11.logger") as logger:
request = Request(self.getURL(server_port, "largechunkedfile")) request = Request(
mockserver.url("/largechunkedfile", is_secure=self.is_secure)
)
def check(logger: mock.Mock) -> None: def check(logger: mock.Mock) -> None:
logger.warning.assert_called_once_with(mock.ANY, mock.ANY) logger.warning.assert_called_once_with(mock.ANY, mock.ANY)
@ -455,18 +331,18 @@ class TestHttp11Base(TestHttpBase):
@deferred_f_from_coro_f @deferred_f_from_coro_f
async def test_download_with_maxsize_per_req( async def test_download_with_maxsize_per_req(
self, server_port: int, download_handler: DownloadHandlerProtocol self, mockserver: MockServer, download_handler: DownloadHandlerProtocol
) -> None: ) -> None:
meta = {"download_maxsize": 2} meta = {"download_maxsize": 2}
request = Request(self.getURL(server_port, "file"), meta=meta) request = Request(mockserver.url("/text", is_secure=self.is_secure), meta=meta)
with pytest.raises((defer.CancelledError, error.ConnectionAborted)): with pytest.raises((defer.CancelledError, error.ConnectionAborted)):
await download_request(download_handler, request) await download_request(download_handler, request)
@deferred_f_from_coro_f @deferred_f_from_coro_f
async def test_download_with_small_maxsize_per_spider( async def test_download_with_small_maxsize_per_spider(
self, server_port: int, download_handler: DownloadHandlerProtocol self, mockserver: MockServer, download_handler: DownloadHandlerProtocol
) -> None: ) -> None:
request = Request(self.getURL(server_port, "file")) request = Request(mockserver.url("/text", is_secure=self.is_secure))
with pytest.raises((defer.CancelledError, error.ConnectionAborted)): with pytest.raises((defer.CancelledError, error.ConnectionAborted)):
await download_request( await download_request(
download_handler, request, Spider("foo", download_maxsize=2) download_handler, request, Spider("foo", download_maxsize=2)
@ -474,29 +350,32 @@ class TestHttp11Base(TestHttpBase):
@deferred_f_from_coro_f @deferred_f_from_coro_f
async def test_download_with_large_maxsize_per_spider( async def test_download_with_large_maxsize_per_spider(
self, server_port: int, download_handler: DownloadHandlerProtocol self, mockserver: MockServer, download_handler: DownloadHandlerProtocol
) -> None: ) -> None:
request = Request(self.getURL(server_port, "file")) request = Request(mockserver.url("/text", is_secure=self.is_secure))
response = await download_request( response = await download_request(
download_handler, request, Spider("foo", download_maxsize=100) download_handler, request, Spider("foo", download_maxsize=100)
) )
assert response.body == b"0123456789" assert response.body == b"Works"
@deferred_f_from_coro_f @deferred_f_from_coro_f
async def test_download_chunked_content( async def test_download_chunked_content(
self, server_port: int, download_handler: DownloadHandlerProtocol self, mockserver: MockServer, download_handler: DownloadHandlerProtocol
) -> None: ) -> None:
request = Request(self.getURL(server_port, "chunked")) request = Request(mockserver.url("/chunked", is_secure=self.is_secure))
response = await download_request(download_handler, request) response = await download_request(download_handler, request)
assert response.body == b"chunked content\n" assert response.body == b"chunked content\n"
@pytest.mark.parametrize("url", ["broken", "broken-chunked"]) @pytest.mark.parametrize("url", ["broken", "broken-chunked"])
@deferred_f_from_coro_f @deferred_f_from_coro_f
async def test_download_cause_data_loss( async def test_download_cause_data_loss(
self, url: str, server_port: int, download_handler: DownloadHandlerProtocol self,
url: str,
mockserver: MockServer,
download_handler: DownloadHandlerProtocol,
) -> None: ) -> None:
# TODO: this one checks for Twisted-specific exceptions # TODO: this one checks for Twisted-specific exceptions
request = Request(self.getURL(server_port, url)) request = Request(mockserver.url(f"/{url}", is_secure=self.is_secure))
with pytest.raises(ResponseFailed) as exc_info: with pytest.raises(ResponseFailed) as exc_info:
await download_request(download_handler, request) await download_request(download_handler, request)
assert any(r.check(_DataLoss) for r in exc_info.value.reasons) assert any(r.check(_DataLoss) for r in exc_info.value.reasons)
@ -504,10 +383,14 @@ class TestHttp11Base(TestHttpBase):
@pytest.mark.parametrize("url", ["broken", "broken-chunked"]) @pytest.mark.parametrize("url", ["broken", "broken-chunked"])
@deferred_f_from_coro_f @deferred_f_from_coro_f
async def test_download_allow_data_loss( async def test_download_allow_data_loss(
self, url: str, server_port: int, download_handler: DownloadHandlerProtocol self,
url: str,
mockserver: MockServer,
download_handler: DownloadHandlerProtocol,
) -> None: ) -> None:
request = Request( request = Request(
self.getURL(server_port, url), meta={"download_fail_on_dataloss": False} mockserver.url(f"/{url}", is_secure=self.is_secure),
meta={"download_fail_on_dataloss": False},
) )
response = await download_request(download_handler, request) response = await download_request(download_handler, request)
assert response.flags == ["dataloss"] assert response.flags == ["dataloss"]
@ -515,11 +398,11 @@ class TestHttp11Base(TestHttpBase):
@pytest.mark.parametrize("url", ["broken", "broken-chunked"]) @pytest.mark.parametrize("url", ["broken", "broken-chunked"])
@deferred_f_from_coro_f @deferred_f_from_coro_f
async def test_download_allow_data_loss_via_setting( async def test_download_allow_data_loss_via_setting(
self, url: str, server_port: int self, url: str, mockserver: MockServer
) -> None: ) -> None:
crawler = get_crawler(settings_dict={"DOWNLOAD_FAIL_ON_DATALOSS": False}) crawler = get_crawler(settings_dict={"DOWNLOAD_FAIL_ON_DATALOSS": False})
download_handler = build_from_crawler(self.download_handler_cls, crawler) download_handler = build_from_crawler(self.download_handler_cls, crawler)
request = Request(self.getURL(server_port, url)) request = Request(mockserver.url(f"/{url}", is_secure=self.is_secure))
try: try:
response = await maybe_deferred_to_future( response = await maybe_deferred_to_future(
download_handler.download_request(request, DefaultSpider()) download_handler.download_request(request, DefaultSpider())
@ -532,15 +415,17 @@ class TestHttp11Base(TestHttpBase):
@deferred_f_from_coro_f @deferred_f_from_coro_f
async def test_protocol( async def test_protocol(
self, server_port: int, download_handler: DownloadHandlerProtocol self, mockserver: MockServer, download_handler: DownloadHandlerProtocol
) -> None: ) -> None:
request = Request(self.getURL(server_port, "host"), method="GET") request = Request(
mockserver.url("/host", is_secure=self.is_secure), method="GET"
)
response = await download_request(download_handler, request) response = await download_request(download_handler, request)
assert response.protocol == "HTTP/1.1" assert response.protocol == "HTTP/1.1"
class TestHttps11Base(TestHttp11Base): class TestHttps11Base(TestHttp11Base):
scheme = "https" is_secure = True
tls_log_message = ( tls_log_message = (
'SSL connection certificate: issuer "/C=IE/O=Scrapy/CN=localhost", ' 'SSL connection certificate: issuer "/C=IE/O=Scrapy/CN=localhost", '
@ -548,18 +433,18 @@ class TestHttps11Base(TestHttp11Base):
) )
@deferred_f_from_coro_f @deferred_f_from_coro_f
async def test_tls_logging(self, server_port: int) -> None: async def test_tls_logging(self, mockserver: MockServer) -> None:
crawler = get_crawler( crawler = get_crawler(
settings_dict={"DOWNLOADER_CLIENT_TLS_VERBOSE_LOGGING": True} settings_dict={"DOWNLOADER_CLIENT_TLS_VERBOSE_LOGGING": True}
) )
download_handler = build_from_crawler(self.download_handler_cls, crawler) download_handler = build_from_crawler(self.download_handler_cls, crawler)
try: try:
with LogCapture() as log_capture: with LogCapture() as log_capture:
request = Request(self.getURL(server_port, "file")) request = Request(mockserver.url("/text", is_secure=self.is_secure))
response = await maybe_deferred_to_future( response = await maybe_deferred_to_future(
download_handler.download_request(request, DefaultSpider()) download_handler.download_request(request, DefaultSpider())
) )
assert response.body == b"0123456789" assert response.body == b"Works"
log_capture.check_present( log_capture.check_present(
("scrapy.core.downloader.tls", "DEBUG", self.tls_log_message) ("scrapy.core.downloader.tls", "DEBUG", self.tls_log_message)
) )
@ -577,31 +462,23 @@ class TestSimpleHttpsBase(ABC):
host = "localhost" host = "localhost"
cipher_string: str | None = None cipher_string: str | None = None
@pytest.fixture(scope="class")
def simple_mockserver(self) -> Generator[SimpleMockServer]:
with SimpleMockServer(
self.keyfile, self.certfile, self.cipher_string
) as simple_mockserver:
yield simple_mockserver
@pytest.fixture(scope="class")
def url(self, simple_mockserver: SimpleMockServer) -> str:
# need to use self.host instead of what mockserver returns
return f"https://{self.host}:{simple_mockserver.port(is_secure=True)}/file"
@property @property
@abstractmethod @abstractmethod
def download_handler_cls(self) -> type[DownloadHandlerProtocol]: def download_handler_cls(self) -> type[DownloadHandlerProtocol]:
raise NotImplementedError raise NotImplementedError
@async_yield_fixture
async def server_port(self, tmp_path: Path) -> AsyncGenerator[int]:
from twisted.internet import reactor
(tmp_path / "file").write_bytes(b"0123456789")
r = static.File(str(tmp_path))
site = server.Site(r, timeout=None)
port = reactor.listenSSL(
0,
site,
ssl_context_factory(
self.keyfile, self.certfile, cipher_string=self.cipher_string
),
interface=self.host,
)
yield port.getHost().port
await port.stopListening()
@async_yield_fixture @async_yield_fixture
async def download_handler(self) -> AsyncGenerator[DownloadHandlerProtocol]: async def download_handler(self) -> AsyncGenerator[DownloadHandlerProtocol]:
if self.cipher_string is not None: if self.cipher_string is not None:
@ -615,14 +492,11 @@ class TestSimpleHttpsBase(ABC):
await close_dh(dh) await close_dh(dh)
def getURL(self, portno: int, path: str) -> str:
return f"https://{self.host}:{portno}/{path}"
@deferred_f_from_coro_f @deferred_f_from_coro_f
async def test_download( async def test_download(
self, server_port: int, download_handler: DownloadHandlerProtocol self, url: str, download_handler: DownloadHandlerProtocol
) -> None: ) -> None:
request = Request(self.getURL(server_port, "file")) request = Request(url)
response = await download_request(download_handler, request) response = await download_request(download_handler, request)
assert response.body == b"0123456789" assert response.body == b"0123456789"
@ -654,9 +528,7 @@ class TestHttpsCustomCiphersBase(TestSimpleHttpsBase):
cipher_string = "CAMELLIA256-SHA" cipher_string = "CAMELLIA256-SHA"
class TestHttpMockServerBase(ABC): class TestHttpWithCrawlerBase(ABC):
"""HTTP 1.1 test case with MockServer"""
@property @property
@abstractmethod @abstractmethod
def settings_dict(self) -> dict[str, Any] | None: def settings_dict(self) -> dict[str, Any] | None:
@ -664,63 +536,40 @@ class TestHttpMockServerBase(ABC):
is_secure = False is_secure = False
@classmethod
def setup_class(cls):
cls.mockserver = MockServer()
cls.mockserver.__enter__()
@classmethod
def teardown_class(cls):
cls.mockserver.__exit__(None, None, None)
@deferred_f_from_coro_f @deferred_f_from_coro_f
async def test_download_with_content_length(self): async def test_download_with_content_length(self, mockserver: MockServer) -> None:
crawler = get_crawler(SingleRequestSpider, self.settings_dict) crawler = get_crawler(SingleRequestSpider, self.settings_dict)
# http://localhost:8998/partial set Content-Length to 1024, use download_maxsize= 1000 to avoid # http://localhost:8998/partial set Content-Length to 1024, use download_maxsize= 1000 to avoid
# download it # download it
await maybe_deferred_to_future( await maybe_deferred_to_future(
crawler.crawl( crawler.crawl(
seed=Request( seed=Request(
url=self.mockserver.url("/partial", is_secure=self.is_secure), url=mockserver.url("/partial", is_secure=self.is_secure),
meta={"download_maxsize": 1000}, meta={"download_maxsize": 1000},
) )
) )
) )
failure = crawler.spider.meta["failure"] assert crawler.spider
failure = crawler.spider.meta["failure"] # type: ignore[attr-defined]
assert isinstance(failure.value, defer.CancelledError) assert isinstance(failure.value, defer.CancelledError)
@deferred_f_from_coro_f @deferred_f_from_coro_f
async def test_download(self): async def test_download(self, mockserver: MockServer) -> None:
crawler = get_crawler(SingleRequestSpider, self.settings_dict) crawler = get_crawler(SingleRequestSpider, self.settings_dict)
await maybe_deferred_to_future( await maybe_deferred_to_future(
crawler.crawl( crawler.crawl(
seed=Request(url=self.mockserver.url("", is_secure=self.is_secure)) seed=Request(url=mockserver.url("", is_secure=self.is_secure))
) )
) )
failure = crawler.spider.meta.get("failure") assert crawler.spider
failure = crawler.spider.meta.get("failure") # type: ignore[attr-defined]
assert failure is None assert failure is None
reason = crawler.spider.meta["close_reason"] reason = crawler.spider.meta["close_reason"] # type: ignore[attr-defined]
assert reason == "finished" assert reason == "finished"
class UriResource(resource.Resource):
"""Return the full uri that was requested"""
def getChild(self, path, request):
return self
def render(self, request):
# Note: this is an ugly hack for CONNECT request timeout test.
# Returning some data here fail SSL/TLS handshake
# ToDo: implement proper HTTPS proxy tests, not faking them.
if request.method != b"CONNECT":
return request.uri
return b""
class TestHttpProxyBase(ABC): class TestHttpProxyBase(ABC):
scheme = "http" is_secure = False
host = "127.0.0.1"
expected_http_proxy_request_body = b"http://example.com" expected_http_proxy_request_body = b"http://example.com"
@property @property
@ -728,17 +577,10 @@ class TestHttpProxyBase(ABC):
def download_handler_cls(self) -> type[DownloadHandlerProtocol]: def download_handler_cls(self) -> type[DownloadHandlerProtocol]:
raise NotImplementedError raise NotImplementedError
@async_yield_fixture @pytest.fixture(scope="session")
async def server_port(self) -> AsyncGenerator[int]: def proxy_mockserver(self) -> Generator[ProxyEchoMockServer]:
from twisted.internet import reactor with ProxyEchoMockServer() as proxy:
yield proxy
site = server.Site(UriResource(), timeout=None)
wrapper = WrappingFactory(site)
port = reactor.listenTCP(0, wrapper, interface=self.host)
yield port.getHost().port
await port.stopListening()
@async_yield_fixture @async_yield_fixture
async def download_handler(self) -> AsyncGenerator[DownloadHandlerProtocol]: async def download_handler(self) -> AsyncGenerator[DownloadHandlerProtocol]:
@ -748,14 +590,13 @@ class TestHttpProxyBase(ABC):
await close_dh(dh) await close_dh(dh)
def getURL(self, portno: int, path: str) -> str:
return f"{self.scheme}://{self.host}:{portno}/{path}"
@deferred_f_from_coro_f @deferred_f_from_coro_f
async def test_download_with_proxy( async def test_download_with_proxy(
self, server_port: int, download_handler: DownloadHandlerProtocol self,
proxy_mockserver: ProxyEchoMockServer,
download_handler: DownloadHandlerProtocol,
) -> None: ) -> None:
http_proxy = self.getURL(server_port, "") http_proxy = proxy_mockserver.url("", is_secure=self.is_secure)
request = Request("http://example.com", meta={"proxy": http_proxy}) request = Request("http://example.com", meta={"proxy": http_proxy})
response = await download_request(download_handler, request) response = await download_request(download_handler, request)
assert response.status == 200 assert response.status == 200
@ -764,9 +605,13 @@ class TestHttpProxyBase(ABC):
@deferred_f_from_coro_f @deferred_f_from_coro_f
async def test_download_without_proxy( async def test_download_without_proxy(
self, server_port: int, download_handler: DownloadHandlerProtocol self,
proxy_mockserver: ProxyEchoMockServer,
download_handler: DownloadHandlerProtocol,
) -> None: ) -> None:
request = Request(self.getURL(server_port, "path/to/resource")) request = Request(
proxy_mockserver.url("/path/to/resource", is_secure=self.is_secure)
)
response = await download_request(download_handler, request) response = await download_request(download_handler, request)
assert response.status == 200 assert response.status == 200
assert response.url == request.url assert response.url == request.url
@ -774,11 +619,13 @@ class TestHttpProxyBase(ABC):
@deferred_f_from_coro_f @deferred_f_from_coro_f
async def test_download_with_proxy_https_timeout( async def test_download_with_proxy_https_timeout(
self, server_port: int, download_handler: DownloadHandlerProtocol self,
proxy_mockserver: ProxyEchoMockServer,
download_handler: DownloadHandlerProtocol,
) -> None: ) -> None:
if NON_EXISTING_RESOLVABLE: if NON_EXISTING_RESOLVABLE:
pytest.skip("Non-existing hosts are resolvable") pytest.skip("Non-existing hosts are resolvable")
http_proxy = self.getURL(server_port, "") http_proxy = proxy_mockserver.url("", is_secure=self.is_secure)
domain = "https://no-such-domain.nosuch" domain = "https://no-such-domain.nosuch"
request = Request(domain, meta={"proxy": http_proxy, "download_timeout": 0.2}) request = Request(domain, meta={"proxy": http_proxy, "download_timeout": 0.2})
with pytest.raises(error.TimeoutError) as exc_info: with pytest.raises(error.TimeoutError) as exc_info:
@ -787,9 +634,11 @@ class TestHttpProxyBase(ABC):
@deferred_f_from_coro_f @deferred_f_from_coro_f
async def test_download_with_proxy_without_http_scheme( async def test_download_with_proxy_without_http_scheme(
self, server_port: int, download_handler: DownloadHandlerProtocol self,
proxy_mockserver: ProxyEchoMockServer,
download_handler: DownloadHandlerProtocol,
) -> None: ) -> None:
http_proxy = self.getURL(server_port, "").replace("http://", "") http_proxy = f"{proxy_mockserver.host}:{proxy_mockserver.port()}"
request = Request("http://example.com", meta={"proxy": http_proxy}) request = Request("http://example.com", meta={"proxy": http_proxy})
response = await download_request(download_handler, request) response = await download_request(download_handler, request)
assert response.status == 200 assert response.status == 200

View File

@ -6,7 +6,7 @@ from scrapy import Request
from scrapy.core.downloader import Downloader, Slot from scrapy.core.downloader import Downloader, Slot
from scrapy.crawler import CrawlerRunner from scrapy.crawler import CrawlerRunner
from scrapy.utils.test import get_crawler from scrapy.utils.test import get_crawler
from tests.mockserver import MockServer from tests.mockserver.http import MockServer
from tests.spiders import MetaSpider from tests.spiders import MetaSpider

View File

@ -16,7 +16,6 @@ import sys
from collections import defaultdict from collections import defaultdict
from dataclasses import dataclass from dataclasses import dataclass
from logging import DEBUG from logging import DEBUG
from pathlib import Path
from unittest.mock import Mock from unittest.mock import Mock
from urllib.parse import urlparse from urllib.parse import urlparse
@ -27,7 +26,6 @@ from pydispatch import dispatcher
from testfixtures import LogCapture from testfixtures import LogCapture
from twisted.internet import defer from twisted.internet import defer
from twisted.internet.defer import inlineCallbacks from twisted.internet.defer import inlineCallbacks
from twisted.web import server, static, util
from scrapy import signals from scrapy import signals
from scrapy.core.engine import ExecutionEngine, _Slot from scrapy.core.engine import ExecutionEngine, _Slot
@ -38,9 +36,11 @@ from scrapy.item import Field, Item
from scrapy.linkextractors import LinkExtractor from scrapy.linkextractors import LinkExtractor
from scrapy.signals import request_scheduled from scrapy.signals import request_scheduled
from scrapy.spiders import Spider from scrapy.spiders import Spider
from scrapy.utils.defer import deferred_f_from_coro_f, maybe_deferred_to_future
from scrapy.utils.signal import disconnect_all from scrapy.utils.signal import disconnect_all
from scrapy.utils.test import get_crawler from scrapy.utils.test import get_crawler
from tests import get_testdata, tests_datadir from tests import get_testdata
from tests.mockserver.http import MockServer
class MyItem(Item): class MyItem(Item):
@ -65,7 +65,6 @@ class DataClassItem:
class MySpider(Spider): class MySpider(Spider):
name = "scrapytest.org" name = "scrapytest.org"
allowed_domains = ["scrapytest.org", "localhost"]
itemurl_re = re.compile(r"item\d+.html") itemurl_re = re.compile(r"item\d+.html")
name_re = re.compile(r"<h1>(.*?)</h1>", re.MULTILINE) name_re = re.compile(r"<h1>(.*?)</h1>", re.MULTILINE)
@ -130,25 +129,6 @@ class ChangeCloseReasonSpider(MySpider):
raise CloseSpider(reason="custom_reason") raise CloseSpider(reason="custom_reason")
def start_test_site(debug=False):
from twisted.internet import reactor
root_dir = Path(tests_datadir, "test_site")
r = static.File(str(root_dir))
r.putChild(b"redirect", util.Redirect(b"/redirected"))
r.putChild(b"redirected", static.Data(b"Redirected here", "text/plain"))
numbers = [str(x).encode("utf8") for x in range(2**18)]
r.putChild(b"numbers", static.Data(b"".join(numbers), "text/plain"))
port = reactor.listenTCP(0, server.Site(r), interface="127.0.0.1")
if debug:
print(
f"Test server running at http://localhost:{port.getHost().port}/ "
"- hit Ctrl-C to finish."
)
return port
class CrawlerRun: class CrawlerRun:
"""A class to run the crawler and keep track of events occurred""" """A class to run the crawler and keep track of events occurred"""
@ -164,12 +144,11 @@ class CrawlerRun:
self.signals_caught = {} self.signals_caught = {}
self.spider_class = spider_class self.spider_class = spider_class
def run(self): async def run(self, mockserver: MockServer) -> None:
self.port = start_test_site() self.mockserver = mockserver
self.portno = self.port.getHost().port
start_urls = [ start_urls = [
self.geturl("/"), self.geturl("/static/"),
self.geturl("/redirect"), self.geturl("/redirect"),
self.geturl("/redirect"), # duplicate self.geturl("/redirect"), # duplicate
self.geturl("/numbers"), self.geturl("/numbers"),
@ -194,20 +173,19 @@ class CrawlerRun:
) )
self.crawler.crawl(start_urls=start_urls) self.crawler.crawl(start_urls=start_urls)
self.deferred = defer.Deferred() self.deferred: defer.Deferred[None] = defer.Deferred()
dispatcher.connect(self.stop, signals.engine_stopped) dispatcher.connect(self.stop, signals.engine_stopped)
return self.deferred await maybe_deferred_to_future(self.deferred)
def stop(self): def stop(self):
self.port.stopListening() # FIXME: wait for this Deferred
for name, signal in vars(signals).items(): for name, signal in vars(signals).items():
if not name.startswith("_"): if not name.startswith("_"):
disconnect_all(signal) disconnect_all(signal)
self.deferred.callback(None) self.deferred.callback(None)
return self.crawler.stop() return self.crawler.stop()
def geturl(self, path): def geturl(self, path: str) -> str:
return f"http://localhost:{self.portno}{path}" return self.mockserver.url(path)
def getpath(self, url): def getpath(self, url):
u = urlparse(url) u = urlparse(url)
@ -249,12 +227,12 @@ class TestEngineBase:
@staticmethod @staticmethod
def _assert_visited_urls(run: CrawlerRun) -> None: def _assert_visited_urls(run: CrawlerRun) -> None:
must_be_visited = [ must_be_visited = [
"/", "/static/",
"/redirect", "/redirect",
"/redirected", "/redirected",
"/item1.html", "/static/item1.html",
"/item2.html", "/static/item2.html",
"/item999.html", "/static/item999.html",
] ]
urls_visited = {rp[0].url for rp in run.respplug} urls_visited = {rp[0].url for rp in run.respplug}
urls_expected = {run.geturl(p) for p in must_be_visited} urls_expected = {run.geturl(p) for p in must_be_visited}
@ -266,7 +244,11 @@ class TestEngineBase:
def _assert_scheduled_requests(run: CrawlerRun, count: int) -> None: def _assert_scheduled_requests(run: CrawlerRun, count: int) -> None:
assert len(run.reqplug) == count assert len(run.reqplug) == count
paths_expected = ["/item999.html", "/item2.html", "/item1.html"] paths_expected = [
"/static/item999.html",
"/static/item2.html",
"/static/item1.html",
]
urls_requested = {rq[0].url for rq in run.reqplug} urls_requested = {rq[0].url for rq in run.reqplug}
urls_expected = {run.geturl(p) for p in paths_expected} urls_expected = {run.geturl(p) for p in paths_expected}
@ -288,7 +270,7 @@ class TestEngineBase:
assert len(run.reqreached) == count assert len(run.reqreached) == count
for response, _ in run.respplug: for response, _ in run.respplug:
if run.getpath(response.url) == "/item999.html": if run.getpath(response.url) == "/static/item999.html":
assert response.status == 404 assert response.status == 404
if run.getpath(response.url) == "/redirect": if run.getpath(response.url) == "/redirect":
assert response.status == 302 assert response.status == 302
@ -334,11 +316,11 @@ class TestEngineBase:
assert len(run.bytes) == 9 assert len(run.bytes) == 9
for request, data in run.bytes.items(): for request, data in run.bytes.items():
joined_data = b"".join(data) joined_data = b"".join(data)
if run.getpath(request.url) == "/": if run.getpath(request.url) == "/static/":
assert joined_data == get_testdata("test_site", "index.html") assert joined_data == get_testdata("test_site", "index.html")
elif run.getpath(request.url) == "/item1.html": elif run.getpath(request.url) == "/static/item1.html":
assert joined_data == get_testdata("test_site", "item1.html") assert joined_data == get_testdata("test_site", "item1.html")
elif run.getpath(request.url) == "/item2.html": elif run.getpath(request.url) == "/static/item2.html":
assert joined_data == get_testdata("test_site", "item2.html") assert joined_data == get_testdata("test_site", "item2.html")
elif run.getpath(request.url) == "/redirected": elif run.getpath(request.url) == "/redirected":
assert joined_data == b"Redirected here" assert joined_data == b"Redirected here"
@ -353,7 +335,7 @@ class TestEngineBase:
b" </body>\n" b" </body>\n"
b"</html>\n" b"</html>\n"
) )
elif run.getpath(request.url) == "/tem999.html": elif run.getpath(request.url) == "/static/item999.html":
assert ( assert (
joined_data == b"\n<html>\n" joined_data == b"\n<html>\n"
b" <head><title>404 - No Such Resource</title></head>\n" b" <head><title>404 - No Such Resource</title></head>\n"
@ -390,8 +372,8 @@ class TestEngineBase:
class TestEngine(TestEngineBase): class TestEngine(TestEngineBase):
@inlineCallbacks @deferred_f_from_coro_f
def test_crawler(self): async def test_crawler(self, mockserver: MockServer) -> None:
for spider in ( for spider in (
MySpider, MySpider,
DictItemsSpider, DictItemsSpider,
@ -399,7 +381,7 @@ class TestEngine(TestEngineBase):
DataClassItemsSpider, DataClassItemsSpider,
): ):
run = CrawlerRun(spider) run = CrawlerRun(spider)
yield run.run() await run.run(mockserver)
self._assert_visited_urls(run) self._assert_visited_urls(run)
self._assert_scheduled_requests(run, count=9) self._assert_scheduled_requests(run, count=9)
self._assert_downloaded_responses(run, count=9) self._assert_downloaded_responses(run, count=9)
@ -407,23 +389,25 @@ class TestEngine(TestEngineBase):
self._assert_signals_caught(run) self._assert_signals_caught(run)
self._assert_bytes_received(run) self._assert_bytes_received(run)
@inlineCallbacks @deferred_f_from_coro_f
def test_crawler_dupefilter(self): async def test_crawler_dupefilter(self, mockserver: MockServer) -> None:
run = CrawlerRun(DupeFilterSpider) run = CrawlerRun(DupeFilterSpider)
yield run.run() await run.run(mockserver)
self._assert_scheduled_requests(run, count=8) self._assert_scheduled_requests(run, count=8)
self._assert_dropped_requests(run) self._assert_dropped_requests(run)
@inlineCallbacks @deferred_f_from_coro_f
def test_crawler_itemerror(self): async def test_crawler_itemerror(self, mockserver: MockServer) -> None:
run = CrawlerRun(ItemZeroDivisionErrorSpider) run = CrawlerRun(ItemZeroDivisionErrorSpider)
yield run.run() await run.run(mockserver)
self._assert_items_error(run) self._assert_items_error(run)
@inlineCallbacks @deferred_f_from_coro_f
def test_crawler_change_close_reason_on_idle(self): async def test_crawler_change_close_reason_on_idle(
self, mockserver: MockServer
) -> None:
run = CrawlerRun(ChangeCloseReasonSpider) run = CrawlerRun(ChangeCloseReasonSpider)
yield run.run() await run.run(mockserver)
assert { assert {
"spider": run.crawler.spider, "spider": run.crawler.spider,
"reason": "custom_reason", "reason": "custom_reason",
@ -539,11 +523,3 @@ def test_request_scheduled_signal(caplog):
f"{scheduler.enqueued!r} != [{keep_request!r}]" f"{scheduler.enqueued!r} != [{keep_request!r}]"
) )
crawler.signals.disconnect(signal_handler, request_scheduled) crawler.signals.disconnect(signal_handler, request_scheduled)
if __name__ == "__main__":
from twisted.internet import reactor # pylint: disable=ungrouped-imports
if len(sys.argv) > 1 and sys.argv[1] == "runserver":
start_test_site(debug=True)
reactor.run()

View File

@ -10,9 +10,8 @@ from twisted.internet.defer import Deferred
from scrapy import Request, Spider, signals from scrapy import Request, Spider, signals
from scrapy.utils.defer import deferred_f_from_coro_f, maybe_deferred_to_future from scrapy.utils.defer import deferred_f_from_coro_f, maybe_deferred_to_future
from scrapy.utils.test import get_crawler from scrapy.utils.test import get_crawler
from tests.mockserver.http import MockServer
from .mockserver import MockServer from tests.test_scheduler import MemoryScheduler
from .test_scheduler import MemoryScheduler
if TYPE_CHECKING: if TYPE_CHECKING:
from scrapy.http import Response from scrapy.http import Response

View File

@ -1,7 +1,11 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from testfixtures import LogCapture from testfixtures import LogCapture
from twisted.internet.defer import inlineCallbacks
from scrapy.exceptions import StopDownload from scrapy.exceptions import StopDownload
from scrapy.utils.defer import deferred_f_from_coro_f
from tests.test_engine import ( from tests.test_engine import (
AttrsItemsSpider, AttrsItemsSpider,
CrawlerRun, CrawlerRun,
@ -11,6 +15,9 @@ from tests.test_engine import (
TestEngineBase, TestEngineBase,
) )
if TYPE_CHECKING:
from tests.mockserver.http import MockServer
class BytesReceivedCrawlerRun(CrawlerRun): class BytesReceivedCrawlerRun(CrawlerRun):
def bytes_received(self, data, request, spider): def bytes_received(self, data, request, spider):
@ -19,8 +26,8 @@ class BytesReceivedCrawlerRun(CrawlerRun):
class TestBytesReceivedEngine(TestEngineBase): class TestBytesReceivedEngine(TestEngineBase):
@inlineCallbacks @deferred_f_from_coro_f
def test_crawler(self): async def test_crawler(self, mockserver: MockServer) -> None:
for spider in ( for spider in (
MySpider, MySpider,
DictItemsSpider, DictItemsSpider,
@ -29,12 +36,12 @@ class TestBytesReceivedEngine(TestEngineBase):
): ):
run = BytesReceivedCrawlerRun(spider) run = BytesReceivedCrawlerRun(spider)
with LogCapture() as log: with LogCapture() as log:
yield run.run() await run.run(mockserver)
log.check_present( log.check_present(
( (
"scrapy.core.downloader.handlers.http11", "scrapy.core.downloader.handlers.http11",
"DEBUG", "DEBUG",
f"Download stopped for <GET http://localhost:{run.portno}/redirected> " f"Download stopped for <GET {mockserver.url('/redirected')}> "
"from signal handler BytesReceivedCrawlerRun.bytes_received", "from signal handler BytesReceivedCrawlerRun.bytes_received",
) )
) )
@ -42,7 +49,7 @@ class TestBytesReceivedEngine(TestEngineBase):
( (
"scrapy.core.downloader.handlers.http11", "scrapy.core.downloader.handlers.http11",
"DEBUG", "DEBUG",
f"Download stopped for <GET http://localhost:{run.portno}/> " f"Download stopped for <GET {mockserver.url('/static/')}> "
"from signal handler BytesReceivedCrawlerRun.bytes_received", "from signal handler BytesReceivedCrawlerRun.bytes_received",
) )
) )
@ -50,7 +57,7 @@ class TestBytesReceivedEngine(TestEngineBase):
( (
"scrapy.core.downloader.handlers.http11", "scrapy.core.downloader.handlers.http11",
"DEBUG", "DEBUG",
f"Download stopped for <GET http://localhost:{run.portno}/numbers> " f"Download stopped for <GET {mockserver.url('/numbers')}> "
"from signal handler BytesReceivedCrawlerRun.bytes_received", "from signal handler BytesReceivedCrawlerRun.bytes_received",
) )
) )

View File

@ -1,7 +1,11 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from testfixtures import LogCapture from testfixtures import LogCapture
from twisted.internet.defer import inlineCallbacks
from scrapy.exceptions import StopDownload from scrapy.exceptions import StopDownload
from scrapy.utils.defer import deferred_f_from_coro_f
from tests.test_engine import ( from tests.test_engine import (
AttrsItemsSpider, AttrsItemsSpider,
CrawlerRun, CrawlerRun,
@ -11,6 +15,9 @@ from tests.test_engine import (
TestEngineBase, TestEngineBase,
) )
if TYPE_CHECKING:
from tests.mockserver.http import MockServer
class HeadersReceivedCrawlerRun(CrawlerRun): class HeadersReceivedCrawlerRun(CrawlerRun):
def headers_received(self, headers, body_length, request, spider): def headers_received(self, headers, body_length, request, spider):
@ -19,8 +26,8 @@ class HeadersReceivedCrawlerRun(CrawlerRun):
class TestHeadersReceivedEngine(TestEngineBase): class TestHeadersReceivedEngine(TestEngineBase):
@inlineCallbacks @deferred_f_from_coro_f
def test_crawler(self): async def test_crawler(self, mockserver: MockServer) -> None:
for spider in ( for spider in (
MySpider, MySpider,
DictItemsSpider, DictItemsSpider,
@ -29,12 +36,12 @@ class TestHeadersReceivedEngine(TestEngineBase):
): ):
run = HeadersReceivedCrawlerRun(spider) run = HeadersReceivedCrawlerRun(spider)
with LogCapture() as log: with LogCapture() as log:
yield run.run() await run.run(mockserver)
log.check_present( log.check_present(
( (
"scrapy.core.downloader.handlers.http11", "scrapy.core.downloader.handlers.http11",
"DEBUG", "DEBUG",
f"Download stopped for <GET http://localhost:{run.portno}/redirected> from" f"Download stopped for <GET {mockserver.url('/redirected')}> from"
" signal handler HeadersReceivedCrawlerRun.headers_received", " signal handler HeadersReceivedCrawlerRun.headers_received",
) )
) )
@ -42,7 +49,7 @@ class TestHeadersReceivedEngine(TestEngineBase):
( (
"scrapy.core.downloader.handlers.http11", "scrapy.core.downloader.handlers.http11",
"DEBUG", "DEBUG",
f"Download stopped for <GET http://localhost:{run.portno}/> from signal" f"Download stopped for <GET {mockserver.url('/static/')}> from signal"
" handler HeadersReceivedCrawlerRun.headers_received", " handler HeadersReceivedCrawlerRun.headers_received",
) )
) )
@ -50,7 +57,7 @@ class TestHeadersReceivedEngine(TestEngineBase):
( (
"scrapy.core.downloader.handlers.http11", "scrapy.core.downloader.handlers.http11",
"DEBUG", "DEBUG",
f"Download stopped for <GET http://localhost:{run.portno}/numbers> from" f"Download stopped for <GET {mockserver.url('/numbers')}> from"
" signal handler HeadersReceivedCrawlerRun.headers_received", " signal handler HeadersReceivedCrawlerRun.headers_received",
) )
) )
@ -66,7 +73,7 @@ class TestHeadersReceivedEngine(TestEngineBase):
@staticmethod @staticmethod
def _assert_visited_urls(run: CrawlerRun) -> None: def _assert_visited_urls(run: CrawlerRun) -> None:
must_be_visited = ["/", "/redirect", "/redirected"] must_be_visited = ["/static/", "/redirect", "/redirected"]
urls_visited = {rp[0].url for rp in run.respplug} urls_visited = {rp[0].url for rp in run.respplug}
urls_expected = {run.geturl(p) for p in must_be_visited} urls_expected = {run.geturl(p) for p in must_be_visited}
assert urls_expected <= urls_visited, ( assert urls_expected <= urls_visited, (

View File

@ -54,7 +54,8 @@ from scrapy.settings import Settings
from scrapy.utils.defer import deferred_f_from_coro_f, maybe_deferred_to_future from scrapy.utils.defer import deferred_f_from_coro_f, maybe_deferred_to_future
from scrapy.utils.python import to_unicode from scrapy.utils.python import to_unicode
from scrapy.utils.test import get_crawler from scrapy.utils.test import get_crawler
from tests.mockserver import MockFTPServer, MockServer from tests.mockserver.ftp import MockFTPServer
from tests.mockserver.http import MockServer
from tests.spiders import ItemSpider from tests.spiders import ItemSpider
if TYPE_CHECKING: if TYPE_CHECKING:

View File

@ -35,7 +35,8 @@ from scrapy.utils.defer import (
deferred_from_coro, deferred_from_coro,
maybe_deferred_to_future, maybe_deferred_to_future,
) )
from tests.mockserver import LeafResource, Status, ssl_context_factory from tests.mockserver.http_resources import LeafResource, Status
from tests.mockserver.utils import ssl_context_factory
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import AsyncGenerator, Coroutine, Generator from collections.abc import AsyncGenerator, Coroutine, Generator

View File

@ -11,7 +11,7 @@ from scrapy.item import Field, Item
from scrapy.logformatter import LogFormatter from scrapy.logformatter import LogFormatter
from scrapy.spiders import Spider from scrapy.spiders import Spider
from scrapy.utils.test import get_crawler from scrapy.utils.test import get_crawler
from tests.mockserver import MockServer from tests.mockserver.http import MockServer
from tests.spiders import ItemSpider from tests.spiders import ItemSpider

View File

@ -13,7 +13,7 @@ from w3lib.url import add_or_replace_parameter
from scrapy import Spider, signals from scrapy import Spider, signals
from scrapy.utils.misc import load_object from scrapy.utils.misc import load_object
from scrapy.utils.test import get_crawler from scrapy.utils.test import get_crawler
from tests.mockserver import MockServer from tests.mockserver.http import MockServer
from tests.spiders import SimpleSpider from tests.spiders import SimpleSpider
if TYPE_CHECKING: if TYPE_CHECKING:
@ -149,7 +149,7 @@ class TestFileDownloadCrawl:
crawler = self._create_crawler(MediaDownloadSpider) crawler = self._create_crawler(MediaDownloadSpider)
with LogCapture() as log: with LogCapture() as log:
yield crawler.crawl( yield crawler.crawl(
self.mockserver.url("/files/images/"), self.mockserver.url("/static/files/images/"),
media_key=self.media_key, media_key=self.media_key,
media_urls_key=self.media_urls_key, media_urls_key=self.media_urls_key,
) )
@ -160,7 +160,7 @@ class TestFileDownloadCrawl:
crawler = self._create_crawler(BrokenLinksMediaDownloadSpider) crawler = self._create_crawler(BrokenLinksMediaDownloadSpider)
with LogCapture() as log: with LogCapture() as log:
yield crawler.crawl( yield crawler.crawl(
self.mockserver.url("/files/images/"), self.mockserver.url("/static/files/images/"),
media_key=self.media_key, media_key=self.media_key,
media_urls_key=self.media_urls_key, media_urls_key=self.media_urls_key,
) )
@ -171,7 +171,7 @@ class TestFileDownloadCrawl:
crawler = self._create_crawler(RedirectedMediaDownloadSpider) crawler = self._create_crawler(RedirectedMediaDownloadSpider)
with LogCapture() as log: with LogCapture() as log:
yield crawler.crawl( yield crawler.crawl(
self.mockserver.url("/files/images/"), self.mockserver.url("/static/files/images/"),
media_key=self.media_key, media_key=self.media_key,
media_urls_key=self.media_urls_key, media_urls_key=self.media_urls_key,
mockserver=self.mockserver, mockserver=self.mockserver,
@ -187,7 +187,7 @@ class TestFileDownloadCrawl:
crawler = self._create_crawler(RedirectedMediaDownloadSpider, settings) crawler = self._create_crawler(RedirectedMediaDownloadSpider, settings)
with LogCapture() as log: with LogCapture() as log:
yield crawler.crawl( yield crawler.crawl(
self.mockserver.url("/files/images/"), self.mockserver.url("/static/files/images/"),
media_key=self.media_key, media_key=self.media_key,
media_urls_key=self.media_urls_key, media_urls_key=self.media_urls_key,
mockserver=self.mockserver, mockserver=self.mockserver,
@ -210,7 +210,7 @@ class TestFileDownloadCrawl:
crawler = self._create_crawler(MediaDownloadSpider, settings) crawler = self._create_crawler(MediaDownloadSpider, settings)
with LogCapture() as log: with LogCapture() as log:
yield crawler.crawl( yield crawler.crawl(
self.mockserver.url("/files/images/"), self.mockserver.url("/static/files/images/"),
media_key=self.media_key, media_key=self.media_key,
media_urls_key=self.media_urls_key, media_urls_key=self.media_urls_key,
mockserver=self.mockserver, mockserver=self.mockserver,

View File

@ -30,7 +30,7 @@ from scrapy.pipelines.files import (
S3FilesStore, S3FilesStore,
) )
from scrapy.utils.test import get_crawler from scrapy.utils.test import get_crawler
from tests.mockserver import MockFTPServer from tests.mockserver.ftp import MockFTPServer
from .test_pipeline_media import _mocked_download_func from .test_pipeline_media import _mocked_download_func

View File

@ -6,7 +6,7 @@ from twisted.internet.defer import Deferred, inlineCallbacks
from scrapy import Request, Spider, signals from scrapy import Request, Spider, signals
from scrapy.utils.defer import deferred_to_future, maybe_deferred_to_future from scrapy.utils.defer import deferred_to_future, maybe_deferred_to_future
from scrapy.utils.test import get_crawler, get_from_asyncio_queue from scrapy.utils.test import get_crawler, get_from_asyncio_queue
from tests.mockserver import MockServer from tests.mockserver.http import MockServer
class SimplePipeline: class SimplePipeline:

View File

@ -12,7 +12,7 @@ from twisted.internet.defer import inlineCallbacks
from scrapy.http import Request from scrapy.http import Request
from scrapy.utils.test import get_crawler from scrapy.utils.test import get_crawler
from tests.mockserver import MockServer from tests.mockserver.http import MockServer
from tests.spiders import SimpleSpider, SingleRequestSpider from tests.spiders import SimpleSpider, SingleRequestSpider

View File

@ -4,7 +4,7 @@ from twisted.internet.defer import inlineCallbacks
from scrapy import Request, signals from scrapy import Request, signals
from scrapy.http.response import Response from scrapy.http.response import Response
from scrapy.utils.test import get_crawler from scrapy.utils.test import get_crawler
from tests.mockserver import MockServer from tests.mockserver.http import MockServer
from tests.spiders import SingleRequestSpider from tests.spiders import SingleRequestSpider
OVERRIDDEN_URL = "https://example.org" OVERRIDDEN_URL = "https://example.org"

View File

@ -3,7 +3,7 @@ from twisted.internet.defer import inlineCallbacks
from scrapy.http import Request from scrapy.http import Request
from scrapy.utils.test import get_crawler from scrapy.utils.test import get_crawler
from tests.mockserver import MockServer from tests.mockserver.http import MockServer
from tests.spiders import MockServerSpider from tests.spiders import MockServerSpider

View File

@ -3,7 +3,7 @@ from twisted.internet.defer import inlineCallbacks
from scrapy.signals import request_left_downloader from scrapy.signals import request_left_downloader
from scrapy.spiders import Spider from scrapy.spiders import Spider
from scrapy.utils.test import get_crawler from scrapy.utils.test import get_crawler
from tests.mockserver import MockServer from tests.mockserver.http import MockServer
class SignalCatcherSpider(Spider): class SignalCatcherSpider(Spider):

View File

@ -18,7 +18,7 @@ from scrapy.spiders import Spider
from scrapy.utils.httpobj import urlparse_cached from scrapy.utils.httpobj import urlparse_cached
from scrapy.utils.misc import load_object from scrapy.utils.misc import load_object
from scrapy.utils.test import get_crawler from scrapy.utils.test import get_crawler
from tests.mockserver import MockServer from tests.mockserver.http import MockServer
class MemoryScheduler(BaseScheduler): class MemoryScheduler(BaseScheduler):

View File

@ -13,7 +13,7 @@ from scrapy.spiders import Spider
from scrapy.utils.httpobj import urlparse_cached from scrapy.utils.httpobj import urlparse_cached
from scrapy.utils.request import fingerprint from scrapy.utils.request import fingerprint
from scrapy.utils.test import get_crawler from scrapy.utils.test import get_crawler
from tests.mockserver import MockServer from tests.mockserver.http import MockServer
PATHS = ["/a", "/b", "/c"] PATHS = ["/a", "/b", "/c"]
URLS = [urljoin("https://example.org", p) for p in PATHS] URLS = [urljoin("https://example.org", p) for p in PATHS]

View File

@ -4,7 +4,7 @@ from twisted.internet.defer import inlineCallbacks
from scrapy import Request, Spider, signals from scrapy import Request, Spider, signals
from scrapy.utils.defer import deferred_f_from_coro_f, maybe_deferred_to_future from scrapy.utils.defer import deferred_f_from_coro_f, maybe_deferred_to_future
from scrapy.utils.test import get_crawler, get_from_asyncio_queue from scrapy.utils.test import get_crawler, get_from_asyncio_queue
from tests.mockserver import MockServer from tests.mockserver.http import MockServer
class ItemSpider(Spider): class ItemSpider(Spider):

View File

@ -11,7 +11,7 @@ from scrapy.settings import Settings
from scrapy.spidermiddlewares.httperror import HttpError, HttpErrorMiddleware from scrapy.spidermiddlewares.httperror import HttpError, HttpErrorMiddleware
from scrapy.spiders import Spider from scrapy.spiders import Spider
from scrapy.utils.test import get_crawler from scrapy.utils.test import get_crawler
from tests.mockserver import MockServer from tests.mockserver.http import MockServer
from tests.spiders import MockServerSpider from tests.spiders import MockServerSpider

View File

@ -3,7 +3,7 @@ from testfixtures import LogCapture
from scrapy import Request, Spider from scrapy import Request, Spider
from scrapy.utils.defer import deferred_f_from_coro_f, maybe_deferred_to_future from scrapy.utils.defer import deferred_f_from_coro_f, maybe_deferred_to_future
from scrapy.utils.test import get_crawler from scrapy.utils.test import get_crawler
from tests.mockserver import MockServer from tests.mockserver.http import MockServer
class LogExceptionMiddleware: class LogExceptionMiddleware:

View File

@ -22,15 +22,12 @@ from scrapy.http import Headers, Request
from scrapy.utils.misc import build_from_crawler from scrapy.utils.misc import build_from_crawler
from scrapy.utils.python import to_bytes, to_unicode from scrapy.utils.python import to_bytes, to_unicode
from scrapy.utils.test import get_crawler from scrapy.utils.test import get_crawler
from tests.mockserver import ( from tests.mockserver.http_resources import (
BrokenDownloadResource,
ErrorResource,
ForeverTakingResource, ForeverTakingResource,
HostHeaderResource, HostHeaderResource,
NoLengthResource,
PayloadResource, PayloadResource,
ssl_context_factory,
) )
from tests.mockserver.utils import ssl_context_factory
from tests.test_core_downloader import TestContextFactoryBase from tests.test_core_downloader import TestContextFactoryBase
@ -195,6 +192,27 @@ class EncodingResource(resource.Resource):
return body.encode(self.out_encoding) return body.encode(self.out_encoding)
class BrokenDownloadResource(resource.Resource):
def render(self, request):
# only sends 3 bytes even though it claims to send 5
request.setHeader(b"content-length", b"5")
request.write(b"abc")
return b""
class ErrorResource(resource.Resource):
def render(self, request):
request.setResponseCode(401)
if request.args.get(b"showlength"):
request.setHeader(b"content-length", b"0")
return b""
class NoLengthResource(resource.Resource):
def render(self, request):
return b"nolength"
@pytest.mark.filterwarnings("ignore::scrapy.exceptions.ScrapyDeprecationWarning") @pytest.mark.filterwarnings("ignore::scrapy.exceptions.ScrapyDeprecationWarning")
class TestWebClient: class TestWebClient:
def _listen(self, site): def _listen(self, site):

View File

@ -1,3 +1,6 @@
import os
from pathlib import Path
from twisted.internet.defer import Deferred from twisted.internet.defer import Deferred
@ -7,3 +10,13 @@ def twisted_sleep(seconds):
d = Deferred() d = Deferred()
reactor.callLater(seconds, d.callback, None) reactor.callLater(seconds, d.callback, None)
return d return d
def get_script_run_env() -> dict[str, str]:
"""Return a OS environment dict suitable to run scripts shipped with tests."""
tests_path = Path(__file__).parent.parent
pythonpath = str(tests_path) + os.pathsep + os.environ.get("PYTHONPATH", "")
env = os.environ.copy()
env["PYTHONPATH"] = pythonpath
return env