1"""Testing `tabnanny` module.
2
3Glossary:
4    * errored    : Whitespace related problems present in file.
5"""
6from unittest import TestCase, mock
7from unittest import mock
8import errno
9import os
10import tabnanny
11import tokenize
12import tempfile
13import textwrap
14from test.support import (captured_stderr, captured_stdout, script_helper,
15                          findfile)
16from test.support.os_helper import unlink
17
18
19SOURCE_CODES = {
20    "incomplete_expression": (
21        'fruits = [\n'
22        '    "Apple",\n'
23        '    "Orange",\n'
24        '    "Banana",\n'
25        '\n'
26        'print(fruits)\n'
27    ),
28    "wrong_indented": (
29        'if True:\n'
30        '    print("hello")\n'
31        '  print("world")\n'
32        'else:\n'
33        '    print("else called")\n'
34    ),
35    "nannynag_errored": (
36        'if True:\n'
37        ' \tprint("hello")\n'
38        '\tprint("world")\n'
39        'else:\n'
40        '    print("else called")\n'
41    ),
42    "error_free": (
43        'if True:\n'
44        '    print("hello")\n'
45        '    print("world")\n'
46        'else:\n'
47        '    print("else called")\n'
48    ),
49    "tab_space_errored_1": (
50        'def my_func():\n'
51        '\t  print("hello world")\n'
52        '\t  if True:\n'
53        '\t\tprint("If called")'
54    ),
55    "tab_space_errored_2": (
56        'def my_func():\n'
57        '\t\tprint("Hello world")\n'
58        '\t\tif True:\n'
59        '\t        print("If called")'
60    )
61}
62
63
64class TemporaryPyFile:
65    """Create a temporary python source code file."""
66
67    def __init__(self, source_code='', directory=None):
68        self.source_code = source_code
69        self.dir = directory
70
71    def __enter__(self):
72        with tempfile.NamedTemporaryFile(
73            mode='w', dir=self.dir, suffix=".py", delete=False
74        ) as f:
75            f.write(self.source_code)
76        self.file_path = f.name
77        return self.file_path
78
79    def __exit__(self, exc_type, exc_value, exc_traceback):
80        unlink(self.file_path)
81
82
83class TestFormatWitnesses(TestCase):
84    """Testing `tabnanny.format_witnesses()`."""
85
86    def test_format_witnesses(self):
87        """Asserting formatter result by giving various input samples."""
88        tests = [
89            ('Test', 'at tab sizes T, e, s, t'),
90            ('', 'at tab size '),
91            ('t', 'at tab size t'),
92            ('  t  ', 'at tab sizes  ,  , t,  ,  '),
93        ]
94
95        for words, expected in tests:
96            with self.subTest(words=words, expected=expected):
97                self.assertEqual(tabnanny.format_witnesses(words), expected)
98
99
100class TestErrPrint(TestCase):
101    """Testing `tabnanny.errprint()`."""
102
103    def test_errprint(self):
104        """Asserting result of `tabnanny.errprint()` by giving sample inputs."""
105        tests = [
106            (['first', 'second'], 'first second\n'),
107            (['first'], 'first\n'),
108            ([1, 2, 3], '1 2 3\n'),
109            ([], '\n')
110        ]
111
112        for args, expected in tests:
113            with self.subTest(arguments=args, expected=expected):
114                with captured_stderr() as stderr:
115                    tabnanny.errprint(*args)
116                self.assertEqual(stderr.getvalue() , expected)
117
118
119class TestNannyNag(TestCase):
120    def test_all_methods(self):
121        """Asserting behaviour of `tabnanny.NannyNag` exception."""
122        tests = [
123            (
124                tabnanny.NannyNag(0, "foo", "bar"),
125                {'lineno': 0, 'msg': 'foo', 'line': 'bar'}
126            ),
127            (
128                tabnanny.NannyNag(5, "testmsg", "testline"),
129                {'lineno': 5, 'msg': 'testmsg', 'line': 'testline'}
130            )
131        ]
132        for nanny, expected in tests:
133            line_number = nanny.get_lineno()
134            msg = nanny.get_msg()
135            line = nanny.get_line()
136            with self.subTest(
137                line_number=line_number, expected=expected['lineno']
138            ):
139                self.assertEqual(expected['lineno'], line_number)
140            with self.subTest(msg=msg, expected=expected['msg']):
141                self.assertEqual(expected['msg'], msg)
142            with self.subTest(line=line, expected=expected['line']):
143                self.assertEqual(expected['line'], line)
144
145
146class TestCheck(TestCase):
147    """Testing tabnanny.check()."""
148
149    def setUp(self):
150        self.addCleanup(setattr, tabnanny, 'verbose', tabnanny.verbose)
151        tabnanny.verbose = 0  # Forcefully deactivating verbose mode.
152
153    def verify_tabnanny_check(self, dir_or_file, out="", err=""):
154        """Common verification for tabnanny.check().
155
156        Use this method to assert expected values of `stdout` and `stderr` after
157        running tabnanny.check() on given `dir` or `file` path. Because
158        tabnanny.check() captures exceptions and writes to `stdout` and
159        `stderr`, asserting standard outputs is the only way.
160        """
161        with captured_stdout() as stdout, captured_stderr() as stderr:
162            tabnanny.check(dir_or_file)
163        self.assertEqual(stdout.getvalue(), out)
164        self.assertEqual(stderr.getvalue(), err)
165
166    def test_correct_file(self):
167        """A python source code file without any errors."""
168        with TemporaryPyFile(SOURCE_CODES["error_free"]) as file_path:
169            self.verify_tabnanny_check(file_path)
170
171    def test_correct_directory_verbose(self):
172        """Directory containing few error free python source code files.
173
174        Because order of files returned by `os.lsdir()` is not fixed, verify the
175        existence of each output lines at `stdout` using `in` operator.
176        `verbose` mode of `tabnanny.verbose` asserts `stdout`.
177        """
178        with tempfile.TemporaryDirectory() as tmp_dir:
179            lines = [f"{tmp_dir!r}: listing directory\n",]
180            file1 = TemporaryPyFile(SOURCE_CODES["error_free"], directory=tmp_dir)
181            file2 = TemporaryPyFile(SOURCE_CODES["error_free"], directory=tmp_dir)
182            with file1 as file1_path, file2 as file2_path:
183                for file_path in (file1_path, file2_path):
184                    lines.append(f"{file_path!r}: Clean bill of health.\n")
185
186                tabnanny.verbose = 1
187                with captured_stdout() as stdout, captured_stderr() as stderr:
188                    tabnanny.check(tmp_dir)
189                stdout = stdout.getvalue()
190                for line in lines:
191                    with self.subTest(line=line):
192                        self.assertIn(line, stdout)
193                self.assertEqual(stderr.getvalue(), "")
194
195    def test_correct_directory(self):
196        """Directory which contains few error free python source code files."""
197        with tempfile.TemporaryDirectory() as tmp_dir:
198            with TemporaryPyFile(SOURCE_CODES["error_free"], directory=tmp_dir):
199                self.verify_tabnanny_check(tmp_dir)
200
201    def test_when_wrong_indented(self):
202        """A python source code file eligible for raising `IndentationError`."""
203        with TemporaryPyFile(SOURCE_CODES["wrong_indented"]) as file_path:
204            err = ('unindent does not match any outer indentation level'
205                ' (<tokenize>, line 3)\n')
206            err = f"{file_path!r}: Indentation Error: {err}"
207            self.verify_tabnanny_check(file_path, err=err)
208
209    def test_when_tokenize_tokenerror(self):
210        """A python source code file eligible for raising 'tokenize.TokenError'."""
211        with TemporaryPyFile(SOURCE_CODES["incomplete_expression"]) as file_path:
212            err = "('EOF in multi-line statement', (7, 0))\n"
213            err = f"{file_path!r}: Token Error: {err}"
214            self.verify_tabnanny_check(file_path, err=err)
215
216    def test_when_nannynag_error_verbose(self):
217        """A python source code file eligible for raising `tabnanny.NannyNag`.
218
219        Tests will assert `stdout` after activating `tabnanny.verbose` mode.
220        """
221        with TemporaryPyFile(SOURCE_CODES["nannynag_errored"]) as file_path:
222            out = f"{file_path!r}: *** Line 3: trouble in tab city! ***\n"
223            out += "offending line: '\\tprint(\"world\")\\n'\n"
224            out += "indent not equal e.g. at tab size 1\n"
225
226            tabnanny.verbose = 1
227            self.verify_tabnanny_check(file_path, out=out)
228
229    def test_when_nannynag_error(self):
230        """A python source code file eligible for raising `tabnanny.NannyNag`."""
231        with TemporaryPyFile(SOURCE_CODES["nannynag_errored"]) as file_path:
232            out = f"{file_path} 3 '\\tprint(\"world\")\\n'\n"
233            self.verify_tabnanny_check(file_path, out=out)
234
235    def test_when_no_file(self):
236        """A python file which does not exist actually in system."""
237        path = 'no_file.py'
238        err = (f"{path!r}: I/O Error: [Errno {errno.ENOENT}] "
239              f"{os.strerror(errno.ENOENT)}: {path!r}\n")
240        self.verify_tabnanny_check(path, err=err)
241
242    def test_errored_directory(self):
243        """Directory containing wrongly indented python source code files."""
244        with tempfile.TemporaryDirectory() as tmp_dir:
245            error_file = TemporaryPyFile(
246                SOURCE_CODES["wrong_indented"], directory=tmp_dir
247            )
248            code_file = TemporaryPyFile(
249                SOURCE_CODES["error_free"], directory=tmp_dir
250            )
251            with error_file as e_file, code_file as c_file:
252                err = ('unindent does not match any outer indentation level'
253                            ' (<tokenize>, line 3)\n')
254                err = f"{e_file!r}: Indentation Error: {err}"
255                self.verify_tabnanny_check(tmp_dir, err=err)
256
257
258class TestProcessTokens(TestCase):
259    """Testing `tabnanny.process_tokens()`."""
260
261    @mock.patch('tabnanny.NannyNag')
262    def test_with_correct_code(self, MockNannyNag):
263        """A python source code without any whitespace related problems."""
264
265        with TemporaryPyFile(SOURCE_CODES["error_free"]) as file_path:
266            with open(file_path) as f:
267                tabnanny.process_tokens(tokenize.generate_tokens(f.readline))
268            self.assertFalse(MockNannyNag.called)
269
270    def test_with_errored_codes_samples(self):
271        """A python source code with whitespace related sampled problems."""
272
273        # "tab_space_errored_1": executes block under type == tokenize.INDENT
274        #                        at `tabnanny.process_tokens()`.
275        # "tab space_errored_2": executes block under
276        #                        `check_equal and type not in JUNK` condition at
277        #                        `tabnanny.process_tokens()`.
278
279        for key in ["tab_space_errored_1", "tab_space_errored_2"]:
280            with self.subTest(key=key):
281                with TemporaryPyFile(SOURCE_CODES[key]) as file_path:
282                    with open(file_path) as f:
283                        tokens = tokenize.generate_tokens(f.readline)
284                        with self.assertRaises(tabnanny.NannyNag):
285                            tabnanny.process_tokens(tokens)
286
287
288class TestCommandLine(TestCase):
289    """Tests command line interface of `tabnanny`."""
290
291    def validate_cmd(self, *args, stdout="", stderr="", partial=False):
292        """Common function to assert the behaviour of command line interface."""
293        _, out, err = script_helper.assert_python_ok('-m', 'tabnanny', *args)
294        # Note: The `splitlines()` will solve the problem of CRLF(\r) added
295        # by OS Windows.
296        out = out.decode('ascii')
297        err = err.decode('ascii')
298        if partial:
299            for std, output in ((stdout, out), (stderr, err)):
300                _output = output.splitlines()
301                for _std in std.splitlines():
302                    with self.subTest(std=_std, output=_output):
303                        self.assertIn(_std, _output)
304        else:
305            self.assertListEqual(out.splitlines(), stdout.splitlines())
306            self.assertListEqual(err.splitlines(), stderr.splitlines())
307
308    def test_with_errored_file(self):
309        """Should displays error when errored python file is given."""
310        with TemporaryPyFile(SOURCE_CODES["wrong_indented"]) as file_path:
311            stderr  = f"{file_path!r}: Indentation Error: "
312            stderr += ('unindent does not match any outer indentation level'
313                    ' (<tokenize>, line 3)')
314            self.validate_cmd(file_path, stderr=stderr)
315
316    def test_with_error_free_file(self):
317        """Should not display anything if python file is correctly indented."""
318        with TemporaryPyFile(SOURCE_CODES["error_free"]) as file_path:
319            self.validate_cmd(file_path)
320
321    def test_command_usage(self):
322        """Should display usage on no arguments."""
323        path = findfile('tabnanny.py')
324        stderr = f"Usage: {path} [-v] file_or_directory ..."
325        self.validate_cmd(stderr=stderr)
326
327    def test_quiet_flag(self):
328        """Should display less when quite mode is on."""
329        with TemporaryPyFile(SOURCE_CODES["nannynag_errored"]) as file_path:
330            stdout = f"{file_path}\n"
331            self.validate_cmd("-q", file_path, stdout=stdout)
332
333    def test_verbose_mode(self):
334        """Should display more error information if verbose mode is on."""
335        with TemporaryPyFile(SOURCE_CODES["nannynag_errored"]) as path:
336            stdout = textwrap.dedent(
337                "offending line: '\\tprint(\"world\")\\n'"
338            ).strip()
339            self.validate_cmd("-v", path, stdout=stdout, partial=True)
340
341    def test_double_verbose_mode(self):
342        """Should display detailed error information if double verbose is on."""
343        with TemporaryPyFile(SOURCE_CODES["nannynag_errored"]) as path:
344            stdout = textwrap.dedent(
345                "offending line: '\\tprint(\"world\")\\n'"
346            ).strip()
347            self.validate_cmd("-vv", path, stdout=stdout, partial=True)
348