Merge lp:~jml/divmod.org/pyflakes-reporter into lp:divmod.org
- pyflakes-reporter
- Merge into trunk
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 |
Related bugs: |
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.
Commit message
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.
Glyph Lefkowitz (glyph) wrote : Posted in a previous version of this proposal | # |
Jonathan Jacobs (jjacobs) wrote : Posted in a previous version of this proposal | # |
I, too, like this idea. I'd love to see it merged.
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
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, problemDecoding
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.
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.
- 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 getProcessOutpu
tAndValue' s return - 2720. By Jonathan Lange
-
Remove unnecessary parens
- 2721. By Jonathan Lange
-
Explain what's going on.
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).
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.
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)?
Jean-Paul Calderone (exarkun) wrote : | # |
Oh. Another new comment. `mod_reporter` doesn't conform to the naming convention and `__import__` is obsoleted by `__future_
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_
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
- 2722. By Jonathan Lange
-
New method for errors.
- 2723. By Jonathan Lange
-
Collapse problemDecoding
Source 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.
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_
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 problemDecoding
I think we're good to merge now.
- 2730. By Jonathan Lange
-
Wrap types in C{}
Glyph Lefkowitz (glyph) wrote : | # |
This change looks good to me. Thanks, Jonathan.
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?
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
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)) |
For what it's worth, seems like a good idea to me.
Tests, please, though.