1 # -*- test-case-name: buildbot.test.test_twisted -*-
3 from twisted
.python
import log
5 from buildbot
.status
import tests
, builder
6 from buildbot
.status
.builder
import SUCCESS
, FAILURE
, WARNINGS
, SKIPPED
7 from buildbot
.process
import step
8 from buildbot
.process
.step
import ShellCommand
17 # BuildSteps that are specific to the Twisted source tree
19 class HLint(ShellCommand
):
20 """I run a 'lint' checker over a set of .xhtml files. Any deviations
21 from recommended style is flagged and put in the output log.
23 This step looks at .changes in the parent Build to extract a list of
24 Lore XHTML files to check."""
27 description
= ["running", "hlint"]
28 descriptionDone
= ["hlint"]
31 # TODO: track time, but not output
34 def __init__(self
, python
=None, **kwargs
):
35 ShellCommand
.__init
__(self
, **kwargs
)
41 for f
in self
.build
.allFiles():
42 if f
.endswith(".xhtml") and not f
.startswith("sandbox/"):
45 hlintTargets
= htmlFiles
.keys()
49 self
.hlintFiles
= hlintTargets
53 c
+= ["bin/lore", "-p", "--output", "lint"] + self
.hlintFiles
56 # add an extra log file to show the .html files we're checking
57 self
.addCompleteLog("files", "\n".join(self
.hlintFiles
)+"\n")
59 ShellCommand
.start(self
)
61 def commandComplete(self
, cmd
):
62 # TODO: remove the 'files' file (a list of .xhtml files that were
63 # submitted to hlint) because it is available in the logfile and
64 # mostly exists to give the user an idea of how long the step will
66 lines
= cmd
.logs
['stdio'].getText().split("\n")
67 warningLines
= filter(lambda line
:':' in line
, lines
)
69 self
.addCompleteLog("warnings", "".join(warningLines
))
70 warnings
= len(warningLines
)
71 self
.warnings
= warnings
73 def evaluateCommand(self
, cmd
):
74 # warnings are in stdout, rc is always 0, unless the tools break
81 def getText2(self
, cmd
, results
):
84 return ["%d hlin%s" % (self
.warnings
,
85 self
.warnings
== 1 and 't' or 'ts')]
87 def countFailedTests(output
):
88 # start scanning 10kb from the end, because there might be a few kb of
89 # import exception tracebacks between the total/time line and the errors
91 chunk
= output
[-10000:]
92 lines
= chunk
.split("\n")
93 lines
.pop() # blank line at end
94 # lines[-3] is "Ran NN tests in 0.242s"
96 # lines[-1] is 'OK' or 'FAILED (failures=1, errors=12)'
97 # or 'FAILED (failures=1)'
98 # or "PASSED (skips=N, successes=N)" (for Twisted-2.0)
99 # there might be other lines dumped here. Scan all the lines.
100 res
= {'total': None,
104 'expectedFailures': 0,
105 'unexpectedSuccesses': 0,
108 out
= re
.search(r
'Ran (\d+) tests', l
)
110 res
['total'] = int(out
.group(1))
111 if (l
.startswith("OK") or
112 l
.startswith("FAILED ") or
113 l
.startswith("PASSED")):
114 # the extra space on FAILED_ is to distinguish the overall
115 # status from an individual test which failed. The lack of a
116 # space on the OK is because it may be printed without any
117 # additional text (if there are no skips,etc)
118 out
= re
.search(r
'failures=(\d+)', l
)
119 if out
: res
['failures'] = int(out
.group(1))
120 out
= re
.search(r
'errors=(\d+)', l
)
121 if out
: res
['errors'] = int(out
.group(1))
122 out
= re
.search(r
'skips=(\d+)', l
)
123 if out
: res
['skips'] = int(out
.group(1))
124 out
= re
.search(r
'expectedFailures=(\d+)', l
)
125 if out
: res
['expectedFailures'] = int(out
.group(1))
126 out
= re
.search(r
'unexpectedSuccesses=(\d+)', l
)
127 if out
: res
['unexpectedSuccesses'] = int(out
.group(1))
128 # successes= is a Twisted-2.0 addition, and is not currently used
129 out
= re
.search(r
'successes=(\d+)', l
)
130 if out
: res
['successes'] = int(out
.group(1))
135 class TrialTestCaseCounter(step
.LogLineObserver
):
136 _line_re
= re
.compile(r
'^([\w\.]+) \.\.\. \[([^\]]+)\]$')
140 def outLineReceived(self
, line
):
141 # different versions of Twisted emit different per-test lines with
142 # the bwverbose reporter.
143 # 2.0.0: testSlave (buildbot.test.test_runner.Create) ... [OK]
144 # 2.1.0: buildbot.test.test_runner.Create.testSlave ... [OK]
145 # 2.4.0: buildbot.test.test_runner.Create.testSlave ... [OK]
146 # Let's just handle the most recent version, since it's the easiest.
150 if line
.startswith("=" * 40):
154 m
= self
._line
_re
.search(line
.strip())
156 testname
, result
= m
.groups()
158 self
.step
.setProgress('tests', self
.numTests
)
161 UNSPECIFIED
=() # since None is a valid choice
163 class Trial(ShellCommand
):
164 """I run a unit test suite using 'trial', a unittest-like testing
165 framework that comes with Twisted. Trial is used to implement Twisted's
166 own unit tests, and is the unittest-framework of choice for many projects
167 that use Twisted internally.
169 Projects that use trial typically have all their test cases in a 'test'
170 subdirectory of their top-level library directory. I.e. for my package
171 'petmail', the tests are in 'petmail/test/test_*.py'. More complicated
172 packages (like Twisted itself) may have multiple test directories, like
173 'twisted/test/test_*.py' for the core functionality and
174 'twisted/mail/test/test_*.py' for the email-specific tests.
176 To run trial tests, you run the 'trial' executable and tell it where the
177 test cases are located. The most common way of doing this is with a
178 module name. For petmail, I would run 'trial petmail.test' and it would
179 locate all the test_*.py files under petmail/test/, running every test
180 case it could find in them. Unlike the unittest.py that comes with
181 Python, you do not run the test_foo.py as a script; you always let trial
182 do the importing and running. The 'tests' parameter controls which tests
183 trial will run: it can be a string or a list of strings.
185 You can also use a higher-level module name and pass the --recursive flag
186 to trial: this will search recursively within the named module to find
187 all test cases. For large multiple-test-directory projects like Twisted,
188 this means you can avoid specifying all the test directories explicitly.
189 Something like 'trial --recursive twisted' will pick up everything.
191 To find these test cases, you must set a PYTHONPATH that allows something
192 like 'import petmail.test' to work. For packages that don't use a
193 separate top-level 'lib' directory, PYTHONPATH=. will work, and will use
194 the test cases (and the code they are testing) in-place.
195 PYTHONPATH=build/lib or PYTHONPATH=build/lib.$ARCH are also useful when
196 you do a'setup.py build' step first. The 'testpath' attribute of this
197 class controls what PYTHONPATH= is set to.
199 Trial has the ability (through the --testmodule flag) to run only the set
200 of test cases named by special 'test-case-name' tags in source files. We
201 can get the list of changed source files from our parent Build and
202 provide them to trial, thus running the minimal set of test cases needed
203 to cover the Changes. This is useful for quick builds, especially in
204 trees with a lot of test cases. The 'testChanges' parameter controls this
205 feature: if set, it will override 'tests'.
207 The trial executable itself is typically just 'trial' (which is usually
208 found on your $PATH as /usr/bin/trial), but it can be overridden with the
209 'trial' parameter. This is useful for Twisted's own unittests, which want
210 to use the copy of bin/trial that comes with the sources. (when bin/trial
211 discovers that it is living in a subdirectory named 'Twisted', it assumes
212 it is being run from the source tree and adds that parent directory to
213 PYTHONPATH. Therefore the canonical way to run Twisted's own unittest
214 suite is './bin/trial twisted.test' rather than 'PYTHONPATH=.
215 /usr/bin/trial twisted.test', especially handy when /usr/bin/trial has
216 not yet been installed).
218 To influence the version of python being used for the tests, or to add
219 flags to the command, set the 'python' parameter. This can be a string
220 (like 'python2.2') or a list (like ['python2.3', '-Wall']).
222 Trial creates and switches into a directory named _trial_temp/ before
223 running the tests, and sends the twisted log (which includes all
224 exceptions) to a file named test.log . This file will be pulled up to
225 the master where it can be seen as part of the status output.
227 There are some class attributes which may be usefully overridden
228 by subclasses. 'trialMode' and 'trialArgs' can influence the trial
233 progressMetrics
= ('output', 'tests', 'test.log')
234 # note: the slash only works on unix buildslaves, of course, but we have
235 # no way to know what the buildslave uses as a separator. TODO: figure
236 # out something clever.
237 logfiles
= {"test.log": "_trial_temp/test.log"}
238 # we use test.log to track Progress at the end of __init__()
240 flunkOnFailure
= True
243 trialMode
= ["--reporter=bwverbose"] # requires Twisted-2.1.0 or newer
244 # for Twisted-2.0.0 or 1.3.0, use ["-o"] instead
246 testpath
= UNSPECIFIED
# required (but can be None)
247 testChanges
= False # TODO: needs better name
251 tests
= None # required
253 def __init__(self
, reactor
=UNSPECIFIED
, python
=None, trial
=None,
254 testpath
=UNSPECIFIED
,
255 tests
=None, testChanges
=None,
256 recurse
=None, randomly
=None,
257 trialMode
=None, trialArgs
=None,
260 @type testpath: string
261 @param testpath: use in PYTHONPATH when running the tests. If
262 None, do not set PYTHONPATH. Setting this to '.' will
263 cause the source files to be used in-place.
265 @type python: string (without spaces) or list
266 @param python: which python executable to use. Will form the start of
267 the argv array that will launch trial. If you use this,
268 you should set 'trial' to an explicit path (like
269 /usr/bin/trial or ./bin/trial). Defaults to None, which
270 leaves it out entirely (running 'trial args' instead of
271 'python ./bin/trial args'). Likely values are 'python',
272 ['python2.2'], ['python', '-Wall'], etc.
275 @param trial: which 'trial' executable to run.
276 Defaults to 'trial', which will cause $PATH to be
277 searched and probably find /usr/bin/trial . If you set
278 'python', this should be set to an explicit path (because
279 'python2.3 trial' will not work).
281 @type trialMode: list of strings
282 @param trialMode: a list of arguments to pass to trial, specifically
283 to set the reporting mode. This defaults to ['-to']
284 which means 'verbose colorless output' to the trial
285 that comes with Twisted-2.0.x and at least -2.1.0 .
286 Newer versions of Twisted may come with a trial
287 that prefers ['--reporter=bwverbose'].
289 @type trialArgs: list of strings
290 @param trialArgs: a list of arguments to pass to trial, available to
291 turn on any extra flags you like. Defaults to [].
293 @type tests: list of strings
294 @param tests: a list of test modules to run, like
295 ['twisted.test.test_defer', 'twisted.test.test_process'].
296 If this is a string, it will be converted into a one-item
299 @type testChanges: boolean
300 @param testChanges: if True, ignore the 'tests' parameter and instead
301 ask the Build for all the files that make up the
302 Changes going into this build. Pass these filenames
303 to trial and ask it to look for test-case-name
304 tags, running just the tests necessary to cover the
307 @type recurse: boolean
308 @param recurse: If True, pass the --recurse option to trial, allowing
309 test cases to be found in deeper subdirectories of the
310 modules listed in 'tests'. This does not appear to be
311 necessary when using testChanges.
313 @type reactor: string
314 @param reactor: which reactor to use, like 'gtk' or 'java'. If not
315 provided, the Twisted's usual platform-dependent
318 @type randomly: boolean
319 @param randomly: if True, add the --random=0 argument, which instructs
320 trial to run the unit tests in a random order each
321 time. This occasionally catches problems that might be
322 masked when one module always runs before another
323 (like failing to make registerAdapter calls before
327 @param kwargs: parameters. The following parameters are inherited from
328 L{ShellCommand} and may be useful to set: workdir,
329 haltOnFailure, flunkOnWarnings, flunkOnFailure,
330 warnOnWarnings, warnOnFailure, want_stdout, want_stderr,
333 ShellCommand
.__init
__(self
, **kwargs
)
337 if self
.python
is not None:
338 if type(self
.python
) is str:
339 self
.python
= [self
.python
]
340 for s
in self
.python
:
342 # this is not strictly an error, but I suspect more
343 # people will accidentally try to use python="python2.3
344 # -Wall" than will use embedded spaces in a python flag
345 log
.msg("python= component '%s' has spaces")
346 log
.msg("To add -Wall, use python=['python', '-Wall']")
347 why
= "python= value has spaces, probably an error"
348 raise ValueError(why
)
352 if " " in self
.trial
:
353 raise ValueError("trial= value has spaces")
354 if trialMode
is not None:
355 self
.trialMode
= trialMode
356 if trialArgs
is not None:
357 self
.trialArgs
= trialArgs
359 if testpath
is not UNSPECIFIED
:
360 self
.testpath
= testpath
361 if self
.testpath
is UNSPECIFIED
:
362 raise ValueError("You must specify testpath= (it can be None)")
363 assert isinstance(self
.testpath
, str) or self
.testpath
is None
365 if reactor
is not UNSPECIFIED
:
366 self
.reactor
= reactor
368 if tests
is not None:
370 if type(self
.tests
) is str:
371 self
.tests
= [self
.tests
]
372 if testChanges
is not None:
373 self
.testChanges
= testChanges
374 #self.recurse = True # not sure this is necessary
376 if not self
.testChanges
and self
.tests
is None:
377 raise ValueError("Must either set testChanges= or provide tests=")
379 if recurse
is not None:
380 self
.recurse
= recurse
381 if randomly
is not None:
382 self
.randomly
= randomly
384 # build up most of the command, then stash it until start()
387 command
.extend(self
.python
)
388 command
.append(self
.trial
)
389 command
.extend(self
.trialMode
)
391 command
.append("--recurse")
393 command
.append("--reactor=%s" % reactor
)
395 command
.append("--random=0")
396 command
.extend(self
.trialArgs
)
397 self
.command
= command
400 self
.description
= ["testing", "(%s)" % self
.reactor
]
401 self
.descriptionDone
= ["tests"]
402 # commandComplete adds (reactorname) to self.text
404 self
.description
= ["testing"]
405 self
.descriptionDone
= ["tests"]
407 # this counter will feed Progress along the 'test cases' metric
408 self
.addLogObserver('stdio', TrialTestCaseCounter())
409 # this one just measures bytes of output in _trial_temp/test.log
410 self
.addLogObserver('test.log',
411 step
.OutputProgressObserver('test.log'))
413 def setupEnvironment(self
, cmd
):
414 ShellCommand
.setupEnvironment(self
, cmd
)
415 if self
.testpath
!= None:
418 cmd
.args
['env'] = {'PYTHONPATH': self
.testpath
}
420 # TODO: somehow, each build causes another copy of
421 # self.testpath to get prepended
422 if e
.get('PYTHONPATH', "") == "":
423 e
['PYTHONPATH'] = self
.testpath
425 e
['PYTHONPATH'] = self
.testpath
+ ":" + e
['PYTHONPATH']
427 p
= cmd
.args
['env']['PYTHONPATH']
428 if type(p
) is not str:
429 log
.msg("hey, not a string:", p
)
431 except (KeyError, TypeError):
432 # KeyError if args doesn't have ['env']
433 # KeyError if args['env'] doesn't have ['PYTHONPATH']
434 # TypeError if args is None
438 # now that self.build.allFiles() is nailed down, finish building the
441 for f
in self
.build
.allFiles():
442 if f
.endswith(".py"):
443 self
.command
.append("--testmodule=%s" % f
)
445 self
.command
.extend(self
.tests
)
446 log
.msg("Trial.start: command is", self
.command
)
448 # if our slave is too old to understand logfiles=, fetch them
449 # manually. This is a fallback for the Twisted buildbot and some old
451 self
._needToPullTestDotLog
= False
452 if self
.slaveVersionIsOlderThan("shell", "2.1"):
453 log
.msg("Trial: buildslave %s is too old to accept logfiles=" %
455 log
.msg(" falling back to 'cat _trial_temp/test.log' instead")
457 self
._needToPullTestDotLog
= True
459 ShellCommand
.start(self
)
462 def commandComplete(self
, cmd
):
463 if not self
._needToPullTestDotLog
:
464 return self
._gotTestDotLog
(cmd
)
466 # if the buildslave was too old, pull test.log now
467 catcmd
= ["cat", "_trial_temp/test.log"]
468 c2
= step
.RemoteShellCommand(command
=catcmd
, workdir
=self
.workdir
)
469 loog
= self
.addLog("test.log")
470 c2
.useLog(loog
, True, logfileName
="stdio")
471 self
.cmd
= c2
# to allow interrupts
472 d
= c2
.run(self
, self
.remote
)
473 d
.addCallback(lambda res
: self
._gotTestDotLog
(cmd
))
476 def rtext(self
, fmt
='%s'):
478 rtext
= fmt
% self
.reactor
479 return rtext
.replace("reactor", "")
482 def _gotTestDotLog(self
, cmd
):
483 # figure out all status, then let the various hook functions return
484 # different pieces of it
486 # 'cmd' is the original trial command, so cmd.logs['stdio'] is the
487 # trial output. We don't have access to test.log from here.
488 output
= cmd
.logs
['stdio'].getText()
489 counts
= countFailedTests(output
)
491 total
= counts
['total']
492 failures
, errors
= counts
['failures'], counts
['errors']
493 parsed
= (total
!= None)
503 total
== 1 and "test" or "tests"),
506 text
+= ["no tests", "run"]
509 text
+= ["testlog", "unparseable"]
517 text
.append("%d %s" % \
519 failures
== 1 and "failure" or "failures"))
521 text
.append("%d %s" % \
523 errors
== 1 and "error" or "errors"))
524 count
= failures
+ errors
525 text2
= "%d tes%s" % (count
, (count
== 1 and 't' or 'ts'))
527 text
+= ["tests", "failed"]
531 text
.append("%d %s" % \
533 counts
['skips'] == 1 and "skip" or "skips"))
534 if counts
['expectedFailures']:
535 text
.append("%d %s" % \
536 (counts
['expectedFailures'],
537 counts
['expectedFailures'] == 1 and "todo"
545 # ignore unexpectedSuccesses for now, but it should really mark
547 if counts
['unexpectedSuccesses']:
548 text
.append("%d surprises" % counts
['unexpectedSuccesses'])
554 text
.append(self
.rtext('(%s)'))
556 text2
= "%s %s" % (text2
, self
.rtext('(%s)'))
558 self
.results
= results
562 def addTestResult(self
, testname
, results
, text
, tlog
):
563 if self
.reactor
is not None:
564 testname
= (self
.reactor
,) + testname
565 tr
= builder
.TestResult(testname
, results
, text
, logs
={'log': tlog
})
566 #self.step_status.build.addTestResult(tr)
567 self
.build
.build_status
.addTestResult(tr
)
569 def createSummary(self
, loog
):
570 output
= loog
.getText()
572 sio
= StringIO
.StringIO(output
)
575 line
= sio
.readline()
578 if line
.find(" exceptions.DeprecationWarning: ") != -1:
580 warning
= line
# TODO: consider stripping basedir prefix here
581 warnings
[warning
] = warnings
.get(warning
, 0) + 1
582 elif (line
.find(" DeprecationWarning: ") != -1 or
583 line
.find(" UserWarning: ") != -1):
584 # next line is the source
585 warning
= line
+ sio
.readline()
586 warnings
[warning
] = warnings
.get(warning
, 0) + 1
587 elif line
.find("Warning: ") != -1:
589 warnings
[warning
] = warnings
.get(warning
, 0) + 1
591 if line
.find("=" * 60) == 0 or line
.find("-" * 60) == 0:
593 problems
+= sio
.read()
597 self
.addCompleteLog("problems", problems
)
598 # now parse the problems for per-test results
599 pio
= StringIO
.StringIO(problems
)
600 pio
.readline() # eat the first separator line
605 line
= pio
.readline()
609 if line
.find("=" * 60) == 0:
611 if line
.find("-" * 60) == 0:
612 # the last case has --- as a separator before the
613 # summary counts are printed
617 # the first line after the === is like:
618 # EXPECTED FAILURE: testLackOfTB (twisted.test.test_failure.FailureTestCase)
619 # SKIPPED: testRETR (twisted.test.test_ftp.TestFTPServer)
620 # FAILURE: testBatchFile (twisted.conch.test.test_sftp.TestOurServerBatchFile)
621 r
= re
.search(r
'^([^:]+): (\w+) \(([\w\.]+)\)', line
)
623 # TODO: cleanup, if there are no problems,
626 result
, name
, case
= r
.groups()
627 testname
= tuple(case
.split(".") + [name
])
628 results
= {'SKIPPED': SKIPPED
,
629 'EXPECTED FAILURE': SUCCESS
,
630 'UNEXPECTED SUCCESS': WARNINGS
,
633 'SUCCESS': SUCCESS
, # not reported
634 }.get(result
, WARNINGS
)
635 text
= result
.lower().split()
637 # the next line is all dashes
638 loog
+= pio
.readline()
640 # the rest goes into the log
643 self
.addTestResult(testname
, results
, text
, loog
)
647 lines
= warnings
.keys()
649 self
.addCompleteLog("warnings", "".join(lines
))
651 def evaluateCommand(self
, cmd
):
654 def getText(self
, cmd
, results
):
656 def getText2(self
, cmd
, results
):
660 class ProcessDocs(ShellCommand
):
661 """I build all docs. This requires some LaTeX packages to be installed.
662 It will result in the full documentation book (dvi, pdf, etc).
666 name
= "process-docs"
668 command
= ["admin/process-docs"]
669 description
= ["processing", "docs"]
670 descriptionDone
= ["docs"]
671 # TODO: track output and time
673 def __init__(self
, **kwargs
):
675 @type workdir: string
676 @keyword workdir: the workdir to start from: must be the base of the
679 @type results: triple of (int, int, string)
680 @keyword results: [rc, warnings, output]
681 - rc==0 if all files were converted successfully.
682 - warnings is a count of hlint warnings.
683 - output is the verbose output of the command.
685 ShellCommand
.__init
__(self
, **kwargs
)
687 def createSummary(self
, log
):
688 output
= log
.getText()
689 # hlint warnings are of the format: 'WARNING: file:line:col: stuff
690 # latex warnings start with "WARNING: LaTeX Warning: stuff", but
691 # sometimes wrap around to a second line.
692 lines
= output
.split("\n")
698 if line
.startswith("WARNING: "):
702 warningLines
.append(line
)
705 self
.addCompleteLog("warnings", "\n".join(warningLines
) + "\n")
706 self
.warnings
= len(warningLines
)
708 def evaluateCommand(self
, cmd
):
715 def getText(self
, cmd
, results
):
716 if results
== SUCCESS
:
717 return ["docs", "successful"]
718 if results
== WARNINGS
:
720 "%d warnin%s" % (self
.warnings
,
721 self
.warnings
== 1 and 'g' or 'gs')]
722 if results
== FAILURE
:
723 return ["docs", "failed"]
725 def getText2(self
, cmd
, results
):
726 if results
== WARNINGS
:
727 return ["%d do%s" % (self
.warnings
,
728 self
.warnings
== 1 and 'c' or 'cs')]
733 class BuildDebs(ShellCommand
):
734 """I build the .deb packages."""
738 command
= ["debuild", "-uc", "-us"]
739 description
= ["building", "debs"]
740 descriptionDone
= ["debs"]
742 def __init__(self
, **kwargs
):
744 @type workdir: string
745 @keyword workdir: the workdir to start from (must be the base of the
747 @type results: double of [int, string]
748 @keyword results: [rc, output].
749 - rc == 0 if all .debs were created successfully
750 - output: string with any errors or warnings
752 ShellCommand
.__init
__(self
, **kwargs
)
754 def commandComplete(self
, cmd
):
755 errors
, warnings
= 0, 0
756 output
= cmd
.logs
['stdio'].getText()
758 sio
= StringIO
.StringIO(output
)
759 for line
in sio
.readlines():
760 if line
.find("E: ") == 0:
763 if line
.find("W: ") == 0:
767 self
.addCompleteLog("problems", summary
)
769 self
.warnings
= warnings
771 def evaluateCommand(self
, cmd
):
780 def getText(self
, cmd
, results
):
783 text
.append("failed")
784 errors
, warnings
= self
.errors
, self
.warnings
785 if warnings
or errors
:
786 text
.append("lintian:")
788 text
.append("%d warnin%s" % (warnings
,
789 warnings
== 1 and 'g' or 'gs'))
791 text
.append("%d erro%s" % (errors
,
792 errors
== 1 and 'r' or 'rs'))
795 def getText2(self
, cmd
, results
):
798 if self
.errors
or self
.warnings
:
799 return ["%d lintian" % (self
.errors
+ self
.warnings
)]
802 class RemovePYCs(ShellCommand
):
804 command
= 'find . -name "*.pyc" | xargs rm'
805 description
= ["removing", ".pyc", "files"]
806 descriptionDone
= ["remove", ".pycs"]