Merge lp:~jml/divmod.org/pyflakes-reporter into lp:divmod.org

Proposed by Jonathan Lange
Status: Merged
Approved by: Glyph Lefkowitz
Approved revision: 2730
Merged at revision: 2699
Proposed branch: lp:~jml/divmod.org/pyflakes-reporter
Merge into: lp:divmod.org
Diff against target: 839 lines (+582/-94)
4 files modified
Pyflakes/bin/pyflakes (+0/-1)
Pyflakes/pyflakes/reporter.py (+79/-0)
Pyflakes/pyflakes/scripts/pyflakes.py (+63/-31)
Pyflakes/pyflakes/test/test_script.py (+440/-62)
To merge this branch: bzr merge lp:~jml/divmod.org/pyflakes-reporter
Reviewer Review Type Date Requested Status
Jean-Paul Calderone Needs Information
Review via email: mp+113857@code.launchpad.net

This proposal supersedes a proposal from 2012-06-27.

Description of the change

A sketch for a reporter in pyflakes. This moves pyflakes toward having an interface that can be called from Python to gather errors without requiring stderr and stdout to be captured. It also separates the format of output from the means of checking.

I was inspired to do this while trying to add a test to my own projects to guarantee that it is pyflakes-clean. I didn't want to do stdout/err trapping, and thought that something like this would be useful.

The pyflakes script wasn't exactly the best tested code. I've tried to add tests to cover the behaviour. The integration tests in particular would bear careful scrutiny. I've doubtless missed many opportunities to remove duplicated code in the tests.

I have also tried to preserve API compatibility. That's why 'reporter' is left as an optional parameter for existing methods.

To post a comment you must log in.
Revision history for this message
Glyph Lefkowitz (glyph) wrote : Posted in a previous version of this proposal

For what it's worth, seems like a good idea to me.

Tests, please, though.

Revision history for this message
Jonathan Jacobs (jjacobs) wrote : Posted in a previous version of this proposal

I, too, like this idea. I'd love to see it merged.

Revision history for this message
Jonathan Lange (jml) wrote : Posted in a previous version of this proposal

On 27 June 2012 19:51, Glyph Lefkowitz <email address hidden> wrote:
> For what it's worth, seems like a good idea to me.
>

Cool, thanks.

> Tests, please, though.

Sure. I'll probably throw this away & start something again TDD. This
seemed the shortest way of describing the change I wished to make.

jml

Revision history for this message
Jean-Paul Calderone (exarkun) wrote :

Thanks. It will be interesting to see what kind of custom reporters people build for pyflakes once this lands.

  0. Perhaps the reporter shouldn't be defined in the scripts module.
  1. These three methods of the reporter, ioError, problemDecodingSource, and syntaxError seems bad to me. These are all just reasons pyflakes couldn't get an AST. They're all quite well described by some exception object that pyflakes catches. And it's entirely possible we'll find other problems that fall into the same category. It would be better to have a single method that dealt with these errors and could be expanded to handle new errors rather than having a method for each and have to add a new method to the reporter interface.
  2. Can we consider accepting (and explicitly documenting) text streams to the reporter instead of byte streams? All of the output pyflakes generates should be text.
  3. Seems like there is an excessive use of `os.path` for a project that already depends on Twisted and even uses FilePath in a few places.
  4. Some things are missing docstrings: test_flake, CheckTests.getErrors,
  5. Thanks for the makeTempFile / assertHasErrors refactoring. That's nice.
  6. popen is deadly poison. I think it would be really great if pyflakes tests didn't need to use it, somehow. At a absolute minimum, it would be nice if none of the details of its use leaked out of the single helper method for using it. eg, no use of `communicate` in individual test methods.

review: Needs Fixing
lp:~jml/divmod.org/pyflakes-reporter updated
2712. By Jonathan Lange

0. Move Reporter to new module, pyflakes.reporter.

2713. By Jonathan Lange

3. Use FilePath rather than os.path (in tests)

2714. By Jonathan Lange

4. Add a bunch of docstrings.

2715. By Jonathan Lange

6 (partial). Rename 'popenPyflakes' to 'runPyflakes'.

2716. By Jonathan Lange

6 (partial). Contain the poison by returning returncode, out, err from runPyflakes6 (partial). Contain the poison by returning returncode, out, err from runPyflakes6 (partial). Contain the poison by returning returncode, out, err from runPyflakes6 (partial). Contain the poison by returning returncode, out, err from runPyflakes6 (partial). Contain the poison by returning returncode, out, err from runPyflakes6 (partial). Contain the poison by returning returncode, out, err from runPyflakes

2717. By Jonathan Lange

6 (partial). Change the integration tests to expect a Deferred from runPyflakes.

2718. By Jonathan Lange

6 (partial). Use Twisted to spawn the process, not subprocess.

2719. By Jonathan Lange

Change the test assertions to more naturally follow getProcessOutputAndValue's return

