From 8c8f4ff03312f5d3312715dd6fb0fa20a361ecb8 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Fri, 25 Jul 2025 12:42:27 +0500 Subject: [PATCH] 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. --- conftest.py | 13 + tests/CrawlerRunner/ip_address.py | 6 +- tests/ftpserver.py | 24 - tests/mockserver.py | 436 ---------------- tests/mockserver/__init__.py | 0 tests/mockserver/dns.py | 67 +++ tests/mockserver/ftp.py | 59 +++ tests/mockserver/http.py | 95 ++++ tests/mockserver/http_base.py | 132 +++++ tests/mockserver/http_resources.py | 309 ++++++++++++ tests/mockserver/proxy_echo.py | 17 + tests/mockserver/simple_https.py | 46 ++ tests/mockserver/utils.py | 23 + tests/test_closespider.py | 2 +- tests/test_command_fetch.py | 2 +- tests/test_command_parse.py | 2 +- tests/test_command_shell.py | 2 +- tests/test_contracts.py | 2 +- tests/test_core_downloader.py | 7 +- tests/test_crawl.py | 2 +- tests/test_crawler.py | 21 +- .../test_downloader_handler_twisted_http10.py | 9 +- .../test_downloader_handler_twisted_http11.py | 4 +- .../test_downloader_handler_twisted_http2.py | 92 ++-- tests/test_downloader_handlers_http_base.py | 467 ++++++------------ tests/test_downloaderslotssettings.py | 2 +- tests/test_engine.py | 100 ++-- tests/test_engine_loop.py | 5 +- tests/test_engine_stop_download_bytes.py | 21 +- tests/test_engine_stop_download_headers.py | 23 +- tests/test_feedexport.py | 3 +- tests/test_http2_client_protocol.py | 3 +- tests/test_logformatter.py | 2 +- tests/test_pipeline_crawl.py | 12 +- tests/test_pipeline_files.py | 2 +- tests/test_pipelines.py | 2 +- tests/test_proxy_connect.py | 2 +- tests/test_request_attribute_binding.py | 2 +- tests/test_request_cb_kwargs.py | 2 +- tests/test_request_left.py | 2 +- tests/test_scheduler.py | 2 +- tests/test_scheduler_base.py | 2 +- tests/test_signals.py | 2 +- tests/test_spidermiddleware_httperror.py | 2 +- tests/test_spidermiddleware_output_chain.py | 2 +- tests/test_webclient.py | 28 +- tests/utils/__init__.py | 13 + 47 files changed, 1124 insertions(+), 949 deletions(-) delete mode 100644 tests/ftpserver.py delete mode 100644 tests/mockserver.py create mode 100644 tests/mockserver/__init__.py create mode 100644 tests/mockserver/dns.py create mode 100644 tests/mockserver/ftp.py create mode 100644 tests/mockserver/http.py create mode 100644 tests/mockserver/http_base.py create mode 100644 tests/mockserver/http_resources.py create mode 100644 tests/mockserver/proxy_echo.py create mode 100644 tests/mockserver/simple_https.py create mode 100644 tests/mockserver/utils.py diff --git a/conftest.py b/conftest.py index 4cfacc2a2..ece57eecc 100644 --- a/conftest.py +++ b/conftest.py @@ -1,10 +1,17 @@ +from __future__ import annotations + from pathlib import Path +from typing import TYPE_CHECKING import pytest from twisted.web.http import H2_ENABLED from scrapy.utils.reactor import set_asyncio_event_loop_policy from tests.keys import generate_keys +from tests.mockserver.http import MockServer + +if TYPE_CHECKING: + from collections.abc import Generator 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") def reactor_pytest(request) -> str: return request.config.getoption("--reactor") diff --git a/tests/CrawlerRunner/ip_address.py b/tests/CrawlerRunner/ip_address.py index 207fc86ad..9811bf90c 100644 --- a/tests/CrawlerRunner/ip_address.py +++ b/tests/CrawlerRunner/ip_address.py @@ -1,10 +1,11 @@ # ruff: noqa: E402 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") -from urllib.parse import urlparse from twisted.names import cache, resolve from twisted.names import hosts as hostsModule @@ -15,7 +16,6 @@ from scrapy import Request, Spider from scrapy.crawler import CrawlerRunner from scrapy.utils.httpobj import urlparse_cached from scrapy.utils.log import configure_logging -from tests.mockserver import MockDNSServer, MockServer # https://stackoverflow.com/a/32784190 @@ -46,7 +46,7 @@ if __name__ == "__main__": from twisted.internet import reactor 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" servers = [(mock_dns_server.host, mock_dns_server.port)] diff --git a/tests/ftpserver.py b/tests/ftpserver.py deleted file mode 100644 index 0c6ca3621..000000000 --- a/tests/ftpserver.py +++ /dev/null @@ -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() diff --git a/tests/mockserver.py b/tests/mockserver.py deleted file mode 100644 index 7e53bc9dc..000000000 --- a/tests/mockserver.py +++ /dev/null @@ -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 = """ """ - args = request.args.copy() - for nl in nlist: - args[b"n"] = [to_bytes(str(nl))] - argstr = urlencode(args, doseq=True) - s += f"follow {nl}
" - s += """""" - 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 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"

Works

World

", - "text/html", - ), - ) - self.putChild( - b"enc-gb18030", - Data(b"

gb18030 encoding

", "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() diff --git a/tests/mockserver/__init__.py b/tests/mockserver/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/mockserver/dns.py b/tests/mockserver/dns.py new file mode 100644 index 000000000..7c08f237c --- /dev/null +++ b/tests/mockserver/dns.py @@ -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() diff --git a/tests/mockserver/ftp.py b/tests/mockserver/ftp.py new file mode 100644 index 000000000..5505ac258 --- /dev/null +++ b/tests/mockserver/ftp.py @@ -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() diff --git a/tests/mockserver/http.py b/tests/mockserver/http.py new file mode 100644 index 000000000..409920dcb --- /dev/null +++ b/tests/mockserver/http.py @@ -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"

Works

World

", + "text/html", + ), + ) + self.putChild( + b"enc-gb18030", + Data(b"

gb18030 encoding

", "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() diff --git a/tests/mockserver/http_base.py b/tests/mockserver/http_base.py new file mode 100644 index 000000000..440d38fb9 --- /dev/null +++ b/tests/mockserver/http_base.py @@ -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 diff --git a/tests/mockserver/http_resources.py b/tests/mockserver/http_resources.py new file mode 100644 index 000000000..c9083b1d9 --- /dev/null +++ b/tests/mockserver/http_resources.py @@ -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 = """ """ + args = request.args.copy() + for nl in nlist: + args[b"n"] = [to_bytes(str(nl))] + argstr = urlencode(args, doseq=True) + s += f"follow {nl}
" + s += """""" + 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 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"" diff --git a/tests/mockserver/proxy_echo.py b/tests/mockserver/proxy_echo.py new file mode 100644 index 000000000..6345726b6 --- /dev/null +++ b/tests/mockserver/proxy_echo.py @@ -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() diff --git a/tests/mockserver/simple_https.py b/tests/mockserver/simple_https.py new file mode 100644 index 000000000..5f23dd2c4 --- /dev/null +++ b/tests/mockserver/simple_https.py @@ -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() diff --git a/tests/mockserver/utils.py b/tests/mockserver/utils.py new file mode 100644 index 000000000..f43152f92 --- /dev/null +++ b/tests/mockserver/utils.py @@ -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 diff --git a/tests/test_closespider.py b/tests/test_closespider.py index 563ecbe92..2243cd371 100644 --- a/tests/test_closespider.py +++ b/tests/test_closespider.py @@ -1,7 +1,7 @@ from twisted.internet.defer import inlineCallbacks from scrapy.utils.test import get_crawler -from tests.mockserver import MockServer +from tests.mockserver.http import MockServer from tests.spiders import ( ErrorSpider, FollowAllSpider, diff --git a/tests/test_command_fetch.py b/tests/test_command_fetch.py index c83594361..b6b443e28 100644 --- a/tests/test_command_fetch.py +++ b/tests/test_command_fetch.py @@ -1,4 +1,4 @@ -from tests.mockserver import MockServer +from tests.mockserver.http import MockServer from tests.test_commands import TestProjectBase diff --git a/tests/test_command_parse.py b/tests/test_command_parse.py index 5c3120c21..fe7a807d4 100644 --- a/tests/test_command_parse.py +++ b/tests/test_command_parse.py @@ -4,7 +4,7 @@ from pathlib import Path from scrapy.commands import parse from scrapy.settings import Settings -from tests.mockserver import MockServer +from tests.mockserver.http import MockServer from tests.test_commands import TestCommandBase diff --git a/tests/test_command_shell.py b/tests/test_command_shell.py index 76c1eb663..76635b1aa 100644 --- a/tests/test_command_shell.py +++ b/tests/test_command_shell.py @@ -8,7 +8,7 @@ from pexpect.popen_spawn import PopenSpawn from scrapy.utils.reactor import _asyncio_reactor_path 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 diff --git a/tests/test_contracts.py b/tests/test_contracts.py index fc3cd9df0..326be570e 100644 --- a/tests/test_contracts.py +++ b/tests/test_contracts.py @@ -18,7 +18,7 @@ from scrapy.item import Field, Item from scrapy.spidermiddlewares.httperror import HttpError from scrapy.spiders import Spider from scrapy.utils.test import get_crawler -from tests.mockserver import MockServer +from tests.mockserver.http import MockServer class DemoItem(Item): diff --git a/tests/test_core_downloader.py b/tests/test_core_downloader.py index ca15c560a..9d33f4280 100644 --- a/tests/test_core_downloader.py +++ b/tests/test_core_downloader.py @@ -6,7 +6,6 @@ from typing import TYPE_CHECKING, Any, cast import OpenSSL.SSL import pytest from pytest_twisted import async_yield_fixture -from twisted.protocols.policies import WrappingFactory from twisted.web import server, static from twisted.web.client import Agent, BrowserLikePolicyForHTTPS, readBody 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.python import to_bytes 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: from twisted.internet.defer import Deferred @@ -44,8 +44,7 @@ class TestContextFactoryBase: r = static.File(str(tmp_path)) r.putChild(b"payload", PayloadResource()) site = server.Site(r, timeout=None) - wrapper = WrappingFactory(site) - port = self._listen(wrapper) + port = self._listen(site) portno = port.getHost().port yield f"https://127.0.0.1:{portno}/" diff --git a/tests/test_crawl.py b/tests/test_crawl.py index 877b23bef..255a03a2a 100644 --- a/tests/test_crawl.py +++ b/tests/test_crawl.py @@ -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.test import get_crawler, get_reactor_settings from tests import NON_EXISTING_RESOLVABLE -from tests.mockserver import MockServer +from tests.mockserver.http import MockServer from tests.spiders import ( AsyncDefAsyncioGenComplexSpider, AsyncDefAsyncioGenExcSpider, diff --git a/tests/test_crawler.py b/tests/test_crawler.py index bc182d2f5..e7e7440a8 100644 --- a/tests/test_crawler.py +++ b/tests/test_crawler.py @@ -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.spider import DefaultSpider 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] = {} @@ -753,7 +754,7 @@ class ScriptRunnerMixin(ABC): args = self.get_script_args(script_name, *script_args) p = subprocess.Popen( args, - env=get_mockserver_env(), + env=get_script_run_env(), stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) @@ -830,14 +831,14 @@ class TestCrawlerProcessSubprocessBase(ScriptRunnerMixin): assert "Spider closed (finished)" in log assert "twisted.internet.error.DNSLookupError" not in log - def test_caching_hostname_resolver_finite_execution(self): - with MockServer() as mock_server: - http_address = mock_server.http_address.replace("0.0.0.0", "127.0.0.1") - log = self.run_script("caching_hostname_resolver.py", http_address) - assert "Spider closed (finished)" in log - assert "ERROR: Error downloading" not in log - assert "TimeoutError" not in log - assert "twisted.internet.error.DNSLookupError" not in log + def test_caching_hostname_resolver_finite_execution( + self, mockserver: MockServer + ) -> None: + log = self.run_script("caching_hostname_resolver.py", mockserver.url("/")) + assert "Spider closed (finished)" in log + assert "ERROR: Error downloading" not in log + assert "TimeoutError" not in log + assert "twisted.internet.error.DNSLookupError" not in log def test_twisted_reactor_asyncio(self): log = self.run_script("twisted_reactor_asyncio.py") diff --git a/tests/test_downloader_handler_twisted_http10.py b/tests/test_downloader_handler_twisted_http10.py index ddb3250db..c3e0aa7c4 100644 --- a/tests/test_downloader_handler_twisted_http10.py +++ b/tests/test_downloader_handler_twisted_http10.py @@ -17,6 +17,7 @@ from tests.test_downloader_handlers_http_base import ( if TYPE_CHECKING: from scrapy.core.downloader.handlers import DownloadHandlerProtocol + from tests.mockserver.http import MockServer class HTTP10DownloadHandlerMixin: @@ -31,15 +32,17 @@ class TestHttp10(HTTP10DownloadHandlerMixin, TestHttpBase): @deferred_f_from_coro_f async def test_protocol( - self, server_port: int, download_handler: DownloadHandlerProtocol + self, mockserver: MockServer, download_handler: DownloadHandlerProtocol ) -> 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) assert response.protocol == "HTTP/1.0" class TestHttps10(TestHttp10): - scheme = "https" + is_secure = True @pytest.mark.filterwarnings("ignore::scrapy.exceptions.ScrapyDeprecationWarning") diff --git a/tests/test_downloader_handler_twisted_http11.py b/tests/test_downloader_handler_twisted_http11.py index 7b26ce03f..79b2a6fc5 100644 --- a/tests/test_downloader_handler_twisted_http11.py +++ b/tests/test_downloader_handler_twisted_http11.py @@ -7,13 +7,13 @@ from typing import TYPE_CHECKING, Any from scrapy.core.downloader.handlers.http11 import HTTP11DownloadHandler from tests.test_downloader_handlers_http_base import ( TestHttp11Base, - TestHttpMockServerBase, TestHttpProxyBase, TestHttps11Base, TestHttpsCustomCiphersBase, TestHttpsInvalidDNSIdBase, TestHttpsInvalidDNSPatternBase, TestHttpsWrongHostnameBase, + TestHttpWithCrawlerBase, TestSimpleHttpsBase, ) @@ -57,7 +57,7 @@ class TestHttps11CustomCiphers(HTTP11DownloadHandlerMixin, TestHttpsCustomCipher pass -class TestHttp11MockServer(TestHttpMockServerBase): +class TestHttp11WithCrawler(TestHttpWithCrawlerBase): @property def settings_dict(self) -> dict[str, Any] | None: return None # default handler settings diff --git a/tests/test_downloader_handler_twisted_http2.py b/tests/test_downloader_handler_twisted_http2.py index a76cf9dfc..3e3e677a2 100644 --- a/tests/test_downloader_handler_twisted_http2.py +++ b/tests/test_downloader_handler_twisted_http2.py @@ -7,33 +7,29 @@ from typing import TYPE_CHECKING, Any from unittest import mock import pytest -from pytest_twisted import async_yield_fixture from testfixtures import LogCapture from twisted.internet import defer, error -from twisted.web import server from twisted.web.error import SchemeNotSupported from twisted.web.http import H2_ENABLED from scrapy.http import Request from scrapy.spiders import Spider 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 ( - TestHttpMockServerBase, TestHttpProxyBase, TestHttps11Base, TestHttpsCustomCiphersBase, TestHttpsInvalidDNSIdBase, TestHttpsInvalidDNSPatternBase, TestHttpsWrongHostnameBase, - UriResource, + TestHttpWithCrawlerBase, download_request, ) if TYPE_CHECKING: - from collections.abc import AsyncGenerator - from scrapy.core.downloader.handlers import DownloadHandlerProtocol + from tests.mockserver.http import MockServer + from tests.mockserver.proxy_echo import ProxyEchoMockServer pytestmark = pytest.mark.skipif( @@ -57,20 +53,24 @@ class TestHttps2(H2DownloadHandlerMixin, TestHttps11Base): @deferred_f_from_coro_f async def test_protocol( - self, server_port: int, download_handler: DownloadHandlerProtocol + self, mockserver: MockServer, download_handler: DownloadHandlerProtocol ) -> 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) assert response.protocol == "h2" @deferred_f_from_coro_f async def test_download_with_maxsize_very_large_file( - self, server_port: int, download_handler: DownloadHandlerProtocol + self, mockserver: MockServer, download_handler: DownloadHandlerProtocol ) -> None: from twisted.internet import reactor 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: logger.error.assert_called_once_with(mock.ANY) @@ -107,30 +107,34 @@ class TestHttps2(H2DownloadHandlerMixin, TestHttps11Base): @deferred_f_from_coro_f async def test_concurrent_requests_same_domain( - self, server_port: int, download_handler: DownloadHandlerProtocol + self, mockserver: MockServer, download_handler: DownloadHandlerProtocol ) -> 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) - 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) assert response2.headers["Content-Length"] == b"79" @pytest.mark.xfail(reason="https://github.com/python-hyper/h2/issues/1247") @deferred_f_from_coro_f async def test_connect_request( - self, server_port: int, download_handler: DownloadHandlerProtocol + self, mockserver: MockServer, download_handler: DownloadHandlerProtocol ) -> 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) assert response.body == b"" @deferred_f_from_coro_f async def test_custom_content_length_good( - self, server_port: int, download_handler: DownloadHandlerProtocol + self, mockserver: MockServer, download_handler: DownloadHandlerProtocol ) -> 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)) request.headers["Content-Length"] = custom_content_length response = await download_request(download_handler, request) @@ -138,9 +142,9 @@ class TestHttps2(H2DownloadHandlerMixin, TestHttps11Base): @deferred_f_from_coro_f async def test_custom_content_length_bad( - self, server_port: int, download_handler: DownloadHandlerProtocol + self, mockserver: MockServer, download_handler: DownloadHandlerProtocol ) -> 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)) bad_content_length = str(len(request.body) + 1) request.headers["Content-Length"] = bad_content_length @@ -159,9 +163,9 @@ class TestHttps2(H2DownloadHandlerMixin, TestHttps11Base): @deferred_f_from_coro_f async def test_duplicate_header( - self, server_port: int, download_handler: DownloadHandlerProtocol + self, mockserver: MockServer, download_handler: DownloadHandlerProtocol ) -> 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" request.headers.appendlist(header, value1) request.headers.appendlist(header, value2) @@ -187,7 +191,7 @@ class TestHttps2CustomCiphers(H2DownloadHandlerMixin, TestHttpsCustomCiphersBase pass -class TestHttp2MockServer(TestHttpMockServerBase): +class TestHttp2WithCrawler(TestHttpWithCrawlerBase): """HTTP 2.0 test case with MockServer""" @property @@ -202,35 +206,31 @@ class TestHttp2MockServer(TestHttpMockServerBase): class TestHttps2Proxy(H2DownloadHandlerMixin, TestHttpProxyBase): - # only used for HTTPS tests - keyfile = "keys/localhost.key" - certfile = "keys/localhost.crt" - scheme = "https" + is_secure = True 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 async def test_download_with_proxy_https_timeout( - self, server_port: int, download_handler: DownloadHandlerProtocol + self, + proxy_mockserver: ProxyEchoMockServer, + download_handler: DownloadHandlerProtocol, ) -> None: with pytest.raises(NotImplementedError): await maybe_deferred_to_future( 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 ) ) diff --git a/tests/test_downloader_handlers_http_base.py b/tests/test_downloader_handlers_http_base.py index 35f5d483e..bbaf9696b 100644 --- a/tests/test_downloader_handlers_http_base.py +++ b/tests/test_downloader_handlers_http_base.py @@ -12,8 +12,6 @@ import pytest from pytest_twisted import async_yield_fixture from testfixtures import LogCapture 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.http import _DataLoss @@ -25,114 +23,18 @@ from scrapy.utils.defer import ( maybe_deferred_to_future, ) from scrapy.utils.misc import build_from_crawler -from scrapy.utils.python import to_bytes from scrapy.utils.spider import DefaultSpider from scrapy.utils.test import get_crawler from tests import NON_EXISTING_RESOLVABLE -from tests.mockserver import ( - Echo, - ForeverTakingResource, - HostHeaderResource, - MockServer, - NoLengthResource, - PayloadResource, - ssl_context_factory, -) +from tests.mockserver.proxy_echo import ProxyEchoMockServer +from tests.mockserver.simple_https import SimpleMockServer from tests.spiders import SingleRequestSpider if TYPE_CHECKING: - from collections.abc import AsyncGenerator - from pathlib import Path + from collections.abc import AsyncGenerator, Generator from scrapy.core.downloader.handlers import DownloadHandlerProtocol - - -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"" + from tests.mockserver.http import MockServer async def download_request( @@ -157,59 +59,13 @@ async def close_dh(dh: DownloadHandlerProtocol) -> None: class TestHttpBase(ABC): - scheme = "http" - host = "localhost" - - # only used for HTTPS tests - keyfile = "keys/localhost.key" - certfile = "keys/localhost.crt" + is_secure = False @property @abstractmethod def download_handler_cls(self) -> type[DownloadHandlerProtocol]: 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 def download_handler(self) -> AsyncGenerator[DownloadHandlerProtocol]: dh = build_from_crawler(self.download_handler_cls, get_crawler()) @@ -218,45 +74,46 @@ class TestHttpBase(ABC): 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 async def test_download( - self, server_port: int, download_handler: DownloadHandlerProtocol + self, mockserver: MockServer, download_handler: DownloadHandlerProtocol ) -> 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) - assert response.body == b"0123456789" + assert response.body == b"Works" @deferred_f_from_coro_f async def test_download_head( - self, server_port: int, download_handler: DownloadHandlerProtocol + self, mockserver: MockServer, download_handler: DownloadHandlerProtocol ) -> 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) assert response.body == b"" @deferred_f_from_coro_f async def test_redirect_status( - self, server_port: int, download_handler: DownloadHandlerProtocol + self, mockserver: MockServer, download_handler: DownloadHandlerProtocol ) -> 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) assert response.status == 302 @deferred_f_from_coro_f async def test_redirect_status_head( - self, server_port: int, download_handler: DownloadHandlerProtocol + self, mockserver: MockServer, download_handler: DownloadHandlerProtocol ) -> 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) assert response.status == 302 @deferred_f_from_coro_f async def test_timeout_download_from_spider_nodata_rcvd( self, - server_port: int, + mockserver: MockServer, download_handler: DownloadHandlerProtocol, reactor_pytest: str, ) -> None: @@ -268,7 +125,7 @@ class TestHttpBase(ABC): # client connects but no data is received 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)) with pytest.raises((defer.TimeoutError, error.TimeoutError)): await maybe_deferred_to_future(d) @@ -276,7 +133,7 @@ class TestHttpBase(ABC): @deferred_f_from_coro_f async def test_timeout_download_from_spider_server_hangs( self, - server_port: int, + mockserver: MockServer, download_handler: DownloadHandlerProtocol, reactor_pytest: str, ) -> None: @@ -287,33 +144,36 @@ class TestHttpBase(ABC): ) # client connects, server send headers and some body bytes but hangs 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)) with pytest.raises((defer.TimeoutError, error.TimeoutError)): await maybe_deferred_to_future(d) + @pytest.mark.parametrize("send_header", [True, False]) @deferred_f_from_coro_f - async def test_host_header_not_in_request_headers( - self, server_port: int, download_handler: DownloadHandlerProtocol + async def test_host_header( + self, + send_header: bool, + mockserver: MockServer, + download_handler: DownloadHandlerProtocol, ) -> 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) - assert response.body == to_bytes(f"{self.host}:{server_port}") - assert not request.headers - - @deferred_f_from_coro_f - async def test_host_header_set_in_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() + assert response.body == host_port.encode() + if send_header: + assert request.headers.get("Host") == host_port.encode() + else: + assert not request.headers @deferred_f_from_coro_f async def test_content_length_zero_bodyless_post_request_headers( - self, server_port: int, download_handler: DownloadHandlerProtocol + self, mockserver: MockServer, download_handler: DownloadHandlerProtocol ) -> None: """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://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) assert response.body == b"0" @deferred_f_from_coro_f async def test_content_length_zero_bodyless_post_only_one( - self, server_port: int, download_handler: DownloadHandlerProtocol + self, mockserver: MockServer, download_handler: DownloadHandlerProtocol ) -> 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) headers = Headers(json.loads(response.text)["headers"]) contentlengths = headers.getlist("Content-Length") @@ -342,20 +206,26 @@ class TestHttpBase(ABC): @deferred_f_from_coro_f async def test_payload( - self, server_port: int, download_handler: DownloadHandlerProtocol + self, mockserver: MockServer, download_handler: DownloadHandlerProtocol ) -> None: 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) assert response.body == body @deferred_f_from_coro_f async def test_response_header_content_length( - self, server_port: int, download_handler: DownloadHandlerProtocol + self, mockserver: MockServer, download_handler: DownloadHandlerProtocol ) -> 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) - assert response.headers[b"content-length"] == b"10" + assert response.headers[b"content-length"] == b"5" @pytest.mark.parametrize( ("filename", "body", "response_class"), @@ -370,18 +240,20 @@ class TestHttpBase(ABC): filename: str, body: bytes, response_class: type[Response], - server_port: int, + mockserver: MockServer, download_handler: DownloadHandlerProtocol, ) -> 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) assert type(response) is response_class # pylint: disable=unidiomatic-typecheck @deferred_f_from_coro_f async def test_get_duplicate_header( - self, server_port: int, download_handler: DownloadHandlerProtocol + self, mockserver: MockServer, download_handler: DownloadHandlerProtocol ) -> 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) 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 async def test_download_without_maxsize_limit( - self, server_port: int, download_handler: DownloadHandlerProtocol + self, mockserver: MockServer, download_handler: DownloadHandlerProtocol ) -> 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) - assert response.body == b"0123456789" + assert response.body == b"Works" @deferred_f_from_coro_f async def test_response_class_choosing_request( - self, server_port: int, download_handler: DownloadHandlerProtocol + self, mockserver: MockServer, download_handler: DownloadHandlerProtocol ) -> None: """Tests choosing of correct response type in case of Content-Type is empty but body contains text. """ 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) assert type(response) is TextResponse # pylint: disable=unidiomatic-typecheck @deferred_f_from_coro_f async def test_download_with_maxsize( - self, server_port: int, download_handler: DownloadHandlerProtocol + self, mockserver: MockServer, download_handler: DownloadHandlerProtocol ) -> 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 # response body. (regardless of headers) 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)): await download_request( - download_handler, request, Spider("foo", download_maxsize=9) + download_handler, request, Spider("foo", download_maxsize=4) ) @deferred_f_from_coro_f async def test_download_with_maxsize_very_large_file( - self, server_port: int, download_handler: DownloadHandlerProtocol + self, mockserver: MockServer, download_handler: DownloadHandlerProtocol ) -> None: from twisted.internet import reactor # TODO: the logger check is specific to scrapy.core.downloader.handlers.http11 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: logger.warning.assert_called_once_with(mock.ANY, mock.ANY) @@ -455,18 +331,18 @@ class TestHttp11Base(TestHttpBase): @deferred_f_from_coro_f async def test_download_with_maxsize_per_req( - self, server_port: int, download_handler: DownloadHandlerProtocol + self, mockserver: MockServer, download_handler: DownloadHandlerProtocol ) -> None: 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)): await download_request(download_handler, request) @deferred_f_from_coro_f async def test_download_with_small_maxsize_per_spider( - self, server_port: int, download_handler: DownloadHandlerProtocol + self, mockserver: MockServer, download_handler: DownloadHandlerProtocol ) -> 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)): await download_request( download_handler, request, Spider("foo", download_maxsize=2) @@ -474,29 +350,32 @@ class TestHttp11Base(TestHttpBase): @deferred_f_from_coro_f async def test_download_with_large_maxsize_per_spider( - self, server_port: int, download_handler: DownloadHandlerProtocol + self, mockserver: MockServer, download_handler: DownloadHandlerProtocol ) -> 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, Spider("foo", download_maxsize=100) ) - assert response.body == b"0123456789" + assert response.body == b"Works" @deferred_f_from_coro_f async def test_download_chunked_content( - self, server_port: int, download_handler: DownloadHandlerProtocol + self, mockserver: MockServer, download_handler: DownloadHandlerProtocol ) -> 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) assert response.body == b"chunked content\n" @pytest.mark.parametrize("url", ["broken", "broken-chunked"]) @deferred_f_from_coro_f 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: # 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: await download_request(download_handler, request) 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"]) @deferred_f_from_coro_f 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: 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) assert response.flags == ["dataloss"] @@ -515,11 +398,11 @@ class TestHttp11Base(TestHttpBase): @pytest.mark.parametrize("url", ["broken", "broken-chunked"]) @deferred_f_from_coro_f async def test_download_allow_data_loss_via_setting( - self, url: str, server_port: int + self, url: str, mockserver: MockServer ) -> None: crawler = get_crawler(settings_dict={"DOWNLOAD_FAIL_ON_DATALOSS": False}) 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: response = await maybe_deferred_to_future( download_handler.download_request(request, DefaultSpider()) @@ -532,15 +415,17 @@ class TestHttp11Base(TestHttpBase): @deferred_f_from_coro_f async def test_protocol( - self, server_port: int, download_handler: DownloadHandlerProtocol + self, mockserver: MockServer, download_handler: DownloadHandlerProtocol ) -> 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) assert response.protocol == "HTTP/1.1" class TestHttps11Base(TestHttp11Base): - scheme = "https" + is_secure = True tls_log_message = ( 'SSL connection certificate: issuer "/C=IE/O=Scrapy/CN=localhost", ' @@ -548,18 +433,18 @@ class TestHttps11Base(TestHttp11Base): ) @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( settings_dict={"DOWNLOADER_CLIENT_TLS_VERBOSE_LOGGING": True} ) download_handler = build_from_crawler(self.download_handler_cls, crawler) try: 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( download_handler.download_request(request, DefaultSpider()) ) - assert response.body == b"0123456789" + assert response.body == b"Works" log_capture.check_present( ("scrapy.core.downloader.tls", "DEBUG", self.tls_log_message) ) @@ -577,31 +462,23 @@ class TestSimpleHttpsBase(ABC): host = "localhost" 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 @abstractmethod def download_handler_cls(self) -> type[DownloadHandlerProtocol]: 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 def download_handler(self) -> AsyncGenerator[DownloadHandlerProtocol]: if self.cipher_string is not None: @@ -615,14 +492,11 @@ class TestSimpleHttpsBase(ABC): await close_dh(dh) - def getURL(self, portno: int, path: str) -> str: - return f"https://{self.host}:{portno}/{path}" - @deferred_f_from_coro_f async def test_download( - self, server_port: int, download_handler: DownloadHandlerProtocol + self, url: str, download_handler: DownloadHandlerProtocol ) -> None: - request = Request(self.getURL(server_port, "file")) + request = Request(url) response = await download_request(download_handler, request) assert response.body == b"0123456789" @@ -654,9 +528,7 @@ class TestHttpsCustomCiphersBase(TestSimpleHttpsBase): cipher_string = "CAMELLIA256-SHA" -class TestHttpMockServerBase(ABC): - """HTTP 1.1 test case with MockServer""" - +class TestHttpWithCrawlerBase(ABC): @property @abstractmethod def settings_dict(self) -> dict[str, Any] | None: @@ -664,63 +536,40 @@ class TestHttpMockServerBase(ABC): 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 - 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) # http://localhost:8998/partial set Content-Length to 1024, use download_maxsize= 1000 to avoid # download it await maybe_deferred_to_future( crawler.crawl( 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}, ) ) ) - failure = crawler.spider.meta["failure"] + assert crawler.spider + failure = crawler.spider.meta["failure"] # type: ignore[attr-defined] assert isinstance(failure.value, defer.CancelledError) @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) await maybe_deferred_to_future( 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 - reason = crawler.spider.meta["close_reason"] + reason = crawler.spider.meta["close_reason"] # type: ignore[attr-defined] 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): - scheme = "http" - host = "127.0.0.1" + is_secure = False expected_http_proxy_request_body = b"http://example.com" @property @@ -728,17 +577,10 @@ class TestHttpProxyBase(ABC): def download_handler_cls(self) -> type[DownloadHandlerProtocol]: raise NotImplementedError - @async_yield_fixture - async def server_port(self) -> AsyncGenerator[int]: - from twisted.internet import reactor - - site = server.Site(UriResource(), timeout=None) - wrapper = WrappingFactory(site) - port = reactor.listenTCP(0, wrapper, interface=self.host) - - yield port.getHost().port - - await port.stopListening() + @pytest.fixture(scope="session") + def proxy_mockserver(self) -> Generator[ProxyEchoMockServer]: + with ProxyEchoMockServer() as proxy: + yield proxy @async_yield_fixture async def download_handler(self) -> AsyncGenerator[DownloadHandlerProtocol]: @@ -748,14 +590,13 @@ class TestHttpProxyBase(ABC): 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 async def test_download_with_proxy( - self, server_port: int, download_handler: DownloadHandlerProtocol + self, + proxy_mockserver: ProxyEchoMockServer, + download_handler: DownloadHandlerProtocol, ) -> 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}) response = await download_request(download_handler, request) assert response.status == 200 @@ -764,9 +605,13 @@ class TestHttpProxyBase(ABC): @deferred_f_from_coro_f async def test_download_without_proxy( - self, server_port: int, download_handler: DownloadHandlerProtocol + self, + proxy_mockserver: ProxyEchoMockServer, + download_handler: DownloadHandlerProtocol, ) -> 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) assert response.status == 200 assert response.url == request.url @@ -774,11 +619,13 @@ class TestHttpProxyBase(ABC): @deferred_f_from_coro_f async def test_download_with_proxy_https_timeout( - self, server_port: int, download_handler: DownloadHandlerProtocol + self, + proxy_mockserver: ProxyEchoMockServer, + download_handler: DownloadHandlerProtocol, ) -> None: if NON_EXISTING_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" request = Request(domain, meta={"proxy": http_proxy, "download_timeout": 0.2}) with pytest.raises(error.TimeoutError) as exc_info: @@ -787,9 +634,11 @@ class TestHttpProxyBase(ABC): @deferred_f_from_coro_f async def test_download_with_proxy_without_http_scheme( - self, server_port: int, download_handler: DownloadHandlerProtocol + self, + proxy_mockserver: ProxyEchoMockServer, + download_handler: DownloadHandlerProtocol, ) -> 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}) response = await download_request(download_handler, request) assert response.status == 200 diff --git a/tests/test_downloaderslotssettings.py b/tests/test_downloaderslotssettings.py index 0d9500464..55a67d6d2 100644 --- a/tests/test_downloaderslotssettings.py +++ b/tests/test_downloaderslotssettings.py @@ -6,7 +6,7 @@ from scrapy import Request from scrapy.core.downloader import Downloader, Slot from scrapy.crawler import CrawlerRunner from scrapy.utils.test import get_crawler -from tests.mockserver import MockServer +from tests.mockserver.http import MockServer from tests.spiders import MetaSpider diff --git a/tests/test_engine.py b/tests/test_engine.py index ecb615f61..9590859bb 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -16,7 +16,6 @@ import sys from collections import defaultdict from dataclasses import dataclass from logging import DEBUG -from pathlib import Path from unittest.mock import Mock from urllib.parse import urlparse @@ -27,7 +26,6 @@ from pydispatch import dispatcher from testfixtures import LogCapture from twisted.internet import defer from twisted.internet.defer import inlineCallbacks -from twisted.web import server, static, util from scrapy import signals from scrapy.core.engine import ExecutionEngine, _Slot @@ -38,9 +36,11 @@ from scrapy.item import Field, Item from scrapy.linkextractors import LinkExtractor from scrapy.signals import request_scheduled 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.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): @@ -65,7 +65,6 @@ class DataClassItem: class MySpider(Spider): name = "scrapytest.org" - allowed_domains = ["scrapytest.org", "localhost"] itemurl_re = re.compile(r"item\d+.html") name_re = re.compile(r"

