From 8d0d82bcc07aaef5211127683aba7de67b923aac Mon Sep 17 00:00:00 2001 From: Jon Dufresne Date: Mon, 7 Nov 2022 08:10:00 -0800 Subject: [PATCH] Add type annotations to the project and use mypy (#2588) Provide type annotations and use mypy for static type checking. Type checkers help ensure that the project is using variables and functions in the code correctly. With mypy, CI will warn when those types are used incorrectly. The mypy project and docs: https://github.com/python/mypy https://mypy.readthedocs.io/en/stable/index.html --- .github/workflows/mypy.yml | 22 ++ codespell_lib/__init__.py | 6 +- codespell_lib/_codespell.py | 117 ++++++---- codespell_lib/py.typed | 0 codespell_lib/tests/test_basic.py | 304 +++++++++++++++++++------ codespell_lib/tests/test_dictionary.py | 53 +++-- pyproject.toml | 14 +- 7 files changed, 386 insertions(+), 130 deletions(-) create mode 100644 .github/workflows/mypy.yml create mode 100644 codespell_lib/py.typed diff --git a/.github/workflows/mypy.yml b/.github/workflows/mypy.yml new file mode 100644 index 0000000000..927c6a24c4 --- /dev/null +++ b/.github/workflows/mypy.yml @@ -0,0 +1,22 @@ +name: mypy + +on: + - push + - pull_request + +jobs: + mypy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Setup python + uses: actions/setup-python@v4 + with: + python-version: '3.7' + + - name: Install dependencies + run: pip install -e .[types] + + - name: Run mypy + run: mypy . diff --git a/codespell_lib/__init__.py b/codespell_lib/__init__.py index 6f455ec3b1..cd77a5534c 100644 --- a/codespell_lib/__init__.py +++ b/codespell_lib/__init__.py @@ -1,2 +1,4 @@ -from ._codespell import _script_main, main # noqa -from ._version import __version__ # noqa +from ._codespell import _script_main, main +from ._version import __version__ + +__all__ = ["_script_main", "main", "__version__"] diff --git a/codespell_lib/_codespell.py b/codespell_lib/_codespell.py index 3cabd2f304..ea8ce77dae 100644 --- a/codespell_lib/_codespell.py +++ b/codespell_lib/_codespell.py @@ -23,6 +23,7 @@ import re import sys import textwrap +from typing import Dict, List, Optional, Pattern, Sequence, Set, Tuple # autogenerated by setuptools_scm from ._version import __version__ as VERSION @@ -91,14 +92,15 @@ class QuietLevels: class GlobMatch: - def __init__(self, pattern): + def __init__(self, pattern: Optional[str]) -> None: + self.pattern_list: Optional[List[str]] if pattern: # Pattern might be a list of comma-delimited strings self.pattern_list = ','.join(pattern).split(',') else: self.pattern_list = None - def match(self, filename): + def match(self, filename: str) -> bool: if self.pattern_list is None: return False @@ -106,20 +108,20 @@ def match(self, filename): class Misspelling: - def __init__(self, data, fix, reason): + def __init__(self, data: str, fix: bool, reason: str) -> None: self.data = data self.fix = fix self.reason = reason class TermColors: - def __init__(self): + def __init__(self) -> None: self.FILE = '\033[33m' self.WWORD = '\033[31m' self.FWORD = '\033[32m' self.DISABLE = '\033[0m' - def disable(self): + def disable(self) -> None: self.FILE = '' self.WWORD = '' self.FWORD = '' @@ -127,16 +129,16 @@ def disable(self): class Summary: - def __init__(self): - self.summary = {} + def __init__(self) -> None: + self.summary: Dict[str, int] = {} - def update(self, wrongword): + def update(self, wrongword: str) -> None: if wrongword in self.summary: self.summary[wrongword] += 1 else: self.summary[wrongword] = 1 - def __str__(self): + def __str__(self) -> str: keys = list(self.summary.keys()) keys.sort() @@ -147,13 +149,13 @@ def __str__(self): class FileOpener: - def __init__(self, use_chardet, quiet_level): + def __init__(self, use_chardet: bool, quiet_level: int) -> None: self.use_chardet = use_chardet if use_chardet: self.init_chardet() self.quiet_level = quiet_level - def init_chardet(self): + def init_chardet(self) -> None: try: from chardet.universaldetector import UniversalDetector except ImportError: @@ -163,21 +165,22 @@ def init_chardet(self): self.encdetector = UniversalDetector() - def open(self, filename): + def open(self, filename: str) -> Tuple[List[str], str]: if self.use_chardet: return self.open_with_chardet(filename) else: return self.open_with_internal(filename) - def open_with_chardet(self, filename): + def open_with_chardet(self, filename: str) -> Tuple[List[str], str]: self.encdetector.reset() - with open(filename, 'rb') as f: - for line in f: + with open(filename, 'rb') as fb: + for line in fb: self.encdetector.feed(line) if self.encdetector.done: break self.encdetector.close() encoding = self.encdetector.result['encoding'] + assert encoding is not None try: f = open(filename, encoding=encoding, newline='') @@ -195,7 +198,7 @@ def open_with_chardet(self, filename): return lines, encoding - def open_with_internal(self, filename): + def open_with_internal(self, filename: str) -> Tuple[List[str], str]: encoding = None first_try = True for encoding in encodings: @@ -228,7 +231,7 @@ def open_with_internal(self, filename): class NewlineHelpFormatter(argparse.HelpFormatter): """Help formatter that preserves newlines and deals with lists.""" - def _split_lines(self, text, width): + def _split_lines(self, text: str, width: int) -> List[str]: parts = text.split('\n') out = [] for part in parts: @@ -248,7 +251,9 @@ def _split_lines(self, text, width): return out -def parse_options(args): +def parse_options( + args: Sequence[str] +) -> Tuple[argparse.Namespace, argparse.ArgumentParser, List[str]]: parser = argparse.ArgumentParser(formatter_class=NewlineHelpFormatter) parser.set_defaults(colors=sys.stdout.isatty()) @@ -452,7 +457,7 @@ def parse_options(args): return options, parser, used_cfg_files -def parse_ignore_words_option(ignore_words_option): +def parse_ignore_words_option(ignore_words_option: List[str]) -> Set[str]: ignore_words = set() if ignore_words_option: for comma_separated_words in ignore_words_option: @@ -461,19 +466,23 @@ def parse_ignore_words_option(ignore_words_option): return ignore_words -def build_exclude_hashes(filename, exclude_lines): +def build_exclude_hashes(filename: str, exclude_lines: Set[str]) -> None: with open(filename, encoding='utf-8') as f: for line in f: exclude_lines.add(line) -def build_ignore_words(filename, ignore_words): +def build_ignore_words(filename: str, ignore_words: Set[str]) -> None: with open(filename, encoding='utf-8') as f: for line in f: ignore_words.add(line.strip()) -def build_dict(filename, misspellings, ignore_words): +def build_dict( + filename: str, + misspellings: Dict[str, Misspelling], + ignore_words: Set[str], +) -> None: with open(filename, encoding='utf-8') as f: for line in f: [key, data] = line.split('->') @@ -501,20 +510,20 @@ def build_dict(filename, misspellings, ignore_words): misspellings[key] = Misspelling(data, fix, reason) -def is_hidden(filename, check_hidden): +def is_hidden(filename: str, check_hidden: bool) -> bool: bfilename = os.path.basename(filename) return bfilename not in ('', '.', '..') and \ (not check_hidden and bfilename[0] == '.') -def is_text_file(filename): +def is_text_file(filename: str) -> bool: with open(filename, mode='rb') as f: s = f.read(1024) return b'\x00' not in s -def fix_case(word, fixword): +def fix_case(word: str, fixword: str) -> str: if word == word.capitalize(): return ', '.join(w.strip().capitalize() for w in fixword.split(',')) elif word == word.upper(): @@ -524,7 +533,12 @@ def fix_case(word, fixword): return fixword -def ask_for_word_fix(line, wrongword, misspelling, interactivity): +def ask_for_word_fix( + line: str, + wrongword: str, + misspelling: Misspelling, + interactivity: int, +) -> Tuple[bool, str]: if interactivity <= 0: return misspelling.fix, fix_case(wrongword, misspelling.data) @@ -562,8 +576,8 @@ def ask_for_word_fix(line, wrongword, misspelling, interactivity): break try: - n = int(n) - r = opt[n] + i = int(n) + r = opt[i] except (ValueError, IndexError): print("Not a valid option\n") @@ -574,21 +588,35 @@ def ask_for_word_fix(line, wrongword, misspelling, interactivity): return misspelling.fix, fix_case(wrongword, misspelling.data) -def print_context(lines, index, context): +def print_context( + lines: List[str], + index: int, + context: Tuple[int, int], +) -> None: # context = (context_before, context_after) for i in range(index - context[0], index + context[1] + 1): if 0 <= i < len(lines): print('%s %s' % ('>' if i == index else ':', lines[i].rstrip())) -def extract_words(text, word_regex, ignore_word_regex): +def extract_words( + text: str, + word_regex: Pattern[str], + ignore_word_regex: Optional[Pattern[str]], +) -> List[str]: if ignore_word_regex: text = ignore_word_regex.sub(' ', text) return word_regex.findall(text) -def apply_uri_ignore_words(check_words, line, word_regex, ignore_word_regex, - uri_regex, uri_ignore_words): +def apply_uri_ignore_words( + check_words: List[str], + line: str, + word_regex: Pattern[str], + ignore_word_regex: Optional[Pattern[str]], + uri_regex: Pattern[str], + uri_ignore_words: Set[str] +) -> None: if not uri_ignore_words: return for uri in re.findall(uri_regex, line): @@ -598,9 +626,20 @@ def apply_uri_ignore_words(check_words, line, word_regex, ignore_word_regex, check_words.remove(uri_word) -def parse_file(filename, colors, summary, misspellings, exclude_lines, - file_opener, word_regex, ignore_word_regex, uri_regex, - uri_ignore_words, context, options): +def parse_file( + filename: str, + colors: TermColors, + summary: Optional[Summary], + misspellings: Dict[str, Misspelling], + exclude_lines: Set[str], + file_opener: FileOpener, + word_regex: Pattern[str], + ignore_word_regex: Optional[Pattern[str]], + uri_regex: Pattern[str], + uri_ignore_words: Set[str], + context: Optional[Tuple[int, int]], + options: argparse.Namespace, +) -> int: bad_count = 0 lines = None changed = False @@ -770,12 +809,12 @@ def parse_file(filename, colors, summary, misspellings, exclude_lines, return bad_count -def _script_main(): +def _script_main() -> int: """Wrap to main() for setuptools.""" return main(*sys.argv[1:]) -def main(*args): +def main(*args: str) -> int: """Contains flow control""" options, parser, used_cfg_files = parse_options(args) @@ -858,7 +897,7 @@ def main(*args): parser.print_help() return EX_USAGE use_dictionaries.append(dictionary) - misspellings = {} + misspellings: Dict[str, Misspelling] = {} for dictionary in use_dictionaries: build_dict(dictionary, misspellings, ignore_words) colors = TermColors() @@ -891,7 +930,7 @@ def main(*args): context_after = max(0, options.after_context) context = (context_before, context_after) - exclude_lines = set() + exclude_lines: Set[str] = set() if options.exclude_file: build_exclude_hashes(options.exclude_file, exclude_lines) diff --git a/codespell_lib/py.typed b/codespell_lib/py.typed new file mode 100644 index 0000000000..e69de29bb2 diff --git a/codespell_lib/tests/test_basic.py b/codespell_lib/tests/test_basic.py index a9401b2a74..892401dc3b 100644 --- a/codespell_lib/tests/test_basic.py +++ b/codespell_lib/tests/test_basic.py @@ -6,7 +6,9 @@ import subprocess import sys from io import StringIO +from pathlib import Path from shutil import copyfile +from typing import Generator, Optional, Tuple, Union import pytest @@ -14,7 +16,7 @@ from codespell_lib._codespell import EX_DATAERR, EX_OK, EX_USAGE, uri_regex_def -def test_constants(): +def test_constants() -> None: """Test our EX constants.""" assert EX_OK == 0 assert EX_USAGE == 64 @@ -25,11 +27,19 @@ class MainWrapper: """Compatibility wrapper for when we used to return the count.""" @staticmethod - def main(*args, count=True, std=False, **kwargs): + def main( + *args: str, + count: bool = True, + std: bool = False, + ) -> Union[int, Tuple[int, str, str]]: if count: args = ('--count',) + args - code = cs_.main(*args, **kwargs) - capsys = inspect.currentframe().f_back.f_locals['capsys'] + code = cs_.main(*args) + frame = inspect.currentframe() + assert frame is not None + frame = frame.f_back + assert frame is not None + capsys = frame.f_locals['capsys'] stdout, stderr = capsys.readouterr() assert code in (EX_OK, EX_USAGE, EX_DATAERR) if code == EX_DATAERR: # have some misspellings @@ -46,7 +56,10 @@ def main(*args, count=True, std=False, **kwargs): cs = MainWrapper() -def run_codespell(args=(), cwd=None): +def run_codespell( + args: Tuple[str, ...] = (), + cwd: Optional[str] = None, +) -> int: """Run codespell.""" args = ('--count',) + args proc = subprocess.run( @@ -56,7 +69,7 @@ def run_codespell(args=(), cwd=None): return count -def test_command(tmpdir): +def test_command(tmpdir: pytest.TempPathFactory) -> None: """Test running the codespell executable.""" # With no arguments does "." d = str(tmpdir) @@ -66,13 +79,18 @@ def test_command(tmpdir): assert run_codespell(cwd=d) == 4 -def test_basic(tmpdir, capsys): +def test_basic( + tmpdir: pytest.TempPathFactory, + capsys: pytest.CaptureFixture[str], +) -> None: """Test some basic functionality.""" assert cs.main('_does_not_exist_') == 0 fname = op.join(str(tmpdir), 'tmp') with open(fname, 'w') as f: pass - code, _, stderr = cs.main('-D', 'foo', f.name, std=True) + result = cs.main('-D', 'foo', f.name, std=True) + assert isinstance(result, tuple) + code, _, stderr = result assert code == EX_USAGE, 'missing dictionary' assert 'cannot find dictionary' in stderr assert cs.main(fname) == 0, 'empty file' @@ -89,11 +107,15 @@ 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 - code, _, stderr = cs.main(fname, '--builtin', 'foo', std=True) + result = cs.main(fname, '--builtin', 'foo', std=True) + assert isinstance(result, tuple) + code, _, stderr = result assert code == EX_USAGE # bad type assert 'Unknown builtin dictionary' in stderr d = str(tmpdir) - code, _, stderr = cs.main(fname, '-D', op.join(d, 'foo'), std=True) + result = cs.main(fname, '-D', op.join(d, 'foo'), std=True) + assert isinstance(result, tuple) + code, _, stderr = result assert code == EX_USAGE # bad dict assert 'cannot find dictionary' in stderr os.remove(fname) @@ -101,7 +123,9 @@ def test_basic(tmpdir, capsys): with open(op.join(d, 'bad.txt'), 'w', newline='') as f: f.write('abandonned\nAbandonned\nABANDONNED\nAbAnDoNnEd\nabandonned\rAbandonned\r\nABANDONNED \n AbAnDoNnEd') # noqa: E501 assert cs.main(d) == 8 - code, _, stderr = cs.main('-w', d, std=True) + result = cs.main('-w', d, std=True) + assert isinstance(result, tuple) + code, _, stderr = result assert code == 0 assert 'FIXED:' in stderr with open(op.join(d, 'bad.txt'), newline='') as f: @@ -112,8 +136,9 @@ 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 - code, stdout, stderr = cs.main( - '-q', '16', '-w', d, count=False, std=True) + result = cs.main('-q', '16', '-w', d, count=False, std=True) + assert isinstance(result, tuple) + code, stdout, stderr = result assert code == 0 assert stdout == stderr == '' assert cs.main(d) == 0 @@ -123,17 +148,21 @@ def test_basic(tmpdir, capsys): assert cs.main(d) == 0 -def test_bad_glob(tmpdir, capsys): +def test_bad_glob( + tmpdir: pytest.TempPathFactory, + capsys: pytest.CaptureFixture[str], +) -> None: # disregard invalid globs, properly handle escaped globs - g = op.join(tmpdir, 'glob') + g = op.join(str(tmpdir), 'glob') os.mkdir(g) fname = op.join(g, '[b-a].txt') with open(fname, 'a') as f: f.write('abandonned\n') assert cs.main(g) == 1 # bad glob is invalid - code, _, stderr = cs.main('--skip', '[b-a].txt', - g, std=True) + result = cs.main('--skip', '[b-a].txt', g, std=True) + assert isinstance(result, tuple) + code, _, stderr = result if sys.hexversion < 0x030A05F0: # Python < 3.10.5 raises re.error assert code == EX_USAGE, 'invalid glob' assert 'invalid glob' in stderr @@ -145,19 +174,29 @@ def test_bad_glob(tmpdir, capsys): @pytest.mark.skipif( not sys.platform == 'linux', reason='Only supported on Linux') -def test_permission_error(tmp_path, capsys): +def test_permission_error( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: """Test permission error handling.""" d = tmp_path with open(d / 'unreadable.txt', 'w') as f: f.write('abandonned\n') - code, _, stderr = cs.main(f.name, std=True) + result = cs.main(f.name, std=True) + assert isinstance(result, tuple) + code, _, stderr = result assert 'WARNING:' not in stderr os.chmod(f.name, 0o000) - code, _, stderr = cs.main(f.name, std=True) + result = cs.main(f.name, std=True) + assert isinstance(result, tuple) + code, _, stderr = result assert 'WARNING:' in stderr -def test_interactivity(tmpdir, capsys): +def test_interactivity( + tmpdir: pytest.TempPathFactory, + capsys: pytest.CaptureFixture[str], +) -> None: """Test interaction""" # Windows can't read a currently-opened file, so here we use # NamedTemporaryFile just to get a good name @@ -171,7 +210,9 @@ def test_interactivity(tmpdir, capsys): with FakeStdin('y\n'): assert cs.main('-i', '3', f.name) == 1 with FakeStdin('n\n'): - code, stdout, _ = cs.main('-w', '-i', '3', f.name, std=True) + result = cs.main('-w', '-i', '3', f.name, std=True) + assert isinstance(result, tuple) + code, stdout, _ = result assert code == 0 assert '==>' in stdout with FakeStdin('x\ny\n'): @@ -213,7 +254,9 @@ def test_interactivity(tmpdir, capsys): f.write('ackward\n') assert cs.main(f.name) == 1 with FakeStdin('x\n1\n'): # blank input -> nothing - code, stdout, _ = cs.main('-w', '-i', '3', f.name, std=True) + result = cs.main('-w', '-i', '3', f.name, std=True) + assert isinstance(result, tuple) + code, stdout, _ = result assert code == 0 assert 'a valid option' in stdout assert cs.main(f.name) == 0 @@ -223,14 +266,21 @@ def test_interactivity(tmpdir, capsys): os.remove(f.name) -def test_summary(tmpdir, capsys): +def test_summary( + tmpdir: pytest.TempPathFactory, + capsys: pytest.CaptureFixture[str], +) -> None: """Test summary functionality.""" with open(op.join(str(tmpdir), 'tmp'), 'w') as f: pass - code, stdout, stderr = cs.main(f.name, std=True, count=False) + result = cs.main(f.name, std=True, count=False) + assert isinstance(result, tuple) + code, stdout, stderr = result assert code == 0 assert stdout == stderr == '', 'no output' - code, stdout, stderr = cs.main(f.name, '--summary', std=True) + result = cs.main(f.name, '--summary', std=True) + assert isinstance(result, tuple) + code, stdout, stderr = result assert code == 0 assert stderr == '0\n' assert 'SUMMARY' in stdout @@ -238,14 +288,19 @@ def test_summary(tmpdir, capsys): with open(f.name, 'w') as f: f.write('abandonned\nabandonned') assert code == 0 - code, stdout, stderr = cs.main(f.name, '--summary', std=True) + result = cs.main(f.name, '--summary', std=True) + assert isinstance(result, tuple) + code, stdout, stderr = result 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, capsys): +def test_ignore_dictionary( + tmpdir: pytest.TempPathFactory, + capsys: pytest.CaptureFixture[str], +) -> None: """Test ignore dictionary functionality.""" d = str(tmpdir) with open(op.join(d, 'bad.txt'), 'w') as f: @@ -257,7 +312,10 @@ def test_ignore_dictionary(tmpdir, capsys): assert cs.main('-I', f.name, bad_name) == 1 -def test_ignore_word_list(tmpdir, capsys): +def test_ignore_word_list( + tmpdir: pytest.TempPathFactory, + capsys: pytest.CaptureFixture[str], +) -> None: """Test ignore word list functionality.""" d = str(tmpdir) with open(op.join(d, 'bad.txt'), 'w') as f: @@ -266,19 +324,27 @@ def test_ignore_word_list(tmpdir, capsys): assert cs.main('-Labandonned,someword', '-Labilty', d) == 1 -def test_custom_regex(tmpdir, capsys): +def test_custom_regex( + tmpdir: pytest.TempPathFactory, + capsys: pytest.CaptureFixture[str], +) -> None: """Test custom word regex.""" d = str(tmpdir) with open(op.join(d, 'bad.txt'), 'w') as f: f.write('abandonned_abondon\n') assert cs.main(d) == 0 assert cs.main('-r', "[a-z]+", d) == 2 - code, _, stderr = cs.main('-r', '[a-z]+', '--write-changes', d, std=True) + result = cs.main('-r', '[a-z]+', '--write-changes', d, std=True) + assert isinstance(result, tuple) + code, _, stderr = result assert code == EX_USAGE assert 'ERROR:' in stderr -def test_exclude_file(tmpdir, capsys): +def test_exclude_file( + tmpdir: pytest.TempPathFactory, + capsys: pytest.CaptureFixture[str], +) -> None: """Test exclude file functionality.""" d = str(tmpdir) with open(op.join(d, 'bad.txt'), 'wb') as f: @@ -291,10 +357,13 @@ def test_exclude_file(tmpdir, capsys): assert cs.main('-x', f.name, bad_name) == 1 -def test_encoding(tmpdir, capsys): +def test_encoding( + tmpdir: pytest.TempPathFactory, + capsys: pytest.CaptureFixture[str], +) -> None: """Test encoding handling.""" # Some simple Unicode things - with open(op.join(str(tmpdir), 'tmp'), 'w') as f: + with open(op.join(str(tmpdir), 'tmp'), 'wb') as f: pass # with CaptureStdout() as sio: assert cs.main(f.name) == 0 @@ -309,28 +378,39 @@ def test_encoding(tmpdir, capsys): with open(f.name, 'wb') as f: f.write(b'Speling error, non-ASCII: h\xe9t\xe9rog\xe9n\xe9it\xe9\n') # check warnings about wrong encoding are enabled with "-q 0" - code, stdout, stderr = cs.main('-q', '0', f.name, std=True, count=True) + result = cs.main('-q', '0', f.name, std=True, count=True) + assert isinstance(result, tuple) + code, stdout, stderr = result assert code == 1 assert 'Speling' in stdout assert 'iso-8859-1' in stderr # check warnings about wrong encoding are disabled with "-q 1" - code, stdout, stderr = cs.main('-q', '1', f.name, std=True, count=True) + result = cs.main('-q', '1', f.name, std=True, count=True) + assert isinstance(result, tuple) + code, stdout, stderr = result assert code == 1 assert 'Speling' in stdout assert 'iso-8859-1' not in stderr # 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) + result = cs.main(f.name, std=True, count=False) + assert isinstance(result, tuple) + code, stdout, stderr = result assert code == 0 assert stdout == stderr == '' - code, stdout, stderr = cs.main('-q', '0', f.name, std=True, count=False) + result = cs.main('-q', '0', f.name, std=True, count=False) + assert isinstance(result, tuple) + code, stdout, stderr = result assert code == 0 assert stdout == '' assert 'WARNING: Binary file' in stderr -def test_ignore(tmpdir, capsys): +def test_ignore( + tmpdir: pytest.TempPathFactory, + capsys: pytest.CaptureFixture[str], +) -> None: """Test ignoring of files and directories.""" d = str(tmpdir) goodtxt = op.join(d, 'good.txt') @@ -357,7 +437,10 @@ def test_ignore(tmpdir, capsys): assert cs.main('--skip=*.js', goodtxt, badtxt, badjs) == 1 -def test_check_filename(tmpdir, capsys): +def test_check_filename( + tmpdir: pytest.TempPathFactory, + capsys: pytest.CaptureFixture[str], +) -> None: """Test filename check.""" d = str(tmpdir) # Empty file @@ -376,7 +459,10 @@ def test_check_filename(tmpdir, capsys): @pytest.mark.skipif((not hasattr(os, "mkfifo") or not callable(os.mkfifo)), reason='requires os.mkfifo') -def test_check_filename_irregular_file(tmpdir, capsys): +def test_check_filename_irregular_file( + tmpdir: pytest.TempPathFactory, + capsys: pytest.CaptureFixture[str], +) -> None: """Test irregular file filename check.""" # Irregular file (!isfile()) d = str(tmpdir) @@ -385,7 +471,10 @@ def test_check_filename_irregular_file(tmpdir, capsys): d = str(tmpdir) -def test_check_hidden(tmpdir, capsys): +def test_check_hidden( + tmpdir: pytest.TempPathFactory, + capsys: pytest.CaptureFixture[str], +) -> None: """Test ignoring of hidden files.""" d = str(tmpdir) # visible file @@ -419,7 +508,7 @@ def test_check_hidden(tmpdir, capsys): assert cs.main('--check-hidden', d) == 2 assert cs.main('--check-hidden', '--check-filenames', d) == 5 # check again with a relative path - rel = op.relpath(tmpdir) + rel = op.relpath(d) assert cs.main(rel) == 0 assert cs.main('--check-hidden', rel) == 2 assert cs.main('--check-hidden', '--check-filenames', rel) == 5 @@ -437,26 +526,37 @@ def test_check_hidden(tmpdir, capsys): assert cs.main('--check-hidden', '--check-filenames', d) == 8 -def test_case_handling(tmpdir, capsys): +def test_case_handling( + tmpdir: pytest.TempPathFactory, + capsys: pytest.CaptureFixture[str], +) -> None: """Test that capitalized entries get detected properly.""" # Some simple Unicode things - with open(op.join(str(tmpdir), 'tmp'), 'w') as f: + with open(op.join(str(tmpdir), 'tmp'), 'wb') as f: pass # with CaptureStdout() as sio: assert cs.main(f.name) == 0 with open(f.name, 'wb') as f: f.write(b'this has an ACII error') - code, stdout, _ = cs.main(f.name, std=True) + result = cs.main(f.name, std=True) + assert isinstance(result, tuple) + code, stdout, _ = result assert code == 1 assert 'ASCII' in stdout - code, _, stderr = cs.main('-w', f.name, std=True) + result = cs.main('-w', f.name, std=True) + assert isinstance(result, tuple) + code, _, stderr = result 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' + with open(f.name, 'rb') as fp: + assert fp.read().decode('utf-8') == 'this has an ASCII error' -def _helper_test_case_handling_in_fixes(tmpdir, capsys, reason): +def _helper_test_case_handling_in_fixes( + tmpdir: pytest.TempPathFactory, + capsys: pytest.CaptureFixture[str], + reason: bool, +) -> None: d = str(tmpdir) with open(op.join(d, 'dictionary.txt'), 'w') as f: @@ -469,7 +569,9 @@ def _helper_test_case_handling_in_fixes(tmpdir, capsys, reason): # the mispelled word is entirely lowercase with open(op.join(d, 'bad.txt'), 'w') as f: f.write('early adoptor\n') - code, stdout, _ = cs.main('-D', dictionary_name, f.name, std=True) + result = cs.main('-D', dictionary_name, f.name, std=True) + assert isinstance(result, tuple) + code, stdout, _ = result # all suggested fixes must be lowercase too assert 'adopter, adaptor' in stdout # the reason, if any, must not be modified @@ -479,7 +581,9 @@ def _helper_test_case_handling_in_fixes(tmpdir, capsys, reason): # the mispelled word is capitalized with open(op.join(d, 'bad.txt'), 'w') as f: f.write('Early Adoptor\n') - code, stdout, _ = cs.main('-D', dictionary_name, f.name, std=True) + result = cs.main('-D', dictionary_name, f.name, std=True) + assert isinstance(result, tuple) + code, stdout, _ = result # all suggested fixes must be capitalized too assert 'Adopter, Adaptor' in stdout # the reason, if any, must not be modified @@ -489,7 +593,9 @@ def _helper_test_case_handling_in_fixes(tmpdir, capsys, reason): # the mispelled word is entirely uppercase with open(op.join(d, 'bad.txt'), 'w') as f: f.write('EARLY ADOPTOR\n') - code, stdout, _ = cs.main('-D', dictionary_name, f.name, std=True) + result = cs.main('-D', dictionary_name, f.name, std=True) + assert isinstance(result, tuple) + code, stdout, _ = result # all suggested fixes must be uppercase too assert 'ADOPTER, ADAPTOR' in stdout # the reason, if any, must not be modified @@ -499,7 +605,9 @@ def _helper_test_case_handling_in_fixes(tmpdir, capsys, reason): # the mispelled word mixes lowercase and uppercase with open(op.join(d, 'bad.txt'), 'w') as f: f.write('EaRlY AdOpToR\n') - code, stdout, _ = cs.main('-D', dictionary_name, f.name, std=True) + result = cs.main('-D', dictionary_name, f.name, std=True) + assert isinstance(result, tuple) + code, stdout, _ = result # all suggested fixes should be lowercase assert 'adopter, adaptor' in stdout # the reason, if any, must not be modified @@ -507,20 +615,28 @@ def _helper_test_case_handling_in_fixes(tmpdir, capsys, reason): assert 'reason' in stdout -def test_case_handling_in_fixes(tmpdir, capsys): +def test_case_handling_in_fixes( + tmpdir: pytest.TempPathFactory, + capsys: pytest.CaptureFixture[str] +) -> None: """Test that the case of fixes is similar to the mispelled word.""" _helper_test_case_handling_in_fixes(tmpdir, capsys, reason=False) _helper_test_case_handling_in_fixes(tmpdir, capsys, reason=True) -def test_context(tmpdir, capsys): +def test_context( + tmpdir: pytest.TempPathFactory, + capsys: pytest.CaptureFixture[str], +) -> None: """Test context options.""" d = str(tmpdir) with open(op.join(d, 'context.txt'), 'w') as f: f.write('line 1\nline 2\nline 3 abandonned\nline 4\nline 5') # symmetric context, fully within file - code, stdout, _ = cs.main('-C', '1', d, std=True) + result = cs.main('-C', '1', d, std=True) + assert isinstance(result, tuple) + code, stdout, _ = result assert code == 1 lines = stdout.split('\n') assert len(lines) == 5 @@ -529,7 +645,9 @@ def test_context(tmpdir, capsys): assert lines[2] == ': line 4' # requested context is bigger than the file - code, stdout, _ = cs.main('-C', '10', d, std=True) + result = cs.main('-C', '10', d, std=True) + assert isinstance(result, tuple) + code, stdout, _ = result assert code == 1 lines = stdout.split('\n') assert len(lines) == 7 @@ -540,7 +658,9 @@ def test_context(tmpdir, capsys): assert lines[4] == ': line 5' # only before context - code, stdout, _ = cs.main('-B', '2', d, std=True) + result = cs.main('-B', '2', d, std=True) + assert isinstance(result, tuple) + code, stdout, _ = result assert code == 1 lines = stdout.split('\n') assert len(lines) == 5 @@ -549,7 +669,9 @@ def test_context(tmpdir, capsys): assert lines[2] == '> line 3 abandonned' # only after context - code, stdout, _ = cs.main('-A', '1', d, std=True) + result = cs.main('-A', '1', d, std=True) + assert isinstance(result, tuple) + code, stdout, _ = result assert code == 1 lines = stdout.split('\n') assert len(lines) == 4 @@ -557,7 +679,9 @@ def test_context(tmpdir, capsys): assert lines[1] == ': line 4' # asymmetric context - code, stdout, _ = cs.main('-B', '2', '-A', '1', d, std=True) + result = cs.main('-B', '2', '-A', '1', d, std=True) + assert isinstance(result, tuple) + code, stdout, _ = result assert code == 1 lines = stdout.split('\n') assert len(lines) == 6 @@ -567,24 +691,33 @@ def test_context(tmpdir, capsys): assert lines[3] == ': line 4' # both '-C' and '-A' on the command line - code, _, stderr = cs.main('-C', '2', '-A', '1', d, std=True) + result = cs.main('-C', '2', '-A', '1', d, std=True) + assert isinstance(result, tuple) + code, _, stderr = result assert code == EX_USAGE lines = stderr.split('\n') assert 'ERROR' in lines[0] # both '-C' and '-B' on the command line - code, _, stderr = cs.main('-C', '2', '-B', '1', d, std=True) + result = cs.main('-C', '2', '-B', '1', d, std=True) + assert isinstance(result, tuple) + code, _, stderr = result assert code == EX_USAGE lines = stderr.split('\n') assert 'ERROR' in lines[0] -def test_ignore_regex_option(tmpdir, capsys): +def test_ignore_regex_option( + tmpdir: pytest.TempPathFactory, + capsys: pytest.CaptureFixture[str], +) -> None: """Test ignore regex option functionality.""" d = str(tmpdir) # Invalid regex. - code, stdout, _ = cs.main('--ignore-regex=(', std=True) + result = cs.main('--ignore-regex=(', std=True) + assert isinstance(result, tuple) + code, stdout, _ = result assert code == EX_USAGE assert 'usage:' in stdout @@ -612,12 +745,17 @@ def test_ignore_regex_option(tmpdir, capsys): assert cs.main(f.name, r'--ignore-regex=\bdonn\b') == 1 -def test_uri_regex_option(tmpdir, capsys): +def test_uri_regex_option( + tmpdir: pytest.TempPathFactory, + capsys: pytest.CaptureFixture[str], +) -> None: """Test --uri-regex option functionality.""" d = str(tmpdir) # Invalid regex. - code, stdout, _ = cs.main('--uri-regex=(', std=True) + result = cs.main('--uri-regex=(', std=True) + assert isinstance(result, tuple) + code, stdout, _ = result assert code == EX_USAGE assert 'usage:' in stdout @@ -646,7 +784,10 @@ def test_uri_regex_option(tmpdir, capsys): '--uri-ignore-words-list=abandonned') == 0 -def test_uri_ignore_words_list_option_uri(tmpdir, capsys): +def test_uri_ignore_words_list_option_uri( + tmpdir: pytest.TempPathFactory, + capsys: pytest.CaptureFixture[str], +) -> None: """Test ignore regex option functionality.""" d = str(tmpdir) @@ -708,7 +849,10 @@ def test_uri_ignore_words_list_option_uri(tmpdir, capsys): assert cs.main(f.name, variation_option) == 1, variation -def test_uri_ignore_words_list_option_email(tmpdir, capsys): +def test_uri_ignore_words_list_option_email( + tmpdir: pytest.TempPathFactory, + capsys: pytest.CaptureFixture[str], +) -> None: """Test ignore regex option functionality.""" d = str(tmpdir) @@ -761,7 +905,7 @@ def test_uri_ignore_words_list_option_email(tmpdir, capsys): assert cs.main(f.name, variation_option) == 1, variation -def test_uri_regex_def(): +def test_uri_regex_def() -> None: uri_regex = re.compile(uri_regex_def) # Tests based on https://mathiasbynens.be/demo/url-regex @@ -855,7 +999,11 @@ def test_uri_regex_def(): @pytest.mark.parametrize('kind', ('toml', 'cfg')) -def test_config_toml(tmp_path, capsys, kind): +def test_config_toml( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + kind: str, +) -> None: """Test loading options from a config file or toml.""" d = tmp_path / 'files' d.mkdir() @@ -865,7 +1013,9 @@ def test_config_toml(tmp_path, capsys, kind): f.write("good") # Should fail when checking both. - code, stdout, _ = cs.main(str(d), count=True, std=True) + result = cs.main(str(d), count=True, std=True) + assert isinstance(result, tuple) + code, stdout, _ = result # Code in this case is not exit code, but count of misspellings. assert code == 2 assert 'bad.txt' in stdout @@ -892,7 +1042,9 @@ def test_config_toml(tmp_path, capsys, kind): """) # Should pass when skipping bad.txt - code, stdout, _ = cs.main(str(d), *args, count=True, std=True) + result = cs.main(str(d), *args, count=True, std=True) + assert isinstance(result, tuple) + code, stdout, _ = result assert code == 0 assert 'bad.txt' not in stdout @@ -900,7 +1052,9 @@ def test_config_toml(tmp_path, capsys, kind): cwd = os.getcwd() try: os.chdir(tmp_path) - code, stdout, _ = cs.main(str(d), count=True, std=True) + result = cs.main(str(d), count=True, std=True) + assert isinstance(result, tuple) + code, stdout, _ = result finally: os.chdir(cwd) assert code == 0 @@ -908,7 +1062,7 @@ def test_config_toml(tmp_path, capsys, kind): @contextlib.contextmanager -def FakeStdin(text): +def FakeStdin(text: str) -> Generator[None, None, None]: oldin = sys.stdin try: in_ = StringIO(text) diff --git a/codespell_lib/tests/test_dictionary.py b/codespell_lib/tests/test_dictionary.py index 745b4ab72c..327c647d2f 100644 --- a/codespell_lib/tests/test_dictionary.py +++ b/codespell_lib/tests/test_dictionary.py @@ -3,6 +3,7 @@ import os.path as op import re import warnings +from typing import Any, Dict, Iterable, Optional, Set, Tuple import pytest @@ -11,7 +12,7 @@ spellers = {} try: - import aspell + import aspell # type: ignore[import] for lang in supported_languages: spellers[lang] = aspell.Speller('lang', lang) except Exception as exp: # probably ImportError, but maybe also language @@ -25,8 +26,8 @@ 'aspell not found, but not required, skipping aspell tests. Got ' 'error during import:\n%s' % (exp,)) -global_err_dicts = {} -global_pairs = set() +global_err_dicts: Dict[str, Dict[str, Any]] = {} +global_pairs: Set[Tuple[str, str]] = set() # Filename, should be seen as errors in aspell or not _data_dir = op.join(op.dirname(__file__), '..', 'data') @@ -36,7 +37,7 @@ fname_params = pytest.mark.parametrize('fname, in_aspell, in_dictionary', _fnames_in_aspell) # noqa: E501 -def test_dictionaries_exist(): +def test_dictionaries_exist() -> None: """Test consistency of dictionaries.""" doc_fnames = {op.basename(f[0]) for f in _fnames_in_aspell} got_fnames = {op.basename(f) @@ -45,7 +46,11 @@ def test_dictionaries_exist(): @fname_params -def test_dictionary_formatting(fname, in_aspell, in_dictionary): +def test_dictionary_formatting( + fname: str, + in_aspell: Tuple[bool, bool], + in_dictionary: Tuple[Iterable[str], Iterable[str]], +) -> None: """Test that all dictionary entries are valid.""" errors = [] with open(fname, 'rb') as fid: @@ -61,7 +66,13 @@ def test_dictionary_formatting(fname, in_aspell, in_dictionary): raise AssertionError('\n' + '\n'.join(errors)) -def _check_aspell(phrase, msg, in_aspell, fname, languages): +def _check_aspell( + phrase: str, + msg: str, + in_aspell: Optional[bool], + fname: str, + languages: Iterable[str], +) -> None: if not spellers: # if no spellcheckers exist return # cannot check if in_aspell is None: @@ -90,7 +101,13 @@ def _check_aspell(phrase, msg, in_aspell, fname, languages): single_comma = re.compile(r'^[^,]*,\s*$') -def _check_err_rep(err, rep, in_aspell, fname, languages): +def _check_err_rep( + err: str, + rep: str, + in_aspell: Tuple[Optional[bool], Optional[bool]], + fname: str, + languages: Tuple[Iterable[str], Iterable[str]], +) -> None: assert whitespace.search(err) is None, 'error %r has whitespace' % err assert ',' not in err, 'error %r has a comma' % err assert len(rep) > 0, ('error %s: correction %r must be non-empty' @@ -100,7 +117,7 @@ def _check_err_rep(err, rep, in_aspell, fname, languages): % (err, rep)) _check_aspell(err, 'error %r' % (err,), in_aspell[0], fname, languages[0]) prefix = 'error %s: correction %r' % (err, rep) - for (r, msg) in [ + for (regex, msg) in [ (start_comma, '%s starts with a comma'), (whitespace_comma, @@ -113,7 +130,7 @@ def _check_err_rep(err, rep, in_aspell, fname, languages): '%s has a trailing space'), (single_comma, '%s has a single entry but contains a trailing comma')]: - assert not r.search(rep), (msg % (prefix,)) + assert not regex.search(rep), (msg % (prefix,)) del msg if rep.count(','): assert rep.endswith(','), ('error %s: multiple corrections must end ' @@ -150,7 +167,7 @@ def _check_err_rep(err, rep, in_aspell, fname, languages): ('a', 'a', 'corrects to itself'), ('a', 'bar, Bar,', 'unique'), ]) -def test_error_checking(err, rep, match): +def test_error_checking(err: str, rep: str, match: str) -> None: """Test that our error checking works.""" with pytest.raises(AssertionError, match=match): _check_err_rep(err, rep, (None, None), 'dummy', @@ -182,7 +199,13 @@ def test_error_checking(err, rep, match): ('a', 'bar abcdef', None, True, 'should be in aspell'), ('a', 'abcdef back', None, False, 'should not be in aspell'), ]) -def test_error_checking_in_aspell(err, rep, err_aspell, rep_aspell, match): +def test_error_checking_in_aspell( + err: str, + rep: str, + err_aspell: Optional[bool], + rep_aspell: Optional[bool], + match: str +) -> None: """Test that our error checking works with aspell.""" with pytest.raises(AssertionError, match=match): _check_err_rep( @@ -205,7 +228,11 @@ def test_error_checking_in_aspell(err, rep, err_aspell, rep_aspell, match): @fname_params @pytest.mark.dependency(name='dictionary loop') -def test_dictionary_looping(fname, in_aspell, in_dictionary): +def test_dictionary_looping( + fname: str, + in_aspell: Tuple[bool, bool], + in_dictionary: Tuple[bool, bool], +) -> None: """Test that all dictionary entries are valid.""" this_err_dict = {} short_fname = op.basename(fname) @@ -263,7 +290,7 @@ def test_dictionary_looping(fname, in_aspell, in_dictionary): @pytest.mark.dependency(depends=['dictionary loop']) -def test_ran_all(): +def test_ran_all() -> None: """Test that all pairwise tests ran.""" for f1, _, _ in _fnames_in_aspell: f1 = op.basename(f1) diff --git a/pyproject.toml b/pyproject.toml index 71d90edf73..4c45d3de38 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,6 +40,12 @@ hard-encoding-detection = [ toml = [ "tomli; python_version < '3.11'" ] +types = [ + "mypy", + "pytest", + "types-chardet", + "types-setuptools", +] [project.scripts] codespell = "codespell_lib:_script_main" @@ -64,11 +70,17 @@ exclude = [ [tool.setuptools.package-data] codespell_lib = [ "data/dictionary*.txt", - "data/linux-kernel.exclude" + "data/linux-kernel.exclude", + "py.typed", ] [tool.check-manifest] ignore = ["codespell_lib/_version.py"] +[tool.mypy] +pretty = true +show_error_codes = true +strict = true + [tool.pytest.ini_options] addopts = "--cov=codespell_lib -rs --cov-report= --tb=short --junit-xml=junit-results.xml"