RAHHH
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
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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,512 @@
|
||||
# -*- 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 (
|
||||
Any,
|
||||
Awaitable,
|
||||
Callable,
|
||||
Iterable,
|
||||
List,
|
||||
Optional,
|
||||
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[Any]
|
||||
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[Any]
|
||||
testDirLock: FilesystemLock
|
||||
testLog: TextIO
|
||||
workers: List[LocalWorker]
|
||||
ampWorkers: List[LocalWorkerAMP]
|
||||
|
||||
_logger = Logger()
|
||||
|
||||
async def run(self, workerAction: WorkerAction[Any]) -> 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[Any],
|
||||
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, None] = None
|
||||
reactorStopping: bool = False
|
||||
testsInProgress: Deferred[object]
|
||||
|
||||
def capture(r: Union[Failure, DistReporter]) -> None:
|
||||
nonlocal result
|
||||
result = r
|
||||
|
||||
def maybeStopTests() -> Optional[Deferred[object]]:
|
||||
nonlocal reactorStopping
|
||||
reactorStopping = True
|
||||
if result is None:
|
||||
testsInProgress.cancel()
|
||||
return testsInProgress
|
||||
return None
|
||||
|
||||
def maybeStopReactor(result: object) -> object:
|
||||
if not reactorStopping:
|
||||
self._reactor.stop()
|
||||
return result
|
||||
|
||||
self._reactor.addSystemEventTrigger("before", "shutdown", maybeStopTests)
|
||||
|
||||
testsInProgress = (
|
||||
Deferred.fromCoroutine(self.runAsync(test, untilFailure))
|
||||
.addBoth(capture)
|
||||
.addBoth(maybeStopReactor)
|
||||
)
|
||||
|
||||
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), f"{result} is not 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,122 @@
|
||||
# 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.
|
||||
discardResult: Callable[[Awaitable[_A]], Deferred[None]] = compose(
|
||||
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() -> _A:
|
||||
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.
|
||||
"""
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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 Any, 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[Any])
|
||||
|
||||
|
||||
def matches_result(
|
||||
successes: Matcher[Any] = equal_to(0),
|
||||
errors: Matcher[Any] = has_length(0),
|
||||
failures: Matcher[Any] = has_length(0),
|
||||
skips: Matcher[Any] = has_length(0),
|
||||
expectedFailures: Matcher[Any] = has_length(0),
|
||||
unexpectedSuccesses: Matcher[Any] = has_length(0),
|
||||
) -> Matcher[Any]:
|
||||
"""
|
||||
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,861 @@
|
||||
# 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 CancelledError, Deferred, succeed
|
||||
from twisted.internet.error import ProcessDone
|
||||
from twisted.internet.protocol import ProcessProtocol, Protocol
|
||||
from twisted.internet.test.modulehelpers import AlternateReactor
|
||||
from twisted.internet.testing import MemoryReactorClock
|
||||
from twisted.python.failure import Failure
|
||||
from twisted.python.filepath import FilePath
|
||||
from twisted.python.lockfile import FilesystemLock
|
||||
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)
|
||||
# TODO: implementing this more comprehensively in MemoryReactor would
|
||||
# be nice, this is rather hard-coded to disttrial's current
|
||||
# implementation.
|
||||
if "before" in self.triggers:
|
||||
self.triggers["before"]["shutdown"][0][0]()
|
||||
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)
|
||||
self.stop()
|
||||
# do not count internal 'stop' against trial-initiated .stop() count
|
||||
self.stopCount -= 1
|
||||
|
||||
|
||||
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_runUnexpectedErrorCtrlC(self) -> None:
|
||||
"""
|
||||
If the reactor is stopped by C-c (i.e. `run` returns before the test
|
||||
case's Deferred has been fired) we should cancel the pending test run.
|
||||
"""
|
||||
runner = self.getRunner(workerPoolFactory=LocalWorkerPool)
|
||||
with self.assertRaises(CancelledError):
|
||||
runner.run(self.suite)
|
||||
|
||||
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=None,
|
||||
path=None,
|
||||
uid=None,
|
||||
gid=None,
|
||||
usePTY=False,
|
||||
childFDs=None,
|
||||
):
|
||||
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) -> None:
|
||||
"""
|
||||
``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) -> None:
|
||||
"""
|
||||
``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) -> None:
|
||||
"""
|
||||
``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]) -> 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[str]
|
||||
workers: List[Worker]
|
||||
_stopped: Deferred[None]
|
||||
|
||||
async def run(self, workerAction: WorkerAction[None]) -> 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) -> None:
|
||||
"""
|
||||
Build an L{WorkerOptions} object to be used in the tests.
|
||||
"""
|
||||
self.options = WorkerOptions()
|
||||
|
||||
def test_standardOptions(self) -> None:
|
||||
"""
|
||||
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) -> None:
|
||||
"""
|
||||
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,165 @@
|
||||
# Copyright (c) Twisted Matrix Laboratories.
|
||||
# See LICENSE for details.
|
||||
|
||||
"""
|
||||
Tests for L{twisted.trial._dist.workerreporter}.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Sized
|
||||
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[Sized]) -> 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.internet.testing import StringTransport
|
||||
from twisted.protocols.amp import AMP
|
||||
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,465 @@
|
||||
# -*- 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 Any, 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",
|
||||
# The DeferredList type annotation assumes all results succeed
|
||||
failure=result, # type: ignore[arg-type]
|
||||
id=testCase,
|
||||
)
|
||||
try:
|
||||
await self._result.addErrorFallible(
|
||||
testCase,
|
||||
# The DeferredList type annotation assumes all results succeed
|
||||
result, # type: ignore[arg-type]
|
||||
)
|
||||
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[Any],
|
||||
logFile: TextIO,
|
||||
):
|
||||
self._ampProtocol = ampProtocol
|
||||
self._logDirectory = logDirectory
|
||||
self._logFile = logFile
|
||||
self.endDeferred: Deferred[None] = 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: Failure) -> None:
|
||||
"""
|
||||
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(
|
||||
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