704 lines
19 KiB
Python
Executable File
704 lines
19 KiB
Python
Executable File
#!/usr/bin/env python
|
|
#
|
|
# Licensed to the Apache Software Foundation (ASF) under one
|
|
# or more contributor license agreements. See the NOTICE file
|
|
# distributed with this work for additional information
|
|
# regarding copyright ownership. The ASF licenses this file
|
|
# to you under the Apache License, Version 2.0 (the
|
|
# "License"); you may not use this file except in compliance
|
|
# with the License. You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing,
|
|
# software distributed under the License is distributed on an
|
|
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
# KIND, either express or implied. See the License for the
|
|
# specific language governing permissions and limitations
|
|
# under the License.
|
|
#
|
|
|
|
# KGIUSTI: mooched from the Proton project's python-test runner
|
|
|
|
# TODO: summarize, test harness preconditions (e.g. broker is alive)
|
|
|
|
from __future__ import print_function
|
|
import logging, optparse, os, struct, sys, time, traceback, types, cgi
|
|
from fnmatch import fnmatchcase as match
|
|
from getopt import GetoptError
|
|
from logging import getLogger, StreamHandler, Formatter, Filter, \
|
|
WARN, DEBUG, ERROR
|
|
|
|
if sys.version_info[0] == 2:
|
|
CLASS_TYPES = (type, types.ClassType)
|
|
else:
|
|
CLASS_TYPES = (type,)
|
|
|
|
levels = {
|
|
"DEBUG": DEBUG,
|
|
"WARN": WARN,
|
|
"ERROR": ERROR
|
|
}
|
|
|
|
sorted_levels = [(v, k) for k, v in list(levels.items())]
|
|
sorted_levels.sort()
|
|
sorted_levels = [v for k, v in sorted_levels]
|
|
|
|
parser = optparse.OptionParser(usage="usage: %prog [options] PATTERN ...",
|
|
description="Run tests matching the specified PATTERNs.")
|
|
parser.add_option("-l", "--list", action="store_true", default=False,
|
|
help="list tests instead of executing them")
|
|
parser.add_option("-f", "--log-file", metavar="FILE", help="log output to FILE")
|
|
parser.add_option("-v", "--log-level", metavar="LEVEL", default="WARN",
|
|
help="only display log messages of LEVEL or higher severity: "
|
|
"%s (default %%default)" % ", ".join(sorted_levels))
|
|
parser.add_option("-c", "--log-category", metavar="CATEGORY", action="append",
|
|
dest="log_categories", default=[],
|
|
help="log only categories matching CATEGORY pattern")
|
|
parser.add_option("-m", "--module", action="append", default=[],
|
|
dest="modules", help="add module to test search path")
|
|
parser.add_option("-i", "--ignore", action="append", default=[],
|
|
help="ignore tests matching IGNORE pattern")
|
|
parser.add_option("-I", "--ignore-file", metavar="IFILE", action="append",
|
|
default=[],
|
|
help="ignore tests matching patterns in IFILE")
|
|
parser.add_option("-H", "--halt-on-error", action="store_true", default=False,
|
|
dest="hoe", help="halt if an error is encountered")
|
|
parser.add_option("-t", "--time", action="store_true", default=False,
|
|
help="report timing information on test run")
|
|
parser.add_option("-D", "--define", metavar="DEFINE", dest="defines",
|
|
action="append", default=[], help="define test parameters")
|
|
parser.add_option("-x", "--xml", metavar="XML", dest="xml",
|
|
help="write test results in Junit style xml suitable for use by CI tools etc")
|
|
parser.add_option("-a", "--always-colorize", action="store_true", dest="always_colorize", default=False,
|
|
help="always colorize the test results rather than relying on terminal tty detection. Useful when invoked from Jython/Maven.")
|
|
parser.add_option("-n", metavar="count", dest="count", type=int, default=1,
|
|
help="run the tests <count> times")
|
|
parser.add_option("-b", "--bare", action="store_true", default=False,
|
|
help="Run bare, i.e. don't capture stack traces. This is useful under Jython as " +
|
|
"captured stack traces do not include the Java portion of the stack," +
|
|
"whereas non captured stack traces do.")
|
|
|
|
class Config:
|
|
|
|
def __init__(self):
|
|
self.defines = {}
|
|
self.log_file = None
|
|
self.log_level = WARN
|
|
self.log_categories = []
|
|
|
|
opts, args = parser.parse_args()
|
|
|
|
includes = []
|
|
excludes = ["*__*__"]
|
|
config = Config()
|
|
list_only = opts.list
|
|
for d in opts.defines:
|
|
try:
|
|
idx = d.index("=")
|
|
name = d[:idx]
|
|
value = d[idx+1:]
|
|
config.defines[name] = value
|
|
except ValueError:
|
|
config.defines[d] = None
|
|
config.log_file = opts.log_file
|
|
config.log_level = levels[opts.log_level.upper()]
|
|
config.log_categories = opts.log_categories
|
|
excludes.extend([v.strip() for v in opts.ignore])
|
|
for v in opts.ignore_file:
|
|
f = open(v)
|
|
for line in f:
|
|
line = line.strip()
|
|
if line.startswith("#"):
|
|
continue
|
|
excludes.append(line)
|
|
f.close()
|
|
|
|
for a in args:
|
|
includes.append(a.strip())
|
|
|
|
if not includes:
|
|
includes.append("*")
|
|
|
|
def is_ignored(path):
|
|
for p in excludes:
|
|
if match(path, p):
|
|
return True
|
|
return False
|
|
|
|
def is_included(path):
|
|
if is_ignored(path):
|
|
return False
|
|
for p in includes:
|
|
if match(path, p):
|
|
return True
|
|
return False
|
|
|
|
def is_smart():
|
|
return sys.stdout.isatty() and os.environ.get("TERM", "dumb") != "dumb"
|
|
|
|
try:
|
|
import fcntl, termios
|
|
|
|
def width():
|
|
if is_smart():
|
|
s = struct.pack("HHHH", 0, 0, 0, 0)
|
|
fd_stdout = sys.stdout.fileno()
|
|
x = fcntl.ioctl(fd_stdout, termios.TIOCGWINSZ, s)
|
|
rows, cols, xpx, ypx = struct.unpack("HHHH", x)
|
|
return cols
|
|
else:
|
|
try:
|
|
return int(os.environ.get("COLUMNS", "80"))
|
|
except ValueError:
|
|
return 80
|
|
|
|
WIDTH = width()
|
|
|
|
def resize(sig, frm):
|
|
global WIDTH
|
|
WIDTH = width()
|
|
|
|
import signal
|
|
signal.signal(signal.SIGWINCH, resize)
|
|
|
|
except ImportError:
|
|
WIDTH = 80
|
|
|
|
def vt100_attrs(*attrs):
|
|
return "\x1B[%sm" % ";".join(map(str, attrs))
|
|
|
|
vt100_reset = vt100_attrs(0)
|
|
|
|
KEYWORDS = {"pass": (32,),
|
|
"skip": (33,),
|
|
"fail": (31,),
|
|
"start": (34,),
|
|
"total": (34,),
|
|
"ignored": (33,),
|
|
"selected": (34,),
|
|
"elapsed": (34,),
|
|
"average": (34,)}
|
|
|
|
def colorize_word(word, text=None):
|
|
if text is None:
|
|
text = word
|
|
return colorize(text, *KEYWORDS.get(word, ()))
|
|
|
|
def colorize(text, *attrs):
|
|
if attrs and (is_smart() or opts.always_colorize):
|
|
return "%s%s%s" % (vt100_attrs(*attrs), text, vt100_reset)
|
|
else:
|
|
return text
|
|
|
|
def indent(text):
|
|
lines = text.split("\n")
|
|
return " %s" % "\n ".join(lines)
|
|
|
|
# Write a 'minimal' Junit xml style report file suitable for use by CI tools such as Jenkins.
|
|
class JunitXmlStyleReporter:
|
|
|
|
def __init__(self, file):
|
|
self.f = open(file, "w");
|
|
|
|
def begin(self):
|
|
self.f.write('<?xml version="1.0" encoding="UTF-8" ?>\n')
|
|
self.f.write('<testsuite name="pythontests">\n')
|
|
|
|
def report(self, name, result):
|
|
parts = name.split(".")
|
|
method = parts[-1]
|
|
module = '.'.join(parts[0:-1])
|
|
self.f.write('<testcase classname="%s" name="%s" time="%f">\n' % (module, method, result.time))
|
|
if result.failed:
|
|
escaped_type = cgi.escape(str(result.exception_type))
|
|
escaped_message = cgi.escape(str(result.exception_message))
|
|
self.f.write('<failure type="%s" message="%s">\n' % (escaped_type, escaped_message))
|
|
self.f.write('<![CDATA[\n')
|
|
self.f.write(result.formatted_exception_trace)
|
|
self.f.write(']]>\n')
|
|
self.f.write('</failure>\n')
|
|
if result.skipped:
|
|
self.f.write('<skipped/>\n')
|
|
self.f.write('</testcase>\n')
|
|
|
|
def end(self):
|
|
self.f.write('</testsuite>\n')
|
|
self.f.close()
|
|
|
|
class Interceptor:
|
|
|
|
def __init__(self):
|
|
self.newline = False
|
|
self.indent = False
|
|
self.passthrough = True
|
|
self.dirty = False
|
|
self.last = None
|
|
|
|
def begin(self):
|
|
self.newline = True
|
|
self.indent = True
|
|
self.passthrough = False
|
|
self.dirty = False
|
|
self.last = None
|
|
|
|
def reset(self):
|
|
self.newline = False
|
|
self.indent = False
|
|
self.passthrough = True
|
|
|
|
class StreamWrapper:
|
|
|
|
def __init__(self, interceptor, stream, prefix=" "):
|
|
self.interceptor = interceptor
|
|
self.stream = stream
|
|
self.prefix = prefix
|
|
|
|
def fileno(self):
|
|
return self.stream.fileno()
|
|
|
|
def isatty(self):
|
|
return self.stream.isatty()
|
|
|
|
def write(self, s):
|
|
if self.interceptor.passthrough:
|
|
self.stream.write(s)
|
|
return
|
|
|
|
if s:
|
|
self.interceptor.dirty = True
|
|
|
|
if self.interceptor.newline:
|
|
self.interceptor.newline = False
|
|
self.stream.write(" %s\n" % colorize_word("start"))
|
|
self.interceptor.indent = True
|
|
if self.interceptor.indent:
|
|
self.stream.write(self.prefix)
|
|
if s.endswith("\n"):
|
|
s = s.replace("\n", "\n%s" % self.prefix)[:-2]
|
|
self.interceptor.indent = True
|
|
else:
|
|
s = s.replace("\n", "\n%s" % self.prefix)
|
|
self.interceptor.indent = False
|
|
self.stream.write(s)
|
|
|
|
if s:
|
|
self.interceptor.last = s[-1]
|
|
|
|
def flush(self):
|
|
self.stream.flush()
|
|
|
|
interceptor = Interceptor()
|
|
|
|
out_wrp = StreamWrapper(interceptor, sys.stdout)
|
|
err_wrp = StreamWrapper(interceptor, sys.stderr)
|
|
|
|
out = sys.stdout
|
|
err = sys.stderr
|
|
sys.stdout = out_wrp
|
|
sys.stderr = err_wrp
|
|
|
|
class PatternFilter(Filter):
|
|
|
|
def __init__(self, *patterns):
|
|
Filter.__init__(self, patterns)
|
|
self.patterns = patterns
|
|
|
|
def filter(self, record):
|
|
if not self.patterns:
|
|
return True
|
|
for p in self.patterns:
|
|
if match(record.name, p):
|
|
return True
|
|
return False
|
|
|
|
root = getLogger()
|
|
handler = StreamHandler(sys.stdout)
|
|
filter = PatternFilter(*config.log_categories)
|
|
handler.addFilter(filter)
|
|
handler.setFormatter(Formatter("%(asctime)s %(levelname)s %(message)s"))
|
|
root.addHandler(handler)
|
|
root.setLevel(WARN)
|
|
|
|
log = getLogger("unit.test")
|
|
|
|
PASS = "pass"
|
|
SKIP = "skip"
|
|
FAIL = "fail"
|
|
|
|
class Runner:
|
|
|
|
def __init__(self):
|
|
self.exception = None
|
|
self.exception_phase_name = None
|
|
self.skip = False
|
|
|
|
def passed(self):
|
|
return not self.exception
|
|
|
|
def skipped(self):
|
|
return self.skip
|
|
|
|
def failed(self):
|
|
return self.exception and not self.skip
|
|
|
|
def halt(self):
|
|
"""determines if the overall test execution should be allowed to continue to the next phase"""
|
|
return self.exception or self.skip
|
|
|
|
def run(self, phase_name, phase):
|
|
"""invokes a test-phase method (which can be the test method itself or a setup/teardown
|
|
method). If the method raises an exception the exception is examined to see if the
|
|
exception should be classified as a 'skipped' test"""
|
|
# we don't try to catch exceptions for jython because currently a
|
|
# jython bug will prevent the java portion of the stack being
|
|
# stored with the exception info in the sys module
|
|
if opts.bare:
|
|
phase()
|
|
else:
|
|
try:
|
|
phase()
|
|
except KeyboardInterrupt:
|
|
raise
|
|
except:
|
|
self.exception_phase_name = phase_name
|
|
self.exception = sys.exc_info()
|
|
exception_type = self.exception[0]
|
|
self.skip = getattr(exception_type, "skipped", False)
|
|
|
|
def status(self):
|
|
if self.passed():
|
|
return PASS
|
|
elif self.skipped():
|
|
return SKIP
|
|
elif self.failed():
|
|
return FAIL
|
|
else:
|
|
return None
|
|
|
|
def get_formatted_exception_trace(self):
|
|
if self.exception:
|
|
if self.skip:
|
|
# format skipped tests without a traceback
|
|
output = indent("".join(traceback.format_exception_only(*self.exception[:2]))).rstrip()
|
|
else:
|
|
output = "Error during %s:" % self.exception_phase_name
|
|
output += indent("".join(traceback.format_exception(*self.exception))).rstrip()
|
|
return output
|
|
|
|
def get_exception_type(self):
|
|
if self.exception:
|
|
return self.exception[0]
|
|
else:
|
|
return None
|
|
|
|
def get_exception_message(self):
|
|
if self.exception:
|
|
return self.exception[1]
|
|
else:
|
|
return None
|
|
|
|
ST_WIDTH = 8
|
|
|
|
def run_test(name, test, config):
|
|
patterns = filter.patterns
|
|
level = root.level
|
|
filter.patterns = config.log_categories
|
|
root.setLevel(config.log_level)
|
|
|
|
parts = name.split(".")
|
|
line = None
|
|
output = ""
|
|
for part in parts:
|
|
if line:
|
|
if len(line) + len(part) >= (WIDTH - ST_WIDTH - 1):
|
|
output += "%s. \\\n" % line
|
|
line = " %s" % part
|
|
else:
|
|
line = "%s.%s" % (line, part)
|
|
else:
|
|
line = part
|
|
|
|
if line:
|
|
output += "%s %s" % (line, (((WIDTH - ST_WIDTH) - len(line))*"."))
|
|
sys.stdout.write(output)
|
|
sys.stdout.flush()
|
|
interceptor.begin()
|
|
start = time.time()
|
|
try:
|
|
runner = test()
|
|
finally:
|
|
interceptor.reset()
|
|
end = time.time()
|
|
if interceptor.dirty:
|
|
if interceptor.last != "\n":
|
|
sys.stdout.write("\n")
|
|
sys.stdout.write(output)
|
|
print(" %s" % colorize_word(runner.status()))
|
|
if runner.failed() or runner.skipped():
|
|
print(runner.get_formatted_exception_trace())
|
|
root.setLevel(level)
|
|
filter.patterns = patterns
|
|
return TestResult(end - start,
|
|
runner.passed(),
|
|
runner.skipped(),
|
|
runner.failed(),
|
|
runner.get_exception_type(),
|
|
runner.get_exception_message(),
|
|
runner.get_formatted_exception_trace())
|
|
|
|
class TestResult:
|
|
|
|
def __init__(self, time, passed, skipped, failed, exception_type, exception_message, formatted_exception_trace):
|
|
self.time = time
|
|
self.passed = passed
|
|
self.skipped = skipped
|
|
self.failed = failed
|
|
self.exception_type = exception_type
|
|
self.exception_message = exception_message
|
|
self.formatted_exception_trace = formatted_exception_trace
|
|
|
|
class FunctionTest:
|
|
|
|
def __init__(self, test):
|
|
self.test = test
|
|
|
|
def name(self):
|
|
return "%s.%s" % (self.test.__module__, self.test.__name__)
|
|
|
|
def run(self):
|
|
return run_test(self.name(), self._run, config)
|
|
|
|
def _run(self):
|
|
runner = Runner()
|
|
runner.run("test", lambda: self.test(config))
|
|
return runner
|
|
|
|
def __repr__(self):
|
|
return "FunctionTest(%r)" % self.test
|
|
|
|
class MethodTest:
|
|
|
|
def __init__(self, cls, method):
|
|
self.cls = cls
|
|
self.method = method
|
|
|
|
def name(self):
|
|
return "%s.%s.%s" % (self.cls.__module__, self.cls.__name__, self.method)
|
|
|
|
def run(self):
|
|
return run_test(self.name(), self._run, config)
|
|
|
|
def _run(self):
|
|
runner = Runner()
|
|
inst = self.cls(self.method)
|
|
test = getattr(inst, self.method)
|
|
|
|
if hasattr(inst, "configure"):
|
|
runner.run("configure", lambda: inst.configure(config))
|
|
if runner.halt(): return runner
|
|
if hasattr(inst, "setUp"):
|
|
runner.run("setup", inst.setUp)
|
|
if runner.halt(): return runner
|
|
elif hasattr(inst, "setup"):
|
|
runner.run("setup", inst.setup)
|
|
if runner.halt(): return runner
|
|
|
|
runner.run("test", test)
|
|
|
|
if hasattr(inst, "tearDown"):
|
|
runner.run("teardown", inst.tearDown)
|
|
elif hasattr(inst, "teardown"):
|
|
runner.run("teardown", inst.teardown)
|
|
|
|
return runner
|
|
|
|
def __repr__(self):
|
|
return "MethodTest(%r, %r)" % (self.cls, self.method)
|
|
|
|
class PatternMatcher:
|
|
|
|
def __init__(self, *patterns):
|
|
self.patterns = patterns
|
|
|
|
def matches(self, name):
|
|
for p in self.patterns:
|
|
if match(name, p):
|
|
return True
|
|
return False
|
|
|
|
class FunctionScanner(PatternMatcher):
|
|
|
|
def inspect(self, obj):
|
|
return type(obj) == types.FunctionType and self.matches(obj.__name__)
|
|
|
|
def descend(self, func):
|
|
# the None is required for older versions of python
|
|
return; yield None
|
|
|
|
def extract(self, func):
|
|
yield FunctionTest(func)
|
|
|
|
class ClassScanner(PatternMatcher):
|
|
|
|
def inspect(self, obj):
|
|
return type(obj) in CLASS_TYPES and self.matches(obj.__name__)
|
|
|
|
def descend(self, cls):
|
|
# the None is required for older versions of python
|
|
return; yield None
|
|
|
|
def extract(self, cls):
|
|
names = dir(cls)
|
|
names.sort()
|
|
for name in names:
|
|
obj = getattr(cls, name)
|
|
t = type(obj)
|
|
if hasattr(obj, '__call__') and name.startswith("test"):
|
|
yield MethodTest(cls, name)
|
|
|
|
class ModuleScanner:
|
|
|
|
def __init__(self, modules):
|
|
self._modules = modules
|
|
|
|
def inspect(self, obj):
|
|
if type(obj) == types.ModuleType:
|
|
return any([obj.__name__.startswith(m) for m in self._modules])
|
|
else:
|
|
return False
|
|
return type(obj) == types.ModuleType and obj.__name__.startswith("unit_test")
|
|
|
|
def descend(self, obj):
|
|
names = dir(obj)
|
|
names.sort()
|
|
for name in names:
|
|
yield getattr(obj, name)
|
|
|
|
def extract(self, obj):
|
|
# the None is required for older versions of python
|
|
return; yield None
|
|
|
|
class Harness:
|
|
|
|
def __init__(self, modules):
|
|
self.scanners = [
|
|
ModuleScanner(modules),
|
|
ClassScanner("*Test", "*Tests", "*TestCase"),
|
|
FunctionScanner("test_*")
|
|
]
|
|
self.tests = []
|
|
self.scanned = []
|
|
self._modules = modules
|
|
|
|
def scan(self):
|
|
objects = []
|
|
for name in self._modules:
|
|
objects.append(__import__(name, None, None, ["dummy"]))
|
|
|
|
while objects:
|
|
obj = objects.pop(0)
|
|
for s in self.scanners:
|
|
if s.inspect(obj):
|
|
self.tests.extend(s.extract(obj))
|
|
for child in s.descend(obj):
|
|
if not (child in self.scanned or child in objects):
|
|
objects.append(child)
|
|
self.scanned.append(obj)
|
|
|
|
modules = opts.modules
|
|
if not modules:
|
|
modules.extend(["unit_tests"])
|
|
h = Harness(modules)
|
|
h.scan()
|
|
|
|
filtered = [t for t in h.tests if is_included(t.name())]
|
|
ignored = [t for t in h.tests if is_ignored(t.name())]
|
|
total = len(filtered) + len(ignored)
|
|
|
|
if opts.xml and not list_only:
|
|
xmlr = JunitXmlStyleReporter(opts.xml);
|
|
xmlr.begin();
|
|
else:
|
|
xmlr = None
|
|
|
|
def runthrough():
|
|
passed = 0
|
|
failed = 0
|
|
skipped = 0
|
|
start = time.time()
|
|
for t in filtered:
|
|
if list_only:
|
|
print(t.name())
|
|
else:
|
|
st = t.run()
|
|
if xmlr:
|
|
xmlr.report(t.name(), st)
|
|
if st.passed:
|
|
passed += 1
|
|
elif st.skipped:
|
|
skipped += 1
|
|
elif st.failed:
|
|
failed += 1
|
|
if opts.hoe:
|
|
break
|
|
end = time.time()
|
|
|
|
run = passed + failed
|
|
|
|
if not list_only:
|
|
if passed:
|
|
_pass = "pass"
|
|
else:
|
|
_pass = "fail"
|
|
if failed:
|
|
outcome = "fail"
|
|
else:
|
|
outcome = "pass"
|
|
if ignored:
|
|
ign = "ignored"
|
|
else:
|
|
ign = "pass"
|
|
if skipped:
|
|
skip = "skip"
|
|
else:
|
|
skip = "pass"
|
|
print(colorize("Totals:", 1), end=None)
|
|
totals = [colorize_word("total", "%s tests" % total),
|
|
colorize_word(_pass, "%s passed" % passed),
|
|
colorize_word(skip, "%s skipped" % skipped),
|
|
colorize_word(ign, "%s ignored" % len(ignored)),
|
|
colorize_word(outcome, "%s failed" % failed)]
|
|
print(", ".join(totals), end=None)
|
|
if opts.hoe and failed > 0:
|
|
print(" -- (halted after %s)" % run)
|
|
else:
|
|
print("")
|
|
if opts.time and run > 0:
|
|
print(colorize("Timing:", 1), end=None)
|
|
timing = [colorize_word("elapsed", "%.2fs elapsed" % (end - start)),
|
|
colorize_word("average", "%.2fs average" % ((end - start)/run))]
|
|
print(", ".join(timing))
|
|
|
|
if xmlr:
|
|
xmlr.end()
|
|
|
|
return failed
|
|
|
|
limit = opts.count
|
|
count = 0
|
|
failures = False
|
|
while limit == 0 or count < limit:
|
|
count += 1
|
|
if runthrough():
|
|
failures = True
|
|
if count > 1:
|
|
print(" -- (failures after %s runthroughs)" % count)
|
|
else:
|
|
continue
|
|
|
|
if failures:
|
|
sys.exit(1)
|
|
else:
|
|
sys.exit(0)
|