2720. By Jonathan Lange

Remove unnecessary parens

2721. By Jonathan Lange

Explain what's going on.

Revision history for this message
Jonathan Lange (jml) wrote :

Sorry about the partial reply.

0. Reporter moved to new module, pyflakes.reporter.
1. Not sure these can be combined into one method while still preserving current output. e.g. IOError doesn't include filename. TODO: Investigate further, try a couple of options.
2. TODO
3. Done, but for tests only. AFAICT, only the tests import Twisted. Happy to change non-test code if you don't mind having it depend on Twisted.
4. Docstrings added
5. My pleasure.
6. I've killed subprocess.Popen usage. Made an ugly compromise for spawning a subprocess and sending stdin. Think it's the least of four evils (the other three being continue w/ subprocess.Popen, duplicate _EverythingGetter in pyflakes, or block this branch until Twisted provides such a helper).

Revision history for this message
Jean-Paul Calderone (exarkun) wrote :

> Reporter moved to new module, pyflakes.reporter.

Great, thanks.

> Not sure these can be combined into one method while still preserving current output.

Okay, that makes sense. I guess pyflakes doesn't offer any API compatibility promises anyway, so we can change this as needed later. ;)

> Done, but for tests only. AFAICT, only the tests import Twisted. Happy to change non-test code if you don't mind having it depend on Twisted.

Just making the tests nice is fine for now, as far as I'm concerned.

> Docstrings added

Great.

> I've killed subprocess.Popen usage. Made an ugly compromise for spawning a subprocess and sending stdin. Think it's the least of four evils (the other three being continue w/ subprocess.Popen, duplicate _EverythingGetter in pyflakes, or block this branch until Twisted provides such a helper).

Hmm. This is going to break hard at some point, though. Though, it will only break the unit tests, not pyflakes itself... Still, `_EverythingGetter` is barely any longer than the subclass being added to pyflakes. Duplicating it in pyflakes seems better than waiting for pyflakes to break when `twisted.internet.utils` changes. Also, `_EverythingGetter` is awful (for example, errbacking with a tuple if a signal ends the process). I'd lean towards copying it (and simplifying it by fixing it to get rid of the nasty signal behavior, at least) instead of importing the private name. If you're not convinced, fine (but I might ask you to fix it when Twisted breaks it ;).

Thanks! Do you plan to do point 2 (text streams) or do you want to delay that for a separate ticket (is there even a ticket associated with this merge proposal)?

review: Needs Information
Revision history for this message
Jean-Paul Calderone (exarkun) wrote :

Oh. Another new comment. `mod_reporter` doesn't conform to the naming convention and `__import__` is obsoleted by `__future__.absolute_import`. Thanks!

Revision history for this message
Jonathan Lange (jml) wrote :

Happy to continue working on this patch. Just needed to cap my time on it yesterday.

When I get cycles to devote to this, will:

 1. duplicate & simplify _EverythingGetter, rather than import it
 2. change mod_reactor import to modReactor (or similar), and change both __import__'s to __future__.absolute_import
 3. attempt to make the reporter interface all text, documenting it as such
 4. attempt to unify the multiple import methods

Thanks for the review.

jml

lp:~jml/divmod.org/pyflakes-reporter updated
2722. By Jonathan Lange

New method for errors.

2723. By Jonathan Lange

Collapse problemDecodingSource and ioError into the one method.

2724. By Jonathan Lange

Document that we expect text, not bytes.

2725. By Jonathan Lange

Better ptype

2726. By Jonathan Lange

Pass unicode

2727. By Jonathan Lange

Correct coding convention

2728. By Jonathan Lange

Use absolute_import.

2729. By Jonathan Lange

Duplicate _EverythingGetter, rather than import it.

Revision history for this message
Jonathan Lange (jml) wrote :

1. duplicate & simplify _EverythingGetter, rather than import it
 Done.

2. change mod_reactor import to modReactor (or similar), and change both __import__'s to __future__.absolute_import
 Done (except I should have said "mod_reporter", not "mod_reactor")

3. attempt to make the reporter interface all text, documenting it as such
 Done, I think.

4. attempt to unify the multiple import methods
 Done, sort of. Combined ioError and problemDecodingSource into one unexpectedError method. Syntax errors have special needs re output, so have left as-is.

I think we're good to merge now.

lp:~jml/divmod.org/pyflakes-reporter updated
2730. By Jonathan Lange

Wrap types in C{}

Revision history for this message
Glyph Lefkowitz (glyph) wrote :

This change looks good to me. Thanks, Jonathan.

Revision history for this message
Jonathan Lange (jml) wrote :

Thanks for the review!

I can't merge this branch, as I'm not in ~divmod-dev and thus cannot commit to lp:divmod.org. Would you mind merging it?

Revision history for this message
Glyph Lefkowitz (glyph) wrote :

