]>
Commit | Line | Data |
---|---|---|
53e6db90 DC |
1 | """ |
2 | Tests for L{pyflakes.scripts.pyflakes}. | |
3 | """ | |
4 | ||
5 | import contextlib | |
6 | import io | |
7 | import os | |
8 | import sys | |
9 | import shutil | |
10 | import subprocess | |
11 | import tempfile | |
12 | ||
13 | from pyflakes.checker import PYPY | |
14 | from pyflakes.messages import UnusedImport | |
15 | from pyflakes.reporter import Reporter | |
16 | from pyflakes.api import ( | |
17 | main, | |
18 | check, | |
19 | checkPath, | |
20 | checkRecursive, | |
21 | iterSourceCode, | |
22 | ) | |
23 | from pyflakes.test.harness import TestCase, skipIf | |
24 | ||
25 | ||
26 | def withStderrTo(stderr, f, *args, **kwargs): | |
27 | """ | |
28 | Call C{f} with C{sys.stderr} redirected to C{stderr}. | |
29 | """ | |
30 | (outer, sys.stderr) = (sys.stderr, stderr) | |
31 | try: | |
32 | return f(*args, **kwargs) | |
33 | finally: | |
34 | sys.stderr = outer | |
35 | ||
36 | ||
37 | class Node: | |
38 | """ | |
39 | Mock an AST node. | |
40 | """ | |
41 | def __init__(self, lineno, col_offset=0): | |
42 | self.lineno = lineno | |
43 | self.col_offset = col_offset | |
44 | ||
45 | ||
46 | class SysStreamCapturing: | |
47 | """Context manager capturing sys.stdin, sys.stdout and sys.stderr. | |
48 | ||
49 | The file handles are replaced with a StringIO object. | |
50 | """ | |
51 | ||
52 | def __init__(self, stdin): | |
53 | self._stdin = io.StringIO(stdin or '', newline=os.linesep) | |
54 | ||
55 | def __enter__(self): | |
56 | self._orig_stdin = sys.stdin | |
57 | self._orig_stdout = sys.stdout | |
58 | self._orig_stderr = sys.stderr | |
59 | ||
60 | sys.stdin = self._stdin | |
61 | sys.stdout = self._stdout_stringio = io.StringIO(newline=os.linesep) | |
62 | sys.stderr = self._stderr_stringio = io.StringIO(newline=os.linesep) | |
63 | ||
64 | return self | |
65 | ||
66 | def __exit__(self, *args): | |
67 | self.output = self._stdout_stringio.getvalue() | |
68 | self.error = self._stderr_stringio.getvalue() | |
69 | ||
70 | sys.stdin = self._orig_stdin | |
71 | sys.stdout = self._orig_stdout | |
72 | sys.stderr = self._orig_stderr | |
73 | ||
74 | ||
75 | class LoggingReporter: | |
76 | """ | |
77 | Implementation of Reporter that just appends any error to a list. | |
78 | """ | |
79 | ||
80 | def __init__(self, log): | |
81 | """ | |
82 | Construct a C{LoggingReporter}. | |
83 | ||
84 | @param log: A list to append log messages to. | |
85 | """ | |
86 | self.log = log | |
87 | ||
88 | def flake(self, message): | |
89 | self.log.append(('flake', str(message))) | |
90 | ||
91 | def unexpectedError(self, filename, message): | |
92 | self.log.append(('unexpectedError', filename, message)) | |
93 | ||
94 | def syntaxError(self, filename, msg, lineno, offset, line): | |
95 | self.log.append(('syntaxError', filename, msg, lineno, offset, line)) | |
96 | ||
97 | ||
98 | class TestIterSourceCode(TestCase): | |
99 | """ | |
100 | Tests for L{iterSourceCode}. | |
101 | """ | |
102 | ||
103 | def setUp(self): | |
104 | self.tempdir = tempfile.mkdtemp() | |
105 | ||
106 | def tearDown(self): | |
107 | shutil.rmtree(self.tempdir) | |
108 | ||
109 | def makeEmptyFile(self, *parts): | |
110 | assert parts | |
111 | fpath = os.path.join(self.tempdir, *parts) | |
112 | open(fpath, 'a').close() | |
113 | return fpath | |
114 | ||
115 | def test_emptyDirectory(self): | |
116 | """ | |
117 | There are no Python files in an empty directory. | |
118 | """ | |
119 | self.assertEqual(list(iterSourceCode([self.tempdir])), []) | |
120 | ||
121 | def test_singleFile(self): | |
122 | """ | |
123 | If the directory contains one Python file, C{iterSourceCode} will find | |
124 | it. | |
125 | """ | |
126 | childpath = self.makeEmptyFile('foo.py') | |
127 | self.assertEqual(list(iterSourceCode([self.tempdir])), [childpath]) | |
128 | ||
129 | def test_onlyPythonSource(self): | |
130 | """ | |
131 | Files that are not Python source files are not included. | |
132 | """ | |
133 | self.makeEmptyFile('foo.pyc') | |
134 | self.assertEqual(list(iterSourceCode([self.tempdir])), []) | |
135 | ||
136 | def test_recurses(self): | |
137 | """ | |
138 | If the Python files are hidden deep down in child directories, we will | |
139 | find them. | |
140 | """ | |
141 | os.mkdir(os.path.join(self.tempdir, 'foo')) | |
142 | apath = self.makeEmptyFile('foo', 'a.py') | |
143 | self.makeEmptyFile('foo', 'a.py~') | |
144 | os.mkdir(os.path.join(self.tempdir, 'bar')) | |
145 | bpath = self.makeEmptyFile('bar', 'b.py') | |
146 | cpath = self.makeEmptyFile('c.py') | |
147 | self.assertEqual( | |
148 | sorted(iterSourceCode([self.tempdir])), | |
149 | sorted([apath, bpath, cpath])) | |
150 | ||
151 | def test_shebang(self): | |
152 | """ | |
153 | Find Python files that don't end with `.py`, but contain a Python | |
154 | shebang. | |
155 | """ | |
156 | python = os.path.join(self.tempdir, 'a') | |
157 | with open(python, 'w') as fd: | |
158 | fd.write('#!/usr/bin/env python\n') | |
159 | ||
160 | self.makeEmptyFile('b') | |
161 | ||
162 | with open(os.path.join(self.tempdir, 'c'), 'w') as fd: | |
163 | fd.write('hello\nworld\n') | |
164 | ||
165 | python3 = os.path.join(self.tempdir, 'e') | |
166 | with open(python3, 'w') as fd: | |
167 | fd.write('#!/usr/bin/env python3\n') | |
168 | ||
169 | pythonw = os.path.join(self.tempdir, 'f') | |
170 | with open(pythonw, 'w') as fd: | |
171 | fd.write('#!/usr/bin/env pythonw\n') | |
172 | ||
173 | python3args = os.path.join(self.tempdir, 'g') | |
174 | with open(python3args, 'w') as fd: | |
175 | fd.write('#!/usr/bin/python3 -u\n') | |
176 | ||
177 | python3d = os.path.join(self.tempdir, 'i') | |
178 | with open(python3d, 'w') as fd: | |
179 | fd.write('#!/usr/local/bin/python3d\n') | |
180 | ||
181 | python38m = os.path.join(self.tempdir, 'j') | |
182 | with open(python38m, 'w') as fd: | |
183 | fd.write('#! /usr/bin/env python3.8m\n') | |
184 | ||
185 | # Should NOT be treated as Python source | |
186 | notfirst = os.path.join(self.tempdir, 'l') | |
187 | with open(notfirst, 'w') as fd: | |
188 | fd.write('#!/bin/sh\n#!/usr/bin/python\n') | |
189 | ||
190 | self.assertEqual( | |
191 | sorted(iterSourceCode([self.tempdir])), | |
192 | sorted([ | |
193 | python, python3, pythonw, python3args, python3d, | |
194 | python38m, | |
195 | ])) | |
196 | ||
197 | def test_multipleDirectories(self): | |
198 | """ | |
199 | L{iterSourceCode} can be given multiple directories. It will recurse | |
200 | into each of them. | |
201 | """ | |
202 | foopath = os.path.join(self.tempdir, 'foo') | |
203 | barpath = os.path.join(self.tempdir, 'bar') | |
204 | os.mkdir(foopath) | |
205 | apath = self.makeEmptyFile('foo', 'a.py') | |
206 | os.mkdir(barpath) | |
207 | bpath = self.makeEmptyFile('bar', 'b.py') | |
208 | self.assertEqual( | |
209 | sorted(iterSourceCode([foopath, barpath])), | |
210 | sorted([apath, bpath])) | |
211 | ||
212 | def test_explicitFiles(self): | |
213 | """ | |
214 | If one of the paths given to L{iterSourceCode} is not a directory but | |
215 | a file, it will include that in its output. | |
216 | """ | |
217 | epath = self.makeEmptyFile('e.py') | |
218 | self.assertEqual(list(iterSourceCode([epath])), | |
219 | [epath]) | |
220 | ||
221 | ||
222 | class TestReporter(TestCase): | |
223 | """ | |
224 | Tests for L{Reporter}. | |
225 | """ | |
226 | ||
227 | def test_syntaxError(self): | |
228 | """ | |
229 | C{syntaxError} reports that there was a syntax error in the source | |
230 | file. It reports to the error stream and includes the filename, line | |
231 | number, error message, actual line of source and a caret pointing to | |
232 | where the error is. | |
233 | """ | |
234 | err = io.StringIO() | |
235 | reporter = Reporter(None, err) | |
236 | reporter.syntaxError('foo.py', 'a problem', 3, 8, 'bad line of source') | |
237 | self.assertEqual( | |
238 | ("foo.py:3:8: a problem\n" | |
239 | "bad line of source\n" | |
240 | " ^\n"), | |
241 | err.getvalue()) | |
242 | ||
243 | def test_syntaxErrorNoOffset(self): | |
244 | """ | |
245 | C{syntaxError} doesn't include a caret pointing to the error if | |
246 | C{offset} is passed as C{None}. | |
247 | """ | |
248 | err = io.StringIO() | |
249 | reporter = Reporter(None, err) | |
250 | reporter.syntaxError('foo.py', 'a problem', 3, None, | |
251 | 'bad line of source') | |
252 | self.assertEqual( | |
253 | ("foo.py:3: a problem\n" | |
254 | "bad line of source\n"), | |
255 | err.getvalue()) | |
256 | ||
257 | def test_syntaxErrorNoText(self): | |
258 | """ | |
259 | C{syntaxError} doesn't include text or nonsensical offsets if C{text} is C{None}. | |
260 | ||
261 | This typically happens when reporting syntax errors from stdin. | |
262 | """ | |
263 | err = io.StringIO() | |
264 | reporter = Reporter(None, err) | |
265 | reporter.syntaxError('<stdin>', 'a problem', 0, 0, None) | |
266 | self.assertEqual(("<stdin>:1:1: a problem\n"), err.getvalue()) | |
267 | ||
268 | def test_multiLineSyntaxError(self): | |
269 | """ | |
270 | If there's a multi-line syntax error, then we only report the last | |
271 | line. The offset is adjusted so that it is relative to the start of | |
272 | the last line. | |
273 | """ | |
274 | err = io.StringIO() | |
275 | lines = [ | |
276 | 'bad line of source', | |
277 | 'more bad lines of source', | |
278 | ] | |
279 | reporter = Reporter(None, err) | |
280 | reporter.syntaxError('foo.py', 'a problem', 3, len(lines[0]) + 7, | |
281 | '\n'.join(lines)) | |
282 | self.assertEqual( | |
283 | ("foo.py:3:25: a problem\n" + | |
284 | lines[-1] + "\n" + | |
285 | " " * 24 + "^\n"), | |
286 | err.getvalue()) | |
287 | ||
288 | def test_unexpectedError(self): | |
289 | """ | |
290 | C{unexpectedError} reports an error processing a source file. | |
291 | """ | |
292 | err = io.StringIO() | |
293 | reporter = Reporter(None, err) | |
294 | reporter.unexpectedError('source.py', 'error message') | |
295 | self.assertEqual('source.py: error message\n', err.getvalue()) | |
296 | ||
297 | def test_flake(self): | |
298 | """ | |
299 | C{flake} reports a code warning from Pyflakes. It is exactly the | |
300 | str() of a L{pyflakes.messages.Message}. | |
301 | """ | |
302 | out = io.StringIO() | |
303 | reporter = Reporter(out, None) | |
304 | message = UnusedImport('foo.py', Node(42), 'bar') | |
305 | reporter.flake(message) | |
306 | self.assertEqual(out.getvalue(), f"{message}\n") | |
307 | ||
308 | ||
309 | class CheckTests(TestCase): | |
310 | """ | |
311 | Tests for L{check} and L{checkPath} which check a file for flakes. | |
312 | """ | |
313 | ||
314 | @contextlib.contextmanager | |
315 | def makeTempFile(self, content): | |
316 | """ | |
317 | Make a temporary file containing C{content} and return a path to it. | |
318 | """ | |
319 | fd, name = tempfile.mkstemp() | |
320 | try: | |
321 | with os.fdopen(fd, 'wb') as f: | |
322 | if not hasattr(content, 'decode'): | |
323 | content = content.encode('ascii') | |
324 | f.write(content) | |
325 | yield name | |
326 | finally: | |
327 | os.remove(name) | |
328 | ||
329 | def assertHasErrors(self, path, errorList): | |
330 | """ | |
331 | Assert that C{path} causes errors. | |
332 | ||
333 | @param path: A path to a file to check. | |
334 | @param errorList: A list of errors expected to be printed to stderr. | |
335 | """ | |
336 | err = io.StringIO() | |
337 | count = withStderrTo(err, checkPath, path) | |
338 | self.assertEqual( | |
339 | (count, err.getvalue()), (len(errorList), ''.join(errorList))) | |
340 | ||
341 | def getErrors(self, path): | |
342 | """ | |
343 | Get any warnings or errors reported by pyflakes for the file at C{path}. | |
344 | ||
345 | @param path: The path to a Python file on disk that pyflakes will check. | |
346 | @return: C{(count, log)}, where C{count} is the number of warnings or | |
347 | errors generated, and log is a list of those warnings, presented | |
348 | as structured data. See L{LoggingReporter} for more details. | |
349 | """ | |
350 | log = [] | |
351 | reporter = LoggingReporter(log) | |
352 | count = checkPath(path, reporter) | |
353 | return count, log | |
354 | ||
355 | def test_legacyScript(self): | |
356 | from pyflakes.scripts import pyflakes as script_pyflakes | |
357 | self.assertIs(script_pyflakes.checkPath, checkPath) | |
358 | ||
359 | def test_missingTrailingNewline(self): | |
360 | """ | |
361 | Source which doesn't end with a newline shouldn't cause any | |
362 | exception to be raised nor an error indicator to be returned by | |
363 | L{check}. | |
364 | """ | |
365 | with self.makeTempFile("def foo():\n\tpass\n\t") as fName: | |
366 | self.assertHasErrors(fName, []) | |
367 | ||
368 | def test_checkPathNonExisting(self): | |
369 | """ | |
370 | L{checkPath} handles non-existing files. | |
371 | """ | |
372 | count, errors = self.getErrors('extremo') | |
373 | self.assertEqual(count, 1) | |
374 | self.assertEqual( | |
375 | errors, | |
376 | [('unexpectedError', 'extremo', 'No such file or directory')]) | |
377 | ||
378 | def test_multilineSyntaxError(self): | |
379 | """ | |
380 | Source which includes a syntax error which results in the raised | |
381 | L{SyntaxError.text} containing multiple lines of source are reported | |
382 | with only the last line of that source. | |
383 | """ | |
384 | source = """\ | |
385 | def foo(): | |
386 | ''' | |
387 | ||
388 | def bar(): | |
389 | pass | |
390 | ||
391 | def baz(): | |
392 | '''quux''' | |
393 | """ | |
394 | ||
395 | # Sanity check - SyntaxError.text should be multiple lines, if it | |
396 | # isn't, something this test was unprepared for has happened. | |
397 | def evaluate(source): | |
398 | exec(source) | |
399 | try: | |
400 | evaluate(source) | |
401 | except SyntaxError as e: | |
402 | if not PYPY and sys.version_info < (3, 10): | |
403 | self.assertTrue(e.text.count('\n') > 1) | |
404 | else: | |
405 | self.fail() | |
406 | ||
407 | with self.makeTempFile(source) as sourcePath: | |
408 | if PYPY: | |
409 | message = 'end of file (EOF) while scanning triple-quoted string literal' | |
410 | elif sys.version_info >= (3, 10): | |
411 | message = 'unterminated triple-quoted string literal (detected at line 8)' # noqa: E501 | |
412 | else: | |
413 | message = 'invalid syntax' | |
414 | ||
415 | if PYPY or sys.version_info >= (3, 10): | |
416 | column = 12 | |
417 | else: | |
418 | column = 8 | |
419 | self.assertHasErrors( | |
420 | sourcePath, | |
421 | ["""\ | |
422 | %s:8:%d: %s | |
423 | '''quux''' | |
424 | %s^ | |
425 | """ % (sourcePath, column, message, ' ' * (column - 1))]) | |
426 | ||
427 | def test_eofSyntaxError(self): | |
428 | """ | |
429 | The error reported for source files which end prematurely causing a | |
430 | syntax error reflects the cause for the syntax error. | |
431 | """ | |
432 | with self.makeTempFile("def foo(") as sourcePath: | |
433 | if PYPY: | |
434 | msg = 'parenthesis is never closed' | |
435 | elif sys.version_info >= (3, 10): | |
436 | msg = "'(' was never closed" | |
437 | else: | |
438 | msg = 'unexpected EOF while parsing' | |
439 | ||
440 | if PYPY or sys.version_info >= (3, 10): | |
441 | column = 8 | |
442 | else: | |
443 | column = 9 | |
444 | ||
445 | spaces = ' ' * (column - 1) | |
446 | expected = '{}:1:{}: {}\ndef foo(\n{}^\n'.format( | |
447 | sourcePath, column, msg, spaces | |
448 | ) | |
449 | ||
450 | self.assertHasErrors(sourcePath, [expected]) | |
451 | ||
452 | def test_eofSyntaxErrorWithTab(self): | |
453 | """ | |
454 | The error reported for source files which end prematurely causing a | |
455 | syntax error reflects the cause for the syntax error. | |
456 | """ | |
457 | with self.makeTempFile("if True:\n\tfoo =") as sourcePath: | |
458 | self.assertHasErrors( | |
459 | sourcePath, | |
460 | [f"""\ | |
461 | {sourcePath}:2:7: invalid syntax | |
462 | \tfoo = | |
463 | \t ^ | |
464 | """]) | |
465 | ||
466 | def test_nonDefaultFollowsDefaultSyntaxError(self): | |
467 | """ | |
468 | Source which has a non-default argument following a default argument | |
469 | should include the line number of the syntax error. However these | |
470 | exceptions do not include an offset. | |
471 | """ | |
472 | source = """\ | |
473 | def foo(bar=baz, bax): | |
474 | pass | |
475 | """ | |
476 | with self.makeTempFile(source) as sourcePath: | |
477 | if sys.version_info >= (3, 12): | |
478 | msg = 'parameter without a default follows parameter with a default' # noqa: E501 | |
479 | else: | |
480 | msg = 'non-default argument follows default argument' | |
481 | ||
482 | if PYPY and sys.version_info >= (3, 9): | |
483 | column = 18 | |
484 | elif PYPY: | |
485 | column = 8 | |
486 | elif sys.version_info >= (3, 10): | |
487 | column = 18 | |
488 | elif sys.version_info >= (3, 9): | |
489 | column = 21 | |
490 | else: | |
491 | column = 9 | |
492 | last_line = ' ' * (column - 1) + '^\n' | |
493 | self.assertHasErrors( | |
494 | sourcePath, | |
495 | [f"""\ | |
496 | {sourcePath}:1:{column}: {msg} | |
497 | def foo(bar=baz, bax): | |
498 | {last_line}"""] | |
499 | ) | |
500 | ||
501 | def test_nonKeywordAfterKeywordSyntaxError(self): | |
502 | """ | |
503 | Source which has a non-keyword argument after a keyword argument should | |
504 | include the line number of the syntax error. However these exceptions | |
505 | do not include an offset. | |
506 | """ | |
507 | source = """\ | |
508 | foo(bar=baz, bax) | |
509 | """ | |
510 | with self.makeTempFile(source) as sourcePath: | |
511 | if sys.version_info >= (3, 9): | |
512 | column = 17 | |
513 | elif not PYPY: | |
514 | column = 14 | |
515 | else: | |
516 | column = 13 | |
517 | last_line = ' ' * (column - 1) + '^\n' | |
518 | columnstr = '%d:' % column | |
519 | ||
520 | message = 'positional argument follows keyword argument' | |
521 | ||
522 | self.assertHasErrors( | |
523 | sourcePath, | |
524 | ["""\ | |
525 | {}:1:{} {} | |
526 | foo(bar=baz, bax) | |
527 | {}""".format(sourcePath, columnstr, message, last_line)]) | |
528 | ||
529 | def test_invalidEscape(self): | |
530 | """ | |
531 | The invalid escape syntax raises ValueError in Python 2 | |
532 | """ | |
533 | # ValueError: invalid \x escape | |
534 | with self.makeTempFile(r"foo = '\xyz'") as sourcePath: | |
535 | position_end = 1 | |
536 | if PYPY and sys.version_info >= (3, 9): | |
537 | column = 7 | |
538 | elif PYPY: | |
539 | column = 6 | |
540 | elif (3, 9) <= sys.version_info < (3, 12): | |
541 | column = 13 | |
542 | else: | |
543 | column = 7 | |
544 | ||
545 | last_line = '%s^\n' % (' ' * (column - 1)) | |
546 | ||
547 | decoding_error = """\ | |
548 | %s:1:%d: (unicode error) 'unicodeescape' codec can't decode bytes \ | |
549 | in position 0-%d: truncated \\xXX escape | |
550 | foo = '\\xyz' | |
551 | %s""" % (sourcePath, column, position_end, last_line) | |
552 | ||
553 | self.assertHasErrors( | |
554 | sourcePath, [decoding_error]) | |
555 | ||
556 | @skipIf(sys.platform == 'win32', 'unsupported on Windows') | |
557 | def test_permissionDenied(self): | |
558 | """ | |
559 | If the source file is not readable, this is reported on standard | |
560 | error. | |
561 | """ | |
562 | if os.getuid() == 0: | |
563 | self.skipTest('root user can access all files regardless of ' | |
564 | 'permissions') | |
565 | with self.makeTempFile('') as sourcePath: | |
566 | os.chmod(sourcePath, 0) | |
567 | count, errors = self.getErrors(sourcePath) | |
568 | self.assertEqual(count, 1) | |
569 | self.assertEqual( | |
570 | errors, | |
571 | [('unexpectedError', sourcePath, "Permission denied")]) | |
572 | ||
573 | def test_pyflakesWarning(self): | |
574 | """ | |
575 | If the source file has a pyflakes warning, this is reported as a | |
576 | 'flake'. | |
577 | """ | |
578 | with self.makeTempFile("import foo") as sourcePath: | |
579 | count, errors = self.getErrors(sourcePath) | |
580 | self.assertEqual(count, 1) | |
581 | self.assertEqual( | |
582 | errors, [('flake', str(UnusedImport(sourcePath, Node(1), 'foo')))]) | |
583 | ||
584 | def test_encodedFileUTF8(self): | |
585 | """ | |
586 | If source file declares the correct encoding, no error is reported. | |
587 | """ | |
588 | SNOWMAN = chr(0x2603) | |
589 | source = ("""\ | |
590 | # coding: utf-8 | |
591 | x = "%s" | |
592 | """ % SNOWMAN).encode('utf-8') | |
593 | with self.makeTempFile(source) as sourcePath: | |
594 | self.assertHasErrors(sourcePath, []) | |
595 | ||
596 | def test_CRLFLineEndings(self): | |
597 | """ | |
598 | Source files with Windows CR LF line endings are parsed successfully. | |
599 | """ | |
600 | with self.makeTempFile("x = 42\r\n") as sourcePath: | |
601 | self.assertHasErrors(sourcePath, []) | |
602 | ||
603 | def test_misencodedFileUTF8(self): | |
604 | """ | |
605 | If a source file contains bytes which cannot be decoded, this is | |
606 | reported on stderr. | |
607 | """ | |
608 | SNOWMAN = chr(0x2603) | |
609 | source = ("""\ | |
610 | # coding: ascii | |
611 | x = "%s" | |
612 | """ % SNOWMAN).encode('utf-8') | |
613 | with self.makeTempFile(source) as sourcePath: | |
614 | self.assertHasErrors( | |
615 | sourcePath, | |
616 | [f"{sourcePath}:1:1: 'ascii' codec can't decode byte 0xe2 in position 21: ordinal not in range(128)\n"]) # noqa: E501 | |
617 | ||
618 | def test_misencodedFileUTF16(self): | |
619 | """ | |
620 | If a source file contains bytes which cannot be decoded, this is | |
621 | reported on stderr. | |
622 | """ | |
623 | SNOWMAN = chr(0x2603) | |
624 | source = ("""\ | |
625 | # coding: ascii | |
626 | x = "%s" | |
627 | """ % SNOWMAN).encode('utf-16') | |
628 | with self.makeTempFile(source) as sourcePath: | |
629 | if sys.version_info < (3, 11, 4): | |
630 | expected = f"{sourcePath}: problem decoding source\n" | |
631 | else: | |
632 | expected = f"{sourcePath}:1: source code string cannot contain null bytes\n" # noqa: E501 | |
633 | ||
634 | self.assertHasErrors(sourcePath, [expected]) | |
635 | ||
636 | def test_checkRecursive(self): | |
637 | """ | |
638 | L{checkRecursive} descends into each directory, finding Python files | |
639 | and reporting problems. | |
640 | """ | |
641 | tempdir = tempfile.mkdtemp() | |
642 | try: | |
643 | os.mkdir(os.path.join(tempdir, 'foo')) | |
644 | file1 = os.path.join(tempdir, 'foo', 'bar.py') | |
645 | with open(file1, 'wb') as fd: | |
646 | fd.write(b"import baz\n") | |
647 | file2 = os.path.join(tempdir, 'baz.py') | |
648 | with open(file2, 'wb') as fd: | |
649 | fd.write(b"import contraband") | |
650 | log = [] | |
651 | reporter = LoggingReporter(log) | |
652 | warnings = checkRecursive([tempdir], reporter) | |
653 | self.assertEqual(warnings, 2) | |
654 | self.assertEqual( | |
655 | sorted(log), | |
656 | sorted([('flake', str(UnusedImport(file1, Node(1), 'baz'))), | |
657 | ('flake', | |
658 | str(UnusedImport(file2, Node(1), 'contraband')))])) | |
659 | finally: | |
660 | shutil.rmtree(tempdir) | |
661 | ||
662 | def test_stdinReportsErrors(self): | |
663 | """ | |
664 | L{check} reports syntax errors from stdin | |
665 | """ | |
666 | source = "max(1 for i in range(10), key=lambda x: x+1)\n" | |
667 | err = io.StringIO() | |
668 | count = withStderrTo(err, check, source, "<stdin>") | |
669 | self.assertEqual(count, 1) | |
670 | errlines = err.getvalue().split("\n")[:-1] | |
671 | ||
672 | if sys.version_info >= (3, 9): | |
673 | expected_error = [ | |
674 | "<stdin>:1:5: Generator expression must be parenthesized", | |
675 | "max(1 for i in range(10), key=lambda x: x+1)", | |
676 | " ^", | |
677 | ] | |
678 | elif PYPY: | |
679 | expected_error = [ | |
680 | "<stdin>:1:4: Generator expression must be parenthesized if not sole argument", # noqa: E501 | |
681 | "max(1 for i in range(10), key=lambda x: x+1)", | |
682 | " ^", | |
683 | ] | |
684 | else: | |
685 | expected_error = [ | |
686 | "<stdin>:1:5: Generator expression must be parenthesized", | |
687 | ] | |
688 | ||
689 | self.assertEqual(errlines, expected_error) | |
690 | ||
691 | ||
692 | class IntegrationTests(TestCase): | |
693 | """ | |
694 | Tests of the pyflakes script that actually spawn the script. | |
695 | """ | |
696 | def setUp(self): | |
697 | self.tempdir = tempfile.mkdtemp() | |
698 | self.tempfilepath = os.path.join(self.tempdir, 'temp') | |
699 | ||
700 | def tearDown(self): | |
701 | shutil.rmtree(self.tempdir) | |
702 | ||
703 | def getPyflakesBinary(self): | |
704 | """ | |
705 | Return the path to the pyflakes binary. | |
706 | """ | |
707 | import pyflakes | |
708 | package_dir = os.path.dirname(pyflakes.__file__) | |
709 | return os.path.join(package_dir, '..', 'bin', 'pyflakes') | |
710 | ||
711 | def runPyflakes(self, paths, stdin=None): | |
712 | """ | |
713 | Launch a subprocess running C{pyflakes}. | |
714 | ||
715 | @param paths: Command-line arguments to pass to pyflakes. | |
716 | @param stdin: Text to use as stdin. | |
717 | @return: C{(returncode, stdout, stderr)} of the completed pyflakes | |
718 | process. | |
719 | """ | |
720 | env = dict(os.environ) | |
721 | env['PYTHONPATH'] = os.pathsep.join(sys.path) | |
722 | command = [sys.executable, self.getPyflakesBinary()] | |
723 | command.extend(paths) | |
724 | if stdin: | |
725 | p = subprocess.Popen(command, env=env, stdin=subprocess.PIPE, | |
726 | stdout=subprocess.PIPE, stderr=subprocess.PIPE) | |
727 | (stdout, stderr) = p.communicate(stdin.encode('ascii')) | |
728 | else: | |
729 | p = subprocess.Popen(command, env=env, | |
730 | stdout=subprocess.PIPE, stderr=subprocess.PIPE) | |
731 | (stdout, stderr) = p.communicate() | |
732 | rv = p.wait() | |
733 | stdout = stdout.decode('utf-8') | |
734 | stderr = stderr.decode('utf-8') | |
735 | return (stdout, stderr, rv) | |
736 | ||
737 | def test_goodFile(self): | |
738 | """ | |
739 | When a Python source file is all good, the return code is zero and no | |
740 | messages are printed to either stdout or stderr. | |
741 | """ | |
742 | open(self.tempfilepath, 'a').close() | |
743 | d = self.runPyflakes([self.tempfilepath]) | |
744 | self.assertEqual(d, ('', '', 0)) | |
745 | ||
746 | def test_fileWithFlakes(self): | |
747 | """ | |
748 | When a Python source file has warnings, the return code is non-zero | |
749 | and the warnings are printed to stdout. | |
750 | """ | |
751 | with open(self.tempfilepath, 'wb') as fd: | |
752 | fd.write(b"import contraband\n") | |
753 | d = self.runPyflakes([self.tempfilepath]) | |
754 | expected = UnusedImport(self.tempfilepath, Node(1), 'contraband') | |
755 | self.assertEqual(d, (f"{expected}{os.linesep}", '', 1)) | |
756 | ||
757 | def test_errors_io(self): | |
758 | """ | |
759 | When pyflakes finds errors with the files it's given, (if they don't | |
760 | exist, say), then the return code is non-zero and the errors are | |
761 | printed to stderr. | |
762 | """ | |
763 | d = self.runPyflakes([self.tempfilepath]) | |
764 | error_msg = '{}: No such file or directory{}'.format(self.tempfilepath, | |
765 | os.linesep) | |
766 | self.assertEqual(d, ('', error_msg, 1)) | |
767 | ||
768 | def test_errors_syntax(self): | |
769 | """ | |
770 | When pyflakes finds errors with the files it's given, (if they don't | |
771 | exist, say), then the return code is non-zero and the errors are | |
772 | printed to stderr. | |
773 | """ | |
774 | with open(self.tempfilepath, 'wb') as fd: | |
775 | fd.write(b"import") | |
776 | d = self.runPyflakes([self.tempfilepath]) | |
777 | error_msg = '{0}:1:7: invalid syntax{1}import{1} ^{1}'.format( | |
778 | self.tempfilepath, os.linesep) | |
779 | self.assertEqual(d, ('', error_msg, 1)) | |
780 | ||
781 | def test_readFromStdin(self): | |
782 | """ | |
783 | If no arguments are passed to C{pyflakes} then it reads from stdin. | |
784 | """ | |
785 | d = self.runPyflakes([], stdin='import contraband') | |
786 | expected = UnusedImport('<stdin>', Node(1), 'contraband') | |
787 | self.assertEqual(d, (f"{expected}{os.linesep}", '', 1)) | |
788 | ||
789 | ||
790 | class TestMain(IntegrationTests): | |
791 | """ | |
792 | Tests of the pyflakes main function. | |
793 | """ | |
794 | def runPyflakes(self, paths, stdin=None): | |
795 | try: | |
796 | with SysStreamCapturing(stdin) as capture: | |
797 | main(args=paths) | |
798 | except SystemExit as e: | |
799 | self.assertIsInstance(e.code, bool) | |
800 | rv = int(e.code) | |
801 | return (capture.output, capture.error, rv) | |
802 | else: | |
803 | raise RuntimeError('SystemExit not raised') |