refactor(wizard): replace hand-rolled arrow-key menu with questionary
CI / check (push) Successful in 32s
CI / release (push) Skipped

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:
2026-08-27 08:36:56 +00:00
parent 12ca06ba0d
commit daf0a203a0
4 changed files with 277 additions and 212 deletions
+1
View File
@@ -8,6 +8,7 @@ dependencies = [
"keyring>=25", "keyring>=25",
"httpx>=0.27", "httpx>=0.27",
"python-debianbts>=4.1.1", "python-debianbts>=4.1.1",
"questionary>=2.1.1",
] ]
[project.scripts] [project.scripts]
+33 -105
View File
@@ -18,6 +18,7 @@ import os
import sys import sys
from collections.abc import Callable, Sequence from collections.abc import Callable, Sequence
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, cast
from weekly_activity.aggregate import source_label from weekly_activity.aggregate import source_label
from weekly_activity.config import ( from weekly_activity.config import (
@@ -32,6 +33,10 @@ from weekly_activity.config import (
) )
from weekly_activity.sources.launchpad import LaunchpadSource from weekly_activity.sources.launchpad import LaunchpadSource
if TYPE_CHECKING:
from prompt_toolkit.key_binding import KeyBindings, KeyPressEvent
_APPLICATION_NAME = "weekly-activity" _APPLICATION_NAME = "weekly-activity"
# Stable processing order, shared by menus and summaries: (config attr, menu text). # 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. Returns the selected index, or None when backed out of a cancellable menu.
""" """
if sys.stdin.isatty() and sys.stdout.isatty(): 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) 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) --- # --- Arrow-key menu (interactive TTYs only) ---
_BOLD = "\x1b[1m" # Sentinel handed back through the prompt when Esc backs out; it can never
_RESET = "\x1b[0m" # collide with a real choice because every choice's value is its list index.
_CANCELLED = object()
_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}
def _read_keypress() -> str: def _tty_menu(title: str, options: Sequence[str], *, cancellable: bool = False) -> int | None:
"""Read one keystroke from stdin (cbreak mode); classify it.""" """Arrow-key menu rendered by questionary instead of hand-rolled ANSI.
import select
fd = sys.stdin.fileno() Up/Down (plus j/k) move the bold ``>``-marked highlight and Enter
# Read raw bytes from the fd: a buffered text read would pull the whole accepts; Esc or Ctrl-C backs out of a cancellable menu while Ctrl-C
# escape sequence into its own buffer, leaving nothing for select to see. alone aborts fixed ones. questionary is imported lazily so
first = os.read(fd, 1) non-interactive commands never pay for prompt-toolkit.
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.
""" """
out = sys.stdout import questionary
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()
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: @registry.add("escape", eager=True)
"""Inline re-rendered menu driven by raw-mode keypresses.""" def _back_out(event: KeyPressEvent) -> None:
import termios event.app.exit(result=_CANCELLED)
import tty
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: try:
tty.setcbreak(fd) picked = question.unsafe_ask()
while True: except (KeyboardInterrupt, EOFError):
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.
if not cancellable: if not cancellable:
raise raise # Ctrl-C aborts fixed menus, like every other wizard input
finally: return None
termios.tcsetattr(fd, termios.TCSADRAIN, saved_state) if not isinstance(picked, int): # the Esc sentinel, or any surprise value
_draw_menu_rows(options, None) return None
return choice return picked
def _ask(prompt: str, default: str = "") -> str: def _ask(prompt: str, default: str = "") -> str:
+208 -107
View File
@@ -2,20 +2,21 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable
import io import io
import os import os
from pathlib import Path
import pty import pty
import re import re
import select import select
import subprocess import subprocess
import sys import sys
import time import time
import types
import unittest import unittest
from pathlib import Path
from typing import Any, cast
from unittest import mock from unittest import mock
from weekly_activity.wizard import _menu, _option_line from weekly_activity.wizard import _CANCELLED, _menu
class NumberedMenuFallbackTest(unittest.TestCase): 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) self.assertEqual(output.count("Please enter a number between 1 and 3."), 2)
class OptionLineRenderingTest(unittest.TestCase): # --- Interactive (TTY) path -----------------------------------------------
"""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")
_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 os
import sys import sys
import termios
import tty
sys.path.insert(0, os.environ["WIZARD_SRC"]) 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() picked = _tty_menu("Pick one", ["Alpha", "Beta", "Gamma"], cancellable=True)
saved = termios.tcgetattr(fd) print(f"PICKED={picked}", flush=True)
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_LINE = re.compile(rb"PICKED=(\d+|None)")
_KEY_LINE = re.compile(rb"KEY=([a-z]+)\r\n")
class ArrowKeyKeypressTest(unittest.TestCase): class ArrowMenuPtyTest(unittest.TestCase):
"""CSI sequences must classify over a real pty instead of surfacing as bare Esc. """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 This is the regression case the reviewer hit: arrow presses must move the
arrow-key menu uses, unreachable through scripted ``io.StringIO`` stdin. 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( def _await(
self, self,
screen: bytearray,
master: int, master: int,
child: subprocess.Popen[bytes], child: subprocess.Popen[bytes],
box: dict[str, bytes], condition,
condition: Callable[[], bool],
stage: str, stage: str,
) -> None: ) -> 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 deadline = time.monotonic() + self.TIMEOUT_S
while not condition(): while not condition():
remaining = deadline - time.monotonic() remaining = deadline - time.monotonic()
@@ -116,90 +270,37 @@ class ArrowKeyKeypressTest(unittest.TestCase):
except OSError: # slave closed: the child exited early except OSError: # slave closed: the child exited early
break break
if chunk: if chunk:
box["stream"] += chunk screen.extend(chunk)
else: else:
return return
if child.poll() is None: if child.poll() is None:
child.kill() child.kill()
child.wait(timeout=5) child.wait(timeout=5)
err = child.stderr.read().decode("utf-8", "replace") if child.stderr else "" 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]: def _picked(self, screen: str) -> str:
"""Send each payload after its READY prompt; return printed key tokens.""" found = _PICKED_LINE.search(screen.encode())
src_dir = str(Path(__file__).resolve().parents[1] / "src") if found is None:
master, slave = pty.openpty() self.fail(f"no PICKED line in {screen[-300:]!r}")
child = subprocess.Popen( return found.group(1).decode()
[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""}
try: def test_down_arrow_moves_highlight_and_enter_accepts(self) -> None:
tokens: list[str] = [] # Start on "Alpha"; one Down must land on "Beta" — the reported bug.
for index, payload in enumerate(sends): screen, _ = self.drive_menu([b"\x1b[B", b"\r"])
self._await( self.assertEqual(self._picked(screen), "1")
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())
self.assertEqual( def test_repeated_down_arrows_reach_third_option(self) -> None:
box["stream"].count(b"READY"), screen, _ = self.drive_menu([b"\x1b[B", b"\x1b[B", b"\r"])
len(sends), self.assertEqual(self._picked(screen), "2")
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_arrow_home_end_sequences_map_to_selection_keys(self) -> None: def test_enter_without_movement_accepts_first_option(self) -> None:
tokens = self._child_tokens([b"\x1b[A", b"\x1b[B", b"\x1b[H", b"\x1b[F"]) screen, _ = self.drive_menu([b"\r"])
self.assertEqual(tokens, ["up", "down", "home", "end"]) self.assertEqual(self._picked(screen), "0")
def test_single_byte_keystrokes_still_classify(self) -> None: def test_escape_backs_out_of_a_cancellable_menu(self) -> None:
# The reader switched to raw fd bytes; letters/Enter must classify as before. screen, _ = self.drive_menu([b"\x1b"])
tokens = self._child_tokens([b"\r", b"k", b"j", b"x"]) self.assertEqual(self._picked(screen), "None")
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"])
if __name__ == "__main__": if __name__ == "__main__":
Generated
+35
View File
@@ -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" }, { 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]] [[package]]
name = "pycparser" name = "pycparser"
version = "3.0" 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" }, { 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]] [[package]]
name = "ruff" name = "ruff"
version = "0.16.4" 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" }, { 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]] [[package]]
name = "weekly-activity" name = "weekly-activity"
version = "0.3.0" version = "0.3.0"
@@ -559,6 +592,7 @@ dependencies = [
{ name = "keyring" }, { name = "keyring" },
{ name = "launchpadlib" }, { name = "launchpadlib" },
{ name = "python-debianbts" }, { name = "python-debianbts" },
{ name = "questionary" },
] ]
[package.dev-dependencies] [package.dev-dependencies]
@@ -576,6 +610,7 @@ requires-dist = [
{ name = "keyring", specifier = ">=25" }, { name = "keyring", specifier = ">=25" },
{ name = "launchpadlib", specifier = ">=2.1.0" }, { name = "launchpadlib", specifier = ">=2.1.0" },
{ name = "python-debianbts", specifier = ">=4.1.1" }, { name = "python-debianbts", specifier = ">=4.1.1" },
{ name = "questionary", specifier = ">=2.1.1" },
] ]
[package.metadata.requires-dev] [package.metadata.requires-dev]