RAHHH
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
# -*- test-case-name: twisted.application.runner.test -*-
|
||||
# Copyright (c) Twisted Matrix Laboratories.
|
||||
# See LICENSE for details.
|
||||
|
||||
"""
|
||||
Facilities for running a Twisted application.
|
||||
"""
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,99 @@
|
||||
# -*- test-case-name: twisted.application.runner.test.test_exit -*-
|
||||
# Copyright (c) Twisted Matrix Laboratories.
|
||||
# See LICENSE for details.
|
||||
|
||||
"""
|
||||
System exit support.
|
||||
"""
|
||||
|
||||
import typing
|
||||
from enum import IntEnum
|
||||
from sys import exit as sysexit, stderr, stdout
|
||||
from typing import Union
|
||||
|
||||
try:
|
||||
import posix as Status
|
||||
except ImportError:
|
||||
|
||||
class Status: # type: ignore[no-redef]
|
||||
"""
|
||||
Object to hang C{EX_*} values off of as a substitute for L{posix}.
|
||||
"""
|
||||
|
||||
EX__BASE = 64
|
||||
|
||||
EX_OK = 0
|
||||
EX_USAGE = EX__BASE
|
||||
EX_DATAERR = EX__BASE + 1
|
||||
EX_NOINPUT = EX__BASE + 2
|
||||
EX_NOUSER = EX__BASE + 3
|
||||
EX_NOHOST = EX__BASE + 4
|
||||
EX_UNAVAILABLE = EX__BASE + 5
|
||||
EX_SOFTWARE = EX__BASE + 6
|
||||
EX_OSERR = EX__BASE + 7
|
||||
EX_OSFILE = EX__BASE + 8
|
||||
EX_CANTCREAT = EX__BASE + 9
|
||||
EX_IOERR = EX__BASE + 10
|
||||
EX_TEMPFAIL = EX__BASE + 11
|
||||
EX_PROTOCOL = EX__BASE + 12
|
||||
EX_NOPERM = EX__BASE + 13
|
||||
EX_CONFIG = EX__BASE + 14
|
||||
|
||||
|
||||
class ExitStatus(IntEnum):
|
||||
"""
|
||||
Standard exit status codes for system programs.
|
||||
|
||||
@cvar EX_OK: Successful termination.
|
||||
@cvar EX_USAGE: Command line usage error.
|
||||
@cvar EX_DATAERR: Data format error.
|
||||
@cvar EX_NOINPUT: Cannot open input.
|
||||
@cvar EX_NOUSER: Addressee unknown.
|
||||
@cvar EX_NOHOST: Host name unknown.
|
||||
@cvar EX_UNAVAILABLE: Service unavailable.
|
||||
@cvar EX_SOFTWARE: Internal software error.
|
||||
@cvar EX_OSERR: System error (e.g., can't fork).
|
||||
@cvar EX_OSFILE: Critical OS file missing.
|
||||
@cvar EX_CANTCREAT: Can't create (user) output file.
|
||||
@cvar EX_IOERR: Input/output error.
|
||||
@cvar EX_TEMPFAIL: Temporary failure; the user is invited to retry.
|
||||
@cvar EX_PROTOCOL: Remote error in protocol.
|
||||
@cvar EX_NOPERM: Permission denied.
|
||||
@cvar EX_CONFIG: Configuration error.
|
||||
"""
|
||||
|
||||
EX_OK = Status.EX_OK
|
||||
EX_USAGE = Status.EX_USAGE
|
||||
EX_DATAERR = Status.EX_DATAERR
|
||||
EX_NOINPUT = Status.EX_NOINPUT
|
||||
EX_NOUSER = Status.EX_NOUSER
|
||||
EX_NOHOST = Status.EX_NOHOST
|
||||
EX_UNAVAILABLE = Status.EX_UNAVAILABLE
|
||||
EX_SOFTWARE = Status.EX_SOFTWARE
|
||||
EX_OSERR = Status.EX_OSERR
|
||||
EX_OSFILE = Status.EX_OSFILE
|
||||
EX_CANTCREAT = Status.EX_CANTCREAT
|
||||
EX_IOERR = Status.EX_IOERR
|
||||
EX_TEMPFAIL = Status.EX_TEMPFAIL
|
||||
EX_PROTOCOL = Status.EX_PROTOCOL
|
||||
EX_NOPERM = Status.EX_NOPERM
|
||||
EX_CONFIG = Status.EX_CONFIG
|
||||
|
||||
|
||||
def exit(status: Union[int, ExitStatus], message: str = "") -> "typing.NoReturn":
|
||||
"""
|
||||
Exit the python interpreter with the given status and an optional message.
|
||||
|
||||
@param status: An exit status. An appropriate value from L{ExitStatus} is
|
||||
recommended.
|
||||
@param message: An optional message to print.
|
||||
"""
|
||||
if message:
|
||||
if status == ExitStatus.EX_OK:
|
||||
out = stdout
|
||||
else:
|
||||
out = stderr
|
||||
out.write(message)
|
||||
out.write("\n")
|
||||
|
||||
sysexit(status)
|
||||
@@ -0,0 +1,282 @@
|
||||
# -*- test-case-name: twisted.application.runner.test.test_pidfile -*-
|
||||
# Copyright (c) Twisted Matrix Laboratories.
|
||||
# See LICENSE for details.
|
||||
|
||||
"""
|
||||
PID file.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import errno
|
||||
from os import getpid, kill, name as SYSTEM_NAME
|
||||
from types import TracebackType
|
||||
from typing import Any, Optional, Type
|
||||
|
||||
from zope.interface import Interface, implementer
|
||||
|
||||
from twisted.logger import Logger
|
||||
from twisted.python.filepath import FilePath
|
||||
|
||||
|
||||
class IPIDFile(Interface):
|
||||
"""
|
||||
Manages a file that remembers a process ID.
|
||||
"""
|
||||
|
||||
def read() -> int:
|
||||
"""
|
||||
Read the process ID stored in this PID file.
|
||||
|
||||
@return: The contained process ID.
|
||||
|
||||
@raise NoPIDFound: If this PID file does not exist.
|
||||
@raise EnvironmentError: If this PID file cannot be read.
|
||||
@raise ValueError: If this PID file's content is invalid.
|
||||
"""
|
||||
|
||||
def writeRunningPID() -> None:
|
||||
"""
|
||||
Store the PID of the current process in this PID file.
|
||||
|
||||
@raise EnvironmentError: If this PID file cannot be written.
|
||||
"""
|
||||
|
||||
def remove() -> None:
|
||||
"""
|
||||
Remove this PID file.
|
||||
|
||||
@raise EnvironmentError: If this PID file cannot be removed.
|
||||
"""
|
||||
|
||||
def isRunning() -> bool:
|
||||
"""
|
||||
Determine whether there is a running process corresponding to the PID
|
||||
in this PID file.
|
||||
|
||||
@return: True if this PID file contains a PID and a process with that
|
||||
PID is currently running; false otherwise.
|
||||
|
||||
@raise EnvironmentError: If this PID file cannot be read.
|
||||
@raise InvalidPIDFileError: If this PID file's content is invalid.
|
||||
@raise StalePIDFileError: If this PID file's content refers to a PID
|
||||
for which there is no corresponding running process.
|
||||
"""
|
||||
|
||||
def __enter__() -> "IPIDFile":
|
||||
"""
|
||||
Enter a context using this PIDFile.
|
||||
|
||||
Writes the PID file with the PID of the running process.
|
||||
|
||||
@raise AlreadyRunningError: A process corresponding to the PID in this
|
||||
PID file is already running.
|
||||
"""
|
||||
|
||||
def __exit__(
|
||||
excType: Optional[Type[BaseException]],
|
||||
excValue: Optional[BaseException],
|
||||
traceback: Optional[TracebackType],
|
||||
) -> Optional[bool]:
|
||||
"""
|
||||
Exit a context using this PIDFile.
|
||||
|
||||
Removes the PID file.
|
||||
"""
|
||||
|
||||
|
||||
@implementer(IPIDFile)
|
||||
class PIDFile:
|
||||
"""
|
||||
Concrete implementation of L{IPIDFile}.
|
||||
|
||||
This implementation is presently not supported on non-POSIX platforms.
|
||||
Specifically, calling L{PIDFile.isRunning} will raise
|
||||
L{NotImplementedError}.
|
||||
"""
|
||||
|
||||
_log = Logger()
|
||||
|
||||
@staticmethod
|
||||
def _format(pid: int) -> bytes:
|
||||
"""
|
||||
Format a PID file's content.
|
||||
|
||||
@param pid: A process ID.
|
||||
|
||||
@return: Formatted PID file contents.
|
||||
"""
|
||||
return f"{int(pid)}\n".encode()
|
||||
|
||||
def __init__(self, filePath: FilePath[Any]) -> None:
|
||||
"""
|
||||
@param filePath: The path to the PID file on disk.
|
||||
"""
|
||||
self.filePath = filePath
|
||||
|
||||
def read(self) -> int:
|
||||
pidString = b""
|
||||
try:
|
||||
with self.filePath.open() as fh:
|
||||
for pidString in fh:
|
||||
break
|
||||
except OSError as e:
|
||||
if e.errno == errno.ENOENT: # No such file
|
||||
raise NoPIDFound("PID file does not exist")
|
||||
raise
|
||||
|
||||
try:
|
||||
return int(pidString)
|
||||
except ValueError:
|
||||
raise InvalidPIDFileError(
|
||||
f"non-integer PID value in PID file: {pidString!r}"
|
||||
)
|
||||
|
||||
def _write(self, pid: int) -> None:
|
||||
"""
|
||||
Store a PID in this PID file.
|
||||
|
||||
@param pid: A PID to store.
|
||||
|
||||
@raise EnvironmentError: If this PID file cannot be written.
|
||||
"""
|
||||
self.filePath.setContent(self._format(pid=pid))
|
||||
|
||||
def writeRunningPID(self) -> None:
|
||||
self._write(getpid())
|
||||
|
||||
def remove(self) -> None:
|
||||
self.filePath.remove()
|
||||
|
||||
def isRunning(self) -> bool:
|
||||
try:
|
||||
pid = self.read()
|
||||
except NoPIDFound:
|
||||
return False
|
||||
|
||||
if SYSTEM_NAME == "posix":
|
||||
return self._pidIsRunningPOSIX(pid)
|
||||
else:
|
||||
raise NotImplementedError(f"isRunning is not implemented on {SYSTEM_NAME}")
|
||||
|
||||
@staticmethod
|
||||
def _pidIsRunningPOSIX(pid: int) -> bool:
|
||||
"""
|
||||
POSIX implementation for running process check.
|
||||
|
||||
Determine whether there is a running process corresponding to the given
|
||||
PID.
|
||||
|
||||
@param pid: The PID to check.
|
||||
|
||||
@return: True if the given PID is currently running; false otherwise.
|
||||
|
||||
@raise EnvironmentError: If this PID file cannot be read.
|
||||
@raise InvalidPIDFileError: If this PID file's content is invalid.
|
||||
@raise StalePIDFileError: If this PID file's content refers to a PID
|
||||
for which there is no corresponding running process.
|
||||
"""
|
||||
try:
|
||||
kill(pid, 0)
|
||||
except OSError as e:
|
||||
if e.errno == errno.ESRCH: # No such process
|
||||
raise StalePIDFileError("PID file refers to non-existing process")
|
||||
elif e.errno == errno.EPERM: # Not permitted to kill
|
||||
return True
|
||||
else:
|
||||
raise
|
||||
else:
|
||||
return True
|
||||
|
||||
def __enter__(self) -> "PIDFile":
|
||||
try:
|
||||
if self.isRunning():
|
||||
raise AlreadyRunningError()
|
||||
except StalePIDFileError:
|
||||
self._log.info("Replacing stale PID file: {log_source}")
|
||||
self.writeRunningPID()
|
||||
return self
|
||||
|
||||
def __exit__(
|
||||
self,
|
||||
excType: Optional[Type[BaseException]],
|
||||
excValue: Optional[BaseException],
|
||||
traceback: Optional[TracebackType],
|
||||
) -> None:
|
||||
self.remove()
|
||||
return None
|
||||
|
||||
|
||||
@implementer(IPIDFile)
|
||||
class NonePIDFile:
|
||||
"""
|
||||
PID file implementation that does nothing.
|
||||
|
||||
This is meant to be used as a "active None" object in place of a PID file
|
||||
when no PID file is desired.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
pass
|
||||
|
||||
def read(self) -> int:
|
||||
raise NoPIDFound("PID file does not exist")
|
||||
|
||||
def _write(self, pid: int) -> None:
|
||||
"""
|
||||
Store a PID in this PID file.
|
||||
|
||||
@param pid: A PID to store.
|
||||
|
||||
@raise EnvironmentError: If this PID file cannot be written.
|
||||
|
||||
@note: This implementation always raises an L{EnvironmentError}.
|
||||
"""
|
||||
raise OSError(errno.EPERM, "Operation not permitted")
|
||||
|
||||
def writeRunningPID(self) -> None:
|
||||
self._write(0)
|
||||
|
||||
def remove(self) -> None:
|
||||
raise OSError(errno.ENOENT, "No such file or directory")
|
||||
|
||||
def isRunning(self) -> bool:
|
||||
return False
|
||||
|
||||
def __enter__(self) -> "NonePIDFile":
|
||||
return self
|
||||
|
||||
def __exit__(
|
||||
self,
|
||||
excType: Optional[Type[BaseException]],
|
||||
excValue: Optional[BaseException],
|
||||
traceback: Optional[TracebackType],
|
||||
) -> None:
|
||||
return None
|
||||
|
||||
|
||||
nonePIDFile: IPIDFile = NonePIDFile()
|
||||
|
||||
|
||||
class AlreadyRunningError(Exception):
|
||||
"""
|
||||
Process is already running.
|
||||
"""
|
||||
|
||||
|
||||
class InvalidPIDFileError(Exception):
|
||||
"""
|
||||
PID file contents are invalid.
|
||||
"""
|
||||
|
||||
|
||||
class StalePIDFileError(Exception):
|
||||
"""
|
||||
PID file contents are valid, but there is no process with the referenced
|
||||
PID.
|
||||
"""
|
||||
|
||||
|
||||
class NoPIDFound(Exception):
|
||||
"""
|
||||
No PID found in PID file.
|
||||
"""
|
||||
@@ -0,0 +1,166 @@
|
||||
# -*- test-case-name: twisted.application.runner.test.test_runner -*-
|
||||
# Copyright (c) Twisted Matrix Laboratories.
|
||||
# See LICENSE for details.
|
||||
|
||||
"""
|
||||
Twisted application runner.
|
||||
"""
|
||||
|
||||
from os import kill
|
||||
from signal import SIGTERM
|
||||
from sys import stderr
|
||||
from typing import Any, Callable, Mapping, TextIO
|
||||
|
||||
from attr import Factory, attrib, attrs
|
||||
from constantly import NamedConstant
|
||||
|
||||
from twisted.internet.interfaces import IReactorCore
|
||||
from twisted.logger import (
|
||||
FileLogObserver,
|
||||
FilteringLogObserver,
|
||||
Logger,
|
||||
LogLevel,
|
||||
LogLevelFilterPredicate,
|
||||
globalLogBeginner,
|
||||
textFileLogObserver,
|
||||
)
|
||||
from ._exit import ExitStatus, exit
|
||||
from ._pidfile import AlreadyRunningError, InvalidPIDFileError, IPIDFile, nonePIDFile
|
||||
|
||||
|
||||
@attrs(frozen=True)
|
||||
class Runner:
|
||||
"""
|
||||
Twisted application runner.
|
||||
|
||||
@cvar _log: The logger attached to this class.
|
||||
|
||||
@ivar _reactor: The reactor to start and run the application in.
|
||||
@ivar _pidFile: The file to store the running process ID in.
|
||||
@ivar _kill: Whether this runner should kill an existing running
|
||||
instance of the application.
|
||||
@ivar _defaultLogLevel: The default log level to start the logging
|
||||
system with.
|
||||
@ivar _logFile: A file stream to write logging output to.
|
||||
@ivar _fileLogObserverFactory: A factory for the file log observer to
|
||||
use when starting the logging system.
|
||||
@ivar _whenRunning: Hook to call after the reactor is running;
|
||||
this is where the application code that relies on the reactor gets
|
||||
called.
|
||||
@ivar _whenRunningArguments: Keyword arguments to pass to
|
||||
C{whenRunning} when it is called.
|
||||
@ivar _reactorExited: Hook to call after the reactor exits.
|
||||
@ivar _reactorExitedArguments: Keyword arguments to pass to
|
||||
C{reactorExited} when it is called.
|
||||
"""
|
||||
|
||||
_log = Logger()
|
||||
|
||||
_reactor = attrib(type=IReactorCore)
|
||||
_pidFile = attrib(type=IPIDFile, default=nonePIDFile)
|
||||
_kill = attrib(type=bool, default=False)
|
||||
_defaultLogLevel = attrib(type=NamedConstant, default=LogLevel.info)
|
||||
_logFile = attrib(type=TextIO, default=stderr)
|
||||
_fileLogObserverFactory = attrib(
|
||||
type=Callable[[TextIO], FileLogObserver], default=textFileLogObserver
|
||||
)
|
||||
_whenRunning = attrib(type=Callable[..., None], default=lambda **_: None)
|
||||
_whenRunningArguments = attrib(type=Mapping[str, Any], default=Factory(dict))
|
||||
_reactorExited = attrib(type=Callable[..., None], default=lambda **_: None)
|
||||
_reactorExitedArguments = attrib(type=Mapping[str, Any], default=Factory(dict))
|
||||
|
||||
def run(self) -> None:
|
||||
"""
|
||||
Run this command.
|
||||
"""
|
||||
pidFile = self._pidFile
|
||||
|
||||
self.killIfRequested()
|
||||
|
||||
try:
|
||||
with pidFile:
|
||||
self.startLogging()
|
||||
self.startReactor()
|
||||
self.reactorExited()
|
||||
|
||||
except AlreadyRunningError:
|
||||
exit(ExitStatus.EX_CONFIG, "Already running.")
|
||||
# When testing, patched exit doesn't exit
|
||||
return # type: ignore[unreachable]
|
||||
|
||||
def killIfRequested(self) -> None:
|
||||
"""
|
||||
If C{self._kill} is true, attempt to kill a running instance of the
|
||||
application.
|
||||
"""
|
||||
pidFile = self._pidFile
|
||||
|
||||
if self._kill:
|
||||
if pidFile is nonePIDFile:
|
||||
exit(ExitStatus.EX_USAGE, "No PID file specified.")
|
||||
# When testing, patched exit doesn't exit
|
||||
return # type: ignore[unreachable]
|
||||
|
||||
try:
|
||||
pid = pidFile.read()
|
||||
except OSError:
|
||||
exit(ExitStatus.EX_IOERR, "Unable to read PID file.")
|
||||
# When testing, patched exit doesn't exit
|
||||
return # type: ignore[unreachable]
|
||||
except InvalidPIDFileError:
|
||||
exit(ExitStatus.EX_DATAERR, "Invalid PID file.")
|
||||
# When testing, patched exit doesn't exit
|
||||
return # type: ignore[unreachable]
|
||||
|
||||
self.startLogging()
|
||||
self._log.info("Terminating process: {pid}", pid=pid)
|
||||
|
||||
kill(pid, SIGTERM)
|
||||
|
||||
exit(ExitStatus.EX_OK)
|
||||
# When testing, patched exit doesn't exit
|
||||
return # type: ignore[unreachable]
|
||||
|
||||
def startLogging(self) -> None:
|
||||
"""
|
||||
Start the L{twisted.logger} logging system.
|
||||
"""
|
||||
logFile = self._logFile
|
||||
|
||||
fileLogObserverFactory = self._fileLogObserverFactory
|
||||
|
||||
fileLogObserver = fileLogObserverFactory(logFile)
|
||||
|
||||
logLevelPredicate = LogLevelFilterPredicate(
|
||||
defaultLogLevel=self._defaultLogLevel
|
||||
)
|
||||
|
||||
filteringObserver = FilteringLogObserver(fileLogObserver, [logLevelPredicate])
|
||||
|
||||
globalLogBeginner.beginLoggingTo([filteringObserver])
|
||||
|
||||
def startReactor(self) -> None:
|
||||
"""
|
||||
Register C{self._whenRunning} with the reactor so that it is called
|
||||
once the reactor is running, then start the reactor.
|
||||
"""
|
||||
self._reactor.callWhenRunning(self.whenRunning)
|
||||
|
||||
self._log.info("Starting reactor...")
|
||||
self._reactor.run()
|
||||
|
||||
def whenRunning(self) -> None:
|
||||
"""
|
||||
Call C{self._whenRunning} with C{self._whenRunningArguments}.
|
||||
|
||||
@note: This method is called after the reactor starts running.
|
||||
"""
|
||||
self._whenRunning(**self._whenRunningArguments)
|
||||
|
||||
def reactorExited(self) -> None:
|
||||
"""
|
||||
Call C{self._reactorExited} with C{self._reactorExitedArguments}.
|
||||
|
||||
@note: This method is called after the reactor exits.
|
||||
"""
|
||||
self._reactorExited(**self._reactorExitedArguments)
|
||||
@@ -0,0 +1,7 @@
|
||||
# -*- test-case-name: twisted.application.runner.test -*-
|
||||
# Copyright (c) Twisted Matrix Laboratories.
|
||||
# See LICENSE for details.
|
||||
|
||||
"""
|
||||
Tests for L{twisted.application.runner}.
|
||||
"""
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,82 @@
|
||||
# Copyright (c) Twisted Matrix Laboratories.
|
||||
# See LICENSE for details.
|
||||
|
||||
"""
|
||||
Tests for L{twisted.application.runner._exit}.
|
||||
"""
|
||||
|
||||
from io import StringIO
|
||||
from typing import Optional, Union
|
||||
|
||||
import twisted.trial.unittest
|
||||
from ...runner import _exit
|
||||
from .._exit import ExitStatus, exit
|
||||
|
||||
|
||||
class ExitTests(twisted.trial.unittest.TestCase):
|
||||
"""
|
||||
Tests for L{exit}.
|
||||
"""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.exit = DummyExit()
|
||||
self.patch(_exit, "sysexit", self.exit)
|
||||
|
||||
def test_exitStatusInt(self) -> None:
|
||||
"""
|
||||
L{exit} given an L{int} status code will pass it to L{sys.exit}.
|
||||
"""
|
||||
status = 1234
|
||||
exit(status)
|
||||
self.assertEqual(self.exit.arg, status) # type: ignore[unreachable]
|
||||
|
||||
def test_exitConstant(self) -> None:
|
||||
"""
|
||||
L{exit} given a L{ValueConstant} status code passes the corresponding
|
||||
value to L{sys.exit}.
|
||||
"""
|
||||
status = ExitStatus.EX_CONFIG
|
||||
exit(status)
|
||||
self.assertEqual(self.exit.arg, status.value) # type: ignore[unreachable]
|
||||
|
||||
def test_exitMessageZero(self) -> None:
|
||||
"""
|
||||
L{exit} given a status code of zero (C{0}) writes the given message to
|
||||
standard output.
|
||||
"""
|
||||
out = StringIO()
|
||||
self.patch(_exit, "stdout", out)
|
||||
|
||||
message = "Hello, world."
|
||||
exit(0, message)
|
||||
|
||||
self.assertEqual(out.getvalue(), message + "\n") # type: ignore[unreachable]
|
||||
|
||||
def test_exitMessageNonZero(self) -> None:
|
||||
"""
|
||||
L{exit} given a non-zero status code writes the given message to
|
||||
standard error.
|
||||
"""
|
||||
out = StringIO()
|
||||
self.patch(_exit, "stderr", out)
|
||||
|
||||
message = "Hello, world."
|
||||
exit(64, message)
|
||||
|
||||
self.assertEqual(out.getvalue(), message + "\n") # type: ignore[unreachable]
|
||||
|
||||
|
||||
class DummyExit:
|
||||
"""
|
||||
Stub for L{sys.exit} that remembers whether it's been called and, if it
|
||||
has, what argument it was given.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.exited = False
|
||||
|
||||
def __call__(self, arg: Optional[Union[int, str]] = None) -> None:
|
||||
assert not self.exited
|
||||
|
||||
self.arg = arg
|
||||
self.exited = True
|
||||
@@ -0,0 +1,419 @@
|
||||
# Copyright (c) Twisted Matrix Laboratories.
|
||||
# See LICENSE for details.
|
||||
|
||||
"""
|
||||
Tests for L{twisted.application.runner._pidfile}.
|
||||
"""
|
||||
|
||||
import errno
|
||||
from functools import wraps
|
||||
from os import getpid, name as SYSTEM_NAME
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from zope.interface.verify import verifyObject
|
||||
|
||||
from typing_extensions import NoReturn
|
||||
|
||||
import twisted.trial.unittest
|
||||
from twisted.python.filepath import FilePath
|
||||
from twisted.python.runtime import platform
|
||||
from twisted.trial.unittest import SkipTest
|
||||
from ...runner import _pidfile
|
||||
from .._pidfile import (
|
||||
AlreadyRunningError,
|
||||
InvalidPIDFileError,
|
||||
IPIDFile,
|
||||
NonePIDFile,
|
||||
NoPIDFound,
|
||||
PIDFile,
|
||||
StalePIDFileError,
|
||||
)
|
||||
|
||||
|
||||
def ifPlatformSupported(f: Callable[..., Any]) -> Callable[..., Any]:
|
||||
"""
|
||||
Decorator for tests that are not expected to work on all platforms.
|
||||
|
||||
Calling L{PIDFile.isRunning} currently raises L{NotImplementedError} on
|
||||
non-POSIX platforms.
|
||||
|
||||
On an unsupported platform, we expect to see any test that calls
|
||||
L{PIDFile.isRunning} to raise either L{NotImplementedError}, L{SkipTest},
|
||||
or C{self.failureException}.
|
||||
(C{self.failureException} may occur in a test that checks for a specific
|
||||
exception but it gets NotImplementedError instead.)
|
||||
|
||||
@param f: The test method to decorate.
|
||||
|
||||
@return: The wrapped callable.
|
||||
"""
|
||||
|
||||
@wraps(f)
|
||||
def wrapper(self: Any, *args: Any, **kwargs: Any) -> Any:
|
||||
supported = platform.getType() == "posix"
|
||||
|
||||
if supported:
|
||||
return f(self, *args, **kwargs)
|
||||
else:
|
||||
e = self.assertRaises(
|
||||
(NotImplementedError, SkipTest, self.failureException),
|
||||
f,
|
||||
self,
|
||||
*args,
|
||||
**kwargs,
|
||||
)
|
||||
if isinstance(e, NotImplementedError):
|
||||
self.assertTrue(str(e).startswith("isRunning is not implemented on "))
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
class PIDFileTests(twisted.trial.unittest.TestCase):
|
||||
"""
|
||||
Tests for L{PIDFile}.
|
||||
"""
|
||||
|
||||
def filePath(self, content: Optional[bytes] = None) -> FilePath[str]:
|
||||
filePath = FilePath(self.mktemp())
|
||||
if content is not None:
|
||||
filePath.setContent(content)
|
||||
return filePath
|
||||
|
||||
def test_interface(self) -> None:
|
||||
"""
|
||||
L{PIDFile} conforms to L{IPIDFile}.
|
||||
"""
|
||||
pidFile = PIDFile(self.filePath())
|
||||
verifyObject(IPIDFile, pidFile)
|
||||
|
||||
def test_formatWithPID(self) -> None:
|
||||
"""
|
||||
L{PIDFile._format} returns the expected format when given a PID.
|
||||
"""
|
||||
self.assertEqual(PIDFile._format(pid=1337), b"1337\n")
|
||||
|
||||
def test_readWithPID(self) -> None:
|
||||
"""
|
||||
L{PIDFile.read} returns the PID from the given file path.
|
||||
"""
|
||||
pid = 1337
|
||||
|
||||
pidFile = PIDFile(self.filePath(PIDFile._format(pid=pid)))
|
||||
|
||||
self.assertEqual(pid, pidFile.read())
|
||||
|
||||
def test_readEmptyPID(self) -> None:
|
||||
"""
|
||||
L{PIDFile.read} raises L{InvalidPIDFileError} when given an empty file
|
||||
path.
|
||||
"""
|
||||
pidValue = b""
|
||||
pidFile = PIDFile(self.filePath(b""))
|
||||
|
||||
e = self.assertRaises(InvalidPIDFileError, pidFile.read)
|
||||
self.assertEqual(str(e), f"non-integer PID value in PID file: {pidValue!r}")
|
||||
|
||||
def test_readWithBogusPID(self) -> None:
|
||||
"""
|
||||
L{PIDFile.read} raises L{InvalidPIDFileError} when given an empty file
|
||||
path.
|
||||
"""
|
||||
pidValue = b"$foo!"
|
||||
pidFile = PIDFile(self.filePath(pidValue))
|
||||
|
||||
e = self.assertRaises(InvalidPIDFileError, pidFile.read)
|
||||
self.assertEqual(str(e), f"non-integer PID value in PID file: {pidValue!r}")
|
||||
|
||||
def test_readDoesntExist(self) -> None:
|
||||
"""
|
||||
L{PIDFile.read} raises L{NoPIDFound} when given a non-existing file
|
||||
path.
|
||||
"""
|
||||
pidFile = PIDFile(self.filePath())
|
||||
|
||||
e = self.assertRaises(NoPIDFound, pidFile.read)
|
||||
self.assertEqual(str(e), "PID file does not exist")
|
||||
|
||||
def test_readOpenRaisesOSErrorNotENOENT(self) -> None:
|
||||
"""
|
||||
L{PIDFile.read} re-raises L{OSError} if the associated C{errno} is
|
||||
anything other than L{errno.ENOENT}.
|
||||
"""
|
||||
|
||||
def oops(mode: str = "r") -> NoReturn:
|
||||
raise OSError(errno.EIO, "I/O error")
|
||||
|
||||
self.patch(FilePath, "open", oops)
|
||||
|
||||
pidFile = PIDFile(self.filePath())
|
||||
|
||||
error = self.assertRaises(OSError, pidFile.read)
|
||||
self.assertEqual(error.errno, errno.EIO)
|
||||
|
||||
def test_writePID(self) -> None:
|
||||
"""
|
||||
L{PIDFile._write} stores the given PID.
|
||||
"""
|
||||
pid = 1995
|
||||
|
||||
pidFile = PIDFile(self.filePath())
|
||||
pidFile._write(pid)
|
||||
|
||||
self.assertEqual(pidFile.read(), pid)
|
||||
|
||||
def test_writePIDInvalid(self) -> None:
|
||||
"""
|
||||
L{PIDFile._write} raises L{ValueError} when given an invalid PID.
|
||||
"""
|
||||
pidFile = PIDFile(self.filePath())
|
||||
|
||||
self.assertRaises(ValueError, pidFile._write, "burp")
|
||||
|
||||
def test_writeRunningPID(self) -> None:
|
||||
"""
|
||||
L{PIDFile.writeRunningPID} stores the PID for the current process.
|
||||
"""
|
||||
pidFile = PIDFile(self.filePath())
|
||||
pidFile.writeRunningPID()
|
||||
|
||||
self.assertEqual(pidFile.read(), getpid())
|
||||
|
||||
def test_remove(self) -> None:
|
||||
"""
|
||||
L{PIDFile.remove} removes the PID file.
|
||||
"""
|
||||
pidFile = PIDFile(self.filePath(b""))
|
||||
self.assertTrue(pidFile.filePath.exists())
|
||||
|
||||
pidFile.remove()
|
||||
self.assertFalse(pidFile.filePath.exists())
|
||||
|
||||
@ifPlatformSupported
|
||||
def test_isRunningDoesExist(self) -> None:
|
||||
"""
|
||||
L{PIDFile.isRunning} returns true for a process that does exist.
|
||||
"""
|
||||
pidFile = PIDFile(self.filePath())
|
||||
pidFile._write(1337)
|
||||
|
||||
def kill(pid: int, signal: int) -> None:
|
||||
return # Don't actually kill anything
|
||||
|
||||
self.patch(_pidfile, "kill", kill)
|
||||
|
||||
self.assertTrue(pidFile.isRunning())
|
||||
|
||||
@ifPlatformSupported
|
||||
def test_isRunningThis(self) -> None:
|
||||
"""
|
||||
L{PIDFile.isRunning} returns true for this process (which is running).
|
||||
|
||||
@note: This differs from L{PIDFileTests.test_isRunningDoesExist} in
|
||||
that it actually invokes the C{kill} system call, which is useful for
|
||||
testing of our chosen method for probing the existence of a process.
|
||||
"""
|
||||
pidFile = PIDFile(self.filePath())
|
||||
pidFile.writeRunningPID()
|
||||
|
||||
self.assertTrue(pidFile.isRunning())
|
||||
|
||||
@ifPlatformSupported
|
||||
def test_isRunningDoesNotExist(self) -> None:
|
||||
"""
|
||||
L{PIDFile.isRunning} raises L{StalePIDFileError} for a process that
|
||||
does not exist (errno=ESRCH).
|
||||
"""
|
||||
pidFile = PIDFile(self.filePath())
|
||||
pidFile._write(1337)
|
||||
|
||||
def kill(pid: int, signal: int) -> None:
|
||||
raise OSError(errno.ESRCH, "No such process")
|
||||
|
||||
self.patch(_pidfile, "kill", kill)
|
||||
|
||||
self.assertRaises(StalePIDFileError, pidFile.isRunning)
|
||||
|
||||
@ifPlatformSupported
|
||||
def test_isRunningNotAllowed(self) -> None:
|
||||
"""
|
||||
L{PIDFile.isRunning} returns true for a process that we are not allowed
|
||||
to kill (errno=EPERM).
|
||||
"""
|
||||
pidFile = PIDFile(self.filePath())
|
||||
pidFile._write(1337)
|
||||
|
||||
def kill(pid: int, signal: int) -> None:
|
||||
raise OSError(errno.EPERM, "Operation not permitted")
|
||||
|
||||
self.patch(_pidfile, "kill", kill)
|
||||
|
||||
self.assertTrue(pidFile.isRunning())
|
||||
|
||||
@ifPlatformSupported
|
||||
def test_isRunningInit(self) -> None:
|
||||
"""
|
||||
L{PIDFile.isRunning} returns true for a process that we are not allowed
|
||||
to kill (errno=EPERM).
|
||||
|
||||
@note: This differs from L{PIDFileTests.test_isRunningNotAllowed} in
|
||||
that it actually invokes the C{kill} system call, which is useful for
|
||||
testing of our chosen method for probing the existence of a process
|
||||
that we are not allowed to kill.
|
||||
|
||||
@note: In this case, we try killing C{init}, which is process #1 on
|
||||
POSIX systems, so this test is not portable. C{init} should always be
|
||||
running and should not be killable by non-root users.
|
||||
"""
|
||||
if SYSTEM_NAME != "posix":
|
||||
raise SkipTest("This test assumes POSIX")
|
||||
|
||||
pidFile = PIDFile(self.filePath())
|
||||
pidFile._write(1) # PID 1 is init on POSIX systems
|
||||
|
||||
self.assertTrue(pidFile.isRunning())
|
||||
|
||||
@ifPlatformSupported
|
||||
def test_isRunningUnknownErrno(self) -> None:
|
||||
"""
|
||||
L{PIDFile.isRunning} re-raises L{OSError} if the attached C{errno}
|
||||
value from L{os.kill} is not an expected one.
|
||||
"""
|
||||
pidFile = PIDFile(self.filePath())
|
||||
pidFile.writeRunningPID()
|
||||
|
||||
def kill(pid: int, signal: int) -> None:
|
||||
raise OSError(errno.EEXIST, "File exists")
|
||||
|
||||
self.patch(_pidfile, "kill", kill)
|
||||
|
||||
self.assertRaises(OSError, pidFile.isRunning)
|
||||
|
||||
def test_isRunningNoPIDFile(self) -> None:
|
||||
"""
|
||||
L{PIDFile.isRunning} returns false if the PID file doesn't exist.
|
||||
"""
|
||||
pidFile = PIDFile(self.filePath())
|
||||
|
||||
self.assertFalse(pidFile.isRunning())
|
||||
|
||||
def test_contextManager(self) -> None:
|
||||
"""
|
||||
When used as a context manager, a L{PIDFile} will store the current pid
|
||||
on entry, then removes the PID file on exit.
|
||||
"""
|
||||
pidFile = PIDFile(self.filePath())
|
||||
self.assertFalse(pidFile.filePath.exists())
|
||||
|
||||
with pidFile:
|
||||
self.assertTrue(pidFile.filePath.exists())
|
||||
self.assertEqual(pidFile.read(), getpid())
|
||||
|
||||
self.assertFalse(pidFile.filePath.exists())
|
||||
|
||||
@ifPlatformSupported
|
||||
def test_contextManagerDoesntExist(self) -> None:
|
||||
"""
|
||||
When used as a context manager, a L{PIDFile} will replace the
|
||||
underlying PIDFile rather than raising L{AlreadyRunningError} if the
|
||||
contained PID file exists but refers to a non-running PID.
|
||||
"""
|
||||
pidFile = PIDFile(self.filePath())
|
||||
pidFile._write(1337)
|
||||
|
||||
def kill(pid: int, signal: int) -> None:
|
||||
raise OSError(errno.ESRCH, "No such process")
|
||||
|
||||
self.patch(_pidfile, "kill", kill)
|
||||
|
||||
e = self.assertRaises(StalePIDFileError, pidFile.isRunning)
|
||||
self.assertEqual(str(e), "PID file refers to non-existing process")
|
||||
|
||||
with pidFile:
|
||||
self.assertEqual(pidFile.read(), getpid())
|
||||
|
||||
@ifPlatformSupported
|
||||
def test_contextManagerAlreadyRunning(self) -> None:
|
||||
"""
|
||||
When used as a context manager, a L{PIDFile} will raise
|
||||
L{AlreadyRunningError} if the there is already a running process with
|
||||
the contained PID.
|
||||
"""
|
||||
pidFile = PIDFile(self.filePath())
|
||||
pidFile._write(1337)
|
||||
|
||||
def kill(pid: int, signal: int) -> None:
|
||||
return # Don't actually kill anything
|
||||
|
||||
self.patch(_pidfile, "kill", kill)
|
||||
|
||||
self.assertTrue(pidFile.isRunning())
|
||||
|
||||
self.assertRaises(AlreadyRunningError, pidFile.__enter__)
|
||||
|
||||
|
||||
class NonePIDFileTests(twisted.trial.unittest.TestCase):
|
||||
"""
|
||||
Tests for L{NonePIDFile}.
|
||||
"""
|
||||
|
||||
def test_interface(self) -> None:
|
||||
"""
|
||||
L{NonePIDFile} conforms to L{IPIDFile}.
|
||||
"""
|
||||
pidFile = NonePIDFile()
|
||||
verifyObject(IPIDFile, pidFile)
|
||||
|
||||
def test_read(self) -> None:
|
||||
"""
|
||||
L{NonePIDFile.read} raises L{NoPIDFound}.
|
||||
"""
|
||||
pidFile = NonePIDFile()
|
||||
|
||||
e = self.assertRaises(NoPIDFound, pidFile.read)
|
||||
self.assertEqual(str(e), "PID file does not exist")
|
||||
|
||||
def test_write(self) -> None:
|
||||
"""
|
||||
L{NonePIDFile._write} raises L{OSError} with an errno of L{errno.EPERM}.
|
||||
"""
|
||||
pidFile = NonePIDFile()
|
||||
|
||||
error = self.assertRaises(OSError, pidFile._write, 0)
|
||||
self.assertEqual(error.errno, errno.EPERM)
|
||||
|
||||
def test_writeRunningPID(self) -> None:
|
||||
"""
|
||||
L{NonePIDFile.writeRunningPID} raises L{OSError} with an errno of
|
||||
L{errno.EPERM}.
|
||||
"""
|
||||
pidFile = NonePIDFile()
|
||||
|
||||
error = self.assertRaises(OSError, pidFile.writeRunningPID)
|
||||
self.assertEqual(error.errno, errno.EPERM)
|
||||
|
||||
def test_remove(self) -> None:
|
||||
"""
|
||||
L{NonePIDFile.remove} raises L{OSError} with an errno of L{errno.EPERM}.
|
||||
"""
|
||||
pidFile = NonePIDFile()
|
||||
|
||||
error = self.assertRaises(OSError, pidFile.remove)
|
||||
self.assertEqual(error.errno, errno.ENOENT)
|
||||
|
||||
def test_isRunning(self) -> None:
|
||||
"""
|
||||
L{NonePIDFile.isRunning} returns L{False}.
|
||||
"""
|
||||
pidFile = NonePIDFile()
|
||||
|
||||
self.assertEqual(pidFile.isRunning(), False)
|
||||
|
||||
def test_contextManager(self) -> None:
|
||||
"""
|
||||
When used as a context manager, a L{NonePIDFile} doesn't raise, despite
|
||||
not existing.
|
||||
"""
|
||||
pidFile = NonePIDFile()
|
||||
|
||||
with pidFile:
|
||||
pass
|
||||
@@ -0,0 +1,454 @@
|
||||
# Copyright (c) Twisted Matrix Laboratories.
|
||||
# See LICENSE for details.
|
||||
|
||||
"""
|
||||
Tests for L{twisted.application.runner._runner}.
|
||||
"""
|
||||
|
||||
import errno
|
||||
from io import StringIO
|
||||
from signal import SIGTERM
|
||||
from types import TracebackType
|
||||
from typing import Any, Iterable, List, Optional, TextIO, Tuple, Type, Union, cast
|
||||
|
||||
from attr import Factory, attrib, attrs
|
||||
|
||||
import twisted.trial.unittest
|
||||
from twisted.internet.testing import MemoryReactor
|
||||
from twisted.logger import (
|
||||
FileLogObserver,
|
||||
FilteringLogObserver,
|
||||
ILogObserver,
|
||||
LogBeginner,
|
||||
LogLevel,
|
||||
LogLevelFilterPredicate,
|
||||
LogPublisher,
|
||||
)
|
||||
from twisted.python.filepath import FilePath
|
||||
from ...runner import _runner
|
||||
from .._exit import ExitStatus
|
||||
from .._pidfile import NonePIDFile, PIDFile
|
||||
from .._runner import Runner
|
||||
|
||||
|
||||
class RunnerTests(twisted.trial.unittest.TestCase):
|
||||
"""
|
||||
Tests for L{Runner}.
|
||||
"""
|
||||
|
||||
def filePath(self, content: Optional[bytes] = None) -> FilePath[str]:
|
||||
filePath = FilePath(self.mktemp())
|
||||
if content is not None:
|
||||
filePath.setContent(content)
|
||||
return filePath
|
||||
|
||||
def setUp(self) -> None:
|
||||
# Patch exit and kill so we can capture usage and prevent actual exits
|
||||
# and kills.
|
||||
|
||||
self.exit = DummyExit()
|
||||
self.kill = DummyKill()
|
||||
|
||||
self.patch(_runner, "exit", self.exit)
|
||||
self.patch(_runner, "kill", self.kill)
|
||||
|
||||
# Patch getpid so we get a known result
|
||||
|
||||
self.pid = 1337
|
||||
self.pidFileContent = f"{self.pid}\n".encode()
|
||||
|
||||
# Patch globalLogBeginner so that we aren't trying to install multiple
|
||||
# global log observers.
|
||||
|
||||
self.stdout = StringIO()
|
||||
self.stderr = StringIO()
|
||||
self.stdio = DummyStandardIO(self.stdout, self.stderr)
|
||||
self.warnings = DummyWarningsModule()
|
||||
|
||||
self.globalLogPublisher = LogPublisher()
|
||||
self.globalLogBeginner = LogBeginner(
|
||||
self.globalLogPublisher,
|
||||
self.stdio.stderr,
|
||||
self.stdio,
|
||||
self.warnings,
|
||||
)
|
||||
|
||||
self.patch(_runner, "stderr", self.stderr)
|
||||
self.patch(_runner, "globalLogBeginner", self.globalLogBeginner)
|
||||
|
||||
def test_runInOrder(self) -> None:
|
||||
"""
|
||||
L{Runner.run} calls the expected methods in order.
|
||||
"""
|
||||
runner = DummyRunner(reactor=MemoryReactor())
|
||||
runner.run()
|
||||
|
||||
self.assertEqual(
|
||||
runner.calledMethods,
|
||||
[
|
||||
"killIfRequested",
|
||||
"startLogging",
|
||||
"startReactor",
|
||||
"reactorExited",
|
||||
],
|
||||
)
|
||||
|
||||
def test_runUsesPIDFile(self) -> None:
|
||||
"""
|
||||
L{Runner.run} uses the provided PID file.
|
||||
"""
|
||||
pidFile = DummyPIDFile()
|
||||
|
||||
runner = Runner(reactor=MemoryReactor(), pidFile=pidFile)
|
||||
|
||||
self.assertFalse(pidFile.entered)
|
||||
self.assertFalse(pidFile.exited)
|
||||
|
||||
runner.run()
|
||||
|
||||
self.assertTrue(pidFile.entered)
|
||||
self.assertTrue(pidFile.exited)
|
||||
|
||||
def test_runAlreadyRunning(self) -> None:
|
||||
"""
|
||||
L{Runner.run} exits with L{ExitStatus.EX_USAGE} and the expected
|
||||
message if a process is already running that corresponds to the given
|
||||
PID file.
|
||||
"""
|
||||
pidFile = PIDFile(self.filePath(self.pidFileContent))
|
||||
pidFile.isRunning = lambda: True # type: ignore[method-assign]
|
||||
|
||||
runner = Runner(reactor=MemoryReactor(), pidFile=pidFile)
|
||||
runner.run()
|
||||
|
||||
self.assertEqual(self.exit.status, ExitStatus.EX_CONFIG)
|
||||
self.assertEqual(self.exit.message, "Already running.")
|
||||
|
||||
def test_killNotRequested(self) -> None:
|
||||
"""
|
||||
L{Runner.killIfRequested} when C{kill} is false doesn't exit and
|
||||
doesn't indiscriminately murder anyone.
|
||||
"""
|
||||
runner = Runner(reactor=MemoryReactor())
|
||||
runner.killIfRequested()
|
||||
|
||||
self.assertEqual(self.kill.calls, [])
|
||||
self.assertFalse(self.exit.exited)
|
||||
|
||||
def test_killRequestedWithoutPIDFile(self) -> None:
|
||||
"""
|
||||
L{Runner.killIfRequested} when C{kill} is true but C{pidFile} is
|
||||
L{nonePIDFile} exits with L{ExitStatus.EX_USAGE} and the expected
|
||||
message; and also doesn't indiscriminately murder anyone.
|
||||
"""
|
||||
runner = Runner(reactor=MemoryReactor(), kill=True)
|
||||
runner.killIfRequested()
|
||||
|
||||
self.assertEqual(self.kill.calls, [])
|
||||
self.assertEqual(self.exit.status, ExitStatus.EX_USAGE)
|
||||
self.assertEqual(self.exit.message, "No PID file specified.")
|
||||
|
||||
def test_killRequestedWithPIDFile(self) -> None:
|
||||
"""
|
||||
L{Runner.killIfRequested} when C{kill} is true and given a C{pidFile}
|
||||
performs a targeted killing of the appropriate process.
|
||||
"""
|
||||
pidFile = PIDFile(self.filePath(self.pidFileContent))
|
||||
runner = Runner(reactor=MemoryReactor(), kill=True, pidFile=pidFile)
|
||||
runner.killIfRequested()
|
||||
|
||||
self.assertEqual(self.kill.calls, [(self.pid, SIGTERM)])
|
||||
self.assertEqual(self.exit.status, ExitStatus.EX_OK)
|
||||
self.assertIdentical(self.exit.message, None)
|
||||
|
||||
def test_killRequestedWithPIDFileCantRead(self) -> None:
|
||||
"""
|
||||
L{Runner.killIfRequested} when C{kill} is true and given a C{pidFile}
|
||||
that it can't read exits with L{ExitStatus.EX_IOERR}.
|
||||
"""
|
||||
pidFile = PIDFile(self.filePath(None))
|
||||
|
||||
def read() -> int:
|
||||
raise OSError(errno.EACCES, "Permission denied")
|
||||
|
||||
pidFile.read = read # type: ignore[method-assign]
|
||||
|
||||
runner = Runner(reactor=MemoryReactor(), kill=True, pidFile=pidFile)
|
||||
runner.killIfRequested()
|
||||
|
||||
self.assertEqual(self.exit.status, ExitStatus.EX_IOERR)
|
||||
self.assertEqual(self.exit.message, "Unable to read PID file.")
|
||||
|
||||
def test_killRequestedWithPIDFileEmpty(self) -> None:
|
||||
"""
|
||||
L{Runner.killIfRequested} when C{kill} is true and given a C{pidFile}
|
||||
containing no value exits with L{ExitStatus.EX_DATAERR}.
|
||||
"""
|
||||
pidFile = PIDFile(self.filePath(b""))
|
||||
runner = Runner(reactor=MemoryReactor(), kill=True, pidFile=pidFile)
|
||||
runner.killIfRequested()
|
||||
|
||||
self.assertEqual(self.exit.status, ExitStatus.EX_DATAERR)
|
||||
self.assertEqual(self.exit.message, "Invalid PID file.")
|
||||
|
||||
def test_killRequestedWithPIDFileNotAnInt(self) -> None:
|
||||
"""
|
||||
L{Runner.killIfRequested} when C{kill} is true and given a C{pidFile}
|
||||
containing a non-integer value exits with L{ExitStatus.EX_DATAERR}.
|
||||
"""
|
||||
pidFile = PIDFile(self.filePath(b"** totally not a number, dude **"))
|
||||
runner = Runner(reactor=MemoryReactor(), kill=True, pidFile=pidFile)
|
||||
runner.killIfRequested()
|
||||
|
||||
self.assertEqual(self.exit.status, ExitStatus.EX_DATAERR)
|
||||
self.assertEqual(self.exit.message, "Invalid PID file.")
|
||||
|
||||
def test_startLogging(self) -> None:
|
||||
"""
|
||||
L{Runner.startLogging} sets up a filtering observer with a log level
|
||||
predicate set to the given log level that contains a file observer of
|
||||
the given type which writes to the given file.
|
||||
"""
|
||||
logFile = StringIO()
|
||||
|
||||
# Patch the log beginner so that we don't try to start the already
|
||||
# running (started by trial) logging system.
|
||||
|
||||
class LogBeginner:
|
||||
observers: List[ILogObserver] = []
|
||||
|
||||
def beginLoggingTo(self, observers: Iterable[ILogObserver]) -> None:
|
||||
LogBeginner.observers = list(observers)
|
||||
|
||||
self.patch(_runner, "globalLogBeginner", LogBeginner())
|
||||
|
||||
# Patch FilteringLogObserver so we can capture its arguments
|
||||
|
||||
class MockFilteringLogObserver(FilteringLogObserver):
|
||||
observer: Optional[ILogObserver] = None
|
||||
predicates: List[LogLevelFilterPredicate] = []
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
observer: ILogObserver,
|
||||
predicates: Iterable[LogLevelFilterPredicate],
|
||||
negativeObserver: ILogObserver = cast(ILogObserver, lambda event: None),
|
||||
) -> None:
|
||||
MockFilteringLogObserver.observer = observer
|
||||
MockFilteringLogObserver.predicates = list(predicates)
|
||||
FilteringLogObserver.__init__(
|
||||
self, observer, predicates, negativeObserver
|
||||
)
|
||||
|
||||
self.patch(_runner, "FilteringLogObserver", MockFilteringLogObserver)
|
||||
|
||||
# Patch FileLogObserver so we can capture its arguments
|
||||
|
||||
class MockFileLogObserver(FileLogObserver):
|
||||
outFile: Optional[TextIO] = None
|
||||
|
||||
def __init__(self, outFile: TextIO) -> None:
|
||||
MockFileLogObserver.outFile = outFile
|
||||
FileLogObserver.__init__(self, outFile, str)
|
||||
|
||||
# Start logging
|
||||
runner = Runner(
|
||||
reactor=MemoryReactor(),
|
||||
defaultLogLevel=LogLevel.critical,
|
||||
logFile=logFile,
|
||||
fileLogObserverFactory=MockFileLogObserver,
|
||||
)
|
||||
runner.startLogging()
|
||||
|
||||
# Check for a filtering observer
|
||||
self.assertEqual(len(LogBeginner.observers), 1)
|
||||
self.assertIsInstance(LogBeginner.observers[0], FilteringLogObserver)
|
||||
|
||||
# Check log level predicate with the correct default log level
|
||||
self.assertEqual(len(MockFilteringLogObserver.predicates), 1)
|
||||
self.assertIsInstance(
|
||||
MockFilteringLogObserver.predicates[0], LogLevelFilterPredicate
|
||||
)
|
||||
self.assertIdentical(
|
||||
MockFilteringLogObserver.predicates[0].defaultLogLevel, LogLevel.critical
|
||||
)
|
||||
|
||||
# Check for a file observer attached to the filtering observer
|
||||
observer = cast(MockFileLogObserver, MockFilteringLogObserver.observer)
|
||||
self.assertIsInstance(observer, MockFileLogObserver)
|
||||
|
||||
# Check for the file we gave it
|
||||
self.assertIdentical(observer.outFile, logFile)
|
||||
|
||||
def test_startReactorWithReactor(self) -> None:
|
||||
"""
|
||||
L{Runner.startReactor} with the C{reactor} argument runs the given
|
||||
reactor.
|
||||
"""
|
||||
reactor = MemoryReactor()
|
||||
runner = Runner(reactor=reactor)
|
||||
runner.startReactor()
|
||||
|
||||
self.assertTrue(reactor.hasRun)
|
||||
|
||||
def test_startReactorWhenRunning(self) -> None:
|
||||
"""
|
||||
L{Runner.startReactor} ensures that C{whenRunning} is called with
|
||||
C{whenRunningArguments} when the reactor is running.
|
||||
"""
|
||||
self._testHook("whenRunning", "startReactor")
|
||||
|
||||
def test_whenRunningWithArguments(self) -> None:
|
||||
"""
|
||||
L{Runner.whenRunning} calls C{whenRunning} with
|
||||
C{whenRunningArguments}.
|
||||
"""
|
||||
self._testHook("whenRunning")
|
||||
|
||||
def test_reactorExitedWithArguments(self) -> None:
|
||||
"""
|
||||
L{Runner.whenRunning} calls C{reactorExited} with
|
||||
C{reactorExitedArguments}.
|
||||
"""
|
||||
self._testHook("reactorExited")
|
||||
|
||||
def _testHook(self, methodName: str, callerName: Optional[str] = None) -> None:
|
||||
"""
|
||||
Verify that the named hook is run with the expected arguments as
|
||||
specified by the arguments used to create the L{Runner}, when the
|
||||
specified caller is invoked.
|
||||
|
||||
@param methodName: The name of the hook to verify.
|
||||
|
||||
@param callerName: The name of the method that is expected to cause the
|
||||
hook to be called.
|
||||
If C{None}, use the L{Runner} method with the same name as the
|
||||
hook.
|
||||
"""
|
||||
if callerName is None:
|
||||
callerName = methodName
|
||||
|
||||
arguments = dict(a=object(), b=object(), c=object())
|
||||
argumentsSeen = []
|
||||
|
||||
def hook(**arguments: object) -> None:
|
||||
argumentsSeen.append(arguments)
|
||||
|
||||
runnerArguments = {
|
||||
methodName: hook,
|
||||
f"{methodName}Arguments": arguments.copy(),
|
||||
}
|
||||
runner = Runner(
|
||||
reactor=MemoryReactor(), **runnerArguments # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
hookCaller = getattr(runner, callerName)
|
||||
hookCaller()
|
||||
|
||||
self.assertEqual(len(argumentsSeen), 1)
|
||||
self.assertEqual(argumentsSeen[0], arguments)
|
||||
|
||||
|
||||
@attrs(frozen=True)
|
||||
class DummyRunner(Runner):
|
||||
"""
|
||||
Stub for L{Runner}.
|
||||
|
||||
Keep track of calls to some methods without actually doing anything.
|
||||
"""
|
||||
|
||||
calledMethods = attrib(type=List[str], default=Factory(list))
|
||||
|
||||
def killIfRequested(self) -> None:
|
||||
self.calledMethods.append("killIfRequested")
|
||||
|
||||
def startLogging(self) -> None:
|
||||
self.calledMethods.append("startLogging")
|
||||
|
||||
def startReactor(self) -> None:
|
||||
self.calledMethods.append("startReactor")
|
||||
|
||||
def reactorExited(self) -> None:
|
||||
self.calledMethods.append("reactorExited")
|
||||
|
||||
|
||||
class DummyPIDFile(NonePIDFile):
|
||||
"""
|
||||
Stub for L{PIDFile}.
|
||||
|
||||
Tracks context manager entry/exit without doing anything.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
NonePIDFile.__init__(self)
|
||||
|
||||
self.entered = False
|
||||
self.exited = False
|
||||
|
||||
def __enter__(self) -> "DummyPIDFile":
|
||||
self.entered = True
|
||||
return self
|
||||
|
||||
def __exit__(
|
||||
self,
|
||||
excType: Optional[Type[BaseException]],
|
||||
excValue: Optional[BaseException],
|
||||
traceback: Optional[TracebackType],
|
||||
) -> None:
|
||||
self.exited = True
|
||||
|
||||
|
||||
class DummyExit:
|
||||
"""
|
||||
Stub for L{_exit.exit} that remembers whether it's been called and, if it has,
|
||||
what arguments it was given.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.exited = False
|
||||
|
||||
def __call__(
|
||||
self, status: Union[int, ExitStatus], message: Optional[str] = None
|
||||
) -> None:
|
||||
assert not self.exited
|
||||
|
||||
self.status = status
|
||||
self.message = message
|
||||
self.exited = True
|
||||
|
||||
|
||||
class DummyKill:
|
||||
"""
|
||||
Stub for L{os.kill} that remembers whether it's been called and, if it has,
|
||||
what arguments it was given.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.calls: List[Tuple[int, int]] = []
|
||||
|
||||
def __call__(self, pid: int, sig: int) -> None:
|
||||
self.calls.append((pid, sig))
|
||||
|
||||
|
||||
class DummyStandardIO:
|
||||
"""
|
||||
Stub for L{sys} which provides L{StringIO} streams as stdout and stderr.
|
||||
"""
|
||||
|
||||
def __init__(self, stdout: TextIO, stderr: TextIO) -> None:
|
||||
self.stdout = stdout
|
||||
self.stderr = stderr
|
||||
|
||||
|
||||
class DummyWarningsModule:
|
||||
"""
|
||||
Stub for L{warnings} which provides a C{showwarning} method that is a no-op.
|
||||
"""
|
||||
|
||||
def showwarning(*args: Any, **kwargs: Any) -> None:
|
||||
"""
|
||||
Do nothing.
|
||||
|
||||
@param args: ignored.
|
||||
@param kwargs: ignored.
|
||||
"""
|
||||
Reference in New Issue
Block a user