(.*?)

", re.MULTILINE) @@ -130,25 +129,6 @@ class ChangeCloseReasonSpider(MySpider): 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: """A class to run the crawler and keep track of events occurred""" @@ -164,12 +144,11 @@ class CrawlerRun: self.signals_caught = {} self.spider_class = spider_class - def run(self): - self.port = start_test_site() - self.portno = self.port.getHost().port + async def run(self, mockserver: MockServer) -> None: + self.mockserver = mockserver start_urls = [ - self.geturl("/"), + self.geturl("/static/"), self.geturl("/redirect"), self.geturl("/redirect"), # duplicate self.geturl("/numbers"), @@ -194,20 +173,19 @@ class CrawlerRun: ) 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) - return self.deferred + await maybe_deferred_to_future(self.deferred) def stop(self): - self.port.stopListening() # FIXME: wait for this Deferred for name, signal in vars(signals).items(): if not name.startswith("_"): disconnect_all(signal) self.deferred.callback(None) return self.crawler.stop() - def geturl(self, path): - return f"http://localhost:{self.portno}{path}" + def geturl(self, path: str) -> str: + return self.mockserver.url(path) def getpath(self, url): u = urlparse(url) @@ -249,12 +227,12 @@ class TestEngineBase: @staticmethod def _assert_visited_urls(run: CrawlerRun) -> None: must_be_visited = [ - "/", + "/static/", "/redirect", "/redirected", - "/item1.html", - "/item2.html", - "/item999.html", + "/static/item1.html", + "/static/item2.html", + "/static/item999.html", ] urls_visited = {rp[0].url for rp in run.respplug} 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: 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_expected = {run.geturl(p) for p in paths_expected} @@ -288,7 +270,7 @@ class TestEngineBase: assert len(run.reqreached) == count for response, _ in run.respplug: - if run.getpath(response.url) == "/item999.html": + if run.getpath(response.url) == "/static/item999.html": assert response.status == 404 if run.getpath(response.url) == "/redirect": assert response.status == 302 @@ -334,11 +316,11 @@ class TestEngineBase: assert len(run.bytes) == 9 for request, data in run.bytes.items(): 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") - elif run.getpath(request.url) == "/item1.html": + elif run.getpath(request.url) == "/static/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") elif run.getpath(request.url) == "/redirected": assert joined_data == b"Redirected here" @@ -353,7 +335,7 @@ class TestEngineBase: b" \n" b"\n" ) - elif run.getpath(request.url) == "/tem999.html": + elif run.getpath(request.url) == "/static/item999.html": assert ( joined_data == b"\n\n" b" 404 - No Such Resource\n" @@ -390,8 +372,8 @@ class TestEngineBase: class TestEngine(TestEngineBase): - @inlineCallbacks - def test_crawler(self): + @deferred_f_from_coro_f + async def test_crawler(self, mockserver: MockServer) -> None: for spider in ( MySpider, DictItemsSpider, @@ -399,7 +381,7 @@ class TestEngine(TestEngineBase): DataClassItemsSpider, ): run = CrawlerRun(spider) - yield run.run() + await run.run(mockserver) self._assert_visited_urls(run) self._assert_scheduled_requests(run, count=9) self._assert_downloaded_responses(run, count=9) @@ -407,23 +389,25 @@ class TestEngine(TestEngineBase): self._assert_signals_caught(run) self._assert_bytes_received(run) - @inlineCallbacks - def test_crawler_dupefilter(self): + @deferred_f_from_coro_f + async def test_crawler_dupefilter(self, mockserver: MockServer) -> None: run = CrawlerRun(DupeFilterSpider) - yield run.run() + await run.run(mockserver) self._assert_scheduled_requests(run, count=8) self._assert_dropped_requests(run) - @inlineCallbacks - def test_crawler_itemerror(self): + @deferred_f_from_coro_f + async def test_crawler_itemerror(self, mockserver: MockServer) -> None: run = CrawlerRun(ItemZeroDivisionErrorSpider) - yield run.run() + await run.run(mockserver) self._assert_items_error(run) - @inlineCallbacks - def test_crawler_change_close_reason_on_idle(self): + @deferred_f_from_coro_f + async def test_crawler_change_close_reason_on_idle( + self, mockserver: MockServer + ) -> None: run = CrawlerRun(ChangeCloseReasonSpider) - yield run.run() + await run.run(mockserver) assert { "spider": run.crawler.spider, "reason": "custom_reason", @@ -539,11 +523,3 @@ def test_request_scheduled_signal(caplog): f"{scheduler.enqueued!r} != [{keep_request!r}]" ) 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() diff --git a/tests/test_engine_loop.py b/tests/test_engine_loop.py index 49a800fe2..2bb5d2ff4 100644 --- a/tests/test_engine_loop.py +++ b/tests/test_engine_loop.py @@ -10,9 +10,8 @@ from twisted.internet.defer import Deferred from scrapy import Request, Spider, signals from scrapy.utils.defer import deferred_f_from_coro_f, maybe_deferred_to_future from scrapy.utils.test import get_crawler - -from .mockserver import MockServer -from .test_scheduler import MemoryScheduler +from tests.mockserver.http import MockServer +from tests.test_scheduler import MemoryScheduler if TYPE_CHECKING: from scrapy.http import Response diff --git a/tests/test_engine_stop_download_bytes.py b/tests/test_engine_stop_download_bytes.py index 2662e45e1..1d7df70eb 100644 --- a/tests/test_engine_stop_download_bytes.py +++ b/tests/test_engine_stop_download_bytes.py @@ -1,7 +1,11 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + from testfixtures import LogCapture -from twisted.internet.defer import inlineCallbacks from scrapy.exceptions import StopDownload +from scrapy.utils.defer import deferred_f_from_coro_f from tests.test_engine import ( AttrsItemsSpider, CrawlerRun, @@ -11,6 +15,9 @@ from tests.test_engine import ( TestEngineBase, ) +if TYPE_CHECKING: + from tests.mockserver.http import MockServer + class BytesReceivedCrawlerRun(CrawlerRun): def bytes_received(self, data, request, spider): @@ -19,8 +26,8 @@ class BytesReceivedCrawlerRun(CrawlerRun): class TestBytesReceivedEngine(TestEngineBase): - @inlineCallbacks - def test_crawler(self): + @deferred_f_from_coro_f + async def test_crawler(self, mockserver: MockServer) -> None: for spider in ( MySpider, DictItemsSpider, @@ -29,12 +36,12 @@ class TestBytesReceivedEngine(TestEngineBase): ): run = BytesReceivedCrawlerRun(spider) with LogCapture() as log: - yield run.run() + await run.run(mockserver) log.check_present( ( "scrapy.core.downloader.handlers.http11", "DEBUG", - f"Download stopped for " + f"Download stopped for " "from signal handler BytesReceivedCrawlerRun.bytes_received", ) ) @@ -42,7 +49,7 @@ class TestBytesReceivedEngine(TestEngineBase): ( "scrapy.core.downloader.handlers.http11", "DEBUG", - f"Download stopped for " + f"Download stopped for " "from signal handler BytesReceivedCrawlerRun.bytes_received", ) ) @@ -50,7 +57,7 @@ class TestBytesReceivedEngine(TestEngineBase): ( "scrapy.core.downloader.handlers.http11", "DEBUG", - f"Download stopped for " + f"Download stopped for " "from signal handler BytesReceivedCrawlerRun.bytes_received", ) ) diff --git a/tests/test_engine_stop_download_headers.py b/tests/test_engine_stop_download_headers.py index 142715927..c01413d4e 100644 --- a/tests/test_engine_stop_download_headers.py +++ b/tests/test_engine_stop_download_headers.py @@ -1,7 +1,11 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + from testfixtures import LogCapture -from twisted.internet.defer import inlineCallbacks from scrapy.exceptions import StopDownload +from scrapy.utils.defer import deferred_f_from_coro_f from tests.test_engine import ( AttrsItemsSpider, CrawlerRun, @@ -11,6 +15,9 @@ from tests.test_engine import ( TestEngineBase, ) +if TYPE_CHECKING: + from tests.mockserver.http import MockServer + class HeadersReceivedCrawlerRun(CrawlerRun): def headers_received(self, headers, body_length, request, spider): @@ -19,8 +26,8 @@ class HeadersReceivedCrawlerRun(CrawlerRun): class TestHeadersReceivedEngine(TestEngineBase): - @inlineCallbacks - def test_crawler(self): + @deferred_f_from_coro_f + async def test_crawler(self, mockserver: MockServer) -> None: for spider in ( MySpider, DictItemsSpider, @@ -29,12 +36,12 @@ class TestHeadersReceivedEngine(TestEngineBase): ): run = HeadersReceivedCrawlerRun(spider) with LogCapture() as log: - yield run.run() + await run.run(mockserver) log.check_present( ( "scrapy.core.downloader.handlers.http11", "DEBUG", - f"Download stopped for from" + f"Download stopped for from" " signal handler HeadersReceivedCrawlerRun.headers_received", ) ) @@ -42,7 +49,7 @@ class TestHeadersReceivedEngine(TestEngineBase): ( "scrapy.core.downloader.handlers.http11", "DEBUG", - f"Download stopped for from signal" + f"Download stopped for from signal" " handler HeadersReceivedCrawlerRun.headers_received", ) ) @@ -50,7 +57,7 @@ class TestHeadersReceivedEngine(TestEngineBase): ( "scrapy.core.downloader.handlers.http11", "DEBUG", - f"Download stopped for from" + f"Download stopped for from" " signal handler HeadersReceivedCrawlerRun.headers_received", ) ) @@ -66,7 +73,7 @@ class TestHeadersReceivedEngine(TestEngineBase): @staticmethod 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_expected = {run.geturl(p) for p in must_be_visited} assert urls_expected <= urls_visited, ( diff --git a/tests/test_feedexport.py b/tests/test_feedexport.py index 309466b90..c5bd1b172 100644 --- a/tests/test_feedexport.py +++ b/tests/test_feedexport.py @@ -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.python import to_unicode 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 if TYPE_CHECKING: diff --git a/tests/test_http2_client_protocol.py b/tests/test_http2_client_protocol.py index 77d328333..3247da61b 100644 --- a/tests/test_http2_client_protocol.py +++ b/tests/test_http2_client_protocol.py @@ -35,7 +35,8 @@ from scrapy.utils.defer import ( deferred_from_coro, 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: from collections.abc import AsyncGenerator, Coroutine, Generator diff --git a/tests/test_logformatter.py b/tests/test_logformatter.py index 4cc48ca51..f21e52e52 100644 --- a/tests/test_logformatter.py +++ b/tests/test_logformatter.py @@ -11,7 +11,7 @@ from scrapy.item import Field, Item from scrapy.logformatter import LogFormatter from scrapy.spiders import Spider from scrapy.utils.test import get_crawler -from tests.mockserver import MockServer +from tests.mockserver.http import MockServer from tests.spiders import ItemSpider diff --git a/tests/test_pipeline_crawl.py b/tests/test_pipeline_crawl.py index 5f758a763..494833fe1 100644 --- a/tests/test_pipeline_crawl.py +++ b/tests/test_pipeline_crawl.py @@ -13,7 +13,7 @@ from w3lib.url import add_or_replace_parameter from scrapy import Spider, signals from scrapy.utils.misc import load_object from scrapy.utils.test import get_crawler -from tests.mockserver import MockServer +from tests.mockserver.http import MockServer from tests.spiders import SimpleSpider if TYPE_CHECKING: @@ -149,7 +149,7 @@ class TestFileDownloadCrawl: crawler = self._create_crawler(MediaDownloadSpider) with LogCapture() as log: yield crawler.crawl( - self.mockserver.url("/files/images/"), + self.mockserver.url("/static/files/images/"), media_key=self.media_key, media_urls_key=self.media_urls_key, ) @@ -160,7 +160,7 @@ class TestFileDownloadCrawl: crawler = self._create_crawler(BrokenLinksMediaDownloadSpider) with LogCapture() as log: yield crawler.crawl( - self.mockserver.url("/files/images/"), + self.mockserver.url("/static/files/images/"), media_key=self.media_key, media_urls_key=self.media_urls_key, ) @@ -171,7 +171,7 @@ class TestFileDownloadCrawl: crawler = self._create_crawler(RedirectedMediaDownloadSpider) with LogCapture() as log: yield crawler.crawl( - self.mockserver.url("/files/images/"), + self.mockserver.url("/static/files/images/"), media_key=self.media_key, media_urls_key=self.media_urls_key, mockserver=self.mockserver, @@ -187,7 +187,7 @@ class TestFileDownloadCrawl: crawler = self._create_crawler(RedirectedMediaDownloadSpider, settings) with LogCapture() as log: yield crawler.crawl( - self.mockserver.url("/files/images/"), + self.mockserver.url("/static/files/images/"), media_key=self.media_key, media_urls_key=self.media_urls_key, mockserver=self.mockserver, @@ -210,7 +210,7 @@ class TestFileDownloadCrawl: crawler = self._create_crawler(MediaDownloadSpider, settings) with LogCapture() as log: yield crawler.crawl( - self.mockserver.url("/files/images/"), + self.mockserver.url("/static/files/images/"), media_key=self.media_key, media_urls_key=self.media_urls_key, mockserver=self.mockserver, diff --git a/tests/test_pipeline_files.py b/tests/test_pipeline_files.py index 424236401..a4cdaa5f8 100644 --- a/tests/test_pipeline_files.py +++ b/tests/test_pipeline_files.py @@ -30,7 +30,7 @@ from scrapy.pipelines.files import ( S3FilesStore, ) 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 diff --git a/tests/test_pipelines.py b/tests/test_pipelines.py index 4df9495a5..064033f01 100644 --- a/tests/test_pipelines.py +++ b/tests/test_pipelines.py @@ -6,7 +6,7 @@ from twisted.internet.defer import Deferred, inlineCallbacks from scrapy import Request, Spider, signals from scrapy.utils.defer import deferred_to_future, maybe_deferred_to_future from scrapy.utils.test import get_crawler, get_from_asyncio_queue -from tests.mockserver import MockServer +from tests.mockserver.http import MockServer class SimplePipeline: diff --git a/tests/test_proxy_connect.py b/tests/test_proxy_connect.py index 400dfa4ba..f9874f821 100644 --- a/tests/test_proxy_connect.py +++ b/tests/test_proxy_connect.py @@ -12,7 +12,7 @@ from twisted.internet.defer import inlineCallbacks from scrapy.http import Request from scrapy.utils.test import get_crawler -from tests.mockserver import MockServer +from tests.mockserver.http import MockServer from tests.spiders import SimpleSpider, SingleRequestSpider diff --git a/tests/test_request_attribute_binding.py b/tests/test_request_attribute_binding.py index c0606ac35..de0f0d895 100644 --- a/tests/test_request_attribute_binding.py +++ b/tests/test_request_attribute_binding.py @@ -4,7 +4,7 @@ from twisted.internet.defer import inlineCallbacks from scrapy import Request, signals from scrapy.http.response import Response from scrapy.utils.test import get_crawler -from tests.mockserver import MockServer +from tests.mockserver.http import MockServer from tests.spiders import SingleRequestSpider OVERRIDDEN_URL = "https://example.org" diff --git a/tests/test_request_cb_kwargs.py b/tests/test_request_cb_kwargs.py index 34a07a3a1..041fc9588 100644 --- a/tests/test_request_cb_kwargs.py +++ b/tests/test_request_cb_kwargs.py @@ -3,7 +3,7 @@ from twisted.internet.defer import inlineCallbacks from scrapy.http import Request from scrapy.utils.test import get_crawler -from tests.mockserver import MockServer +from tests.mockserver.http import MockServer from tests.spiders import MockServerSpider diff --git a/tests/test_request_left.py b/tests/test_request_left.py index 451125932..721bfc76e 100644 --- a/tests/test_request_left.py +++ b/tests/test_request_left.py @@ -3,7 +3,7 @@ from twisted.internet.defer import inlineCallbacks from scrapy.signals import request_left_downloader from scrapy.spiders import Spider from scrapy.utils.test import get_crawler -from tests.mockserver import MockServer +from tests.mockserver.http import MockServer class SignalCatcherSpider(Spider): diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index 9aed270e3..ef03771c3 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -18,7 +18,7 @@ from scrapy.spiders import Spider from scrapy.utils.httpobj import urlparse_cached from scrapy.utils.misc import load_object from scrapy.utils.test import get_crawler -from tests.mockserver import MockServer +from tests.mockserver.http import MockServer class MemoryScheduler(BaseScheduler): diff --git a/tests/test_scheduler_base.py b/tests/test_scheduler_base.py index f85a754c2..5d31a9886 100644 --- a/tests/test_scheduler_base.py +++ b/tests/test_scheduler_base.py @@ -13,7 +13,7 @@ from scrapy.spiders import Spider from scrapy.utils.httpobj import urlparse_cached from scrapy.utils.request import fingerprint from scrapy.utils.test import get_crawler -from tests.mockserver import MockServer +from tests.mockserver.http import MockServer PATHS = ["/a", "/b", "/c"] URLS = [urljoin("https://example.org", p) for p in PATHS] diff --git a/tests/test_signals.py b/tests/test_signals.py index dcbd0fb35..2e4f9ffb1 100644 --- a/tests/test_signals.py +++ b/tests/test_signals.py @@ -4,7 +4,7 @@ from twisted.internet.defer import inlineCallbacks from scrapy import Request, Spider, signals 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 tests.mockserver import MockServer +from tests.mockserver.http import MockServer class ItemSpider(Spider): diff --git a/tests/test_spidermiddleware_httperror.py b/tests/test_spidermiddleware_httperror.py index 5289335e3..b41ae986e 100644 --- a/tests/test_spidermiddleware_httperror.py +++ b/tests/test_spidermiddleware_httperror.py @@ -11,7 +11,7 @@ from scrapy.settings import Settings from scrapy.spidermiddlewares.httperror import HttpError, HttpErrorMiddleware from scrapy.spiders import Spider from scrapy.utils.test import get_crawler -from tests.mockserver import MockServer +from tests.mockserver.http import MockServer from tests.spiders import MockServerSpider diff --git a/tests/test_spidermiddleware_output_chain.py b/tests/test_spidermiddleware_output_chain.py index d8af25b3a..3f2b2057b 100644 --- a/tests/test_spidermiddleware_output_chain.py +++ b/tests/test_spidermiddleware_output_chain.py @@ -3,7 +3,7 @@ from testfixtures import LogCapture from scrapy import Request, Spider from scrapy.utils.defer import deferred_f_from_coro_f, maybe_deferred_to_future from scrapy.utils.test import get_crawler -from tests.mockserver import MockServer +from tests.mockserver.http import MockServer class LogExceptionMiddleware: diff --git a/tests/test_webclient.py b/tests/test_webclient.py index d441a03a9..963cb606e 100644 --- a/tests/test_webclient.py +++ b/tests/test_webclient.py @@ -22,15 +22,12 @@ from scrapy.http import Headers, Request from scrapy.utils.misc import build_from_crawler from scrapy.utils.python import to_bytes, to_unicode from scrapy.utils.test import get_crawler -from tests.mockserver import ( - BrokenDownloadResource, - ErrorResource, +from tests.mockserver.http_resources import ( ForeverTakingResource, HostHeaderResource, - NoLengthResource, PayloadResource, - ssl_context_factory, ) +from tests.mockserver.utils import ssl_context_factory from tests.test_core_downloader import TestContextFactoryBase @@ -195,6 +192,27 @@ class EncodingResource(resource.Resource): 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") class TestWebClient: def _listen(self, site): diff --git a/tests/utils/__init__.py b/tests/utils/__init__.py index e5e56f414..a81f2d1a2 100644 --- a/tests/utils/__init__.py +++ b/tests/utils/__init__.py @@ -1,3 +1,6 @@ +import os +from pathlib import Path + from twisted.internet.defer import Deferred @@ -7,3 +10,13 @@ def twisted_sleep(seconds): d = Deferred() reactor.callLater(seconds, d.callback, None) 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