1
2
3 from twisted.python import log
4
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
10
11 try:
12 import cStringIO
13 StringIO = cStringIO
14 except ImportError:
15 import StringIO
16 import re
17
18
19
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.
23
24 This step looks at .changes in the parent Build to extract a list of
25 Lore XHTML files to check."""
26
27 name = "hlint"
28 description = ["running", "hlint"]
29 descriptionDone = ["hlint"]
30 warnOnWarnings = True
31 warnOnFailure = True
32
33 warnings = 0
34
35 - def __init__(self, python=None, **kwargs):
39
41
42 htmlFiles = {}
43 for f in self.build.allFiles():
44 if f.endswith(".xhtml") and not f.startswith("sandbox/"):
45 htmlFiles[f] = 1
46
47 hlintTargets = htmlFiles.keys()
48 hlintTargets.sort()
49 if not hlintTargets:
50 return SKIPPED
51 self.hlintFiles = hlintTargets
52 c = []
53 if self.python:
54 c.append(self.python)
55 c += ["bin/lore", "-p", "--output", "lint"] + self.hlintFiles
56 self.setCommand(c)
57
58
59 self.addCompleteLog("files", "\n".join(self.hlintFiles)+"\n")
60
61 ShellCommand.start(self)
62
74
82
83 - def getText2(self, cmd, results):
84 if cmd.rc != 0:
85 return ["hlint"]
86 return ["%d hlin%s" % (self.warnings,
87 self.warnings == 1 and 't' or 'ts')]
88
90
91
92
93 chunk = output[-10000:]
94 lines = chunk.split("\n")
95 lines.pop()
96
97
98
99
100
101
102 res = {'total': None,
103 'failures': 0,
104 'errors': 0,
105 'skips': 0,
106 'expectedFailures': 0,
107 'unexpectedSuccesses': 0,
108 }
109 for l in lines:
110 out = re.search(r'Ran (\d+) tests', l)
111 if out:
112 res['total'] = int(out.group(1))
113 if (l.startswith("OK") or
114 l.startswith("FAILED ") or
115 l.startswith("PASSED")):
116
117
118
119
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
131 out = re.search(r'successes=(\d+)', l)
132 if out: res['successes'] = int(out.group(1))
133
134 return res
135
136
138 _line_re = re.compile(r'^(?:Doctest: )?([\w\.]+) \.\.\. \[([^\]]+)\]$')
139 numTests = 0
140 finished = False
141
143
144
145
146
147
148
149
150
151
152 if self.finished:
153 return
154 if line.startswith("=" * 40):
155 self.finished = True
156 return
157
158 m = self._line_re.search(line.strip())
159 if m:
160 testname, result = m.groups()
161 self.numTests += 1
162 self.step.setProgress('tests', self.numTests)
163
164
165 UNSPECIFIED=()
166
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.
172
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.
179
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.
188
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.
196
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'.
204
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).
215
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']).
219
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.
224
225 There are some class attributes which may be usefully overridden
226 by subclasses. 'trialMode' and 'trialArgs' can influence the trial
227 command line.
228 """
229
230 name = "trial"
231 progressMetrics = ('output', 'tests', 'test.log')
232
233
234
235 logfiles = {"test.log": "_trial_temp/test.log"}
236
237
238 flunkOnFailure = True
239 python = None
240 trial = "trial"
241 trialMode = ["--reporter=bwverbose"]
242
243 trialArgs = []
244 testpath = UNSPECIFIED
245 testChanges = False
246 recurse = False
247 reactor = None
248 randomly = False
249 tests = None
250
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,
256 **kwargs):
257 """
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.
262
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.
271
272 @type trial: string
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).
278
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'].
286
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 [].
290
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
295 list.
296
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
303 changes.
304
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.
310
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
314 default is used.
315
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
322 lookups are done).
323
324 @type kwargs: dict
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,
329 timeout.
330 """
331 ShellCommand.__init__(self, **kwargs)
332 self.addFactoryArguments(reactor=reactor,
333 python=python,
334 trial=trial,
335 testpath=testpath,
336 tests=tests,
337 testChanges=testChanges,
338 recurse=recurse,
339 randomly=randomly,
340 trialMode=trialMode,
341 trialArgs=trialArgs,
342 )
343
344 if python:
345 self.python = python
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:
350 if " " in s:
351
352
353
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)
358
359 if trial:
360 self.trial = trial
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
367
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
373
374 if reactor is not UNSPECIFIED:
375 self.reactor = reactor
376
377 if tests is not None:
378 self.tests = tests
379 if type(self.tests) is str:
380 self.tests = [self.tests]
381 if testChanges is not None:
382 self.testChanges = testChanges
383
384
385 if not self.testChanges and self.tests is None:
386 raise ValueError("Must either set testChanges= or provide tests=")
387
388 if recurse is not None:
389 self.recurse = recurse
390 if randomly is not None:
391 self.randomly = randomly
392
393
394 command = []
395 if self.python:
396 command.extend(self.python)
397 command.append(self.trial)
398 command.extend(self.trialMode)
399 if self.recurse:
400 command.append("--recurse")
401 if self.reactor:
402 command.append("--reactor=%s" % reactor)
403 if self.randomly:
404 command.append("--random=0")
405 command.extend(self.trialArgs)
406 self.command = command
407
408 if self.reactor:
409 self.description = ["testing", "(%s)" % self.reactor]
410 self.descriptionDone = ["tests"]
411
412 else:
413 self.description = ["testing"]
414 self.descriptionDone = ["tests"]
415
416
417 self.addLogObserver('stdio', TrialTestCaseCounter())
418
419 self.addLogObserver('test.log', OutputProgressObserver('test.log'))
420
422 ShellCommand.setupEnvironment(self, cmd)
423 if self.testpath != None:
424 e = cmd.args['env']
425 if e is None:
426 cmd.args['env'] = {'PYTHONPATH': self.testpath}
427 else:
428
429
430 if e.get('PYTHONPATH', "") == "":
431 e['PYTHONPATH'] = self.testpath
432 else:
433 e['PYTHONPATH'] = self.testpath + ":" + e['PYTHONPATH']
434 try:
435 p = cmd.args['env']['PYTHONPATH']
436 if type(p) is not str:
437 log.msg("hey, not a string:", p)
438 assert False
439 except (KeyError, TypeError):
440
441
442
443 pass
444
446
447
448 if self.testChanges:
449 for f in self.build.allFiles():
450 if f.endswith(".py"):
451 self.command.append("--testmodule=%s" % f)
452 else:
453 self.command.extend(self.tests)
454 log.msg("Trial.start: command is", self.command)
455
456
457
458
459 self._needToPullTestDotLog = False
460 if self.slaveVersionIsOlderThan("shell", "2.1"):
461 log.msg("Trial: buildslave %s is too old to accept logfiles=" %
462 self.getSlaveName())
463 log.msg(" falling back to 'cat _trial_temp/test.log' instead")
464 self.logfiles = {}
465 self._needToPullTestDotLog = True
466
467 ShellCommand.start(self)
468
469
471 if not self._needToPullTestDotLog:
472 return self._gotTestDotLog(cmd)
473
474
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
480 d = c2.run(self, self.remote)
481 d.addCallback(lambda res: self._gotTestDotLog(cmd))
482 return d
483
484 - def rtext(self, fmt='%s'):
485 if self.reactor:
486 rtext = fmt % self.reactor
487 return rtext.replace("reactor", "")
488 return ""
489
491
492
493
494
495
496 output = cmd.logs['stdio'].getText()
497 counts = countFailedTests(output)
498
499 total = counts['total']
500 failures, errors = counts['failures'], counts['errors']
501 parsed = (total != None)
502 text = []
503 text2 = ""
504
505 if cmd.rc == 0:
506 if parsed:
507 results = SUCCESS
508 if total:
509 text += ["%d %s" % \
510 (total,
511 total == 1 and "test" or "tests"),
512 "passed"]
513 else:
514 text += ["no tests", "run"]
515 else:
516 results = FAILURE
517 text += ["testlog", "unparseable"]
518 text2 = "tests"
519 else:
520
521 results = FAILURE
522 if parsed:
523 text.append("tests")
524 if failures:
525 text.append("%d %s" % \
526 (failures,
527 failures == 1 and "failure" or "failures"))
528 if errors:
529 text.append("%d %s" % \
530 (errors,
531 errors == 1 and "error" or "errors"))
532 count = failures + errors
533 text2 = "%d tes%s" % (count, (count == 1 and 't' or 'ts'))
534 else:
535 text += ["tests", "failed"]
536 text2 = "tests"
537
538 if counts['skips']:
539 text.append("%d %s" % \
540 (counts['skips'],
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"
546 or "todos"))
547 if 0:
548 results = WARNINGS
549 if not text2:
550 text2 = "todo"
551
552 if 0:
553
554
555 if counts['unexpectedSuccesses']:
556 text.append("%d surprises" % counts['unexpectedSuccesses'])
557 results = WARNINGS
558 if not text2:
559 text2 = "tests"
560
561 if self.reactor:
562 text.append(self.rtext('(%s)'))
563 if text2:
564 text2 = "%s %s" % (text2, self.rtext('(%s)'))
565
566 self.results = results
567 self.text = text
568 self.text2 = [text2]
569
576
578 output = loog.getText()
579 problems = ""
580 sio = StringIO.StringIO(output)
581 warnings = {}
582 while 1:
583 line = sio.readline()
584 if line == "":
585 break
586 if line.find(" exceptions.DeprecationWarning: ") != -1:
587
588 warning = line
589 warnings[warning] = warnings.get(warning, 0) + 1
590 elif (line.find(" DeprecationWarning: ") != -1 or
591 line.find(" UserWarning: ") != -1):
592
593 warning = line + sio.readline()
594 warnings[warning] = warnings.get(warning, 0) + 1
595 elif line.find("Warning: ") != -1:
596 warning = line
597 warnings[warning] = warnings.get(warning, 0) + 1
598
599 if line.find("=" * 60) == 0 or line.find("-" * 60) == 0:
600 problems += line
601 problems += sio.read()
602 break
603
604 if problems:
605 self.addCompleteLog("problems", problems)
606
607 pio = StringIO.StringIO(problems)
608 pio.readline()
609 testname = None
610 done = False
611 while not done:
612 while 1:
613 line = pio.readline()
614 if line == "":
615 done = True
616 break
617 if line.find("=" * 60) == 0:
618 break
619 if line.find("-" * 60) == 0:
620
621
622 done = True
623 break
624 if testname is None:
625
626
627
628
629 r = re.search(r'^([^:]+): (\w+) \(([\w\.]+)\)', line)
630 if not r:
631
632
633 continue
634 result, name, case = r.groups()
635 testname = tuple(case.split(".") + [name])
636 results = {'SKIPPED': SKIPPED,
637 'EXPECTED FAILURE': SUCCESS,
638 'UNEXPECTED SUCCESS': WARNINGS,
639 'FAILURE': FAILURE,
640 'ERROR': FAILURE,
641 'SUCCESS': SUCCESS,
642 }.get(result, WARNINGS)
643 text = result.lower().split()
644 loog = line
645
646 loog += pio.readline()
647 else:
648
649 loog += line
650 if testname:
651 self.addTestResult(testname, results, text, loog)
652 testname = None
653
654 if warnings:
655 lines = warnings.keys()
656 lines.sort()
657 self.addCompleteLog("warnings", "".join(lines))
658
661
662 - def getText(self, cmd, results):
664 - def getText2(self, cmd, results):
666
667
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).
671
672 """
673
674 name = "process-docs"
675 warnOnWarnings = 1
676 command = ["admin/process-docs"]
677 description = ["processing", "docs"]
678 descriptionDone = ["docs"]
679
680
682 """
683 @type workdir: string
684 @keyword workdir: the workdir to start from: must be the base of the
685 Twisted tree
686 """
687 ShellCommand.__init__(self, **kwargs)
688
690 output = log.getText()
691
692
693
694 lines = output.split("\n")
695 warningLines = []
696 wantNext = False
697 for line in lines:
698 wantThis = wantNext
699 wantNext = False
700 if line.startswith("WARNING: "):
701 wantThis = True
702 wantNext = True
703 if wantThis:
704 warningLines.append(line)
705
706 if warningLines:
707 self.addCompleteLog("warnings", "\n".join(warningLines) + "\n")
708 self.warnings = len(warningLines)
709
716
717 - def getText(self, cmd, results):
718 if results == SUCCESS:
719 return ["docs", "successful"]
720 if results == WARNINGS:
721 return ["docs",
722 "%d warnin%s" % (self.warnings,
723 self.warnings == 1 and 'g' or 'gs')]
724 if results == FAILURE:
725 return ["docs", "failed"]
726
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')]
731 return ["docs"]
732
733
734
736 """I build the .deb packages."""
737
738 name = "debuild"
739 flunkOnFailure = 1
740 command = ["debuild", "-uc", "-us"]
741 description = ["building", "debs"]
742 descriptionDone = ["debs"]
743
745 """
746 @type workdir: string
747 @keyword workdir: the workdir to start from (must be the base of the
748 Twisted tree)
749 """
750 ShellCommand.__init__(self, **kwargs)
751
753 errors, warnings = 0, 0
754 output = cmd.logs['stdio'].getText()
755 summary = ""
756 sio = StringIO.StringIO(output)
757 for line in sio.readlines():
758 if line.find("E: ") == 0:
759 summary += line
760 errors += 1
761 if line.find("W: ") == 0:
762 summary += line
763 warnings += 1
764 if summary:
765 self.addCompleteLog("problems", summary)
766 self.errors = errors
767 self.warnings = warnings
768
777
778 - def getText(self, cmd, results):
779 text = ["debuild"]
780 if cmd.rc != 0:
781 text.append("failed")
782 errors, warnings = self.errors, self.warnings
783 if warnings or errors:
784 text.append("lintian:")
785 if warnings:
786 text.append("%d warnin%s" % (warnings,
787 warnings == 1 and 'g' or 'gs'))
788 if errors:
789 text.append("%d erro%s" % (errors,
790 errors == 1 and 'r' or 'rs'))
791 return text
792
793 - def getText2(self, cmd, results):
794 if cmd.rc != 0:
795 return ["debuild"]
796 if self.errors or self.warnings:
797 return ["%d lintian" % (self.errors + self.warnings)]
798 return []
799
805