| # | |
| # This program is free software; you can redistribute it and/or modify | |
| # it under the terms of the GNU General Public License as published by | |
| # the Free Software Foundation; version 2 of the License. | |
| # | |
| # This program is distributed in the hope that it will be useful, | |
| # but WITHOUT ANY WARRANTY; without even the implied warranty of | |
| # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |
| # GNU General Public License for more details. | |
| # | |
| # You should have received a copy of the GNU General Public License | |
| # along with this program; if not, see | |
| # https://www.gnu.org/licenses/old-licenses/gpl-2.0.html. | |
| """ | |
| Copyright (C) 2010-2011 Lucas De Marchi <lucas.de.marchi@gmail.com> | |
| Copyright (C) 2011 ProFUSION embedded systems | |
| """ | |
| import argparse | |
| import configparser | |
| import ctypes | |
| import fnmatch | |
| import itertools | |
| import os | |
| import re | |
| import shlex | |
| import stat | |
| import sys | |
| import tempfile | |
| import textwrap | |
| from collections.abc import Iterable, Sequence | |
| from re import Match, Pattern | |
| from typing import ( | |
| Any, | |
| Optional, | |
| TextIO, | |
| ) | |
| if sys.platform == "win32": | |
| from ctypes import wintypes | |
| ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004 | |
| STD_OUTPUT_HANDLE = wintypes.HANDLE(-11) | |
| from ._spellchecker import Misspelling, build_dict | |
| from ._text_util import fix_case | |
| # autogenerated by setuptools_scm | |
| from ._version import ( # type: ignore[import-not-found] | |
| __version__ as VERSION, # noqa: N812 | |
| ) | |
| word_regex_def = r"[\w\-'’]+" # noqa: RUF001 | |
| # While we want to treat characters like ( or " as okay for a starting break, | |
| # these may occur unescaped in URIs, and so we are more restrictive on the | |
| # endpoint. Emails are more restrictive, so the endpoint remains flexible. | |
| uri_regex_def = ( | |
| r"(\b(?:https?|[ts]?ftp|file|git|smb)://[^\s]+(?=$|\s)|\b[\w.%+-]+@[\w.-]+\b)" | |
| ) | |
| codespell_ignore_tag = "codespell:ignore" | |
| codespell_ignore_next_line_tag = "codespell:ignore-next-line" | |
| inline_ignore_regex = re.compile( | |
| rf"[^\w\s]\s*{codespell_ignore_tag}(?!-)\b(\s+(?P<words>[\w,]*))?" | |
| ) | |
| ignore_next_line_regex = re.compile( | |
| rf"[^\w\s]\s*{codespell_ignore_next_line_tag}\b(\s+(?P<words>[\w,]*))?" | |
| ) | |
| # Editorial "[sic]" marker following a word, allowing an intervening closing | |
| # quote so a quoted misspelling like `"..." [sic]` is matched. | |
| sic_regex = re.compile(r"[\"'’”)\s]*\[sic\]", re.IGNORECASE) # noqa: RUF001 | |
| USAGE = """ | |
| \t%prog [OPTIONS] [file1 file2 ... fileN] | |
| """ | |
| supported_languages_en = ("en", "en_GB", "en_US", "en_CA", "en_AU") | |
| supported_languages = supported_languages_en | |
| # Users might want to link this file into /usr/local/bin, so we resolve the | |
| # symbolic link path to the real path if necessary. | |
| _data_root = os.path.join(os.path.dirname(os.path.realpath(__file__)), "data") | |
| _builtin_dictionaries = ( | |
| # name, desc, name, err in aspell, correction in aspell, \ | |
| # err dictionary array, rep dictionary array | |
| # The arrays must contain the names of aspell dictionaries | |
| # The aspell tests here aren't the ideal state, but the None's are | |
| # realistic for obscure words | |
| ("clear", "for unambiguous errors", "", False, None, supported_languages_en, None), | |
| ( | |
| "rare", | |
| "for rare (but valid) words that are likely to be errors", | |
| "_rare", | |
| None, | |
| None, | |
| None, | |
| None, | |
| ), | |
| ( | |
| "informal", | |
| "for making informal words more formal", | |
| "_informal", | |
| True, | |
| True, | |
| supported_languages_en, | |
| supported_languages_en, | |
| ), | |
| ( | |
| "usage", | |
| "for replacing phrasing with recommended terms", | |
| "_usage", | |
| None, | |
| None, | |
| None, | |
| None, | |
| ), | |
| ( | |
| "code", | |
| "for words from code and/or mathematics that are likely to be typos in other contexts (such as uint)", # noqa: E501 | |
| "_code", | |
| None, | |
| None, | |
| None, | |
| None, | |
| ), | |
| ( | |
| "names", | |
| "for valid proper names that might be typos", | |
| "_names", | |
| None, | |
| None, | |
| None, | |
| None, | |
| ), | |
| ( | |
| "en-GB_to_en-US", | |
| "for corrections from en-GB to en-US", | |
| "_en-GB_to_en-US", | |
| True, | |
| True, | |
| ("en_GB",), | |
| ("en_US",), | |
| ), | |
| ( | |
| "en_to_en-OX", | |
| "for corrections from en to en-OX", | |
| "_en_to_en-OX_AUTOGENERATED", | |
| None, | |
| None, | |
| None, | |
| None, | |
| ), | |
| ) | |
| _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 | |
| EX_CONFIG = 78 | |
| # OPTIONS: | |
| # | |
| # ARGUMENTS: | |
| # dict_filename The file containing the dictionary of misspellings. | |
| # If set to '-', it will be read from stdin | |
| # file1 .. fileN Files to check spelling | |
| class QuietLevels: | |
| NONE = 0 | |
| ENCODING = 1 | |
| BINARY_FILE = 2 | |
| DISABLED_FIXES = 4 | |
| NON_AUTOMATIC_FIXES = 8 | |
| FIXES = 16 | |
| CONFIG_FILES = 32 | |
| class GlobMatch: | |
| def __init__(self, pattern: list[str]) -> None: | |
| self.pattern_list: list[str] = pattern | |
| def match(self, filename: str) -> bool: | |
| return any(fnmatch.fnmatch(filename, p) for p in self.pattern_list) | |
| class TermColors: | |
| def __init__(self) -> None: | |
| self.FILE = "\033[33m" | |
| self.WWORD = "\033[31m" | |
| self.FWORD = "\033[32m" | |
| self.DISABLE = "\033[0m" | |
| def disable(self) -> None: | |
| self.FILE = "" | |
| self.WWORD = "" | |
| self.FWORD = "" | |
| self.DISABLE = "" | |
| class Summary: | |
| def __init__(self) -> None: | |
| self.summary: dict[str, int] = {} | |
| def update(self, wrongword: str) -> None: | |
| if wrongword in self.summary: | |
| self.summary[wrongword] += 1 | |
| else: | |
| self.summary[wrongword] = 1 | |
| def __str__(self) -> str: | |
| keys = list(self.summary.keys()) | |
| keys.sort() | |
| return "\n".join( | |
| [f"{key}{self.summary.get(key):{15 - len(key)}}" for key in keys] | |
| ) | |
| class FileOpener: | |
| def __init__( | |
| self, | |
| use_chardet: bool, | |
| quiet_level: int, | |
| ignore_multiline_regex: Optional[Pattern[str]], | |
| ) -> None: | |
| self.use_chardet = use_chardet | |
| if use_chardet: | |
| self.init_chardet() | |
| self.quiet_level = quiet_level | |
| self.ignore_multiline_regex = ignore_multiline_regex | |
| def init_chardet(self) -> None: | |
| try: | |
| import chardet # noqa: F401 | |
| except ImportError: | |
| msg = ( | |
| "There's no chardet installed to import from. " | |
| "Please, install it and check your PYTHONPATH " | |
| "environment variable" | |
| ) | |
| raise ImportError(msg) | |
| try: | |
| from chardet import UniversalDetector | |
| except ImportError: # chardet < 7 | |
| from chardet.universaldetector import UniversalDetector | |
| self.encdetector = UniversalDetector() | |
| def open(self, filename: str) -> tuple[list[tuple[bool, int, list[str]]], str]: | |
| if self.use_chardet: | |
| return self.open_with_chardet(filename) | |
| return self.open_with_internal(filename) | |
| def open_with_chardet( | |
| self, filename: str | |
| ) -> tuple[list[tuple[bool, int, list[str]]], str]: | |
| self.encdetector.reset() | |
| 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"] | |
| try: | |
| f = open(filename, encoding=encoding, newline="") | |
| except UnicodeDecodeError: | |
| print(f"ERROR: Could not detect encoding: {filename}", file=sys.stderr) | |
| raise | |
| except LookupError: | |
| print( | |
| f"ERROR: Don't know how to handle encoding {encoding}: {filename}", | |
| file=sys.stderr, | |
| ) | |
| raise | |
| else: | |
| lines = self.get_lines(f) | |
| f.close() | |
| return lines, f.encoding | |
| def open_with_internal( | |
| self, filename: str | |
| ) -> tuple[list[tuple[bool, int, list[str]]], str]: | |
| encoding = None | |
| first_try = True | |
| for encoding in ("utf-8", "iso-8859-1"): | |
| if first_try: | |
| first_try = False | |
| elif not self.quiet_level & QuietLevels.ENCODING: | |
| print(f'WARNING: Trying next encoding "{encoding}"', file=sys.stderr) | |
| with open(filename, encoding=encoding, newline="") as f: | |
| try: | |
| lines = self.get_lines(f) | |
| except UnicodeDecodeError: | |
| if not self.quiet_level & QuietLevels.ENCODING: | |
| print( | |
| f'WARNING: Cannot decode file using encoding "{encoding}": ' | |
| f"{filename}", | |
| file=sys.stderr, | |
| ) | |
| else: | |
| break | |
| else: | |
| # reading with encoding "iso-8859-1" cannot fail with UnicodeDecodeError | |
| msg = "Unknown encoding" | |
| raise RuntimeError(msg) # pragma: no cover | |
| return lines, encoding | |
| def get_lines(self, f: TextIO) -> list[tuple[bool, int, list[str]]]: | |
| fragments = [] | |
| line_number = 0 | |
| if self.ignore_multiline_regex: | |
| text = f.read() | |
| pos = 0 | |
| for m in re.finditer(self.ignore_multiline_regex, text): | |
| lines = text[pos : m.start()].splitlines(True) | |
| fragments.append((False, line_number, lines)) | |
| line_number += len(lines) | |
| lines = m.group().splitlines(True) | |
| fragments.append((True, line_number, lines)) | |
| line_number += len(lines) - 1 | |
| pos = m.end() | |
| lines = text[pos:].splitlines(True) | |
| fragments.append((False, line_number, lines)) | |
| else: | |
| fragments.append((False, line_number, f.readlines())) | |
| return fragments | |
| # -.-:-.-:-.-:-.:-.-:-.-:-.-:-.-:-.:-.-:-.-:-.-:-.-:-.:-.-:- | |
| # If someday this breaks, we can just switch to using RawTextHelpFormatter, | |
| # but it has the disadvantage of not wrapping our long lines. | |
| class NewlineHelpFormatter(argparse.HelpFormatter): | |
| """Help formatter that preserves newlines and deals with lists.""" | |
| def _split_lines(self, text: str, width: int) -> list[str]: | |
| parts = text.split("\n") | |
| out = [] | |
| for part in parts: | |
| # Eventually we could allow others... | |
| indent_start = "- " | |
| offset = len(indent_start) if part.startswith(indent_start) else 0 | |
| part = part[offset:] | |
| part = self._whitespace_matcher.sub(" ", part).strip() | |
| parts = textwrap.wrap(part, width - offset) | |
| parts = [" " * offset + p for p in parts] | |
| if offset: | |
| parts[0] = indent_start + parts[0][offset:] | |
| out.extend(parts) | |
| return out | |
| def _toml_to_parseconfig(toml_dict: dict[str, Any]) -> dict[str, Any]: | |
| """Convert a dict read from a TOML file to the parseconfig.read_dict() format.""" | |
| return { | |
| k: "" if v is True else ",".join(v) if isinstance(v, list) else v | |
| for k, v in toml_dict.items() | |
| if v is not False | |
| } | |
| def _supports_ansi_colors() -> bool: | |
| if sys.platform == "win32": | |
| # Windows Terminal enables ANSI escape codes by default. In other cases | |
| # it is disabled. | |
| # See https://ss64.com/nt/syntax-ansi.html for more information. | |
| kernel32 = ctypes.WinDLL("kernel32") | |
| # fmt: off | |
| kernel32.GetConsoleMode.argtypes = ( | |
| wintypes.HANDLE, # _In_ hConsoleHandle | |
| wintypes.LPDWORD, # _Out_ lpMode | |
| ) | |
| # fmt: on | |
| kernel32.GetConsoleMode.restype = wintypes.BOOL | |
| mode = wintypes.DWORD() | |
| handle = kernel32.GetStdHandle(STD_OUTPUT_HANDLE) | |
| if not kernel32.GetConsoleMode(handle, ctypes.byref(mode)): | |
| # TODO: print a warning with the error message on stderr? | |
| return False | |
| return (mode.value & ENABLE_VIRTUAL_TERMINAL_PROCESSING) != 0 | |
| elif sys.platform == "wasi": | |
| # WASI disables ANSI escape codes for security reasons. | |
| # See https://github.com/WebAssembly/WASI/issues/162. | |
| return False | |
| elif sys.stdout.isatty(): | |
| return True | |
| return False | |
| def parse_options( | |
| args: Sequence[str], | |
| ) -> tuple[argparse.Namespace, argparse.ArgumentParser, list[str]]: | |
| # Split lines read from `@PATH` using shlex.split(), otherwise default | |
| # behaviour is to have one arg per line. See: | |
| # https://docs.python.org/3/library/argparse.html#argparse.ArgumentParser.convert_arg_line_to_args | |
| class ArgumentParser2(argparse.ArgumentParser): | |
| def convert_arg_line_to_args(self, arg_line: str) -> list[str]: | |
| if sys.platform == "win32": | |
| # On Windows, shlex.split() seems to be messed up by back | |
| # slashes. Temporarily changing them to forward slashes seems | |
| # to make things work better. | |
| arg_line = arg_line.replace("\\", "/") | |
| ret = shlex.split(arg_line) | |
| ret = [p.replace("/", "\\") for p in ret] | |
| else: | |
| ret = shlex.split(arg_line) | |
| return ret | |
| parser = ArgumentParser2( | |
| formatter_class=NewlineHelpFormatter, | |
| fromfile_prefix_chars="@", | |
| epilog="Use @PATH to read additional arguments from file PATH.", | |
| ) | |
| parser.set_defaults(colors=_supports_ansi_colors()) | |
| parser.add_argument("--version", action="version", version=VERSION) | |
| parser.add_argument( | |
| "-d", | |
| "--disable-colors", | |
| action="store_false", | |
| dest="colors", | |
| help="disable colors, even when printing to terminal", | |
| ) | |
| parser.add_argument( | |
| "-c", | |
| "--enable-colors", | |
| action="store_true", | |
| dest="colors", | |
| help="enable colors, even when not printing to terminal", | |
| ) | |
| parser.add_argument( | |
| "-w", | |
| "--write-changes", | |
| action="store_true", | |
| default=False, | |
| help="write changes in place if possible", | |
| ) | |
| parser.add_argument( | |
| "--exit-non-zero-on-fix", | |
| action="store_true", | |
| default=False, | |
| help="exit with a non-zero status if any changes were written, " | |
| "even if every misspelling was fixed. Has no effect without " | |
| "-w/--write-changes or -i/--interactive.", | |
| ) | |
| parser.add_argument( | |
| "-D", | |
| "--dictionary", | |
| action="append", | |
| help="comma-separated list of custom dictionary files that " | |
| "contain spelling corrections. If this flag is not specified " | |
| 'or equals "-" then the default dictionary is used.', | |
| ) | |
| builtin_opts = "\n- ".join( | |
| [""] + [f"{d[0]!r} {d[1]}" for d in _builtin_dictionaries] | |
| ) | |
| parser.add_argument( | |
| "--builtin", | |
| dest="builtin", | |
| default=_builtin_default, | |
| metavar="BUILTIN-LIST", | |
| help="comma-separated list of builtin dictionaries " | |
| 'to include (when "-D -" or no "-D" is passed). ' | |
| "Use 'all' to include every builtin dictionary. " | |
| "Current options are:" + builtin_opts + "\n" | |
| "The default is %(default)r.", | |
| ) | |
| parser.add_argument( | |
| "--ignore-regex", | |
| action="store", | |
| type=str, | |
| help="regular expression that is used to find " | |
| "patterns to ignore by treating as whitespace. " | |
| "When writing regular expressions, consider " | |
| "ensuring there are boundary non-word chars, " | |
| 'e.g., "\\bmatch\\b". Defaults to ' | |
| "empty/disabled.", | |
| ) | |
| parser.add_argument( | |
| "--ignore-multiline-regex", | |
| action="store", | |
| type=str, | |
| help="regular expression that is used to ignore " | |
| "text that may span multi-line regions. " | |
| "The regex is run with re.DOTALL. For example to " | |
| "allow skipping of regions of Python code using " | |
| "begin/end comments one could use: " | |
| "--ignore-multiline-regex " | |
| "'# codespell:ignore-begin *\\n.*# codespell:ignore-end *\\n'. " | |
| "Defaults to empty/disabled.", | |
| ) | |
| parser.add_argument( | |
| "-I", | |
| "--ignore-words", | |
| action="append", | |
| metavar="FILES", | |
| help="comma-separated list of files that contain " | |
| "words to be ignored by codespell. Files 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 " | |
| "by codespell. Words are case sensitive based on " | |
| "how they are written in the dictionary file.", | |
| ) | |
| parser.add_argument( | |
| "--uri-ignore-words-list", | |
| action="append", | |
| metavar="WORDS", | |
| help="comma-separated list of words to be ignored " | |
| "by codespell in URIs and emails only. Words are " | |
| "case sensitive based on how they are written in " | |
| 'the dictionary file. If set to "*", all ' | |
| "misspelling in URIs and emails will be ignored.", | |
| ) | |
| parser.add_argument( | |
| "-r", | |
| "--regex", | |
| action="store", | |
| type=str, | |
| help="regular expression that is used to find words. " | |
| "By default any alphanumeric character, the " | |
| "underscore, the hyphen, and the apostrophe are " | |
| "used to build words. This option cannot be " | |
| "specified together with --write-changes.", | |
| ) | |
| parser.add_argument( | |
| "--uri-regex", | |
| action="store", | |
| type=str, | |
| help="regular expression that is used to find URIs " | |
| "and emails. A default expression is provided.", | |
| ) | |
| parser.add_argument( | |
| "-s", | |
| "--summary", | |
| 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 " | |
| "accepts globs as well. E.g.: if you want " | |
| "codespell to skip .eps and .txt files, " | |
| 'you\'d give "*.eps,*.txt" to this option.', | |
| ) | |
| parser.add_argument( | |
| "-x", | |
| "--exclude-file", | |
| action="append", | |
| type=str, | |
| metavar="FILES", | |
| help="ignore whole lines that match those in " | |
| "the comma-separated list of files EXCLUDE. " | |
| "The lines in these files should match the " | |
| "to-be-excluded lines exactly", | |
| ) | |
| parser.add_argument( | |
| "-i", | |
| "--interactive", | |
| action="store", | |
| type=int, | |
| default=0, | |
| choices=range(0, 4), | |
| help="set interactive mode when writing changes:\n" | |
| "- 0: no interactivity.\n" | |
| "- 1: ask for confirmation; 'a'/'s' answer for the rest of the file.\n" | |
| "- 2: ask user to choose one fix when more than one is available;" | |
| " 'Na'/'s' answer for the rest of the file.\n" | |
| "- 3: both 1 and 2", | |
| metavar="MODE", | |
| ) | |
| parser.add_argument( | |
| "-q", | |
| "--quiet-level", | |
| action="store", | |
| type=int, | |
| default=34, | |
| choices=range(0, 64), | |
| 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" | |
| "- 4: omit warnings about automatic fixes that were disabled in the dictionary.\n" # noqa: E501 | |
| "- 8: don't print anything for non-automatic fixes.\n" | |
| "- 16: don't print the list of fixed files.\n" | |
| "- 32: don't print configuration files.\n" | |
| "As usual with bitmasks, these levels can be " | |
| "combined; e.g. use 3 for levels 1+2, 7 for " | |
| "1+2+4, 23 for 1+2+4+16, etc. " | |
| "The default mask is %(default)s.", | |
| metavar="LEVEL", | |
| ) | |
| parser.add_argument( | |
| "-e", | |
| "--hard-encoding-detection", | |
| action="store_true", | |
| default=False, | |
| 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.", | |
| ) | |
| parser.add_argument( | |
| "-f", | |
| "--check-filenames", | |
| action="store_true", | |
| default=False, | |
| help="check file names as well", | |
| ) | |
| parser.add_argument( | |
| "-H", | |
| "--check-hidden", | |
| action="store_true", | |
| default=False, | |
| 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", | |
| ) | |
| parser.add_argument( | |
| "-B", | |
| "--before-context", | |
| type=int, | |
| metavar="LINES", | |
| help="print LINES of leading context", | |
| ) | |
| parser.add_argument( | |
| "-C", | |
| "--context", | |
| type=int, | |
| metavar="LINES", | |
| help="print LINES of surrounding context", | |
| ) | |
| parser.add_argument( | |
| "--ignore-sic", | |
| action="store_true", | |
| default=False, | |
| help='ignore a misspelling immediately followed by the editorial "[sic]" ' | |
| "marker (optionally preceded by a closing quote).", | |
| ) | |
| parser.add_argument( | |
| "--stdin-single-line", | |
| action="store_true", | |
| help="output just a single line for each misspelling in stdin mode", | |
| ) | |
| parser.add_argument("--config", type=str, help="path to config file.") | |
| parser.add_argument("--toml", type=str, help="path to a pyproject.toml file.") | |
| parser.add_argument("files", nargs="*", help="files or directories to check") | |
| # Parse command line options. | |
| options = parser.parse_args(list(args)) | |
| # Load config files and look for ``codespell`` options. | |
| cfg_files = ["setup.cfg", ".codespellrc"] | |
| if options.config: | |
| cfg_files.append(options.config) | |
| config = configparser.ConfigParser(interpolation=None) | |
| # Read toml before other config files. | |
| toml_files = [] | |
| used_toml_files = [] | |
| tomllib_raise_error = False | |
| if os.path.isfile("pyproject.toml"): | |
| toml_files.append("pyproject.toml") | |
| if options.toml: | |
| toml_files.append(options.toml) | |
| tomllib_raise_error = True | |
| if toml_files: | |
| if sys.version_info >= (3, 11): | |
| import tomllib | |
| else: | |
| try: | |
| import tomli as tomllib # type: ignore[no-redef] | |
| except ImportError as e: | |
| if tomllib_raise_error: | |
| msg = ( | |
| f"tomllib or tomli are required to read pyproject.toml " | |
| f"but could not be imported, got: {e}" | |
| ) | |
| raise ImportError(msg) from None | |
| tomllib = None # type: ignore[assignment] | |
| if tomllib is not None: | |
| for toml_file in toml_files: | |
| with open(toml_file, "rb") as f: | |
| data = tomllib.load(f).get("tool", {}).get("codespell") | |
| if data is not None: | |
| if not isinstance(data, dict): | |
| msg = f"{toml_file}: [tool.codespell] must be a table" | |
| raise configparser.Error(msg) | |
| config.read_dict({"codespell": _toml_to_parseconfig(data)}) | |
| if toml_file not in used_toml_files: | |
| used_toml_files.append(toml_file) | |
| # Collect which config files are going to be used | |
| used_ini_files = [] | |
| for cfg_file in cfg_files: | |
| _cfg = configparser.ConfigParser() | |
| _cfg.read(cfg_file) | |
| if _cfg.has_section("codespell"): | |
| used_ini_files.append(cfg_file) | |
| # Use INI config files (TOML was already applied above) | |
| config.read(used_ini_files) | |
| used_cfg_files = used_toml_files + used_ini_files | |
| if config.has_section("codespell"): | |
| # Build a "fake" argv list using option name and value. | |
| cfg_args = [] | |
| for key in config["codespell"]: | |
| # Add option as arg. | |
| cfg_args.append(f"--{key}") | |
| # If value is blank, skip. | |
| val = config["codespell"][key] | |
| if val: | |
| cfg_args.append(val) | |
| # Parse config file options. | |
| options = parser.parse_args(cfg_args) | |
| # Re-parse command line options to override config. | |
| options = parser.parse_args(list(args), namespace=options) | |
| if not options.files: | |
| options.files.append(".") | |
| return options, parser, used_cfg_files | |
| def process_ignore_words( | |
| words: Iterable[str], ignore_words: set[str], ignore_words_cased: set[str] | |
| ) -> None: | |
| for word in words: | |
| word = word.strip() | |
| if word == word.lower(): | |
| ignore_words.add(word) | |
| else: | |
| ignore_words_cased.add(word) | |
| def parse_ignore_words_option( | |
| ignore_words_option: list[str], | |
| ) -> tuple[set[str], set[str]]: | |
| ignore_words: set[str] = set() | |
| ignore_words_cased: set[str] = set() | |
| if ignore_words_option: | |
| for comma_separated_words in ignore_words_option: | |
| process_ignore_words( | |
| (word.strip() for word in comma_separated_words.split(",")), | |
| ignore_words, | |
| ignore_words_cased, | |
| ) | |
| return (ignore_words, ignore_words_cased) | |
| def build_exclude_hashes(filename: str, exclude_lines: set[str]) -> None: | |
| with open(filename, encoding="utf-8") as f: | |
| exclude_lines.update(line.rstrip() for line in f) | |
| def build_ignore_words( | |
| filename: str, ignore_words: set[str], ignore_words_cased: set[str] | |
| ) -> None: | |
| with open(filename, encoding="utf-8") as f: | |
| process_ignore_words( | |
| (line.strip() for line in f), ignore_words, ignore_words_cased | |
| ) | |
| 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: str) -> bool: | |
| with open(filename, mode="rb") as f: | |
| s = f.read(1024) | |
| return b"\x00" not in s | |
| def _no_more_input(misspelling: Misspelling) -> tuple[bool, str, bool]: | |
| # An unanswered prompt must not count as a "yes": stdin being at EOF means | |
| # the answers ran out (or never existed), so leave the word alone and stop | |
| # asking about it in this file. | |
| print("\nNo answer: leaving the rest of this file alone") | |
| return False, misspelling.data, True | |
| def ask_for_word_fix( | |
| line: str, | |
| match: Match[str], | |
| misspelling: Misspelling, | |
| interactivity: int, | |
| colors: TermColors, | |
| filename: str, | |
| lineno: int, | |
| ) -> tuple[bool, str, bool]: | |
| """Ask about one match. | |
| Returns (fix, data, remember), where data is the replacement in dictionary | |
| form, uncased, for the caller to case per match, and remember says whether | |
| the answer was given for the rest of the file rather than for this match. | |
| This function must not mutate `misspelling`: the object is shared by every | |
| match of the word in the run, so an answer would leak into every later | |
| match and file (GH-62). | |
| """ | |
| cfilename = f"{colors.FILE}{filename}{colors.DISABLE}" | |
| cline = f"{colors.FILE}{lineno}{colors.DISABLE}" | |
| wrongword = match.group() | |
| line_ui = ( | |
| f"{line[: match.start()]}" | |
| f"{colors.WWORD}{wrongword}{colors.DISABLE}" | |
| f"{line[match.end() :]}" | |
| ) | |
| if misspelling.fix and interactivity & 1: | |
| r = "" | |
| fixword = fix_case(wrongword, misspelling.data) | |
| while not r: | |
| print( | |
| f"{cfilename}:{cline}: {line_ui}\t{wrongword} ==> {fixword} (Y/n/a/s) ", | |
| end="", | |
| flush=True, | |
| ) | |
| answer = sys.stdin.readline() | |
| if not answer: | |
| return _no_more_input(misspelling) | |
| r = answer.strip().upper() | |
| if not r: | |
| r = "Y" | |
| if r not in ("Y", "N", "A", "S"): | |
| print( | |
| "Say 'y' or 'n' for this one, " | |
| "'a' or 's' for all of them in this file" | |
| ) | |
| r = "" | |
| return r in ("Y", "A"), misspelling.data, r in ("A", "S") | |
| elif (interactivity & 2) and not misspelling.fix and not misspelling.reason: | |
| # if it is not disabled, i.e. it just has more than one possible fix, | |
| # we ask the user which word to use | |
| r = "" | |
| remember = False | |
| opt = [w.strip() for w in misspelling.data.split(",")] | |
| while not r: | |
| print( | |
| f"{cfilename}:{cline}: {line_ui} Choose an option " | |
| "(blank for none, Na for whole file, s to skip): ", | |
| end="", | |
| ) | |
| for i, o in enumerate(opt): | |
| fixword = fix_case(wrongword, o) | |
| print(f" {i}) {fixword}", end="") | |
| print(": ", end="", flush=True) | |
| answer = sys.stdin.readline() | |
| if not answer: | |
| return _no_more_input(misspelling) | |
| n = answer.strip().lower() | |
| if not n: | |
| break | |
| if n == "s": | |
| return False, misspelling.data, True | |
| remember = n.endswith("a") | |
| try: | |
| i = int(n[:-1] if remember else n) | |
| r = opt[i] | |
| except (ValueError, IndexError): | |
| print("Not a valid option\n") | |
| if r: | |
| return True, r, remember | |
| return misspelling.fix, misspelling.data, False | |
| 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(f"{'>' if i == index else ':'} {lines[i].rstrip()}") | |
| def _ignore_word_sub( | |
| text: str, | |
| ignore_word_regex: Optional[Pattern[str]], | |
| ) -> str: | |
| if ignore_word_regex: | |
| text = ignore_word_regex.sub(" ", text) | |
| return text | |
| def extract_words( | |
| text: str, | |
| word_regex: Pattern[str], | |
| ignore_word_regex: Optional[Pattern[str]], | |
| ) -> list[str]: | |
| return word_regex.findall(_ignore_word_sub(text, ignore_word_regex)) | |
| def extract_words_iter( | |
| text: str, | |
| word_regex: Pattern[str], | |
| ignore_word_regex: Optional[Pattern[str]], | |
| ) -> list[Match[str]]: | |
| return list(word_regex.finditer(_ignore_word_sub(text, ignore_word_regex))) | |
| def apply_uri_ignore_words( | |
| check_matches: list[Match[str]], | |
| line: str, | |
| word_regex: Pattern[str], | |
| ignore_word_regex: Optional[Pattern[str]], | |
| uri_regex: Pattern[str], | |
| uri_ignore_words: set[str], | |
| ) -> list[Match[str]]: | |
| if not uri_ignore_words: | |
| return check_matches | |
| for uri in uri_regex.findall(line): | |
| for uri_word in extract_words(uri, word_regex, ignore_word_regex): | |
| if uri_word in uri_ignore_words: | |
| # determine/remove only the first among matches | |
| for i, match in enumerate(check_matches): | |
| if match.group() == uri_word: | |
| check_matches = check_matches[:i] + check_matches[i + 1 :] | |
| break | |
| return check_matches | |
| def _format_colored_output( | |
| filename: str, | |
| colors: TermColors, | |
| line_num: int, | |
| wrong: str, | |
| right: str, | |
| ) -> tuple[str, str, str, str]: | |
| """Format colored strings for output. | |
| Args: | |
| filename: The filename being processed. | |
| colors: TermColors instance for color formatting. | |
| line_num: Line number (1-based) where the misspelling was found. | |
| wrong: The misspelled word. | |
| right: The correct word. | |
| Returns: | |
| Tuple of (filename, line_num, wrong_word, right_word) with color codes. | |
| """ | |
| cfilename = f"{colors.FILE}{filename}{colors.DISABLE}" | |
| cline = f"{colors.FILE}{line_num}{colors.DISABLE}" | |
| cwrongword = f"{colors.WWORD}{wrong}{colors.DISABLE}" | |
| crightword = f"{colors.FWORD}{right}{colors.DISABLE}" | |
| return cfilename, cline, cwrongword, crightword | |
| def parse_lines( | |
| fragment: tuple[bool, int, list[str]], | |
| filename: str, | |
| colors: TermColors, | |
| summary: Optional[Summary], | |
| misspellings: dict[str, Misspelling], | |
| ignore_words_cased: set[str], | |
| exclude_lines: set[str], | |
| 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, | |
| asked_for: dict[str, tuple[bool, str]], | |
| ) -> tuple[int, bool, list[tuple[int, str, str]]]: | |
| bad_count = 0 | |
| changed = False | |
| changes_made: list[tuple[int, str, str]] = [] | |
| _, fragment_line_number, lines = fragment | |
| next_line_ignore_words: Optional[set[str]] = None | |
| for i, line in enumerate(lines): | |
| line = line.rstrip() | |
| # Apply any ignore-next-line directive carried from the previous line. | |
| pending_next_line_ignore = next_line_ignore_words | |
| next_line_ignore_words = None | |
| directive_words: set[str] = set() | |
| if codespell_ignore_next_line_tag in line: | |
| nl_match = ignore_next_line_regex.search(line) | |
| if nl_match: | |
| directive_words = set( | |
| filter(None, (nl_match.group("words") or "").split(",")) | |
| ) | |
| next_line_ignore_words = directive_words | |
| if not line or line in exclude_lines: | |
| continue | |
| line_number = fragment_line_number + i | |
| extra_words_to_ignore: set[str] = set() | |
| match = ( | |
| inline_ignore_regex.search(line) if codespell_ignore_tag in line else None | |
| ) | |
| if match: | |
| extra_words_to_ignore = set( | |
| filter(None, (match.group("words") or "").split(",")) | |
| ) | |
| if not extra_words_to_ignore: | |
| continue | |
| # Words named in an ignore-next-line directive are ignored on its own line too. | |
| extra_words_to_ignore |= directive_words | |
| if pending_next_line_ignore is not None: | |
| if not pending_next_line_ignore: | |
| continue | |
| extra_words_to_ignore |= pending_next_line_ignore | |
| fixed_words = set() | |
| # If all URI spelling errors will be ignored, erase any URI before | |
| # extracting words. Otherwise, apply ignores after extracting words. | |
| # This ensures that if a URI ignore word occurs both inside a URI and | |
| # outside, it will still be a spelling error. | |
| if "*" in uri_ignore_words: | |
| line = uri_regex.sub(" ", line) | |
| check_matches = extract_words_iter(line, word_regex, ignore_word_regex) | |
| if "*" not in uri_ignore_words: | |
| check_matches = apply_uri_ignore_words( | |
| check_matches, | |
| line, | |
| word_regex, | |
| ignore_word_regex, | |
| uri_regex, | |
| uri_ignore_words, | |
| ) | |
| for match in check_matches: | |
| word = match.group() | |
| if word in ignore_words_cased: | |
| continue | |
| lword = word.lower() | |
| if lword in misspellings and lword not in extra_words_to_ignore: | |
| # Sometimes we find a 'misspelling' which is actually a valid word | |
| # preceded by a string escape sequence. Ignore such cases as | |
| # they're usually false alarms; see issue #17 among others. | |
| char_before_idx = match.start() - 1 | |
| if ( | |
| char_before_idx >= 0 | |
| and line[char_before_idx] == "\\" | |
| # bell, backspace, formfeed, newline, carriage-return, tab, vtab. | |
| and word.startswith(("a", "b", "f", "n", "r", "t", "v")) | |
| and lword[1:] not in misspellings | |
| ): | |
| continue | |
| # An "[sic]" marker right after the word flags it as an | |
| # intentional/quoted spelling, so leave it alone. | |
| if options.ignore_sic and sic_regex.match(line, match.end()): | |
| continue | |
| context_shown = False | |
| fix = misspellings[lword].fix | |
| fixword = fix_case(word, misspellings[lword].data) | |
| if options.interactive: | |
| if lword in asked_for: | |
| fix, data = asked_for[lword] | |
| else: | |
| if context is not None: | |
| context_shown = True | |
| print_context(lines, i, context) | |
| fix, data, remember = ask_for_word_fix( | |
| lines[i], | |
| match, | |
| misspellings[lword], | |
| options.interactive, | |
| colors=colors, | |
| filename=filename, | |
| lineno=line_number + 1, | |
| ) | |
| if remember: | |
| asked_for[lword] = (fix, data) | |
| # The answer is uncased: case it for this match. | |
| fixword = fix_case(word, data) | |
| if summary and fix: | |
| summary.update(lword) | |
| if word in fixed_words: # can skip because of re.sub below | |
| continue | |
| if options.write_changes and fix: | |
| new_line = re.sub(rf"\b{word}\b", fixword, lines[i]) | |
| if new_line != lines[i]: | |
| changed = True | |
| lines[i] = new_line | |
| fixed_words.add(word) | |
| changes_made.append((line_number + 1, word, fixword)) | |
| continue | |
| # Not found in the original line (e.g. --ignore-regex split | |
| # it out of a larger word), so report it instead (GH-2056). | |
| # otherwise warning was explicitly set by interactive mode | |
| if ( | |
| options.interactive & 2 | |
| and not fix | |
| and not misspellings[lword].reason | |
| ): | |
| continue | |
| cfilename, cline, cwrongword, crightword = _format_colored_output( | |
| filename, colors, line_number + 1, word, fixword | |
| ) | |
| reason = misspellings[lword].reason | |
| if reason: | |
| if options.quiet_level & QuietLevels.DISABLED_FIXES: | |
| continue | |
| creason = f" | {colors.FILE}{reason}{colors.DISABLE}" | |
| else: | |
| if options.quiet_level & QuietLevels.NON_AUTOMATIC_FIXES: | |
| continue | |
| creason = "" | |
| # If we get to this point (uncorrected error) we should change | |
| # our bad_count and thus return value | |
| bad_count += 1 | |
| if (not context_shown) and (context is not None): | |
| print_context(lines, i, context) | |
| if filename != "-": | |
| print( | |
| f"{cfilename}:{cline}: {cwrongword} ==> {crightword}{creason}" | |
| ) | |
| elif options.stdin_single_line: | |
| print(f"{cline}: {cwrongword} ==> {crightword}{creason}") | |
| else: | |
| print( | |
| f"{cline}: {line.strip()}\n\t{cwrongword} " | |
| f"==> {crightword}{creason}" | |
| ) | |
| return bad_count, changed, changes_made | |
| def parse_file( | |
| filename: str, | |
| colors: TermColors, | |
| summary: Optional[Summary], | |
| misspellings: dict[str, Misspelling], | |
| ignore_words_cased: set[str], | |
| 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, | |
| ) -> tuple[int, bool]: | |
| bad_count = 0 | |
| fragments = None | |
| # Read lines. | |
| if filename == "-": | |
| f = sys.stdin | |
| encoding = "utf-8" | |
| fragments = file_opener.get_lines(f) | |
| else: | |
| if options.check_filenames: | |
| for word in extract_words(filename, word_regex, ignore_word_regex): | |
| if word in ignore_words_cased: | |
| continue | |
| lword = word.lower() | |
| if lword not in misspellings: | |
| continue | |
| fix = misspellings[lword].fix | |
| fixword = fix_case(word, misspellings[lword].data) | |
| if summary and fix: | |
| summary.update(lword) | |
| cfilename, _, cwrongword, crightword = _format_colored_output( | |
| filename, colors, 0, word, fixword | |
| ) | |
| reason = misspellings[lword].reason | |
| if reason: | |
| if options.quiet_level & QuietLevels.DISABLED_FIXES: | |
| continue | |
| creason = f" | {colors.FILE}{reason}{colors.DISABLE}" | |
| else: | |
| if options.quiet_level & QuietLevels.NON_AUTOMATIC_FIXES: | |
| continue | |
| creason = "" | |
| bad_count += 1 | |
| print(f"{cfilename}: {cwrongword} ==> {crightword}{creason}") | |
| # ignore irregular files | |
| if not os.path.isfile(filename): | |
| return bad_count, False | |
| try: | |
| text = is_text_file(filename) | |
| except PermissionError as e: | |
| print(f"WARNING: {e.strerror}: {filename}", file=sys.stderr) | |
| return bad_count, False | |
| except OSError: | |
| return bad_count, False | |
| if not text: | |
| if not options.quiet_level & QuietLevels.BINARY_FILE: | |
| print(f"WARNING: Binary file: {filename}", file=sys.stderr) | |
| return bad_count, False | |
| try: | |
| fragments, encoding = file_opener.open(filename) | |
| except OSError: | |
| return bad_count, False | |
| # Parse lines. | |
| changed = False | |
| changes_made: list[tuple[int, str, str]] = [] | |
| # Answers given for the whole file ('a'/'s'): lword -> (fix, uncased fix). | |
| # Plain y/n answers are not remembered here: they are about one match. | |
| asked_for: dict[str, tuple[bool, str]] = {} | |
| for fragment in fragments: | |
| ignore, _, _ = fragment | |
| if ignore: | |
| continue | |
| bad_count_update, changed_update, changes_made_update = parse_lines( | |
| fragment, | |
| filename, | |
| colors, | |
| summary, | |
| misspellings, | |
| ignore_words_cased, | |
| exclude_lines, | |
| word_regex, | |
| ignore_word_regex, | |
| uri_regex, | |
| uri_ignore_words, | |
| context, | |
| options, | |
| asked_for, | |
| ) | |
| bad_count += bad_count_update | |
| changed = changed or changed_update | |
| changes_made.extend(changes_made_update) | |
| # Write out lines, if changed. | |
| if changed: | |
| if filename == "-": | |
| print("---") | |
| for _, _, lines in fragments: | |
| for line in lines: | |
| print(line, end="") | |
| else: | |
| if not options.quiet_level & QuietLevels.FIXES: | |
| print( | |
| f"{colors.FWORD}FIXED:{colors.DISABLE} " | |
| f"{colors.FILE}{filename}{colors.DISABLE}", | |
| file=sys.stderr, | |
| ) | |
| for line_num, wrong, right in changes_made: | |
| cfilename, cline, cwrongword, crightword = _format_colored_output( | |
| filename, colors, line_num, wrong, right | |
| ) | |
| print( | |
| f" {cfilename}:{cline}: {cwrongword} ==> {crightword}", | |
| file=sys.stderr, | |
| ) | |
| _write_file_atomically(filename, encoding, fragments) | |
| return bad_count, changed | |
| def _write_file_atomically( | |
| filename: str, | |
| encoding: str, | |
| fragments: Iterable[tuple[Any, Any, list[str]]], | |
| ) -> None: | |
| """Replace *filename* with a same-directory tempfile so a failed write | |
| cannot leave the original file truncated to zero bytes. | |
| """ | |
| # Resolve symlinks so we replace the target, not the link itself | |
| filename = os.path.realpath(filename) | |
| directory = os.path.dirname(filename) | |
| try: | |
| original_mode = stat.S_IMODE(os.stat(filename).st_mode) | |
| except OSError: | |
| original_mode = None | |
| fd, tmp_path = tempfile.mkstemp(prefix=".codespell-", suffix=".tmp", dir=directory) | |
| try: | |
| with os.fdopen(fd, "w", encoding=encoding, newline="") as tmp: | |
| for _, _, lines in fragments: | |
| tmp.writelines(lines) | |
| if original_mode is not None: | |
| os.chmod(tmp_path, original_mode) | |
| os.replace(tmp_path, filename) | |
| except Exception: | |
| try: | |
| os.unlink(tmp_path) | |
| except OSError: | |
| pass | |
| raise | |
| def flatten_clean_comma_separated_arguments( | |
| arguments: Iterable[str], | |
| ) -> list[str]: | |
| """ | |
| >>> flatten_clean_comma_separated_arguments(["a, b ,\n c, d,", "e"]) | |
| ['a', 'b', 'c', 'd', 'e'] | |
| >>> flatten_clean_comma_separated_arguments([]) | |
| [] | |
| """ | |
| return [ | |
| item.strip() for argument in arguments for item in argument.split(",") if item | |
| ] | |
| def _script_main() -> int: | |
| """Wrap to main() for setuptools.""" | |
| try: | |
| return main(*sys.argv[1:]) | |
| except KeyboardInterrupt: | |
| # User has typed CTRL+C | |
| sys.stdout.write("\n") | |
| return 130 | |
| def _usage_error(parser: argparse.ArgumentParser, message: str) -> int: | |
| parser.print_usage() | |
| print(message, file=sys.stderr) | |
| return EX_USAGE | |
| def _select_builtin_dictionary(builtin_option: str) -> list[str]: | |
| use = sorted(set(builtin_option.split(","))) | |
| if "all" in use: | |
| use = [u for u in use if u != "all"] + [ | |
| builtin[0] for builtin in _builtin_dictionaries | |
| ] | |
| use = sorted(set(use)) | |
| use_dictionaries = [] | |
| for u in use: | |
| for builtin in _builtin_dictionaries: | |
| if builtin[0] == u: | |
| use_dictionaries.append( | |
| os.path.join(_data_root, f"dictionary{builtin[2]}.txt") | |
| ) | |
| break | |
| else: | |
| raise KeyError(u) | |
| return use_dictionaries | |
| def main(*args: str) -> int: | |
| """Contains flow control""" | |
| try: | |
| options, parser, used_cfg_files = parse_options(args) | |
| except configparser.Error as e: | |
| print( | |
| f"ERROR: ill-formed config file: {e.message}", | |
| file=sys.stderr, | |
| ) | |
| return EX_CONFIG | |
| # Report used config files | |
| if not options.quiet_level & QuietLevels.CONFIG_FILES: | |
| used_basenames = {os.path.basename(cfg_file) for cfg_file in used_cfg_files} | |
| if "pyproject.toml" in used_basenames and ".codespellrc" in used_basenames: | |
| print( | |
| "WARNING: both pyproject.toml and .codespellrc contain " | |
| "codespell settings; .codespellrc takes precedence", | |
| file=sys.stderr, | |
| ) | |
| if len(used_cfg_files) > 0: | |
| print("Used config files:") | |
| for ifile, cfg_file in enumerate(used_cfg_files, start=1): | |
| print(f" {ifile}: {cfg_file}") | |
| if options.interactive > 0: | |
| options.write_changes = True | |
| if options.regex and options.write_changes: | |
| return _usage_error( | |
| parser, | |
| "ERROR: --write-changes cannot be used together with --regex", | |
| ) | |
| word_regex = options.regex or word_regex_def | |
| try: | |
| word_regex = re.compile(word_regex) | |
| except re.error as e: | |
| return _usage_error( | |
| parser, | |
| f'ERROR: invalid --regex "{word_regex}" ({e})', | |
| ) | |
| if options.ignore_regex: | |
| try: | |
| ignore_word_regex = re.compile(options.ignore_regex) | |
| except re.error as e: | |
| return _usage_error( | |
| parser, | |
| f'ERROR: invalid --ignore-regex "{options.ignore_regex}" ({e})', | |
| ) | |
| else: | |
| ignore_word_regex = None | |
| if options.ignore_multiline_regex: | |
| try: | |
| ignore_multiline_regex = re.compile( | |
| options.ignore_multiline_regex, re.DOTALL | |
| ) | |
| except re.error as e: | |
| return _usage_error( | |
| parser, | |
| f"ERROR: invalid --ignore-multiline-regex " | |
| f'"{options.ignore_multiline_regex}" ({e})', | |
| ) | |
| else: | |
| ignore_multiline_regex = None | |
| ignore_words, ignore_words_cased = parse_ignore_words_option( | |
| options.ignore_words_list | |
| ) | |
| if options.ignore_words: | |
| ignore_words_files = flatten_clean_comma_separated_arguments( | |
| options.ignore_words | |
| ) | |
| for ignore_words_file in ignore_words_files: | |
| if not os.path.isfile(ignore_words_file): | |
| return _usage_error( | |
| parser, | |
| f"ERROR: cannot find ignore-words file: {ignore_words_file}", | |
| ) | |
| build_ignore_words(ignore_words_file, ignore_words, ignore_words_cased) | |
| uri_regex = options.uri_regex or uri_regex_def | |
| try: | |
| uri_regex = re.compile(uri_regex) | |
| except re.error as e: | |
| return _usage_error( | |
| parser, | |
| f'ERROR: invalid --uri-regex "{uri_regex}" ({e})', | |
| ) | |
| uri_ignore_words = set( | |
| itertools.chain(*parse_ignore_words_option(options.uri_ignore_words_list)) | |
| ) | |
| dictionaries = flatten_clean_comma_separated_arguments(options.dictionary or ["-"]) | |
| use_dictionaries = [] | |
| for dictionary in dictionaries: | |
| if dictionary == "-": | |
| try: | |
| use_dictionaries.extend(_select_builtin_dictionary(options.builtin)) | |
| except KeyError as e: | |
| return _usage_error( | |
| parser, | |
| f"ERROR: Unknown builtin dictionary: {e.args[0]}", | |
| ) | |
| else: | |
| if not os.path.isfile(dictionary): | |
| return _usage_error( | |
| parser, | |
| f"ERROR: cannot find dictionary file: {dictionary}", | |
| ) | |
| use_dictionaries.append(dictionary) | |
| misspellings: dict[str, Misspelling] = {} | |
| for dictionary in use_dictionaries: | |
| build_dict(dictionary, misspellings, ignore_words) | |
| colors = TermColors() | |
| if not options.colors: | |
| colors.disable() | |
| summary = Summary() if options.summary else None | |
| context = None | |
| if options.context is not None: | |
| if (options.before_context is not None) or (options.after_context is not None): | |
| return _usage_error( | |
| parser, | |
| "ERROR: --context/-C cannot be used together with " | |
| "--context-before/-B or --context-after/-A", | |
| ) | |
| context_both = max(0, options.context) | |
| context = (context_both, context_both) | |
| elif (options.before_context is not None) or (options.after_context is not None): | |
| context_before = 0 | |
| context_after = 0 | |
| if options.before_context is not None: | |
| context_before = max(0, options.before_context) | |
| if options.after_context is not None: | |
| context_after = max(0, options.after_context) | |
| context = (context_before, context_after) | |
| exclude_lines: set[str] = set() | |
| if options.exclude_file: | |
| exclude_files = flatten_clean_comma_separated_arguments(options.exclude_file) | |
| for exclude_file in exclude_files: | |
| build_exclude_hashes(exclude_file, exclude_lines) | |
| file_opener = FileOpener( | |
| options.hard_encoding_detection, | |
| options.quiet_level, | |
| ignore_multiline_regex, | |
| ) | |
| glob_match = GlobMatch( | |
| flatten_clean_comma_separated_arguments(options.skip) if options.skip else [] | |
| ) | |
| try: | |
| glob_match.match("/random/path") # does not need a real path | |
| except re.error: | |
| return _usage_error( | |
| parser, | |
| "ERROR: --skip/-S has been fed an invalid glob, " | |
| "try escaping special characters", | |
| ) | |
| bad_count = 0 | |
| any_changed = False | |
| for filename in sorted(options.files): | |
| # ignore hidden files | |
| if is_hidden(filename, options.check_hidden): | |
| continue | |
| if os.path.isdir(filename): | |
| for root, dirs, files in os.walk(filename): | |
| if glob_match.match(root): # skip (absolute) directories | |
| dirs.clear() | |
| continue | |
| if is_hidden(root, options.check_hidden): # dir itself hidden | |
| continue | |
| for file_ in sorted(files): | |
| # ignore hidden files in directories | |
| if is_hidden(file_, options.check_hidden): | |
| continue | |
| if glob_match.match(file_): # skip files | |
| continue | |
| fname = os.path.join(root, file_) | |
| if glob_match.match(fname): # skip paths | |
| continue | |
| bad_count_update, changed = parse_file( | |
| fname, | |
| colors, | |
| summary, | |
| misspellings, | |
| ignore_words_cased, | |
| exclude_lines, | |
| file_opener, | |
| word_regex, | |
| ignore_word_regex, | |
| uri_regex, | |
| uri_ignore_words, | |
| context, | |
| options, | |
| ) | |
| bad_count += bad_count_update | |
| any_changed = any_changed or changed | |
| # skip (relative) directories | |
| dirs[:] = [ | |
| dir_ | |
| for dir_ in dirs | |
| if not glob_match.match(dir_) | |
| and not is_hidden(dir_, options.check_hidden) | |
| ] | |
| elif not glob_match.match(filename): # skip files | |
| bad_count_update, changed = parse_file( | |
| filename, | |
| colors, | |
| summary, | |
| misspellings, | |
| ignore_words_cased, | |
| exclude_lines, | |
| file_opener, | |
| word_regex, | |
| ignore_word_regex, | |
| uri_regex, | |
| uri_ignore_words, | |
| context, | |
| options, | |
| ) | |
| bad_count += bad_count_update | |
| any_changed = any_changed or changed | |
| if summary: | |
| print("\n-------8<-------\nSUMMARY:") | |
| print(summary) | |
| if options.count: | |
| print(bad_count, file=sys.stderr) | |
| failed = bad_count or (any_changed and options.exit_non_zero_on_fix) | |
| return EX_DATAERR if failed else EX_OK |