fix: an empty REPL line no longer quits the chapter silently (#569)

Every chapter REPL treated an empty line as a quit command and exited with
status 0 without a word, while the banner only promises "Type q to quit".
Under VS Code's debugger the Python extension's environment-activation line
is often injected into stdin after the program started, so the first thing
the REPL read was a blank line and the chapter vanished right after its
banner.

An empty line is now ignored and the prompt comes back; q and exit still
quit, and EOF/Ctrl-C still end the loop. s17 already behaved this way. A
static test keeps every chapter's quit check free of the empty string.

Fixes #567
This commit is contained in:
Lukas
2026-10-27 12:39:40 +08:00
committed by GitHub
parent 4e3f7273d3
commit 2f8cfb03a6
17 changed files with 107 additions and 16 deletions
+3 -1
View File
@@ -138,7 +138,9 @@ if __name__ == "__main__":
query = input("\001\033[36m\002s01 >> \001\033[0m\002")
except (EOFError, KeyboardInterrupt):
break
if query.strip().lower() in ("q", "exit", ""):
if not query.strip():
continue
if query.strip().lower() in ("q", "exit"):
break
history.append({"role": "user", "content": query})
agent_loop(history)
+3 -1
View File
@@ -196,7 +196,9 @@ if __name__ == "__main__":
query = input("\001\033[36m\002s02 >> \001\033[0m\002")
except (EOFError, KeyboardInterrupt):
break
if query.strip().lower() in ("q", "exit", ""):
if not query.strip():
continue
if query.strip().lower() in ("q", "exit"):
break
history.append({"role": "user", "content": query})
agent_loop(history)
+3 -1
View File
@@ -257,7 +257,9 @@ if __name__ == "__main__":
query = input("\001\033[36m\002s03 >> \001\033[0m\002")
except (EOFError, KeyboardInterrupt):
break
if query.strip().lower() in ("q", "exit", ""):
if not query.strip():
continue
if query.strip().lower() in ("q", "exit"):
break
history.append({"role": "user", "content": query})
agent_loop(history)
+3 -1
View File
@@ -269,7 +269,9 @@ if __name__ == "__main__":
query = input("\001\033[36m\002s04 >> \001\033[0m\002")
except (EOFError, KeyboardInterrupt):
break
if query.strip().lower() in ("q", "exit", ""):
if not query.strip():
continue
if query.strip().lower() in ("q", "exit"):
break
trigger_hooks("UserPromptSubmit", query)
history.append({"role": "user", "content": query})
+3 -1
View File
@@ -357,7 +357,9 @@ if __name__ == "__main__":
query = input("\001\033[36m\002s05 >> \001\033[0m\002")
except (EOFError, KeyboardInterrupt):
break
if query.strip().lower() in ("q", "exit", ""):
if not query.strip():
continue
if query.strip().lower() in ("q", "exit"):
break
trigger_hooks("UserPromptSubmit", query)
history.append({"role": "user", "content": query})
+3 -1
View File
@@ -372,7 +372,9 @@ if __name__ == "__main__":
query = input("\001\033[36m\002s06 >> \001\033[0m\002")
except (EOFError, KeyboardInterrupt):
break
if query.strip().lower() in ("q", "exit", ""):
if not query.strip():
continue
if query.strip().lower() in ("q", "exit"):
break
trigger_hooks("UserPromptSubmit", query)
history.append({"role": "user", "content": query})
+3 -1
View File
@@ -388,7 +388,9 @@ if __name__ == "__main__":
query = input("\001\033[36m\002s07 >> \001\033[0m\002")
except (EOFError, KeyboardInterrupt):
break
if query.strip().lower() in ("q", "exit", ""):
if not query.strip():
continue
if query.strip().lower() in ("q", "exit"):
break
trigger_hooks("UserPromptSubmit", query)
history.append({"role": "user", "content": query})
+3 -1
View File
@@ -591,7 +591,9 @@ if __name__ == "__main__":
query = input("\001\033[36m\002s08 >> \001\033[0m\002")
except (EOFError, KeyboardInterrupt):
break
if query.strip().lower() in ("q", "exit", ""):
if not query.strip():
continue
if query.strip().lower() in ("q", "exit"):
break
trigger_hooks("UserPromptSubmit", query)
history.append({"role": "user", "content": query})
+3 -1
View File
@@ -773,7 +773,9 @@ if __name__ == "__main__":
query = input("\001\033[36m\002s09 >> \001\033[0m\002")
except (EOFError, KeyboardInterrupt):
break
if query.strip().lower() in ("q", "exit", ""):
if not query.strip():
continue
if query.strip().lower() in ("q", "exit"):
break
trigger_hooks("UserPromptSubmit", query)
history.append({"role": "user", "content": query})
+3 -1
View File
@@ -589,7 +589,9 @@ if __name__ == "__main__":
query = input("\001\033[36m\002s10 >> \001\033[0m\002")
except (EOFError, KeyboardInterrupt):
break
if query.strip().lower() in ("q", "exit", ""):
if not query.strip():
continue
if query.strip().lower() in ("q", "exit"):
break
trigger_hooks("UserPromptSubmit", query)
history.append({"role": "user", "content": query})
+3 -1
View File
@@ -531,7 +531,9 @@ if __name__ == "__main__":
query = input("\001\033[36m\002s11 >> \001\033[0m\002")
except (EOFError, KeyboardInterrupt):
break
if query.strip().lower() in ("q", "exit", ""):
if not query.strip():
continue
if query.strip().lower() in ("q", "exit"):
break
trigger_hooks("UserPromptSubmit", query)
history.append({"role": "user", "content": query})
+3 -1
View File
@@ -782,7 +782,9 @@ if __name__ == "__main__":
query = input("\001\033[36m\002s12 >> \001\033[0m\002")
except (EOFError, KeyboardInterrupt):
break
if query.strip().lower() in ("q", "exit", ""):
if not query.strip():
continue
if query.strip().lower() in ("q", "exit"):
break
with agent_lock:
run_agent_turn_locked(query)
+5 -1
View File
@@ -1862,7 +1862,11 @@ if __name__ == "__main__":
if kind == "quit":
break
if kind == "user":
if payload is None or payload.strip().lower() in {"q", "exit", ""}:
if payload is None:
break
if not payload.strip():
continue
if payload.strip().lower() in {"q", "exit"}:
break
trigger_hooks("UserPromptSubmit", payload)
history.append({"role": "user", "content": payload})
+3 -1
View File
@@ -536,7 +536,9 @@ if __name__ == "__main__":
query = input("s14 >> ")
except (EOFError, KeyboardInterrupt):
break
if query.strip().lower() in {"q", "exit", ""}:
if not query.strip():
continue
if query.strip().lower() in {"q", "exit"}:
break
trigger_hooks("UserPromptSubmit", query)
history.append({"role": "user", "content": query})
+3 -1
View File
@@ -3313,7 +3313,9 @@ if __name__ == "__main__":
query = CONSOLE.ask()
except (EOFError, KeyboardInterrupt):
break
if query.strip().lower() in ("q", "exit", ""):
if not query.strip():
continue
if query.strip().lower() in ("q", "exit"):
break
with agent_lock:
trigger_hooks("UserPromptSubmit", query)
+3 -1
View File
@@ -868,7 +868,9 @@ def run_cli():
query = host.CONSOLE.ask()
except (EOFError, KeyboardInterrupt):
break
if query.strip().lower() in ("q", "exit", ""):
if not query.strip():
continue
if query.strip().lower() in ("q", "exit"):
break
with host.agent_lock:
host.trigger_hooks("UserPromptSubmit", query)
+57
View File
@@ -0,0 +1,57 @@
"""Every chapter REPL quits only on an explicit command (issue #567).
An empty line used to be a silent exit with status 0, which contradicted the
"Type q to quit" banner and, combined with VS Code's environment-activation
line being injected into stdin, made a debugged chapter vanish right after its
banner. An empty line is now ignored and the prompt comes back.
"""
import ast
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
CHAPTER_FILES = sorted(ROOT.glob("s*/code.py"))
def _quit_checks(source: str) -> list[ast.Compare]:
"""Return every `X.strip().lower() in (...)` / `in {...}` comparison."""
found = []
for node in ast.walk(ast.parse(source)):
if not isinstance(node, ast.Compare) or len(node.ops) != 1 or not isinstance(node.ops[0], ast.In):
continue
left = node.left
if (
isinstance(left, ast.Call)
and isinstance(left.func, ast.Attribute)
and left.func.attr == "lower"
and isinstance(left.func.value, ast.Call)
and isinstance(left.func.value.func, ast.Attribute)
and left.func.value.func.attr == "strip"
):
found.append(node)
return found
def test_every_chapter_has_a_repl_quit_check():
assert len(CHAPTER_FILES) >= 17
for path in CHAPTER_FILES:
assert _quit_checks(path.read_text(encoding="utf-8")), f"{path}: no quit command check found"
def test_empty_line_is_never_a_quit_command():
for path in CHAPTER_FILES:
for check in _quit_checks(path.read_text(encoding="utf-8")):
container = check.comparators[0]
assert isinstance(container, (ast.Tuple, ast.Set)), f"{path}:{check.lineno}"
values = [c.value for c in container.elts if isinstance(c, ast.Constant)]
assert "" not in values, f"{path}:{check.lineno}: empty input still quits the REPL"
assert "q" in values, f"{path}:{check.lineno}: q must still quit"
def test_blank_input_is_skipped_before_the_quit_check():
pattern = re.compile(r"if not (\w+)\.strip\(\):\n\s+continue\n\s+if \1\.strip\(\)\.lower\(\) in", re.M)
for path in CHAPTER_FILES:
if path.parent.name.startswith("s17"):
continue # s17 already handled blank input before this change
assert pattern.search(path.read_text(encoding="utf-8")), f"{path}: blank input is not skipped"