RAHHH
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
# -*- test-case-name: twisted.application.twist.test -*-
|
||||
# Copyright (c) Twisted Matrix Laboratories.
|
||||
# See LICENSE for details.
|
||||
|
||||
"""
|
||||
C{twist} command line tool.
|
||||
"""
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,207 @@
|
||||
# -*- test-case-name: twisted.application.twist.test.test_options -*-
|
||||
# Copyright (c) Twisted Matrix Laboratories.
|
||||
# See LICENSE for details.
|
||||
|
||||
"""
|
||||
Command line options for C{twist}.
|
||||
"""
|
||||
|
||||
import typing
|
||||
from sys import stderr, stdout
|
||||
from textwrap import dedent
|
||||
from typing import Callable, Iterable, Mapping, Optional, Sequence, Tuple, cast
|
||||
|
||||
from twisted.copyright import version
|
||||
from twisted.internet.interfaces import IReactorCore
|
||||
from twisted.logger import (
|
||||
InvalidLogLevelError,
|
||||
LogLevel,
|
||||
jsonFileLogObserver,
|
||||
textFileLogObserver,
|
||||
)
|
||||
from twisted.plugin import getPlugins
|
||||
from twisted.python.usage import Options, UsageError
|
||||
from ..reactors import NoSuchReactor, getReactorTypes, installReactor
|
||||
from ..runner._exit import ExitStatus, exit
|
||||
from ..service import IServiceMaker
|
||||
|
||||
openFile = open
|
||||
|
||||
|
||||
def _update_doc(opt: Callable[["TwistOptions", str], None], **kwargs: str) -> None:
|
||||
"""
|
||||
Update the docstring of a method that implements an option.
|
||||
The string is dedented and the given keyword arguments are substituted.
|
||||
"""
|
||||
opt.__doc__ = dedent(opt.__doc__ or "").format(**kwargs)
|
||||
|
||||
|
||||
class TwistOptions(Options):
|
||||
"""
|
||||
Command line options for C{twist}.
|
||||
"""
|
||||
|
||||
defaultReactorName = "default"
|
||||
defaultLogLevel = LogLevel.info
|
||||
|
||||
def __init__(self) -> None:
|
||||
Options.__init__(self)
|
||||
|
||||
self["reactorName"] = self.defaultReactorName
|
||||
self["logLevel"] = self.defaultLogLevel
|
||||
self["logFile"] = stdout
|
||||
# An empty long description is explicitly set here as otherwise
|
||||
# when executing from distributed trial twisted.python.usage will
|
||||
# pull the description from `__main__` which is another entry point.
|
||||
self.longdesc = ""
|
||||
|
||||
def getSynopsis(self) -> str:
|
||||
return f"{Options.getSynopsis(self)} plugin [plugin_options]"
|
||||
|
||||
def opt_version(self) -> "typing.NoReturn":
|
||||
"""
|
||||
Print version and exit.
|
||||
"""
|
||||
exit(ExitStatus.EX_OK, f"{version}")
|
||||
|
||||
def opt_reactor(self, name: str) -> None:
|
||||
"""
|
||||
The name of the reactor to use.
|
||||
(options: {options})
|
||||
"""
|
||||
# Actually actually actually install the reactor right at this very
|
||||
# moment, before any other code (for example, a sub-command plugin)
|
||||
# runs and accidentally imports and installs the default reactor.
|
||||
try:
|
||||
self["reactor"] = self.installReactor(name)
|
||||
except NoSuchReactor:
|
||||
raise UsageError(f"Unknown reactor: {name}")
|
||||
else:
|
||||
self["reactorName"] = name
|
||||
|
||||
_update_doc(
|
||||
opt_reactor,
|
||||
options=", ".join(f'"{rt.shortName}"' for rt in getReactorTypes()),
|
||||
)
|
||||
|
||||
def installReactor(self, name: str) -> IReactorCore:
|
||||
"""
|
||||
Install the reactor.
|
||||
"""
|
||||
if name == self.defaultReactorName:
|
||||
from twisted.internet import reactor
|
||||
|
||||
return cast(IReactorCore, reactor)
|
||||
else:
|
||||
return installReactor(name)
|
||||
|
||||
def opt_log_level(self, levelName: str) -> None:
|
||||
"""
|
||||
Set default log level.
|
||||
(options: {options}; default: "{default}")
|
||||
"""
|
||||
try:
|
||||
self["logLevel"] = LogLevel.levelWithName(levelName)
|
||||
except InvalidLogLevelError:
|
||||
raise UsageError(f"Invalid log level: {levelName}")
|
||||
|
||||
_update_doc(
|
||||
opt_log_level,
|
||||
options=", ".join(
|
||||
f'"{constant.name}"' for constant in LogLevel.iterconstants()
|
||||
),
|
||||
default=defaultLogLevel.name,
|
||||
)
|
||||
|
||||
def opt_log_file(self, fileName: str) -> None:
|
||||
"""
|
||||
Log to file. ("-" for stdout, "+" for stderr; default: "-")
|
||||
"""
|
||||
if fileName == "-":
|
||||
self["logFile"] = stdout
|
||||
return
|
||||
|
||||
if fileName == "+":
|
||||
self["logFile"] = stderr
|
||||
return
|
||||
|
||||
try:
|
||||
self["logFile"] = openFile(fileName, "a")
|
||||
except OSError as e:
|
||||
exit(
|
||||
ExitStatus.EX_IOERR,
|
||||
f"Unable to open log file {fileName!r}: {e}",
|
||||
)
|
||||
|
||||
def opt_log_format(self, format: str) -> None:
|
||||
"""
|
||||
Log file format.
|
||||
(options: "text", "json"; default: "text" if the log file is a tty,
|
||||
otherwise "json")
|
||||
"""
|
||||
format = format.lower()
|
||||
|
||||
if format == "text":
|
||||
self["fileLogObserverFactory"] = textFileLogObserver
|
||||
elif format == "json":
|
||||
self["fileLogObserverFactory"] = jsonFileLogObserver
|
||||
else:
|
||||
raise UsageError(f"Invalid log format: {format}")
|
||||
self["logFormat"] = format
|
||||
|
||||
_update_doc(opt_log_format)
|
||||
|
||||
def selectDefaultLogObserver(self) -> None:
|
||||
"""
|
||||
Set C{fileLogObserverFactory} to the default appropriate for the
|
||||
chosen C{logFile}.
|
||||
"""
|
||||
if "fileLogObserverFactory" not in self:
|
||||
logFile = self["logFile"]
|
||||
|
||||
if hasattr(logFile, "isatty") and logFile.isatty():
|
||||
self["fileLogObserverFactory"] = textFileLogObserver
|
||||
self["logFormat"] = "text"
|
||||
else:
|
||||
self["fileLogObserverFactory"] = jsonFileLogObserver
|
||||
self["logFormat"] = "json"
|
||||
|
||||
def parseOptions(self, options: Optional[Sequence[str]] = None) -> None:
|
||||
self.selectDefaultLogObserver()
|
||||
|
||||
Options.parseOptions(self, options=options)
|
||||
|
||||
if "reactor" not in self:
|
||||
self["reactor"] = self.installReactor(self["reactorName"])
|
||||
|
||||
@property
|
||||
def plugins(self) -> Mapping[str, IServiceMaker]:
|
||||
if "plugins" not in self:
|
||||
plugins = {}
|
||||
for plugin in getPlugins(IServiceMaker):
|
||||
plugins[plugin.tapname] = plugin
|
||||
self["plugins"] = plugins
|
||||
|
||||
return cast(Mapping[str, IServiceMaker], self["plugins"])
|
||||
|
||||
@property
|
||||
def subCommands(
|
||||
self,
|
||||
) -> Iterable[Tuple[str, None, Callable[[IServiceMaker], Options], str]]:
|
||||
plugins = self.plugins
|
||||
for name in sorted(plugins):
|
||||
plugin = plugins[name]
|
||||
|
||||
# Don't pass plugin.options along in order to avoid resolving the
|
||||
# options attribute right away, in case it's a property with a
|
||||
# non-trivial getter (eg, one which imports modules).
|
||||
def options(plugin: IServiceMaker = plugin) -> Options:
|
||||
return cast(Options, plugin.options())
|
||||
|
||||
yield (plugin.tapname, None, options, plugin.description)
|
||||
|
||||
def postOptions(self) -> None:
|
||||
Options.postOptions(self)
|
||||
|
||||
if self.subCommand is None:
|
||||
raise UsageError("No plugin specified.")
|
||||
@@ -0,0 +1,114 @@
|
||||
# -*- test-case-name: twisted.application.twist.test.test_twist -*-
|
||||
# Copyright (c) Twisted Matrix Laboratories.
|
||||
# See LICENSE for details.
|
||||
|
||||
"""
|
||||
Run a Twisted application.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from typing import Sequence
|
||||
|
||||
from twisted.application.app import _exitWithSignal
|
||||
from twisted.internet.interfaces import IReactorCore, _ISupportsExitSignalCapturing
|
||||
from twisted.python.usage import Options, UsageError
|
||||
from ..runner._exit import ExitStatus, exit
|
||||
from ..runner._runner import Runner
|
||||
from ..service import Application, IService, IServiceMaker
|
||||
from ._options import TwistOptions
|
||||
|
||||
|
||||
class Twist:
|
||||
"""
|
||||
Run a Twisted application.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def options(argv: Sequence[str]) -> TwistOptions:
|
||||
"""
|
||||
Parse command line options.
|
||||
|
||||
@param argv: Command line arguments.
|
||||
@return: The parsed options.
|
||||
"""
|
||||
options = TwistOptions()
|
||||
|
||||
try:
|
||||
options.parseOptions(argv[1:])
|
||||
except UsageError as e:
|
||||
exit(ExitStatus.EX_USAGE, f"Error: {e}\n\n{options}")
|
||||
|
||||
return options
|
||||
|
||||
@staticmethod
|
||||
def service(plugin: IServiceMaker, options: Options) -> IService:
|
||||
"""
|
||||
Create the application service.
|
||||
|
||||
@param plugin: The name of the plugin that implements the service
|
||||
application to run.
|
||||
@param options: Options to pass to the application.
|
||||
@return: The created application service.
|
||||
"""
|
||||
service = plugin.makeService(options)
|
||||
application = Application(plugin.tapname)
|
||||
service.setServiceParent(application)
|
||||
|
||||
return IService(application)
|
||||
|
||||
@staticmethod
|
||||
def startService(reactor: IReactorCore, service: IService) -> None:
|
||||
"""
|
||||
Start the application service.
|
||||
|
||||
@param reactor: The reactor to run the service with.
|
||||
@param service: The application service to run.
|
||||
"""
|
||||
service.startService()
|
||||
|
||||
# Ask the reactor to stop the service before shutting down
|
||||
reactor.addSystemEventTrigger("before", "shutdown", service.stopService)
|
||||
|
||||
@staticmethod
|
||||
def run(twistOptions: TwistOptions) -> None:
|
||||
"""
|
||||
Run the application service.
|
||||
|
||||
@param twistOptions: Command line options to convert to runner
|
||||
arguments.
|
||||
"""
|
||||
runner = Runner(
|
||||
reactor=twistOptions["reactor"],
|
||||
defaultLogLevel=twistOptions["logLevel"],
|
||||
logFile=twistOptions["logFile"],
|
||||
fileLogObserverFactory=twistOptions["fileLogObserverFactory"],
|
||||
)
|
||||
runner.run()
|
||||
reactor = twistOptions["reactor"]
|
||||
if _ISupportsExitSignalCapturing.providedBy(reactor):
|
||||
if reactor._exitSignal is not None:
|
||||
_exitWithSignal(reactor._exitSignal)
|
||||
|
||||
@classmethod
|
||||
def main(cls, argv: Sequence[str] = sys.argv) -> None:
|
||||
"""
|
||||
Executable entry point for L{Twist}.
|
||||
Processes options and run a twisted reactor with a service.
|
||||
|
||||
@param argv: Command line arguments.
|
||||
@type argv: L{list}
|
||||
"""
|
||||
options = cls.options(argv)
|
||||
|
||||
reactor = options["reactor"]
|
||||
# If subCommand is None, TwistOptions.parseOptions() raises UsageError
|
||||
# and Twist.options() will exit the runner, so we'll never get here.
|
||||
subCommand = options.subCommand
|
||||
assert subCommand is not None
|
||||
service = cls.service(
|
||||
plugin=options.plugins[subCommand],
|
||||
options=options.subOptions,
|
||||
)
|
||||
|
||||
cls.startService(reactor, service)
|
||||
cls.run(options)
|
||||
@@ -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}.
|
||||
"""
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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)
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user