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.runner.test -*-
# Copyright (c) Twisted Matrix Laboratories.
# See LICENSE for details.
"""
Facilities for running a Twisted application.
"""

View File

@@ -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)

View File

@@ -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.
"""

View 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)

View File

@@ -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}.
"""

View File

@@ -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

View File

@@ -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

View File

@@ -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.
"""