This commit is contained in:
2024-12-17 14:36:15 -08:00
parent b2dbf46d28
commit 06d106de53
17731 changed files with 3037186 additions and 144 deletions

View File

@@ -0,0 +1,7 @@
# -*- test-case-name: twisted.application.twist.test -*-
# Copyright (c) Twisted Matrix Laboratories.
# See LICENSE for details.
"""
Tests for L{twisted.application.twist}.
"""

View File

@@ -0,0 +1,355 @@
# Copyright (c) Twisted Matrix Laboratories.
# See LICENSE for details.
"""
Tests for L{twisted.application.twist._options}.
"""
from sys import stderr, stdout
from typing import Callable, Dict, List, Optional, TextIO, Tuple
import twisted.trial.unittest
from twisted.copyright import version
from twisted.internet import reactor
from twisted.internet.interfaces import IReactorCore
from twisted.internet.testing import MemoryReactor
from twisted.logger import (
FileLogObserver,
LogLevel,
jsonFileLogObserver,
textFileLogObserver,
)
from twisted.python.usage import UsageError
from ...reactors import NoSuchReactor
from ...runner._exit import ExitStatus
from ...runner.test.test_runner import DummyExit
from ...service import ServiceMaker
from ...twist import _options
from .._options import TwistOptions
class OptionsTests(twisted.trial.unittest.TestCase):
"""
Tests for L{TwistOptions}.
"""
def patchExit(self) -> None:
"""
Patch L{_twist.exit} so we can capture usage and prevent actual exits.
"""
self.exit = DummyExit()
self.patch(_options, "exit", self.exit)
def patchOpen(self) -> None:
"""
Patch L{_options.open} so we can capture usage and prevent actual opens.
"""
self.opened: List[Tuple[str, Optional[str]]] = []
def fakeOpen(name: str, mode: Optional[str] = None) -> TextIO:
if name == "nocanopen":
raise OSError(None, None, name)
self.opened.append((name, mode))
return NotImplemented
self.patch(_options, "openFile", fakeOpen)
def patchInstallReactor(self) -> None:
"""
Patch C{_options.installReactor} so we can capture usage and prevent
actual installs.
"""
self.installedReactors: Dict[str, IReactorCore] = {}
def installReactor(name: str) -> IReactorCore:
if name != "fusion":
raise NoSuchReactor()
reactor = MemoryReactor()
self.installedReactors[name] = reactor
return reactor
self.patch(_options, "installReactor", installReactor)
def test_synopsis(self) -> None:
"""
L{TwistOptions.getSynopsis} appends arguments.
"""
options = TwistOptions()
self.assertTrue(options.getSynopsis().endswith(" plugin [plugin_options]"))
def test_version(self) -> None:
"""
L{TwistOptions.opt_version} exits with L{ExitStatus.EX_OK} and prints
the version.
"""
self.patchExit()
options = TwistOptions()
options.opt_version()
self.assertEquals(self.exit.status, ExitStatus.EX_OK) # type: ignore[unreachable]
self.assertEquals(self.exit.message, version)
def test_reactor(self) -> None:
"""
L{TwistOptions.installReactor} installs the chosen reactor and sets
the reactor name.
"""
self.patchInstallReactor()
options = TwistOptions()
options.opt_reactor("fusion")
self.assertEqual(set(self.installedReactors), {"fusion"})
self.assertEquals(options["reactorName"], "fusion")
def test_installCorrectReactor(self) -> None:
"""
L{TwistOptions.installReactor} installs the chosen reactor after the
command line options have been parsed.
"""
self.patchInstallReactor()
options = TwistOptions()
options.subCommand = "test-subcommand"
options.parseOptions(["--reactor=fusion"])
self.assertEqual(set(self.installedReactors), {"fusion"})
def test_installReactorBogus(self) -> None:
"""
L{TwistOptions.installReactor} raises UsageError if an unknown reactor
is specified.
"""
self.patchInstallReactor()
options = TwistOptions()
self.assertRaises(UsageError, options.opt_reactor, "coal")
def test_installReactorDefault(self) -> None:
"""
L{TwistOptions.installReactor} returns the currently installed reactor
when the default reactor name is specified.
"""
options = TwistOptions()
self.assertIdentical(reactor, options.installReactor("default"))
def test_logLevelValid(self) -> None:
"""
L{TwistOptions.opt_log_level} sets the corresponding log level.
"""
options = TwistOptions()
options.opt_log_level("warn")
self.assertIdentical(options["logLevel"], LogLevel.warn)
def test_logLevelInvalid(self) -> None:
"""
L{TwistOptions.opt_log_level} with an invalid log level name raises
UsageError.
"""
options = TwistOptions()
self.assertRaises(UsageError, options.opt_log_level, "cheese")
def _testLogFile(self, name: str, expectedStream: TextIO) -> None:
"""
Set log file name and check the selected output stream.
@param name: The name of the file.
@param expectedStream: The expected stream.
"""
options = TwistOptions()
options.opt_log_file(name)
self.assertIdentical(options["logFile"], expectedStream)
def test_logFileStdout(self) -> None:
"""
L{TwistOptions.opt_log_file} given C{"-"} as a file name uses stdout.
"""
self._testLogFile("-", stdout)
def test_logFileStderr(self) -> None:
"""
L{TwistOptions.opt_log_file} given C{"+"} as a file name uses stderr.
"""
self._testLogFile("+", stderr)
def test_logFileNamed(self) -> None:
"""
L{TwistOptions.opt_log_file} opens the given file name in append mode.
"""
self.patchOpen()
options = TwistOptions()
options.opt_log_file("mylog")
self.assertEqual([("mylog", "a")], self.opened)
def test_logFileCantOpen(self) -> None:
"""
L{TwistOptions.opt_log_file} exits with L{ExitStatus.EX_IOERR} if
unable to open the log file due to an L{EnvironmentError}.
"""
self.patchExit()
self.patchOpen()
options = TwistOptions()
options.opt_log_file("nocanopen")
self.assertEquals(self.exit.status, ExitStatus.EX_IOERR)
self.assertIsNotNone(self.exit.message)
self.assertTrue(
self.exit.message.startswith( # type: ignore[union-attr]
"Unable to open log file 'nocanopen': "
)
)
def _testLogFormat(
self, format: str, expectedObserverFactory: Callable[[TextIO], FileLogObserver]
) -> None:
"""
Set log file format and check the selected observer factory.
@param format: The format of the file.
@param expectedObserverFactory: The expected observer factory.
"""
options = TwistOptions()
options.opt_log_format(format)
self.assertIdentical(options["fileLogObserverFactory"], expectedObserverFactory)
self.assertEqual(options["logFormat"], format)
def test_logFormatText(self) -> None:
"""
L{TwistOptions.opt_log_format} given C{"text"} uses a
L{textFileLogObserver}.
"""
self._testLogFormat("text", textFileLogObserver)
def test_logFormatJSON(self) -> None:
"""
L{TwistOptions.opt_log_format} given C{"text"} uses a
L{textFileLogObserver}.
"""
self._testLogFormat("json", jsonFileLogObserver)
def test_logFormatInvalid(self) -> None:
"""
L{TwistOptions.opt_log_format} given an invalid format name raises
L{UsageError}.
"""
options = TwistOptions()
self.assertRaises(UsageError, options.opt_log_format, "frommage")
def test_selectDefaultLogObserverNoOverride(self) -> None:
"""
L{TwistOptions.selectDefaultLogObserver} will not override an already
selected observer.
"""
self.patchOpen()
options = TwistOptions()
options.opt_log_format("text") # Ask for text
options.opt_log_file("queso") # File, not a tty
options.selectDefaultLogObserver()
# Because we didn't select a file that is a tty, the default is JSON,
# but since we asked for text, we should get text.
self.assertIdentical(options["fileLogObserverFactory"], textFileLogObserver)
self.assertEqual(options["logFormat"], "text")
def test_selectDefaultLogObserverDefaultWithTTY(self) -> None:
"""
L{TwistOptions.selectDefaultLogObserver} will not override an already
selected observer.
"""
class TTYFile:
def isatty(self) -> bool:
return True
# stdout may not be a tty, so let's make sure it thinks it is
self.patch(_options, "stdout", TTYFile())
options = TwistOptions()
options.opt_log_file("-") # stdout, a tty
options.selectDefaultLogObserver()
self.assertIdentical(options["fileLogObserverFactory"], textFileLogObserver)
self.assertEqual(options["logFormat"], "text")
def test_selectDefaultLogObserverDefaultWithoutTTY(self) -> None:
"""
L{TwistOptions.selectDefaultLogObserver} will not override an already
selected observer.
"""
self.patchOpen()
options = TwistOptions()
options.opt_log_file("queso") # File, not a tty
options.selectDefaultLogObserver()
self.assertIdentical(options["fileLogObserverFactory"], jsonFileLogObserver)
self.assertEqual(options["logFormat"], "json")
def test_pluginsType(self) -> None:
"""
L{TwistOptions.plugins} is a mapping of available plug-ins.
"""
options = TwistOptions()
plugins = options.plugins
for name in plugins:
self.assertIsInstance(name, str)
self.assertIsInstance(plugins[name], ServiceMaker)
def test_pluginsIncludeWeb(self) -> None:
"""
L{TwistOptions.plugins} includes a C{"web"} plug-in.
This is an attempt to verify that something we expect to be in the list
is in there without enumerating all of the built-in plug-ins.
"""
options = TwistOptions()
self.assertIn("web", options.plugins)
def test_subCommandsType(self) -> None:
"""
L{TwistOptions.subCommands} is an iterable of tuples as expected by
L{twisted.python.usage.Options}.
"""
options = TwistOptions()
for name, shortcut, parser, doc in options.subCommands:
self.assertIsInstance(name, str)
self.assertIdentical(shortcut, None)
self.assertTrue(callable(parser))
self.assertIsInstance(doc, str)
def test_subCommandsIncludeWeb(self) -> None:
"""
L{TwistOptions.subCommands} includes a sub-command for every plug-in.
"""
options = TwistOptions()
plugins = set(options.plugins)
subCommands = {name for name, shortcut, parser, doc in options.subCommands}
self.assertEqual(subCommands, plugins)
def test_postOptionsNoSubCommand(self) -> None:
"""
L{TwistOptions.postOptions} raises L{UsageError} is it has no
sub-command.
"""
self.patchInstallReactor()
options = TwistOptions()
self.assertRaises(UsageError, options.postOptions)