I'd better not, but I've just added you to divmod-dev; I'm somewhat surprised you weren't a member already, as you are officially a Divmod alumnus too ;-).

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'Pyflakes/bin/pyflakes'
2--- Pyflakes/bin/pyflakes 2008-08-28 14:33:07 +0000
3+++ Pyflakes/bin/pyflakes 2012-10-23 13:09:20 +0000
4@@ -1,4 +1,3 @@
5 #!/usr/bin/python
6-
7 from pyflakes.scripts.pyflakes import main
8 main()
9
10=== added file 'Pyflakes/pyflakes/reporter.py'
11--- Pyflakes/pyflakes/reporter.py 1970-01-01 00:00:00 +0000
12+++ Pyflakes/pyflakes/reporter.py 2012-10-23 13:09:20 +0000
13@@ -0,0 +1,79 @@
14+# (c) 2005-2012 Divmod, Inc.
15+# See LICENSE file for details
16+
17+import sys
18+
19+
20+class Reporter(object):
21+ """
22+ Formats the results of pyflakes checks to users.
23+ """
24+
25+ def __init__(self, warningStream, errorStream):
26+ """
27+ Construct a L{Reporter}.
28+
29+ @param warningStream: A file-like object where warnings will be
30+ written to. The stream's C{write} method must accept unicode.
31+ C{sys.stdout} is a good value.
32+ @param errorStream: A file-like object where error output will be
33+ written to. The stream's C{write} method must accept unicode.
34+ C{sys.stderr} is a good value.
35+ """
36+ self._stdout = warningStream
37+ self._stderr = errorStream
38+
39+
40+ def unexpectedError(self, filename, msg):
41+ """
42+ An unexpected error occurred trying to process C{filename}.
43+
44+ @param filename: The path to a file that we could not process.
45+ @ptype filename: C{unicode}
46+ @param msg: A message explaining the problem.
47+ @ptype msg: C{unicode}
48+ """
49+ self._stderr.write(u"%s: %s\n" % (filename, msg))
50+
51+
52+ def syntaxError(self, filename, msg, lineno, offset, text):
53+ """
54+ There was a syntax errror in C{filename}.
55+
56+ @param filename: The path to the file with the syntax error.
57+ @ptype filename: C{unicode}
58+ @param msg: An explanation of the syntax error.
59+ @ptype msg: C{unicode}
60+ @param lineno: The line number where the syntax error occurred.
61+ @ptype lineno: C{int}
62+ @param offset: The column on which the syntax error occurred.
63+ @ptype offset: C{int}
64+ @param text: The source code containing the syntax error.
65+ @ptype text: C{unicode}
66+ """
67+ line = text.splitlines()[-1]
68+ if offset is not None:
69+ offset = offset - (len(text) - len(line))
70+ self._stderr.write(u'%s:%d: %s\n' % (filename, lineno, msg))
71+ self._stderr.write(line)
72+ self._stderr.write(u'\n')
73+ if offset is not None:
74+ self._stderr.write(u" " * (offset + 1) + u"^\n")
75+
76+
77+ def flake(self, message):
78+ """
79+ pyflakes found something wrong with the code.
80+
81+ @param: A L{pyflakes.messages.Message}.
82+ """
83+ self._stdout.write(unicode(message))
84+ self._stdout.write(u'\n')
85+
86+
87+
88+def _makeDefaultReporter():
89+ """
90+ Make a reporter that can be used when no reporter is specified.
91+ """
92+ return Reporter(sys.stdout, sys.stderr)
93
94=== modified file 'Pyflakes/pyflakes/scripts/pyflakes.py'
95--- Pyflakes/pyflakes/scripts/pyflakes.py 2010-04-13 14:53:04 +0000
96+++ Pyflakes/pyflakes/scripts/pyflakes.py 2012-10-23 13:09:20 +0000
97@@ -1,15 +1,18 @@
98-
99 """
100 Implementation of the command-line I{pyflakes} tool.
101 """
102
103+from __future__ import absolute_import
104+
105 import sys
106 import os
107 import _ast
108
109-checker = __import__('pyflakes.checker').checker
110-
111-def check(codeString, filename):
112+from pyflakes import checker
113+from pyflakes import reporter as modReporter
114+
115+
116+def check(codeString, filename, reporter=None):
117 """
118 Check the Python source given by C{codeString} for flakes.
119
120@@ -20,9 +23,14 @@
121 errors.
122 @type filename: C{str}
123
124+ @param reporter: A L{Reporter} instance, where errors and warnings will be
125+ reported.
126+
127 @return: The number of warnings emitted.
128 @rtype: C{int}
129 """
130+ if reporter is None:
131+ reporter = modReporter._makeDefaultReporter()
132 # First, compile into an AST and handle syntax errors.
133 try:
134 tree = compile(codeString, filename, "exec", _ast.PyCF_ONLY_AST)
135@@ -36,55 +44,79 @@
136 # Avoid using msg, since for the only known case, it contains a
137 # bogus message that claims the encoding the file declared was
138 # unknown.
139- print >> sys.stderr, "%s: problem decoding source" % (filename, )
140+ reporter.unexpectedError(filename, u'problem decoding source')
141 else:
142- line = text.splitlines()[-1]
143-
144- if offset is not None:
145- offset = offset - (len(text) - len(line))
146-
147- print >> sys.stderr, '%s:%d: %s' % (filename, lineno, msg)
148- print >> sys.stderr, line
149-
150- if offset is not None:
151- print >> sys.stderr, " " * offset, "^"
152-
153+ reporter.syntaxError(filename, msg, lineno, offset, text)
154 return 1
155 else:
156 # Okay, it's syntactically valid. Now check it.
157 w = checker.Checker(tree, filename)
158 w.messages.sort(lambda a, b: cmp(a.lineno, b.lineno))
159 for warning in w.messages:
160- print warning
161+ reporter.flake(warning)
162 return len(w.messages)
163
164
165-def checkPath(filename):
166+def checkPath(filename, reporter=None):
167 """
168 Check the given path, printing out any warnings detected.
169
170+ @param reporter: A L{Reporter} instance, where errors and warnings will be
171+ reported.
172+
173 @return: the number of warnings printed
174 """
175+ if reporter is None:
176+ reporter = modReporter._makeDefaultReporter()
177 try:
178- return check(file(filename, 'U').read() + '\n', filename)
179+ return check(file(filename, 'U').read() + '\n', filename, reporter)
180 except IOError, msg:
181- print >> sys.stderr, "%s: %s" % (filename, msg.args[1])
182+ reporter.unexpectedError(filename, msg.args[1])
183 return 1
184
185
186+
187+def iterSourceCode(paths):
188+ """
189+ Iterate over all Python source files in C{paths}.
190+
191+ @param paths: A list of paths. Directories will be recursed into and
192+ any .py files found will be yielded. Any non-directories will be
193+ yielded as-is.
194+ """
195+ for path in paths:
196+ if os.path.isdir(path):
197+ for dirpath, dirnames, filenames in os.walk(path):
198+ for filename in filenames:
199+ if filename.endswith('.py'):
200+ yield os.path.join(dirpath, filename)
201+ else:
202+ yield path
203+
204+
205+
206+def checkRecursive(paths, reporter):
207+ """
208+ Recursively check all source files in C{paths}.
209+
210+ @param paths: A list of paths to Python source files and directories
211+ containing Python source files.
212+ @param reporter: A L{Reporter} where all of the warnings and errors
213+ will be reported to.
214+ @return: The number of warnings found.
215+ """
216+ warnings = 0
217+ for sourcePath in iterSourceCode(paths):
218+ warnings += checkPath(sourcePath, reporter)
219+ return warnings
220+
221+
222+
223 def main():
224- warnings = 0
225 args = sys.argv[1:]
226+ reporter = modReporter._makeDefaultReporter()
227 if args:
228- for arg in args:
229- if os.path.isdir(arg):
230- for dirpath, dirnames, filenames in os.walk(arg):
231- for filename in filenames:
232- if filename.endswith('.py'):
233- warnings += checkPath(os.path.join(dirpath, filename))
234- else:
235- warnings += checkPath(arg)
236+ warnings = checkRecursive(args, reporter)
237 else:
238- warnings += check(sys.stdin.read(), '<stdin>')
239-
240+ warnings = check(sys.stdin.read(), '<stdin>', reporter)
241 raise SystemExit(warnings > 0)
242
243=== modified file 'Pyflakes/pyflakes/test/test_script.py'
244--- Pyflakes/pyflakes/test/test_script.py 2009-06-17 20:58:48 +0000
245+++ Pyflakes/pyflakes/test/test_script.py 2012-10-23 13:09:20 +0000
246@@ -1,51 +1,296 @@
247-
248 """
249 Tests for L{pyflakes.scripts.pyflakes}.
250 """
251
252+import os
253 import sys
254 from StringIO import StringIO
255
256+from twisted.internet import protocol
257+from twisted.internet.utils import (
258+ _callProtocolWithDeferred,
259+ getProcessOutputAndValue,
260+ )
261 from twisted.python.filepath import FilePath
262 from twisted.trial.unittest import TestCase
263
264-from pyflakes.scripts.pyflakes import checkPath
265-
266-def withStderrTo(stderr, f):
267+from pyflakes.messages import UnusedImport
268+from pyflakes.reporter import Reporter
269+from pyflakes.scripts.pyflakes import (
270+ checkPath,
271+ checkRecursive,
272+ iterSourceCode,
273+ )
274+
275+
276+def withStderrTo(stderr, f, *args, **kwargs):
277 """
278 Call C{f} with C{sys.stderr} redirected to C{stderr}.
279 """
280 (outer, sys.stderr) = (sys.stderr, stderr)
281 try:
282- return f()
283+ return f(*args, **kwargs)
284 finally:
285 sys.stderr = outer
286
287
288
289+class LoggingReporter(object):
290+ """
291+ Implementation of Reporter that just appends any errors to a list.
292+ """
293+
294+ def __init__(self, log):
295+ """
296+ Construct a C{LoggingReporter}.
297+
298+ @param log: A list to append log messages to.
299+ """
300+ self.log = log
301+
302+
303+ def flake(self, message):
304+ self.log.append(('flake', str(message)))
305+
306+
307+ def unexpectedError(self, filename, message):
308+ self.log.append(('unexpectedError', filename, message))
309+
310+
311+ def syntaxError(self, filename, msg, lineno, offset, line):
312+ self.log.append(('syntaxError', filename, msg, lineno, offset, line))
313+
314+
315+
316+class TestIterSourceCode(TestCase):
317+ """
318+ Tests for L{iterSourceCode}.
319+ """
320+
321+ def test_emptyDirectory(self):
322+ """
323+ There are no Python files in an empty directory.
324+ """
325+ tempdir = FilePath(self.mktemp())
326+ tempdir.createDirectory()
327+ self.assertEqual(list(iterSourceCode([tempdir.path])), [])
328+
329+
330+ def test_singleFile(self):
331+ """
332+ If the directory contains one Python file, C{iterSourceCode} will find
333+ it.
334+ """
335+ tempdir = FilePath(self.mktemp())
336+ tempdir.createDirectory()
337+ tempdir.child('foo.py').touch()
338+ self.assertEqual(
339+ list(iterSourceCode([tempdir.path])),
340+ [tempdir.child('foo.py').path])
341+
342+
343+ def test_onlyPythonSource(self):
344+ """
345+ Files that are not Python source files are not included.
346+ """
347+ tempdir = FilePath(self.mktemp())
348+ tempdir.createDirectory()
349+ tempdir.child('foo.pyc').touch()
350+ self.assertEqual(list(iterSourceCode([tempdir.path])), [])
351+
352+
353+ def test_recurses(self):
354+ """
355+ If the Python files are hidden deep down in child directories, we will
356+ find them.
357+ """
358+ tempdir = FilePath(self.mktemp())
359+ tempdir.createDirectory()
360+ tempdir.child('foo').createDirectory()
361+ tempdir.child('foo').child('a.py').touch()
362+ tempdir.child('bar').createDirectory()
363+ tempdir.child('bar').child('b.py').touch()
364+ tempdir.child('c.py').touch()
365+ self.assertEqual(
366+ sorted(iterSourceCode([tempdir.path])),
367+ sorted([tempdir.child('foo').child('a.py').path,
368+ tempdir.child('bar').child('b.py').path,
369+ tempdir.child('c.py').path]))
370+
371+
372+ def test_multipleDirectories(self):
373+ """
374+ L{iterSourceCode} can be given multiple directories. It will recurse
375+ into each of them.
376+ """
377+ tempdir = FilePath(self.mktemp())
378+ tempdir.createDirectory()
379+ foo = tempdir.child('foo')
380+ foo.createDirectory()
381+ foo.child('a.py').touch()
382+ bar = tempdir.child('bar')
383+ bar.createDirectory()
384+ bar.child('b.py').touch()
385+ self.assertEqual(
386+ sorted(iterSourceCode([foo.path, bar.path])),
387+ sorted([foo.child('a.py').path,
388+ bar.child('b.py').path]))
389+
390+
391+ def test_explicitFiles(self):
392+ """
393+ If one of the paths given to L{iterSourceCode} is not a directory but
394+ a file, it will include that in its output.
395+ """
396+ tempfile = FilePath(self.mktemp())
397+ tempfile.touch()
398+ self.assertEqual(list(iterSourceCode([tempfile.path])),
399+ [tempfile.path])
400+
401+
402+
403+class TestReporter(TestCase):
404+ """
405+ Tests for L{Reporter}.
406+ """
407+
408+ def test_syntaxError(self):
409+ """
410+ C{syntaxError} reports that there was a syntax error in the source
411+ file. It reports to the error stream and includes the filename, line
412+ number, error message, actual line of source and a caret pointing to
413+ where the error is.
414+ """
415+ err = StringIO()
416+ reporter = Reporter(None, err)
417+ reporter.syntaxError('foo.py', 'a problem', 3, 4, 'bad line of source')
418+ self.assertEquals(
419+ ("foo.py:3: a problem\n"
420+ "bad line of source\n"
421+ " ^\n"),
422+ err.getvalue())
423+
424+
425+ def test_syntaxErrorNoOffset(self):
426+ """
427+ C{syntaxError} doesn't include a caret pointing to the error if
428+ C{offset} is passed as C{None}.
429+ """
430+ err = StringIO()
431+ reporter = Reporter(None, err)
432+ reporter.syntaxError('foo.py', 'a problem', 3, None,
433+ 'bad line of source')
434+ self.assertEquals(
435+ ("foo.py:3: a problem\n"
436+ "bad line of source\n"),
437+ err.getvalue())
438+
439+
440+ def test_multiLineSyntaxError(self):
441+ """
442+ If there's a multi-line syntax error, then we only report the last
443+ line. The offset is adjusted so that it is relative to the start of
444+ the last line.
445+ """
446+ err = StringIO()
447+ lines = [
448+ 'bad line of source',
449+ 'more bad lines of source',
450+ ]
451+ reporter = Reporter(None, err)
452+ reporter.syntaxError('foo.py', 'a problem', 3, len(lines[0]) + 5,
453+ '\n'.join(lines))
454+ self.assertEquals(
455+ ("foo.py:3: a problem\n" +
456+ lines[-1] + "\n" +
457+ " ^\n"),
458+ err.getvalue())
459+
460+
461+ def test_unexpectedError(self):
462+ """
463+ C{unexpectedError} reports an error processing a source file.
464+ """
465+ err = StringIO()
466+ reporter = Reporter(None, err)
467+ reporter.unexpectedError(u'source.py', u'error message')
468+ self.assertEquals(u'source.py: error message\n', err.getvalue())
469+
470+
471+ def test_flake(self):
472+ """
473+ C{flake} reports a code warning from Pyflakes. It is exactly the
474+ str() of a L{pyflakes.messages.Message}.
475+ """
476+ out = StringIO()
477+ reporter = Reporter(out, None)
478+ message = UnusedImport('foo.py', 42, 'bar')
479+ reporter.flake(message)
480+ self.assertEquals(out.getvalue(), "%s\n" % (message,))
481+
482+
483+
484 class CheckTests(TestCase):
485 """
486 Tests for L{check} and L{checkPath} which check a file for flakes.
487 """
488+
489+ def makeTempFile(self, content):
490+ """
491+ Make a temporary file containing C{content} and return a path to it.
492+ """
493+ path = FilePath(self.mktemp())
494+ path.setContent(content)
495+ return path.path
496+
497+
498+ def assertHasErrors(self, path, errorList):
499+ """
500+ Assert that C{path} causes errors.
501+
502+ @param path: A path to a file to check.
503+ @param errorList: A list of errors expected to be printed to stderr.
504+ """
505+ err = StringIO()
506+ count = withStderrTo(err, checkPath, path)
507+ self.assertEquals(
508+ (count, err.getvalue()), (len(errorList), ''.join(errorList)))
509+
510+
511+ def getErrors(self, path):
512+ """
513+ Get any warnings or errors reported by pyflakes for the file at C{path}.
514+
515+ @param path: The path to a Python file on disk that pyflakes will check.
516+ @return: C{(count, log)}, where C{count} is the number of warnings or
517+ errors generated, and log is a list of those warnings, presented
518+ as structured data. See L{LoggingReporter} for more details.
519+ """
520+ log = []
521+ reporter = LoggingReporter(log)
522+ count = checkPath(path, reporter)
523+ return count, log
524+
525+
526 def test_missingTrailingNewline(self):
527 """
528 Source which doesn't end with a newline shouldn't cause any
529 exception to be raised nor an error indicator to be returned by
530 L{check}.
531 """
532- fName = self.mktemp()
533- FilePath(fName).setContent("def foo():\n\tpass\n\t")
534- self.assertFalse(checkPath(fName))
535+ fName = self.makeTempFile("def foo():\n\tpass\n\t")
536+ self.assertHasErrors(fName, [])
537
538
539 def test_checkPathNonExisting(self):
540 """
541 L{checkPath} handles non-existing files.
542 """
543- err = StringIO()
544- count = withStderrTo(err, lambda: checkPath('extremo'))
545- self.assertEquals(err.getvalue(), 'extremo: No such file or directory\n')
546+ count, errors = self.getErrors('extremo')
547 self.assertEquals(count, 1)
548+ self.assertEquals(
549+ errors,
550+ [('unexpectedError', 'extremo', 'No such file or directory')])
551
552
553 def test_multilineSyntaxError(self):
554@@ -72,19 +317,14 @@
555 exc = self.assertRaises(SyntaxError, evaluate, source)
556 self.assertTrue(exc.text.count('\n') > 1)
557
558- sourcePath = FilePath(self.mktemp())
559- sourcePath.setContent(source)
560- err = StringIO()
561- count = withStderrTo(err, lambda: checkPath(sourcePath.path))
562- self.assertEqual(count, 1)
563-
564- self.assertEqual(
565- err.getvalue(),
566- """\
567+ sourcePath = self.makeTempFile(source)
568+ self.assertHasErrors(
569+ sourcePath, ["""\
570 %s:8: invalid syntax
571 '''quux'''
572 ^
573-""" % (sourcePath.path,))
574+"""
575+ % (sourcePath,)])
576
577
578 def test_eofSyntaxError(self):
579@@ -92,19 +332,14 @@
580 The error reported for source files which end prematurely causing a
581 syntax error reflects the cause for the syntax error.
582 """
583- source = "def foo("
584- sourcePath = FilePath(self.mktemp())
585- sourcePath.setContent(source)
586- err = StringIO()
587- count = withStderrTo(err, lambda: checkPath(sourcePath.path))
588- self.assertEqual(count, 1)
589- self.assertEqual(
590- err.getvalue(),
591- """\
592+ sourcePath = self.makeTempFile("def foo(")
593+ self.assertHasErrors(
594+ sourcePath,
595+ ["""\
596 %s:1: unexpected EOF while parsing
597 def foo(
598 ^
599-""" % (sourcePath.path,))
600+""" % (sourcePath,)])
601
602
603 def test_nonDefaultFollowsDefaultSyntaxError(self):
604@@ -117,17 +352,13 @@
605 def foo(bar=baz, bax):
606 pass
607 """
608- sourcePath = FilePath(self.mktemp())
609- sourcePath.setContent(source)
610- err = StringIO()
611- count = withStderrTo(err, lambda: checkPath(sourcePath.path))
612- self.assertEqual(count, 1)
613- self.assertEqual(
614- err.getvalue(),
615- """\
616+ sourcePath = self.makeTempFile(source)
617+ self.assertHasErrors(
618+ sourcePath,
619+ ["""\
620 %s:1: non-default argument follows default argument
621 def foo(bar=baz, bax):
622-""" % (sourcePath.path,))
623+""" % (sourcePath,)])
624
625
626 def test_nonKeywordAfterKeywordSyntaxError(self):
627@@ -139,32 +370,40 @@
628 source = """\
629 foo(bar=baz, bax)
630 """
631- sourcePath = FilePath(self.mktemp())
632- sourcePath.setContent(source)
633- err = StringIO()
634- count = withStderrTo(err, lambda: checkPath(sourcePath.path))
635- self.assertEqual(count, 1)
636- self.assertEqual(
637- err.getvalue(),
638- """\
639+ sourcePath = self.makeTempFile(source)
640+ self.assertHasErrors(
641+ sourcePath,
642+ ["""\
643 %s:1: non-keyword arg after keyword arg
644 foo(bar=baz, bax)
645-""" % (sourcePath.path,))
646+""" % (sourcePath,)])
647
648
649 def test_permissionDenied(self):
650 """
651- If the a source file is not readable, this is reported on standard
652+ If the source file is not readable, this is reported on standard
653 error.
654 """
655 sourcePath = FilePath(self.mktemp())
656 sourcePath.setContent('')
657 sourcePath.chmod(0)
658- err = StringIO()
659- count = withStderrTo(err, lambda: checkPath(sourcePath.path))
660- self.assertEquals(count, 1)
661- self.assertEquals(
662- err.getvalue(), "%s: Permission denied\n" % (sourcePath.path,))
663+ count, errors = self.getErrors(sourcePath.path)
664+ self.assertEquals(count, 1)
665+ self.assertEquals(
666+ errors,
667+ [('unexpectedError', sourcePath.path, "Permission denied")])
668+
669+
670+ def test_pyflakesWarning(self):
671+ """
672+ If the source file has a pyflakes warning, this is reported as a
673+ 'flake'.
674+ """
675+ sourcePath = self.makeTempFile("import foo")
676+ count, errors = self.getErrors(sourcePath)
677+ self.assertEquals(count, 1)
678+ self.assertEquals(
679+ errors, [('flake', str(UnusedImport(sourcePath, 1, 'foo')))])
680
681
682 def test_misencodedFile(self):
683@@ -176,10 +415,149 @@
684 # coding: ascii
685 x = "\N{SNOWMAN}"
686 """.encode('utf-8')
687- sourcePath = FilePath(self.mktemp())
688- sourcePath.setContent(source)
689- err = StringIO()
690- count = withStderrTo(err, lambda: checkPath(sourcePath.path))
691- self.assertEquals(count, 1)
692- self.assertEquals(
693- err.getvalue(), "%s: problem decoding source\n" % (sourcePath.path,))
694+ sourcePath = self.makeTempFile(source)
695+ self.assertHasErrors(
696+ sourcePath, ["%s: problem decoding source\n" % (sourcePath,)])
697+
698+
699+ def test_checkRecursive(self):
700+ """
701+ L{checkRecursive} descends into each directory, finding Python files
702+ and reporting problems.
703+ """
704+ tempdir = FilePath(self.mktemp())
705+ tempdir.createDirectory()
706+ tempdir.child('foo').createDirectory()
707+ file1 = tempdir.child('foo').child('bar.py')
708+ file1.setContent("import baz\n")
709+ file2 = tempdir.child('baz.py')
710+ file2.setContent("import contraband")
711+ log = []
712+ reporter = LoggingReporter(log)
713+ warnings = checkRecursive([tempdir.path], reporter)
714+ self.assertEqual(warnings, 2)
715+ self.assertEqual(
716+ sorted(log),
717+ sorted([('flake', str(UnusedImport(file1.path, 1, 'baz'))),
718+ ('flake',
719+ str(UnusedImport(file2.path, 1, 'contraband')))]))
720+
721+
722+
723+class _EverythingGetterWithStdin(protocol.ProcessProtocol):
724+ """
725+ C{ProcessProtocol} that writes to stdin and gathers exit code, stdout and
726+ stderr.
727+ """
728+
729+ # Although Twisted provides a helper that spawns a subprocess, gathers its
730+ # exit code, standard output and standard error
731+ # (i.e. getProcessOutputAndValue), it does *not* provide one that data to
732+ # be written to stdin first.
733+
734+ def __init__(self, deferred, stdin):
735+ self.deferred = deferred
736+ self.outBuf = StringIO()
737+ self.errBuf = StringIO()
738+ self.outReceived = self.outBuf.write
739+ self.errReceived = self.errBuf.write
740+ self.stdin = stdin
741+
742+
743+ def connectionMade(self):
744+ self.transport.write(self.stdin)
745+ self.transport.closeStdin()
746+
747+
748+ def processEnded(self, reason):
749+ out = self.outBuf.getvalue()
750+ err = self.errBuf.getvalue()
751+ e = reason.value
752+ code = e.exitCode
753+ if e.signal:
754+ code = -e.signal
755+ self.deferred.callback((out, err, code))
756+
757+
758+
759+class IntegrationTests(TestCase):
760+ """
761+ Tests of the pyflakes script that actually spawn the script.
762+ """
763+
764+ def getPyflakesBinary(self):
765+ """
766+ Return the path to the pyflakes binary.
767+ """
768+ import pyflakes
769+ package_dir = FilePath(pyflakes.__file__).parent()
770+ return package_dir.sibling('bin').child('pyflakes').path
771+
772+
773+ def runPyflakes(self, paths, stdin=None):
774+ """
775+ Launch a subprocess running C{pyflakes}.
776+
777+ @param args: Command-line arguments to pass to pyflakes.
778+ @param kwargs: Options passed on to C{subprocess.Popen}.
779+ @return: C{(returncode, stdout, stderr)} of the completed pyflakes
780+ process.
781+ """
782+ env = dict(os.environ)
783+ env['PYTHONPATH'] = os.pathsep.join(sys.path)
784+ command = [self.getPyflakesBinary()]
785+ command.extend(paths)
786+ if stdin:
787+ d = _callProtocolWithDeferred(
788+ lambda d: _EverythingGetterWithStdin(d, stdin),
789+ sys.executable, command, env=env, path=None)
790+ else:
791+ d = getProcessOutputAndValue(sys.executable, command, env=env)
792+ return d
793+
794+
795+ def test_goodFile(self):
796+ """
797+ When a Python source file is all good, the return code is zero and no
798+ messages are printed to either stdout or stderr.
799+ """
800+ tempfile = FilePath(self.mktemp())
801+ tempfile.touch()
802+ d = self.runPyflakes([tempfile.path])
803+ return d.addCallback(self.assertEqual, ('', '', 0))
804+
805+
806+ def test_fileWithFlakes(self):
807+ """
808+ When a Python source file has warnings, the return code is non-zero
809+ and the warnings are printed to stdout.
810+ """
811+ tempfile = FilePath(self.mktemp())
812+ tempfile.setContent("import contraband\n")
813+ d = self.runPyflakes([tempfile.path])
814+ return d.addCallback(
815+ self.assertEqual,
816+ ("%s\n" % UnusedImport(tempfile.path, 1, 'contraband'), '', 1))
817+
818+
819+ def test_errors(self):
820+ """
821+ When pyflakes finds errors with the files it's given, (if they don't
822+ exist, say), then the return code is non-zero and the errors are
823+ printed to stderr.
824+ """
825+ tempfile = FilePath(self.mktemp())
826+ d = self.runPyflakes([tempfile.path])
827+ return d.addCallback(
828+ self.assertEqual,
829+ ('', '%s: No such file or directory\n' % (tempfile.path,), 1))
830+
831+
832+ def test_readFromStdin(self):
833+ """
834+ If no arguments are passed to C{pyflakes} then it reads from stdin.
835+ """
836+ d = self.runPyflakes([], stdin='import contraband')
837+ return d.addCallback(
838+ self.assertEqual,
839+ ("%s\n" % UnusedImport('<stdin>', 1, 'contraband'), '', 1))

Subscribers

People subscribed via source and target branches

to all changes: