diff --git a/pyproject.toml b/pyproject.toml index e2ce52b..4ff69a6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,6 +8,7 @@ dependencies = [ "keyring>=25", "httpx>=0.27", "python-debianbts>=4.1.1", + "questionary>=2.1.1", ] [project.scripts] diff --git a/src/weekly_activity/wizard.py b/src/weekly_activity/wizard.py index c9ca2e1..5608fa3 100644 --- a/src/weekly_activity/wizard.py +++ b/src/weekly_activity/wizard.py @@ -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: diff --git a/tests/test_wizard.py b/tests/test_wizard.py index 5bb0b56..fbfe9e9 100644 --- a/tests/test_wizard.py +++ b/tests/test_wizard.py @@ -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__": diff --git a/uv.lock b/uv.lock index d92f677..fd75631 100644 --- a/uv.lock +++ b/uv.lock @@ -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]