refactor(wizard): replace hand-rolled arrow-key menu with questionary
The reviewer asked for a known-good interactive-menu library instead of the custom termios/select key reader; arrow keys had failed in their terminal. The TTY path now delegates to questionary.select (pointer ">", bold highlight via pointer/selected styles), keeping choice values mapped to list indices. Cancellable menus graft an eager Escape binding onto the prompt's own key-binding registry so Esc backs out while Ctrl-C still aborts fixed menus and backs out of cancellable ones. The non-TTY numbered fallback is untouched, and hand-rolled internals (_arrow_menu/_read_keypress/_option_line/_draw_menu_rows) are gone.
This commit was merged in pull request #1.
This commit is contained in:
@@ -8,6 +8,7 @@ dependencies = [
|
||||
"keyring>=25",
|
||||
"httpx>=0.27",
|
||||
"python-debianbts>=4.1.1",
|
||||
"questionary>=2.1.1",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
|
||||
+33
-105
@@ -18,6 +18,7 @@ import os
|
||||
import sys
|
||||
from collections.abc import Callable, Sequence
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, cast
|
||||
|
||||
from weekly_activity.aggregate import source_label
|
||||
from weekly_activity.config import (
|
||||
@@ -32,6 +33,10 @@ from weekly_activity.config import (
|
||||
)
|
||||
from weekly_activity.sources.launchpad import LaunchpadSource
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from prompt_toolkit.key_binding import KeyBindings, KeyPressEvent
|
||||
|
||||
|
||||
_APPLICATION_NAME = "weekly-activity"
|
||||
|
||||
# Stable processing order, shared by menus and summaries: (config attr, menu text).
|
||||
@@ -291,7 +296,7 @@ def _menu(title: str, options: Sequence[str], *, cancellable: bool = False) -> i
|
||||
Returns the selected index, or None when backed out of a cancellable menu.
|
||||
"""
|
||||
if sys.stdin.isatty() and sys.stdout.isatty():
|
||||
return _arrow_menu(title, options, cancellable=cancellable)
|
||||
return _tty_menu(title, options, cancellable=cancellable)
|
||||
return _numbered_menu(title, options, cancellable=cancellable)
|
||||
|
||||
|
||||
@@ -320,119 +325,42 @@ def _numbered_menu(title: str, options: Sequence[str], *, cancellable: bool = Fa
|
||||
# --- Arrow-key menu (interactive TTYs only) ---
|
||||
|
||||
|
||||
_BOLD = "\x1b[1m"
|
||||
_RESET = "\x1b[0m"
|
||||
|
||||
_ERASE_LINE = "\x1b[2K"
|
||||
_CURSOR_DOWN = "\x1b[1B"
|
||||
_CURSOR_UP_TEMPLATE = "\x1b[{}A"
|
||||
|
||||
# Key classifications shared by the reader and the menu loop.
|
||||
_KEY_UP = "up"
|
||||
_KEY_DOWN = "down"
|
||||
_KEY_HOME = "home"
|
||||
_KEY_END = "end"
|
||||
_KEY_ENTER = "enter"
|
||||
_KEY_ESCAPE = "escape"
|
||||
_KEY_OTHER = "other"
|
||||
|
||||
_CSI_KEYS = {"[A": _KEY_UP, "[B": _KEY_DOWN, "[H": _KEY_HOME, "[F": _KEY_END}
|
||||
# Sentinel handed back through the prompt when Esc backs out; it can never
|
||||
# collide with a real choice because every choice's value is its list index.
|
||||
_CANCELLED = object()
|
||||
|
||||
|
||||
def _read_keypress() -> str:
|
||||
"""Read one keystroke from stdin (cbreak mode); classify it."""
|
||||
import select
|
||||
def _tty_menu(title: str, options: Sequence[str], *, cancellable: bool = False) -> int | None:
|
||||
"""Arrow-key menu rendered by questionary instead of hand-rolled ANSI.
|
||||
|
||||
fd = sys.stdin.fileno()
|
||||
# Read raw bytes from the fd: a buffered text read would pull the whole
|
||||
# escape sequence into its own buffer, leaving nothing for select to see.
|
||||
first = os.read(fd, 1)
|
||||
if first == b"\x1b":
|
||||
# A bare Esc is not followed by more bytes; CSI sequences are. Peek briefly.
|
||||
readable, _, _ = select.select([fd], [], [], 0.05)
|
||||
if not readable:
|
||||
return _KEY_ESCAPE
|
||||
tail = os.read(fd, 2)
|
||||
return _CSI_KEYS.get(tail.decode("ascii", "replace"), _KEY_OTHER)
|
||||
if first in (b"\r", b"\n"):
|
||||
return _KEY_ENTER
|
||||
if first == b"\x01": # Ctrl-A
|
||||
return _KEY_HOME
|
||||
if first == b"\x05": # Ctrl-E
|
||||
return _KEY_END
|
||||
if first == b"k":
|
||||
return _KEY_UP
|
||||
if first == b"j":
|
||||
return _KEY_DOWN
|
||||
return _KEY_OTHER
|
||||
|
||||
|
||||
def _option_line(option: str, selected: bool) -> str:
|
||||
"""One rendered row: bold with a '>' marker on the selected line."""
|
||||
prefix = "> " if selected else " "
|
||||
if selected:
|
||||
return f"{_BOLD}{prefix}{option}{_RESET}"
|
||||
return f"{prefix}{option}"
|
||||
|
||||
|
||||
def _draw_menu_rows(options: Sequence[str], selected: int | None) -> None:
|
||||
"""Redraw or clear the option rows.
|
||||
|
||||
On entry the cursor sits at column 0 of the blank row just below the
|
||||
block; writes move up and repaint each row so the title above stays
|
||||
untouched. With ``selected=None`` every row is erased instead,
|
||||
clearing the menu before trailing prompts run.
|
||||
Up/Down (plus j/k) move the bold ``>``-marked highlight and Enter
|
||||
accepts; Esc or Ctrl-C backs out of a cancellable menu while Ctrl-C
|
||||
alone aborts fixed ones. questionary is imported lazily so
|
||||
non-interactive commands never pay for prompt-toolkit.
|
||||
"""
|
||||
out = sys.stdout
|
||||
out.write(_CURSOR_UP_TEMPLATE.format(len(options)))
|
||||
for index, option in enumerate(options):
|
||||
out.write(_ERASE_LINE + "\r")
|
||||
if selected is not None:
|
||||
out.write(_option_line(option, index == selected))
|
||||
out.write(_CURSOR_DOWN)
|
||||
out.flush()
|
||||
import questionary
|
||||
|
||||
choices = [questionary.Choice(title=text, value=index) for index, text in enumerate(options)]
|
||||
style = questionary.Style([("pointer", "bold"), ("selected", "bold")])
|
||||
question = questionary.select(title, choices, pointer=">", style=style)
|
||||
if cancellable:
|
||||
# questionary wires arrows, Enter and Ctrl-C itself but leaves Esc as
|
||||
# a no-op; graft back-out onto this prompt's own key-binding registry.
|
||||
registry = cast("KeyBindings", question.application.key_bindings)
|
||||
|
||||
def _arrow_menu(title: str, options: Sequence[str], *, cancellable: bool = False) -> int | None:
|
||||
"""Inline re-rendered menu driven by raw-mode keypresses."""
|
||||
import termios
|
||||
import tty
|
||||
@registry.add("escape", eager=True)
|
||||
def _back_out(event: KeyPressEvent) -> None:
|
||||
event.app.exit(result=_CANCELLED)
|
||||
|
||||
print(title)
|
||||
selected = 0
|
||||
for index, option in enumerate(options): # fresh draw lands one row per option
|
||||
print(_option_line(option, index == selected))
|
||||
fd = sys.stdin.fileno()
|
||||
saved_state = termios.tcgetattr(fd)
|
||||
choice: int | None = None
|
||||
try:
|
||||
tty.setcbreak(fd)
|
||||
while True:
|
||||
key = _read_keypress()
|
||||
if key == _KEY_ENTER:
|
||||
choice = selected
|
||||
break
|
||||
if key == _KEY_UP:
|
||||
selected = (selected - 1) % len(options)
|
||||
elif key == _KEY_DOWN:
|
||||
selected = (selected + 1) % len(options)
|
||||
elif key == _KEY_HOME:
|
||||
selected = 0
|
||||
elif key == _KEY_END:
|
||||
selected = len(options) - 1
|
||||
elif cancellable and key in (_KEY_ESCAPE, "q"):
|
||||
break
|
||||
else: # Esc/q on fixed menus, or any unrecognised key: no-op.
|
||||
continue
|
||||
_draw_menu_rows(options, selected)
|
||||
except KeyboardInterrupt:
|
||||
# ISIG stays enabled under cbreak, so Ctrl-C raises SIGINT here.
|
||||
picked = question.unsafe_ask()
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
if not cancellable:
|
||||
raise
|
||||
finally:
|
||||
termios.tcsetattr(fd, termios.TCSADRAIN, saved_state)
|
||||
_draw_menu_rows(options, None)
|
||||
return choice
|
||||
raise # Ctrl-C aborts fixed menus, like every other wizard input
|
||||
return None
|
||||
if not isinstance(picked, int): # the Esc sentinel, or any surprise value
|
||||
return None
|
||||
return picked
|
||||
|
||||
|
||||
def _ask(prompt: str, default: str = "") -> str:
|
||||
|
||||
+208
-107
@@ -2,20 +2,21 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
import io
|
||||
import os
|
||||
from pathlib import Path
|
||||
import pty
|
||||
import re
|
||||
import select
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import types
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
from unittest import mock
|
||||
|
||||
from weekly_activity.wizard import _menu, _option_line
|
||||
from weekly_activity.wizard import _CANCELLED, _menu
|
||||
|
||||
|
||||
class NumberedMenuFallbackTest(unittest.TestCase):
|
||||
@@ -52,58 +53,211 @@ class NumberedMenuFallbackTest(unittest.TestCase):
|
||||
self.assertEqual(output.count("Please enter a number between 1 and 3."), 2)
|
||||
|
||||
|
||||
class OptionLineRenderingTest(unittest.TestCase):
|
||||
"""Selected rows carry the '>' marker and bold codes; others stay plain."""
|
||||
|
||||
def test_selected_line_is_bold_and_marked(self) -> None:
|
||||
self.assertEqual(_option_line("Exit", True), "\x1b[1m> Exit\x1b[0m")
|
||||
|
||||
def test_unselected_line_is_indented_and_plain(self) -> None:
|
||||
self.assertEqual(_option_line("Remove a source", False), " Remove a source")
|
||||
# --- Interactive (TTY) path -----------------------------------------------
|
||||
|
||||
|
||||
_CHILD_SCRIPT = """
|
||||
class _Tty(io.StringIO):
|
||||
"""A stream claiming to be a terminal so _menu routes to questionary."""
|
||||
|
||||
def isatty(self) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
class _FakeKeyBindings:
|
||||
"""Registry mimicking prompt_toolkit's enough to capture added handlers."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.added: list[tuple[tuple[Any, ...], Any]] = []
|
||||
|
||||
def add(self, *keys: Any, eager: bool = False):
|
||||
def register(handler):
|
||||
self.added.append((keys, handler))
|
||||
return handler
|
||||
|
||||
return register
|
||||
|
||||
|
||||
class _FakeApplication:
|
||||
def __init__(self) -> None:
|
||||
self.key_bindings = _FakeKeyBindings()
|
||||
self.exit_calls: list[dict[str, Any]] = []
|
||||
|
||||
def exit(self, result: Any = None, exception: BaseException | None = None, style=None) -> None:
|
||||
self.exit_calls.append({"result": result, "exception": exception})
|
||||
|
||||
|
||||
class _FakeQuestion:
|
||||
def __init__(self, outcome: Any) -> None:
|
||||
self.application = _FakeApplication()
|
||||
self.outcome = outcome
|
||||
|
||||
def unsafe_ask(self) -> Any:
|
||||
if isinstance(self.outcome, BaseException):
|
||||
raise self.outcome
|
||||
return self.outcome
|
||||
|
||||
|
||||
class _FakeQuestionary:
|
||||
"""Stand-in exposing exactly the surface wizard._tty_menu touches."""
|
||||
|
||||
def __init__(self, outcome: Any) -> None:
|
||||
self.outcome = outcome
|
||||
self.select_outcome = _FakeQuestion(outcome)
|
||||
self.seen: dict[str, Any] = {}
|
||||
|
||||
def Choice(self, **kwargs: Any) -> Any:
|
||||
return types.SimpleNamespace(**kwargs)
|
||||
|
||||
@staticmethod
|
||||
def Style(styles: list[tuple[str, str]]) -> list[tuple[str, str]]:
|
||||
return styles
|
||||
|
||||
def select(self, message: str, choices: list[Any], **kwargs: Any) -> _FakeQuestion:
|
||||
self.seen.update(message=message, choices=list(choices), **kwargs)
|
||||
return self.select_outcome
|
||||
|
||||
|
||||
class QuestionaryDelegationTest(unittest.TestCase):
|
||||
"""On terminals _menu must hand selection to questionary, not re-implement it."""
|
||||
|
||||
def ask_tty(
|
||||
self,
|
||||
options: list[str],
|
||||
outcome: Any,
|
||||
*,
|
||||
cancellable: bool,
|
||||
) -> tuple[int | None, _FakeQuestion, dict[str, Any]]:
|
||||
"""Run the TTY branch against an injected fake questionary module."""
|
||||
fake = _FakeQuestionary(outcome)
|
||||
|
||||
with (
|
||||
mock.patch.object(sys, "stdin", _Tty()),
|
||||
mock.patch.object(sys, "stdout", _Tty()),
|
||||
mock.patch.dict(sys.modules, {"questionary": cast("Any", fake)}),
|
||||
):
|
||||
picked = _menu("Pick one", options, cancellable=cancellable)
|
||||
return picked, fake.select_outcome, fake.seen
|
||||
|
||||
def test_non_cancellable_menu_delegates_and_maps_choice_value_to_index(self) -> None:
|
||||
picked, question, seen = self.ask_tty(
|
||||
["Alpha", "Beta", "Gamma"],
|
||||
outcome=2,
|
||||
cancellable=False,
|
||||
)
|
||||
self.assertEqual(picked, 2)
|
||||
self.assertEqual(seen["message"], "Pick one")
|
||||
self.assertEqual(seen["pointer"], ">")
|
||||
self.assertEqual(
|
||||
[(choice.title, choice.value) for choice in seen["choices"]],
|
||||
[("Alpha", 0), ("Beta", 1), ("Gamma", 2)],
|
||||
)
|
||||
# A fixed menu grafts nothing onto questionary's own bindings.
|
||||
self.assertEqual(question.application.key_bindings.added, [])
|
||||
|
||||
def test_cancellable_menu_keeps_fixed_menus_behaviour_on_ctrl_c(self) -> None:
|
||||
with self.assertRaises(KeyboardInterrupt):
|
||||
self.ask_tty(["Alpha", "Beta"], outcome=KeyboardInterrupt(), cancellable=False)
|
||||
|
||||
def test_cancellable_menu_maps_ctrl_c_to_none(self) -> None:
|
||||
picked, _, _ = self.ask_tty(
|
||||
["Alpha", "Beta"],
|
||||
outcome=KeyboardInterrupt(),
|
||||
cancellable=True,
|
||||
)
|
||||
self.assertIsNone(picked)
|
||||
|
||||
def test_cancellable_menu_grafts_escape_onto_the_prompt_bindings(self) -> None:
|
||||
picked, question, _ = self.ask_tty(
|
||||
["Alpha", "Beta"],
|
||||
outcome=_CANCELLED,
|
||||
cancellable=True,
|
||||
)
|
||||
self.assertIsNone(picked) # a non-int answer means "backed out"
|
||||
keys, handler = question.application.key_bindings.added[-1]
|
||||
self.assertIn("escape", keys)
|
||||
handler(types.SimpleNamespace(app=question.application))
|
||||
self.assertIs(question.application.exit_calls[0]["result"], _CANCELLED)
|
||||
|
||||
|
||||
# --- Real-terminal proof over a pty ----------------------------------------
|
||||
|
||||
|
||||
_MENU_CHILD = """
|
||||
import os
|
||||
import sys
|
||||
import termios
|
||||
import tty
|
||||
|
||||
sys.path.insert(0, os.environ["WIZARD_SRC"])
|
||||
from weekly_activity.wizard import _read_keypress # noqa: E402
|
||||
from weekly_activity.wizard import _tty_menu # noqa: E402
|
||||
|
||||
fd = sys.stdin.fileno()
|
||||
saved = termios.tcgetattr(fd)
|
||||
tty.setcbreak(fd)
|
||||
try:
|
||||
for _ in range(int(sys.argv[1])):
|
||||
print("READY", flush=True)
|
||||
print(f"KEY={_read_keypress()}", flush=True)
|
||||
finally:
|
||||
termios.tcsetattr(fd, termios.TCSADRAIN, saved)
|
||||
picked = _tty_menu("Pick one", ["Alpha", "Beta", "Gamma"], cancellable=True)
|
||||
print(f"PICKED={picked}", flush=True)
|
||||
"""
|
||||
|
||||
|
||||
_KEY_LINE = re.compile(rb"KEY=([a-z]+)\r\n")
|
||||
_PICKED_LINE = re.compile(rb"PICKED=(\d+|None)")
|
||||
|
||||
|
||||
class ArrowKeyKeypressTest(unittest.TestCase):
|
||||
"""CSI sequences must classify over a real pty instead of surfacing as bare Esc.
|
||||
class ArrowMenuPtyTest(unittest.TestCase):
|
||||
"""Drive the genuine questionary menu across a real pty with raw keypresses.
|
||||
|
||||
Drives the genuine ``_read_keypress()`` on a cbreak-mode terminal — the path the
|
||||
arrow-key menu uses, unreachable through scripted ``io.StringIO`` stdin.
|
||||
This is the regression case the reviewer hit: arrow presses must move the
|
||||
highlight of a live menu, which scripted ``io.StringIO`` stdin cannot reach.
|
||||
"""
|
||||
|
||||
TIMEOUT_S = 20.0
|
||||
TIMEOUT_S = 30.0
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.child_env = {
|
||||
**os.environ,
|
||||
"WIZARD_SRC": str(Path(__file__).resolve().parents[1] / "src"),
|
||||
}
|
||||
|
||||
def drive_menu(self, sends: list[bytes]) -> tuple[str, int]:
|
||||
"""Render the menu on a pty, send keystrokes, return screen + exit code."""
|
||||
master, slave = pty.openpty()
|
||||
child = subprocess.Popen(
|
||||
[sys.executable, "-c", _MENU_CHILD],
|
||||
stdin=slave,
|
||||
stdout=slave,
|
||||
stderr=subprocess.PIPE,
|
||||
env=self.child_env,
|
||||
)
|
||||
os.close(slave)
|
||||
screen = bytearray()
|
||||
|
||||
try:
|
||||
self._await(screen, master, child, lambda: b"Gamma" in screen, "menu render")
|
||||
for payload in sends:
|
||||
os.write(master, payload)
|
||||
time.sleep(0.2) # let the vt100 parser see keystrokes separately
|
||||
self._await(
|
||||
screen,
|
||||
master,
|
||||
child,
|
||||
lambda: _PICKED_LINE.search(bytes(screen)) is not None,
|
||||
"final PICKED line",
|
||||
)
|
||||
child.wait(timeout=self.TIMEOUT_S)
|
||||
err = child.stderr.read().decode("utf-8", "replace") if child.stderr else ""
|
||||
self.assertEqual(child.returncode, 0, f"menu child failed: {err}")
|
||||
return bytes(screen).decode("utf-8", "replace"), child.returncode
|
||||
finally:
|
||||
if child.poll() is None:
|
||||
child.kill()
|
||||
child.wait()
|
||||
if child.stderr is not None:
|
||||
child.stderr.close()
|
||||
os.close(master)
|
||||
|
||||
def _await(
|
||||
self,
|
||||
screen: bytearray,
|
||||
master: int,
|
||||
child: subprocess.Popen[bytes],
|
||||
box: dict[str, bytes],
|
||||
condition: Callable[[], bool],
|
||||
condition,
|
||||
stage: str,
|
||||
) -> None:
|
||||
"""Block until ``condition`` holds on the pty stream, else fail loudly."""
|
||||
"""Read the pty until ``condition`` holds on accumulated output."""
|
||||
deadline = time.monotonic() + self.TIMEOUT_S
|
||||
while not condition():
|
||||
remaining = deadline - time.monotonic()
|
||||
@@ -116,90 +270,37 @@ class ArrowKeyKeypressTest(unittest.TestCase):
|
||||
except OSError: # slave closed: the child exited early
|
||||
break
|
||||
if chunk:
|
||||
box["stream"] += chunk
|
||||
screen.extend(chunk)
|
||||
else:
|
||||
return
|
||||
if child.poll() is None:
|
||||
child.kill()
|
||||
child.wait(timeout=5)
|
||||
err = child.stderr.read().decode("utf-8", "replace") if child.stderr else ""
|
||||
self.fail(f"{stage}: stalled; tail={box['stream'][-300:]!r} stderr={err!r}")
|
||||
self.fail(f"{stage}: stalled; tail={bytes(screen)[-300:]!r} stderr={err!r}")
|
||||
|
||||
def _child_tokens(self, sends: list[bytes]) -> list[str]:
|
||||
"""Send each payload after its READY prompt; return printed key tokens."""
|
||||
src_dir = str(Path(__file__).resolve().parents[1] / "src")
|
||||
master, slave = pty.openpty()
|
||||
child = subprocess.Popen(
|
||||
[sys.executable, "-c", _CHILD_SCRIPT, str(len(sends))],
|
||||
stdin=slave,
|
||||
stdout=slave,
|
||||
stderr=subprocess.PIPE,
|
||||
env={**os.environ, "WIZARD_SRC": src_dir},
|
||||
)
|
||||
os.close(slave)
|
||||
box: dict[str, bytes] = {"stream": b""}
|
||||
def _picked(self, screen: str) -> str:
|
||||
found = _PICKED_LINE.search(screen.encode())
|
||||
if found is None:
|
||||
self.fail(f"no PICKED line in {screen[-300:]!r}")
|
||||
return found.group(1).decode()
|
||||
|
||||
try:
|
||||
tokens: list[str] = []
|
||||
for index, payload in enumerate(sends):
|
||||
self._await(
|
||||
master,
|
||||
child,
|
||||
box,
|
||||
lambda i=index: box["stream"].count(b"READY") > i,
|
||||
f"READY prompt {index} before {payload!r}",
|
||||
)
|
||||
seen = len(_KEY_LINE.findall(box["stream"]))
|
||||
os.write(master, payload)
|
||||
self._await(
|
||||
master,
|
||||
child,
|
||||
box,
|
||||
lambda n=seen: len(_KEY_LINE.findall(box["stream"])) > n,
|
||||
f"token line after {payload!r}",
|
||||
)
|
||||
tokens.append(_KEY_LINE.findall(box["stream"])[-1].decode())
|
||||
def test_down_arrow_moves_highlight_and_enter_accepts(self) -> None:
|
||||
# Start on "Alpha"; one Down must land on "Beta" — the reported bug.
|
||||
screen, _ = self.drive_menu([b"\x1b[B", b"\r"])
|
||||
self.assertEqual(self._picked(screen), "1")
|
||||
|
||||
self.assertEqual(
|
||||
box["stream"].count(b"READY"),
|
||||
len(sends),
|
||||
f"unexpected output tail {box['stream'][-300:]!r}",
|
||||
)
|
||||
self.assertEqual(len(_KEY_LINE.findall(box["stream"])), len(sends))
|
||||
try:
|
||||
child.wait(timeout=self.TIMEOUT_S)
|
||||
except subprocess.TimeoutExpired:
|
||||
child.kill()
|
||||
child.wait()
|
||||
self.fail("child ignored termination after completing every keystroke")
|
||||
err = child.stderr.read().decode("utf-8", "replace") if child.stderr else ""
|
||||
self.assertEqual(child.returncode, 0, err)
|
||||
return tokens
|
||||
finally:
|
||||
if child.poll() is None:
|
||||
child.kill()
|
||||
child.wait()
|
||||
if child.stderr is not None:
|
||||
child.stderr.close()
|
||||
os.close(master)
|
||||
def test_repeated_down_arrows_reach_third_option(self) -> None:
|
||||
screen, _ = self.drive_menu([b"\x1b[B", b"\x1b[B", b"\r"])
|
||||
self.assertEqual(self._picked(screen), "2")
|
||||
|
||||
def test_arrow_home_end_sequences_map_to_selection_keys(self) -> None:
|
||||
tokens = self._child_tokens([b"\x1b[A", b"\x1b[B", b"\x1b[H", b"\x1b[F"])
|
||||
self.assertEqual(tokens, ["up", "down", "home", "end"])
|
||||
def test_enter_without_movement_accepts_first_option(self) -> None:
|
||||
screen, _ = self.drive_menu([b"\r"])
|
||||
self.assertEqual(self._picked(screen), "0")
|
||||
|
||||
def test_single_byte_keystrokes_still_classify(self) -> None:
|
||||
# The reader switched to raw fd bytes; letters/Enter must classify as before.
|
||||
tokens = self._child_tokens([b"\r", b"k", b"j", b"x"])
|
||||
self.assertEqual(tokens, ["enter", "up", "down", "other"])
|
||||
|
||||
def test_bare_escape_is_classified_as_escape(self) -> None:
|
||||
tokens = self._child_tokens([b"\x1b"])
|
||||
self.assertEqual(tokens, ["escape"])
|
||||
|
||||
def test_escape_sequence_tail_does_not_leak_into_next_keypress(self) -> None:
|
||||
# The original report: an arrow press poisoned the following reads.
|
||||
tokens = self._child_tokens([b"\x1b[B", b"j", b"\r"])
|
||||
self.assertEqual(tokens, ["down", "down", "enter"])
|
||||
def test_escape_backs_out_of_a_cancellable_menu(self) -> None:
|
||||
screen, _ = self.drive_menu([b"\x1b"])
|
||||
self.assertEqual(self._picked(screen), "None")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -381,6 +381,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/16/12b82f791c7f50ddec566873d5bdd245baa1491bac11d15ffb98aecc8f8b/pefile-2024.8.26-py3-none-any.whl", hash = "sha256:76f8b485dcd3b1bb8166f1128d395fa3d87af26360c2358fb75b80019b957c6f", size = 74766, upload-time = "2024-08-26T21:01:02.632Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prompt-toolkit"
|
||||
version = "3.0.53"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "wcwidth" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7d/ea/39b988c938f75cb75d7045b5c69f8bfed47ee2152c8837fb403de29d6fb8/prompt_toolkit-3.0.53.tar.gz", hash = "sha256:9ec8a0ad96d5c56148b3f914aa79c1564c3fde5d2e6b876e7bc327e353cf8fa6", size = 435492, upload-time = "2026-07-26T20:56:14.758Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/6f/84908cad2d6aa5144abcf7b42709fe4fdb459bc640ec7ac5786e7693dabc/prompt_toolkit-3.0.53-py3-none-any.whl", hash = "sha256:01c0891d7f9237d5e339f7d3e42cdae80b7534abb1c7c0e3352efba6231492f2", size = 392288, upload-time = "2026-07-26T20:56:12.512Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pycparser"
|
||||
version = "3.0"
|
||||
@@ -457,6 +469,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "questionary"
|
||||
version = "2.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "prompt-toolkit" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f6/45/eafb0bba0f9988f6a2520f9ca2df2c82ddfa8d67c95d6625452e97b204a5/questionary-2.1.1.tar.gz", hash = "sha256:3d7e980292bb0107abaa79c68dd3eee3c561b83a0f89ae482860b181c8bd412d", size = 25845, upload-time = "2025-08-28T19:00:20.851Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/26/1062c7ec1b053db9e499b4d2d5bc231743201b74051c973dadeac80a8f43/questionary-2.1.1-py3-none-any.whl", hash = "sha256:a51af13f345f1cdea62347589fbb6df3b290306ab8930713bfae4d475a7d4a59", size = 36753, upload-time = "2025-08-28T19:00:19.56Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.16.4"
|
||||
@@ -550,6 +574,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/f3/00b61e720165f73c6ef3c7a6ea732314c8ca1ba95b9ed1545fd788d9af9b/wadllib-2.1.0-py3-none-any.whl", hash = "sha256:41b58db0bb5fb21e188c7452281ee7b364b2690a4f6c388afea0ceaeafed1132", size = 61997, upload-time = "2026-07-01T10:48:01.37Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wcwidth"
|
||||
version = "0.8.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/34/74/c6428f875774288bec1396f5bfcbc2d925700a4dad61727fd5f2b12f249d/wcwidth-0.8.2.tar.gz", hash = "sha256:91fbef97204b96a3d4d421609b80340b760cf33e26da123ff243d76b1fda8dda", size = 1466253, upload-time = "2026-06-29T18:11:11.601Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/96/42/3e5985a0a7e57de470b320c6d6a1a67c844f6737a587f3d44dd13d1819e7/wcwidth-0.8.2-py3-none-any.whl", hash = "sha256:d63947694a0539a1d51e01eda7caf800c291020e6cdd7e28ad7b14dd33ad4f85", size = 323166, upload-time = "2026-06-29T18:11:09.888Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "weekly-activity"
|
||||
version = "0.3.0"
|
||||
@@ -559,6 +592,7 @@ dependencies = [
|
||||
{ name = "keyring" },
|
||||
{ name = "launchpadlib" },
|
||||
{ name = "python-debianbts" },
|
||||
{ name = "questionary" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
@@ -576,6 +610,7 @@ requires-dist = [
|
||||
{ name = "keyring", specifier = ">=25" },
|
||||
{ name = "launchpadlib", specifier = ">=2.1.0" },
|
||||
{ name = "python-debianbts", specifier = ">=4.1.1" },
|
||||
{ name = "questionary", specifier = ">=2.1.1" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
|
||||
Reference in New Issue
Block a user