1 # -*- test-case-name: buildbot.test.test_twisted -*-
3 from twisted
.python
import log
5 from buildbot
.status
import builder
6 from buildbot
.status
.builder
import SUCCESS
, FAILURE
, WARNINGS
, SKIPPED
7 from buildbot
.process
.buildstep
import LogLineObserver
, OutputProgressObserver
8 from buildbot
.process
.buildstep
import RemoteShellCommand
9 from buildbot
.steps
.shell
import ShellCommand
18 # BuildSteps that are specific to the Twisted source tree
20 class HLint(ShellCommand
):
21 """I run a 'lint' checker over a set of .xhtml files. Any deviations
22 from recommended style is flagged and put in the output log.
24 This step looks at .changes in the parent Build to extract a list of
25 Lore XHTML files to check."""
28 description
= ["running", "hlint"]
29 descriptionDone
= ["hlint"]
32 # TODO: track time, but not output
35 def __init__(self
, python
=None, **kwargs
):
36 ShellCommand
.__init
__(self
, **kwargs
)
37 self
.addFactoryArguments(python
=python
)
43 for f
in self
.build
.allFiles():
44 if f
.endswith(".xhtml") and not f
.startswith("sandbox/"):
47 hlintTargets
= htmlFiles
.keys()
51 self
.hlintFiles
= hlintTargets
55 c
+= ["bin/lore", "-p", "--output", "lint"] + self
.hlintFiles
58 # add an extra log file to show the .html files we're checking
59 self
.addCompleteLog("files", "\n".join(self
.hlintFiles
)+"\n")
61 ShellCommand
.start(self
)
63 def commandComplete(self
, cmd
):
64 # TODO: remove the 'files' file (a list of .xhtml files that were
65 # submitted to hlint) because it is available in the logfile and
66 # mostly exists to give the user an idea of how long the step will
68 lines
= cmd
.logs
['stdio'].getText().split("\n")
69 warningLines
= filter(lambda line
:':' in line
, lines
)
71 self
.addCompleteLog("warnings", "".join(warningLines
))
72 warnings
= len(warningLines
)
73 self
.warnings
= warnings
75 def evaluateCommand(self
, cmd
):
76 # warnings are in stdout, rc is always 0, unless the tools break
83 def getText2(self
, cmd
, results
):
86 return ["%d hlin%s" % (self
.warnings
,
87 self
.warnings
== 1 and 't' or 'ts')]
89 def countFailedTests(output
):
90 # start scanning 10kb from the end, because there might be a few kb of
91 # import exception tracebacks between the total/time line and the errors
93 chunk
= output
[-10000:]
94 lines
= chunk
.split("\n")
95 lines
.pop() # blank line at end
96 # lines[-3] is "Ran NN tests in 0.242s"
98 # lines[-1] is 'OK' or 'FAILED (failures=1, errors=12)'
99 # or 'FAILED (failures=1)'
100 # or "PASSED (skips=N, successes=N)" (for Twisted-2.0)
101 # there might be other lines dumped here. Scan all the lines.
102 res
= {'total': None,
106 'expectedFailures': 0,
107 'unexpectedSuccesses': 0,
110 out
= re
.search(r
'Ran (\d+) tests', l
)
112 res
['total'] = int(out
.group(1))
113 if (l
.startswith("OK") or
114 l
.startswith("FAILED ") or
115 l
.startswith("PASSED")):
116 # the extra space on FAILED_ is to distinguish the overall
117 # status from an individual test which failed. The lack of a
118 # space on the OK is because it may be printed without any
119 # additional text (if there are no skips,etc)
120 out
= re
.search(r
'failures=(\d+)', l
)
121 if out
: res
['failures'] = int(out
.group(1))
122 out
= re
.search(r
'errors=(\d+)', l
)
123 if out
: res
['errors'] = int(out
.group(1))
124 out
= re
.search(r
'skips=(\d+)', l
)
125 if out
: res
['skips'] = int(out
.group(1))
126 out
= re
.search(r
'expectedFailures=(\d+)', l
)
127 if out
: res
['expectedFailures'] = int(out
.group(1))
128 out
= re
.search(r
'unexpectedSuccesses=(\d+)', l
)
129 if out
: res
['unexpectedSuccesses'] = int(out
.group(1))
130 # successes= is a Twisted-2.0 addition, and is not currently used
131 out
= re
.search(r
'successes=(\d+)', l
)
132 if out
: res
['successes'] = int(out
.group(1))
137 class TrialTestCaseCounter(LogLineObserver
):
138 _line_re
= re
.compile(r
'^(?:Doctest: )?([\w\.]+) \.\.\. \[([^\]]+)\]$')
142 def outLineReceived(self
, line
):
143 # different versions of Twisted emit different per-test lines with
144 # the bwverbose reporter.
145 # 2.0.0: testSlave (buildbot.test.test_runner.Create) ... [OK]
146 # 2.1.0: buildbot.test.test_runner.Create.testSlave ... [OK]
147 # 2.4.0: buildbot.test.test_runner.Create.testSlave ... [OK]
148 # Let's just handle the most recent version, since it's the easiest.
149 # Note that doctests create lines line this:
150 # Doctest: viff.field.GF ... [OK]
154 if line
.startswith("=" * 40):
158 m
= self
._line
_re
.search(line
.strip())
160 testname
, result
= m
.groups()
162 self
.step
.setProgress('tests', self
.numTests
)
165 UNSPECIFIED
=() # since None is a valid choice
167 class Trial(ShellCommand
):
168 """I run a unit test suite using 'trial', a unittest-like testing
169 framework that comes with Twisted. Trial is used to implement Twisted's
170 own unit tests, and is the unittest-framework of choice for many projects
171 that use Twisted internally.
173 Projects that use trial typically have all their test cases in a 'test'
174 subdirectory of their top-level library directory. I.e. for my package
175 'petmail', the tests are in 'petmail/test/test_*.py'. More complicated
176 packages (like Twisted itself) may have multiple test directories, like
177 'twisted/test/test_*.py' for the core functionality and
178 'twisted/mail/test/test_*.py' for the email-specific tests.
180 To run trial tests, you run the 'trial' executable and tell it where the
181 test cases are located. The most common way of doing this is with a
182 module name. For petmail, I would run 'trial petmail.test' and it would
183 locate all the test_*.py files under petmail/test/, running every test
184 case it could find in them. Unlike the unittest.py that comes with
185 Python, you do not run the test_foo.py as a script; you always let trial
186 do the importing and running. The 'tests' parameter controls which tests
187 trial will run: it can be a string or a list of strings.
189 To find these test cases, you must set a PYTHONPATH that allows something
190 like 'import petmail.test' to work. For packages that don't use a
191 separate top-level 'lib' directory, PYTHONPATH=. will work, and will use
192 the test cases (and the code they are testing) in-place.
193 PYTHONPATH=build/lib or PYTHONPATH=build/lib.$ARCH are also useful when
194 you do a'setup.py build' step first. The 'testpath' attribute of this
195 class controls what PYTHONPATH= is set to.
197 Trial has the ability (through the --testmodule flag) to run only the set
198 of test cases named by special 'test-case-name' tags in source files. We
199 can get the list of changed source files from our parent Build and
200 provide them to trial, thus running the minimal set of test cases needed
201 to cover the Changes. This is useful for quick builds, especially in
202 trees with a lot of test cases. The 'testChanges' parameter controls this
203 feature: if set, it will override 'tests'.
205 The trial executable itself is typically just 'trial' (which is usually
206 found on your $PATH as /usr/bin/trial), but it can be overridden with the
207 'trial' parameter. This is useful for Twisted's own unittests, which want
208 to use the copy of bin/trial that comes with the sources. (when bin/trial
209 discovers that it is living in a subdirectory named 'Twisted', it assumes
210 it is being run from the source tree and adds that parent directory to
211 PYTHONPATH. Therefore the canonical way to run Twisted's own unittest
212 suite is './bin/trial twisted.test' rather than 'PYTHONPATH=.
213 /usr/bin/trial twisted.test', especially handy when /usr/bin/trial has
214 not yet been installed).
216 To influence the version of python being used for the tests, or to add
217 flags to the command, set the 'python' parameter. This can be a string
218 (like 'python2.2') or a list (like ['python2.3', '-Wall']).
220 Trial creates and switches into a directory named _trial_temp/ before
221 running the tests, and sends the twisted log (which includes all
222 exceptions) to a file named test.log . This file will be pulled up to
223 the master where it can be seen as part of the status output.
225 There are some class attributes which may be usefully overridden
226 by subclasses. 'trialMode' and 'trialArgs' can influence the trial
231 progressMetrics
= ('output', 'tests', 'test.log')
232 # note: the slash only works on unix buildslaves, of course, but we have
233 # no way to know what the buildslave uses as a separator. TODO: figure
234 # out something clever.
235 logfiles
= {"test.log": "_trial_temp/test.log"}
236 # we use test.log to track Progress at the end of __init__()
238 flunkOnFailure
= True
241 trialMode
= ["--reporter=bwverbose"] # requires Twisted-2.1.0 or newer
242 # for Twisted-2.0.0 or 1.3.0, use ["-o"] instead
244 testpath
= UNSPECIFIED
# required (but can be None)
245 testChanges
= False # TODO: needs better name
249 tests
= None # required
251 def __init__(self
, reactor
=UNSPECIFIED
, python
=None, trial
=None,
252 testpath
=UNSPECIFIED
,
253 tests
=None, testChanges
=None,
254 recurse
=None, randomly
=None,
255 trialMode
=None, trialArgs
=None,
258 @type testpath: string
259 @param testpath: use in PYTHONPATH when running the tests. If
260 None, do not set PYTHONPATH. Setting this to '.' will
261 cause the source files to be used in-place.
263 @type python: string (without spaces) or list
264 @param python: which python executable to use. Will form the start of
265 the argv array that will launch trial. If you use this,
266 you should set 'trial' to an explicit path (like
267 /usr/bin/trial or ./bin/trial). Defaults to None, which
268 leaves it out entirely (running 'trial args' instead of
269 'python ./bin/trial args'). Likely values are 'python',
270 ['python2.2'], ['python', '-Wall'], etc.
273 @param trial: which 'trial' executable to run.
274 Defaults to 'trial', which will cause $PATH to be
275 searched and probably find /usr/bin/trial . If you set
276 'python', this should be set to an explicit path (because
277 'python2.3 trial' will not work).
279 @type trialMode: list of strings
280 @param trialMode: a list of arguments to pass to trial, specifically
281 to set the reporting mode. This defaults to ['-to']
282 which means 'verbose colorless output' to the trial
283 that comes with Twisted-2.0.x and at least -2.1.0 .
284 Newer versions of Twisted may come with a trial
285 that prefers ['--reporter=bwverbose'].
287 @type trialArgs: list of strings
288 @param trialArgs: a list of arguments to pass to trial, available to
289 turn on any extra flags you like. Defaults to [].
291 @type tests: list of strings
292 @param tests: a list of test modules to run, like
293 ['twisted.test.test_defer', 'twisted.test.test_process'].
294 If this is a string, it will be converted into a one-item
297 @type testChanges: boolean
298 @param testChanges: if True, ignore the 'tests' parameter and instead
299 ask the Build for all the files that make up the
300 Changes going into this build. Pass these filenames
301 to trial and ask it to look for test-case-name
302 tags, running just the tests necessary to cover the
305 @type recurse: boolean
306 @param recurse: If True, pass the --recurse option to trial, allowing
307 test cases to be found in deeper subdirectories of the
308 modules listed in 'tests'. This does not appear to be
309 necessary when using testChanges.
311 @type reactor: string
312 @param reactor: which reactor to use, like 'gtk' or 'java'. If not
313 provided, the Twisted's usual platform-dependent
316 @type randomly: boolean
317 @param randomly: if True, add the --random=0 argument, which instructs
318 trial to run the unit tests in a random order each
319 time. This occasionally catches problems that might be
320 masked when one module always runs before another
321 (like failing to make registerAdapter calls before
325 @param kwargs: parameters. The following parameters are inherited from
326 L{ShellCommand} and may be useful to set: workdir,
327 haltOnFailure, flunkOnWarnings, flunkOnFailure,
328 warnOnWarnings, warnOnFailure, want_stdout, want_stderr,
331 ShellCommand
.__init
__(self
, **kwargs
)
332 self
.addFactoryArguments(reactor
=reactor
,
337 testChanges
=testChanges
,
346 if self
.python
is not None:
347 if type(self
.python
) is str:
348 self
.python
= [self
.python
]
349 for s
in self
.python
:
351 # this is not strictly an error, but I suspect more
352 # people will accidentally try to use python="python2.3
353 # -Wall" than will use embedded spaces in a python flag
354 log
.msg("python= component '%s' has spaces")
355 log
.msg("To add -Wall, use python=['python', '-Wall']")
356 why
= "python= value has spaces, probably an error"
357 raise ValueError(why
)
361 if " " in self
.trial
:
362 raise ValueError("trial= value has spaces")
363 if trialMode
is not None:
364 self
.trialMode
= trialMode
365 if trialArgs
is not None:
366 self
.trialArgs
= trialArgs
368 if testpath
is not UNSPECIFIED
:
369 self
.testpath
= testpath
370 if self
.testpath
is UNSPECIFIED
:
371 raise ValueError("You must specify testpath= (it can be None)")
372 assert isinstance(self
.testpath
, str) or self
.testpath
is None
374 if reactor
is not UNSPECIFIED
:
375 self
.reactor
= reactor
377 if tests
is not None:
379 if type(self
.tests
) is str:
380 self
.tests
= [self
.tests
]
381 if testChanges
is not None:
382 self
.testChanges
= testChanges
383 #self.recurse = True # not sure this is necessary
385 if not self
.testChanges
and self
.tests
is None:
386 raise ValueError("Must either set testChanges= or provide tests=")
388 if recurse
is not None:
389 self
.recurse
= recurse
390 if randomly
is not None:
391 self
.randomly
= randomly
393 # build up most of the command, then stash it until start()
396 command
.extend(self
.python
)
397 command
.append(self
.trial
)
398 command
.extend(self
.trialMode
)
400 command
.append("--recurse")
402 command
.append("--reactor=%s" % reactor
)
404 command
.append("--random=0")
405 command
.extend(self
.trialArgs
)
406 self
.command
= command
409 self
.description
= ["testing", "(%s)" % self
.reactor
]
410 self
.descriptionDone
= ["tests"]
411 # commandComplete adds (reactorname) to self.text
413 self
.description
= ["testing"]
414 self
.descriptionDone
= ["tests"]
416 # this counter will feed Progress along the 'test cases' metric
417 self
.addLogObserver('stdio', TrialTestCaseCounter())
418 # this one just measures bytes of output in _trial_temp/test.log
419 self
.addLogObserver('test.log', OutputProgressObserver('test.log'))
421 def setupEnvironment(self
, cmd
):
422 ShellCommand
.setupEnvironment(self
, cmd
)
423 if self
.testpath
!= None:
426 cmd
.args
['env'] = {'PYTHONPATH': self
.testpath
}
428 # TODO: somehow, each build causes another copy of
429 # self.testpath to get prepended
430 if e
.get('PYTHONPATH', "") == "":
431 e
['PYTHONPATH'] = self
.testpath
433 e
['PYTHONPATH'] = self
.testpath
+ ":" + e
['PYTHONPATH']
435 p
= cmd
.args
['env']['PYTHONPATH']
436 if type(p
) is not str:
437 log
.msg("hey, not a string:", p
)
439 except (KeyError, TypeError):
440 # KeyError if args doesn't have ['env']
441 # KeyError if args['env'] doesn't have ['PYTHONPATH']
442 # TypeError if args is None
446 # now that self.build.allFiles() is nailed down, finish building the
449 for f
in self
.build
.allFiles():
450 if f
.endswith(".py"):
451 self
.command
.append("--testmodule=%s" % f
)
453 self
.command
.extend(self
.tests
)
454 log
.msg("Trial.start: command is", self
.command
)
456 # if our slave is too old to understand logfiles=, fetch them
457 # manually. This is a fallback for the Twisted buildbot and some old
459 self
._needToPullTestDotLog
= False
460 if self
.slaveVersionIsOlderThan("shell", "2.1"):
461 log
.msg("Trial: buildslave %s is too old to accept logfiles=" %
463 log
.msg(" falling back to 'cat _trial_temp/test.log' instead")
465 self
._needToPullTestDotLog
= True
467 ShellCommand
.start(self
)
470 def commandComplete(self
, cmd
):
471 if not self
._needToPullTestDotLog
:
472 return self
._gotTestDotLog
(cmd
)
474 # if the buildslave was too old, pull test.log now
475 catcmd
= ["cat", "_trial_temp/test.log"]
476 c2
= RemoteShellCommand(command
=catcmd
, workdir
=self
.workdir
)
477 loog
= self
.addLog("test.log")
478 c2
.useLog(loog
, True, logfileName
="stdio")
479 self
.cmd
= c2
# to allow interrupts
480 d
= c2
.run(self
, self
.remote
)
481 d
.addCallback(lambda res
: self
._gotTestDotLog
(cmd
))
484 def rtext(self
, fmt
='%s'):
486 rtext
= fmt
% self
.reactor
487 return rtext
.replace("reactor", "")
490 def _gotTestDotLog(self
, cmd
):
491 # figure out all status, then let the various hook functions return
492 # different pieces of it
494 # 'cmd' is the original trial command, so cmd.logs['stdio'] is the
495 # trial output. We don't have access to test.log from here.
496 output
= cmd
.logs
['stdio'].getText()
497 counts
= countFailedTests(output
)
499 total
= counts
['total']
500 failures
, errors
= counts
['failures'], counts
['errors']
501 parsed
= (total
!= None)
511 total
== 1 and "test" or "tests"),
514 text
+= ["no tests", "run"]
517 text
+= ["testlog", "unparseable"]
525 text
.append("%d %s" % \
527 failures
== 1 and "failure" or "failures"))
529 text
.append("%d %s" % \
531 errors
== 1 and "error" or "errors"))
532 count
= failures
+ errors
533 text2
= "%d tes%s" % (count
, (count
== 1 and 't' or 'ts'))
535 text
+= ["tests", "failed"]
539 text
.append("%d %s" % \
541 counts
['skips'] == 1 and "skip" or "skips"))
542 if counts
['expectedFailures']:
543 text
.append("%d %s" % \
544 (counts
['expectedFailures'],
545 counts
['expectedFailures'] == 1 and "todo"
553 # ignore unexpectedSuccesses for now, but it should really mark
555 if counts
['unexpectedSuccesses']:
556 text
.append("%d surprises" % counts
['unexpectedSuccesses'])
562 text
.append(self
.rtext('(%s)'))
564 text2
= "%s %s" % (text2
, self
.rtext('(%s)'))
566 self
.results
= results
570 def addTestResult(self
, testname
, results
, text
, tlog
):
571 if self
.reactor
is not None:
572 testname
= (self
.reactor
,) + testname
573 tr
= builder
.TestResult(testname
, results
, text
, logs
={'log': tlog
})
574 #self.step_status.build.addTestResult(tr)
575 self
.build
.build_status
.addTestResult(tr
)
577 def createSummary(self
, loog
):
578 output
= loog
.getText()
580 sio
= StringIO
.StringIO(output
)
583 line
= sio
.readline()
586 if line
.find(" exceptions.DeprecationWarning: ") != -1:
588 warning
= line
# TODO: consider stripping basedir prefix here
589 warnings
[warning
] = warnings
.get(warning
, 0) + 1
590 elif (line
.find(" DeprecationWarning: ") != -1 or
591 line
.find(" UserWarning: ") != -1):
592 # next line is the source
593 warning
= line
+ sio
.readline()
594 warnings
[warning
] = warnings
.get(warning
, 0) + 1
595 elif line
.find("Warning: ") != -1:
597 warnings
[warning
] = warnings
.get(warning
, 0) + 1
599 if line
.find("=" * 60) == 0 or line
.find("-" * 60) == 0:
601 problems
+= sio
.read()
605 self
.addCompleteLog("problems", problems
)
606 # now parse the problems for per-test results
607 pio
= StringIO
.StringIO(problems
)
608 pio
.readline() # eat the first separator line
613 line
= pio
.readline()
617 if line
.find("=" * 60) == 0:
619 if line
.find("-" * 60) == 0:
620 # the last case has --- as a separator before the
621 # summary counts are printed
625 # the first line after the === is like:
626 # EXPECTED FAILURE: testLackOfTB (twisted.test.test_failure.FailureTestCase)
627 # SKIPPED: testRETR (twisted.test.test_ftp.TestFTPServer)
628 # FAILURE: testBatchFile (twisted.conch.test.test_sftp.TestOurServerBatchFile)
629 r
= re
.search(r
'^([^:]+): (\w+) \(([\w\.]+)\)', line
)
631 # TODO: cleanup, if there are no problems,
634 result
, name
, case
= r
.groups()
635 testname
= tuple(case
.split(".") + [name
])
636 results
= {'SKIPPED': SKIPPED
,
637 'EXPECTED FAILURE': SUCCESS
,
638 'UNEXPECTED SUCCESS': WARNINGS
,
641 'SUCCESS': SUCCESS
, # not reported
642 }.get(result
, WARNINGS
)
643 text
= result
.lower().split()
645 # the next line is all dashes
646 loog
+= pio
.readline()
648 # the rest goes into the log
651 self
.addTestResult(testname
, results
, text
, loog
)
655 lines
= warnings
.keys()
657 self
.addCompleteLog("warnings", "".join(lines
))
659 def evaluateCommand(self
, cmd
):
662 def getText(self
, cmd
, results
):
664 def getText2(self
, cmd
, results
):
668 class ProcessDocs(ShellCommand
):
669 """I build all docs. This requires some LaTeX packages to be installed.
670 It will result in the full documentation book (dvi, pdf, etc).
674 name
= "process-docs"
676 command
= ["admin/process-docs"]
677 description
= ["processing", "docs"]
678 descriptionDone
= ["docs"]
679 # TODO: track output and time
681 def __init__(self
, **kwargs
):
683 @type workdir: string
684 @keyword workdir: the workdir to start from: must be the base of the
687 ShellCommand
.__init
__(self
, **kwargs
)
689 def createSummary(self
, log
):
690 output
= log
.getText()
691 # hlint warnings are of the format: 'WARNING: file:line:col: stuff
692 # latex warnings start with "WARNING: LaTeX Warning: stuff", but
693 # sometimes wrap around to a second line.
694 lines
= output
.split("\n")
700 if line
.startswith("WARNING: "):
704 warningLines
.append(line
)
707 self
.addCompleteLog("warnings", "\n".join(warningLines
) + "\n")
708 self
.warnings
= len(warningLines
)
710 def evaluateCommand(self
, cmd
):
717 def getText(self
, cmd
, results
):
718 if results
== SUCCESS
:
719 return ["docs", "successful"]
720 if results
== WARNINGS
:
722 "%d warnin%s" % (self
.warnings
,
723 self
.warnings
== 1 and 'g' or 'gs')]
724 if results
== FAILURE
:
725 return ["docs", "failed"]
727 def getText2(self
, cmd
, results
):
728 if results
== WARNINGS
:
729 return ["%d do%s" % (self
.warnings
,
730 self
.warnings
== 1 and 'c' or 'cs')]
735 class BuildDebs(ShellCommand
):
736 """I build the .deb packages."""
740 command
= ["debuild", "-uc", "-us"]
741 description
= ["building", "debs"]
742 descriptionDone
= ["debs"]
744 def __init__(self
, **kwargs
):
746 @type workdir: string
747 @keyword workdir: the workdir to start from (must be the base of the
750 ShellCommand
.__init
__(self
, **kwargs
)
752 def commandComplete(self
, cmd
):
753 errors
, warnings
= 0, 0
754 output
= cmd
.logs
['stdio'].getText()
756 sio
= StringIO
.StringIO(output
)
757 for line
in sio
.readlines():
758 if line
.find("E: ") == 0:
761 if line
.find("W: ") == 0:
765 self
.addCompleteLog("problems", summary
)
767 self
.warnings
= warnings
769 def evaluateCommand(self
, cmd
):
778 def getText(self
, cmd
, results
):
781 text
.append("failed")
782 errors
, warnings
= self
.errors
, self
.warnings
783 if warnings
or errors
:
784 text
.append("lintian:")
786 text
.append("%d warnin%s" % (warnings
,
787 warnings
== 1 and 'g' or 'gs'))
789 text
.append("%d erro%s" % (errors
,
790 errors
== 1 and 'r' or 'rs'))
793 def getText2(self
, cmd
, results
):
796 if self
.errors
or self
.warnings
:
797 return ["%d lintian" % (self
.errors
+ self
.warnings
)]
800 class RemovePYCs(ShellCommand
):
802 command
= 'find . -name "*.pyc" | xargs rm'
803 description
= ["removing", ".pyc", "files"]
804 descriptionDone
= ["remove", ".pycs"]