commit 1d1f6668c46fd5d1ae703de78bfc6031b059b161 equwal <13551856+equwal@users.noreply.github.com> 2026-10-03 19:13:19 -0700 Show the steps in bemenu when Python has no Tk Linux desktop g runs Wayland without X11, so its Python has no tkinter and handoff.py crashed at import. The tkinter import is now optional. Without Tk, MenuDialog lists the steps in bemenu: Done and Not done answer, typed text becomes the note for Not done, Escape means Not done, and the timeout still applies.
skills/deploy-handoff/SKILL.md | 2 +- skills/deploy-handoff/handoff.py | 63 ++++++++++++++++++++++++++++++-- skills/deploy-handoff/test_handoff.py | 69 +++++++++++++++++++++++++++++++++++ 3 files changed, 129 insertions(+), 5 deletions(-)
diff --git a/skills/deploy-handoff/SKILL.md b/skills/deploy-handoff/SKILL.md index bac242c..d0cdfc8 100644 --- a/skills/deploy-handoff/SKILL.md +++ b/skills/deploy-handoff/SKILL.md @@ -16,7 +16,7 @@ description: >- Run the commands in a POSIX shell. On Windows, use Git Bash. Windows PowerShell 5.1 removes the double quotation marks inside the arguments. -The script shows a small dialog on top of the browser. The dialog tells the user what to do. The user does the last step and clicks **Done** or **Not done**. The script never clicks for the user. +The script shows a small dialog on top of the browser. The dialog tells the user what to do. The user does the last step and clicks **Done** or **Not done**. The script never clicks for the user. If Python has no Tk, for example on a Wayland desktop without X11, the script shows the steps in a `bemenu` list instead. ## 0. Select the automation mode diff --git a/skills/deploy-handoff/handoff.py b/skills/deploy-handoff/handoff.py index a4ccf13..e597666 100644 --- a/skills/deploy-handoff/handoff.py +++ b/skills/deploy-handoff/handoff.py @@ -26,17 +26,24 @@ import shutil import subprocess import sys import time -import tkinter as tk import tomllib import urllib.request import webbrowser from collections.abc import Callable, Iterable from dataclasses import dataclass from pathlib import Path -from tkinter import font as tkfont -from tkinter import ttk from urllib.parse import quote, urlencode, urlsplit +# A Wayland desktop without X11 has no Tk. Then MenuDialog shows the steps in bemenu. +try: + import tkinter as tk + from tkinter import font as tkfont + from tkinter import ttk + + HAVE_TK = True +except ImportError: + HAVE_TK = False + SHIPPED_CONFIG = Path(__file__).with_name("config.toml") USER_CONFIG = ( Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config") @@ -513,6 +520,52 @@ class Dialog: return self.status, self.note_text +class MenuDialog: + """The steps in a bemenu list, for a Wayland desktop that has no Tk.""" + + DONE = "Done" + NOT_DONE = "Not done" + + def __init__(self, request: Request, timeout_minutes: float, reopen: Callable[[], None]): + if shutil.which("bemenu") is None: + raise HandoffError("Cannot show the steps: install Python Tk or bemenu.") + self.title = request.title + self.deadline = time.monotonic() + timeout_minutes * 60 + steps = [f"{number}. {step}" for number, step in enumerate(request.steps, 1)] + self.items = [ + *steps, + f"Site: {urlsplit(request.url).hostname}", + "If you cannot finish, type the reason and press Enter.", + self.DONE, + self.NOT_DONE, + ] + + def run(self) -> tuple[str, str]: + """Show the list until the user selects an answer or the time ends.""" + while (remaining := self.deadline - time.monotonic()) > 0: + command = ["bemenu", "--list", str(len(self.items)), "--prompt", self.title] + try: + answer = subprocess.run( + command, + input="\n".join(self.items), + capture_output=True, + text=True, + timeout=remaining, + ) + except subprocess.TimeoutExpired: + break + choice = answer.stdout.strip() + # Escape makes bemenu exit with code 1. + if answer.returncode != 0 or choice == self.NOT_DONE: + return "not_done", "" + if choice == self.DONE: + return "done", "" + if choice not in self.items: + return "not_done", choice + # The user selected a step or the site. Show the list again. + return "timeout", "" + + def parse_args(argv: list[str] | None) -> argparse.Namespace: """Read the command line.""" common = argparse.ArgumentParser(add_help=False) @@ -584,9 +637,11 @@ def main(argv: list[str] | None = None) -> int: # With --no-open, the caller drove a browser to the page already. if args.command == "pr" or not args.no_open: open_page(request.url) + show = Dialog if HAVE_TK else MenuDialog + dialog = show(request, args.timeout, lambda: open_page(request.url)) except HandoffError as exc: return report({"status": "error", "error": str(exc)}) - status, note = Dialog(request, args.timeout, lambda: open_page(request.url)).run() + status, note = dialog.run() result: dict[str, str | None] = {"status": status, "note": note} if request.pr is not None and status == "done": result["pr_url"] = find_open_pr(*request.pr) diff --git a/skills/deploy-handoff/test_handoff.py b/skills/deploy-handoff/test_handoff.py index 645c83c..86ff32d 100644 --- a/skills/deploy-handoff/test_handoff.py +++ b/skills/deploy-handoff/test_handoff.py @@ -647,6 +647,66 @@ class DialogTest(unittest.TestCase): self.assertIn("Site: github.com", texts) +class MenuDialogTest(unittest.TestCase): + """Test the bemenu dialog with a fake bemenu.""" + + def setUp(self) -> None: + which = mock.patch("shutil.which", return_value="/usr/bin/bemenu") + which.start() + self.addCleanup(which.stop) + self.request = Request("Publish 1.2", "https://github.com/o", ("Click it.",)) + + def answer(self, *results: subprocess.CompletedProcess[str] | Exception) -> mock.MagicMock: + run = mock.MagicMock(side_effect=list(results)) + patcher = mock.patch("subprocess.run", run) + patcher.start() + self.addCleanup(patcher.stop) + return run + + def pick(self, choice: str, code: int = 0) -> subprocess.CompletedProcess[str]: + return subprocess.CompletedProcess(["bemenu"], code, stdout=choice + "\n") + + def run_menu(self, timeout_minutes: float = 1) -> tuple[str, str]: + return handoff.MenuDialog(self.request, timeout_minutes, lambda: None).run() + + def test_done(self) -> None: + run = self.answer(self.pick("Done")) + self.assertEqual(self.run_menu(), ("done", "")) + command = run.call_args.args[0] + self.assertEqual(command[0], "bemenu") + self.assertIn("Publish 1.2", command) + self.assertIn("1. Click it.", run.call_args.kwargs["input"].splitlines()) + self.assertIn("Site: github.com", run.call_args.kwargs["input"].splitlines()) + + def test_not_done(self) -> None: + self.answer(self.pick("Not done")) + self.assertEqual(self.run_menu(), ("not_done", "")) + + def test_escape_means_not_done(self) -> None: + self.answer(self.pick("", code=1)) + self.assertEqual(self.run_menu(), ("not_done", "")) + + def test_typed_text_is_the_note(self) -> None: + self.answer(self.pick(" no access ")) + self.assertEqual(self.run_menu(), ("not_done", "no access")) + + def test_a_step_shows_the_list_again(self) -> None: + run = self.answer(self.pick("1. Click it."), self.pick("Done")) + self.assertEqual(self.run_menu(), ("done", "")) + self.assertEqual(run.call_count, 2) + + def test_timeout(self) -> None: + self.answer(subprocess.TimeoutExpired(["bemenu"], 1)) + self.assertEqual(self.run_menu(), ("timeout", "")) + + def test_no_bemenu_is_an_error(self) -> None: + with ( + mock.patch("shutil.which", return_value=None), + self.assertRaises(HandoffError), + ): + handoff.MenuDialog(self.request, 1, lambda: None) + + class MainTest(unittest.TestCase): """Test main with a fake browser and a fake dialog.""" @@ -705,6 +765,15 @@ class MainTest(unittest.TestCase): self.open_page.assert_not_called() self.dialog.assert_called_once() + def test_without_tk_uses_bemenu(self) -> None: + # Linux desktop g runs Wayland without X11, so its Python has no tkinter. + self.patch("HAVE_TK", False) + menu = self.patch("MenuDialog", mock.MagicMock()) + menu.return_value.run.return_value = ("done", "") + self.assertEqual(self.open_url("https://github.com/o")[0], 0) + menu.assert_called_once() + self.dialog.assert_not_called() + def test_open_refuses_long_steps(self) -> None: code, result = self.run_main( "open", "--url", "https://github.com/o", "--title", "Do it", "--step", "word " * 21