Initial Commit
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
# -*- test-case-name: twisted.trial._dist.test -*-
|
||||
# Copyright (c) Twisted Matrix Laboratories.
|
||||
# See LICENSE for details.
|
||||
|
||||
"""
|
||||
This package implements the distributed Trial test runner:
|
||||
|
||||
- The L{twisted.trial._dist.disttrial} module implements a test runner which
|
||||
runs in a manager process and can launch additional worker processes in
|
||||
which to run tests and gather up results from all of them.
|
||||
|
||||
- The L{twisted.trial._dist.options} module defines command line options used
|
||||
to configure the distributed test runner.
|
||||
|
||||
- The L{twisted.trial._dist.managercommands} module defines AMP commands
|
||||
which are sent from worker processes back to the manager process to report
|
||||
the results of tests.
|
||||
|
||||
- The L{twisted.trial._dist.workercommands} module defines AMP commands which
|
||||
are sent from the manager process to the worker processes to control the
|
||||
execution of tests there.
|
||||
|
||||
- The L{twisted.trial._dist.distreporter} module defines a proxy for
|
||||
L{twisted.trial.itrial.IReporter} which enforces the typical requirement
|
||||
that results be passed to a reporter for only one test at a time, allowing
|
||||
any reporter to be used with despite disttrial's simultaneously running
|
||||
tests.
|
||||
|
||||
- The L{twisted.trial._dist.workerreporter} module implements a
|
||||
L{twisted.trial.itrial.IReporter} which is used by worker processes and
|
||||
reports results back to the manager process using AMP commands.
|
||||
|
||||
- The L{twisted.trial._dist.workertrial} module is a runnable script which is
|
||||
the main point for worker processes.
|
||||
|
||||
- The L{twisted.trial._dist.worker} process defines the manager's AMP
|
||||
protocol for accepting results from worker processes and a process protocol
|
||||
for use running workers as local child processes (as opposed to
|
||||
distributing them to another host).
|
||||
|
||||
@since: 12.3
|
||||
"""
|
||||
|
||||
# File descriptors numbers used to set up pipes with the worker.
|
||||
_WORKER_AMP_STDIN = 3
|
||||
|
||||
_WORKER_AMP_STDOUT = 4
|
||||
@@ -0,0 +1,90 @@
|
||||
# -*- test-case-name: twisted.trial._dist.test.test_distreporter -*-
|
||||
#
|
||||
# Copyright (c) Twisted Matrix Laboratories.
|
||||
# See LICENSE for details.
|
||||
|
||||
"""
|
||||
The reporter is not made to support concurrent test running, so we will
|
||||
hold test results in here and only send them to the reporter once the
|
||||
test is over.
|
||||
|
||||
@since: 12.3
|
||||
"""
|
||||
|
||||
from types import TracebackType
|
||||
from typing import Optional, Tuple, Union
|
||||
|
||||
from zope.interface import implementer
|
||||
|
||||
from twisted.python.components import proxyForInterface
|
||||
from twisted.python.failure import Failure
|
||||
from ..itrial import IReporter, ITestCase
|
||||
|
||||
ReporterFailure = Union[Failure, Tuple[type, Exception, TracebackType]]
|
||||
|
||||
|
||||
@implementer(IReporter)
|
||||
class DistReporter(proxyForInterface(IReporter)): # type: ignore[misc]
|
||||
"""
|
||||
See module docstring.
|
||||
"""
|
||||
|
||||
def __init__(self, original):
|
||||
super().__init__(original)
|
||||
self.running = {}
|
||||
|
||||
def startTest(self, test):
|
||||
"""
|
||||
Queue test starting.
|
||||
"""
|
||||
self.running[test.id()] = []
|
||||
self.running[test.id()].append((self.original.startTest, test))
|
||||
|
||||
def addFailure(self, test: ITestCase, fail: ReporterFailure) -> None:
|
||||
"""
|
||||
Queue adding a failure.
|
||||
"""
|
||||
self.running[test.id()].append((self.original.addFailure, test, fail))
|
||||
|
||||
def addError(self, test: ITestCase, error: ReporterFailure) -> None:
|
||||
"""
|
||||
Queue error adding.
|
||||
"""
|
||||
self.running[test.id()].append((self.original.addError, test, error))
|
||||
|
||||
def addSkip(self, test, reason):
|
||||
"""
|
||||
Queue adding a skip.
|
||||
"""
|
||||
self.running[test.id()].append((self.original.addSkip, test, reason))
|
||||
|
||||
def addUnexpectedSuccess(self, test, todo=None):
|
||||
"""
|
||||
Queue adding an unexpected success.
|
||||
"""
|
||||
self.running[test.id()].append((self.original.addUnexpectedSuccess, test, todo))
|
||||
|
||||
def addExpectedFailure(
|
||||
self, test: ITestCase, error: ReporterFailure, todo: Optional[str] = None
|
||||
) -> None:
|
||||
"""
|
||||
Queue adding an expected failure.
|
||||
"""
|
||||
self.running[test.id()].append(
|
||||
(self.original.addExpectedFailure, test, error, todo)
|
||||
)
|
||||
|
||||
def addSuccess(self, test):
|
||||
"""
|
||||
Queue adding a success.
|
||||
"""
|
||||
self.running[test.id()].append((self.original.addSuccess, test))
|
||||
|
||||
def stopTest(self, test):
|
||||
"""
|
||||
Queue stopping the test, then unroll the queue.
|
||||
"""
|
||||
self.running[test.id()].append((self.original.stopTest, test))
|
||||
for step in self.running[test.id()]:
|
||||
step[0](*step[1:])
|
||||
del self.running[test.id()]
|
||||
@@ -0,0 +1,481 @@
|
||||
# -*- test-case-name: twisted.trial._dist.test.test_disttrial -*-
|
||||
# Copyright (c) Twisted Matrix Laboratories.
|
||||
# See LICENSE for details.
|
||||
|
||||
"""
|
||||
This module contains the trial distributed runner, the management class
|
||||
responsible for coordinating all of trial's behavior at the highest level.
|
||||
|
||||
@since: 12.3
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from functools import partial
|
||||
from os.path import isabs
|
||||
from typing import Awaitable, Callable, Iterable, List, Sequence, TextIO, Union, cast
|
||||
from unittest import TestCase, TestSuite
|
||||
|
||||
from attrs import define, field, frozen
|
||||
from attrs.converters import default_if_none
|
||||
|
||||
from twisted.internet.defer import Deferred, DeferredList, gatherResults
|
||||
from twisted.internet.interfaces import IReactorCore, IReactorProcess
|
||||
from twisted.logger import Logger
|
||||
from twisted.python.failure import Failure
|
||||
from twisted.python.filepath import FilePath
|
||||
from twisted.python.lockfile import FilesystemLock
|
||||
from twisted.python.modules import theSystemPath
|
||||
from .._asyncrunner import _iterateTests
|
||||
from ..itrial import IReporter, ITestCase
|
||||
from ..reporter import UncleanWarningsReporterWrapper
|
||||
from ..runner import TestHolder
|
||||
from ..util import _unusedTestDirectory, openTestLog
|
||||
from . import _WORKER_AMP_STDIN, _WORKER_AMP_STDOUT
|
||||
from .distreporter import DistReporter
|
||||
from .functional import countingCalls, discardResult, iterateWhile, takeWhile
|
||||
from .worker import LocalWorker, LocalWorkerAMP, WorkerAction
|
||||
|
||||
|
||||
class IDistTrialReactor(IReactorCore, IReactorProcess):
|
||||
"""
|
||||
The reactor interfaces required by disttrial.
|
||||
"""
|
||||
|
||||
|
||||
def _defaultReactor() -> IDistTrialReactor:
|
||||
"""
|
||||
Get the default reactor, ensuring it is suitable for use with disttrial.
|
||||
"""
|
||||
import twisted.internet.reactor as defaultReactor
|
||||
|
||||
if all(
|
||||
[
|
||||
IReactorCore.providedBy(defaultReactor),
|
||||
IReactorProcess.providedBy(defaultReactor),
|
||||
]
|
||||
):
|
||||
# If it provides each of the interfaces then it provides the
|
||||
# intersection interface. cast it to make it easier to talk about
|
||||
# later on.
|
||||
return cast(IDistTrialReactor, defaultReactor)
|
||||
|
||||
raise TypeError("Reactor does not provide the right interfaces")
|
||||
|
||||
|
||||
@frozen
|
||||
class WorkerPoolConfig:
|
||||
"""
|
||||
Configuration parameters for a pool of test-running workers.
|
||||
|
||||
@ivar numWorkers: The number of workers in the pool.
|
||||
|
||||
@ivar workingDirectory: A directory in which working directories for each
|
||||
of the workers will be created.
|
||||
|
||||
@ivar workerArguments: Extra arguments to pass the worker process in its
|
||||
argv.
|
||||
|
||||
@ivar logFile: The basename of the overall test log file.
|
||||
"""
|
||||
|
||||
numWorkers: int
|
||||
workingDirectory: FilePath
|
||||
workerArguments: Sequence[str]
|
||||
logFile: str
|
||||
|
||||
|
||||
@define
|
||||
class StartedWorkerPool:
|
||||
"""
|
||||
A pool of workers which have already been started.
|
||||
|
||||
@ivar workingDirectory: A directory holding the working directories for
|
||||
each of the workers.
|
||||
|
||||
@ivar testDirLock: An object representing the cooperative lock this pool
|
||||
holds on its working directory.
|
||||
|
||||
@ivar testLog: The open overall test log file.
|
||||
|
||||
@ivar workers: Objects corresponding to the worker child processes and
|
||||
adapting between process-related interfaces and C{IProtocol}.
|
||||
|
||||
@ivar ampWorkers: AMP protocol instances corresponding to the worker child
|
||||
processes.
|
||||
"""
|
||||
|
||||
workingDirectory: FilePath
|
||||
testDirLock: FilesystemLock
|
||||
testLog: TextIO
|
||||
workers: List[LocalWorker]
|
||||
ampWorkers: List[LocalWorkerAMP]
|
||||
|
||||
_logger = Logger()
|
||||
|
||||
async def run(self, workerAction: WorkerAction) -> None:
|
||||
"""
|
||||
Run an action on all of the workers in the pool.
|
||||
"""
|
||||
await gatherResults(
|
||||
discardResult(workerAction(worker)) for worker in self.ampWorkers
|
||||
)
|
||||
return None
|
||||
|
||||
async def join(self) -> None:
|
||||
"""
|
||||
Shut down all of the workers in the pool.
|
||||
|
||||
The pool is unusable after this method is called.
|
||||
"""
|
||||
results = await DeferredList(
|
||||
[Deferred.fromCoroutine(worker.exit()) for worker in self.workers],
|
||||
consumeErrors=True,
|
||||
)
|
||||
for n, (succeeded, failure) in enumerate(results):
|
||||
if not succeeded:
|
||||
self._logger.failure(f"joining disttrial worker #{n} failed", failure)
|
||||
|
||||
del self.workers[:]
|
||||
del self.ampWorkers[:]
|
||||
self.testLog.close()
|
||||
self.testDirLock.unlock()
|
||||
|
||||
|
||||
@frozen
|
||||
class WorkerPool:
|
||||
"""
|
||||
Manage a fixed-size collection of child processes which can run tests.
|
||||
|
||||
@ivar _config: Configuration for the precise way in which the pool is run.
|
||||
"""
|
||||
|
||||
_config: WorkerPoolConfig
|
||||
|
||||
def _createLocalWorkers(
|
||||
self,
|
||||
protocols: Iterable[LocalWorkerAMP],
|
||||
workingDirectory: FilePath,
|
||||
logFile: TextIO,
|
||||
) -> List[LocalWorker]:
|
||||
"""
|
||||
Create local worker protocol instances and return them.
|
||||
|
||||
@param protocols: The process/protocol adapters to use for the created
|
||||
workers.
|
||||
|
||||
@param workingDirectory: The base path in which we should run the
|
||||
workers.
|
||||
|
||||
@param logFile: The test log, for workers to write to.
|
||||
|
||||
@return: A list of C{quantity} C{LocalWorker} instances.
|
||||
"""
|
||||
return [
|
||||
LocalWorker(protocol, workingDirectory.child(str(x)), logFile)
|
||||
for x, protocol in enumerate(protocols)
|
||||
]
|
||||
|
||||
def _launchWorkerProcesses(self, spawner, protocols, arguments):
|
||||
"""
|
||||
Spawn processes from a list of process protocols.
|
||||
|
||||
@param spawner: A C{IReactorProcess.spawnProcess} implementation.
|
||||
|
||||
@param protocols: An iterable of C{ProcessProtocol} instances.
|
||||
|
||||
@param arguments: Extra arguments passed to the processes.
|
||||
"""
|
||||
workertrialPath = theSystemPath["twisted.trial._dist.workertrial"].filePath.path
|
||||
childFDs = {
|
||||
0: "w",
|
||||
1: "r",
|
||||
2: "r",
|
||||
_WORKER_AMP_STDIN: "w",
|
||||
_WORKER_AMP_STDOUT: "r",
|
||||
}
|
||||
environ = os.environ.copy()
|
||||
# Add an environment variable containing the raw sys.path, to be used
|
||||
# by subprocesses to try to make it identical to the parent's.
|
||||
environ["PYTHONPATH"] = os.pathsep.join(sys.path)
|
||||
for worker in protocols:
|
||||
args = [sys.executable, workertrialPath]
|
||||
args.extend(arguments)
|
||||
spawner(worker, sys.executable, args=args, childFDs=childFDs, env=environ)
|
||||
|
||||
async def start(self, reactor: IReactorProcess) -> StartedWorkerPool:
|
||||
"""
|
||||
Launch all of the workers for this pool.
|
||||
|
||||
@return: A started pool object that can run jobs using the workers.
|
||||
"""
|
||||
testDir, testDirLock = _unusedTestDirectory(
|
||||
self._config.workingDirectory,
|
||||
)
|
||||
|
||||
if isabs(self._config.logFile):
|
||||
# Open a log file wherever the user asked.
|
||||
testLogPath = FilePath(self._config.logFile)
|
||||
else:
|
||||
# Open a log file in the chosen working directory (not necessarily
|
||||
# the same as our configured working directory, if that path was
|
||||
# in use).
|
||||
testLogPath = testDir.preauthChild(self._config.logFile)
|
||||
testLog = openTestLog(testLogPath)
|
||||
|
||||
ampWorkers = [LocalWorkerAMP() for x in range(self._config.numWorkers)]
|
||||
workers = self._createLocalWorkers(
|
||||
ampWorkers,
|
||||
testDir,
|
||||
testLog,
|
||||
)
|
||||
self._launchWorkerProcesses(
|
||||
reactor.spawnProcess,
|
||||
workers,
|
||||
self._config.workerArguments,
|
||||
)
|
||||
|
||||
return StartedWorkerPool(
|
||||
testDir,
|
||||
testDirLock,
|
||||
testLog,
|
||||
workers,
|
||||
ampWorkers,
|
||||
)
|
||||
|
||||
|
||||
def shouldContinue(untilFailure: bool, result: IReporter) -> bool:
|
||||
"""
|
||||
Determine whether the test suite should be iterated again.
|
||||
|
||||
@param untilFailure: C{True} if the suite is supposed to run until
|
||||
failure.
|
||||
|
||||
@param result: The test result of the test suite iteration which just
|
||||
completed.
|
||||
"""
|
||||
return untilFailure and result.wasSuccessful()
|
||||
|
||||
|
||||
async def runTests(
|
||||
pool: StartedWorkerPool,
|
||||
testCases: Iterable[ITestCase],
|
||||
result: DistReporter,
|
||||
driveWorker: Callable[
|
||||
[DistReporter, Sequence[ITestCase], LocalWorkerAMP], Awaitable[None]
|
||||
],
|
||||
) -> None:
|
||||
try:
|
||||
# Run the tests using the worker pool.
|
||||
await pool.run(partial(driveWorker, result, testCases))
|
||||
except Exception:
|
||||
# Exceptions from test code are handled somewhere else. An
|
||||
# exception here is a bug in the runner itself. The only
|
||||
# convenient place to put it is in the result, though.
|
||||
result.original.addError(TestHolder("<runTests>"), Failure())
|
||||
|
||||
|
||||
@define
|
||||
class DistTrialRunner:
|
||||
"""
|
||||
A specialized runner for distributed trial. The runner launches a number of
|
||||
local worker processes which will run tests.
|
||||
|
||||
@ivar _maxWorkers: the number of workers to be spawned.
|
||||
|
||||
@ivar _exitFirst: ``True`` to stop the run as soon as a test case fails.
|
||||
``False`` to run through the whole suite and report all of the results
|
||||
at the end.
|
||||
|
||||
@ivar stream: stream which the reporter will use.
|
||||
|
||||
@ivar _reporterFactory: the reporter class to be used.
|
||||
"""
|
||||
|
||||
_distReporterFactory = DistReporter
|
||||
_logger = Logger()
|
||||
|
||||
# accepts a `realtime` keyword argument which we can't annotate, so punt
|
||||
# on the argument annotation
|
||||
_reporterFactory: Callable[..., IReporter]
|
||||
_maxWorkers: int
|
||||
_workerArguments: List[str]
|
||||
_exitFirst: bool = False
|
||||
_reactor: IDistTrialReactor = field(
|
||||
# mypy doesn't understand the converter
|
||||
default=None,
|
||||
converter=default_if_none(factory=_defaultReactor), # type: ignore [misc]
|
||||
)
|
||||
# mypy doesn't understand the converter
|
||||
stream: TextIO = field(default=None, converter=default_if_none(sys.stdout)) # type: ignore [misc]
|
||||
|
||||
_tracebackFormat: str = "default"
|
||||
_realTimeErrors: bool = False
|
||||
_uncleanWarnings: bool = False
|
||||
_logfile: str = "test.log"
|
||||
_workingDirectory: str = "_trial_temp"
|
||||
_workerPoolFactory: Callable[[WorkerPoolConfig], WorkerPool] = WorkerPool
|
||||
|
||||
def _makeResult(self) -> DistReporter:
|
||||
"""
|
||||
Make reporter factory, and wrap it with a L{DistReporter}.
|
||||
"""
|
||||
reporter = self._reporterFactory(
|
||||
self.stream, self._tracebackFormat, realtime=self._realTimeErrors
|
||||
)
|
||||
if self._uncleanWarnings:
|
||||
reporter = UncleanWarningsReporterWrapper(reporter)
|
||||
return self._distReporterFactory(reporter)
|
||||
|
||||
def writeResults(self, result):
|
||||
"""
|
||||
Write test run final outcome to result.
|
||||
|
||||
@param result: A C{TestResult} which will print errors and the summary.
|
||||
"""
|
||||
result.done()
|
||||
|
||||
async def _driveWorker(
|
||||
self,
|
||||
result: DistReporter,
|
||||
testCases: Sequence[ITestCase],
|
||||
worker: LocalWorkerAMP,
|
||||
) -> None:
|
||||
"""
|
||||
Drive a L{LocalWorkerAMP} instance, iterating the tests and calling
|
||||
C{run} for every one of them.
|
||||
|
||||
@param worker: The L{LocalWorkerAMP} to drive.
|
||||
|
||||
@param result: The global L{DistReporter} instance.
|
||||
|
||||
@param testCases: The global list of tests to iterate.
|
||||
|
||||
@return: A coroutine that completes after all of the tests have
|
||||
completed.
|
||||
"""
|
||||
|
||||
async def task(case):
|
||||
try:
|
||||
await worker.run(case, result)
|
||||
except Exception:
|
||||
result.original.addError(case, Failure())
|
||||
|
||||
for case in testCases:
|
||||
await task(case)
|
||||
|
||||
async def runAsync(
|
||||
self,
|
||||
suite: Union[TestCase, TestSuite],
|
||||
untilFailure: bool = False,
|
||||
) -> DistReporter:
|
||||
"""
|
||||
Spawn local worker processes and load tests. After that, run them.
|
||||
|
||||
@param suite: A test or suite to be run.
|
||||
|
||||
@param untilFailure: If C{True}, continue to run the tests until they
|
||||
fail.
|
||||
|
||||
@return: A coroutine that completes with the test result.
|
||||
"""
|
||||
|
||||
# Realize a concrete set of tests to run.
|
||||
testCases = list(_iterateTests(suite))
|
||||
|
||||
# Create a worker pool to use to execute them.
|
||||
poolStarter = self._workerPoolFactory(
|
||||
WorkerPoolConfig(
|
||||
# Don't make it larger than is useful or allowed.
|
||||
min(len(testCases), self._maxWorkers),
|
||||
FilePath(self._workingDirectory),
|
||||
self._workerArguments,
|
||||
self._logfile,
|
||||
),
|
||||
)
|
||||
|
||||
# Announce that we're beginning. countTestCases result is preferred
|
||||
# (over len(testCases)) because testCases may contain synthetic cases
|
||||
# for error reporting purposes.
|
||||
self.stream.write(f"Running {suite.countTestCases()} tests.\n")
|
||||
|
||||
# Start the worker pool.
|
||||
startedPool = await poolStarter.start(self._reactor)
|
||||
|
||||
# The condition that will determine whether the test run repeats.
|
||||
condition = partial(shouldContinue, untilFailure)
|
||||
|
||||
# A function that will run the whole suite once.
|
||||
@countingCalls
|
||||
async def runAndReport(n: int) -> DistReporter:
|
||||
if untilFailure:
|
||||
# If and only if we're running the suite more than once,
|
||||
# provide a report about which run this is.
|
||||
self.stream.write(f"Test Pass {n + 1}\n")
|
||||
|
||||
result = self._makeResult()
|
||||
|
||||
if self._exitFirst:
|
||||
# Keep giving out tests as long as the result object has only
|
||||
# seen success.
|
||||
casesCondition = lambda _: result.original.wasSuccessful()
|
||||
else:
|
||||
casesCondition = lambda _: True
|
||||
|
||||
await runTests(
|
||||
startedPool,
|
||||
takeWhile(casesCondition, testCases),
|
||||
result,
|
||||
self._driveWorker,
|
||||
)
|
||||
self.writeResults(result)
|
||||
return result
|
||||
|
||||
try:
|
||||
# Start submitting tests to workers in the pool. Perhaps repeat
|
||||
# the whole test suite more than once, if appropriate for our
|
||||
# configuration.
|
||||
return await iterateWhile(condition, runAndReport)
|
||||
finally:
|
||||
# Shut down the worker pool.
|
||||
await startedPool.join()
|
||||
|
||||
def _run(self, test: Union[TestCase, TestSuite], untilFailure: bool) -> IReporter:
|
||||
result: Union[Failure, DistReporter]
|
||||
|
||||
def capture(r):
|
||||
nonlocal result
|
||||
result = r
|
||||
|
||||
d = Deferred.fromCoroutine(self.runAsync(test, untilFailure))
|
||||
d.addBoth(capture)
|
||||
d.addBoth(lambda ignored: self._reactor.stop())
|
||||
self._reactor.run()
|
||||
|
||||
if isinstance(result, Failure):
|
||||
result.raiseException()
|
||||
|
||||
# mypy can't see that raiseException raises an exception so we can
|
||||
# only get here if result is not a Failure, so tell mypy result is
|
||||
# certainly a DistReporter at this point.
|
||||
assert isinstance(result, DistReporter)
|
||||
|
||||
# Unwrap the DistReporter to give the caller some regular IReporter
|
||||
# object. DistReporter isn't type annotated correctly so fix it here.
|
||||
return cast(IReporter, result.original)
|
||||
|
||||
def run(self, test: Union[TestCase, TestSuite]) -> IReporter:
|
||||
"""
|
||||
Run a reactor and a test suite.
|
||||
|
||||
@param test: The test or suite to run.
|
||||
"""
|
||||
return self._run(test, untilFailure=False)
|
||||
|
||||
def runUntilFailure(self, test: Union[TestCase, TestSuite]) -> IReporter:
|
||||
"""
|
||||
Run the tests with local worker processes until they fail.
|
||||
|
||||
@param test: The test or suite to run.
|
||||
"""
|
||||
return self._run(test, untilFailure=True)
|
||||
@@ -0,0 +1,125 @@
|
||||
# Copyright (c) Twisted Matrix Laboratories.
|
||||
# See LICENSE for details.
|
||||
|
||||
"""
|
||||
General functional-style helpers for disttrial.
|
||||
"""
|
||||
|
||||
from functools import partial, wraps
|
||||
from typing import Awaitable, Callable, Iterable, Optional, TypeVar
|
||||
|
||||
from twisted.internet.defer import Deferred, succeed
|
||||
|
||||
_A = TypeVar("_A")
|
||||
_B = TypeVar("_B")
|
||||
_C = TypeVar("_C")
|
||||
|
||||
|
||||
def fromOptional(default: _A, optional: Optional[_A]) -> _A:
|
||||
"""
|
||||
Get a definite value from an optional value.
|
||||
|
||||
@param default: The value to return if the optional value is missing.
|
||||
|
||||
@param optional: The optional value to return if it exists.
|
||||
"""
|
||||
if optional is None:
|
||||
return default
|
||||
return optional
|
||||
|
||||
|
||||
def takeWhile(condition: Callable[[_A], bool], xs: Iterable[_A]) -> Iterable[_A]:
|
||||
"""
|
||||
:return: An iterable over C{xs} that stops when C{condition} returns
|
||||
``False`` based on the value of iterated C{xs}.
|
||||
"""
|
||||
for x in xs:
|
||||
if condition(x):
|
||||
yield x
|
||||
else:
|
||||
break
|
||||
|
||||
|
||||
async def sequence(a: Awaitable[_A], b: Awaitable[_B]) -> _B:
|
||||
"""
|
||||
Wait for one action to complete and then another.
|
||||
|
||||
If either action fails, failure is propagated. If the first action fails,
|
||||
the second action is not waited on.
|
||||
"""
|
||||
await a
|
||||
return await b
|
||||
|
||||
|
||||
def flip(f: Callable[[_A, _B], _C]) -> Callable[[_B, _A], _C]:
|
||||
"""
|
||||
Create a function like another but with the order of the first two
|
||||
arguments flipped.
|
||||
"""
|
||||
|
||||
@wraps(f)
|
||||
def g(b, a):
|
||||
return f(a, b)
|
||||
|
||||
return g
|
||||
|
||||
|
||||
def compose(fx: Callable[[_B], _C], fy: Callable[[_A], _B]) -> Callable[[_A], _C]:
|
||||
"""
|
||||
Create a function that calls one function with an argument and then
|
||||
another function with the result of the first function.
|
||||
"""
|
||||
|
||||
@wraps(fx)
|
||||
@wraps(fy)
|
||||
def g(a):
|
||||
return fx(fy(a))
|
||||
|
||||
return g
|
||||
|
||||
|
||||
# Discard the result of an awaitable and substitute None in its place.
|
||||
#
|
||||
# Ignore the `Cannot infer type argument 1 of "compose"`
|
||||
# https://github.com/python/mypy/issues/6220
|
||||
discardResult: Callable[[Awaitable[_A]], Deferred[None]] = compose( # type: ignore[misc]
|
||||
Deferred.fromCoroutine,
|
||||
partial(flip(sequence), succeed(None)),
|
||||
)
|
||||
|
||||
|
||||
async def iterateWhile(
|
||||
predicate: Callable[[_A], bool],
|
||||
action: Callable[[], Awaitable[_A]],
|
||||
) -> _A:
|
||||
"""
|
||||
Call a function repeatedly until its result fails to satisfy a predicate.
|
||||
|
||||
@param predicate: The check to apply.
|
||||
|
||||
@param action: The function to call.
|
||||
|
||||
@return: The result of C{action} which did not satisfy C{predicate}.
|
||||
"""
|
||||
while True:
|
||||
result = await action()
|
||||
if not predicate(result):
|
||||
return result
|
||||
|
||||
|
||||
def countingCalls(f: Callable[[int], _A]) -> Callable[[], _A]:
|
||||
"""
|
||||
Wrap a function with another that automatically passes an integer counter
|
||||
of the number of calls that have gone through the wrapper.
|
||||
"""
|
||||
counter = 0
|
||||
|
||||
def g():
|
||||
nonlocal counter
|
||||
try:
|
||||
result = f(counter)
|
||||
finally:
|
||||
counter += 1
|
||||
return result
|
||||
|
||||
return g
|
||||
@@ -0,0 +1,89 @@
|
||||
# Copyright (c) Twisted Matrix Laboratories.
|
||||
# See LICENSE for details.
|
||||
|
||||
"""
|
||||
Commands for reporting test success of failure to the manager.
|
||||
|
||||
@since: 12.3
|
||||
"""
|
||||
|
||||
from twisted.protocols.amp import Boolean, Command, Integer, Unicode
|
||||
|
||||
NativeString = Unicode
|
||||
|
||||
|
||||
class AddSuccess(Command):
|
||||
"""
|
||||
Add a success.
|
||||
"""
|
||||
|
||||
arguments = [(b"testName", NativeString())]
|
||||
response = [(b"success", Boolean())]
|
||||
|
||||
|
||||
class AddError(Command):
|
||||
"""
|
||||
Add an error.
|
||||
"""
|
||||
|
||||
arguments = [
|
||||
(b"testName", NativeString()),
|
||||
(b"errorClass", NativeString()),
|
||||
(b"errorStreamId", Integer()),
|
||||
(b"framesStreamId", Integer()),
|
||||
]
|
||||
response = [(b"success", Boolean())]
|
||||
|
||||
|
||||
class AddFailure(Command):
|
||||
"""
|
||||
Add a failure.
|
||||
"""
|
||||
|
||||
arguments = [
|
||||
(b"testName", NativeString()),
|
||||
(b"failStreamId", Integer()),
|
||||
(b"failClass", NativeString()),
|
||||
(b"framesStreamId", Integer()),
|
||||
]
|
||||
response = [(b"success", Boolean())]
|
||||
|
||||
|
||||
class AddSkip(Command):
|
||||
"""
|
||||
Add a skip.
|
||||
"""
|
||||
|
||||
arguments = [(b"testName", NativeString()), (b"reason", NativeString())]
|
||||
response = [(b"success", Boolean())]
|
||||
|
||||
|
||||
class AddExpectedFailure(Command):
|
||||
"""
|
||||
Add an expected failure.
|
||||
"""
|
||||
|
||||
arguments = [
|
||||
(b"testName", NativeString()),
|
||||
(b"errorStreamId", Integer()),
|
||||
(b"todo", NativeString()),
|
||||
]
|
||||
response = [(b"success", Boolean())]
|
||||
|
||||
|
||||
class AddUnexpectedSuccess(Command):
|
||||
"""
|
||||
Add an unexpected success.
|
||||
"""
|
||||
|
||||
arguments = [(b"testName", NativeString()), (b"todo", NativeString())]
|
||||
response = [(b"success", Boolean())]
|
||||
|
||||
|
||||
class TestWrite(Command):
|
||||
"""
|
||||
Write test log.
|
||||
"""
|
||||
|
||||
arguments = [(b"out", NativeString())]
|
||||
response = [(b"success", Boolean())]
|
||||
@@ -0,0 +1,28 @@
|
||||
# -*- test-case-name: twisted.trial._dist.test.test_options -*-
|
||||
#
|
||||
# Copyright (c) Twisted Matrix Laboratories.
|
||||
# See LICENSE for details.
|
||||
|
||||
"""
|
||||
Options handling specific to trial's workers.
|
||||
|
||||
@since: 12.3
|
||||
"""
|
||||
|
||||
from twisted.application.app import ReactorSelectionMixin
|
||||
from twisted.python.filepath import FilePath
|
||||
from twisted.python.usage import Options
|
||||
from twisted.scripts.trial import _BasicOptions
|
||||
|
||||
|
||||
class WorkerOptions(_BasicOptions, Options, ReactorSelectionMixin):
|
||||
"""
|
||||
Options forwarded to the trial distributed worker.
|
||||
"""
|
||||
|
||||
def coverdir(self):
|
||||
"""
|
||||
Return a L{FilePath} representing the directory into which coverage
|
||||
results should be written.
|
||||
"""
|
||||
return FilePath("coverage")
|
||||
@@ -0,0 +1,100 @@
|
||||
"""
|
||||
Buffer byte streams.
|
||||
"""
|
||||
|
||||
from itertools import count
|
||||
from typing import Dict, Iterator, List, TypeVar
|
||||
|
||||
from attrs import Factory, define
|
||||
|
||||
from twisted.protocols.amp import AMP, Command, Integer, String as Bytes
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class StreamOpen(Command):
|
||||
"""
|
||||
Open a new stream.
|
||||
"""
|
||||
|
||||
response = [(b"streamId", Integer())]
|
||||
|
||||
|
||||
class StreamWrite(Command):
|
||||
"""
|
||||
Write a chunk of data to a stream.
|
||||
"""
|
||||
|
||||
arguments = [
|
||||
(b"streamId", Integer()),
|
||||
(b"data", Bytes()),
|
||||
]
|
||||
|
||||
|
||||
@define
|
||||
class StreamReceiver:
|
||||
"""
|
||||
Buffering de-multiplexing byte stream receiver.
|
||||
"""
|
||||
|
||||
_counter: Iterator[int] = count()
|
||||
_streams: Dict[int, List[bytes]] = Factory(dict)
|
||||
|
||||
def open(self) -> int:
|
||||
"""
|
||||
Open a new stream and return its unique identifier.
|
||||
"""
|
||||
newId = next(self._counter)
|
||||
self._streams[newId] = []
|
||||
return newId
|
||||
|
||||
def write(self, streamId: int, chunk: bytes) -> None:
|
||||
"""
|
||||
Write to an open stream using its unique identifier.
|
||||
|
||||
@raise KeyError: If there is no such open stream.
|
||||
"""
|
||||
self._streams[streamId].append(chunk)
|
||||
|
||||
def finish(self, streamId: int) -> List[bytes]:
|
||||
"""
|
||||
Indicate an open stream may receive no further data and return all of
|
||||
its current contents.
|
||||
|
||||
@raise KeyError: If there is no such open stream.
|
||||
"""
|
||||
return self._streams.pop(streamId)
|
||||
|
||||
|
||||
def chunk(data: bytes, chunkSize: int) -> Iterator[bytes]:
|
||||
"""
|
||||
Break a byte string into pieces of no more than ``chunkSize`` length.
|
||||
|
||||
@param data: The byte string.
|
||||
|
||||
@param chunkSize: The maximum length of the resulting pieces. All pieces
|
||||
except possibly the last will be this length.
|
||||
|
||||
@return: The pieces.
|
||||
"""
|
||||
pos = 0
|
||||
while pos < len(data):
|
||||
yield data[pos : pos + chunkSize]
|
||||
pos += chunkSize
|
||||
|
||||
|
||||
async def stream(amp: AMP, chunks: Iterator[bytes]) -> int:
|
||||
"""
|
||||
Send the given stream chunks, one by one, over the given connection.
|
||||
|
||||
The chunks are sent using L{StreamWrite} over a stream opened using
|
||||
L{StreamOpen}.
|
||||
|
||||
@return: The identifier of the stream over which the chunks were sent.
|
||||
"""
|
||||
streamId = (await amp.callRemote(StreamOpen))["streamId"]
|
||||
assert isinstance(streamId, int)
|
||||
|
||||
for oneChunk in chunks:
|
||||
await amp.callRemote(StreamWrite, streamId=streamId, data=oneChunk)
|
||||
return streamId
|
||||
@@ -0,0 +1,6 @@
|
||||
# Copyright (c) Twisted Matrix Laboratories.
|
||||
# See LICENSE for details.
|
||||
|
||||
"""
|
||||
Distributed trial test runner tests.
|
||||
"""
|
||||
@@ -0,0 +1,192 @@
|
||||
# Copyright (c) Twisted Matrix Laboratories.
|
||||
# See LICENSE for details.
|
||||
|
||||
"""
|
||||
Hamcrest matchers useful throughout the test suite.
|
||||
"""
|
||||
|
||||
__all__ = [
|
||||
"matches_result",
|
||||
"HasSum",
|
||||
"IsSequenceOf",
|
||||
]
|
||||
|
||||
from typing import List, Sequence, Tuple, TypeVar
|
||||
|
||||
from hamcrest import (
|
||||
contains_exactly,
|
||||
contains_string,
|
||||
equal_to,
|
||||
has_length,
|
||||
has_properties,
|
||||
instance_of,
|
||||
)
|
||||
from hamcrest.core.base_matcher import BaseMatcher
|
||||
from hamcrest.core.core.allof import AllOf
|
||||
from hamcrest.core.description import Description
|
||||
from hamcrest.core.matcher import Matcher
|
||||
from typing_extensions import Protocol
|
||||
|
||||
from twisted.python.failure import Failure
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class Semigroup(Protocol[T]):
|
||||
"""
|
||||
A type with an associative binary operator.
|
||||
|
||||
Common examples of a semigroup are integers with addition and strings with
|
||||
concatenation.
|
||||
"""
|
||||
|
||||
def __add__(self, other: T) -> T:
|
||||
"""
|
||||
This must be associative: a + (b + c) == (a + b) + c
|
||||
"""
|
||||
|
||||
|
||||
S = TypeVar("S", bound=Semigroup)
|
||||
|
||||
|
||||
def matches_result(
|
||||
successes: Matcher = equal_to(0),
|
||||
errors: Matcher = has_length(0),
|
||||
failures: Matcher = has_length(0),
|
||||
skips: Matcher = has_length(0),
|
||||
expectedFailures: Matcher = has_length(0),
|
||||
unexpectedSuccesses: Matcher = has_length(0),
|
||||
) -> Matcher:
|
||||
"""
|
||||
Match a L{TestCase} instances with matching attributes.
|
||||
"""
|
||||
return has_properties(
|
||||
{
|
||||
"successes": successes,
|
||||
"errors": errors,
|
||||
"failures": failures,
|
||||
"skips": skips,
|
||||
"expectedFailures": expectedFailures,
|
||||
"unexpectedSuccesses": unexpectedSuccesses,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class HasSum(BaseMatcher[Sequence[S]]):
|
||||
"""
|
||||
Match a sequence the elements of which sum to a value matched by
|
||||
another matcher.
|
||||
|
||||
:ivar sumMatcher: The matcher which must match the sum.
|
||||
:ivar zero: The zero value for the matched type.
|
||||
"""
|
||||
|
||||
def __init__(self, sumMatcher: Matcher[S], zero: S) -> None:
|
||||
self.sumMatcher = sumMatcher
|
||||
self.zero = zero
|
||||
|
||||
def _sum(self, sequence: Sequence[S]) -> S:
|
||||
if not sequence:
|
||||
return self.zero
|
||||
result = self.zero
|
||||
for elem in sequence:
|
||||
result = result + elem
|
||||
return result
|
||||
|
||||
def _matches(self, item: Sequence[S]) -> bool:
|
||||
"""
|
||||
Determine whether the sum of the sequence is matched.
|
||||
"""
|
||||
s = self._sum(item)
|
||||
return self.sumMatcher.matches(s)
|
||||
|
||||
def describe_mismatch(self, item: Sequence[S], description: Description) -> None:
|
||||
"""
|
||||
Describe the mismatch.
|
||||
"""
|
||||
s = self._sum(item)
|
||||
description.append_description_of(self)
|
||||
self.sumMatcher.describe_mismatch(s, description)
|
||||
return None
|
||||
|
||||
def describe_to(self, description: Description) -> None:
|
||||
"""
|
||||
Describe this matcher for error messages.
|
||||
"""
|
||||
description.append_text("a sequence with sum ")
|
||||
description.append_description_of(self.sumMatcher)
|
||||
description.append_text(", ")
|
||||
|
||||
|
||||
class IsSequenceOf(BaseMatcher[Sequence[T]]):
|
||||
"""
|
||||
Match a sequence where every element is matched by another matcher.
|
||||
|
||||
:ivar elementMatcher: The matcher which must match every element of the
|
||||
sequence.
|
||||
"""
|
||||
|
||||
def __init__(self, elementMatcher: Matcher[T]) -> None:
|
||||
self.elementMatcher = elementMatcher
|
||||
|
||||
def _matches(self, item: Sequence[T]) -> bool:
|
||||
"""
|
||||
Determine whether every element of the sequence is matched.
|
||||
"""
|
||||
for elem in item:
|
||||
if not self.elementMatcher.matches(elem):
|
||||
return False
|
||||
return True
|
||||
|
||||
def describe_mismatch(self, item: Sequence[T], description: Description) -> None:
|
||||
"""
|
||||
Describe the mismatch.
|
||||
"""
|
||||
for idx, elem in enumerate(item):
|
||||
if not self.elementMatcher.matches(elem):
|
||||
description.append_description_of(self)
|
||||
description.append_text(f"not sequence with element #{idx} {elem!r}")
|
||||
|
||||
def describe_to(self, description: Description) -> None:
|
||||
"""
|
||||
Describe this matcher for error messages.
|
||||
"""
|
||||
description.append_text("a sequence containing only ")
|
||||
description.append_description_of(self.elementMatcher)
|
||||
description.append_text(", ")
|
||||
|
||||
|
||||
def isFailure(**properties: Matcher[object]) -> Matcher[object]:
|
||||
"""
|
||||
Match an instance of L{Failure} with matching attributes.
|
||||
"""
|
||||
return AllOf(
|
||||
instance_of(Failure),
|
||||
has_properties(**properties),
|
||||
)
|
||||
|
||||
|
||||
def similarFrame(
|
||||
functionName: str, fileName: str
|
||||
) -> Matcher[Sequence[Tuple[str, str, int, List[object], List[object]]]]:
|
||||
"""
|
||||
Match a tuple representation of a frame like those used by
|
||||
L{twisted.python.failure.Failure}.
|
||||
"""
|
||||
# The frames depend on exact layout of the source
|
||||
# code in files and on the filesystem so we won't
|
||||
# bother being very precise here. Just verify we
|
||||
# see some distinctive fragments.
|
||||
#
|
||||
# In particular, the last frame should be a tuple like
|
||||
#
|
||||
# (functionName, fileName, someint, [], [])
|
||||
return contains_exactly(
|
||||
equal_to(functionName),
|
||||
contains_string(fileName), # type: ignore[arg-type]
|
||||
instance_of(int), # type: ignore[arg-type]
|
||||
# Unfortunately Failure makes them sometimes tuples, sometimes
|
||||
# dict_items.
|
||||
has_length(0), # type: ignore[arg-type]
|
||||
has_length(0), # type: ignore[arg-type]
|
||||
)
|
||||
@@ -0,0 +1,61 @@
|
||||
# Copyright (c) Twisted Matrix Laboratories.
|
||||
# See LICENSE for details.
|
||||
|
||||
"""
|
||||
Tests for L{twisted.trial._dist.distreporter}.
|
||||
"""
|
||||
|
||||
from io import StringIO
|
||||
|
||||
from twisted.python.failure import Failure
|
||||
from twisted.trial._dist.distreporter import DistReporter
|
||||
from twisted.trial.reporter import TreeReporter
|
||||
from twisted.trial.unittest import TestCase
|
||||
|
||||
|
||||
class DistReporterTests(TestCase):
|
||||
"""
|
||||
Tests for L{DistReporter}.
|
||||
"""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.stream = StringIO()
|
||||
self.distReporter = DistReporter(TreeReporter(self.stream))
|
||||
self.test = TestCase()
|
||||
|
||||
def test_startSuccessStop(self) -> None:
|
||||
"""
|
||||
Success output only gets sent to the stream after the test has stopped.
|
||||
"""
|
||||
self.distReporter.startTest(self.test)
|
||||
self.assertEqual(self.stream.getvalue(), "")
|
||||
self.distReporter.addSuccess(self.test)
|
||||
self.assertEqual(self.stream.getvalue(), "")
|
||||
self.distReporter.stopTest(self.test)
|
||||
self.assertNotEqual(self.stream.getvalue(), "")
|
||||
|
||||
def test_startErrorStop(self) -> None:
|
||||
"""
|
||||
Error output only gets sent to the stream after the test has stopped.
|
||||
"""
|
||||
self.distReporter.startTest(self.test)
|
||||
self.assertEqual(self.stream.getvalue(), "")
|
||||
self.distReporter.addError(self.test, Failure(Exception("error")))
|
||||
self.assertEqual(self.stream.getvalue(), "")
|
||||
self.distReporter.stopTest(self.test)
|
||||
self.assertNotEqual(self.stream.getvalue(), "")
|
||||
|
||||
def test_forwardedMethods(self) -> None:
|
||||
"""
|
||||
Calling methods of L{DistReporter} add calls to the running queue of
|
||||
the test.
|
||||
"""
|
||||
self.distReporter.startTest(self.test)
|
||||
self.distReporter.addFailure(self.test, Failure(Exception("foo")))
|
||||
self.distReporter.addError(self.test, Failure(Exception("bar")))
|
||||
self.distReporter.addSkip(self.test, "egg")
|
||||
self.distReporter.addUnexpectedSuccess(self.test, "spam")
|
||||
self.distReporter.addExpectedFailure(
|
||||
self.test, Failure(Exception("err")), "foo"
|
||||
)
|
||||
self.assertEqual(len(self.distReporter.running[self.test.id()]), 6)
|
||||
@@ -0,0 +1,843 @@
|
||||
# Copyright (c) Twisted Matrix Laboratories.
|
||||
# See LICENSE for details.
|
||||
|
||||
"""
|
||||
Tests for L{twisted.trial._dist.disttrial}.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from functools import partial
|
||||
from io import StringIO
|
||||
from os.path import sep
|
||||
from typing import Callable, List, Set
|
||||
from unittest import TestCase as PyUnitTestCase
|
||||
|
||||
from zope.interface import implementer, verify
|
||||
|
||||
from attrs import Factory, assoc, define, field
|
||||
from hamcrest import (
|
||||
assert_that,
|
||||
contains,
|
||||
ends_with,
|
||||
equal_to,
|
||||
has_length,
|
||||
none,
|
||||
starts_with,
|
||||
)
|
||||
from hamcrest.core.core.allof import AllOf
|
||||
from hypothesis import given
|
||||
from hypothesis.strategies import booleans, sampled_from
|
||||
|
||||
from twisted.internet import interfaces
|
||||
from twisted.internet.base import ReactorBase
|
||||
from twisted.internet.defer import Deferred, succeed
|
||||
from twisted.internet.error import ProcessDone
|
||||
from twisted.internet.protocol import ProcessProtocol, Protocol
|
||||
from twisted.internet.test.modulehelpers import AlternateReactor
|
||||
from twisted.python.failure import Failure
|
||||
from twisted.python.filepath import FilePath
|
||||
from twisted.python.lockfile import FilesystemLock
|
||||
from twisted.test.proto_helpers import MemoryReactorClock
|
||||
from twisted.trial._dist import _WORKER_AMP_STDIN
|
||||
from twisted.trial._dist.distreporter import DistReporter
|
||||
from twisted.trial._dist.disttrial import DistTrialRunner, WorkerPool, WorkerPoolConfig
|
||||
from twisted.trial._dist.functional import (
|
||||
countingCalls,
|
||||
discardResult,
|
||||
fromOptional,
|
||||
iterateWhile,
|
||||
sequence,
|
||||
)
|
||||
from twisted.trial._dist.worker import LocalWorker, RunResult, Worker, WorkerAction
|
||||
from twisted.trial.reporter import (
|
||||
Reporter,
|
||||
TestResult,
|
||||
TreeReporter,
|
||||
UncleanWarningsReporterWrapper,
|
||||
)
|
||||
from twisted.trial.runner import ErrorHolder, TrialSuite
|
||||
from twisted.trial.unittest import SynchronousTestCase, TestCase
|
||||
from ...test import erroneous, sample
|
||||
from .matchers import matches_result
|
||||
|
||||
|
||||
@define
|
||||
class FakeTransport:
|
||||
"""
|
||||
A simple fake process transport.
|
||||
"""
|
||||
|
||||
_closed: Set[int] = field(default=Factory(set))
|
||||
|
||||
def writeToChild(self, fd, data):
|
||||
"""
|
||||
Ignore write calls.
|
||||
"""
|
||||
|
||||
def closeChildFD(self, fd):
|
||||
"""
|
||||
Mark one of the child descriptors as closed.
|
||||
"""
|
||||
self._closed.add(fd)
|
||||
|
||||
|
||||
@implementer(interfaces.IReactorProcess)
|
||||
class CountingReactor(MemoryReactorClock):
|
||||
"""
|
||||
A fake reactor that counts the calls to L{IReactorCore.run},
|
||||
L{IReactorCore.stop}, and L{IReactorProcess.spawnProcess}.
|
||||
"""
|
||||
|
||||
spawnCount = 0
|
||||
stopCount = 0
|
||||
runCount = 0
|
||||
|
||||
def __init__(self, workers):
|
||||
MemoryReactorClock.__init__(self)
|
||||
self._workers = workers
|
||||
|
||||
def spawnProcess(
|
||||
self,
|
||||
workerProto,
|
||||
executable,
|
||||
args=(),
|
||||
env={},
|
||||
path=None,
|
||||
uid=None,
|
||||
gid=None,
|
||||
usePTY=0,
|
||||
childFDs=None,
|
||||
):
|
||||
"""
|
||||
See L{IReactorProcess.spawnProcess}.
|
||||
|
||||
@param workerProto: See L{IReactorProcess.spawnProcess}.
|
||||
@param args: See L{IReactorProcess.spawnProcess}.
|
||||
@param kwargs: See L{IReactorProcess.spawnProcess}.
|
||||
"""
|
||||
self._workers.append(workerProto)
|
||||
workerProto.makeConnection(FakeTransport())
|
||||
self.spawnCount += 1
|
||||
|
||||
def stop(self):
|
||||
"""
|
||||
See L{IReactorCore.stop}.
|
||||
"""
|
||||
MemoryReactorClock.stop(self)
|
||||
self.stopCount += 1
|
||||
|
||||
def run(self):
|
||||
"""
|
||||
See L{IReactorCore.run}.
|
||||
"""
|
||||
self.runCount += 1
|
||||
|
||||
# The same as IReactorCore.run, except no stop.
|
||||
self.running = True
|
||||
self.hasRun = True
|
||||
|
||||
for f, args, kwargs in self.whenRunningHooks:
|
||||
f(*args, **kwargs)
|
||||
|
||||
|
||||
class CountingReactorTests(SynchronousTestCase):
|
||||
"""
|
||||
Tests for L{CountingReactor}.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.workers = []
|
||||
self.reactor = CountingReactor(self.workers)
|
||||
|
||||
def test_providesIReactorProcess(self):
|
||||
"""
|
||||
L{CountingReactor} instances provide L{IReactorProcess}.
|
||||
"""
|
||||
verify.verifyObject(interfaces.IReactorProcess, self.reactor)
|
||||
|
||||
def test_spawnProcess(self):
|
||||
"""
|
||||
The process protocol for a spawned process is connected to a
|
||||
transport and appended onto the provided C{workers} list, and
|
||||
the reactor's C{spawnCount} increased.
|
||||
"""
|
||||
self.assertFalse(self.reactor.spawnCount)
|
||||
|
||||
proto = Protocol()
|
||||
for count in [1, 2]:
|
||||
self.reactor.spawnProcess(proto, sys.executable, args=[sys.executable])
|
||||
self.assertTrue(proto.transport)
|
||||
self.assertEqual(self.workers, [proto] * count)
|
||||
self.assertEqual(self.reactor.spawnCount, count)
|
||||
|
||||
def test_stop(self):
|
||||
"""
|
||||
Stopping the reactor increments its C{stopCount}
|
||||
"""
|
||||
self.assertFalse(self.reactor.stopCount)
|
||||
for count in [1, 2]:
|
||||
self.reactor.stop()
|
||||
self.assertEqual(self.reactor.stopCount, count)
|
||||
|
||||
def test_run(self):
|
||||
"""
|
||||
Running the reactor increments its C{runCount}, does not imply
|
||||
C{stop}, and calls L{IReactorCore.callWhenRunning} hooks.
|
||||
"""
|
||||
self.assertFalse(self.reactor.runCount)
|
||||
|
||||
whenRunningCalls = []
|
||||
self.reactor.callWhenRunning(whenRunningCalls.append, None)
|
||||
|
||||
for count in [1, 2]:
|
||||
self.reactor.run()
|
||||
self.assertEqual(self.reactor.runCount, count)
|
||||
self.assertEqual(self.reactor.stopCount, 0)
|
||||
self.assertEqual(len(whenRunningCalls), count)
|
||||
|
||||
|
||||
class WorkerPoolTests(TestCase):
|
||||
"""
|
||||
Tests for L{WorkerPool}.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.parent = FilePath(self.mktemp())
|
||||
self.workingDirectory = self.parent.child("_trial_temp")
|
||||
self.config = WorkerPoolConfig(
|
||||
numWorkers=4,
|
||||
workingDirectory=self.workingDirectory,
|
||||
workerArguments=[],
|
||||
logFile="out.log",
|
||||
)
|
||||
self.pool = WorkerPool(self.config)
|
||||
|
||||
def test_createLocalWorkers(self):
|
||||
"""
|
||||
C{_createLocalWorkers} iterates the list of protocols and create one
|
||||
L{LocalWorker} for each.
|
||||
"""
|
||||
protocols = [object() for x in range(4)]
|
||||
workers = self.pool._createLocalWorkers(protocols, FilePath("path"), StringIO())
|
||||
for s in workers:
|
||||
self.assertIsInstance(s, LocalWorker)
|
||||
self.assertEqual(4, len(workers))
|
||||
|
||||
def test_launchWorkerProcesses(self):
|
||||
"""
|
||||
Given a C{spawnProcess} function, C{_launchWorkerProcess} launches a
|
||||
python process with an existing path as its argument.
|
||||
"""
|
||||
protocols = [ProcessProtocol() for i in range(4)]
|
||||
arguments = []
|
||||
environment = {}
|
||||
|
||||
def fakeSpawnProcess(
|
||||
processProtocol,
|
||||
executable,
|
||||
args=(),
|
||||
env={},
|
||||
path=None,
|
||||
uid=None,
|
||||
gid=None,
|
||||
usePTY=0,
|
||||
childFDs=None,
|
||||
):
|
||||
arguments.append(executable)
|
||||
arguments.extend(args)
|
||||
environment.update(env)
|
||||
|
||||
self.pool._launchWorkerProcesses(fakeSpawnProcess, protocols, ["foo"])
|
||||
self.assertEqual(arguments[0], arguments[1])
|
||||
self.assertTrue(os.path.exists(arguments[2]))
|
||||
self.assertEqual("foo", arguments[3])
|
||||
# The child process runs with PYTHONPATH set to exactly the parent's
|
||||
# import search path so that the child has a good chance of finding
|
||||
# the same source files the parent would have found.
|
||||
self.assertEqual(os.pathsep.join(sys.path), environment["PYTHONPATH"])
|
||||
|
||||
def test_run(self):
|
||||
"""
|
||||
C{run} dispatches the given action to each of its workers exactly once.
|
||||
"""
|
||||
# Make sure the parent of the working directory exists so
|
||||
# manage a lock in it.
|
||||
self.parent.makedirs()
|
||||
|
||||
workers = []
|
||||
starting = self.pool.start(CountingReactor([]))
|
||||
started = self.successResultOf(starting)
|
||||
running = started.run(lambda w: succeed(workers.append(w)))
|
||||
self.successResultOf(running)
|
||||
assert_that(workers, has_length(self.config.numWorkers))
|
||||
|
||||
def test_runUsedDirectory(self):
|
||||
"""
|
||||
L{WorkerPool.start} checks if the test directory is already locked, and if
|
||||
it is generates a name based on it.
|
||||
"""
|
||||
# Make sure the parent of the working directory exists so we can
|
||||
# manage a lock in it.
|
||||
self.parent.makedirs()
|
||||
|
||||
# Lock the directory the runner will expect to use.
|
||||
lock = FilesystemLock(self.workingDirectory.path + ".lock")
|
||||
self.assertTrue(lock.lock())
|
||||
self.addCleanup(lock.unlock)
|
||||
|
||||
# Start up the pool
|
||||
fakeReactor = CountingReactor([])
|
||||
started = self.successResultOf(self.pool.start(fakeReactor))
|
||||
|
||||
# Verify it took a nearby directory instead.
|
||||
self.assertEqual(
|
||||
started.workingDirectory,
|
||||
self.workingDirectory.sibling("_trial_temp-1"),
|
||||
)
|
||||
|
||||
def test_join(self):
|
||||
"""
|
||||
L{StartedWorkerPool.join} causes all of the workers to exit, closes the
|
||||
log file, and unlocks the test directory.
|
||||
"""
|
||||
self.parent.makedirs()
|
||||
|
||||
reactor = CountingReactor([])
|
||||
started = self.successResultOf(self.pool.start(reactor))
|
||||
joining = Deferred.fromCoroutine(started.join())
|
||||
self.assertNoResult(joining)
|
||||
for w in reactor._workers:
|
||||
assert_that(w.transport._closed, contains(_WORKER_AMP_STDIN))
|
||||
for fd in w.transport._closed:
|
||||
w.childConnectionLost(fd)
|
||||
for f in [w.processExited, w.processEnded]:
|
||||
f(Failure(ProcessDone(0)))
|
||||
assert_that(self.successResultOf(joining), none())
|
||||
assert_that(started.testLog.closed, equal_to(True))
|
||||
assert_that(started.testDirLock.locked, equal_to(False))
|
||||
|
||||
@given(
|
||||
booleans(),
|
||||
sampled_from(
|
||||
[
|
||||
"out.log",
|
||||
f"subdir{sep}out.log",
|
||||
]
|
||||
),
|
||||
)
|
||||
def test_logFile(self, absolute: bool, logFile: str) -> None:
|
||||
"""
|
||||
L{WorkerPool.start} creates a L{StartedWorkerPool} configured with a
|
||||
log file based on the L{WorkerPoolConfig.logFile}.
|
||||
"""
|
||||
if absolute:
|
||||
logFile = self.parent.path + sep + logFile
|
||||
|
||||
config = assoc(self.config, logFile=logFile)
|
||||
|
||||
if absolute:
|
||||
matches = equal_to(logFile)
|
||||
else:
|
||||
matches = AllOf(
|
||||
# This might have a suffix if the configured workingDirectory
|
||||
# was found to be in-use already so we don't add a sep suffix.
|
||||
starts_with(config.workingDirectory.path),
|
||||
# This should be exactly the suffix so we add a sep prefix.
|
||||
ends_with(sep + logFile),
|
||||
)
|
||||
|
||||
pool = WorkerPool(config)
|
||||
started = self.successResultOf(pool.start(CountingReactor([])))
|
||||
assert_that(started.testLog.name, matches)
|
||||
|
||||
|
||||
class DistTrialRunnerTests(TestCase):
|
||||
"""
|
||||
Tests for L{DistTrialRunner}.
|
||||
"""
|
||||
|
||||
suite = TrialSuite([sample.FooTest("test_foo")])
|
||||
|
||||
def getRunner(self, **overrides):
|
||||
"""
|
||||
Create a runner for testing.
|
||||
"""
|
||||
args = dict(
|
||||
reporterFactory=TreeReporter,
|
||||
workingDirectory=self.mktemp(),
|
||||
stream=StringIO(),
|
||||
maxWorkers=4,
|
||||
workerArguments=[],
|
||||
workerPoolFactory=partial(LocalWorkerPool, autostop=True),
|
||||
reactor=CountingReactor([]),
|
||||
)
|
||||
args.update(overrides)
|
||||
return DistTrialRunner(**args)
|
||||
|
||||
def test_writeResults(self):
|
||||
"""
|
||||
L{DistTrialRunner.writeResults} writes to the stream specified in the
|
||||
init.
|
||||
"""
|
||||
stringIO = StringIO()
|
||||
result = DistReporter(Reporter(stringIO))
|
||||
runner = self.getRunner()
|
||||
runner.writeResults(result)
|
||||
self.assertTrue(stringIO.tell() > 0)
|
||||
|
||||
def test_minimalWorker(self):
|
||||
"""
|
||||
L{DistTrialRunner.runAsync} doesn't try to start more workers than the
|
||||
number of tests.
|
||||
"""
|
||||
pool = None
|
||||
|
||||
def recordingFactory(*a, **kw):
|
||||
nonlocal pool
|
||||
pool = LocalWorkerPool(*a, autostop=True, **kw)
|
||||
return pool
|
||||
|
||||
maxWorkers = 7
|
||||
numTests = 3
|
||||
|
||||
runner = self.getRunner(
|
||||
maxWorkers=maxWorkers, workerPoolFactory=recordingFactory
|
||||
)
|
||||
suite = TrialSuite([TestCase() for n in range(numTests)])
|
||||
self.successResultOf(runner.runAsync(suite))
|
||||
assert_that(pool._started[0].workers, has_length(numTests))
|
||||
|
||||
def test_runUncleanWarnings(self) -> None:
|
||||
"""
|
||||
Running with the C{unclean-warnings} option makes L{DistTrialRunner} uses
|
||||
the L{UncleanWarningsReporterWrapper}.
|
||||
"""
|
||||
runner = self.getRunner(uncleanWarnings=True)
|
||||
d = runner.runAsync(self.suite)
|
||||
result = self.successResultOf(d)
|
||||
self.assertIsInstance(result, DistReporter)
|
||||
self.assertIsInstance(result.original, UncleanWarningsReporterWrapper)
|
||||
|
||||
def test_runWithoutTest(self):
|
||||
"""
|
||||
L{DistTrialRunner} can run an empty test suite.
|
||||
"""
|
||||
stream = StringIO()
|
||||
runner = self.getRunner(stream=stream)
|
||||
result = self.successResultOf(runner.runAsync(TrialSuite()))
|
||||
self.assertIsInstance(result, DistReporter)
|
||||
output = stream.getvalue()
|
||||
self.assertIn("Running 0 test", output)
|
||||
self.assertIn("PASSED", output)
|
||||
|
||||
def test_runWithoutTestButWithAnError(self):
|
||||
"""
|
||||
Even if there is no test, the suite can contain an error (most likely,
|
||||
an import error): this should make the run fail, and the error should
|
||||
be printed.
|
||||
"""
|
||||
err = ErrorHolder("an error", Failure(RuntimeError("foo bar")))
|
||||
stream = StringIO()
|
||||
runner = self.getRunner(stream=stream)
|
||||
|
||||
result = self.successResultOf(runner.runAsync(err))
|
||||
self.assertIsInstance(result, DistReporter)
|
||||
output = stream.getvalue()
|
||||
self.assertIn("Running 0 test", output)
|
||||
self.assertIn("foo bar", output)
|
||||
self.assertIn("an error", output)
|
||||
self.assertIn("errors=1", output)
|
||||
self.assertIn("FAILED", output)
|
||||
|
||||
def test_runUnexpectedError(self) -> None:
|
||||
"""
|
||||
If for some reasons we can't connect to the worker process, the error is
|
||||
recorded in the result object.
|
||||
"""
|
||||
runner = self.getRunner(workerPoolFactory=BrokenWorkerPool)
|
||||
result = self.successResultOf(runner.runAsync(self.suite))
|
||||
errors = result.original.errors
|
||||
assert_that(errors, has_length(1))
|
||||
assert_that(errors[0][1].type, equal_to(WorkerPoolBroken))
|
||||
|
||||
def test_runUnexpectedWorkerError(self) -> None:
|
||||
"""
|
||||
If for some reason the worker process cannot run a test, the error is
|
||||
recorded in the result object.
|
||||
"""
|
||||
runner = self.getRunner(
|
||||
workerPoolFactory=partial(
|
||||
LocalWorkerPool, workerFactory=_BrokenLocalWorker, autostop=True
|
||||
)
|
||||
)
|
||||
result = self.successResultOf(runner.runAsync(self.suite))
|
||||
errors = result.original.errors
|
||||
assert_that(errors, has_length(1))
|
||||
assert_that(errors[0][1].type, equal_to(WorkerBroken))
|
||||
|
||||
def test_runWaitForProcessesDeferreds(self) -> None:
|
||||
"""
|
||||
L{DistTrialRunner} waits for the worker pool to stop.
|
||||
"""
|
||||
pool = None
|
||||
|
||||
def recordingFactory(*a, **kw):
|
||||
nonlocal pool
|
||||
pool = LocalWorkerPool(*a, autostop=False, **kw)
|
||||
return pool
|
||||
|
||||
runner = self.getRunner(
|
||||
workerPoolFactory=recordingFactory,
|
||||
)
|
||||
d = Deferred.fromCoroutine(runner.runAsync(self.suite))
|
||||
if pool is None:
|
||||
self.fail("worker pool was never created")
|
||||
|
||||
assert pool is not None
|
||||
stopped = pool._started[0]._stopped
|
||||
self.assertNoResult(d)
|
||||
stopped.callback(None)
|
||||
result = self.successResultOf(d)
|
||||
self.assertIsInstance(result, DistReporter)
|
||||
|
||||
def test_exitFirst(self):
|
||||
"""
|
||||
L{DistTrialRunner} can run in C{exitFirst} mode where it will run until a
|
||||
test fails and then abandon the rest of the suite.
|
||||
"""
|
||||
stream = StringIO()
|
||||
# Construct a suite with a failing test in the middle.
|
||||
suite = TrialSuite(
|
||||
[
|
||||
sample.FooTest("test_foo"),
|
||||
erroneous.TestRegularFail("test_fail"),
|
||||
sample.FooTest("test_bar"),
|
||||
]
|
||||
)
|
||||
runner = self.getRunner(stream=stream, exitFirst=True, maxWorkers=2)
|
||||
d = runner.runAsync(suite)
|
||||
result = self.successResultOf(d)
|
||||
assert_that(
|
||||
result.original,
|
||||
matches_result(
|
||||
successes=1,
|
||||
failures=has_length(1),
|
||||
),
|
||||
)
|
||||
|
||||
def test_runUntilFailure(self):
|
||||
"""
|
||||
L{DistTrialRunner} can run in C{untilFailure} mode where it will run
|
||||
the given tests until they fail.
|
||||
"""
|
||||
stream = StringIO()
|
||||
case = erroneous.EventuallyFailingTestCase("test_it")
|
||||
runner = self.getRunner(stream=stream)
|
||||
d = runner.runAsync(case, untilFailure=True)
|
||||
result = self.successResultOf(d)
|
||||
# The case is hard-coded to fail on its 5th run.
|
||||
self.assertEqual(5, case.n)
|
||||
self.assertFalse(result.wasSuccessful())
|
||||
output = stream.getvalue()
|
||||
|
||||
# It passes each time except the last.
|
||||
self.assertEqual(
|
||||
output.count("PASSED"),
|
||||
case.n - 1,
|
||||
"expected to see PASSED in output",
|
||||
)
|
||||
# It also fails at the end.
|
||||
self.assertIn("FAIL", output)
|
||||
|
||||
# It also reports its progress.
|
||||
for i in range(1, 6):
|
||||
self.assertIn(f"Test Pass {i}", output)
|
||||
|
||||
# It also reports the number of tests run as part of each iteration.
|
||||
self.assertEqual(
|
||||
output.count("Ran 1 tests in"),
|
||||
case.n,
|
||||
"expected to see per-iteration test count in output",
|
||||
)
|
||||
|
||||
def test_run(self) -> None:
|
||||
"""
|
||||
L{DistTrialRunner.run} returns a L{DistReporter} containing the result of
|
||||
the test suite run.
|
||||
"""
|
||||
runner = self.getRunner()
|
||||
result = runner.run(self.suite)
|
||||
assert_that(result.wasSuccessful(), equal_to(True))
|
||||
assert_that(result.successes, equal_to(1))
|
||||
|
||||
def test_installedReactor(self) -> None:
|
||||
"""
|
||||
L{DistTrialRunner.run} uses the installed reactor L{DistTrialRunner} was
|
||||
constructed without a reactor.
|
||||
"""
|
||||
reactor = CountingReactor([])
|
||||
with AlternateReactor(reactor):
|
||||
runner = self.getRunner(reactor=None)
|
||||
result = runner.run(self.suite)
|
||||
assert_that(result.errors, equal_to([]))
|
||||
assert_that(result.failures, equal_to([]))
|
||||
assert_that(result.wasSuccessful(), equal_to(True))
|
||||
assert_that(result.successes, equal_to(1))
|
||||
assert_that(reactor.runCount, equal_to(1))
|
||||
assert_that(reactor.stopCount, equal_to(1))
|
||||
|
||||
def test_wrongInstalledReactor(self) -> None:
|
||||
"""
|
||||
L{DistTrialRunner} raises L{TypeError} if the installed reactor provides
|
||||
neither L{IReactorCore} nor L{IReactorProcess} and no other reactor is
|
||||
given.
|
||||
"""
|
||||
|
||||
class Core(ReactorBase):
|
||||
def installWaker(self):
|
||||
pass
|
||||
|
||||
@implementer(interfaces.IReactorProcess)
|
||||
class Process:
|
||||
def spawnProcess(
|
||||
self,
|
||||
processProtocol,
|
||||
executable,
|
||||
args,
|
||||
env,
|
||||
path,
|
||||
uid,
|
||||
gid,
|
||||
usePTY,
|
||||
childFDs,
|
||||
):
|
||||
pass
|
||||
|
||||
class Neither:
|
||||
pass
|
||||
|
||||
# It provides neither
|
||||
with AlternateReactor(Neither()):
|
||||
with self.assertRaises(TypeError):
|
||||
self.getRunner(reactor=None)
|
||||
|
||||
# It is missing IReactorProcess
|
||||
with AlternateReactor(Core()):
|
||||
with self.assertRaises(TypeError):
|
||||
self.getRunner(reactor=None)
|
||||
|
||||
# It is missing IReactorCore
|
||||
with AlternateReactor(Process()):
|
||||
with self.assertRaises(TypeError):
|
||||
self.getRunner(reactor=None)
|
||||
|
||||
def test_runFailure(self):
|
||||
"""
|
||||
If there is an unexpected exception running the test suite then it is
|
||||
re-raised by L{DistTrialRunner.run}.
|
||||
"""
|
||||
# Give it a broken worker pool factory. There's no exception handling
|
||||
# for such an error in the implementation..
|
||||
class BrokenFactory(Exception):
|
||||
pass
|
||||
|
||||
def brokenFactory(*args, **kwargs):
|
||||
raise BrokenFactory()
|
||||
|
||||
runner = self.getRunner(workerPoolFactory=brokenFactory)
|
||||
with self.assertRaises(BrokenFactory):
|
||||
runner.run(self.suite)
|
||||
|
||||
|
||||
class FunctionalTests(TestCase):
|
||||
"""
|
||||
Tests for the functional helpers that need it.
|
||||
"""
|
||||
|
||||
def test_fromOptional(self) -> None:
|
||||
"""
|
||||
``fromOptional`` accepts a default value and an ``Optional`` value of the
|
||||
same type and returns the default value if the optional value is
|
||||
``None`` or the optional value otherwise.
|
||||
"""
|
||||
assert_that(fromOptional(1, None), equal_to(1))
|
||||
assert_that(fromOptional(2, 2), equal_to(2))
|
||||
|
||||
def test_discardResult(self) -> None:
|
||||
"""
|
||||
``discardResult`` accepts an awaitable and returns a ``Deferred`` that
|
||||
fires with ``None`` after the awaitable completes.
|
||||
"""
|
||||
a: Deferred[str] = Deferred()
|
||||
d = discardResult(a)
|
||||
self.assertNoResult(d)
|
||||
a.callback("result")
|
||||
assert_that(self.successResultOf(d), none())
|
||||
|
||||
def test_sequence(self):
|
||||
"""
|
||||
``sequence`` accepts two awaitables and returns an awaitable that waits
|
||||
for the first one to complete and then completes with the result of
|
||||
the second one.
|
||||
"""
|
||||
a: Deferred[str] = Deferred()
|
||||
b: Deferred[int] = Deferred()
|
||||
c = Deferred.fromCoroutine(sequence(a, b))
|
||||
b.callback(42)
|
||||
self.assertNoResult(c)
|
||||
a.callback("hello")
|
||||
assert_that(self.successResultOf(c), equal_to(42))
|
||||
|
||||
def test_iterateWhile(self):
|
||||
"""
|
||||
``iterateWhile`` executes the actions from its factory until the predicate
|
||||
does not match an action result.
|
||||
"""
|
||||
actions: List[Deferred[int]] = [Deferred(), Deferred(), Deferred()]
|
||||
|
||||
def predicate(value):
|
||||
return value != 42
|
||||
|
||||
d: Deferred[int] = Deferred.fromCoroutine(
|
||||
iterateWhile(predicate, list(actions).pop)
|
||||
)
|
||||
# Let the action it is waiting on complete
|
||||
actions.pop().callback(7)
|
||||
|
||||
# It does not match the predicate so it is not done yet.
|
||||
self.assertNoResult(d)
|
||||
|
||||
# Let the action it is waiting on now complete - with the result it
|
||||
# wants.
|
||||
actions.pop().callback(42)
|
||||
|
||||
assert_that(self.successResultOf(d), equal_to(42))
|
||||
|
||||
def test_countingCalls(self):
|
||||
"""
|
||||
``countingCalls`` decorates a function so that it is called with an
|
||||
increasing counter and passes the return value through.
|
||||
"""
|
||||
|
||||
@countingCalls
|
||||
def target(n: int) -> int:
|
||||
return n + 1
|
||||
|
||||
for expected in range(1, 10):
|
||||
assert_that(target(), equal_to(expected))
|
||||
|
||||
|
||||
class WorkerPoolBroken(Exception):
|
||||
"""
|
||||
An exception for ``StartedWorkerPoolBroken`` to fail with to allow tests
|
||||
to exercise exception code paths.
|
||||
"""
|
||||
|
||||
|
||||
class StartedWorkerPoolBroken:
|
||||
"""
|
||||
A broken, started worker pool. Its workers cannot run actions. They
|
||||
always raise an exception.
|
||||
"""
|
||||
|
||||
async def run(self, workerAction: WorkerAction) -> None:
|
||||
raise WorkerPoolBroken()
|
||||
|
||||
async def join(self) -> None:
|
||||
return None
|
||||
|
||||
|
||||
@define
|
||||
class BrokenWorkerPool:
|
||||
"""
|
||||
A worker pool that has workers with a broken ``run`` method.
|
||||
"""
|
||||
|
||||
_config: WorkerPoolConfig
|
||||
|
||||
async def start(
|
||||
self, reactor: interfaces.IReactorProcess
|
||||
) -> StartedWorkerPoolBroken:
|
||||
return StartedWorkerPoolBroken()
|
||||
|
||||
|
||||
class _LocalWorker:
|
||||
"""
|
||||
A L{Worker} that runs tests in this process in the usual way.
|
||||
|
||||
This is a test double for L{LocalWorkerAMP} which allows testing worker
|
||||
pool logic without sending tests over an AMP connection to be run
|
||||
somewhere else..
|
||||
"""
|
||||
|
||||
async def run(self, case: PyUnitTestCase, result: TestResult) -> RunResult:
|
||||
"""
|
||||
Directly run C{case} in the usual way.
|
||||
"""
|
||||
TrialSuite([case]).run(result)
|
||||
return {"success": True}
|
||||
|
||||
|
||||
class WorkerBroken(Exception):
|
||||
"""
|
||||
A worker tried to run a test case but the worker is broken.
|
||||
"""
|
||||
|
||||
|
||||
class _BrokenLocalWorker:
|
||||
"""
|
||||
A L{Worker} that always fails to run test cases.
|
||||
"""
|
||||
|
||||
async def run(self, case: PyUnitTestCase, result: TestResult) -> None:
|
||||
"""
|
||||
Raise an exception instead of running C{case}.
|
||||
"""
|
||||
raise WorkerBroken()
|
||||
|
||||
|
||||
@define
|
||||
class StartedLocalWorkerPool:
|
||||
"""
|
||||
A started L{LocalWorkerPool}.
|
||||
"""
|
||||
|
||||
workingDirectory: FilePath
|
||||
workers: List[Worker]
|
||||
_stopped: Deferred
|
||||
|
||||
async def run(self, workerAction: WorkerAction) -> None:
|
||||
"""
|
||||
Run the action with each local worker.
|
||||
"""
|
||||
for worker in self.workers:
|
||||
await workerAction(worker)
|
||||
|
||||
async def join(self):
|
||||
await self._stopped
|
||||
|
||||
|
||||
@define
|
||||
class LocalWorkerPool:
|
||||
"""
|
||||
Implement a worker pool that runs tests in-process instead of in child
|
||||
processes.
|
||||
"""
|
||||
|
||||
_config: WorkerPoolConfig
|
||||
_started: List[StartedLocalWorkerPool] = field(default=Factory(list))
|
||||
_autostop: bool = False
|
||||
_workerFactory: Callable[[], Worker] = _LocalWorker
|
||||
|
||||
async def start(
|
||||
self, reactor: interfaces.IReactorProcess
|
||||
) -> StartedLocalWorkerPool:
|
||||
workers = [self._workerFactory() for i in range(self._config.numWorkers)]
|
||||
started = StartedLocalWorkerPool(
|
||||
self._config.workingDirectory,
|
||||
workers,
|
||||
(succeed(None) if self._autostop else Deferred()),
|
||||
)
|
||||
self._started.append(started)
|
||||
return started
|
||||
@@ -0,0 +1,188 @@
|
||||
"""
|
||||
Tests for L{twisted.trial._dist.test.matchers}.
|
||||
"""
|
||||
|
||||
from typing import Callable, Sequence, Tuple, Type
|
||||
|
||||
from hamcrest import anything, assert_that, contains, contains_string, equal_to, not_
|
||||
from hamcrest.core.matcher import Matcher
|
||||
from hamcrest.core.string_description import StringDescription
|
||||
from hypothesis import given
|
||||
from hypothesis.strategies import (
|
||||
binary,
|
||||
booleans,
|
||||
integers,
|
||||
just,
|
||||
lists,
|
||||
one_of,
|
||||
sampled_from,
|
||||
text,
|
||||
tuples,
|
||||
)
|
||||
|
||||
from twisted.python.failure import Failure
|
||||
from twisted.trial.unittest import SynchronousTestCase
|
||||
from .matchers import HasSum, IsSequenceOf, S, isFailure, similarFrame
|
||||
|
||||
Summer = Callable[[Sequence[S]], S]
|
||||
concatInt = sum
|
||||
concatStr = "".join
|
||||
concatBytes = b"".join
|
||||
|
||||
|
||||
class HasSumTests(SynchronousTestCase):
|
||||
"""
|
||||
Tests for L{HasSum}.
|
||||
"""
|
||||
|
||||
summables = one_of(
|
||||
tuples(lists(integers()), just(concatInt)),
|
||||
tuples(lists(text()), just(concatStr)),
|
||||
tuples(lists(binary()), just(concatBytes)),
|
||||
)
|
||||
|
||||
@given(summables)
|
||||
def test_matches(self, summable: Tuple[Sequence[S], Summer[S]]) -> None:
|
||||
"""
|
||||
L{HasSum} matches a sequence if the elements sum to a value matched by
|
||||
the parameterized matcher.
|
||||
|
||||
:param summable: A tuple of a sequence of values to try to match and a
|
||||
function which can compute the correct sum for that sequence.
|
||||
"""
|
||||
seq, sumFunc = summable
|
||||
expected = sumFunc(seq)
|
||||
zero = sumFunc([])
|
||||
matcher = HasSum(equal_to(expected), zero)
|
||||
|
||||
description = StringDescription()
|
||||
assert_that(matcher.matches(seq, description), equal_to(True))
|
||||
assert_that(str(description), equal_to(""))
|
||||
|
||||
@given(summables)
|
||||
def test_mismatches(
|
||||
self,
|
||||
summable: Tuple[
|
||||
Sequence[S],
|
||||
Summer[S],
|
||||
],
|
||||
) -> None:
|
||||
"""
|
||||
L{HasSum} does not match a sequence if the elements do not sum to a
|
||||
value matched by the parameterized matcher.
|
||||
|
||||
:param summable: See L{test_matches}.
|
||||
"""
|
||||
seq, sumFunc = summable
|
||||
zero = sumFunc([])
|
||||
# A matcher that never matches.
|
||||
sumMatcher: Matcher[S] = not_(anything())
|
||||
matcher = HasSum(sumMatcher, zero)
|
||||
|
||||
actualDescription = StringDescription()
|
||||
assert_that(matcher.matches(seq, actualDescription), equal_to(False))
|
||||
|
||||
sumMatcherDescription = StringDescription()
|
||||
sumMatcherDescription.append_description_of(sumMatcher)
|
||||
actualStr = str(actualDescription)
|
||||
assert_that(actualStr, contains_string("a sequence with sum"))
|
||||
assert_that(actualStr, contains_string(str(sumMatcherDescription)))
|
||||
|
||||
|
||||
class IsSequenceOfTests(SynchronousTestCase):
|
||||
"""
|
||||
Tests for L{IsSequenceOf}.
|
||||
"""
|
||||
|
||||
sequences = lists(booleans())
|
||||
|
||||
@given(integers(min_value=0, max_value=1000))
|
||||
def test_matches(self, numItems: int) -> None:
|
||||
"""
|
||||
L{IsSequenceOf} matches a sequence if all of the elements are
|
||||
matched by the parameterized matcher.
|
||||
|
||||
:param numItems: The length of a sequence to try to match.
|
||||
"""
|
||||
seq = [True] * numItems
|
||||
matcher = IsSequenceOf(equal_to(True))
|
||||
|
||||
actualDescription = StringDescription()
|
||||
assert_that(matcher.matches(seq, actualDescription), equal_to(True))
|
||||
assert_that(str(actualDescription), equal_to(""))
|
||||
|
||||
@given(integers(min_value=0, max_value=1000), integers(min_value=0, max_value=1000))
|
||||
def test_mismatches(self, numBefore: int, numAfter: int) -> None:
|
||||
"""
|
||||
L{IsSequenceOf} does not match a sequence if any of the elements
|
||||
are not matched by the parameterized matcher.
|
||||
|
||||
:param numBefore: In the sequence to try to match, the number of
|
||||
elements expected to match before an expected mismatch.
|
||||
|
||||
:param numAfter: In the sequence to try to match, the number of
|
||||
elements expected expected to match after an expected mismatch.
|
||||
"""
|
||||
# Hide the non-matching value somewhere in the sequence.
|
||||
seq = [True] * numBefore + [False] + [True] * numAfter
|
||||
matcher = IsSequenceOf(equal_to(True))
|
||||
|
||||
actualDescription = StringDescription()
|
||||
assert_that(matcher.matches(seq, actualDescription), equal_to(False))
|
||||
actualStr = str(actualDescription)
|
||||
assert_that(actualStr, contains_string("a sequence containing only"))
|
||||
assert_that(
|
||||
actualStr, contains_string(f"not sequence with element #{numBefore}")
|
||||
)
|
||||
|
||||
|
||||
class IsFailureTests(SynchronousTestCase):
|
||||
"""
|
||||
Tests for L{isFailure}.
|
||||
"""
|
||||
|
||||
@given(sampled_from([ValueError, ZeroDivisionError, RuntimeError]))
|
||||
def test_matches(self, excType: Type[BaseException]) -> None:
|
||||
"""
|
||||
L{isFailure} matches instances of L{Failure} with matching
|
||||
attributes.
|
||||
|
||||
:param excType: An exception type to wrap in a L{Failure} to be
|
||||
matched against.
|
||||
"""
|
||||
matcher = isFailure(type=equal_to(excType))
|
||||
failure = Failure(excType())
|
||||
assert_that(matcher.matches(failure), equal_to(True))
|
||||
|
||||
@given(sampled_from([ValueError, ZeroDivisionError, RuntimeError]))
|
||||
def test_mismatches(self, excType: Type[BaseException]) -> None:
|
||||
"""
|
||||
L{isFailure} does not match instances of L{Failure} with
|
||||
attributes that don't match.
|
||||
|
||||
:param excType: An exception type to wrap in a L{Failure} to be
|
||||
matched against.
|
||||
"""
|
||||
matcher = isFailure(type=equal_to(excType), other=not_(anything()))
|
||||
failure = Failure(excType())
|
||||
assert_that(matcher.matches(failure), equal_to(False))
|
||||
|
||||
def test_frames(self):
|
||||
"""
|
||||
The L{similarFrame} matcher matches elements of the C{frames} list
|
||||
of a L{Failure}.
|
||||
"""
|
||||
try:
|
||||
raise ValueError("Oh no")
|
||||
except BaseException:
|
||||
f = Failure()
|
||||
|
||||
actualDescription = StringDescription()
|
||||
matcher = isFailure(
|
||||
frames=contains(similarFrame("test_frames", "test_matchers"))
|
||||
)
|
||||
assert_that(
|
||||
matcher.matches(f, actualDescription),
|
||||
equal_to(True),
|
||||
actualDescription,
|
||||
)
|
||||
@@ -0,0 +1,48 @@
|
||||
# Copyright (c) Twisted Matrix Laboratories.
|
||||
# See LICENSE for details.
|
||||
|
||||
"""
|
||||
Tests for distributed trial's options management.
|
||||
"""
|
||||
|
||||
import gc
|
||||
import os
|
||||
import sys
|
||||
|
||||
from twisted.trial._dist.options import WorkerOptions
|
||||
from twisted.trial.unittest import TestCase
|
||||
|
||||
|
||||
class WorkerOptionsTests(TestCase):
|
||||
"""
|
||||
Tests for L{WorkerOptions}.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
"""
|
||||
Build an L{WorkerOptions} object to be used in the tests.
|
||||
"""
|
||||
self.options = WorkerOptions()
|
||||
|
||||
def test_standardOptions(self):
|
||||
"""
|
||||
L{WorkerOptions} supports a subset of standard options supported by
|
||||
trial.
|
||||
"""
|
||||
self.addCleanup(sys.setrecursionlimit, sys.getrecursionlimit())
|
||||
if gc.isenabled():
|
||||
self.addCleanup(gc.enable)
|
||||
gc.enable()
|
||||
self.options.parseOptions(["--recursionlimit", "2000", "--disablegc"])
|
||||
self.assertEqual(2000, sys.getrecursionlimit())
|
||||
self.assertFalse(gc.isenabled())
|
||||
|
||||
def test_coverage(self):
|
||||
"""
|
||||
L{WorkerOptions.coverdir} returns the C{coverage} child directory of
|
||||
the current directory to be used for storing coverage data.
|
||||
"""
|
||||
self.assertEqual(
|
||||
os.path.realpath(os.path.join(os.getcwd(), "coverage")),
|
||||
self.options.coverdir().path,
|
||||
)
|
||||
@@ -0,0 +1,208 @@
|
||||
"""
|
||||
Tests for L{twisted.trial._dist.stream}.
|
||||
"""
|
||||
|
||||
from random import Random
|
||||
from typing import Awaitable, Dict, List, TypeVar, Union
|
||||
|
||||
from hamcrest import (
|
||||
all_of,
|
||||
assert_that,
|
||||
calling,
|
||||
equal_to,
|
||||
has_length,
|
||||
is_,
|
||||
less_than_or_equal_to,
|
||||
raises,
|
||||
)
|
||||
from hypothesis import given
|
||||
from hypothesis.strategies import binary, integers, just, lists, randoms, text
|
||||
|
||||
from twisted.internet.defer import Deferred, fail
|
||||
from twisted.internet.interfaces import IProtocol
|
||||
from twisted.internet.protocol import Protocol
|
||||
from twisted.protocols.amp import AMP
|
||||
from twisted.python.failure import Failure
|
||||
from twisted.test.iosim import FakeTransport, connect
|
||||
from twisted.trial.unittest import SynchronousTestCase
|
||||
from ..stream import StreamOpen, StreamReceiver, StreamWrite, chunk, stream
|
||||
from .matchers import HasSum, IsSequenceOf
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class StreamReceiverTests(SynchronousTestCase):
|
||||
"""
|
||||
Tests for L{StreamReceiver}
|
||||
"""
|
||||
|
||||
@given(lists(lists(binary())), randoms())
|
||||
def test_streamReceived(self, streams: List[List[bytes]], random: Random) -> None:
|
||||
"""
|
||||
All data passed to L{StreamReceiver.write} is returned by a call to
|
||||
L{StreamReceiver.finish} with a matching C{streamId}.
|
||||
"""
|
||||
receiver = StreamReceiver()
|
||||
streamIds = [receiver.open() for _ in streams]
|
||||
|
||||
# uncorrelate the results with open() order
|
||||
random.shuffle(streamIds)
|
||||
|
||||
expectedData = dict(zip(streamIds, streams))
|
||||
for streamId, strings in expectedData.items():
|
||||
for s in strings:
|
||||
receiver.write(streamId, s)
|
||||
|
||||
# uncorrelate the results with write() order
|
||||
random.shuffle(streamIds)
|
||||
|
||||
actualData = {streamId: receiver.finish(streamId) for streamId in streamIds}
|
||||
|
||||
assert_that(actualData, is_(equal_to(expectedData)))
|
||||
|
||||
@given(integers(), just("data"))
|
||||
def test_writeBadStreamId(self, streamId: int, data: str) -> None:
|
||||
"""
|
||||
L{StreamReceiver.write} raises L{KeyError} if called with a
|
||||
streamId not associated with an open stream.
|
||||
"""
|
||||
receiver = StreamReceiver()
|
||||
assert_that(calling(receiver.write).with_args(streamId, data), raises(KeyError))
|
||||
|
||||
@given(integers())
|
||||
def test_badFinishStreamId(self, streamId: int) -> None:
|
||||
"""
|
||||
L{StreamReceiver.finish} raises L{KeyError} if called with a
|
||||
streamId not associated with an open stream.
|
||||
"""
|
||||
receiver = StreamReceiver()
|
||||
assert_that(calling(receiver.finish).with_args(streamId), raises(KeyError))
|
||||
|
||||
def test_finishRemovesStream(self) -> None:
|
||||
"""
|
||||
L{StreamReceiver.finish} removes the identified stream.
|
||||
"""
|
||||
receiver = StreamReceiver()
|
||||
streamId = receiver.open()
|
||||
receiver.finish(streamId)
|
||||
assert_that(calling(receiver.finish).with_args(streamId), raises(KeyError))
|
||||
|
||||
|
||||
class ChunkTests(SynchronousTestCase):
|
||||
"""
|
||||
Tests for ``chunk``.
|
||||
"""
|
||||
|
||||
@given(data=text(), chunkSize=integers(min_value=1))
|
||||
def test_chunk(self, data, chunkSize):
|
||||
"""
|
||||
L{chunk} returns an iterable of L{str} where each element is no
|
||||
longer than the given limit. The concatenation of the strings is also
|
||||
equal to the original input string.
|
||||
"""
|
||||
chunks = list(chunk(data, chunkSize))
|
||||
assert_that(
|
||||
chunks,
|
||||
all_of(
|
||||
IsSequenceOf(
|
||||
has_length(less_than_or_equal_to(chunkSize)),
|
||||
),
|
||||
HasSum(equal_to(data), data[:0]),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class AMPStreamReceiver(AMP):
|
||||
"""
|
||||
A simple AMP interface to L{StreamReceiver}.
|
||||
"""
|
||||
|
||||
def __init__(self, streams: StreamReceiver) -> None:
|
||||
self.streams = streams
|
||||
|
||||
@StreamOpen.responder
|
||||
def streamOpen(self) -> Dict[str, object]:
|
||||
return {"streamId": self.streams.open()}
|
||||
|
||||
@StreamWrite.responder
|
||||
def streamWrite(self, streamId: int, data: bytes) -> Dict[str, object]:
|
||||
self.streams.write(streamId, data)
|
||||
return {}
|
||||
|
||||
|
||||
def interact(server: IProtocol, client: IProtocol, interaction: Awaitable[T]) -> T:
|
||||
"""
|
||||
Let C{server} and C{client} exchange bytes while C{interaction} runs.
|
||||
"""
|
||||
finished = False
|
||||
result: Union[Failure, T]
|
||||
|
||||
async def to_coroutine() -> T:
|
||||
return await interaction
|
||||
|
||||
def collect_result(r: Union[Failure, T]) -> None:
|
||||
nonlocal result, finished
|
||||
finished = True
|
||||
result = r
|
||||
|
||||
pump = connect(
|
||||
server,
|
||||
FakeTransport(server, isServer=True),
|
||||
client,
|
||||
FakeTransport(client, isServer=False),
|
||||
)
|
||||
interacting = Deferred.fromCoroutine(to_coroutine())
|
||||
interacting.addBoth(collect_result)
|
||||
|
||||
pump.flush()
|
||||
|
||||
if finished:
|
||||
if isinstance(result, Failure):
|
||||
result.raiseException()
|
||||
return result
|
||||
raise Exception("Interaction failed to produce a result.")
|
||||
|
||||
|
||||
class InteractTests(SynchronousTestCase):
|
||||
"""
|
||||
Tests for the test helper L{interact}.
|
||||
"""
|
||||
|
||||
def test_failure(self):
|
||||
"""
|
||||
If the interaction results in a failure then L{interact} raises an
|
||||
exception.
|
||||
"""
|
||||
|
||||
class ArbitraryException(Exception):
|
||||
pass
|
||||
|
||||
with self.assertRaises(ArbitraryException):
|
||||
interact(Protocol(), Protocol(), fail(ArbitraryException()))
|
||||
|
||||
def test_incomplete(self):
|
||||
"""
|
||||
If the interaction fails to produce a result then L{interact} raises
|
||||
an exception.
|
||||
"""
|
||||
with self.assertRaises(Exception):
|
||||
interact(Protocol(), Protocol(), Deferred())
|
||||
|
||||
|
||||
class StreamTests(SynchronousTestCase):
|
||||
"""
|
||||
Tests for L{stream}.
|
||||
"""
|
||||
|
||||
@given(lists(binary()))
|
||||
def test_stream(self, chunks: List[bytes]) -> None:
|
||||
"""
|
||||
All of the chunks passed to L{stream} are sent in order over a
|
||||
stream using the given AMP connection.
|
||||
"""
|
||||
sender = AMP()
|
||||
streams = StreamReceiver()
|
||||
streamId = interact(
|
||||
AMPStreamReceiver(streams), sender, stream(sender, iter(chunks))
|
||||
)
|
||||
assert_that(streams.finish(streamId), is_(equal_to(chunks)))
|
||||
@@ -0,0 +1,532 @@
|
||||
# Copyright (c) Twisted Matrix Laboratories.
|
||||
# See LICENSE for details.
|
||||
|
||||
"""
|
||||
Test for distributed trial worker side.
|
||||
"""
|
||||
|
||||
import os
|
||||
from io import BytesIO, StringIO
|
||||
from typing import Type
|
||||
from unittest import TestCase as PyUnitTestCase
|
||||
|
||||
from zope.interface.verify import verifyObject
|
||||
|
||||
from hamcrest import assert_that, equal_to, has_item, has_length
|
||||
|
||||
from twisted.internet.defer import Deferred, fail
|
||||
from twisted.internet.error import ConnectionLost, ProcessDone
|
||||
from twisted.internet.interfaces import IAddress, ITransport
|
||||
from twisted.python.failure import Failure
|
||||
from twisted.python.filepath import FilePath
|
||||
from twisted.test.iosim import connectedServerAndClient
|
||||
from twisted.trial._dist import managercommands
|
||||
from twisted.trial._dist.worker import (
|
||||
LocalWorker,
|
||||
LocalWorkerAMP,
|
||||
LocalWorkerTransport,
|
||||
NotRunning,
|
||||
WorkerException,
|
||||
WorkerProtocol,
|
||||
)
|
||||
from twisted.trial.reporter import TestResult
|
||||
from twisted.trial.test import pyunitcases, skipping
|
||||
from twisted.trial.unittest import TestCase, makeTodo
|
||||
from .matchers import isFailure, matches_result, similarFrame
|
||||
|
||||
|
||||
class WorkerProtocolTests(TestCase):
|
||||
"""
|
||||
Tests for L{WorkerProtocol}.
|
||||
"""
|
||||
|
||||
worker: WorkerProtocol
|
||||
server: LocalWorkerAMP
|
||||
|
||||
def setUp(self) -> None:
|
||||
"""
|
||||
Set up a transport, a result stream and a protocol instance.
|
||||
"""
|
||||
self.worker, self.server, pump = connectedServerAndClient(
|
||||
LocalWorkerAMP, WorkerProtocol, greet=False
|
||||
)
|
||||
self.flush = pump.flush
|
||||
|
||||
def test_run(self) -> None:
|
||||
"""
|
||||
Sending the L{workercommands.Run} command to the worker returns a
|
||||
response with C{success} sets to C{True}.
|
||||
"""
|
||||
d = Deferred.fromCoroutine(
|
||||
self.server.run(pyunitcases.PyUnitTest("test_pass"), TestResult())
|
||||
)
|
||||
self.flush()
|
||||
self.assertEqual({"success": True}, self.successResultOf(d))
|
||||
|
||||
def test_start(self) -> None:
|
||||
"""
|
||||
The C{start} command changes the current path.
|
||||
"""
|
||||
curdir = os.path.realpath(os.path.curdir)
|
||||
self.addCleanup(os.chdir, curdir)
|
||||
self.worker.start("..")
|
||||
self.assertNotEqual(os.path.realpath(os.path.curdir), curdir)
|
||||
|
||||
|
||||
class WorkerProtocolErrorTests(TestCase):
|
||||
"""
|
||||
Tests for L{WorkerProtocol}'s handling of certain errors related to
|
||||
running the tests themselves (i.e., not test errors but test
|
||||
infrastructure/runner errors).
|
||||
"""
|
||||
|
||||
def _runErrorTest(
|
||||
self, brokenTestName: str, loggedExceptionType: Type[BaseException]
|
||||
) -> None:
|
||||
worker, server, pump = connectedServerAndClient(
|
||||
LocalWorkerAMP, WorkerProtocol, greet=False
|
||||
)
|
||||
expectedCase = pyunitcases.BrokenRunInfrastructure(brokenTestName)
|
||||
result = TestResult()
|
||||
Deferred.fromCoroutine(server.run(expectedCase, result))
|
||||
pump.flush()
|
||||
assert_that(result, matches_result(errors=has_length(1)))
|
||||
[(actualCase, errors)] = result.errors
|
||||
assert_that(actualCase, equal_to(expectedCase))
|
||||
|
||||
# Additionally, we expect that the worker protocol logged the failure
|
||||
# once so that it is visible somewhere, even if it cannot deliver it
|
||||
# back to the parent process (which it can in this case). Since the
|
||||
# worker runs in process with us, that failure is in our log so we can
|
||||
# easily make an assertion about it. Also, if we don't flush it, the
|
||||
# test fails. As far as the type goes, we just have to be aware of
|
||||
# the implementation details of `BrokenRunInfrastructure`.
|
||||
assert_that(self.flushLoggedErrors(loggedExceptionType), has_length(1))
|
||||
|
||||
def test_addSuccessError(self) -> None:
|
||||
"""
|
||||
If there is an error reporting success then the test run is marked as
|
||||
an error.
|
||||
"""
|
||||
self._runErrorTest("test_addSuccess", AttributeError)
|
||||
|
||||
def test_addErrorError(self) -> None:
|
||||
"""
|
||||
If there is an error reporting an error then the test run is marked as
|
||||
an error.
|
||||
"""
|
||||
self._runErrorTest("test_addError", AttributeError)
|
||||
|
||||
def test_addFailureError(self) -> None:
|
||||
"""
|
||||
If there is an error reporting a failure then the test run is marked
|
||||
as an error.
|
||||
"""
|
||||
self._runErrorTest("test_addFailure", AttributeError)
|
||||
|
||||
def test_addSkipError(self) -> None:
|
||||
"""
|
||||
If there is an error reporting a skip then the test run is marked
|
||||
as an error.
|
||||
"""
|
||||
self._runErrorTest("test_addSkip", AttributeError)
|
||||
|
||||
def test_addExpectedFailure(self) -> None:
|
||||
"""
|
||||
If there is an error reporting an expected failure then the test
|
||||
run is marked as an error.
|
||||
"""
|
||||
self._runErrorTest("test_addExpectedFailure", AttributeError)
|
||||
|
||||
def test_addUnexpectedSuccess(self) -> None:
|
||||
"""
|
||||
If there is an error reporting an unexpected ccess then the test
|
||||
run is marked as an error.
|
||||
"""
|
||||
self._runErrorTest("test_addUnexpectedSuccess", AttributeError)
|
||||
|
||||
def test_failedFailureReport(self) -> None:
|
||||
"""
|
||||
A failure encountered while reporting a reporting failure is logged.
|
||||
"""
|
||||
worker, server, pump = connectedServerAndClient(
|
||||
LocalWorkerAMP, WorkerProtocol, greet=False
|
||||
)
|
||||
|
||||
# We can easily break everything by eliminating the worker protocol's
|
||||
# transport. This prevents it from ever sending anything to the
|
||||
# manager protocol.
|
||||
worker.transport = None
|
||||
|
||||
expectedCase = pyunitcases.PyUnitTest("test_pass")
|
||||
result = TestResult()
|
||||
Deferred.fromCoroutine(server.run(expectedCase, result))
|
||||
pump.flush()
|
||||
|
||||
# There should be two exceptions logged here. The first is from the
|
||||
# attempt to report the success result. The second is a report that
|
||||
# the first failed.
|
||||
assert_that(self.flushLoggedErrors(ConnectionLost), has_length(2))
|
||||
|
||||
|
||||
class LocalWorkerAMPTests(TestCase):
|
||||
"""
|
||||
Test case for distributed trial's manager-side local worker AMP protocol
|
||||
"""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.worker, self.managerAMP, pump = connectedServerAndClient(
|
||||
LocalWorkerAMP, WorkerProtocol, greet=False
|
||||
)
|
||||
self.flush = pump.flush
|
||||
|
||||
def workerRunTest(
|
||||
self, testCase: PyUnitTestCase, makeResult: Type[TestResult] = TestResult
|
||||
) -> TestResult:
|
||||
result = makeResult()
|
||||
d = Deferred.fromCoroutine(self.managerAMP.run(testCase, result))
|
||||
self.flush()
|
||||
self.assertEqual({"success": True}, self.successResultOf(d))
|
||||
return result
|
||||
|
||||
def test_runSuccess(self) -> None:
|
||||
"""
|
||||
Run a test, and succeed.
|
||||
"""
|
||||
result = self.workerRunTest(pyunitcases.PyUnitTest("test_pass"))
|
||||
assert_that(result, matches_result(successes=equal_to(1)))
|
||||
|
||||
def test_runExpectedFailure(self) -> None:
|
||||
"""
|
||||
Run a test, and fail expectedly.
|
||||
"""
|
||||
expectedCase = skipping.SynchronousStrictTodo("test_todo1")
|
||||
result = self.workerRunTest(expectedCase)
|
||||
assert_that(result, matches_result(expectedFailures=has_length(1)))
|
||||
[(actualCase, exceptionMessage, todoReason)] = result.expectedFailures
|
||||
assert_that(actualCase, equal_to(expectedCase))
|
||||
|
||||
# Match the strings used in the test we ran.
|
||||
assert_that(exceptionMessage, equal_to("expected failure"))
|
||||
assert_that(todoReason, equal_to(makeTodo("todo1")))
|
||||
|
||||
def test_runError(self) -> None:
|
||||
"""
|
||||
Run a test, and encounter an error.
|
||||
"""
|
||||
expectedCase = pyunitcases.PyUnitTest("test_error")
|
||||
result = self.workerRunTest(expectedCase)
|
||||
assert_that(result, matches_result(errors=has_length(1)))
|
||||
[(actualCase, failure)] = result.errors
|
||||
assert_that(expectedCase, equal_to(actualCase))
|
||||
assert_that(
|
||||
failure,
|
||||
isFailure(
|
||||
type=equal_to(Exception),
|
||||
value=equal_to(WorkerException("pyunit error")),
|
||||
frames=has_item(similarFrame("test_error", "pyunitcases.py")), # type: ignore[arg-type]
|
||||
),
|
||||
)
|
||||
|
||||
def test_runFailure(self) -> None:
|
||||
"""
|
||||
Run a test, and fail.
|
||||
"""
|
||||
expectedCase = pyunitcases.PyUnitTest("test_fail")
|
||||
result = self.workerRunTest(expectedCase)
|
||||
assert_that(result, matches_result(failures=has_length(1)))
|
||||
[(actualCase, failure)] = result.failures
|
||||
assert_that(expectedCase, equal_to(actualCase))
|
||||
assert_that(
|
||||
failure,
|
||||
isFailure(
|
||||
# AssertionError is the type raised by TestCase.fail
|
||||
type=equal_to(AssertionError),
|
||||
value=equal_to(WorkerException("pyunit failure")),
|
||||
),
|
||||
)
|
||||
|
||||
def test_runSkip(self) -> None:
|
||||
"""
|
||||
Run a test, but skip it.
|
||||
"""
|
||||
expectedCase = pyunitcases.PyUnitTest("test_skip")
|
||||
result = self.workerRunTest(expectedCase)
|
||||
assert_that(result, matches_result(skips=has_length(1)))
|
||||
[(actualCase, skip)] = result.skips
|
||||
assert_that(expectedCase, equal_to(actualCase))
|
||||
assert_that(skip, equal_to("pyunit skip"))
|
||||
|
||||
def test_runUnexpectedSuccesses(self) -> None:
|
||||
"""
|
||||
Run a test, and succeed unexpectedly.
|
||||
"""
|
||||
expectedCase = skipping.SynchronousStrictTodo("test_todo7")
|
||||
result = self.workerRunTest(expectedCase)
|
||||
assert_that(result, matches_result(unexpectedSuccesses=has_length(1)))
|
||||
[(actualCase, unexpectedSuccess)] = result.unexpectedSuccesses
|
||||
assert_that(expectedCase, equal_to(actualCase))
|
||||
assert_that(unexpectedSuccess, equal_to("todo7"))
|
||||
|
||||
def test_testWrite(self) -> None:
|
||||
"""
|
||||
L{LocalWorkerAMP.testWrite} writes the data received to its test
|
||||
stream.
|
||||
"""
|
||||
stream = StringIO()
|
||||
self.managerAMP.setTestStream(stream)
|
||||
d = self.worker.callRemote(managercommands.TestWrite, out="Some output")
|
||||
self.flush()
|
||||
self.assertEqual({"success": True}, self.successResultOf(d))
|
||||
self.assertEqual("Some output\n", stream.getvalue())
|
||||
|
||||
def test_stopAfterRun(self) -> None:
|
||||
"""
|
||||
L{LocalWorkerAMP.run} calls C{stopTest} on its test result once the
|
||||
C{Run} commands has succeeded.
|
||||
"""
|
||||
stopped = []
|
||||
|
||||
class StopTestResult(TestResult):
|
||||
def stopTest(self, test: PyUnitTestCase) -> None:
|
||||
stopped.append(test)
|
||||
|
||||
case = pyunitcases.PyUnitTest("test_pass")
|
||||
self.workerRunTest(case, StopTestResult)
|
||||
assert_that(stopped, equal_to([case]))
|
||||
|
||||
|
||||
class SpyDataLocalWorkerAMP(LocalWorkerAMP):
|
||||
"""
|
||||
A fake implementation of L{LocalWorkerAMP} that records the received
|
||||
data and doesn't automatically dispatch any command..
|
||||
"""
|
||||
|
||||
id = 0
|
||||
dataString = b""
|
||||
|
||||
def dataReceived(self, data):
|
||||
self.dataString += data
|
||||
|
||||
|
||||
class FakeTransport:
|
||||
"""
|
||||
A fake process transport implementation for testing.
|
||||
"""
|
||||
|
||||
dataString = b""
|
||||
calls = 0
|
||||
|
||||
def writeToChild(self, fd, data):
|
||||
self.dataString += data
|
||||
|
||||
def loseConnection(self):
|
||||
self.calls += 1
|
||||
|
||||
|
||||
class LocalWorkerTests(TestCase):
|
||||
"""
|
||||
Tests for L{LocalWorker} and L{LocalWorkerTransport}.
|
||||
"""
|
||||
|
||||
def tidyLocalWorker(self, *args, **kwargs):
|
||||
"""
|
||||
Create a L{LocalWorker}, connect it to a transport, and ensure
|
||||
its log files are closed.
|
||||
|
||||
@param args: See L{LocalWorker}
|
||||
|
||||
@param kwargs: See L{LocalWorker}
|
||||
|
||||
@return: a L{LocalWorker} instance
|
||||
"""
|
||||
worker = LocalWorker(*args, **kwargs)
|
||||
worker.makeConnection(FakeTransport())
|
||||
self.addCleanup(worker._outLog.close)
|
||||
self.addCleanup(worker._errLog.close)
|
||||
return worker
|
||||
|
||||
def test_exitBeforeConnected(self):
|
||||
"""
|
||||
L{LocalWorker.exit} fails with L{NotRunning} if it is called before the
|
||||
protocol is connected to a transport.
|
||||
"""
|
||||
worker = LocalWorker(
|
||||
SpyDataLocalWorkerAMP(), FilePath(self.mktemp()), StringIO()
|
||||
)
|
||||
self.failureResultOf(worker.exit(), NotRunning)
|
||||
|
||||
def test_exitAfterDisconnected(self):
|
||||
"""
|
||||
L{LocalWorker.exit} fails with L{NotRunning} if it is called after the the
|
||||
protocol is disconnected from its transport.
|
||||
"""
|
||||
worker = self.tidyLocalWorker(
|
||||
SpyDataLocalWorkerAMP(), FilePath(self.mktemp()), StringIO()
|
||||
)
|
||||
worker.processEnded(Failure(ProcessDone(0)))
|
||||
# Since we're not calling exit until after the process has ended, it
|
||||
# won't consume the ProcessDone failure on the internal `endDeferred`.
|
||||
# Swallow it here.
|
||||
self.failureResultOf(worker.endDeferred, ProcessDone)
|
||||
|
||||
# Now assert that exit behaves.
|
||||
self.failureResultOf(worker.exit(), NotRunning)
|
||||
|
||||
def test_childDataReceived(self):
|
||||
"""
|
||||
L{LocalWorker.childDataReceived} forwards the received data to linked
|
||||
L{AMP} protocol if the right file descriptor, otherwise forwards to
|
||||
C{ProcessProtocol.childDataReceived}.
|
||||
"""
|
||||
localWorker = self.tidyLocalWorker(
|
||||
SpyDataLocalWorkerAMP(), FilePath(self.mktemp()), "test.log"
|
||||
)
|
||||
localWorker._outLog = BytesIO()
|
||||
localWorker.childDataReceived(4, b"foo")
|
||||
localWorker.childDataReceived(1, b"bar")
|
||||
self.assertEqual(b"foo", localWorker._ampProtocol.dataString)
|
||||
self.assertEqual(b"bar", localWorker._outLog.getvalue())
|
||||
|
||||
def test_newlineStyle(self):
|
||||
"""
|
||||
L{LocalWorker} writes the log data with local newlines.
|
||||
"""
|
||||
amp = SpyDataLocalWorkerAMP()
|
||||
tempDir = FilePath(self.mktemp())
|
||||
tempDir.makedirs()
|
||||
logPath = tempDir.child("test.log")
|
||||
|
||||
with open(logPath.path, "wt", encoding="utf-8") as logFile:
|
||||
worker = LocalWorker(amp, tempDir, logFile)
|
||||
worker.makeConnection(FakeTransport())
|
||||
self.addCleanup(worker._outLog.close)
|
||||
self.addCleanup(worker._errLog.close)
|
||||
|
||||
expected = "Here comes the \N{sun}!"
|
||||
amp.testWrite(expected)
|
||||
|
||||
self.assertEqual(
|
||||
# os.linesep is the local newline.
|
||||
(expected + os.linesep),
|
||||
# getContent reads in binary mode so we'll see the bytes that
|
||||
# actually ended up in the file.
|
||||
logPath.getContent().decode("utf-8"),
|
||||
)
|
||||
|
||||
def test_outReceived(self):
|
||||
"""
|
||||
L{LocalWorker.outReceived} logs the output into its C{_outLog} log
|
||||
file.
|
||||
"""
|
||||
localWorker = self.tidyLocalWorker(
|
||||
SpyDataLocalWorkerAMP(), FilePath(self.mktemp()), "test.log"
|
||||
)
|
||||
localWorker._outLog = BytesIO()
|
||||
data = b"The quick brown fox jumps over the lazy dog"
|
||||
localWorker.outReceived(data)
|
||||
self.assertEqual(data, localWorker._outLog.getvalue())
|
||||
|
||||
def test_errReceived(self):
|
||||
"""
|
||||
L{LocalWorker.errReceived} logs the errors into its C{_errLog} log
|
||||
file.
|
||||
"""
|
||||
localWorker = self.tidyLocalWorker(
|
||||
SpyDataLocalWorkerAMP(), FilePath(self.mktemp()), "test.log"
|
||||
)
|
||||
localWorker._errLog = BytesIO()
|
||||
data = b"The quick brown fox jumps over the lazy dog"
|
||||
localWorker.errReceived(data)
|
||||
self.assertEqual(data, localWorker._errLog.getvalue())
|
||||
|
||||
def test_write(self):
|
||||
"""
|
||||
L{LocalWorkerTransport.write} forwards the written data to the given
|
||||
transport.
|
||||
"""
|
||||
transport = FakeTransport()
|
||||
localTransport = LocalWorkerTransport(transport)
|
||||
data = b"The quick brown fox jumps over the lazy dog"
|
||||
localTransport.write(data)
|
||||
self.assertEqual(data, transport.dataString)
|
||||
|
||||
def test_writeSequence(self):
|
||||
"""
|
||||
L{LocalWorkerTransport.writeSequence} forwards the written data to the
|
||||
given transport.
|
||||
"""
|
||||
transport = FakeTransport()
|
||||
localTransport = LocalWorkerTransport(transport)
|
||||
data = (b"The quick ", b"brown fox jumps ", b"over the lazy dog")
|
||||
localTransport.writeSequence(data)
|
||||
self.assertEqual(b"".join(data), transport.dataString)
|
||||
|
||||
def test_loseConnection(self):
|
||||
"""
|
||||
L{LocalWorkerTransport.loseConnection} forwards the call to the given
|
||||
transport.
|
||||
"""
|
||||
transport = FakeTransport()
|
||||
localTransport = LocalWorkerTransport(transport)
|
||||
localTransport.loseConnection()
|
||||
|
||||
self.assertEqual(transport.calls, 1)
|
||||
|
||||
def test_connectionLost(self):
|
||||
"""
|
||||
L{LocalWorker.connectionLost} closes the per-worker log streams.
|
||||
"""
|
||||
|
||||
localWorker = self.tidyLocalWorker(
|
||||
SpyDataLocalWorkerAMP(), FilePath(self.mktemp()), "test.log"
|
||||
)
|
||||
localWorker.connectionLost(None)
|
||||
self.assertTrue(localWorker._outLog.closed)
|
||||
self.assertTrue(localWorker._errLog.closed)
|
||||
|
||||
def test_processEnded(self):
|
||||
"""
|
||||
L{LocalWorker.processEnded} calls C{connectionLost} on itself and on
|
||||
the L{AMP} protocol.
|
||||
"""
|
||||
transport = FakeTransport()
|
||||
protocol = SpyDataLocalWorkerAMP()
|
||||
localWorker = LocalWorker(protocol, FilePath(self.mktemp()), "test.log")
|
||||
localWorker.makeConnection(transport)
|
||||
localWorker.processEnded(Failure(ProcessDone(0)))
|
||||
self.assertTrue(localWorker._outLog.closed)
|
||||
self.assertTrue(localWorker._errLog.closed)
|
||||
self.assertIdentical(None, protocol.transport)
|
||||
return self.assertFailure(localWorker.endDeferred, ProcessDone)
|
||||
|
||||
def test_addresses(self):
|
||||
"""
|
||||
L{LocalWorkerTransport.getPeer} and L{LocalWorkerTransport.getHost}
|
||||
return L{IAddress} objects.
|
||||
"""
|
||||
localTransport = LocalWorkerTransport(None)
|
||||
self.assertTrue(verifyObject(IAddress, localTransport.getPeer()))
|
||||
self.assertTrue(verifyObject(IAddress, localTransport.getHost()))
|
||||
|
||||
def test_transport(self):
|
||||
"""
|
||||
L{LocalWorkerTransport} implements L{ITransport} to be able to be used
|
||||
by L{AMP}.
|
||||
"""
|
||||
localTransport = LocalWorkerTransport(None)
|
||||
self.assertTrue(verifyObject(ITransport, localTransport))
|
||||
|
||||
def test_startError(self):
|
||||
"""
|
||||
L{LocalWorker} swallows the exceptions returned by the L{AMP} protocol
|
||||
start method, as it generates unnecessary errors.
|
||||
"""
|
||||
|
||||
def failCallRemote(command, directory):
|
||||
return fail(RuntimeError("oops"))
|
||||
|
||||
protocol = SpyDataLocalWorkerAMP()
|
||||
protocol.callRemote = failCallRemote
|
||||
self.tidyLocalWorker(protocol, FilePath(self.mktemp()), "test.log")
|
||||
self.assertEqual([], self.flushLoggedErrors(RuntimeError))
|
||||
@@ -0,0 +1,164 @@
|
||||
# Copyright (c) Twisted Matrix Laboratories.
|
||||
# See LICENSE for details.
|
||||
|
||||
"""
|
||||
Tests for L{twisted.trial._dist.workerreporter}.
|
||||
"""
|
||||
|
||||
|
||||
from unittest import TestCase
|
||||
|
||||
from hamcrest import assert_that, equal_to, has_length
|
||||
from hamcrest.core.matcher import Matcher
|
||||
|
||||
from twisted.internet.defer import Deferred
|
||||
from twisted.test.iosim import connectedServerAndClient
|
||||
from twisted.trial._dist.worker import LocalWorkerAMP, WorkerProtocol
|
||||
from twisted.trial.reporter import TestResult
|
||||
from twisted.trial.test import erroneous, pyunitcases, sample, skipping
|
||||
from twisted.trial.unittest import SynchronousTestCase
|
||||
from .matchers import matches_result
|
||||
|
||||
|
||||
def run(case: SynchronousTestCase, target: TestCase) -> TestResult:
|
||||
"""
|
||||
Run C{target} and return a test result as populated by a worker reporter.
|
||||
|
||||
@param case: A test case to use to help run the target.
|
||||
"""
|
||||
result = TestResult()
|
||||
worker, local, pump = connectedServerAndClient(LocalWorkerAMP, WorkerProtocol)
|
||||
d = Deferred.fromCoroutine(local.run(target, result))
|
||||
pump.flush()
|
||||
assert_that(case.successResultOf(d), equal_to({"success": True}))
|
||||
return result
|
||||
|
||||
|
||||
class WorkerReporterTests(SynchronousTestCase):
|
||||
"""
|
||||
Tests for L{WorkerReporter}.
|
||||
"""
|
||||
|
||||
def assertTestRun(self, target: TestCase, **expectations: Matcher) -> None:
|
||||
"""
|
||||
Run the given test and assert that the result matches the given
|
||||
expectations.
|
||||
"""
|
||||
assert_that(run(self, target), matches_result(**expectations))
|
||||
|
||||
def test_outsideReportingContext(self) -> None:
|
||||
"""
|
||||
L{WorkerReporter}'s implementation of test result methods raise
|
||||
L{ValueError} when called outside of the
|
||||
L{WorkerReporter.gatherReportingResults} context manager.
|
||||
"""
|
||||
worker, local, pump = connectedServerAndClient(LocalWorkerAMP, WorkerProtocol)
|
||||
|
||||
case = sample.FooTest("test_foo")
|
||||
with self.assertRaises(ValueError):
|
||||
worker._result.addSuccess(case)
|
||||
|
||||
def test_addSuccess(self) -> None:
|
||||
"""
|
||||
L{WorkerReporter} propagates successes.
|
||||
"""
|
||||
self.assertTestRun(sample.FooTest("test_foo"), successes=equal_to(1))
|
||||
|
||||
def test_addError(self) -> None:
|
||||
"""
|
||||
L{WorkerReporter} propagates errors from trial's TestCases.
|
||||
"""
|
||||
self.assertTestRun(
|
||||
erroneous.TestAsynchronousFail("test_exception"), errors=has_length(1)
|
||||
)
|
||||
|
||||
def test_addErrorGreaterThan64k(self) -> None:
|
||||
"""
|
||||
L{WorkerReporter} propagates errors with large string representations.
|
||||
"""
|
||||
self.assertTestRun(
|
||||
erroneous.TestAsynchronousFail("test_exceptionGreaterThan64k"),
|
||||
errors=has_length(1),
|
||||
)
|
||||
|
||||
def test_addErrorGreaterThan64kEncoded(self) -> None:
|
||||
"""
|
||||
L{WorkerReporter} propagates errors with a string representation that
|
||||
is smaller than an implementation-specific limit but which encode to a
|
||||
byte representation that exceeds this limit.
|
||||
"""
|
||||
self.assertTestRun(
|
||||
erroneous.TestAsynchronousFail("test_exceptionGreaterThan64kEncoded"),
|
||||
errors=has_length(1),
|
||||
)
|
||||
|
||||
def test_addErrorTuple(self) -> None:
|
||||
"""
|
||||
L{WorkerReporter} propagates errors from pyunit's TestCases.
|
||||
"""
|
||||
self.assertTestRun(pyunitcases.PyUnitTest("test_error"), errors=has_length(1))
|
||||
|
||||
def test_addFailure(self) -> None:
|
||||
"""
|
||||
L{WorkerReporter} propagates test failures from trial's TestCases.
|
||||
"""
|
||||
self.assertTestRun(
|
||||
erroneous.TestRegularFail("test_fail"), failures=has_length(1)
|
||||
)
|
||||
|
||||
def test_addFailureGreaterThan64k(self) -> None:
|
||||
"""
|
||||
L{WorkerReporter} propagates test failures with large string representations.
|
||||
"""
|
||||
self.assertTestRun(
|
||||
erroneous.TestAsynchronousFail("test_failGreaterThan64k"),
|
||||
failures=has_length(1),
|
||||
)
|
||||
|
||||
def test_addFailureTuple(self) -> None:
|
||||
"""
|
||||
L{WorkerReporter} propagates test failures from pyunit's TestCases.
|
||||
"""
|
||||
self.assertTestRun(pyunitcases.PyUnitTest("test_fail"), failures=has_length(1))
|
||||
|
||||
def test_addSkip(self) -> None:
|
||||
"""
|
||||
L{WorkerReporter} propagates skips.
|
||||
"""
|
||||
self.assertTestRun(
|
||||
skipping.SynchronousSkipping("test_skip1"), skips=has_length(1)
|
||||
)
|
||||
|
||||
def test_addSkipPyunit(self) -> None:
|
||||
"""
|
||||
L{WorkerReporter} propagates skips from L{unittest.TestCase} cases.
|
||||
"""
|
||||
self.assertTestRun(
|
||||
pyunitcases.PyUnitTest("test_skip"),
|
||||
skips=has_length(1),
|
||||
)
|
||||
|
||||
def test_addExpectedFailure(self) -> None:
|
||||
"""
|
||||
L{WorkerReporter} propagates expected failures.
|
||||
"""
|
||||
self.assertTestRun(
|
||||
skipping.SynchronousStrictTodo("test_todo1"), expectedFailures=has_length(1)
|
||||
)
|
||||
|
||||
def test_addExpectedFailureGreaterThan64k(self) -> None:
|
||||
"""
|
||||
WorkerReporter propagates expected failures with large string representations.
|
||||
"""
|
||||
self.assertTestRun(
|
||||
skipping.ExpectedFailure("test_expectedFailureGreaterThan64k"),
|
||||
expectedFailures=has_length(1),
|
||||
)
|
||||
|
||||
def test_addUnexpectedSuccess(self) -> None:
|
||||
"""
|
||||
L{WorkerReporter} propagates unexpected successes.
|
||||
"""
|
||||
self.assertTestRun(
|
||||
skipping.SynchronousTodo("test_todo3"), unexpectedSuccesses=has_length(1)
|
||||
)
|
||||
@@ -0,0 +1,147 @@
|
||||
# Copyright (c) Twisted Matrix Laboratories.
|
||||
# See LICENSE for details.
|
||||
|
||||
"""
|
||||
Tests for L{twisted.trial._dist.workertrial}.
|
||||
"""
|
||||
|
||||
import errno
|
||||
import sys
|
||||
from io import BytesIO
|
||||
|
||||
from twisted.protocols.amp import AMP
|
||||
from twisted.test.proto_helpers import StringTransport
|
||||
from twisted.trial._dist import (
|
||||
_WORKER_AMP_STDIN,
|
||||
_WORKER_AMP_STDOUT,
|
||||
managercommands,
|
||||
workercommands,
|
||||
workertrial,
|
||||
)
|
||||
from twisted.trial._dist.workertrial import WorkerLogObserver, main
|
||||
from twisted.trial.unittest import TestCase
|
||||
|
||||
|
||||
class FakeAMP(AMP):
|
||||
"""
|
||||
A fake amp protocol.
|
||||
"""
|
||||
|
||||
|
||||
class WorkerLogObserverTests(TestCase):
|
||||
"""
|
||||
Tests for L{WorkerLogObserver}.
|
||||
"""
|
||||
|
||||
def test_emit(self):
|
||||
"""
|
||||
L{WorkerLogObserver} forwards data to L{managercommands.TestWrite}.
|
||||
"""
|
||||
calls = []
|
||||
|
||||
class FakeClient:
|
||||
def callRemote(self, method, **kwargs):
|
||||
calls.append((method, kwargs))
|
||||
|
||||
observer = WorkerLogObserver(FakeClient())
|
||||
observer.emit({"message": ["Some log"]})
|
||||
self.assertEqual(calls, [(managercommands.TestWrite, {"out": "Some log"})])
|
||||
|
||||
|
||||
class MainTests(TestCase):
|
||||
"""
|
||||
Tests for L{main}.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.readStream = BytesIO()
|
||||
self.writeStream = BytesIO()
|
||||
self.patch(
|
||||
workertrial, "startLoggingWithObserver", self.startLoggingWithObserver
|
||||
)
|
||||
self.addCleanup(setattr, sys, "argv", sys.argv)
|
||||
sys.argv = ["trial"]
|
||||
|
||||
def fdopen(self, fd, mode=None):
|
||||
"""
|
||||
Fake C{os.fdopen} implementation which returns C{self.readStream} for
|
||||
the stdin fd and C{self.writeStream} for the stdout fd.
|
||||
"""
|
||||
if fd == _WORKER_AMP_STDIN:
|
||||
self.assertEqual("rb", mode)
|
||||
return self.readStream
|
||||
elif fd == _WORKER_AMP_STDOUT:
|
||||
self.assertEqual("wb", mode)
|
||||
return self.writeStream
|
||||
else:
|
||||
raise AssertionError(f"Unexpected fd {fd!r}")
|
||||
|
||||
def startLoggingWithObserver(self, emit, setStdout):
|
||||
"""
|
||||
Override C{startLoggingWithObserver} for not starting logging.
|
||||
"""
|
||||
self.assertFalse(setStdout)
|
||||
|
||||
def test_empty(self):
|
||||
"""
|
||||
If no data is ever written, L{main} exits without writing data out.
|
||||
"""
|
||||
main(self.fdopen)
|
||||
self.assertEqual(b"", self.writeStream.getvalue())
|
||||
|
||||
def test_forwardCommand(self):
|
||||
"""
|
||||
L{main} forwards data from its input stream to a L{WorkerProtocol}
|
||||
instance which writes data to the output stream.
|
||||
"""
|
||||
client = FakeAMP()
|
||||
clientTransport = StringTransport()
|
||||
client.makeConnection(clientTransport)
|
||||
client.callRemote(workercommands.Run, testCase="doesntexist")
|
||||
self.readStream = clientTransport.io
|
||||
self.readStream.seek(0, 0)
|
||||
main(self.fdopen)
|
||||
# Just brazenly encode irrelevant implementation details here, why
|
||||
# not.
|
||||
self.assertIn(b"StreamOpen", self.writeStream.getvalue())
|
||||
|
||||
def test_readInterrupted(self):
|
||||
"""
|
||||
If reading the input stream fails with a C{IOError} with errno
|
||||
C{EINTR}, L{main} ignores it and continues reading.
|
||||
"""
|
||||
excInfos = []
|
||||
|
||||
class FakeStream:
|
||||
count = 0
|
||||
|
||||
def read(oself, size):
|
||||
oself.count += 1
|
||||
if oself.count == 1:
|
||||
raise OSError(errno.EINTR)
|
||||
else:
|
||||
excInfos.append(sys.exc_info())
|
||||
return b""
|
||||
|
||||
self.readStream = FakeStream()
|
||||
main(self.fdopen)
|
||||
self.assertEqual(b"", self.writeStream.getvalue())
|
||||
self.assertEqual([(None, None, None)], excInfos)
|
||||
|
||||
def test_otherReadError(self):
|
||||
"""
|
||||
L{main} only ignores C{IOError} with C{EINTR} errno: otherwise, the
|
||||
error pops out.
|
||||
"""
|
||||
|
||||
class FakeStream:
|
||||
count = 0
|
||||
|
||||
def read(oself, size):
|
||||
oself.count += 1
|
||||
if oself.count == 1:
|
||||
raise OSError("Something else")
|
||||
return ""
|
||||
|
||||
self.readStream = FakeStream()
|
||||
self.assertRaises(IOError, main, self.fdopen)
|
||||
@@ -0,0 +1,460 @@
|
||||
# -*- test-case-name: twisted.trial._dist.test.test_worker -*-
|
||||
#
|
||||
# Copyright (c) Twisted Matrix Laboratories.
|
||||
# See LICENSE for details.
|
||||
|
||||
"""
|
||||
This module implements the worker classes.
|
||||
|
||||
@since: 12.3
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import Awaitable, Callable, Dict, List, Optional, TextIO, TypeVar
|
||||
from unittest import TestCase
|
||||
|
||||
from zope.interface import implementer
|
||||
|
||||
from attrs import frozen
|
||||
from typing_extensions import Protocol, TypedDict
|
||||
|
||||
from twisted.internet.defer import Deferred, DeferredList
|
||||
from twisted.internet.error import ProcessDone
|
||||
from twisted.internet.interfaces import IAddress, ITransport
|
||||
from twisted.internet.protocol import ProcessProtocol
|
||||
from twisted.logger import Logger
|
||||
from twisted.protocols.amp import AMP
|
||||
from twisted.python.failure import Failure
|
||||
from twisted.python.filepath import FilePath
|
||||
from twisted.python.reflect import namedObject
|
||||
from twisted.trial._dist import (
|
||||
_WORKER_AMP_STDIN,
|
||||
_WORKER_AMP_STDOUT,
|
||||
managercommands,
|
||||
workercommands,
|
||||
)
|
||||
from twisted.trial._dist.workerreporter import WorkerReporter
|
||||
from twisted.trial.reporter import TestResult
|
||||
from twisted.trial.runner import TestLoader, TrialSuite
|
||||
from twisted.trial.unittest import Todo
|
||||
from .stream import StreamOpen, StreamReceiver, StreamWrite
|
||||
|
||||
|
||||
@frozen(auto_exc=False)
|
||||
class WorkerException(Exception):
|
||||
"""
|
||||
An exception was reported by a test running in a worker process.
|
||||
|
||||
@ivar message: An error message describing the exception.
|
||||
"""
|
||||
|
||||
message: str
|
||||
|
||||
|
||||
class RunResult(TypedDict):
|
||||
"""
|
||||
Represent the result of a L{workercommands.Run} command.
|
||||
"""
|
||||
|
||||
success: bool
|
||||
|
||||
|
||||
class Worker(Protocol):
|
||||
"""
|
||||
An object that can run actions.
|
||||
"""
|
||||
|
||||
async def run(self, case: TestCase, result: TestResult) -> RunResult:
|
||||
"""
|
||||
Run a test case.
|
||||
"""
|
||||
|
||||
|
||||
_T = TypeVar("_T")
|
||||
WorkerAction = Callable[[Worker], Awaitable[_T]]
|
||||
|
||||
|
||||
class WorkerProtocol(AMP):
|
||||
"""
|
||||
The worker-side trial distributed protocol.
|
||||
"""
|
||||
|
||||
logger = Logger()
|
||||
|
||||
def __init__(self, forceGarbageCollection=False):
|
||||
self._loader = TestLoader()
|
||||
self._result = WorkerReporter(self)
|
||||
self._forceGarbageCollection = forceGarbageCollection
|
||||
|
||||
@workercommands.Run.responder
|
||||
async def run(self, testCase: str) -> RunResult:
|
||||
"""
|
||||
Run a test case by name.
|
||||
"""
|
||||
with self._result.gatherReportingResults() as results:
|
||||
case = self._loader.loadByName(testCase)
|
||||
suite = TrialSuite([case], self._forceGarbageCollection)
|
||||
suite.run(self._result)
|
||||
|
||||
allSucceeded = True
|
||||
for (success, result) in await DeferredList(results, consumeErrors=True):
|
||||
if success:
|
||||
# Nothing to do here, proceed to the next result.
|
||||
continue
|
||||
|
||||
# There was some error reporting a result to the peer.
|
||||
allSucceeded = False
|
||||
|
||||
# We can try to report the error but since something has already
|
||||
# gone wrong we shouldn't be extremely confident that this will
|
||||
# succeed. So we will also log it (and any errors reporting *it*)
|
||||
# to our local log.
|
||||
self.logger.failure(
|
||||
"Result reporting for {id} failed",
|
||||
failure=result,
|
||||
id=testCase,
|
||||
)
|
||||
try:
|
||||
await self._result.addErrorFallible(testCase, result)
|
||||
except BaseException:
|
||||
# We failed to report the failure to the peer. It doesn't
|
||||
# seem very likely that reporting this new failure to the peer
|
||||
# will succeed so just log it locally.
|
||||
self.logger.failure(
|
||||
"Additionally, reporting the reporting failure failed."
|
||||
)
|
||||
|
||||
return {"success": allSucceeded}
|
||||
|
||||
@workercommands.Start.responder
|
||||
def start(self, directory):
|
||||
"""
|
||||
Set up the worker, moving into given directory for tests to run in
|
||||
them.
|
||||
"""
|
||||
os.chdir(directory)
|
||||
return {"success": True}
|
||||
|
||||
|
||||
class LocalWorkerAMP(AMP):
|
||||
"""
|
||||
Local implementation of the manager commands.
|
||||
"""
|
||||
|
||||
def __init__(self, boxReceiver=None, locator=None):
|
||||
super().__init__(boxReceiver, locator)
|
||||
self._streams = StreamReceiver()
|
||||
|
||||
@StreamOpen.responder
|
||||
def streamOpen(self):
|
||||
return {"streamId": self._streams.open()}
|
||||
|
||||
@StreamWrite.responder
|
||||
def streamWrite(self, streamId, data):
|
||||
self._streams.write(streamId, data)
|
||||
return {}
|
||||
|
||||
@managercommands.AddSuccess.responder
|
||||
def addSuccess(self, testName):
|
||||
"""
|
||||
Add a success to the reporter.
|
||||
"""
|
||||
self._result.addSuccess(self._testCase)
|
||||
return {"success": True}
|
||||
|
||||
def _buildFailure(
|
||||
self,
|
||||
error: WorkerException,
|
||||
errorClass: str,
|
||||
frames: List[str],
|
||||
) -> Failure:
|
||||
"""
|
||||
Helper to build a C{Failure} with some traceback.
|
||||
|
||||
@param error: An C{Exception} instance.
|
||||
|
||||
@param errorClass: The class name of the C{error} class.
|
||||
|
||||
@param frames: A flat list of strings representing the information need
|
||||
to approximatively rebuild C{Failure} frames.
|
||||
|
||||
@return: A L{Failure} instance with enough information about a test
|
||||
error.
|
||||
"""
|
||||
errorType = namedObject(errorClass)
|
||||
failure = Failure(error, errorType)
|
||||
for i in range(0, len(frames), 3):
|
||||
failure.frames.append(
|
||||
(frames[i], frames[i + 1], int(frames[i + 2]), [], [])
|
||||
)
|
||||
return failure
|
||||
|
||||
@managercommands.AddError.responder
|
||||
def addError(
|
||||
self,
|
||||
testName: str,
|
||||
errorClass: str,
|
||||
errorStreamId: int,
|
||||
framesStreamId: int,
|
||||
) -> Dict[str, bool]:
|
||||
"""
|
||||
Add an error to the reporter.
|
||||
|
||||
@param errorStreamId: The identifier of a stream over which the text
|
||||
of this error was previously completely sent to the peer.
|
||||
|
||||
@param framesStreamId: The identifier of a stream over which the lines
|
||||
of the traceback for this error were previously completely sent to
|
||||
the peer.
|
||||
|
||||
@param error: A message describing the error.
|
||||
"""
|
||||
error = b"".join(self._streams.finish(errorStreamId)).decode("utf-8")
|
||||
frames = [
|
||||
frame.decode("utf-8") for frame in self._streams.finish(framesStreamId)
|
||||
]
|
||||
# Wrap the error message in ``WorkerException`` because it is not
|
||||
# possible to transfer arbitrary exception values over the AMP
|
||||
# connection to the main process but we must give *some* Exception
|
||||
# (not a str) to the test result object.
|
||||
failure = self._buildFailure(WorkerException(error), errorClass, frames)
|
||||
self._result.addError(self._testCase, failure)
|
||||
return {"success": True}
|
||||
|
||||
@managercommands.AddFailure.responder
|
||||
def addFailure(
|
||||
self,
|
||||
testName: str,
|
||||
failStreamId: int,
|
||||
failClass: str,
|
||||
framesStreamId: int,
|
||||
) -> Dict[str, bool]:
|
||||
"""
|
||||
Add a failure to the reporter.
|
||||
|
||||
@param failStreamId: The identifier of a stream over which the text of
|
||||
this failure was previously completely sent to the peer.
|
||||
|
||||
@param framesStreamId: The identifier of a stream over which the lines
|
||||
of the traceback for this error were previously completely sent to the
|
||||
peer.
|
||||
"""
|
||||
fail = b"".join(self._streams.finish(failStreamId)).decode("utf-8")
|
||||
frames = [
|
||||
frame.decode("utf-8") for frame in self._streams.finish(framesStreamId)
|
||||
]
|
||||
# See addError for info about use of WorkerException here.
|
||||
failure = self._buildFailure(WorkerException(fail), failClass, frames)
|
||||
self._result.addFailure(self._testCase, failure)
|
||||
return {"success": True}
|
||||
|
||||
@managercommands.AddSkip.responder
|
||||
def addSkip(self, testName, reason):
|
||||
"""
|
||||
Add a skip to the reporter.
|
||||
"""
|
||||
self._result.addSkip(self._testCase, reason)
|
||||
return {"success": True}
|
||||
|
||||
@managercommands.AddExpectedFailure.responder
|
||||
def addExpectedFailure(
|
||||
self, testName: str, errorStreamId: int, todo: Optional[str]
|
||||
) -> Dict[str, bool]:
|
||||
"""
|
||||
Add an expected failure to the reporter.
|
||||
|
||||
@param errorStreamId: The identifier of a stream over which the text
|
||||
of this error was previously completely sent to the peer.
|
||||
"""
|
||||
error = b"".join(self._streams.finish(errorStreamId)).decode("utf-8")
|
||||
_todo = Todo("<unknown>" if todo is None else todo)
|
||||
self._result.addExpectedFailure(self._testCase, error, _todo)
|
||||
return {"success": True}
|
||||
|
||||
@managercommands.AddUnexpectedSuccess.responder
|
||||
def addUnexpectedSuccess(self, testName, todo):
|
||||
"""
|
||||
Add an unexpected success to the reporter.
|
||||
"""
|
||||
self._result.addUnexpectedSuccess(self._testCase, todo)
|
||||
return {"success": True}
|
||||
|
||||
@managercommands.TestWrite.responder
|
||||
def testWrite(self, out):
|
||||
"""
|
||||
Print test output from the worker.
|
||||
"""
|
||||
self._testStream.write(out + "\n")
|
||||
self._testStream.flush()
|
||||
return {"success": True}
|
||||
|
||||
async def run(self, testCase: TestCase, result: TestResult) -> RunResult:
|
||||
"""
|
||||
Run a test.
|
||||
"""
|
||||
self._testCase = testCase
|
||||
self._result = result
|
||||
self._result.startTest(testCase)
|
||||
testCaseId = testCase.id()
|
||||
try:
|
||||
return await self.callRemote(workercommands.Run, testCase=testCaseId) # type: ignore[no-any-return]
|
||||
finally:
|
||||
self._result.stopTest(testCase)
|
||||
|
||||
def setTestStream(self, stream):
|
||||
"""
|
||||
Set the stream used to log output from tests.
|
||||
"""
|
||||
self._testStream = stream
|
||||
|
||||
|
||||
@implementer(IAddress)
|
||||
class LocalWorkerAddress:
|
||||
"""
|
||||
A L{IAddress} implementation meant to provide stub addresses for
|
||||
L{ITransport.getPeer} and L{ITransport.getHost}.
|
||||
"""
|
||||
|
||||
|
||||
@implementer(ITransport)
|
||||
class LocalWorkerTransport:
|
||||
"""
|
||||
A stub transport implementation used to support L{AMP} over a
|
||||
L{ProcessProtocol} transport.
|
||||
"""
|
||||
|
||||
def __init__(self, transport):
|
||||
self._transport = transport
|
||||
|
||||
def write(self, data):
|
||||
"""
|
||||
Forward data to transport.
|
||||
"""
|
||||
self._transport.writeToChild(_WORKER_AMP_STDIN, data)
|
||||
|
||||
def writeSequence(self, sequence):
|
||||
"""
|
||||
Emulate C{writeSequence} by iterating data in the C{sequence}.
|
||||
"""
|
||||
for data in sequence:
|
||||
self._transport.writeToChild(_WORKER_AMP_STDIN, data)
|
||||
|
||||
def loseConnection(self):
|
||||
"""
|
||||
Closes the transport.
|
||||
"""
|
||||
self._transport.loseConnection()
|
||||
|
||||
def getHost(self):
|
||||
"""
|
||||
Return a L{LocalWorkerAddress} instance.
|
||||
"""
|
||||
return LocalWorkerAddress()
|
||||
|
||||
def getPeer(self):
|
||||
"""
|
||||
Return a L{LocalWorkerAddress} instance.
|
||||
"""
|
||||
return LocalWorkerAddress()
|
||||
|
||||
|
||||
class NotRunning(Exception):
|
||||
"""
|
||||
An operation was attempted on a worker process which is not running.
|
||||
"""
|
||||
|
||||
|
||||
class LocalWorker(ProcessProtocol):
|
||||
"""
|
||||
Local process worker protocol. This worker runs as a local process and
|
||||
communicates via stdin/out.
|
||||
|
||||
@ivar _ampProtocol: The L{AMP} protocol instance used to communicate with
|
||||
the worker.
|
||||
|
||||
@ivar _logDirectory: The directory where logs will reside.
|
||||
|
||||
@ivar _logFile: The main log file for tests output.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
ampProtocol: LocalWorkerAMP,
|
||||
logDirectory: FilePath,
|
||||
logFile: TextIO,
|
||||
):
|
||||
self._ampProtocol = ampProtocol
|
||||
self._logDirectory = logDirectory
|
||||
self._logFile = logFile
|
||||
self.endDeferred: Deferred = Deferred()
|
||||
|
||||
async def exit(self) -> None:
|
||||
"""
|
||||
Cause the worker process to exit.
|
||||
"""
|
||||
if self.transport is None:
|
||||
raise NotRunning()
|
||||
|
||||
endDeferred = self.endDeferred
|
||||
self.transport.closeChildFD(_WORKER_AMP_STDIN)
|
||||
try:
|
||||
await endDeferred
|
||||
except ProcessDone:
|
||||
pass
|
||||
|
||||
def connectionMade(self):
|
||||
"""
|
||||
When connection is made, create the AMP protocol instance.
|
||||
"""
|
||||
self._ampProtocol.makeConnection(LocalWorkerTransport(self.transport))
|
||||
self._logDirectory.makedirs(ignoreExistingDirectory=True)
|
||||
self._outLog = self._logDirectory.child("out.log").open("w")
|
||||
self._errLog = self._logDirectory.child("err.log").open("w")
|
||||
self._ampProtocol.setTestStream(self._logFile)
|
||||
d = self._ampProtocol.callRemote(
|
||||
workercommands.Start,
|
||||
directory=self._logDirectory.path,
|
||||
)
|
||||
# Ignore the potential errors, the test suite will fail properly and it
|
||||
# would just print garbage.
|
||||
d.addErrback(lambda x: None)
|
||||
|
||||
def connectionLost(self, reason):
|
||||
"""
|
||||
On connection lost, close the log files that we're managing for stdin
|
||||
and stdout.
|
||||
"""
|
||||
self._outLog.close()
|
||||
self._errLog.close()
|
||||
self.transport = None
|
||||
|
||||
def processEnded(self, reason):
|
||||
"""
|
||||
When the process closes, call C{connectionLost} for cleanup purposes
|
||||
and forward the information to the C{_ampProtocol}.
|
||||
"""
|
||||
self.connectionLost(reason)
|
||||
self._ampProtocol.connectionLost(reason)
|
||||
self.endDeferred.callback(reason)
|
||||
|
||||
def outReceived(self, data):
|
||||
"""
|
||||
Send data received from stdout to log.
|
||||
"""
|
||||
|
||||
self._outLog.write(data)
|
||||
|
||||
def errReceived(self, data):
|
||||
"""
|
||||
Write error data to log.
|
||||
"""
|
||||
self._errLog.write(data)
|
||||
|
||||
def childDataReceived(self, childFD, data):
|
||||
"""
|
||||
Handle data received on the specific pipe for the C{_ampProtocol}.
|
||||
"""
|
||||
if childFD == _WORKER_AMP_STDOUT:
|
||||
self._ampProtocol.dataReceived(data)
|
||||
else:
|
||||
ProcessProtocol.childDataReceived(self, childFD, data)
|
||||
@@ -0,0 +1,30 @@
|
||||
# Copyright (c) Twisted Matrix Laboratories.
|
||||
# See LICENSE for details.
|
||||
|
||||
"""
|
||||
Commands for telling a worker to load tests or run tests.
|
||||
|
||||
@since: 12.3
|
||||
"""
|
||||
|
||||
from twisted.protocols.amp import Boolean, Command, Unicode
|
||||
|
||||
NativeString = Unicode
|
||||
|
||||
|
||||
class Run(Command):
|
||||
"""
|
||||
Run a test.
|
||||
"""
|
||||
|
||||
arguments = [(b"testCase", NativeString())]
|
||||
response = [(b"success", Boolean())]
|
||||
|
||||
|
||||
class Start(Command):
|
||||
"""
|
||||
Set up the worker process, giving the running directory.
|
||||
"""
|
||||
|
||||
arguments = [(b"directory", NativeString())]
|
||||
response = [(b"success", Boolean())]
|
||||
@@ -0,0 +1,354 @@
|
||||
# -*- test-case-name: twisted.trial._dist.test.test_workerreporter -*-
|
||||
#
|
||||
# Copyright (c) Twisted Matrix Laboratories.
|
||||
# See LICENSE for details.
|
||||
|
||||
"""
|
||||
Test reporter forwarding test results over trial distributed AMP commands.
|
||||
|
||||
@since: 12.3
|
||||
"""
|
||||
|
||||
from types import TracebackType
|
||||
from typing import Callable, List, Optional, Sequence, Type, TypeVar
|
||||
from unittest import TestCase as PyUnitTestCase
|
||||
|
||||
from attrs import Factory, define
|
||||
from typing_extensions import Literal
|
||||
|
||||
from twisted.internet.defer import Deferred, maybeDeferred
|
||||
from twisted.protocols.amp import AMP, MAX_VALUE_LENGTH
|
||||
from twisted.python.failure import Failure
|
||||
from twisted.python.reflect import qual
|
||||
from twisted.trial._dist import managercommands
|
||||
from twisted.trial.reporter import TestResult
|
||||
from ..reporter import TrialFailure
|
||||
from .stream import chunk, stream
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
async def addError(
|
||||
amp: AMP, testName: str, errorClass: str, error: str, frames: List[str]
|
||||
) -> None:
|
||||
"""
|
||||
Send an error to the worker manager over an AMP connection.
|
||||
|
||||
First the pieces which can be large are streamed over the connection.
|
||||
Then, L{managercommands.AddError} is called with the rest of the
|
||||
information and the stream IDs.
|
||||
|
||||
:param amp: The connection to use.
|
||||
:param testName: The name (or ID) of the test the error relates to.
|
||||
:param errorClass: The fully qualified name of the error type.
|
||||
:param error: The string representation of the error.
|
||||
:param frames: The lines of the traceback associated with the error.
|
||||
"""
|
||||
|
||||
errorStreamId = await stream(amp, chunk(error.encode("utf-8"), MAX_VALUE_LENGTH))
|
||||
framesStreamId = await stream(amp, (frame.encode("utf-8") for frame in frames))
|
||||
|
||||
await amp.callRemote(
|
||||
managercommands.AddError,
|
||||
testName=testName,
|
||||
errorClass=errorClass,
|
||||
errorStreamId=errorStreamId,
|
||||
framesStreamId=framesStreamId,
|
||||
)
|
||||
|
||||
|
||||
async def addFailure(
|
||||
amp: AMP, testName: str, fail: str, failClass: str, frames: List[str]
|
||||
) -> None:
|
||||
"""
|
||||
Like L{addError} but for failures.
|
||||
|
||||
:param amp: See L{addError}
|
||||
:param testName: See L{addError}
|
||||
:param failClass: The fully qualified name of the exception associated
|
||||
with the failure.
|
||||
:param fail: The string representation of the failure.
|
||||
:param frames: The lines of the traceback associated with the error.
|
||||
"""
|
||||
failStreamId = await stream(amp, chunk(fail.encode("utf-8"), MAX_VALUE_LENGTH))
|
||||
framesStreamId = await stream(amp, (frame.encode("utf-8") for frame in frames))
|
||||
|
||||
await amp.callRemote(
|
||||
managercommands.AddFailure,
|
||||
testName=testName,
|
||||
failClass=failClass,
|
||||
failStreamId=failStreamId,
|
||||
framesStreamId=framesStreamId,
|
||||
)
|
||||
|
||||
|
||||
async def addExpectedFailure(amp: AMP, testName: str, error: str, todo: str) -> None:
|
||||
"""
|
||||
Like L{addError} but for expected failures.
|
||||
|
||||
:param amp: See L{addError}
|
||||
:param testName: See L{addError}
|
||||
:param error: The string representation of the expected failure.
|
||||
:param todo: The string description of the expectation.
|
||||
"""
|
||||
errorStreamId = await stream(amp, chunk(error.encode("utf-8"), MAX_VALUE_LENGTH))
|
||||
|
||||
await amp.callRemote(
|
||||
managercommands.AddExpectedFailure,
|
||||
testName=testName,
|
||||
errorStreamId=errorStreamId,
|
||||
todo=todo,
|
||||
)
|
||||
|
||||
|
||||
@define
|
||||
class ReportingResults:
|
||||
"""
|
||||
A mutable container for the result of sending test results back to the
|
||||
parent process.
|
||||
|
||||
Since it is possible for these sends to fail asynchronously but the
|
||||
L{TestResult} protocol is not well suited for asynchronous result
|
||||
reporting, results are collected on an instance of this class and when the
|
||||
runner believes the test is otherwise complete, it can collect the results
|
||||
and do something with any errors.
|
||||
|
||||
:ivar _reporter: The L{WorkerReporter} this object is associated with.
|
||||
This is the object doing the result reporting.
|
||||
|
||||
:ivar _results: A list of L{Deferred} instances representing the results
|
||||
of reporting operations. This is expected to grow over the course of
|
||||
the test run and then be inspected by the runner once the test is
|
||||
over. The public interface to this list is via the context manager
|
||||
interface.
|
||||
"""
|
||||
|
||||
_reporter: "WorkerReporter"
|
||||
_results: List[Deferred[object]] = Factory(list)
|
||||
|
||||
def __enter__(self) -> Sequence[Deferred[object]]:
|
||||
"""
|
||||
Begin a new reportable context in which results can be collected.
|
||||
|
||||
:return: A sequence which will contain the L{Deferred} instances
|
||||
representing the results of all test result reporting that happens
|
||||
while the context manager is active. The sequence is extended as
|
||||
the test runs so its value should not be consumed until the test
|
||||
is over.
|
||||
"""
|
||||
return self._results
|
||||
|
||||
def __exit__(
|
||||
self,
|
||||
excType: Type[BaseException],
|
||||
excValue: BaseException,
|
||||
excTraceback: TracebackType,
|
||||
) -> Literal[False]:
|
||||
"""
|
||||
End the reportable context.
|
||||
"""
|
||||
self._reporter._reporting = None
|
||||
return False
|
||||
|
||||
def record(self, result: Deferred[object]) -> None:
|
||||
"""
|
||||
Record a L{Deferred} instance representing one test result reporting
|
||||
operation.
|
||||
"""
|
||||
self._results.append(result)
|
||||
|
||||
|
||||
class WorkerReporter(TestResult):
|
||||
"""
|
||||
Reporter for trial's distributed workers. We send things not through a
|
||||
stream, but through an C{AMP} protocol's C{callRemote} method.
|
||||
|
||||
@ivar _DEFAULT_TODO: Default message for expected failures and
|
||||
unexpected successes, used only if a C{Todo} is not provided.
|
||||
|
||||
@ivar _reporting: When a "result reporting" context is active, the
|
||||
corresponding context manager. Otherwise, L{None}.
|
||||
"""
|
||||
|
||||
_DEFAULT_TODO = "Test expected to fail"
|
||||
|
||||
ampProtocol: AMP
|
||||
_reporting: Optional[ReportingResults] = None
|
||||
|
||||
def __init__(self, ampProtocol):
|
||||
"""
|
||||
@param ampProtocol: The communication channel with the trial
|
||||
distributed manager which collects all test results.
|
||||
"""
|
||||
super().__init__()
|
||||
self.ampProtocol = ampProtocol
|
||||
|
||||
def gatherReportingResults(self) -> ReportingResults:
|
||||
"""
|
||||
Get a "result reporting" context manager.
|
||||
|
||||
In a "result reporting" context, asynchronous test result reporting
|
||||
methods may be used safely. Their results (in particular, failures)
|
||||
are available from the context manager.
|
||||
"""
|
||||
self._reporting = ReportingResults(self)
|
||||
return self._reporting
|
||||
|
||||
def _getFailure(self, error: TrialFailure) -> Failure:
|
||||
"""
|
||||
Convert a C{sys.exc_info()}-style tuple to a L{Failure}, if necessary.
|
||||
"""
|
||||
if isinstance(error, tuple):
|
||||
return Failure(error[1], error[0], error[2])
|
||||
return error
|
||||
|
||||
def _getFrames(self, failure: Failure) -> List[str]:
|
||||
"""
|
||||
Extract frames from a C{Failure} instance.
|
||||
"""
|
||||
frames: List[str] = []
|
||||
for frame in failure.frames:
|
||||
# The code object's name, the code object's filename, and the line
|
||||
# number.
|
||||
frames.extend([frame[0], frame[1], str(frame[2])])
|
||||
return frames
|
||||
|
||||
def _call(self, f: Callable[[], T]) -> None:
|
||||
"""
|
||||
Call L{f} if and only if a "result reporting" context is active.
|
||||
|
||||
@param f: A function to call. Its result is accumulated into the
|
||||
result reporting context. It may return a L{Deferred} or a
|
||||
coroutine or synchronously raise an exception or return a result
|
||||
value.
|
||||
|
||||
@raise ValueError: If no result reporting context is active.
|
||||
"""
|
||||
if self._reporting is not None:
|
||||
self._reporting.record(maybeDeferred(f))
|
||||
else:
|
||||
raise ValueError(
|
||||
"Cannot call command outside of reporting context manager."
|
||||
)
|
||||
|
||||
def addSuccess(self, test: PyUnitTestCase) -> None:
|
||||
"""
|
||||
Send a success to the parent process.
|
||||
|
||||
This must be called in context managed by L{gatherReportingResults}.
|
||||
"""
|
||||
super().addSuccess(test)
|
||||
testName = test.id()
|
||||
self._call(
|
||||
lambda: self.ampProtocol.callRemote( # type: ignore[no-any-return]
|
||||
managercommands.AddSuccess, testName=testName
|
||||
)
|
||||
)
|
||||
|
||||
async def addErrorFallible(self, testName: str, errorObj: TrialFailure) -> None:
|
||||
"""
|
||||
Attempt to report an error to the parent process.
|
||||
|
||||
Unlike L{addError} this can fail asynchronously. This version is for
|
||||
infrastructure code that can apply its own failure handling.
|
||||
|
||||
@return: A L{Deferred} that fires with the result of the attempt.
|
||||
"""
|
||||
failure = self._getFailure(errorObj)
|
||||
errorStr = failure.getErrorMessage()
|
||||
errorClass = qual(failure.type)
|
||||
frames = self._getFrames(failure)
|
||||
await addError(
|
||||
self.ampProtocol,
|
||||
testName,
|
||||
errorClass,
|
||||
errorStr,
|
||||
frames,
|
||||
)
|
||||
|
||||
def addError(self, test: PyUnitTestCase, error: TrialFailure) -> None:
|
||||
"""
|
||||
Send an error to the parent process.
|
||||
"""
|
||||
super().addError(test, error)
|
||||
testName = test.id()
|
||||
self._call(lambda: self.addErrorFallible(testName, error))
|
||||
|
||||
def addFailure(self, test: PyUnitTestCase, fail: TrialFailure) -> None:
|
||||
"""
|
||||
Send a Failure over.
|
||||
"""
|
||||
super().addFailure(test, fail)
|
||||
testName = test.id()
|
||||
failure = self._getFailure(fail)
|
||||
failureMessage = failure.getErrorMessage()
|
||||
failClass = qual(failure.type)
|
||||
frames = self._getFrames(failure)
|
||||
self._call(
|
||||
lambda: addFailure(
|
||||
self.ampProtocol,
|
||||
testName,
|
||||
failureMessage,
|
||||
failClass,
|
||||
frames,
|
||||
),
|
||||
)
|
||||
|
||||
def addSkip(self, test, reason):
|
||||
"""
|
||||
Send a skip over.
|
||||
"""
|
||||
super().addSkip(test, reason)
|
||||
reason = str(reason)
|
||||
testName = test.id()
|
||||
self._call(
|
||||
lambda: self.ampProtocol.callRemote(
|
||||
managercommands.AddSkip, testName=testName, reason=reason
|
||||
)
|
||||
)
|
||||
|
||||
def _getTodoReason(self, todo):
|
||||
"""
|
||||
Get the reason for a C{Todo}.
|
||||
|
||||
If C{todo} is L{None}, return a sensible default.
|
||||
"""
|
||||
if todo is None:
|
||||
return self._DEFAULT_TODO
|
||||
else:
|
||||
return todo.reason
|
||||
|
||||
def addExpectedFailure(self, test, error, todo=None):
|
||||
"""
|
||||
Send an expected failure over.
|
||||
"""
|
||||
super().addExpectedFailure(test, error, todo)
|
||||
errorMessage = error.getErrorMessage()
|
||||
testName = test.id()
|
||||
self._call(
|
||||
lambda: addExpectedFailure(
|
||||
self.ampProtocol,
|
||||
testName=testName,
|
||||
error=errorMessage,
|
||||
todo=self._getTodoReason(todo),
|
||||
)
|
||||
)
|
||||
|
||||
def addUnexpectedSuccess(self, test, todo=None):
|
||||
"""
|
||||
Send an unexpected success over.
|
||||
"""
|
||||
super().addUnexpectedSuccess(test, todo)
|
||||
testName = test.id()
|
||||
self._call(
|
||||
lambda: self.ampProtocol.callRemote(
|
||||
managercommands.AddUnexpectedSuccess,
|
||||
testName=testName,
|
||||
todo=self._getTodoReason(todo),
|
||||
)
|
||||
)
|
||||
|
||||
def printSummary(self):
|
||||
"""
|
||||
I{Don't} print a summary
|
||||
"""
|
||||
@@ -0,0 +1,93 @@
|
||||
# -*- test-case-name: twisted.trial._dist.test.test_workertrial -*-
|
||||
#
|
||||
# Copyright (c) Twisted Matrix Laboratories.
|
||||
# See LICENSE for details.
|
||||
|
||||
"""
|
||||
Implementation of C{AMP} worker commands, and main executable entry point for
|
||||
the workers.
|
||||
|
||||
@since: 12.3
|
||||
"""
|
||||
|
||||
import errno
|
||||
import os
|
||||
import sys
|
||||
|
||||
from twisted.internet.protocol import FileWrapper
|
||||
from twisted.python.log import startLoggingWithObserver, textFromEventDict
|
||||
from twisted.trial._dist import _WORKER_AMP_STDIN, _WORKER_AMP_STDOUT
|
||||
from twisted.trial._dist.options import WorkerOptions
|
||||
|
||||
|
||||
class WorkerLogObserver:
|
||||
"""
|
||||
A log observer that forward its output to a C{AMP} protocol.
|
||||
"""
|
||||
|
||||
def __init__(self, protocol):
|
||||
"""
|
||||
@param protocol: a connected C{AMP} protocol instance.
|
||||
@type protocol: C{AMP}
|
||||
"""
|
||||
self.protocol = protocol
|
||||
|
||||
def emit(self, eventDict):
|
||||
"""
|
||||
Produce a log output.
|
||||
"""
|
||||
from twisted.trial._dist import managercommands
|
||||
|
||||
text = textFromEventDict(eventDict)
|
||||
if text is None:
|
||||
return
|
||||
self.protocol.callRemote(managercommands.TestWrite, out=text)
|
||||
|
||||
|
||||
def main(_fdopen=os.fdopen):
|
||||
"""
|
||||
Main function to be run if __name__ == "__main__".
|
||||
|
||||
@param _fdopen: If specified, the function to use in place of C{os.fdopen}.
|
||||
@type _fdopen: C{callable}
|
||||
"""
|
||||
config = WorkerOptions()
|
||||
config.parseOptions()
|
||||
|
||||
from twisted.trial._dist.worker import WorkerProtocol
|
||||
|
||||
workerProtocol = WorkerProtocol(config["force-gc"])
|
||||
|
||||
protocolIn = _fdopen(_WORKER_AMP_STDIN, "rb")
|
||||
protocolOut = _fdopen(_WORKER_AMP_STDOUT, "wb")
|
||||
workerProtocol.makeConnection(FileWrapper(protocolOut))
|
||||
|
||||
observer = WorkerLogObserver(workerProtocol)
|
||||
startLoggingWithObserver(observer.emit, False)
|
||||
|
||||
while True:
|
||||
try:
|
||||
r = protocolIn.read(1)
|
||||
except OSError as e:
|
||||
if e.args[0] == errno.EINTR:
|
||||
continue
|
||||
else:
|
||||
raise
|
||||
if r == b"":
|
||||
break
|
||||
else:
|
||||
workerProtocol.dataReceived(r)
|
||||
protocolOut.flush()
|
||||
sys.stdout.flush()
|
||||
sys.stderr.flush()
|
||||
|
||||
if config.tracer:
|
||||
sys.settrace(None)
|
||||
results = config.tracer.results()
|
||||
results.write_results(
|
||||
show_missing=True, summary=False, coverdir=config.coverdir().path
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user