diff --git a/codespell_lib/_codespell.py b/codespell_lib/_codespell.py index 2e6689fc..f52b840c 100755 --- a/codespell_lib/_codespell.py +++ b/codespell_lib/_codespell.py @@ -32,7 +32,7 @@ encodings = ('utf-8', 'iso-8859-1') USAGE = """ \t%prog [OPTIONS] [file1 file2 ... fileN] """ -VERSION = '1.18.dev0' +VERSION = '2.0.dev0' # Users might want to link this file into /usr/local/bin, so we resolve the # symbolic link path to the real path if necessary. @@ -51,6 +51,12 @@ _builtin_dictionaries = ( ) _builtin_default = 'clear,rare' +# docs say os.EX_USAGE et al. are only available on Unix systems, so to be safe +# we protect and just use the values they are on macOS and Linux +EX_OK = 0 +EX_USAGE = 64 +EX_DATAERR = 65 + # OPTIONS: # # ARGUMENTS: @@ -254,7 +260,7 @@ def parse_options(args): parser.add_argument('-D', '--dictionary', action='append', - help='Custom dictionary file that contains spelling ' + help='custom dictionary file that contains spelling ' 'corrections. If this flag is not specified or ' 'equals "-" then the default dictionary is used. ' 'This option can be specified multiple times.') @@ -263,24 +269,24 @@ def parse_options(args): parser.add_argument('--builtin', dest='builtin', default=_builtin_default, metavar='BUILTIN-LIST', - help='Comma-separated list of builtin dictionaries ' + help='comma-separated list of builtin dictionaries ' 'to include (when "-D -" or no "-D" is passed). ' 'Current options are:' + builtin_opts + '\n' 'The default is %(default)r.') parser.add_argument('-I', '--ignore-words', action='append', metavar='FILE', - help='File that contains words which will be ignored ' + help='file that contains words which will be ignored ' 'by codespell. File must contain 1 word per line.' ' Words are case sensitive based on how they are ' 'written in the dictionary file') parser.add_argument('-L', '--ignore-words-list', action='append', metavar='WORDS', - help='Comma separated list of words to be ignored ' + help='comma separated list of words to be ignored ' 'by codespell. Words are case sensitive based on ' 'how they are written in the dictionary file') parser.add_argument('-r', '--regex', action='store', type=str, - help='Regular expression which is used to find words. ' + help='regular expression which is used to find words. ' 'By default any alphanumeric character, the ' 'underscore, the hyphen, and the apostrophe is ' 'used to build words. This option cannot be ' @@ -289,9 +295,14 @@ def parse_options(args): action='store_true', default=False, help='print summary of fixes') + parser.add_argument('--count', + action='store_true', default=False, + help='print the number of errors as the last line of ' + 'stderr') + parser.add_argument('-S', '--skip', action='append', - help='Comma-separated list of files to skip. It ' + help='comma-separated list of files to skip. It ' 'accepts globs as well. E.g.: if you want ' 'codespell to skip .eps and .txt files, ' 'you\'d give "*.eps,*.txt" to this option.') @@ -302,7 +313,7 @@ def parse_options(args): parser.add_argument('-i', '--interactive', action='store', type=int, default=0, - help='Set interactive mode when writing changes:\n' + help='set interactive mode when writing changes:\n' '- 0: no interactivity.\n' '- 1: ask for confirmation.\n' '- 2: ask user to choose one fix when more than one is available.\n' # noqa: E501 @@ -310,7 +321,7 @@ def parse_options(args): parser.add_argument('-q', '--quiet-level', action='store', type=int, default=2, - help='Bitmask that allows suppressing messages:\n' + help='bitmask that allows suppressing messages:\n' '- 0: print all messages.\n' '- 1: disable warnings about wrong encoding.\n' '- 2: disable warnings about binary files.\n' @@ -324,7 +335,7 @@ def parse_options(args): parser.add_argument('-e', '--hard-encoding-detection', action='store_true', default=False, - help='Use chardet to detect the encoding of each ' + help='use chardet to detect the encoding of each ' 'file. This can slow down codespell, but is more ' 'reliable in detecting encodings other than ' 'utf-8, iso8859-1, and ascii.') @@ -335,7 +346,7 @@ def parse_options(args): parser.add_argument('-H', '--check-hidden', action='store_true', default=False, - help='Check hidden files and directories (those ' + help='check hidden files and directories (those ' 'starting with ".") as well.') parser.add_argument('-A', '--after-context', type=int, metavar='LINES', help='print LINES of trailing context') @@ -642,7 +653,7 @@ def main(*args): print("ERROR: --write-changes cannot be used together with " "--regex") parser.print_help() - return 1 + return EX_USAGE word_regex = options.regex or word_regex_def try: word_regex = re.compile(word_regex) @@ -650,7 +661,7 @@ def main(*args): print("ERROR: invalid regular expression \"%s\" (%s)" % (word_regex, err), file=sys.stderr) parser.print_help() - return 1 + return EX_USAGE ignore_words_files = options.ignore_words or [] ignore_words = set() @@ -659,7 +670,7 @@ def main(*args): print("ERROR: cannot find ignore-words file: %s" % ignore_words_file, file=sys.stderr) parser.print_help() - return 1 + return EX_USAGE build_ignore_words(ignore_words_file, ignore_words) ignore_words_list = options.ignore_words_list or [] @@ -687,13 +698,13 @@ def main(*args): print("ERROR: Unknown builtin dictionary: %s" % (u,), file=sys.stderr) parser.print_help() - return 1 + return EX_USAGE else: if not os.path.isfile(dictionary): print("ERROR: cannot find dictionary file: %s" % dictionary, file=sys.stderr) parser.print_help() - return 1 + return EX_USAGE use_dictionaries.append(dictionary) misspellings = dict() for dictionary in use_dictionaries: @@ -714,7 +725,7 @@ def main(*args): print("ERROR: --context/-C cannot be used together with " "--context-before/-B or --context-after/-A") parser.print_help() - return 1 + return EX_USAGE context_both = max(0, options.context) context = (context_both, context_both) elif (options.before_context is not None) or \ @@ -772,4 +783,6 @@ def main(*args): if summary: print("\n-------8<-------\nSUMMARY:") print(summary) - return bad_count + if options.count: + print(bad_count, file=sys.stderr) + return EX_DATAERR if bad_count else EX_OK diff --git a/codespell_lib/tests/test_basic.py b/codespell_lib/tests/test_basic.py index 4a54fd06..4107b6ce 100644 --- a/codespell_lib/tests/test_basic.py +++ b/codespell_lib/tests/test_basic.py @@ -3,6 +3,7 @@ from __future__ import print_function import contextlib +import inspect import os import os.path as op from shutil import copyfile @@ -11,18 +12,54 @@ import sys import pytest -import codespell_lib as cs +import codespell_lib as cs_ +from codespell_lib._codespell import EX_USAGE, EX_OK, EX_DATAERR + + +def test_constants(): + """Test our EX constants.""" + assert EX_OK == 0 + assert EX_USAGE == 64 + assert EX_DATAERR == 65 + + +class MainWrapper(object): + """Compatibility wrapper for when we used to return the count.""" + + def main(self, *args, count=True, std=False, **kwargs): + if count: + args = ('--count',) + args + code = cs_.main(*args, **kwargs) + capsys = inspect.currentframe().f_back.f_locals['capsys'] + stdout, stderr = capsys.readouterr() + assert code in (EX_OK, EX_USAGE, EX_DATAERR) + if code == EX_DATAERR: # have some misspellings + code = int(stderr.split('\n')[-2]) + elif code == EX_OK and count: + code = int(stderr.split('\n')[-2]) + assert code == 0 + if std: + return (code, stdout, stderr) + else: + return code + + +cs = MainWrapper() def run_codespell(args=(), cwd=None): - """Helper to run codespell""" - return subprocess.Popen( + """Run codespell.""" + args = ('--count',) + args + proc = subprocess.Popen( ['codespell'] + list(args), cwd=cwd, - stdout=subprocess.PIPE, stderr=subprocess.PIPE).wait() + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + stderr = proc.communicate()[1].decode('utf-8') + count = int(stderr.split('\n')[-2]) + return count def test_command(tmpdir): - """Test running the codespell executable""" + """Test running the codespell executable.""" # With no arguments does "." d = str(tmpdir) assert run_codespell(cwd=d) == 0 @@ -32,13 +69,14 @@ def test_command(tmpdir): def test_basic(tmpdir, capsys): - """Test some basic functionality""" + """Test some basic functionality.""" assert cs.main('_does_not_exist_') == 0 fname = op.join(str(tmpdir), 'tmp') with open(fname, 'w') as f: pass - assert cs.main('-D', 'foo', f.name) == 1, 'missing dictionary' - assert 'cannot find dictionary' in capsys.readouterr()[1] + code, _, stderr = cs.main('-D', 'foo', f.name, std=True) + assert code == EX_USAGE, 'missing dictionary' + assert 'cannot find dictionary' in stderr assert cs.main(fname) == 0, 'empty file' with open(fname, 'a') as f: f.write('this is a test file\n') @@ -53,22 +91,21 @@ def test_basic(tmpdir, capsys): f.write('tim\ngonna\n') assert cs.main(fname) == 2, 'with a name' assert cs.main('--builtin', 'clear,rare,names,informal', fname) == 4 - capsys.readouterr() - assert cs.main(fname, '--builtin', 'foo') == 1 # bad type sys.exit(1) - stdout = capsys.readouterr()[1] - assert 'Unknown builtin dictionary' in stdout + code, _, stderr = cs.main(fname, '--builtin', 'foo', std=True) + assert code == EX_USAGE # bad type + assert 'Unknown builtin dictionary' in stderr d = str(tmpdir) - assert cs.main(fname, '-D', op.join(d, 'foo')) == 1 # bad dict - stdout = capsys.readouterr()[1] - assert 'cannot find dictionary' in stdout + code, _, stderr = cs.main(fname, '-D', op.join(d, 'foo'), std=True) + assert code == EX_USAGE # bad dict + assert 'cannot find dictionary' in stderr os.remove(fname) with open(op.join(d, 'bad.txt'), 'w') as f: f.write('abandonned\nAbandonned\nABANDONNED\nAbAnDoNnEd') assert cs.main(d) == 4 - capsys.readouterr() - assert cs.main('-w', d) == 0 - assert 'FIXED:' in capsys.readouterr()[1] + code, _, stderr = cs.main('-w', d, std=True) + assert code == 0 + assert 'FIXED:' in stderr with open(op.join(d, 'bad.txt')) as f: new_content = f.read() assert cs.main(d) == 0 @@ -77,9 +114,10 @@ def test_basic(tmpdir, capsys): with open(op.join(d, 'bad.txt'), 'w') as f: f.write('abandonned abandonned\n') assert cs.main(d) == 2 - capsys.readouterr() - assert cs.main('-q', '16', '-w', d) == 0 - assert capsys.readouterr() == ('', '') + code, stdout, stderr = cs.main( + '-q', '16', '-w', d, count=False, std=True) + assert code == 0 + assert stdout == stderr == '' assert cs.main(d) == 0 # empty directory @@ -101,8 +139,9 @@ def test_interactivity(tmpdir, capsys): with FakeStdin('y\n'): assert cs.main('-i', '3', f.name) == 1 with FakeStdin('n\n'): - assert cs.main('-w', '-i', '3', f.name) == 0 - assert '==>' in capsys.readouterr()[0] + code, stdout, _ = cs.main('-w', '-i', '3', f.name, std=True) + assert code == 0 + assert '==>' in stdout with FakeStdin('x\ny\n'): assert cs.main('-w', '-i', '3', f.name) == 0 assert cs.main(f.name) == 0 @@ -141,10 +180,10 @@ def test_interactivity(tmpdir, capsys): with open(f.name, 'w') as f: f.write('ackward\n') assert cs.main(f.name) == 1 - capsys.readouterr() with FakeStdin('x\n1\n'): # blank input -> nothing - assert cs.main('-w', '-i', '3', f.name) == 0 - assert 'a valid option' in capsys.readouterr()[0] + code, stdout, _ = cs.main('-w', '-i', '3', f.name, std=True) + assert code == 0 + assert 'a valid option' in stdout assert cs.main(f.name) == 0 with open(f.name, 'r') as f: assert f.read() == 'backward\n' @@ -153,30 +192,28 @@ def test_interactivity(tmpdir, capsys): def test_summary(tmpdir, capsys): - """Test summary functionality""" + """Test summary functionality.""" with open(op.join(str(tmpdir), 'tmp'), 'w') as f: pass - try: - cs.main(f.name) - assert capsys.readouterr() == ('', ''), 'no output' - cs.main(f.name, '--summary') - stdout, stderr = capsys.readouterr() - assert stderr == '' - assert 'SUMMARY' in stdout - assert len(stdout.split('\n')) == 5 - with open(f.name, 'w') as f: - f.write('abandonned\nabandonned') - cs.main(f.name, '--summary') - stdout, stderr = capsys.readouterr() - assert stderr == '' - assert 'SUMMARY' in stdout - assert len(stdout.split('\n')) == 7 - assert 'abandonned' in stdout.split()[-2] - finally: - os.remove(f.name) + code, stdout, stderr = cs.main(f.name, std=True, count=False) + assert code == 0 + assert stdout == stderr == '', 'no output' + code, stdout, stderr = cs.main(f.name, '--summary', std=True) + assert code == 0 + assert stderr == '0\n' + assert 'SUMMARY' in stdout + assert len(stdout.split('\n')) == 5 + with open(f.name, 'w') as f: + f.write('abandonned\nabandonned') + assert code == 0 + code, stdout, stderr = cs.main(f.name, '--summary', std=True) + assert stderr == '2\n' + assert 'SUMMARY' in stdout + assert len(stdout.split('\n')) == 7 + assert 'abandonned' in stdout.split()[-2] -def test_ignore_dictionary(tmpdir): +def test_ignore_dictionary(tmpdir, capsys): """Test ignore dictionary functionality.""" d = str(tmpdir) with open(op.join(d, 'bad.txt'), 'w') as f: @@ -188,8 +225,8 @@ def test_ignore_dictionary(tmpdir): assert cs.main('-I', f.name, bad_name) == 1 -def test_ignore_word_list(tmpdir): - """Test ignore word list functionality""" +def test_ignore_word_list(tmpdir, capsys): + """Test ignore word list functionality.""" d = str(tmpdir) with open(op.join(d, 'bad.txt'), 'w') as f: f.write('abandonned\nabondon\nabilty\n') @@ -204,12 +241,12 @@ def test_custom_regex(tmpdir, capsys): f.write('abandonned_abondon\n') assert cs.main(d) == 0 assert cs.main('-r', "[a-z]+", d) == 2 - capsys.readouterr() - assert cs.main('-r', '[a-z]+', '--write-changes', d) == 1 - assert 'ERROR:' in capsys.readouterr()[0] + code, stdout, _ = cs.main('-r', '[a-z]+', '--write-changes', d, std=True) + assert code == EX_USAGE + assert 'ERROR:' in stdout -def test_exclude_file(tmpdir): +def test_exclude_file(tmpdir, capsys): """Test exclude file functionality.""" d = str(tmpdir) with open(op.join(d, 'bad.txt'), 'wb') as f: @@ -229,28 +266,26 @@ def test_encoding(tmpdir, capsys): pass # with CaptureStdout() as sio: assert cs.main(f.name) == 0 - try: - with open(f.name, 'wb') as f: - f.write(u'naïve\n'.encode('utf-8')) - assert cs.main(f.name) == 0 - assert cs.main('-e', f.name) == 0 - with open(f.name, 'ab') as f: - f.write(u'naieve\n'.encode('utf-8')) - assert cs.main(f.name) == 1 - # Binary file warning - with open(f.name, 'wb') as f: - f.write(b'\x00\x00naiive\x00\x00') - capsys.readouterr() - assert cs.main(f.name) == 0 - assert capsys.readouterr() == ('', '') - assert cs.main('-q', '0', f.name) == 0 - assert 'WARNING: Binary file' in capsys.readouterr()[1] - assert capsys.readouterr() == ('', '') - finally: - os.remove(f.name) + with open(f.name, 'wb') as f: + f.write(u'naïve\n'.encode('utf-8')) + assert cs.main(f.name) == 0 + assert cs.main('-e', f.name) == 0 + with open(f.name, 'ab') as f: + f.write(u'naieve\n'.encode('utf-8')) + assert cs.main(f.name) == 1 + # Binary file warning + with open(f.name, 'wb') as f: + f.write(b'\x00\x00naiive\x00\x00') + code, stdout, stderr = cs.main(f.name, std=True, count=False) + assert code == 0 + assert stdout == stderr == '' + code, stdout, stderr = cs.main('-q', '0', f.name, std=True, count=False) + assert code == 0 + assert stdout == '' + assert 'WARNING: Binary file' in stderr -def test_ignore(tmpdir): +def test_ignore(tmpdir, capsys): """Test ignoring of files and directories.""" d = str(tmpdir) with open(op.join(d, 'good.txt'), 'w') as f: @@ -272,7 +307,7 @@ def test_ignore(tmpdir): assert cs.main('--skip=*ignoredir/bad*', d) == 1 -def test_check_filename(tmpdir): +def test_check_filename(tmpdir, capsys): """Test filename check.""" d = str(tmpdir) # Empty file @@ -291,7 +326,7 @@ def test_check_filename(tmpdir): @pytest.mark.skipif((not hasattr(os, "mkfifo") or not callable(os.mkfifo)), reason='requires os.mkfifo') -def test_check_filename_irregular_file(tmpdir): +def test_check_filename_irregular_file(tmpdir, capsys): """Test irregular file filename check.""" # Irregular file (!isfile()) d = str(tmpdir) @@ -300,7 +335,7 @@ def test_check_filename_irregular_file(tmpdir): d = str(tmpdir) -def test_check_hidden(tmpdir): +def test_check_hidden(tmpdir, capsys): """Test ignoring of hidden files.""" d = str(tmpdir) # visible file @@ -342,17 +377,16 @@ def test_case_handling(tmpdir, capsys): pass # with CaptureStdout() as sio: assert cs.main(f.name) == 0 - try: - with open(f.name, 'wb') as f: - f.write('this has an ACII error'.encode('utf-8')) - assert cs.main(f.name) == 1 - assert 'ASCII' in capsys.readouterr()[0] - assert cs.main('-w', f.name) == 0 - assert 'FIXED' in capsys.readouterr()[1] - with open(f.name, 'rb') as f: - assert f.read().decode('utf-8') == 'this has an ASCII error' - finally: - os.remove(f.name) + with open(f.name, 'wb') as f: + f.write('this has an ACII error'.encode('utf-8')) + code, stdout, _ = cs.main(f.name, std=True) + assert code == 1 + assert 'ASCII' in stdout + code, _, stderr = cs.main('-w', f.name, std=True) + assert code == 0 + assert 'FIXED' in stderr + with open(f.name, 'rb') as f: + assert f.read().decode('utf-8') == 'this has an ASCII error' def test_context(tmpdir, capsys): @@ -362,16 +396,18 @@ def test_context(tmpdir, capsys): f.write('line 1\nline 2\nline 3 abandonned\nline 4\nline 5') # symmetric context, fully within file - cs.main('-C', '1', d) - lines = capsys.readouterr()[0].split('\n') + code, stdout, _ = cs.main('-C', '1', d, std=True) + assert code == 1 + lines = stdout.split('\n') assert len(lines) == 5 assert lines[0] == ': line 2' assert lines[1] == '> line 3 abandonned' assert lines[2] == ': line 4' # requested context is bigger than the file - cs.main('-C', '10', d) - lines = capsys.readouterr()[0].split('\n') + code, stdout, _ = cs.main('-C', '10', d, std=True) + assert code == 1 + lines = stdout.split('\n') assert len(lines) == 7 assert lines[0] == ': line 1' assert lines[1] == ': line 2' @@ -380,23 +416,26 @@ def test_context(tmpdir, capsys): assert lines[4] == ': line 5' # only before context - cs.main('-B', '2', d) - lines = capsys.readouterr()[0].split('\n') + code, stdout, _ = cs.main('-B', '2', d, std=True) + assert code == 1 + lines = stdout.split('\n') assert len(lines) == 5 assert lines[0] == ': line 1' assert lines[1] == ': line 2' assert lines[2] == '> line 3 abandonned' # only after context - cs.main('-A', '1', d) - lines = capsys.readouterr()[0].split('\n') + code, stdout, _ = cs.main('-A', '1', d, std=True) + assert code == 1 + lines = stdout.split('\n') assert len(lines) == 4 assert lines[0] == '> line 3 abandonned' assert lines[1] == ': line 4' # asymmetric context - cs.main('-B', '2', '-A', '1', d) - lines = capsys.readouterr()[0].split('\n') + code, stdout, _ = cs.main('-B', '2', '-A', '1', d, std=True) + assert code == 1 + lines = stdout.split('\n') assert len(lines) == 6 assert lines[0] == ': line 1' assert lines[1] == ': line 2' @@ -404,13 +443,15 @@ def test_context(tmpdir, capsys): assert lines[3] == ': line 4' # both '-C' and '-A' on the command line - cs.main('-C', '2', '-A', '1', d) - lines = capsys.readouterr()[0].split('\n') + code, stdout, _ = cs.main('-C', '2', '-A', '1', d, std=True) + assert code == EX_USAGE + lines = stdout.split('\n') assert 'ERROR' in lines[0] # both '-C' and '-B' on the command line - cs.main('-C', '2', '-B', '1', d) - lines = capsys.readouterr()[0].split('\n') + code, stdout, stderr = cs.main('-C', '2', '-B', '1', d, std=True) + assert code == EX_USAGE + lines = stdout.split('\n') assert 'ERROR' in lines[0]