Interactive mode: an answer applies to one occurrence, not the whole run (#3990)
Co-authored-by: Eric Larson <larson.eric.d@gmail.com>
diff --git a/.coveragerc b/.coveragerc
index b160954..09fee0e 100644
--- a/.coveragerc
+++ b/.coveragerc
@@ -2,3 +2,6 @@
branch = True
source = codespell_lib
omit = */codespell_lib/tests/*
+# Some tests run the codespell entry point in a subprocess; without this their
+# coverage is invisible (pytest-cov 7 dropped its own subprocess support).
+patch = subprocess
diff --git a/codespell_lib/_codespell.py b/codespell_lib/_codespell.py
index e7d94aa..1ec09fd 100644
--- a/codespell_lib/_codespell.py
+++ b/codespell_lib/_codespell.py
@@ -587,8 +587,9 @@
choices=range(0, 4),
help="set interactive mode when writing changes:\n"
"- 0: no interactivity.\n"
- "- 1: ask for confirmation.\n"
- "- 2: ask user to choose one fix when more than one is available.\n"
+ "- 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",
)
@@ -806,6 +807,14 @@
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],
@@ -814,14 +823,21 @@
colors: TermColors,
filename: str,
lineno: int,
-) -> tuple[bool, str]:
+) -> 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()
- if interactivity <= 0:
- return misspelling.fix, fix_case(wrongword, misspelling.data)
-
line_ui = (
f"{line[: match.start()]}"
f"{colors.WWORD}{wrongword}{colors.DISABLE}"
@@ -833,29 +849,36 @@
fixword = fix_case(wrongword, misspelling.data)
while not r:
print(
- f"{cfilename}:{cline}: {line_ui}\t{wrongword} ==> {fixword} (Y/n) ",
+ f"{cfilename}:{cline}: {line_ui}\t{wrongword} ==> {fixword} (Y/n/a/s) ",
end="",
flush=True,
)
- r = sys.stdin.readline().strip().upper()
+ 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"):
- print("Say 'y' or 'n'")
+ 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 = ""
- if r == "N":
- misspelling.fix = False
+ return r in ("Y", "A"), misspelling.data, r in ("A", "S")
- elif (interactivity & 2) and not misspelling.reason:
+ 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): ",
+ 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):
@@ -863,21 +886,26 @@
print(f" {i}) {fixword}", end="")
print(": ", end="", flush=True)
- n = sys.stdin.readline().strip()
+ 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)
+ i = int(n[:-1] if remember else n)
r = opt[i]
except (ValueError, IndexError):
print("Not a valid option\n")
if r:
- misspelling.fix = True
- misspelling.data = r
+ return True, r, remember
- return misspelling.fix, fix_case(wrongword, misspelling.data)
+ return misspelling.fix, misspelling.data, False
def print_context(
@@ -977,6 +1005,7 @@
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
@@ -1025,7 +1054,6 @@
extra_words_to_ignore |= pending_next_line_ignore
fixed_words = set()
- asked_for = set()
# If all URI spelling errors will be ignored, erase any URI before
# extracting words. Otherwise, apply ignores after extracting words.
@@ -1071,20 +1099,26 @@
fix = misspellings[lword].fix
fixword = fix_case(word, misspellings[lword].data)
- if options.interactive and lword not in asked_for:
- if context is not None:
- context_shown = True
- print_context(lines, i, context)
- fix, fixword = ask_for_word_fix(
- lines[i],
- match,
- misspellings[lword],
- options.interactive,
- colors=colors,
- filename=filename,
- lineno=i + 1,
- )
- asked_for.add(lword)
+ 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)
@@ -1225,6 +1259,9 @@
# 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:
@@ -1244,6 +1281,7 @@
uri_ignore_words,
context,
options,
+ asked_for,
)
bad_count += bad_count_update
changed = changed or changed_update
diff --git a/codespell_lib/tests/test_basic.py b/codespell_lib/tests/test_basic.py
index c82d847..0127f57 100644
--- a/codespell_lib/tests/test_basic.py
+++ b/codespell_lib/tests/test_basic.py
@@ -1671,3 +1671,205 @@
print("Testing with direct call to cs_.main()")
r = cs_.main(*args[1:])
print(f"{r=}")
+
+
+PROMPT = "(Y/n/a/s)"
+
+
+def run_codespell_interactive(
+ args: tuple[Any, ...],
+ answers: str,
+ cwd: Optional[Path] = None,
+) -> "subprocess.CompletedProcess[str]":
+ """Run codespell feeding interactive answers on stdin."""
+ args = tuple(str(arg) for arg in args)
+ return subprocess.run( # noqa: S603
+ ["codespell", *args], # noqa: S607
+ cwd=cwd,
+ input=answers,
+ capture_output=True,
+ encoding="utf-8",
+ check=False,
+ )
+
+
+def test_interactive_rejection_is_per_match(
+ tmp_path: Path,
+) -> None:
+ """A y/n answer is about one match, not the rest of the run (GH-62)."""
+ f1 = tmp_path / "f1.txt"
+ f2 = tmp_path / "f2.txt"
+ f1.write_text("abandonned\nabandonned\n")
+ f2.write_text("abandonned\n")
+ proc = run_codespell_interactive(("-w", "-i", "1", f1, f2), answers="n\ny\ny\n")
+ assert proc.stdout.count(PROMPT) == 3
+ assert f1.read_text() == "abandonned\nabandoned\n"
+ assert f2.read_text() == "abandoned\n"
+
+
+def test_interactive_answer_for_whole_file(
+ tmp_path: Path,
+) -> None:
+ """'a' and 's' answer for the rest of the file, and stop at its end."""
+ f1 = tmp_path / "f1.txt"
+ f2 = tmp_path / "f2.txt"
+ f1.write_text("abandonned\nabandonned\n")
+ f2.write_text("abandonned\nabandonned\n")
+ proc = run_codespell_interactive(("-w", "-i", "1", f1, f2), answers="s\na\n")
+ assert proc.stdout.count(PROMPT) == 2
+ assert f1.read_text() == "abandonned\nabandonned\n"
+ assert f2.read_text() == "abandoned\nabandoned\n"
+
+
+def test_interactive_whole_file_answer_spans_fragments(
+ tmp_path: Path,
+) -> None:
+ """An ignored multiline region splits a file but must not re-ask."""
+ f = tmp_path / "f.txt"
+ f.write_text("abandonned\nSKIPSTART\nfoo\nSKIPEND\nabandonned\n")
+ proc = run_codespell_interactive(
+ ("-w", "-i", "1", "--ignore-multiline-regex", r"SKIPSTART[\s\S]*?SKIPEND", f),
+ answers="s\n",
+ )
+ assert proc.stdout.count(PROMPT) == 1
+ assert f.read_text() == "abandonned\nSKIPSTART\nfoo\nSKIPEND\nabandonned\n"
+
+
+def test_interactive_answer_keeps_case(
+ tmp_path: Path,
+) -> None:
+ """An answer is cased for each match, not for the one that was asked about."""
+ f = tmp_path / "f.txt"
+ f.write_text("abandonned\nAbandonned\nABANDONNED\n")
+ proc = run_codespell_interactive(("-w", "-i", "1", f), answers="a\n")
+ assert proc.stdout.count(PROMPT) == 1
+ assert f.read_text() == "abandoned\nAbandoned\nABANDONED\n"
+
+
+def test_interactive_context_is_shown_once(
+ tmp_path: Path,
+) -> None:
+ """-C prints the surrounding lines before asking, and not again after."""
+ f = tmp_path / "f.txt"
+ f.write_text("first line\nabandonned\n")
+ proc = run_codespell_interactive(("-w", "-i", "1", "-C", "1", f), answers="n\n")
+ assert proc.stdout.count(PROMPT) == 1
+ assert proc.stdout.count("first line") == 1
+ assert f.read_text() == "first line\nabandonned\n"
+
+
+def test_interactive_invalid_answer_asks_again(
+ tmp_path: Path,
+) -> None:
+ """Anything that is not y/n/a/s re-asks about the same match."""
+ f = tmp_path / "f.txt"
+ f.write_text("abandonned\n")
+ proc = run_codespell_interactive(("-w", "-i", "1", f), answers="x\ny\n")
+ assert proc.stdout.count(PROMPT) == 2
+ assert "Say 'y' or 'n'" in proc.stdout
+ assert f.read_text() == "abandoned\n"
+
+
+@pytest.mark.parametrize(
+ ("level", "text"),
+ [
+ ("1", "abandonned\nabandonned\n"), # asked with y/n/a/s
+ ("2", "aache\naache\n"), # asked with a list of fixes
+ ("3", "abandonned\naache\n"), # both
+ ],
+)
+def test_interactive_no_answer_fixes_nothing(
+ tmp_path: Path,
+ level: str,
+ text: str,
+) -> None:
+ """Running out of answers must not count as accepting the rest."""
+ f = tmp_path / "f.txt"
+ f.write_text(text)
+ proc = run_codespell_interactive(("-w", "-i", level, f), answers="")
+ assert "No answer" in proc.stdout
+ assert f.read_text() == text
+
+
+def test_interactive_level_2_answer_keeps_case(
+ tmp_path: Path,
+) -> None:
+ """The same, for the answer chosen from a list of fixes.
+
+ The list is asked about once per match, and keeps every candidate: choosing
+ one used to narrow the list for every later match (GH-62).
+ """
+ f = tmp_path / "f.txt"
+ f.write_text("aache\nAache\n")
+ proc = run_codespell_interactive(("-w", "-i", "2", f), answers="0\n0\n")
+ assert proc.stdout.count("Choose an option") == 2
+ assert proc.stdout.count("1) ache") == 1
+ assert proc.stdout.count("1) Ache") == 1
+ assert f.read_text() == "cache\nCache\n"
+
+
+def test_interactive_level_2_answer_for_whole_file(
+ tmp_path: Path,
+) -> None:
+ """A number with a trailing 'a' picks that fix for the rest of the file."""
+ f = tmp_path / "f.txt"
+ f.write_text("aache\nAache\n")
+ proc = run_codespell_interactive(("-w", "-i", "2", f), answers="0a\n")
+ assert proc.stdout.count("Choose an option") == 1
+ assert f.read_text() == "cache\nCache\n"
+
+
+def test_interactive_level_2_skip_whole_file(
+ tmp_path: Path,
+) -> None:
+ """'s' leaves the word alone for the rest of the file."""
+ f1 = tmp_path / "f1.txt"
+ f2 = tmp_path / "f2.txt"
+ f1.write_text("aache\naache\n")
+ f2.write_text("aache\n")
+ proc = run_codespell_interactive(("-w", "-i", "2", f1, f2), answers="s\n0\n")
+ assert proc.stdout.count("Choose an option") == 2
+ assert f1.read_text() == "aache\naache\n"
+ assert f2.read_text() == "cache\n"
+
+
+def test_interactive_level_2_invalid_answer_asks_again(
+ tmp_path: Path,
+) -> None:
+ """A bare 'a' is not a choice: it re-asks rather than picking something."""
+ f = tmp_path / "f.txt"
+ f.write_text("aache\n")
+ proc = run_codespell_interactive(("-w", "-i", "2", f), answers="a\n9\n1\n")
+ assert proc.stdout.count("Choose an option") == 3
+ assert proc.stdout.count("Not a valid option") == 2
+ assert f.read_text() == "ache\n"
+
+
+def test_interactive_level_2_no_prompt_for_single_fix(
+ tmp_path: Path,
+) -> None:
+ """Level 2 prompts only when more than one fix is available (as --help says).
+
+ A word with a single candidate used to get an option list where the blank
+ "none" answer still applied the fix (GH-62).
+ """
+ f = tmp_path / "f.txt"
+ f.write_text("abandonned\n")
+ proc = run_codespell_interactive(("-w", "-i", "2", f), answers="\n")
+ assert "Choose an option" not in proc.stdout
+ assert f.read_text() == "abandoned\n"
+
+
+def test_interactive_level_3_rejection_keeps_yn_prompt(
+ tmp_path: Path,
+) -> 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")
+ 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"
diff --git a/pyproject.toml b/pyproject.toml
index 6c9f452..7cbdb71 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -43,7 +43,7 @@
"chardet",
"pre-commit",
"pytest",
- "pytest-cov",
+ "pytest-cov>=7",
"pytest-dependency",
"Pygments",
"ruff",
@@ -54,7 +54,7 @@
"chardet>=5.1.0",
"mypy",
"pytest",
- "pytest-cov",
+ "pytest-cov>=7",
"pytest-dependency",
]
@@ -177,7 +177,7 @@
[tool.ruff.lint.pylint]
allow-magic-value-types = ["bytes", "int", "str",]
-max-args = 13
+max-args = 14
max-branches = 48
max-returns = 12
max-statements = 120