From d6890777aa618e6acb56c3ec25a3f87c3ca4de2e Mon Sep 17 00:00:00 2001 From: Tetyana Tarasenko Date: Thu, 29 Oct 2026 12:21:57 +0100 Subject: [PATCH] Accept names as well as numbers for the -i and -q bitmasks Closes GH-2526. --- README.rst | 7 +++ codespell_lib/_codespell.py | 72 +++++++++++++++++++++++-------- codespell_lib/tests/test_basic.py | 49 ++++++++++++++++++++- 3 files changed, 110 insertions(+), 18 deletions(-) diff --git a/README.rst b/README.rst index 5d0f288fcc..61d179469b 100644 --- a/README.rst +++ b/README.rst @@ -107,6 +107,13 @@ Display them without terminal colors and with a quiet level of 3. Run interactive mode level 3 and write changes to file. +.. code-block:: sh + + codespell -i ask,choose -q encoding,binary-file + +The ``-i`` and ``-q`` bitmasks can also be given as comma-separated names; +this is the same as ``-i 3 -q 3``. See ``codespell --help`` for the names. + We ship a collection of dictionaries that are an improved version of the one available `on Wikipedia `_ after applying them in projects like Linux Kernel, EFL, oFono among others. diff --git a/codespell_lib/_codespell.py b/codespell_lib/_codespell.py index b46eec40e6..0290b92717 100644 --- a/codespell_lib/_codespell.py +++ b/codespell_lib/_codespell.py @@ -28,7 +28,7 @@ import sys import tempfile import textwrap -from collections.abc import Iterable, Sequence +from collections.abc import Callable, Iterable, Sequence from re import Match, Pattern from typing import ( Any, @@ -176,6 +176,44 @@ class QuietLevels: CONFIG_FILES = 32 +_INTERACTIVE_NAMES = {"ask": 1, "choose": 2} +_QUIET_LEVEL_NAMES = { + "encoding": QuietLevels.ENCODING, + "binary-file": QuietLevels.BINARY_FILE, + "disabled-fixes": QuietLevels.DISABLED_FIXES, + "non-automatic-fixes": QuietLevels.NON_AUTOMATIC_FIXES, + "fixes": QuietLevels.FIXES, + "config-files": QuietLevels.CONFIG_FILES, +} + + +def _bitmask(names: dict[str, int]) -> Callable[[str], int]: + """Return an argparse type for a bitmask option. + + The value is either the number as before (``3``) or a comma-separated + list of names (``ask,choose``) that are ORed together (GH-2526). + """ + all_bits = sum(names.values()) + + def parse(value: str) -> int: + if value.isdigit(): + level = int(value) + if level > all_bits: + msg = f"invalid choice: {value} (choose from 0-{all_bits})" + raise argparse.ArgumentTypeError(msg) + return level + level = 0 + for name in value.split(","): + try: + level |= names[name.strip()] + except KeyError: + msg = f"invalid choice: {name!r} (choose from {', '.join(names)})" + raise argparse.ArgumentTypeError(msg) from None + return level + + return parse + + class GlobMatch: def __init__(self, pattern: list[str]) -> None: self.pattern_list: list[str] = pattern @@ -592,15 +630,15 @@ def convert_arg_line_to_args(self, arg_line: str) -> list[str]: "-i", "--interactive", action="store", - type=int, + type=_bitmask(_INTERACTIVE_NAMES), default=0, - choices=range(0, 4), - help="set interactive mode when writing changes:\n" + help="set interactive mode when writing changes, as a number " + "or as a comma-separated list of names:\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;" + "- 1 or ask: ask for confirmation; 'a'/'s' answer for the rest of the file.\n" + "- 2 or choose: 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", + "- 3 or ask,choose: both 1 and 2", metavar="MODE", ) @@ -608,19 +646,19 @@ def convert_arg_line_to_args(self, arg_line: str) -> list[str]: "-q", "--quiet-level", action="store", - type=int, + type=_bitmask(_QUIET_LEVEL_NAMES), default=34, - choices=range(0, 64), - help="bitmask that allows suppressing messages:\n" + help="bitmask that allows suppressing messages, as a number " + "or as a comma-separated list of names:\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" + "- 1 or encoding: disable warnings about wrong encoding.\n" + "- 2 or binary-file: disable warnings about binary files.\n" + "- 4 or disabled-fixes: omit warnings about automatic fixes that were disabled in the dictionary.\n" # noqa: E501 + "- 8 or non-automatic-fixes: don't print anything for non-automatic fixes.\n" + "- 16 or fixes: don't print the list of fixed files.\n" + "- 32 or config-files: 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 " + "combined; e.g. use 3 or encoding,binary-file for levels 1+2, 7 for " "1+2+4, 23 for 1+2+4+16, etc. " "The default mask is %(default)s.", metavar="LEVEL", diff --git a/codespell_lib/tests/test_basic.py b/codespell_lib/tests/test_basic.py index 1b03896d24..7ab1c8efc8 100644 --- a/codespell_lib/tests/test_basic.py +++ b/codespell_lib/tests/test_basic.py @@ -1492,6 +1492,26 @@ def test_quiet_level_32( assert "Used config files:" in stdout assert "setup.cfg" in stdout + # Names are the same as the numbers, on the command line and in a + # config file (GH-2526). + result = cs.main(str(d), *args, "--quiet-level=config-files", std=True) + assert isinstance(result, tuple) + code, stdout, _ = result + assert code == 0 + assert "Used config files:" not in stdout + result = cs.main(str(d), *args, "--quiet-level=encoding,binary-file", std=True) + assert isinstance(result, tuple) + code, stdout, _ = result + assert code == 0 + assert "Used config files:" in stdout + with open(conf, "a", encoding="utf-8") as f: + f.write("quiet-level = config-files\n") + result = cs.main(str(d), *args, std=True) + assert isinstance(result, tuple) + code, stdout, _ = result + assert code == 0 + assert "Used config files:" not in stdout + def test_ill_formed_ini_config_file( tmp_path: Path, @@ -1958,16 +1978,43 @@ def test_interactive_level_2_no_prompt_for_single_fix( assert f.read_text() == "abandoned\n" +@pytest.mark.parametrize("level", ["3", "ask,choose"]) def test_interactive_level_3_rejection_keeps_yn_prompt( tmp_path: Path, + level: str, ) -> None: """A rejected word must stay a Y/n question, not degrade to an option list.""" f1 = tmp_path / "f1.txt" f2 = tmp_path / "f2.txt" f1.write_text("abandonned\n") f2.write_text("abandonned\n") - proc = run_codespell_interactive(("-w", "-i", "3", f1, f2), answers="n\nn\n") + proc = run_codespell_interactive(("-w", "-i", level, f1, f2), answers="n\nn\n") assert proc.stdout.count(PROMPT) == 2 assert "Choose an option" not in proc.stdout assert f1.read_text() == "abandonned\n" assert f2.read_text() == "abandonned\n" + + +@pytest.mark.parametrize( + ("option", "value", "choices"), + [ + ("-i", "4", "0-3"), + ("-i", "ask,confirm", "ask, choose"), + ("-q", "64", "0-63"), + ("-q", "binary", "encoding, binary-file"), + ], +) +def test_bitmask_option_invalid_value( + tmp_path: Path, + option: str, + value: str, + choices: str, +) -> None: + """A number out of range or an unknown name is rejected (GH-2526).""" + f = tmp_path / "f.txt" + f.write_text("abandonned\n") + proc = run_codespell_interactive((option, value, f), answers="") + assert proc.returncode == 2 + assert "invalid choice" in proc.stderr + assert choices in proc.stderr + assert f.read_text() == "abandonned\n"