View File

@@ -0,0 +1,256 @@
# Copyright (c) Twisted Matrix Laboratories.
# See LICENSE for details.
"""
Tests for L{twisted.application.twist._twist}.
"""
from sys import stdout
from typing import Any, Dict, List
import twisted.trial.unittest
from twisted.internet.interfaces import IReactorCore
from twisted.internet.testing import MemoryReactor
from twisted.logger import LogLevel, jsonFileLogObserver
from twisted.test.test_twistd import SignalCapturingMemoryReactor
from ...runner._exit import ExitStatus
from ...runner._runner import Runner
from ...runner.test.test_runner import DummyExit
from ...service import IService, MultiService
from ...twist import _twist
from .._options import TwistOptions
from .._twist import Twist
class TwistTests(twisted.trial.unittest.TestCase):
"""
Tests for L{Twist}.
"""
def setUp(self) -> None:
self.patchInstallReactor()
def patchExit(self) -> None:
"""
Patch L{_twist.exit} so we can capture usage and prevent actual exits.
"""
self.exit = DummyExit()
self.patch(_twist, "exit", self.exit)
def patchInstallReactor(self) -> None:
"""
Patch C{_options.installReactor} so we can capture usage and prevent
actual installs.
"""
self.installedReactors: Dict[str, IReactorCore] = {}
def installReactor(_: TwistOptions, name: str) -> IReactorCore:
reactor = MemoryReactor()
self.installedReactors[name] = reactor
return reactor
self.patch(TwistOptions, "installReactor", installReactor)
def patchStartService(self) -> None:
"""
Patch L{MultiService.startService} so we can capture usage and prevent
actual starts.
"""
self.serviceStarts: List[IService] = []
def startService(service: IService) -> None:
self.serviceStarts.append(service)
self.patch(MultiService, "startService", startService)
def test_optionsValidArguments(self) -> None:
"""
L{Twist.options} given valid arguments returns options.
"""
options = Twist.options(["twist", "web"])
self.assertIsInstance(options, TwistOptions)
def test_optionsInvalidArguments(self) -> None:
"""
L{Twist.options} given invalid arguments exits with
L{ExitStatus.EX_USAGE} and an error/usage message.
"""
self.patchExit()
Twist.options(["twist", "--bogus-bagels"])
self.assertIdentical(self.exit.status, ExitStatus.EX_USAGE)
self.assertIsNotNone(self.exit.message)
self.assertTrue(
self.exit.message.startswith("Error: ") # type: ignore[union-attr]
)
self.assertTrue(
self.exit.message.endswith( # type: ignore[union-attr]
f"\n\n{TwistOptions()}"
)
)
def test_service(self) -> None:
"""
L{Twist.service} returns an L{IService}.
"""
options = Twist.options(["twist", "web"]) # web should exist
service = Twist.service(options.plugins["web"], options.subOptions)
self.assertTrue(IService.providedBy(service))
def test_startService(self) -> None:
"""
L{Twist.startService} starts the service and registers a trigger to
stop the service when the reactor shuts down.
"""
options = Twist.options(["twist", "web"])
reactor = options["reactor"]
subCommand = options.subCommand
assert subCommand is not None
service = Twist.service(
plugin=options.plugins[subCommand],
options=options.subOptions,
)
self.patchStartService()
Twist.startService(reactor, service)
self.assertEqual(self.serviceStarts, [service])
self.assertEqual(
reactor.triggers["before"]["shutdown"], [(service.stopService, (), {})]
)
def test_run(self) -> None:
"""
L{Twist.run} runs the runner with arguments corresponding to the given
options.
"""
argsSeen = []
self.patch(Runner, "__init__", lambda self, **args: argsSeen.append(args))
self.patch(Runner, "run", lambda self: None)
twistOptions = Twist.options(
["twist", "--reactor=default", "--log-format=json", "web"]
)
Twist.run(twistOptions)
self.assertEqual(len(argsSeen), 1)
self.assertEqual(
argsSeen[0],
dict(
reactor=self.installedReactors["default"],
defaultLogLevel=LogLevel.info,
logFile=stdout,
fileLogObserverFactory=jsonFileLogObserver,
),
)
def test_main(self) -> None:
"""
L{Twist.main} runs the runner with arguments corresponding to the given
command line arguments.
"""
self.patchStartService()
runners = []
class Runner:
def __init__(self, **kwargs: Any) -> None:
self.args = kwargs
self.runs = 0
runners.append(self)
def run(self) -> None:
self.runs += 1
self.patch(_twist, "Runner", Runner)
Twist.main(["twist", "--reactor=default", "--log-format=json", "web"])
self.assertEqual(len(self.serviceStarts), 1)
self.assertEqual(len(runners), 1)
self.assertEqual(
runners[0].args,
dict(
reactor=self.installedReactors["default"],
defaultLogLevel=LogLevel.info,
logFile=stdout,
fileLogObserverFactory=jsonFileLogObserver,
),
)
self.assertEqual(runners[0].runs, 1)
class TwistExitTests(twisted.trial.unittest.TestCase):
"""
Tests to verify that the Twist script takes the expected actions related
to signals and the reactor.
"""
def setUp(self) -> None:
self.exitWithSignalCalled = False
def fakeExitWithSignal(sig: int) -> None:
"""
Fake to capture whether L{twisted.application._exitWithSignal
was called.
@param sig: Signal value
@type sig: C{int}
"""
self.exitWithSignalCalled = True
self.patch(_twist, "_exitWithSignal", fakeExitWithSignal)
def startLogging(_: Runner) -> None:
"""
Prevent Runner from adding new log observers or other
tests outside this module will fail.
@param _: Unused self param
"""
self.patch(Runner, "startLogging", startLogging)
def test_twistReactorDoesntExitWithSignal(self) -> None:
"""
_exitWithSignal is not called if the reactor's _exitSignal attribute
is zero.
"""
reactor = SignalCapturingMemoryReactor()
reactor._exitSignal = None
options = TwistOptions()
options["reactor"] = reactor
options["fileLogObserverFactory"] = jsonFileLogObserver
Twist.run(options)
self.assertFalse(self.exitWithSignalCalled)
def test_twistReactorHasNoExitSignalAttr(self) -> None:
"""
_exitWithSignal is not called if the runner's reactor does not
implement L{twisted.internet.interfaces._ISupportsExitSignalCapturing}
"""
reactor = MemoryReactor()
options = TwistOptions()
options["reactor"] = reactor
options["fileLogObserverFactory"] = jsonFileLogObserver
Twist.run(options)
self.assertFalse(self.exitWithSignalCalled)
def test_twistReactorExitsWithSignal(self) -> None:
"""
_exitWithSignal is called if the runner's reactor exits due
to a signal.
"""
reactor = SignalCapturingMemoryReactor()
reactor._exitSignal = 2
options = TwistOptions()
options["reactor"] = reactor
options["fileLogObserverFactory"] = jsonFileLogObserver
Twist.run(options)
self.assertTrue(self.exitWithSignalCalled)