From 0d2ae7c3ed4b93040d111343a900c6b336d2c4d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Langa?= Date: Tue, 15 Jul 2025 14:14:46 +0200 Subject: [PATCH 01/12] gh-135621: Remove dependency on curses from PyREPL --- Lib/_pyrepl/_minimal_curses.py | 68 -- Lib/_pyrepl/curses.py | 33 - Lib/_pyrepl/terminfo.py | 855 ++++++++++++++++++++++ Lib/_pyrepl/unix_console.py | 16 +- Lib/_pyrepl/unix_eventqueue.py | 11 +- Lib/test/test_pyrepl/__init__.py | 11 +- Lib/test/test_pyrepl/test_eventqueue.py | 11 +- Lib/test/test_pyrepl/test_terminfo.py | 557 ++++++++++++++ Lib/test/test_pyrepl/test_unix_console.py | 38 +- 9 files changed, 1442 insertions(+), 158 deletions(-) delete mode 100644 Lib/_pyrepl/_minimal_curses.py delete mode 100644 Lib/_pyrepl/curses.py create mode 100644 Lib/_pyrepl/terminfo.py create mode 100644 Lib/test/test_pyrepl/test_terminfo.py diff --git a/Lib/_pyrepl/_minimal_curses.py b/Lib/_pyrepl/_minimal_curses.py deleted file mode 100644 index d884f880f50ac7..00000000000000 --- a/Lib/_pyrepl/_minimal_curses.py +++ /dev/null @@ -1,68 +0,0 @@ -"""Minimal '_curses' module, the low-level interface for curses module -which is not meant to be used directly. - -Based on ctypes. It's too incomplete to be really called '_curses', so -to use it, you have to import it and stick it in sys.modules['_curses'] -manually. - -Note that there is also a built-in module _minimal_curses which will -hide this one if compiled in. -""" - -import ctypes -import ctypes.util - - -class error(Exception): - pass - - -def _find_clib() -> str: - trylibs = ["ncursesw", "ncurses", "curses"] - - for lib in trylibs: - path = ctypes.util.find_library(lib) - if path: - return path - raise ModuleNotFoundError("curses library not found", name="_pyrepl._minimal_curses") - - -_clibpath = _find_clib() -clib = ctypes.cdll.LoadLibrary(_clibpath) - -clib.setupterm.argtypes = [ctypes.c_char_p, ctypes.c_int, ctypes.POINTER(ctypes.c_int)] -clib.setupterm.restype = ctypes.c_int - -clib.tigetstr.argtypes = [ctypes.c_char_p] -clib.tigetstr.restype = ctypes.c_ssize_t - -clib.tparm.argtypes = [ctypes.c_char_p] + 9 * [ctypes.c_int] # type: ignore[operator] -clib.tparm.restype = ctypes.c_char_p - -OK = 0 -ERR = -1 - -# ____________________________________________________________ - - -def setupterm(termstr, fd): - err = ctypes.c_int(0) - result = clib.setupterm(termstr, fd, ctypes.byref(err)) - if result == ERR: - raise error("setupterm() failed (err=%d)" % err.value) - - -def tigetstr(cap): - if not isinstance(cap, bytes): - cap = cap.encode("ascii") - result = clib.tigetstr(cap) - if result == ERR: - return None - return ctypes.cast(result, ctypes.c_char_p).value - - -def tparm(str, i1=0, i2=0, i3=0, i4=0, i5=0, i6=0, i7=0, i8=0, i9=0): - result = clib.tparm(str, i1, i2, i3, i4, i5, i6, i7, i8, i9) - if result is None: - raise error("tparm() returned NULL") - return result diff --git a/Lib/_pyrepl/curses.py b/Lib/_pyrepl/curses.py deleted file mode 100644 index 3a624d9f6835d1..00000000000000 --- a/Lib/_pyrepl/curses.py +++ /dev/null @@ -1,33 +0,0 @@ -# Copyright 2000-2010 Michael Hudson-Doyle -# Armin Rigo -# -# All Rights Reserved -# -# -# Permission to use, copy, modify, and distribute this software and -# its documentation for any purpose is hereby granted without fee, -# provided that the above copyright notice appear in all copies and -# that both that copyright notice and this permission notice appear in -# supporting documentation. -# -# THE AUTHOR MICHAEL HUDSON DISCLAIMS ALL WARRANTIES WITH REGARD TO -# THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY -# AND FITNESS, IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, -# INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER -# RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF -# CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN -# CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. - - -try: - import _curses -except ImportError: - try: - import curses as _curses # type: ignore[no-redef] - except ImportError: - from . import _minimal_curses as _curses # type: ignore[no-redef] - -setupterm = _curses.setupterm -tigetstr = _curses.tigetstr -tparm = _curses.tparm -error = _curses.error diff --git a/Lib/_pyrepl/terminfo.py b/Lib/_pyrepl/terminfo.py new file mode 100644 index 00000000000000..981e204331c754 --- /dev/null +++ b/Lib/_pyrepl/terminfo.py @@ -0,0 +1,855 @@ +"""Pure Python curses-like terminal capability queries.""" + +from dataclasses import dataclass, field +import errno +import os +from pathlib import Path +import re +import struct + + +# Terminfo constants +MAGIC16 = 0o432 # Magic number for 16-bit terminfo format +MAGIC32 = 0o1036 # Magic number for 32-bit terminfo format + +# Special values for absent/cancelled capabilities +ABSENT_BOOLEAN = -1 +ABSENT_NUMERIC = -1 +CANCELLED_NUMERIC = -2 +ABSENT_STRING = None +CANCELLED_STRING = None + +# Standard string capability names from ncurses Caps file +# This matches the order used by ncurses when compiling terminfo +_STRING_CAPABILITY_NAMES = { + "cbt": 0, + "bel": 1, + "cr": 2, + "csr": 3, + "tbc": 4, + "clear": 5, + "el": 6, + "ed": 7, + "hpa": 8, + "cmdch": 9, + "cup": 10, + "cud1": 11, + "home": 12, + "civis": 13, + "cub1": 14, + "mrcup": 15, + "cnorm": 16, + "cuf1": 17, + "ll": 18, + "cuu1": 19, + "cvvis": 20, + "dch1": 21, + "dl1": 22, + "dsl": 23, + "hd": 24, + "smacs": 25, + "blink": 26, + "bold": 27, + "smcup": 28, + "smdc": 29, + "dim": 30, + "smir": 31, + "invis": 32, + "prot": 33, + "rev": 34, + "smso": 35, + "smul": 36, + "ech": 37, + "rmacs": 38, + "sgr0": 39, + "rmcup": 40, + "rmdc": 41, + "rmir": 42, + "rmso": 43, + "rmul": 44, + "flash": 45, + "ff": 46, + "fsl": 47, + "is1": 48, + "is2": 49, + "is3": 50, + "if": 51, + "ich1": 52, + "il1": 53, + "ip": 54, + "kbs": 55, + "ktbc": 56, + "kclr": 57, + "kctab": 58, + "kdch1": 59, + "kdl1": 60, + "kcud1": 61, + "krmir": 62, + "kel": 63, + "ked": 64, + "kf0": 65, + "kf1": 66, + "kf10": 67, + "kf2": 68, + "kf3": 69, + "kf4": 70, + "kf5": 71, + "kf6": 72, + "kf7": 73, + "kf8": 74, + "kf9": 75, + "khome": 76, + "kich1": 77, + "kil1": 78, + "kcub1": 79, + "kll": 80, + "knp": 81, + "kpp": 82, + "kcuf1": 83, + "kind": 84, + "kri": 85, + "khts": 86, + "kcuu1": 87, + "rmkx": 88, + "smkx": 89, + "lf0": 90, + "lf1": 91, + "lf10": 92, + "lf2": 93, + "lf3": 94, + "lf4": 95, + "lf5": 96, + "lf6": 97, + "lf7": 98, + "lf8": 99, + "lf9": 100, + "rmm": 101, + "smm": 102, + "nel": 103, + "pad": 104, + "dch": 105, + "dl": 106, + "cud": 107, + "ich": 108, + "indn": 109, + "il": 110, + "cub": 111, + "cuf": 112, + "rin": 113, + "cuu": 114, + "pfkey": 115, + "pfloc": 116, + "pfx": 117, + "mc0": 118, + "mc4": 119, + "mc5": 120, + "rep": 121, + "rs1": 122, + "rs2": 123, + "rs3": 124, + "rf": 125, + "rc": 126, + "vpa": 127, + "sc": 128, + "ind": 129, + "ri": 130, + "sgr": 131, + "hts": 132, + "wind": 133, + "ht": 134, + "tsl": 135, + "uc": 136, + "hu": 137, + "iprog": 138, + "ka1": 139, + "ka3": 140, + "kb2": 141, + "kc1": 142, + "kc3": 143, + "mc5p": 144, + "rmp": 145, + "acsc": 146, + "pln": 147, + "kcbt": 148, + "smxon": 149, + "rmxon": 150, + "smam": 151, + "rmam": 152, + "xonc": 153, + "xoffc": 154, + "enacs": 155, + "smln": 156, + "rmln": 157, + "kbeg": 158, + "kcan": 159, + "kclo": 160, + "kcmd": 161, + "kcpy": 162, + "kcrt": 163, + "kend": 164, + "kent": 165, + "kext": 166, + "kfnd": 167, + "khlp": 168, + "kmrk": 169, + "kmsg": 170, + "kmov": 171, + "knxt": 172, + "kopn": 173, + "kopt": 174, + "kprv": 175, + "kprt": 176, + "krdo": 177, + "kref": 178, + "krfr": 179, + "krpl": 180, + "krst": 181, + "kres": 182, + "ksav": 183, + "kspd": 184, + "kund": 185, + "kBEG": 186, + "kCAN": 187, + "kCMD": 188, + "kCPY": 189, + "kCRT": 190, + "kDC": 191, + "kDL": 192, + "kslt": 193, + "kEND": 194, + "kEOL": 195, + "kEXT": 196, + "kFND": 197, + "kHLP": 198, + "kHOM": 199, + "kIC": 200, + "kLFT": 201, + "kMSG": 202, + "kMOV": 203, + "kNXT": 204, + "kOPT": 205, + "kPRV": 206, + "kPRT": 207, + "kRDO": 208, + "kRPL": 209, + "kRIT": 210, + "kRES": 211, + "kSAV": 212, + "kSPD": 213, + "kUND": 214, + "rfi": 215, + "kf11": 216, + "kf12": 217, + "kf13": 218, + "kf14": 219, + "kf15": 220, + "kf16": 221, + "kf17": 222, + "kf18": 223, + "kf19": 224, + "kf20": 225, + "kf21": 226, + "kf22": 227, + "kf23": 228, + "kf24": 229, + "kf25": 230, + "kf26": 231, + "kf27": 232, + "kf28": 233, + "kf29": 234, + "kf30": 235, + "kf31": 236, + "kf32": 237, + "kf33": 238, + "kf34": 239, + "kf35": 240, + "kf36": 241, + "kf37": 242, + "kf38": 243, + "kf39": 244, + "kf40": 245, + "kf41": 246, + "kf42": 247, + "kf43": 248, + "kf44": 249, + "kf45": 250, + "kf46": 251, + "kf47": 252, + "kf48": 253, + "kf49": 254, + "kf50": 255, + "kf51": 256, + "kf52": 257, + "kf53": 258, + "kf54": 259, + "kf55": 260, + "kf56": 261, + "kf57": 262, + "kf58": 263, + "kf59": 264, + "kf60": 265, + "kf61": 266, + "kf62": 267, + "kf63": 268, + "el1": 269, + "mgc": 270, + "smgl": 271, + "smgr": 272, + "fln": 273, + "sclk": 274, + "dclk": 275, + "rmclk": 276, + "cwin": 277, + "wingo": 278, + "hup": 279, + "dial": 280, + "qdial": 281, + "tone": 282, + "pulse": 283, + "hook": 284, + "pause": 285, + "wait": 286, + "u0": 287, + "u1": 288, + "u2": 289, + "u3": 290, + "u4": 291, + "u5": 292, + "u6": 293, + "u7": 294, + "u8": 295, + "u9": 296, + "op": 297, + "oc": 298, + "initc": 299, + "initp": 300, + "scp": 301, + "setf": 302, + "setb": 303, + "cpi": 304, + "lpi": 305, + "chr": 306, + "cvr": 307, + "defc": 308, + "swidm": 309, + "sdrfq": 310, + "sitm": 311, + "slm": 312, + "smicm": 313, + "snlq": 314, + "snrmq": 315, + "sshm": 316, + "ssubm": 317, + "ssupm": 318, + "sum": 319, + "rwidm": 320, + "ritm": 321, + "rlm": 322, + "rmicm": 323, + "rshm": 324, + "rsubm": 325, + "rsupm": 326, + "rum": 327, + "mhpa": 328, + "mcud1": 329, + "mcub1": 330, + "mcuf1": 331, + "mvpa": 332, + "mcuu1": 333, + "porder": 334, + "mcud": 335, + "mcub": 336, + "mcuf": 337, + "mcuu": 338, + "scs": 339, + "smgb": 340, + "smgbp": 341, + "smglp": 342, + "smgrp": 343, + "smgt": 344, + "smgtp": 345, + "sbim": 346, + "scsd": 347, + "rbim": 348, + "rcsd": 349, + "subcs": 350, + "supcs": 351, + "docr": 352, + "zerom": 353, + "csnm": 354, + "kmous": 355, + "minfo": 356, + "reqmp": 357, + "getm": 358, + "setaf": 359, + "setab": 360, + "pfxl": 361, + "devt": 362, + "csin": 363, + "s0ds": 364, + "s1ds": 365, + "s2ds": 366, + "s3ds": 367, + "smglr": 368, + "smgtb": 369, + "birep": 370, + "binel": 371, + "bicr": 372, + "colornm": 373, + "defbi": 374, + "endbi": 375, + "setcolor": 376, + "slines": 377, + "dispc": 378, + "smpch": 379, + "rmpch": 380, + "smsc": 381, + "rmsc": 382, + "pctrm": 383, + "scesc": 384, + "scesa": 385, + "ehhlm": 386, + "elhlm": 387, + "elohlm": 388, + "erhlm": 389, + "ethlm": 390, + "evhlm": 391, + "sgr1": 392, + "slength": 393, + "OTi2": 394, + "OTrs": 395, + "OTnl": 396, + "OTbc": 397, + "OTko": 398, + "OTma": 399, + "OTG2": 400, + "OTG3": 401, + "OTG1": 402, + "OTG4": 403, + "OTGR": 404, + "OTGL": 405, + "OTGU": 406, + "OTGD": 407, + "OTGH": 408, + "OTGV": 409, + "OTGC": 410, + "meml": 411, + "memu": 412, + "box1": 413, +} + +# Reverse mapping for standard capabilities +_STRING_NAMES: list[str | None] = [None] * 414 # Standard string capabilities + +for name, idx in _STRING_CAPABILITY_NAMES.items(): + if idx < len(_STRING_NAMES): + _STRING_NAMES[idx] = name + + +def _get_terminfo_dirs() -> list[Path]: + """Get list of directories to search for terminfo files. + + Based on ncurses behavior in: + - ncurses/tinfo/db_iterator.c:_nc_next_db() + - ncurses/tinfo/read_entry.c:_nc_read_entry() + """ + dirs = [] + + terminfo = os.environ.get('TERMINFO') + if terminfo: + dirs.append(terminfo) + + try: + home = Path.home() + dirs.append(str(home / '.terminfo')) + except RuntimeError: + pass + + # Check TERMINFO_DIRS + terminfo_dirs = os.environ.get('TERMINFO_DIRS', '') + if terminfo_dirs: + for d in terminfo_dirs.split(':'): + if d: + dirs.append(d) + + dirs.extend([ + '/usr/share/terminfo', + '/usr/share/misc/terminfo', + '/usr/local/share/terminfo', + '/etc/terminfo', + ]) + + return [Path(d) for d in dirs if Path(d).is_dir()] + + +def _read_terminfo_file(terminal_name: str) -> bytes: + """Find and read terminfo file for given terminal name. + + Terminfo files are stored in directories using the first character + of the terminal name as a subdirectory. + """ + if not isinstance(terminal_name, str): + raise TypeError("`terminal_name` must be a string") + + if not terminal_name: + raise ValueError("`terminal_name` cannot be empty") + + first_char = terminal_name[0].lower() + filename = terminal_name + + for directory in _get_terminfo_dirs(): + path = directory / first_char / filename + if path.is_file(): + return path.read_bytes() + + # Try with hex encoding of first char (for special chars) + hex_dir = '%02x' % ord(first_char) + path = directory / hex_dir / filename + if path.is_file(): + return path.read_bytes() + + raise FileNotFoundError(errno.ENOENT, os.strerror(errno.ENOENT), filename) + + +# Hard-coded terminal capabilities for common terminals +# This is a minimal subset needed by PyREPL +_TERMINAL_CAPABILITIES = { + # ANSI/xterm-compatible terminals + "ansi": { + # Bell + "bel": b"\x07", + # Cursor movement + "cub": b"\x1b[%p1%dD", # Move cursor left N columns + "cud": b"\x1b[%p1%dB", # Move cursor down N rows + "cuf": b"\x1b[%p1%dC", # Move cursor right N columns + "cuu": b"\x1b[%p1%dA", # Move cursor up N rows + "cub1": b"\x1b[D", # Move cursor left 1 column + "cud1": b"\x1b[B", # Move cursor down 1 row + "cuf1": b"\x1b[C", # Move cursor right 1 column + "cuu1": b"\x1b[A", # Move cursor up 1 row + "cup": b"\x1b[%i%p1%d;%p2%dH", # Move cursor to row, column + "hpa": b"\x1b[%i%p1%dG", # Move cursor to column + # Clear operations + "clear": b"\x1b[H\x1b[2J", # Clear screen and home cursor + "el": b"\x1b[K", # Clear to end of line + # Insert/delete + "dch": b"\x1b[%p1%dP", # Delete N characters + "dch1": b"\x1b[P", # Delete 1 character + "ich": b"\x1b[%p1%d@", # Insert N characters + "ich1": b"\x1b[@", # Insert 1 character + # Cursor visibility + "civis": b"\x1b[?25l", # Make cursor invisible + "cnorm": b"\x1b[?25h", # Make cursor normal (visible) + # Scrolling + "ind": b"\n", # Scroll up one line + "ri": b"\x1bM", # Scroll down one line + # Keypad mode + "smkx": b"\x1b[?1h\x1b=", # Enable keypad mode + "rmkx": b"\x1b[?1l\x1b>", # Disable keypad mode + # Padding (not used in modern terminals) + "pad": b"", + # Function keys and special keys + "kdch1": b"\x1b[3~", # Delete key + "kcud1": b"\x1b[B", # Down arrow + "kend": b"\x1b[F", # End key + "kent": b"\x1bOM", # Enter key + "khome": b"\x1b[H", # Home key + "kich1": b"\x1b[2~", # Insert key + "kcub1": b"\x1b[D", # Left arrow + "knp": b"\x1b[6~", # Page down + "kpp": b"\x1b[5~", # Page up + "kcuf1": b"\x1b[C", # Right arrow + "kcuu1": b"\x1b[A", # Up arrow + # Function keys F1-F20 + "kf1": b"\x1bOP", "kf2": b"\x1bOQ", "kf3": b"\x1bOR", "kf4": b"\x1bOS", + "kf5": b"\x1b[15~", "kf6": b"\x1b[17~", "kf7": b"\x1b[18~", "kf8": b"\x1b[19~", + "kf9": b"\x1b[20~", "kf10": b"\x1b[21~", "kf11": b"\x1b[23~", "kf12": b"\x1b[24~", + "kf13": b"\x1b[25~", "kf14": b"\x1b[26~", "kf15": b"\x1b[28~", "kf16": b"\x1b[29~", + "kf17": b"\x1b[31~", "kf18": b"\x1b[32~", "kf19": b"\x1b[33~", "kf20": b"\x1b[34~", + }, + # Dumb terminal - minimal capabilities + "dumb": { + "bel": b"\x07", # Bell + "cud1": b"\n", # Move down 1 row (newline) + "ind": b"\n", # Scroll up one line (newline) + }, + # Linux console + "linux": { + # Bell + "bel": b"\x07", + # Cursor movement + "cub": b"\x1b[%p1%dD", # Move cursor left N columns + "cud": b"\x1b[%p1%dB", # Move cursor down N rows + "cuf": b"\x1b[%p1%dC", # Move cursor right N columns + "cuu": b"\x1b[%p1%dA", # Move cursor up N rows + "cub1": b"\x08", # Move cursor left 1 column (backspace) + "cud1": b"\n", # Move cursor down 1 row (newline) + "cuf1": b"\x1b[C", # Move cursor right 1 column + "cuu1": b"\x1b[A", # Move cursor up 1 row + "cup": b"\x1b[%i%p1%d;%p2%dH", # Move cursor to row, column + "hpa": b"\x1b[%i%p1%dG", # Move cursor to column + # Clear operations + "clear": b"\x1b[H\x1b[J", # Clear screen and home cursor (different from ansi!) + "el": b"\x1b[K", # Clear to end of line + # Insert/delete + "dch": b"\x1b[%p1%dP", # Delete N characters + "dch1": b"\x1b[P", # Delete 1 character + "ich": b"\x1b[%p1%d@", # Insert N characters + "ich1": b"\x1b[@", # Insert 1 character + # Cursor visibility + "civis": b"\x1b[?25l\x1b[?1c", # Make cursor invisible + "cnorm": b"\x1b[?25h\x1b[?0c", # Make cursor normal + # Scrolling + "ind": b"\n", # Scroll up one line + "ri": b"\x1bM", # Scroll down one line + # Keypad mode + "smkx": b"\x1b[?1h\x1b=", # Enable keypad mode + "rmkx": b"\x1b[?1l\x1b>", # Disable keypad mode + # Function keys and special keys + "kdch1": b"\x1b[3~", # Delete key + "kcud1": b"\x1b[B", # Down arrow + "kend": b"\x1b[4~", # End key (different from ansi!) + "khome": b"\x1b[1~", # Home key (different from ansi!) + "kich1": b"\x1b[2~", # Insert key + "kcub1": b"\x1b[D", # Left arrow + "knp": b"\x1b[6~", # Page down + "kpp": b"\x1b[5~", # Page up + "kcuf1": b"\x1b[C", # Right arrow + "kcuu1": b"\x1b[A", # Up arrow + # Function keys + "kf1": b"\x1b[[A", "kf2": b"\x1b[[B", "kf3": b"\x1b[[C", "kf4": b"\x1b[[D", + "kf5": b"\x1b[[E", "kf6": b"\x1b[17~", "kf7": b"\x1b[18~", "kf8": b"\x1b[19~", + "kf9": b"\x1b[20~", "kf10": b"\x1b[21~", "kf11": b"\x1b[23~", "kf12": b"\x1b[24~", + "kf13": b"\x1b[25~", "kf14": b"\x1b[26~", "kf15": b"\x1b[28~", "kf16": b"\x1b[29~", + "kf17": b"\x1b[31~", "kf18": b"\x1b[32~", "kf19": b"\x1b[33~", "kf20": b"\x1b[34~", + } +} + +# Map common TERM values to capability sets +_TERM_ALIASES = { + "xterm": "ansi", + "xterm-color": "ansi", + "xterm-256color": "ansi", + "screen": "ansi", + "screen-256color": "ansi", + "tmux": "ansi", + "tmux-256color": "ansi", + "vt100": "ansi", + "vt220": "ansi", + "rxvt": "ansi", + "rxvt-unicode": "ansi", + "rxvt-unicode-256color": "ansi", + "unknown": "dumb", +} + +@dataclass +class TermInfo: + terminal_name: str | bytes | None + fallback: bool = True + + _names: list[str] = field(default_factory=list) + _booleans: list[int] = field(default_factory=list) + _numbers: list[int] = field(default_factory=list) + _strings: list[bytes | None] = field(default_factory=list) + _capabilities: dict[str, bytes] = field(default_factory=dict) + + def __post_init__(self) -> None: + """Initialize terminal capabilities for the given terminal type. + + Based on ncurses implementation in: + - ncurses/tinfo/lib_setup.c:setupterm() and _nc_setupterm() + - ncurses/tinfo/lib_setup.c:TINFO_SETUP_TERM() + + This version first attempts to read terminfo database files like ncurses, + then, if `fallback` is True, falls back to hardcoded capabilities for + common terminal types. + """ + # If termstr is None or empty, try to get from environment + if not self.terminal_name: + self.terminal_name = os.environ.get('TERM') or 'ANSI' + + if isinstance(self.terminal_name, bytes): + self.terminal_name = self.terminal_name.decode('ascii') + + try: + self._parse_terminfo_file(self.terminal_name) + except (OSError, ValueError): + if not self.fallback: + raise + + term_type = _TERM_ALIASES.get(self.terminal_name, self.terminal_name) + if term_type not in _TERMINAL_CAPABILITIES: + term_type = 'dumb' + self._capabilities = _TERMINAL_CAPABILITIES[term_type].copy() + + def _parse_terminfo_file(self, terminal_name: str) -> None: + """Parse a terminfo file. + + Based on ncurses implementation in: + - ncurses/tinfo/read_entry.c:_nc_read_termtype() + - ncurses/tinfo/read_entry.c:_nc_read_file_entry() + """ + data = _read_terminfo_file(terminal_name) + too_short = f"TermInfo file for {terminal_name!r} too short" + offset = 0 + if len(data) < 12: + raise ValueError(too_short) + + magic = struct.unpack(' len(data): + raise ValueError(too_short) + names = data[offset:offset+name_size-1].decode('ascii', errors='ignore') + offset += name_size + + # Read boolean capabilities + if offset + bool_count > len(data): + raise ValueError(too_short) + booleans = list(data[offset:offset+bool_count]) + offset += bool_count + + # Align to even byte boundary for numbers + if offset % 2: + offset += 1 + + # Read numeric capabilities + numbers = [] + for i in range(num_count): + if offset + number_size > len(data): + raise ValueError(too_short) + num = struct.unpack(number_format, data[offset:offset+number_size])[0] + numbers.append(num) + offset += number_size + + # Read string offsets + string_offsets = [] + for i in range(str_count): + if offset + 2 > len(data): + raise ValueError(too_short) + off = struct.unpack(' len(data): + raise ValueError(too_short) + string_table = data[offset:offset+str_size] + + # Extract strings from string table + strings: list[bytes | None] = [] + for off in string_offsets: + if off < 0: + strings.append(CANCELLED_STRING) + elif off < len(string_table): + # Find null terminator + end = off + while end < len(string_table) and string_table[end] != 0: + end += 1 + if end <= len(string_table): + strings.append(string_table[off:end]) + else: + strings.append(ABSENT_STRING) + else: + strings.append(ABSENT_STRING) + + self._names = names.split('|') + self._booleans = booleans + self._numbers = numbers + self._strings = strings + + def get(self, cap: str) -> bytes | None: + """Get terminal capability string by name. + + Based on ncurses implementation in: + - ncurses/tinfo/lib_ti.c:tigetstr() + + The ncurses version searches through compiled terminfo data structures. + This version first checks parsed terminfo data, then falls back to + hardcoded capabilities. + """ + if not isinstance(cap, str): + raise TypeError(f"`cap` must be a string, not {type(cap)}") + + if self._capabilities: + # Fallbacks populated, use them + return self._capabilities[cap] + + # Look up in standard capabilities first + if cap in _STRING_CAPABILITY_NAMES: + index = _STRING_CAPABILITY_NAMES[cap] + if index < len(self._strings): + return self._strings[index] + + # Note: we don't support extended capabilities since PyREPL doesn't + # need them. + return None + + +def tparm(cap_bytes: bytes, *params: int) -> bytes: + """Parameterize a terminal capability string. + + Based on ncurses implementation in: + - ncurses/tinfo/lib_tparm.c:tparm() + - ncurses/tinfo/lib_tparm.c:tparam_internal() + + The ncurses version implements a full stack-based interpreter for + terminfo parameter strings. This pure Python version implements only + the subset of parameter substitution operations needed by PyREPL: + - %i (increment parameters for 1-based indexing) + - %p[1-9]%d (parameter substitution) + - %p[1-9]%{n}%+%d (parameter plus constant) + """ + if not isinstance(cap_bytes, bytes): + raise TypeError(f"`cap` must be bytes, not {type(cap_bytes)}") + + result = cap_bytes + + # %i - increment parameters (1-based instead of 0-based) + increment = b"%i" in result + if increment: + result = result.replace(b"%i", b"") + + # Replace %p1%d, %p2%d, etc. with actual parameter values + for i in range(len(params)): + pattern = b"%%p%d%%d" % (i + 1) + if pattern in result: + value = params[i] + if increment: + value += 1 + result = result.replace(pattern, str(value).encode('ascii')) + + # Handle %p1%{1}%+%d (parameter plus constant) + # Used in some cursor positioning sequences + pattern_re = re.compile(rb'%p(\d)%\{(\d+)\}%\+%d') + matches = list(pattern_re.finditer(result)) + for match in reversed(matches): # reversed to maintain positions + param_idx = int(match.group(1)) + constant = int(match.group(2)) + value = params[param_idx] + constant + result = ( + result[:match.start()] + + str(value).encode('ascii') + + result[match.end():] + ) + + return result diff --git a/Lib/_pyrepl/unix_console.py b/Lib/_pyrepl/unix_console.py index d21cdd9b076d86..a7e49923191c07 100644 --- a/Lib/_pyrepl/unix_console.py +++ b/Lib/_pyrepl/unix_console.py @@ -33,7 +33,7 @@ import platform from fcntl import ioctl -from . import curses +from . import terminfo from .console import Console, Event from .fancy_termios import tcgetattr, tcsetattr from .trace import trace @@ -60,7 +60,7 @@ class InvalidTerminal(RuntimeError): pass -_error = (termios.error, curses.error, InvalidTerminal) +_error = (termios.error, InvalidTerminal) SIGWINCH_EVENT = "repaint" @@ -157,7 +157,7 @@ def __init__( self.pollob = poll() self.pollob.register(self.input_fd, select.POLLIN) - curses.setupterm(term or None, self.output_fd) + self.terminfo = terminfo.TermInfo(term or None) self.term = term @overload @@ -167,7 +167,7 @@ def _my_getstr(cap: str, optional: Literal[False] = False) -> bytes: ... def _my_getstr(cap: str, optional: bool) -> bytes | None: ... def _my_getstr(cap: str, optional: bool = False) -> bytes | None: - r = curses.tigetstr(cap) + r = self.terminfo.get(cap) if not optional and r is None: raise InvalidTerminal( f"terminal doesn't have the required {cap} capability" @@ -201,7 +201,7 @@ def _my_getstr(cap: str, optional: bool = False) -> bytes | None: self.__setup_movement() - self.event_queue = EventQueue(self.input_fd, self.encoding) + self.event_queue = EventQueue(self.input_fd, self.encoding, self.terminfo) self.cursor_visible = 1 signal.signal(signal.SIGCONT, self._sigcont_handler) @@ -597,14 +597,14 @@ def __setup_movement(self): if self._dch1: self.dch1 = self._dch1 elif self._dch: - self.dch1 = curses.tparm(self._dch, 1) + self.dch1 = terminfo.tparm(self._dch, 1) else: self.dch1 = None if self._ich1: self.ich1 = self._ich1 elif self._ich: - self.ich1 = curses.tparm(self._ich, 1) + self.ich1 = terminfo.tparm(self._ich, 1) else: self.ich1 = None @@ -701,7 +701,7 @@ def __write(self, text): self.__buffer.append((text, 0)) def __write_code(self, fmt, *args): - self.__buffer.append((curses.tparm(fmt, *args), 1)) + self.__buffer.append((terminfo.tparm(fmt, *args), 1)) def __maybe_write_code(self, fmt, *args): if fmt: diff --git a/Lib/_pyrepl/unix_eventqueue.py b/Lib/_pyrepl/unix_eventqueue.py index 29b3e9dd5efd07..2a9cca59e7477f 100644 --- a/Lib/_pyrepl/unix_eventqueue.py +++ b/Lib/_pyrepl/unix_eventqueue.py @@ -18,7 +18,7 @@ # CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN # CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. -from . import curses +from .terminfo import TermInfo from .trace import trace from .base_eventqueue import BaseEventQueue from termios import tcgetattr, VERASE @@ -54,22 +54,23 @@ b'\033Oc': 'ctrl right', } -def get_terminal_keycodes() -> dict[bytes, str]: +def get_terminal_keycodes(ti: TermInfo) -> dict[bytes, str]: """ Generates a dictionary mapping terminal keycodes to human-readable names. """ keycodes = {} for key, terminal_code in TERMINAL_KEYNAMES.items(): - keycode = curses.tigetstr(terminal_code) + keycode = ti.get(terminal_code) trace('key {key} tiname {terminal_code} keycode {keycode!r}', **locals()) if keycode: keycodes[keycode] = key keycodes.update(CTRL_ARROW_KEYCODES) return keycodes + class EventQueue(BaseEventQueue): - def __init__(self, fd: int, encoding: str) -> None: - keycodes = get_terminal_keycodes() + def __init__(self, fd: int, encoding: str, ti: TermInfo) -> None: + keycodes = get_terminal_keycodes(ti) if os.isatty(fd): backspace = tcgetattr(fd)[6][VERASE] keycodes[backspace] = "backspace" diff --git a/Lib/test/test_pyrepl/__init__.py b/Lib/test/test_pyrepl/__init__.py index 8359d9844623c2..ca273763bed98d 100644 --- a/Lib/test/test_pyrepl/__init__.py +++ b/Lib/test/test_pyrepl/__init__.py @@ -1,14 +1,5 @@ import os -import sys -from test.support import requires, load_package_tests -from test.support.import_helper import import_module - -if sys.platform != "win32": - # On non-Windows platforms, testing pyrepl currently requires that the - # 'curses' resource be given on the regrtest command line using the -u - # option. Additionally, we need to attempt to import curses and readline. - requires("curses") - curses = import_module("curses") +from test.support import load_package_tests def load_tests(*args): diff --git a/Lib/test/test_pyrepl/test_eventqueue.py b/Lib/test/test_pyrepl/test_eventqueue.py index edfe6ac4748f33..69d9612b70dc77 100644 --- a/Lib/test/test_pyrepl/test_eventqueue.py +++ b/Lib/test/test_pyrepl/test_eventqueue.py @@ -3,6 +3,8 @@ from unittest.mock import patch from test import support +from _pyrepl import terminfo + try: from _pyrepl.console import Event from _pyrepl import base_eventqueue @@ -172,17 +174,22 @@ def _push(keys): self.assertEqual(eq.get(), _event("key", "a")) +class EmptyTermInfo(terminfo.TermInfo): + def get(self, cap: str) -> bytes: + return b"" + + @unittest.skipIf(support.MS_WINDOWS, "No Unix event queue on Windows") class TestUnixEventQueue(EventQueueTestBase, unittest.TestCase): def setUp(self): - self.enterContext(patch("_pyrepl.curses.tigetstr", lambda x: b"")) self.file = tempfile.TemporaryFile() def tearDown(self) -> None: self.file.close() def make_eventqueue(self) -> base_eventqueue.BaseEventQueue: - return unix_eventqueue.EventQueue(self.file.fileno(), "utf-8") + ti = EmptyTermInfo("ansi") + return unix_eventqueue.EventQueue(self.file.fileno(), "utf-8", ti) @unittest.skipUnless(support.MS_WINDOWS, "No Windows event queue on Unix") diff --git a/Lib/test/test_pyrepl/test_terminfo.py b/Lib/test/test_pyrepl/test_terminfo.py new file mode 100644 index 00000000000000..a0c379cc5c6c83 --- /dev/null +++ b/Lib/test/test_pyrepl/test_terminfo.py @@ -0,0 +1,557 @@ +"""Tests comparing PyREPL's pure Python curses implementation with the standard curses module.""" + +import json +import os +import subprocess +import sys +import unittest +from test.support import requires +from textwrap import dedent + +# Only run these tests if curses is available +requires('curses') + +try: + import _curses +except ImportError: + try: + import curses as _curses + except ImportError: + _curses = None + +from _pyrepl import terminfo + + +class TestCursesCompatibility(unittest.TestCase): + """Test that PyREPL's curses implementation matches the standard curses behavior. + + Python's `curses` doesn't allow calling `setupterm()` again with a different + $TERM in the same process, so we subprocess all `curses` tests to get correctly + set up terminfo.""" + + def setUp(self): + if _curses is None: + raise unittest.SkipTest( + "`curses` capability provided to regrtest but `_curses` not importable" + ) + self.original_term = os.environ.get('TERM', None) + + def tearDown(self): + if self.original_term is not None: + os.environ['TERM'] = self.original_term + elif 'TERM' in os.environ: + del os.environ['TERM'] + + def test_setupterm_basic(self): + """Test basic setupterm functionality.""" + # Test with explicit terminal type + test_terms = ['xterm', 'xterm-256color', 'vt100', 'ansi'] + + for term in test_terms: + with self.subTest(term=term): + ncurses_code = dedent( + f''' + import _curses + import json + try: + _curses.setupterm({repr(term)}, 1) + print(json.dumps({{"success": True}})) + except Exception as e: + print(json.dumps({{"success": False, "error": str(e)}})) + ''' + ) + + result = subprocess.run([sys.executable, '-c', ncurses_code], + capture_output=True, text=True) + ncurses_data = json.loads(result.stdout) + std_success = ncurses_data["success"] + + # Set up with PyREPL curses + try: + terminfo.TermInfo(term) + pyrepl_success = True + except Exception as e: + pyrepl_success = False + pyrepl_error = e + + # Both should succeed or both should fail + if std_success: + self.assertTrue(pyrepl_success, + f"Standard curses succeeded but PyREPL failed for {term}") + else: + # If standard curses failed, PyREPL might still succeed with fallback + # This is acceptable as PyREPL has hardcoded fallbacks + pass + + def test_setupterm_none(self): + """Test setupterm with None (uses TERM from environment).""" + # Test with current TERM + ncurses_code = dedent( + ''' + import _curses + import json + try: + _curses.setupterm(None, 1) + print(json.dumps({"success": True})) + except Exception as e: + print(json.dumps({"success": False, "error": str(e)})) + ''' + ) + + result = subprocess.run([sys.executable, '-c', ncurses_code], + capture_output=True, text=True) + ncurses_data = json.loads(result.stdout) + std_success = ncurses_data["success"] + + try: + terminfo.TermInfo(None) + pyrepl_success = True + except Exception: + pyrepl_success = False + + # Both should have same result + if std_success: + self.assertTrue(pyrepl_success, + "Standard curses succeeded but PyREPL failed for None") + + def test_tigetstr_common_capabilities(self): + """Test tigetstr for common terminal capabilities.""" + # Test with a known terminal type + term = 'xterm' + + # Get ALL capabilities from infocmp + all_caps = [] + try: + result = subprocess.run(['infocmp', '-1', term], + capture_output=True, text=True, check=True) + for line in result.stdout.splitlines(): + line = line.strip() + if '=' in line and not line.startswith('#'): + cap_name = line.split('=')[0] + all_caps.append(cap_name) + except: + # If infocmp fails, at least test the critical ones + all_caps = [ + 'cup', 'clear', 'el', 'cub1', 'cuf1', 'cuu1', 'cud1', 'bel', + 'ind', 'ri', 'civis', 'cnorm', 'smkx', 'rmkx', 'cub', 'cuf', + 'cud', 'cuu', 'home', 'hpa', 'vpa', 'cr', 'nel', 'ht' + ] + + ncurses_code = dedent( + f''' + import _curses + import json + _curses.setupterm({repr(term)}, 1) + results = {{}} + for cap in {repr(all_caps)}: + try: + val = _curses.tigetstr(cap) + if val is None: + results[cap] = None + elif val == -1: + results[cap] = -1 + else: + results[cap] = list(val) + except: + results[cap] = "error" + print(json.dumps(results)) + ''' + ) + + result = subprocess.run([sys.executable, '-c', ncurses_code], + capture_output=True, text=True) + self.assertEqual(result.returncode, 0, f"Failed to run ncurses: {result.stderr}") + + ncurses_data = json.loads(result.stdout) + + ti = terminfo.TermInfo(term) + + # Test every single capability + for cap in all_caps: + if cap not in ncurses_data or ncurses_data[cap] == "error": + continue + + with self.subTest(capability=cap): + ncurses_val = ncurses_data[cap] + if isinstance(ncurses_val, list): + ncurses_val = bytes(ncurses_val) + + pyrepl_val = ti.get(cap) + + self.assertEqual(pyrepl_val, ncurses_val, + f"Capability {cap}: ncurses={repr(ncurses_val)}, " + f"pyrepl={repr(pyrepl_val)}") + + def test_tigetstr_input_types(self): + """Test tigetstr with different input types.""" + term = 'xterm' + cap = 'cup' + + # Test standard curses behavior with string in subprocess + ncurses_code = dedent( + f''' + import _curses + import json + _curses.setupterm({repr(term)}, 1) + + # Test with string input + try: + std_str_result = _curses.tigetstr({repr(cap)}) + std_accepts_str = True + if std_str_result is None: + std_str_val = None + elif std_str_result == -1: + std_str_val = -1 + else: + std_str_val = list(std_str_result) + except TypeError: + std_accepts_str = False + std_str_val = None + + print(json.dumps({{ + "accepts_str": std_accepts_str, + "str_result": std_str_val + }})) + ''' + ) + + result = subprocess.run([sys.executable, '-c', ncurses_code], + capture_output=True, text=True) + ncurses_data = json.loads(result.stdout) + + # PyREPL setup + ti = terminfo.TermInfo(term) + + # PyREPL behavior with string + try: + pyrepl_str_result = ti.get(cap) + pyrepl_accepts_str = True + except TypeError: + pyrepl_accepts_str = False + + # PyREPL should also only accept strings for compatibility + with self.assertRaises(TypeError): + ti.get(cap.encode('ascii')) + + # Both should accept string input + self.assertEqual(pyrepl_accepts_str, ncurses_data["accepts_str"], + "PyREPL and standard curses should have same string handling") + self.assertTrue(pyrepl_accepts_str, "PyREPL should accept string input") + + def test_tparm_basic(self): + """Test basic tparm functionality.""" + term = 'xterm' + ti = terminfo.TermInfo(term) + + # Test cursor positioning (cup) + cup = ti.get('cup') + if cup and cup != -1: + # Test various parameter combinations + test_cases = [ + (0, 0), # Top-left + (5, 10), # Arbitrary position + (23, 79), # Bottom-right of standard terminal + (999, 999), # Large values + ] + + # Get ncurses results in subprocess + ncurses_code = dedent( + f''' + import _curses + import json + _curses.setupterm({repr(term)}, 1) + + # Get cup capability + cup = _curses.tigetstr('cup') + results = {{}} + + for row, col in {repr(test_cases)}: + try: + result = _curses.tparm(cup, row, col) + results[f"{{row}},{{col}}"] = list(result) + except Exception as e: + results[f"{{row}},{{col}}"] = {{"error": str(e)}} + + print(json.dumps(results)) + ''' + ) + + result = subprocess.run([sys.executable, '-c', ncurses_code], + capture_output=True, text=True) + self.assertEqual(result.returncode, 0, f"Failed to run ncurses: {result.stderr}") + ncurses_data = json.loads(result.stdout) + + for row, col in test_cases: + with self.subTest(row=row, col=col): + # Standard curses tparm from subprocess + key = f"{row},{col}" + if isinstance(ncurses_data[key], dict) and "error" in ncurses_data[key]: + self.fail(f"ncurses tparm failed: {ncurses_data[key]['error']}") + std_result = bytes(ncurses_data[key]) + + # PyREPL curses tparm + pyrepl_result = terminfo.tparm(cup, row, col) + + # Results should be identical + self.assertEqual(pyrepl_result, std_result, + f"tparm(cup, {row}, {col}): " + f"std={repr(std_result)}, pyrepl={repr(pyrepl_result)}") + else: + raise unittest.SkipTest("test_tparm_basic() requires the `cup` capability") + + def test_tparm_multiple_params(self): + """Test tparm with capabilities using multiple parameters.""" + term = 'xterm' + ti = terminfo.TermInfo(term) + + # Test capabilities that take parameters + param_caps = { + 'cub': 1, # cursor_left with count + 'cuf': 1, # cursor_right with count + 'cuu': 1, # cursor_up with count + 'cud': 1, # cursor_down with count + 'dch': 1, # delete_character with count + 'ich': 1, # insert_character with count + } + + # Get all capabilities from PyREPL first + pyrepl_caps = {} + for cap in param_caps: + cap_value = ti.get(cap) + if cap_value and cap_value != -1: + pyrepl_caps[cap] = cap_value + + if not pyrepl_caps: + self.skipTest("No parametrized capabilities found") + + # Get ncurses results in subprocess + ncurses_code = dedent( + f''' + import _curses + import json + _curses.setupterm({repr(term)}, 1) + + param_caps = {repr(param_caps)} + test_values = [1, 5, 10, 99] + results = {{}} + + for cap in param_caps: + cap_value = _curses.tigetstr(cap) + if cap_value and cap_value != -1: + for value in test_values: + try: + result = _curses.tparm(cap_value, value) + results[f"{{cap}},{{value}}"] = list(result) + except Exception as e: + results[f"{{cap}},{{value}}"] = {{"error": str(e)}} + + print(json.dumps(results)) + ''' + ) + + result = subprocess.run([sys.executable, '-c', ncurses_code], + capture_output=True, text=True) + self.assertEqual(result.returncode, 0, f"Failed to run ncurses: {result.stderr}") + ncurses_data = json.loads(result.stdout) + + for cap, cap_value in pyrepl_caps.items(): + with self.subTest(capability=cap): + # Test with different parameter values + for value in [1, 5, 10, 99]: + key = f"{cap},{value}" + if key in ncurses_data: + if isinstance(ncurses_data[key], dict) and "error" in ncurses_data[key]: + self.fail(f"ncurses tparm failed: {ncurses_data[key]['error']}") + std_result = bytes(ncurses_data[key]) + + pyrepl_result = terminfo.tparm(cap_value, value) + self.assertEqual( + pyrepl_result, + std_result, + f"tparm({cap}, {value}): " + f"std={repr(std_result)}, pyrepl={repr(pyrepl_result)}" + ) + + def test_tparm_null_handling(self): + """Test tparm with None/null input.""" + term = 'xterm' + + ncurses_code = dedent( + f''' + import _curses + import json + _curses.setupterm({repr(term)}, 1) + + # Test with None + try: + _curses.tparm(None) + raises_typeerror = False + except TypeError: + raises_typeerror = True + except Exception as e: + raises_typeerror = False + error_type = type(e).__name__ + + print(json.dumps({{"raises_typeerror": raises_typeerror}})) + ''' + ) + + result = subprocess.run([sys.executable, '-c', ncurses_code], + capture_output=True, text=True) + ncurses_data = json.loads(result.stdout) + + # PyREPL setup + ti = terminfo.TermInfo(term) + + # Test with None - both should raise TypeError + if ncurses_data["raises_typeerror"]: + with self.assertRaises(TypeError): + terminfo.tparm(None) + else: + # If ncurses doesn't raise TypeError, PyREPL shouldn't either + try: + terminfo.tparm(None) + except TypeError: + self.fail("PyREPL raised TypeError but ncurses did not") + + def test_special_terminals(self): + """Test with special terminal types.""" + special_terms = [ + 'dumb', # Minimal terminal + 'unknown', # Should fall back to defaults + 'linux', # Linux console + 'screen', # GNU Screen + 'tmux', # tmux + ] + + # Get all string capabilities from ncurses + all_caps = [] + try: + # Get all capability names from infocmp + result = subprocess.run(['infocmp', '-1', 'xterm'], + capture_output=True, text=True, check=True) + for line in result.stdout.splitlines(): + line = line.strip() + if '=' in line: + cap_name = line.split('=')[0] + all_caps.append(cap_name) + except: + # Fall back to a core set if infocmp fails + all_caps = ['cup', 'clear', 'el', 'cub', 'cuf', 'cud', 'cuu', + 'cub1', 'cuf1', 'cud1', 'cuu1', 'home', 'bel', + 'ind', 'ri', 'nel', 'cr', 'ht', 'hpa', 'vpa', + 'dch', 'dch1', 'dl', 'dl1', 'ich', 'ich1', 'il', 'il1', + 'sgr0', 'smso', 'rmso', 'smul', 'rmul', 'bold', 'rev', + 'blink', 'dim', 'smacs', 'rmacs', 'civis', 'cnorm', + 'sc', 'rc', 'hts', 'tbc', 'ed', 'kbs', 'kcud1', 'kcub1', + 'kcuf1', 'kcuu1', 'kdch1', 'khome', 'kend', 'knp', 'kpp', + 'kich1', 'kf1', 'kf2', 'kf3', 'kf4', 'kf5', 'kf6', 'kf7', + 'kf8', 'kf9', 'kf10', 'rmkx', 'smkx'] + + for term in special_terms: + with self.subTest(term=term): + ncurses_code = dedent( + f''' + import _curses + import json + import sys + + try: + _curses.setupterm({repr(term)}, 1) + results = {{}} + for cap in {repr(all_caps)}: + try: + val = _curses.tigetstr(cap) + if val is None: + results[cap] = None + elif val == -1: + results[cap] = -1 + else: + # Convert bytes to list of ints for JSON + results[cap] = list(val) + except: + results[cap] = "error" + print(json.dumps(results)) + except Exception as e: + print(json.dumps({{"error": str(e)}})) + ''' + ) + + # Get ncurses results + result = subprocess.run([sys.executable, '-c', ncurses_code], + capture_output=True, text=True) + if result.returncode != 0: + self.fail(f"Failed to get ncurses data for {term}: {result.stderr}") + + try: + ncurses_data = json.loads(result.stdout) + except json.JSONDecodeError: + self.fail(f"Failed to parse ncurses output for {term}: {result.stdout}") + + if "error" in ncurses_data and len(ncurses_data) == 1: + # ncurses failed to setup this terminal + # PyREPL should still work with fallback + ti = terminfo.TermInfo(term) + continue + + ti = terminfo.TermInfo(term) + + # Compare all capabilities + for cap in all_caps: + if cap not in ncurses_data: + continue + + with self.subTest(term=term, capability=cap): + ncurses_val = ncurses_data[cap] + if isinstance(ncurses_val, list): + # Convert back to bytes + ncurses_val = bytes(ncurses_val) + + pyrepl_val = ti.get(cap) + + # Both should return the same value + self.assertEqual(pyrepl_val, ncurses_val, + f"Capability {cap} for {term}: " + f"ncurses={repr(ncurses_val)}, " + f"pyrepl={repr(pyrepl_val)}") + + def test_terminfo_fallback(self): + """Test that PyREPL falls back gracefully when terminfo is not found.""" + # Use a non-existent terminal type + fake_term = 'nonexistent-terminal-type-12345' + + # Check if standard curses can setup this terminal in subprocess + ncurses_code = dedent( + f''' + import _curses + import json + try: + _curses.setupterm({repr(fake_term)}, 1) + print(json.dumps({{"success": True}})) + except _curses.error: + print(json.dumps({{"success": False, "error": "curses.error"}})) + except Exception as e: + print(json.dumps({{"success": False, "error": str(e)}})) + ''' + ) + + result = subprocess.run([sys.executable, '-c', ncurses_code], + capture_output=True, text=True) + ncurses_data = json.loads(result.stdout) + + if ncurses_data["success"]: + # If it succeeded, skip this test as we can't test fallback + self.skipTest(f"System unexpectedly has terminfo for '{fake_term}'") + + # PyREPL should succeed with fallback + try: + ti = terminfo.TermInfo(fake_term) + pyrepl_ok = True + except: + pyrepl_ok = False + + self.assertTrue(pyrepl_ok, "PyREPL should fall back for unknown terminals") + + # Should still be able to get basic capabilities + bel = ti.get('bel') + self.assertIsNotNone(bel, "PyREPL should provide basic capabilities after fallback") diff --git a/Lib/test/test_pyrepl/test_unix_console.py b/Lib/test/test_pyrepl/test_unix_console.py index b3f7dc028fe210..1cf3b40350c4f4 100644 --- a/Lib/test/test_pyrepl/test_unix_console.py +++ b/Lib/test/test_pyrepl/test_unix_console.py @@ -16,9 +16,13 @@ except ImportError: pass +from _pyrepl.terminfo import _TERMINAL_CAPABILITIES + +TERM_CAPABILITIES = _TERMINAL_CAPABILITIES["ansi"] + def unix_console(events, **kwargs): - console = UnixConsole() + console = UnixConsole(term="xterm") console.get_event = MagicMock(side_effect=events) console.getpending = MagicMock(return_value=Event("key", "")) @@ -50,41 +54,11 @@ def unix_console(events, **kwargs): ) -TERM_CAPABILITIES = { - "bel": b"\x07", - "civis": b"\x1b[?25l", - "clear": b"\x1b[H\x1b[2J", - "cnorm": b"\x1b[?12l\x1b[?25h", - "cub": b"\x1b[%p1%dD", - "cub1": b"\x08", - "cud": b"\x1b[%p1%dB", - "cud1": b"\n", - "cuf": b"\x1b[%p1%dC", - "cuf1": b"\x1b[C", - "cup": b"\x1b[%i%p1%d;%p2%dH", - "cuu": b"\x1b[%p1%dA", - "cuu1": b"\x1b[A", - "dch1": b"\x1b[P", - "dch": b"\x1b[%p1%dP", - "el": b"\x1b[K", - "hpa": b"\x1b[%i%p1%dG", - "ich": b"\x1b[%p1%d@", - "ich1": None, - "ind": b"\n", - "pad": None, - "ri": b"\x1bM", - "rmkx": b"\x1b[?1l\x1b>", - "smkx": b"\x1b[?1h\x1b=", -} - - @unittest.skipIf(sys.platform == "win32", "No Unix event queue on Windows") -@patch("_pyrepl.curses.tigetstr", lambda s: TERM_CAPABILITIES.get(s)) @patch( - "_pyrepl.curses.tparm", + "_pyrepl.terminfo.tparm", lambda s, *args: s + b":" + b",".join(str(i).encode() for i in args), ) -@patch("_pyrepl.curses.setupterm", lambda a, b: None) @patch( "termios.tcgetattr", lambda _: [ From 6408df6fb0d6ad24737d00d5f71307e487d2e61d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Langa?= Date: Fri, 18 Jul 2025 17:15:06 +0200 Subject: [PATCH 02/12] Add blurb, reformat, fix a type error --- Lib/_pyrepl/terminfo.py | 257 ++++++++------ Lib/test/test_pyrepl/test_terminfo.py | 333 +++++++++++------- ...-07-18-17-15-00.gh-issue-135621.9cyCNb.rst | 2 + 3 files changed, 363 insertions(+), 229 deletions(-) create mode 100644 Misc/NEWS.d/next/Build/2025-07-18-17-15-00.gh-issue-135621.9cyCNb.rst diff --git a/Lib/_pyrepl/terminfo.py b/Lib/_pyrepl/terminfo.py index 981e204331c754..252cdceade1cd1 100644 --- a/Lib/_pyrepl/terminfo.py +++ b/Lib/_pyrepl/terminfo.py @@ -9,7 +9,7 @@ # Terminfo constants -MAGIC16 = 0o432 # Magic number for 16-bit terminfo format +MAGIC16 = 0o432 # Magic number for 16-bit terminfo format MAGIC32 = 0o1036 # Magic number for 32-bit terminfo format # Special values for absent/cancelled capabilities @@ -455,29 +455,31 @@ def _get_terminfo_dirs() -> list[Path]: """ dirs = [] - terminfo = os.environ.get('TERMINFO') + terminfo = os.environ.get("TERMINFO") if terminfo: dirs.append(terminfo) try: home = Path.home() - dirs.append(str(home / '.terminfo')) + dirs.append(str(home / ".terminfo")) except RuntimeError: pass # Check TERMINFO_DIRS - terminfo_dirs = os.environ.get('TERMINFO_DIRS', '') + terminfo_dirs = os.environ.get("TERMINFO_DIRS", "") if terminfo_dirs: - for d in terminfo_dirs.split(':'): + for d in terminfo_dirs.split(":"): if d: dirs.append(d) - dirs.extend([ - '/usr/share/terminfo', - '/usr/share/misc/terminfo', - '/usr/local/share/terminfo', - '/etc/terminfo', - ]) + dirs.extend( + [ + "/usr/share/terminfo", + "/usr/share/misc/terminfo", + "/usr/local/share/terminfo", + "/etc/terminfo", + ] + ) return [Path(d) for d in dirs if Path(d).is_dir()] @@ -503,7 +505,7 @@ def _read_terminfo_file(terminal_name: str) -> bytes: return path.read_bytes() # Try with hex encoding of first char (for special chars) - hex_dir = '%02x' % ord(first_char) + hex_dir = "%02x" % ord(first_char) path = directory / hex_dir / filename if path.is_file(): return path.read_bytes() @@ -519,110 +521,140 @@ def _read_terminfo_file(terminal_name: str) -> bytes: # Bell "bel": b"\x07", # Cursor movement - "cub": b"\x1b[%p1%dD", # Move cursor left N columns - "cud": b"\x1b[%p1%dB", # Move cursor down N rows - "cuf": b"\x1b[%p1%dC", # Move cursor right N columns - "cuu": b"\x1b[%p1%dA", # Move cursor up N rows - "cub1": b"\x1b[D", # Move cursor left 1 column - "cud1": b"\x1b[B", # Move cursor down 1 row - "cuf1": b"\x1b[C", # Move cursor right 1 column - "cuu1": b"\x1b[A", # Move cursor up 1 row + "cub": b"\x1b[%p1%dD", # Move cursor left N columns + "cud": b"\x1b[%p1%dB", # Move cursor down N rows + "cuf": b"\x1b[%p1%dC", # Move cursor right N columns + "cuu": b"\x1b[%p1%dA", # Move cursor up N rows + "cub1": b"\x1b[D", # Move cursor left 1 column + "cud1": b"\x1b[B", # Move cursor down 1 row + "cuf1": b"\x1b[C", # Move cursor right 1 column + "cuu1": b"\x1b[A", # Move cursor up 1 row "cup": b"\x1b[%i%p1%d;%p2%dH", # Move cursor to row, column - "hpa": b"\x1b[%i%p1%dG", # Move cursor to column + "hpa": b"\x1b[%i%p1%dG", # Move cursor to column # Clear operations "clear": b"\x1b[H\x1b[2J", # Clear screen and home cursor - "el": b"\x1b[K", # Clear to end of line + "el": b"\x1b[K", # Clear to end of line # Insert/delete - "dch": b"\x1b[%p1%dP", # Delete N characters - "dch1": b"\x1b[P", # Delete 1 character - "ich": b"\x1b[%p1%d@", # Insert N characters - "ich1": b"\x1b[@", # Insert 1 character + "dch": b"\x1b[%p1%dP", # Delete N characters + "dch1": b"\x1b[P", # Delete 1 character + "ich": b"\x1b[%p1%d@", # Insert N characters + "ich1": b"\x1b[@", # Insert 1 character # Cursor visibility - "civis": b"\x1b[?25l", # Make cursor invisible - "cnorm": b"\x1b[?25h", # Make cursor normal (visible) + "civis": b"\x1b[?25l", # Make cursor invisible + "cnorm": b"\x1b[?25h", # Make cursor normal (visible) # Scrolling - "ind": b"\n", # Scroll up one line - "ri": b"\x1bM", # Scroll down one line + "ind": b"\n", # Scroll up one line + "ri": b"\x1bM", # Scroll down one line # Keypad mode - "smkx": b"\x1b[?1h\x1b=", # Enable keypad mode - "rmkx": b"\x1b[?1l\x1b>", # Disable keypad mode + "smkx": b"\x1b[?1h\x1b=", # Enable keypad mode + "rmkx": b"\x1b[?1l\x1b>", # Disable keypad mode # Padding (not used in modern terminals) "pad": b"", # Function keys and special keys - "kdch1": b"\x1b[3~", # Delete key - "kcud1": b"\x1b[B", # Down arrow - "kend": b"\x1b[F", # End key - "kent": b"\x1bOM", # Enter key - "khome": b"\x1b[H", # Home key - "kich1": b"\x1b[2~", # Insert key - "kcub1": b"\x1b[D", # Left arrow - "knp": b"\x1b[6~", # Page down - "kpp": b"\x1b[5~", # Page up - "kcuf1": b"\x1b[C", # Right arrow - "kcuu1": b"\x1b[A", # Up arrow + "kdch1": b"\x1b[3~", # Delete key + "kcud1": b"\x1b[B", # Down arrow + "kend": b"\x1b[F", # End key + "kent": b"\x1bOM", # Enter key + "khome": b"\x1b[H", # Home key + "kich1": b"\x1b[2~", # Insert key + "kcub1": b"\x1b[D", # Left arrow + "knp": b"\x1b[6~", # Page down + "kpp": b"\x1b[5~", # Page up + "kcuf1": b"\x1b[C", # Right arrow + "kcuu1": b"\x1b[A", # Up arrow # Function keys F1-F20 - "kf1": b"\x1bOP", "kf2": b"\x1bOQ", "kf3": b"\x1bOR", "kf4": b"\x1bOS", - "kf5": b"\x1b[15~", "kf6": b"\x1b[17~", "kf7": b"\x1b[18~", "kf8": b"\x1b[19~", - "kf9": b"\x1b[20~", "kf10": b"\x1b[21~", "kf11": b"\x1b[23~", "kf12": b"\x1b[24~", - "kf13": b"\x1b[25~", "kf14": b"\x1b[26~", "kf15": b"\x1b[28~", "kf16": b"\x1b[29~", - "kf17": b"\x1b[31~", "kf18": b"\x1b[32~", "kf19": b"\x1b[33~", "kf20": b"\x1b[34~", + "kf1": b"\x1bOP", + "kf2": b"\x1bOQ", + "kf3": b"\x1bOR", + "kf4": b"\x1bOS", + "kf5": b"\x1b[15~", + "kf6": b"\x1b[17~", + "kf7": b"\x1b[18~", + "kf8": b"\x1b[19~", + "kf9": b"\x1b[20~", + "kf10": b"\x1b[21~", + "kf11": b"\x1b[23~", + "kf12": b"\x1b[24~", + "kf13": b"\x1b[25~", + "kf14": b"\x1b[26~", + "kf15": b"\x1b[28~", + "kf16": b"\x1b[29~", + "kf17": b"\x1b[31~", + "kf18": b"\x1b[32~", + "kf19": b"\x1b[33~", + "kf20": b"\x1b[34~", }, # Dumb terminal - minimal capabilities "dumb": { - "bel": b"\x07", # Bell - "cud1": b"\n", # Move down 1 row (newline) - "ind": b"\n", # Scroll up one line (newline) + "bel": b"\x07", # Bell + "cud1": b"\n", # Move down 1 row (newline) + "ind": b"\n", # Scroll up one line (newline) }, # Linux console "linux": { # Bell "bel": b"\x07", # Cursor movement - "cub": b"\x1b[%p1%dD", # Move cursor left N columns - "cud": b"\x1b[%p1%dB", # Move cursor down N rows - "cuf": b"\x1b[%p1%dC", # Move cursor right N columns - "cuu": b"\x1b[%p1%dA", # Move cursor up N rows - "cub1": b"\x08", # Move cursor left 1 column (backspace) - "cud1": b"\n", # Move cursor down 1 row (newline) - "cuf1": b"\x1b[C", # Move cursor right 1 column - "cuu1": b"\x1b[A", # Move cursor up 1 row + "cub": b"\x1b[%p1%dD", # Move cursor left N columns + "cud": b"\x1b[%p1%dB", # Move cursor down N rows + "cuf": b"\x1b[%p1%dC", # Move cursor right N columns + "cuu": b"\x1b[%p1%dA", # Move cursor up N rows + "cub1": b"\x08", # Move cursor left 1 column (backspace) + "cud1": b"\n", # Move cursor down 1 row (newline) + "cuf1": b"\x1b[C", # Move cursor right 1 column + "cuu1": b"\x1b[A", # Move cursor up 1 row "cup": b"\x1b[%i%p1%d;%p2%dH", # Move cursor to row, column - "hpa": b"\x1b[%i%p1%dG", # Move cursor to column + "hpa": b"\x1b[%i%p1%dG", # Move cursor to column # Clear operations - "clear": b"\x1b[H\x1b[J", # Clear screen and home cursor (different from ansi!) - "el": b"\x1b[K", # Clear to end of line + "clear": b"\x1b[H\x1b[J", # Clear screen and home cursor (different from ansi!) + "el": b"\x1b[K", # Clear to end of line # Insert/delete - "dch": b"\x1b[%p1%dP", # Delete N characters - "dch1": b"\x1b[P", # Delete 1 character - "ich": b"\x1b[%p1%d@", # Insert N characters - "ich1": b"\x1b[@", # Insert 1 character + "dch": b"\x1b[%p1%dP", # Delete N characters + "dch1": b"\x1b[P", # Delete 1 character + "ich": b"\x1b[%p1%d@", # Insert N characters + "ich1": b"\x1b[@", # Insert 1 character # Cursor visibility "civis": b"\x1b[?25l\x1b[?1c", # Make cursor invisible "cnorm": b"\x1b[?25h\x1b[?0c", # Make cursor normal # Scrolling - "ind": b"\n", # Scroll up one line - "ri": b"\x1bM", # Scroll down one line + "ind": b"\n", # Scroll up one line + "ri": b"\x1bM", # Scroll down one line # Keypad mode - "smkx": b"\x1b[?1h\x1b=", # Enable keypad mode - "rmkx": b"\x1b[?1l\x1b>", # Disable keypad mode + "smkx": b"\x1b[?1h\x1b=", # Enable keypad mode + "rmkx": b"\x1b[?1l\x1b>", # Disable keypad mode # Function keys and special keys - "kdch1": b"\x1b[3~", # Delete key - "kcud1": b"\x1b[B", # Down arrow - "kend": b"\x1b[4~", # End key (different from ansi!) - "khome": b"\x1b[1~", # Home key (different from ansi!) - "kich1": b"\x1b[2~", # Insert key - "kcub1": b"\x1b[D", # Left arrow - "knp": b"\x1b[6~", # Page down - "kpp": b"\x1b[5~", # Page up - "kcuf1": b"\x1b[C", # Right arrow - "kcuu1": b"\x1b[A", # Up arrow + "kdch1": b"\x1b[3~", # Delete key + "kcud1": b"\x1b[B", # Down arrow + "kend": b"\x1b[4~", # End key (different from ansi!) + "khome": b"\x1b[1~", # Home key (different from ansi!) + "kich1": b"\x1b[2~", # Insert key + "kcub1": b"\x1b[D", # Left arrow + "knp": b"\x1b[6~", # Page down + "kpp": b"\x1b[5~", # Page up + "kcuf1": b"\x1b[C", # Right arrow + "kcuu1": b"\x1b[A", # Up arrow # Function keys - "kf1": b"\x1b[[A", "kf2": b"\x1b[[B", "kf3": b"\x1b[[C", "kf4": b"\x1b[[D", - "kf5": b"\x1b[[E", "kf6": b"\x1b[17~", "kf7": b"\x1b[18~", "kf8": b"\x1b[19~", - "kf9": b"\x1b[20~", "kf10": b"\x1b[21~", "kf11": b"\x1b[23~", "kf12": b"\x1b[24~", - "kf13": b"\x1b[25~", "kf14": b"\x1b[26~", "kf15": b"\x1b[28~", "kf16": b"\x1b[29~", - "kf17": b"\x1b[31~", "kf18": b"\x1b[32~", "kf19": b"\x1b[33~", "kf20": b"\x1b[34~", - } + "kf1": b"\x1b[[A", + "kf2": b"\x1b[[B", + "kf3": b"\x1b[[C", + "kf4": b"\x1b[[D", + "kf5": b"\x1b[[E", + "kf6": b"\x1b[17~", + "kf7": b"\x1b[18~", + "kf8": b"\x1b[19~", + "kf9": b"\x1b[20~", + "kf10": b"\x1b[21~", + "kf11": b"\x1b[23~", + "kf12": b"\x1b[24~", + "kf13": b"\x1b[25~", + "kf14": b"\x1b[26~", + "kf15": b"\x1b[28~", + "kf16": b"\x1b[29~", + "kf17": b"\x1b[31~", + "kf18": b"\x1b[32~", + "kf19": b"\x1b[33~", + "kf20": b"\x1b[34~", + }, } # Map common TERM values to capability sets @@ -642,6 +674,7 @@ def _read_terminfo_file(terminal_name: str) -> bytes: "unknown": "dumb", } + @dataclass class TermInfo: terminal_name: str | bytes | None @@ -666,10 +699,10 @@ def __post_init__(self) -> None: """ # If termstr is None or empty, try to get from environment if not self.terminal_name: - self.terminal_name = os.environ.get('TERM') or 'ANSI' + self.terminal_name = os.environ.get("TERM") or "ANSI" if isinstance(self.terminal_name, bytes): - self.terminal_name = self.terminal_name.decode('ascii') + self.terminal_name = self.terminal_name.decode("ascii") try: self._parse_terminfo_file(self.terminal_name) @@ -677,9 +710,11 @@ def __post_init__(self) -> None: if not self.fallback: raise - term_type = _TERM_ALIASES.get(self.terminal_name, self.terminal_name) + term_type = _TERM_ALIASES.get( + self.terminal_name, self.terminal_name + ) if term_type not in _TERMINAL_CAPABILITIES: - term_type = 'dumb' + term_type = "dumb" self._capabilities = _TERMINAL_CAPABILITIES[term_type].copy() def _parse_terminfo_file(self, terminal_name: str) -> None: @@ -695,12 +730,12 @@ def _parse_terminfo_file(self, terminal_name: str) -> None: if len(data) < 12: raise ValueError(too_short) - magic = struct.unpack(' None: ) # Parse header - name_size = struct.unpack(' len(data): raise ValueError(too_short) - names = data[offset:offset+name_size-1].decode('ascii', errors='ignore') + names = data[offset : offset + name_size - 1].decode( + "ascii", errors="ignore" + ) offset += name_size # Read boolean capabilities if offset + bool_count > len(data): raise ValueError(too_short) - booleans = list(data[offset:offset+bool_count]) + booleans = list(data[offset : offset + bool_count]) offset += bool_count # Align to even byte boundary for numbers @@ -737,7 +774,9 @@ def _parse_terminfo_file(self, terminal_name: str) -> None: for i in range(num_count): if offset + number_size > len(data): raise ValueError(too_short) - num = struct.unpack(number_format, data[offset:offset+number_size])[0] + num = struct.unpack( + number_format, data[offset : offset + number_size] + )[0] numbers.append(num) offset += number_size @@ -746,14 +785,14 @@ def _parse_terminfo_file(self, terminal_name: str) -> None: for i in range(str_count): if offset + 2 > len(data): raise ValueError(too_short) - off = struct.unpack(' len(data): raise ValueError(too_short) - string_table = data[offset:offset+str_size] + string_table = data[offset : offset + str_size] # Extract strings from string table strings: list[bytes | None] = [] @@ -772,7 +811,7 @@ def _parse_terminfo_file(self, terminal_name: str) -> None: else: strings.append(ABSENT_STRING) - self._names = names.split('|') + self._names = names.split("|") self._booleans = booleans self._numbers = numbers self._strings = strings @@ -836,20 +875,20 @@ def tparm(cap_bytes: bytes, *params: int) -> bytes: value = params[i] if increment: value += 1 - result = result.replace(pattern, str(value).encode('ascii')) + result = result.replace(pattern, str(value).encode("ascii")) # Handle %p1%{1}%+%d (parameter plus constant) # Used in some cursor positioning sequences - pattern_re = re.compile(rb'%p(\d)%\{(\d+)\}%\+%d') + pattern_re = re.compile(rb"%p(\d)%\{(\d+)\}%\+%d") matches = list(pattern_re.finditer(result)) for match in reversed(matches): # reversed to maintain positions param_idx = int(match.group(1)) constant = int(match.group(2)) value = params[param_idx] + constant result = ( - result[:match.start()] - + str(value).encode('ascii') - + result[match.end():] + result[: match.start()] + + str(value).encode("ascii") + + result[match.end() :] ) return result diff --git a/Lib/test/test_pyrepl/test_terminfo.py b/Lib/test/test_pyrepl/test_terminfo.py index a0c379cc5c6c83..f4220138d847de 100644 --- a/Lib/test/test_pyrepl/test_terminfo.py +++ b/Lib/test/test_pyrepl/test_terminfo.py @@ -9,7 +9,7 @@ from textwrap import dedent # Only run these tests if curses is available -requires('curses') +requires("curses") try: import _curses @@ -22,6 +22,10 @@ from _pyrepl import terminfo +ABSENT_STRING = terminfo.ABSENT_STRING +CANCELLED_STRING = terminfo.CANCELLED_STRING + + class TestCursesCompatibility(unittest.TestCase): """Test that PyREPL's curses implementation matches the standard curses behavior. @@ -34,23 +38,23 @@ def setUp(self): raise unittest.SkipTest( "`curses` capability provided to regrtest but `_curses` not importable" ) - self.original_term = os.environ.get('TERM', None) + self.original_term = os.environ.get("TERM", None) def tearDown(self): if self.original_term is not None: - os.environ['TERM'] = self.original_term - elif 'TERM' in os.environ: - del os.environ['TERM'] + os.environ["TERM"] = self.original_term + elif "TERM" in os.environ: + del os.environ["TERM"] def test_setupterm_basic(self): """Test basic setupterm functionality.""" # Test with explicit terminal type - test_terms = ['xterm', 'xterm-256color', 'vt100', 'ansi'] + test_terms = ["xterm", "xterm-256color", "vt100", "ansi"] for term in test_terms: with self.subTest(term=term): ncurses_code = dedent( - f''' + f""" import _curses import json try: @@ -58,11 +62,14 @@ def test_setupterm_basic(self): print(json.dumps({{"success": True}})) except Exception as e: print(json.dumps({{"success": False, "error": str(e)}})) - ''' + """ ) - result = subprocess.run([sys.executable, '-c', ncurses_code], - capture_output=True, text=True) + result = subprocess.run( + [sys.executable, "-c", ncurses_code], + capture_output=True, + text=True, + ) ncurses_data = json.loads(result.stdout) std_success = ncurses_data["success"] @@ -76,8 +83,10 @@ def test_setupterm_basic(self): # Both should succeed or both should fail if std_success: - self.assertTrue(pyrepl_success, - f"Standard curses succeeded but PyREPL failed for {term}") + self.assertTrue( + pyrepl_success, + f"Standard curses succeeded but PyREPL failed for {term}", + ) else: # If standard curses failed, PyREPL might still succeed with fallback # This is acceptable as PyREPL has hardcoded fallbacks @@ -87,7 +96,7 @@ def test_setupterm_none(self): """Test setupterm with None (uses TERM from environment).""" # Test with current TERM ncurses_code = dedent( - ''' + """ import _curses import json try: @@ -95,11 +104,14 @@ def test_setupterm_none(self): print(json.dumps({"success": True})) except Exception as e: print(json.dumps({"success": False, "error": str(e)})) - ''' + """ ) - result = subprocess.run([sys.executable, '-c', ncurses_code], - capture_output=True, text=True) + result = subprocess.run( + [sys.executable, "-c", ncurses_code], + capture_output=True, + text=True, + ) ncurses_data = json.loads(result.stdout) std_success = ncurses_data["success"] @@ -111,34 +123,42 @@ def test_setupterm_none(self): # Both should have same result if std_success: - self.assertTrue(pyrepl_success, - "Standard curses succeeded but PyREPL failed for None") + self.assertTrue( + pyrepl_success, + "Standard curses succeeded but PyREPL failed for None", + ) def test_tigetstr_common_capabilities(self): """Test tigetstr for common terminal capabilities.""" # Test with a known terminal type - term = 'xterm' + term = "xterm" # Get ALL capabilities from infocmp all_caps = [] try: - result = subprocess.run(['infocmp', '-1', term], - capture_output=True, text=True, check=True) + result = subprocess.run( + ["infocmp", "-1", term], + capture_output=True, + text=True, + check=True, + ) for line in result.stdout.splitlines(): line = line.strip() - if '=' in line and not line.startswith('#'): - cap_name = line.split('=')[0] + if "=" in line and not line.startswith("#"): + cap_name = line.split("=")[0] all_caps.append(cap_name) except: # If infocmp fails, at least test the critical ones + # fmt: off all_caps = [ - 'cup', 'clear', 'el', 'cub1', 'cuf1', 'cuu1', 'cud1', 'bel', - 'ind', 'ri', 'civis', 'cnorm', 'smkx', 'rmkx', 'cub', 'cuf', - 'cud', 'cuu', 'home', 'hpa', 'vpa', 'cr', 'nel', 'ht' + "cup", "clear", "el", "cub1", "cuf1", "cuu1", "cud1", "bel", + "ind", "ri", "civis", "cnorm", "smkx", "rmkx", "cub", "cuf", + "cud", "cuu", "home", "hpa", "vpa", "cr", "nel", "ht" ] + # fmt: on ncurses_code = dedent( - f''' + f""" import _curses import json _curses.setupterm({repr(term)}, 1) @@ -155,12 +175,17 @@ def test_tigetstr_common_capabilities(self): except: results[cap] = "error" print(json.dumps(results)) - ''' + """ ) - result = subprocess.run([sys.executable, '-c', ncurses_code], - capture_output=True, text=True) - self.assertEqual(result.returncode, 0, f"Failed to run ncurses: {result.stderr}") + result = subprocess.run( + [sys.executable, "-c", ncurses_code], + capture_output=True, + text=True, + ) + self.assertEqual( + result.returncode, 0, f"Failed to run ncurses: {result.stderr}" + ) ncurses_data = json.loads(result.stdout) @@ -178,18 +203,21 @@ def test_tigetstr_common_capabilities(self): pyrepl_val = ti.get(cap) - self.assertEqual(pyrepl_val, ncurses_val, - f"Capability {cap}: ncurses={repr(ncurses_val)}, " - f"pyrepl={repr(pyrepl_val)}") + self.assertEqual( + pyrepl_val, + ncurses_val, + f"Capability {cap}: ncurses={repr(ncurses_val)}, " + f"pyrepl={repr(pyrepl_val)}", + ) def test_tigetstr_input_types(self): """Test tigetstr with different input types.""" - term = 'xterm' - cap = 'cup' + term = "xterm" + cap = "cup" # Test standard curses behavior with string in subprocess ncurses_code = dedent( - f''' + f""" import _curses import json _curses.setupterm({repr(term)}, 1) @@ -212,11 +240,14 @@ def test_tigetstr_input_types(self): "accepts_str": std_accepts_str, "str_result": std_str_val }})) - ''' + """ ) - result = subprocess.run([sys.executable, '-c', ncurses_code], - capture_output=True, text=True) + result = subprocess.run( + [sys.executable, "-c", ncurses_code], + capture_output=True, + text=True, + ) ncurses_data = json.loads(result.stdout) # PyREPL setup @@ -231,32 +262,37 @@ def test_tigetstr_input_types(self): # PyREPL should also only accept strings for compatibility with self.assertRaises(TypeError): - ti.get(cap.encode('ascii')) + ti.get(cap.encode("ascii")) # Both should accept string input - self.assertEqual(pyrepl_accepts_str, ncurses_data["accepts_str"], - "PyREPL and standard curses should have same string handling") - self.assertTrue(pyrepl_accepts_str, "PyREPL should accept string input") + self.assertEqual( + pyrepl_accepts_str, + ncurses_data["accepts_str"], + "PyREPL and standard curses should have same string handling", + ) + self.assertTrue( + pyrepl_accepts_str, "PyREPL should accept string input" + ) def test_tparm_basic(self): """Test basic tparm functionality.""" - term = 'xterm' + term = "xterm" ti = terminfo.TermInfo(term) # Test cursor positioning (cup) - cup = ti.get('cup') - if cup and cup != -1: + cup = ti.get("cup") + if cup and cup not in {ABSENT_STRING, CANCELLED_STRING}: # Test various parameter combinations test_cases = [ - (0, 0), # Top-left - (5, 10), # Arbitrary position - (23, 79), # Bottom-right of standard terminal + (0, 0), # Top-left + (5, 10), # Arbitrary position + (23, 79), # Bottom-right of standard terminal (999, 999), # Large values ] # Get ncurses results in subprocess ncurses_code = dedent( - f''' + f""" import _curses import json _curses.setupterm({repr(term)}, 1) @@ -273,52 +309,70 @@ def test_tparm_basic(self): results[f"{{row}},{{col}}"] = {{"error": str(e)}} print(json.dumps(results)) - ''' + """ ) - result = subprocess.run([sys.executable, '-c', ncurses_code], - capture_output=True, text=True) - self.assertEqual(result.returncode, 0, f"Failed to run ncurses: {result.stderr}") + result = subprocess.run( + [sys.executable, "-c", ncurses_code], + capture_output=True, + text=True, + ) + self.assertEqual( + result.returncode, 0, f"Failed to run ncurses: {result.stderr}" + ) ncurses_data = json.loads(result.stdout) for row, col in test_cases: with self.subTest(row=row, col=col): # Standard curses tparm from subprocess key = f"{row},{col}" - if isinstance(ncurses_data[key], dict) and "error" in ncurses_data[key]: - self.fail(f"ncurses tparm failed: {ncurses_data[key]['error']}") + if ( + isinstance(ncurses_data[key], dict) + and "error" in ncurses_data[key] + ): + self.fail( + f"ncurses tparm failed: {ncurses_data[key]['error']}" + ) std_result = bytes(ncurses_data[key]) # PyREPL curses tparm pyrepl_result = terminfo.tparm(cup, row, col) # Results should be identical - self.assertEqual(pyrepl_result, std_result, - f"tparm(cup, {row}, {col}): " - f"std={repr(std_result)}, pyrepl={repr(pyrepl_result)}") + self.assertEqual( + pyrepl_result, + std_result, + f"tparm(cup, {row}, {col}): " + f"std={repr(std_result)}, pyrepl={repr(pyrepl_result)}", + ) else: - raise unittest.SkipTest("test_tparm_basic() requires the `cup` capability") + raise unittest.SkipTest( + "test_tparm_basic() requires the `cup` capability" + ) def test_tparm_multiple_params(self): """Test tparm with capabilities using multiple parameters.""" - term = 'xterm' + term = "xterm" ti = terminfo.TermInfo(term) # Test capabilities that take parameters param_caps = { - 'cub': 1, # cursor_left with count - 'cuf': 1, # cursor_right with count - 'cuu': 1, # cursor_up with count - 'cud': 1, # cursor_down with count - 'dch': 1, # delete_character with count - 'ich': 1, # insert_character with count + "cub": 1, # cursor_left with count + "cuf": 1, # cursor_right with count + "cuu": 1, # cursor_up with count + "cud": 1, # cursor_down with count + "dch": 1, # delete_character with count + "ich": 1, # insert_character with count } # Get all capabilities from PyREPL first pyrepl_caps = {} for cap in param_caps: cap_value = ti.get(cap) - if cap_value and cap_value != -1: + if cap_value and cap_value not in { + ABSENT_STRING, + CANCELLED_STRING, + }: pyrepl_caps[cap] = cap_value if not pyrepl_caps: @@ -326,7 +380,7 @@ def test_tparm_multiple_params(self): # Get ncurses results in subprocess ncurses_code = dedent( - f''' + f""" import _curses import json _curses.setupterm({repr(term)}, 1) @@ -346,12 +400,17 @@ def test_tparm_multiple_params(self): results[f"{{cap}},{{value}}"] = {{"error": str(e)}} print(json.dumps(results)) - ''' + """ ) - result = subprocess.run([sys.executable, '-c', ncurses_code], - capture_output=True, text=True) - self.assertEqual(result.returncode, 0, f"Failed to run ncurses: {result.stderr}") + result = subprocess.run( + [sys.executable, "-c", ncurses_code], + capture_output=True, + text=True, + ) + self.assertEqual( + result.returncode, 0, f"Failed to run ncurses: {result.stderr}" + ) ncurses_data = json.loads(result.stdout) for cap, cap_value in pyrepl_caps.items(): @@ -360,8 +419,13 @@ def test_tparm_multiple_params(self): for value in [1, 5, 10, 99]: key = f"{cap},{value}" if key in ncurses_data: - if isinstance(ncurses_data[key], dict) and "error" in ncurses_data[key]: - self.fail(f"ncurses tparm failed: {ncurses_data[key]['error']}") + if ( + isinstance(ncurses_data[key], dict) + and "error" in ncurses_data[key] + ): + self.fail( + f"ncurses tparm failed: {ncurses_data[key]['error']}" + ) std_result = bytes(ncurses_data[key]) pyrepl_result = terminfo.tparm(cap_value, value) @@ -369,15 +433,15 @@ def test_tparm_multiple_params(self): pyrepl_result, std_result, f"tparm({cap}, {value}): " - f"std={repr(std_result)}, pyrepl={repr(pyrepl_result)}" + f"std={repr(std_result)}, pyrepl={repr(pyrepl_result)}", ) def test_tparm_null_handling(self): """Test tparm with None/null input.""" - term = 'xterm' + term = "xterm" ncurses_code = dedent( - f''' + f""" import _curses import json _curses.setupterm({repr(term)}, 1) @@ -393,11 +457,14 @@ def test_tparm_null_handling(self): error_type = type(e).__name__ print(json.dumps({{"raises_typeerror": raises_typeerror}})) - ''' + """ ) - result = subprocess.run([sys.executable, '-c', ncurses_code], - capture_output=True, text=True) + result = subprocess.run( + [sys.executable, "-c", ncurses_code], + capture_output=True, + text=True, + ) ncurses_data = json.loads(result.stdout) # PyREPL setup @@ -417,41 +484,48 @@ def test_tparm_null_handling(self): def test_special_terminals(self): """Test with special terminal types.""" special_terms = [ - 'dumb', # Minimal terminal - 'unknown', # Should fall back to defaults - 'linux', # Linux console - 'screen', # GNU Screen - 'tmux', # tmux + "dumb", # Minimal terminal + "unknown", # Should fall back to defaults + "linux", # Linux console + "screen", # GNU Screen + "tmux", # tmux ] # Get all string capabilities from ncurses all_caps = [] try: # Get all capability names from infocmp - result = subprocess.run(['infocmp', '-1', 'xterm'], - capture_output=True, text=True, check=True) + result = subprocess.run( + ["infocmp", "-1", "xterm"], + capture_output=True, + text=True, + check=True, + ) for line in result.stdout.splitlines(): line = line.strip() - if '=' in line: - cap_name = line.split('=')[0] + if "=" in line: + cap_name = line.split("=")[0] all_caps.append(cap_name) except: # Fall back to a core set if infocmp fails - all_caps = ['cup', 'clear', 'el', 'cub', 'cuf', 'cud', 'cuu', - 'cub1', 'cuf1', 'cud1', 'cuu1', 'home', 'bel', - 'ind', 'ri', 'nel', 'cr', 'ht', 'hpa', 'vpa', - 'dch', 'dch1', 'dl', 'dl1', 'ich', 'ich1', 'il', 'il1', - 'sgr0', 'smso', 'rmso', 'smul', 'rmul', 'bold', 'rev', - 'blink', 'dim', 'smacs', 'rmacs', 'civis', 'cnorm', - 'sc', 'rc', 'hts', 'tbc', 'ed', 'kbs', 'kcud1', 'kcub1', - 'kcuf1', 'kcuu1', 'kdch1', 'khome', 'kend', 'knp', 'kpp', - 'kich1', 'kf1', 'kf2', 'kf3', 'kf4', 'kf5', 'kf6', 'kf7', - 'kf8', 'kf9', 'kf10', 'rmkx', 'smkx'] + # fmt: off + all_caps = [ + "cup", "clear", "el", "cub", "cuf", "cud", "cuu", "cub1", + "cuf1", "cud1", "cuu1", "home", "bel", "ind", "ri", "nel", "cr", + "ht", "hpa", "vpa", "dch", "dch1", "dl", "dl1", "ich", "ich1", + "il", "il1", "sgr0", "smso", "rmso", "smul", "rmul", "bold", + "rev", "blink", "dim", "smacs", "rmacs", "civis", "cnorm", "sc", + "rc", "hts", "tbc", "ed", "kbs", "kcud1", "kcub1", "kcuf1", + "kcuu1", "kdch1", "khome", "kend", "knp", "kpp", "kich1", "kf1", + "kf2", "kf3", "kf4", "kf5", "kf6", "kf7", "kf8", "kf9", "kf10", + "rmkx", "smkx" + ] + # fmt: on for term in special_terms: with self.subTest(term=term): ncurses_code = dedent( - f''' + f""" import _curses import json import sys @@ -474,19 +548,26 @@ def test_special_terminals(self): print(json.dumps(results)) except Exception as e: print(json.dumps({{"error": str(e)}})) - ''' + """ ) # Get ncurses results - result = subprocess.run([sys.executable, '-c', ncurses_code], - capture_output=True, text=True) + result = subprocess.run( + [sys.executable, "-c", ncurses_code], + capture_output=True, + text=True, + ) if result.returncode != 0: - self.fail(f"Failed to get ncurses data for {term}: {result.stderr}") + self.fail( + f"Failed to get ncurses data for {term}: {result.stderr}" + ) try: ncurses_data = json.loads(result.stdout) except json.JSONDecodeError: - self.fail(f"Failed to parse ncurses output for {term}: {result.stdout}") + self.fail( + f"Failed to parse ncurses output for {term}: {result.stdout}" + ) if "error" in ncurses_data and len(ncurses_data) == 1: # ncurses failed to setup this terminal @@ -510,19 +591,22 @@ def test_special_terminals(self): pyrepl_val = ti.get(cap) # Both should return the same value - self.assertEqual(pyrepl_val, ncurses_val, - f"Capability {cap} for {term}: " - f"ncurses={repr(ncurses_val)}, " - f"pyrepl={repr(pyrepl_val)}") + self.assertEqual( + pyrepl_val, + ncurses_val, + f"Capability {cap} for {term}: " + f"ncurses={repr(ncurses_val)}, " + f"pyrepl={repr(pyrepl_val)}", + ) def test_terminfo_fallback(self): """Test that PyREPL falls back gracefully when terminfo is not found.""" # Use a non-existent terminal type - fake_term = 'nonexistent-terminal-type-12345' + fake_term = "nonexistent-terminal-type-12345" # Check if standard curses can setup this terminal in subprocess ncurses_code = dedent( - f''' + f""" import _curses import json try: @@ -532,16 +616,21 @@ def test_terminfo_fallback(self): print(json.dumps({{"success": False, "error": "curses.error"}})) except Exception as e: print(json.dumps({{"success": False, "error": str(e)}})) - ''' + """ ) - result = subprocess.run([sys.executable, '-c', ncurses_code], - capture_output=True, text=True) + result = subprocess.run( + [sys.executable, "-c", ncurses_code], + capture_output=True, + text=True, + ) ncurses_data = json.loads(result.stdout) if ncurses_data["success"]: # If it succeeded, skip this test as we can't test fallback - self.skipTest(f"System unexpectedly has terminfo for '{fake_term}'") + self.skipTest( + f"System unexpectedly has terminfo for '{fake_term}'" + ) # PyREPL should succeed with fallback try: @@ -550,8 +639,12 @@ def test_terminfo_fallback(self): except: pyrepl_ok = False - self.assertTrue(pyrepl_ok, "PyREPL should fall back for unknown terminals") + self.assertTrue( + pyrepl_ok, "PyREPL should fall back for unknown terminals" + ) # Should still be able to get basic capabilities - bel = ti.get('bel') - self.assertIsNotNone(bel, "PyREPL should provide basic capabilities after fallback") + bel = ti.get("bel") + self.assertIsNotNone( + bel, "PyREPL should provide basic capabilities after fallback" + ) diff --git a/Misc/NEWS.d/next/Build/2025-07-18-17-15-00.gh-issue-135621.9cyCNb.rst b/Misc/NEWS.d/next/Build/2025-07-18-17-15-00.gh-issue-135621.9cyCNb.rst new file mode 100644 index 00000000000000..fe7f962ccbb096 --- /dev/null +++ b/Misc/NEWS.d/next/Build/2025-07-18-17-15-00.gh-issue-135621.9cyCNb.rst @@ -0,0 +1,2 @@ +PyREPL no longer depends on the :mod:`curses` standard library. Contributed +by Ɓukasz Langa. From 8aee463f2dd6fca56da35d69d93f19ad5338d477 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Langa?= Date: Fri, 18 Jul 2025 17:32:43 +0200 Subject: [PATCH 03/12] Fix optional capability handling --- Lib/_pyrepl/terminfo.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/_pyrepl/terminfo.py b/Lib/_pyrepl/terminfo.py index 252cdceade1cd1..3a9080b4aca099 100644 --- a/Lib/_pyrepl/terminfo.py +++ b/Lib/_pyrepl/terminfo.py @@ -831,7 +831,7 @@ def get(self, cap: str) -> bytes | None: if self._capabilities: # Fallbacks populated, use them - return self._capabilities[cap] + return self._capabilities.get(cap) # Look up in standard capabilities first if cap in _STRING_CAPABILITY_NAMES: From 4b7076772bca077165ca77b69bc5692b92aca785 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Langa?= Date: Fri, 18 Jul 2025 18:01:36 +0200 Subject: [PATCH 04/12] Skip if termios unavailable --- Lib/test/test_pyrepl/__init__.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Lib/test/test_pyrepl/__init__.py b/Lib/test/test_pyrepl/__init__.py index ca273763bed98d..8ef472eb0cffaf 100644 --- a/Lib/test/test_pyrepl/__init__.py +++ b/Lib/test/test_pyrepl/__init__.py @@ -1,5 +1,14 @@ import os from test.support import load_package_tests +import unittest + + +try: + import termios +except ImportError: + raise unittest.SkipTest("termios required") +else: + del termios def load_tests(*args): From 58d3e84cd4db34084685365c10d89cafe159f2d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Langa?= Date: Sat, 19 Jul 2025 00:22:11 +0200 Subject: [PATCH 05/12] Initialize test UnixConsole to xterm --- Lib/test/test_pyrepl/test_unix_console.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/test/test_pyrepl/test_unix_console.py b/Lib/test/test_pyrepl/test_unix_console.py index 1cf3b40350c4f4..ab1236768cfb3e 100644 --- a/Lib/test/test_pyrepl/test_unix_console.py +++ b/Lib/test/test_pyrepl/test_unix_console.py @@ -295,7 +295,7 @@ def same_console(events): def test_getheightwidth_with_invalid_environ(self, _os_write): # gh-128636 - console = UnixConsole() + console = UnixConsole(term="xterm") with os_helper.EnvironmentVarGuard() as env: env["LINES"] = "" self.assertIsInstance(console.getheightwidth(), tuple) From 0448edd525166de725482c28a2afbdcaa6d60a42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Langa?= Date: Sat, 19 Jul 2025 10:48:11 +0200 Subject: [PATCH 06/12] Properly gate tests requiring subprocesses --- Lib/test/test_pyrepl/test_pyrepl.py | 7 ++++++- Lib/test/test_pyrepl/test_terminfo.py | 6 +++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/Lib/test/test_pyrepl/test_pyrepl.py b/Lib/test/test_pyrepl/test_pyrepl.py index 98bae7dd703fd9..8721c252e4ec8a 100644 --- a/Lib/test/test_pyrepl/test_pyrepl.py +++ b/Lib/test/test_pyrepl/test_pyrepl.py @@ -12,7 +12,7 @@ from unittest import TestCase, skipUnless, skipIf from unittest.mock import patch from test.support import force_not_colorized, make_clean_env, Py_DEBUG -from test.support import SHORT_TIMEOUT, STDLIB_DIR +from test.support import has_subprocess_support, SHORT_TIMEOUT, STDLIB_DIR from test.support.import_helper import import_module from test.support.os_helper import EnvironmentVarGuard, unlink @@ -38,6 +38,10 @@ class ReplTestCase(TestCase): + def setUp(self): + if not has_subprocess_support: + raise unittest.SkipTest("test module requires subprocess") + def run_repl( self, repl_input: str | list[str], @@ -1371,6 +1375,7 @@ def setUp(self): # Cleanup from PYTHON* variables to isolate from local # user settings, see #121359. Such variables should be # added later in test methods to patched os.environ. + super().setUp() patcher = patch('os.environ', new=make_clean_env()) self.addCleanup(patcher.stop) patcher.start() diff --git a/Lib/test/test_pyrepl/test_terminfo.py b/Lib/test/test_pyrepl/test_terminfo.py index f4220138d847de..d71d607a1bd2d9 100644 --- a/Lib/test/test_pyrepl/test_terminfo.py +++ b/Lib/test/test_pyrepl/test_terminfo.py @@ -5,7 +5,7 @@ import subprocess import sys import unittest -from test.support import requires +from test.support import requires, has_subprocess_support from textwrap import dedent # Only run these tests if curses is available @@ -38,6 +38,10 @@ def setUp(self): raise unittest.SkipTest( "`curses` capability provided to regrtest but `_curses` not importable" ) + + if not has_subprocess_support: + raise unittest.SkipTest("test module requires subprocess") + self.original_term = os.environ.get("TERM", None) def tearDown(self): From 2a8ea7604c711197192e18cd2369967684852707 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Langa?= Date: Sat, 19 Jul 2025 13:32:34 +0200 Subject: [PATCH 07/12] Fix the fix --- Lib/test/test_pyrepl/test_pyrepl.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Lib/test/test_pyrepl/test_pyrepl.py b/Lib/test/test_pyrepl/test_pyrepl.py index 8721c252e4ec8a..de10a8a07c8f3f 100644 --- a/Lib/test/test_pyrepl/test_pyrepl.py +++ b/Lib/test/test_pyrepl/test_pyrepl.py @@ -9,7 +9,7 @@ import sys import tempfile from pkgutil import ModuleInfo -from unittest import TestCase, skipUnless, skipIf +from unittest import TestCase, skipUnless, skipIf, SkipTest from unittest.mock import patch from test.support import force_not_colorized, make_clean_env, Py_DEBUG from test.support import has_subprocess_support, SHORT_TIMEOUT, STDLIB_DIR @@ -40,7 +40,7 @@ class ReplTestCase(TestCase): def setUp(self): if not has_subprocess_support: - raise unittest.SkipTest("test module requires subprocess") + raise SkipTest("test module requires subprocess") def run_repl( self, From 52d1a1906bf323a62146f088621286cc40aaa8ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Langa?= Date: Sat, 19 Jul 2025 13:56:49 +0200 Subject: [PATCH 08/12] Use implicit numbering in _STRING_NAMES --- Lib/_pyrepl/terminfo.py | 474 +++++----------------------------------- 1 file changed, 51 insertions(+), 423 deletions(-) diff --git a/Lib/_pyrepl/terminfo.py b/Lib/_pyrepl/terminfo.py index 3a9080b4aca099..e8466f7f291edd 100644 --- a/Lib/_pyrepl/terminfo.py +++ b/Lib/_pyrepl/terminfo.py @@ -19,431 +19,59 @@ ABSENT_STRING = None CANCELLED_STRING = None + # Standard string capability names from ncurses Caps file # This matches the order used by ncurses when compiling terminfo -_STRING_CAPABILITY_NAMES = { - "cbt": 0, - "bel": 1, - "cr": 2, - "csr": 3, - "tbc": 4, - "clear": 5, - "el": 6, - "ed": 7, - "hpa": 8, - "cmdch": 9, - "cup": 10, - "cud1": 11, - "home": 12, - "civis": 13, - "cub1": 14, - "mrcup": 15, - "cnorm": 16, - "cuf1": 17, - "ll": 18, - "cuu1": 19, - "cvvis": 20, - "dch1": 21, - "dl1": 22, - "dsl": 23, - "hd": 24, - "smacs": 25, - "blink": 26, - "bold": 27, - "smcup": 28, - "smdc": 29, - "dim": 30, - "smir": 31, - "invis": 32, - "prot": 33, - "rev": 34, - "smso": 35, - "smul": 36, - "ech": 37, - "rmacs": 38, - "sgr0": 39, - "rmcup": 40, - "rmdc": 41, - "rmir": 42, - "rmso": 43, - "rmul": 44, - "flash": 45, - "ff": 46, - "fsl": 47, - "is1": 48, - "is2": 49, - "is3": 50, - "if": 51, - "ich1": 52, - "il1": 53, - "ip": 54, - "kbs": 55, - "ktbc": 56, - "kclr": 57, - "kctab": 58, - "kdch1": 59, - "kdl1": 60, - "kcud1": 61, - "krmir": 62, - "kel": 63, - "ked": 64, - "kf0": 65, - "kf1": 66, - "kf10": 67, - "kf2": 68, - "kf3": 69, - "kf4": 70, - "kf5": 71, - "kf6": 72, - "kf7": 73, - "kf8": 74, - "kf9": 75, - "khome": 76, - "kich1": 77, - "kil1": 78, - "kcub1": 79, - "kll": 80, - "knp": 81, - "kpp": 82, - "kcuf1": 83, - "kind": 84, - "kri": 85, - "khts": 86, - "kcuu1": 87, - "rmkx": 88, - "smkx": 89, - "lf0": 90, - "lf1": 91, - "lf10": 92, - "lf2": 93, - "lf3": 94, - "lf4": 95, - "lf5": 96, - "lf6": 97, - "lf7": 98, - "lf8": 99, - "lf9": 100, - "rmm": 101, - "smm": 102, - "nel": 103, - "pad": 104, - "dch": 105, - "dl": 106, - "cud": 107, - "ich": 108, - "indn": 109, - "il": 110, - "cub": 111, - "cuf": 112, - "rin": 113, - "cuu": 114, - "pfkey": 115, - "pfloc": 116, - "pfx": 117, - "mc0": 118, - "mc4": 119, - "mc5": 120, - "rep": 121, - "rs1": 122, - "rs2": 123, - "rs3": 124, - "rf": 125, - "rc": 126, - "vpa": 127, - "sc": 128, - "ind": 129, - "ri": 130, - "sgr": 131, - "hts": 132, - "wind": 133, - "ht": 134, - "tsl": 135, - "uc": 136, - "hu": 137, - "iprog": 138, - "ka1": 139, - "ka3": 140, - "kb2": 141, - "kc1": 142, - "kc3": 143, - "mc5p": 144, - "rmp": 145, - "acsc": 146, - "pln": 147, - "kcbt": 148, - "smxon": 149, - "rmxon": 150, - "smam": 151, - "rmam": 152, - "xonc": 153, - "xoffc": 154, - "enacs": 155, - "smln": 156, - "rmln": 157, - "kbeg": 158, - "kcan": 159, - "kclo": 160, - "kcmd": 161, - "kcpy": 162, - "kcrt": 163, - "kend": 164, - "kent": 165, - "kext": 166, - "kfnd": 167, - "khlp": 168, - "kmrk": 169, - "kmsg": 170, - "kmov": 171, - "knxt": 172, - "kopn": 173, - "kopt": 174, - "kprv": 175, - "kprt": 176, - "krdo": 177, - "kref": 178, - "krfr": 179, - "krpl": 180, - "krst": 181, - "kres": 182, - "ksav": 183, - "kspd": 184, - "kund": 185, - "kBEG": 186, - "kCAN": 187, - "kCMD": 188, - "kCPY": 189, - "kCRT": 190, - "kDC": 191, - "kDL": 192, - "kslt": 193, - "kEND": 194, - "kEOL": 195, - "kEXT": 196, - "kFND": 197, - "kHLP": 198, - "kHOM": 199, - "kIC": 200, - "kLFT": 201, - "kMSG": 202, - "kMOV": 203, - "kNXT": 204, - "kOPT": 205, - "kPRV": 206, - "kPRT": 207, - "kRDO": 208, - "kRPL": 209, - "kRIT": 210, - "kRES": 211, - "kSAV": 212, - "kSPD": 213, - "kUND": 214, - "rfi": 215, - "kf11": 216, - "kf12": 217, - "kf13": 218, - "kf14": 219, - "kf15": 220, - "kf16": 221, - "kf17": 222, - "kf18": 223, - "kf19": 224, - "kf20": 225, - "kf21": 226, - "kf22": 227, - "kf23": 228, - "kf24": 229, - "kf25": 230, - "kf26": 231, - "kf27": 232, - "kf28": 233, - "kf29": 234, - "kf30": 235, - "kf31": 236, - "kf32": 237, - "kf33": 238, - "kf34": 239, - "kf35": 240, - "kf36": 241, - "kf37": 242, - "kf38": 243, - "kf39": 244, - "kf40": 245, - "kf41": 246, - "kf42": 247, - "kf43": 248, - "kf44": 249, - "kf45": 250, - "kf46": 251, - "kf47": 252, - "kf48": 253, - "kf49": 254, - "kf50": 255, - "kf51": 256, - "kf52": 257, - "kf53": 258, - "kf54": 259, - "kf55": 260, - "kf56": 261, - "kf57": 262, - "kf58": 263, - "kf59": 264, - "kf60": 265, - "kf61": 266, - "kf62": 267, - "kf63": 268, - "el1": 269, - "mgc": 270, - "smgl": 271, - "smgr": 272, - "fln": 273, - "sclk": 274, - "dclk": 275, - "rmclk": 276, - "cwin": 277, - "wingo": 278, - "hup": 279, - "dial": 280, - "qdial": 281, - "tone": 282, - "pulse": 283, - "hook": 284, - "pause": 285, - "wait": 286, - "u0": 287, - "u1": 288, - "u2": 289, - "u3": 290, - "u4": 291, - "u5": 292, - "u6": 293, - "u7": 294, - "u8": 295, - "u9": 296, - "op": 297, - "oc": 298, - "initc": 299, - "initp": 300, - "scp": 301, - "setf": 302, - "setb": 303, - "cpi": 304, - "lpi": 305, - "chr": 306, - "cvr": 307, - "defc": 308, - "swidm": 309, - "sdrfq": 310, - "sitm": 311, - "slm": 312, - "smicm": 313, - "snlq": 314, - "snrmq": 315, - "sshm": 316, - "ssubm": 317, - "ssupm": 318, - "sum": 319, - "rwidm": 320, - "ritm": 321, - "rlm": 322, - "rmicm": 323, - "rshm": 324, - "rsubm": 325, - "rsupm": 326, - "rum": 327, - "mhpa": 328, - "mcud1": 329, - "mcub1": 330, - "mcuf1": 331, - "mvpa": 332, - "mcuu1": 333, - "porder": 334, - "mcud": 335, - "mcub": 336, - "mcuf": 337, - "mcuu": 338, - "scs": 339, - "smgb": 340, - "smgbp": 341, - "smglp": 342, - "smgrp": 343, - "smgt": 344, - "smgtp": 345, - "sbim": 346, - "scsd": 347, - "rbim": 348, - "rcsd": 349, - "subcs": 350, - "supcs": 351, - "docr": 352, - "zerom": 353, - "csnm": 354, - "kmous": 355, - "minfo": 356, - "reqmp": 357, - "getm": 358, - "setaf": 359, - "setab": 360, - "pfxl": 361, - "devt": 362, - "csin": 363, - "s0ds": 364, - "s1ds": 365, - "s2ds": 366, - "s3ds": 367, - "smglr": 368, - "smgtb": 369, - "birep": 370, - "binel": 371, - "bicr": 372, - "colornm": 373, - "defbi": 374, - "endbi": 375, - "setcolor": 376, - "slines": 377, - "dispc": 378, - "smpch": 379, - "rmpch": 380, - "smsc": 381, - "rmsc": 382, - "pctrm": 383, - "scesc": 384, - "scesa": 385, - "ehhlm": 386, - "elhlm": 387, - "elohlm": 388, - "erhlm": 389, - "ethlm": 390, - "evhlm": 391, - "sgr1": 392, - "slength": 393, - "OTi2": 394, - "OTrs": 395, - "OTnl": 396, - "OTbc": 397, - "OTko": 398, - "OTma": 399, - "OTG2": 400, - "OTG3": 401, - "OTG1": 402, - "OTG4": 403, - "OTGR": 404, - "OTGL": 405, - "OTGU": 406, - "OTGD": 407, - "OTGH": 408, - "OTGV": 409, - "OTGC": 410, - "meml": 411, - "memu": 412, - "box1": 413, -} - -# Reverse mapping for standard capabilities -_STRING_NAMES: list[str | None] = [None] * 414 # Standard string capabilities - -for name, idx in _STRING_CAPABILITY_NAMES.items(): - if idx < len(_STRING_NAMES): - _STRING_NAMES[idx] = name +# fmt: off +_STRING_NAMES: tuple[str, ...] = ( + "cbt", "bel", "cr", "csr", "tbc", "clear", "el", "ed", "hpa", "cmdch", + "cup", "cud1", "home", "civis", "cub1", "mrcup", "cnorm", "cuf1", "ll", + "cuu1", "cvvis", "dch1", "dl1", "dsl", "hd", "smacs", "blink", "bold", + "smcup", "smdc", "dim", "smir", "invis", "prot", "rev", "smso", "smul", + "ech", "rmacs", "sgr0", "rmcup", "rmdc", "rmir", "rmso", "rmul", "flash", + "ff", "fsl", "is1", "is2", "is3", "if", "ich1", "il1", "ip", "kbs", "ktbc", + "kclr", "kctab", "kdch1", "kdl1", "kcud1", "krmir", "kel", "ked", "kf0", + "kf1", "kf10", "kf2", "kf3", "kf4", "kf5", "kf6", "kf7", "kf8", "kf9", + "khome", "kich1", "kil1", "kcub1", "kll", "knp", "kpp", "kcuf1", "kind", + "kri", "khts", "kcuu1", "rmkx", "smkx", "lf0", "lf1", "lf10", "lf2", "lf3", + "lf4", "lf5", "lf6", "lf7", "lf8", "lf9", "rmm", "smm", "nel", "pad", "dch", + "dl", "cud", "ich", "indn", "il", "cub", "cuf", "rin", "cuu", "pfkey", + "pfloc", "pfx", "mc0", "mc4", "mc5", "rep", "rs1", "rs2", "rs3", "rf", "rc", + "vpa", "sc", "ind", "ri", "sgr", "hts", "wind", "ht", "tsl", "uc", "hu", + "iprog", "ka1", "ka3", "kb2", "kc1", "kc3", "mc5p", "rmp", "acsc", "pln", + "kcbt", "smxon", "rmxon", "smam", "rmam", "xonc", "xoffc", "enacs", "smln", + "rmln", "kbeg", "kcan", "kclo", "kcmd", "kcpy", "kcrt", "kend", "kent", + "kext", "kfnd", "khlp", "kmrk", "kmsg", "kmov", "knxt", "kopn", "kopt", + "kprv", "kprt", "krdo", "kref", "krfr", "krpl", "krst", "kres", "ksav", + "kspd", "kund", "kBEG", "kCAN", "kCMD", "kCPY", "kCRT", "kDC", "kDL", + "kslt", "kEND", "kEOL", "kEXT", "kFND", "kHLP", "kHOM", "kIC", "kLFT", + "kMSG", "kMOV", "kNXT", "kOPT", "kPRV", "kPRT", "kRDO", "kRPL", "kRIT", + "kRES", "kSAV", "kSPD", "kUND", "rfi", "kf11", "kf12", "kf13", "kf14", + "kf15", "kf16", "kf17", "kf18", "kf19", "kf20", "kf21", "kf22", "kf23", + "kf24", "kf25", "kf26", "kf27", "kf28", "kf29", "kf30", "kf31", "kf32", + "kf33", "kf34", "kf35", "kf36", "kf37", "kf38", "kf39", "kf40", "kf41", + "kf42", "kf43", "kf44", "kf45", "kf46", "kf47", "kf48", "kf49", "kf50", + "kf51", "kf52", "kf53", "kf54", "kf55", "kf56", "kf57", "kf58", "kf59", + "kf60", "kf61", "kf62", "kf63", "el1", "mgc", "smgl", "smgr", "fln", "sclk", + "dclk", "rmclk", "cwin", "wingo", "hup","dial", "qdial", "tone", "pulse", + "hook", "pause", "wait", "u0", "u1", "u2", "u3", "u4", "u5", "u6", "u7", + "u8", "u9", "op", "oc", "initc", "initp", "scp", "setf", "setb", "cpi", + "lpi", "chr", "cvr", "defc", "swidm", "sdrfq", "sitm", "slm", "smicm", + "snlq", "snrmq", "sshm", "ssubm", "ssupm", "sum", "rwidm", "ritm", "rlm", + "rmicm", "rshm", "rsubm", "rsupm", "rum", "mhpa", "mcud1", "mcub1", "mcuf1", + "mvpa", "mcuu1", "porder", "mcud", "mcub", "mcuf", "mcuu", "scs", "smgb", + "smgbp", "smglp", "smgrp", "smgt", "smgtp", "sbim", "scsd", "rbim", "rcsd", + "subcs", "supcs", "docr", "zerom", "csnm", "kmous", "minfo", "reqmp", + "getm", "setaf", "setab", "pfxl", "devt", "csin", "s0ds", "s1ds", "s2ds", + "s3ds", "smglr", "smgtb", "birep", "binel", "bicr", "colornm", "defbi", + "endbi", "setcolor", "slines", "dispc", "smpch", "rmpch", "smsc", "rmsc", + "pctrm", "scesc", "scesa", "ehhlm", "elhlm", "elohlm", "erhlm", "ethlm", + "evhlm", "sgr1", "slength", "OTi2", "OTrs", "OTnl", "OTbc", "OTko", "OTma", + "OTG2", "OTG3", "OTG1", "OTG4", "OTGR", "OTGL", "OTGU", "OTGD", "OTGH", + "OTGV", "OTGC","meml", "memu", "box1" +) +# fmt: on +_STRING_CAPABILITY_NAMES = {name: i for i, name in enumerate(_STRING_NAMES)} def _get_terminfo_dirs() -> list[Path]: From 7aa2ec88b0b7fc494c2599e21b7745e80c8e5f6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Langa?= Date: Sat, 19 Jul 2025 14:05:48 +0200 Subject: [PATCH 09/12] Parse the terminfo file header in one go --- Lib/_pyrepl/terminfo.py | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/Lib/_pyrepl/terminfo.py b/Lib/_pyrepl/terminfo.py index e8466f7f291edd..8ba564342557aa 100644 --- a/Lib/_pyrepl/terminfo.py +++ b/Lib/_pyrepl/terminfo.py @@ -354,11 +354,14 @@ def _parse_terminfo_file(self, terminal_name: str) -> None: """ data = _read_terminfo_file(terminal_name) too_short = f"TermInfo file for {terminal_name!r} too short" - offset = 0 - if len(data) < 12: + offset = 12 + if len(data) < offset: raise ValueError(too_short) - magic = struct.unpack(" None: f"TermInfo file for {terminal_name!r} uses unknown magic" ) - # Parse header - name_size = struct.unpack(" len(data): raise ValueError(too_short) From 9db38c79f910c766bba15195102ed290217ca54f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Langa?= Date: Sat, 19 Jul 2025 14:46:18 +0200 Subject: [PATCH 10/12] Make terminal name validation more comprehensive --- Lib/_pyrepl/terminfo.py | 22 ++++++++++++++++------ Lib/test/test_pyrepl/test_terminfo.py | 13 +++++++++++++ 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/Lib/_pyrepl/terminfo.py b/Lib/_pyrepl/terminfo.py index 8ba564342557aa..cedc1789574d85 100644 --- a/Lib/_pyrepl/terminfo.py +++ b/Lib/_pyrepl/terminfo.py @@ -112,18 +112,28 @@ def _get_terminfo_dirs() -> list[Path]: return [Path(d) for d in dirs if Path(d).is_dir()] -def _read_terminfo_file(terminal_name: str) -> bytes: - """Find and read terminfo file for given terminal name. - - Terminfo files are stored in directories using the first character - of the terminal name as a subdirectory. - """ +def _validate_terminal_name_or_raise(terminal_name: str) -> None: if not isinstance(terminal_name, str): raise TypeError("`terminal_name` must be a string") if not terminal_name: raise ValueError("`terminal_name` cannot be empty") + if "\x00" in terminal_name: + raise ValueError("NUL character found in `terminal_name`") + + t = Path(terminal_name) + if len(t.parts) > 1: + raise ValueError("`terminal_name` cannot contain path separators") + + +def _read_terminfo_file(terminal_name: str) -> bytes: + """Find and read terminfo file for given terminal name. + + Terminfo files are stored in directories using the first character + of the terminal name as a subdirectory. + """ + _validate_terminal_name_or_raise(terminal_name) first_char = terminal_name[0].lower() filename = terminal_name diff --git a/Lib/test/test_pyrepl/test_terminfo.py b/Lib/test/test_pyrepl/test_terminfo.py index d71d607a1bd2d9..ce3d36de085716 100644 --- a/Lib/test/test_pyrepl/test_terminfo.py +++ b/Lib/test/test_pyrepl/test_terminfo.py @@ -652,3 +652,16 @@ def test_terminfo_fallback(self): self.assertIsNotNone( bel, "PyREPL should provide basic capabilities after fallback" ) + + def test_invalid_terminal_names(self): + cases = [ + (42, TypeError), + ("", ValueError), + ("w\x00t", ValueError), + (f"..{os.sep}name", ValueError), + ] + + for term, exc in cases: + with self.subTest(term=term): + with self.assertRaises(exc): + terminfo._validate_terminal_name_or_raise(term) From 2a053c557ed28eacb5d281290d9d60c28973c720 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Langa?= Date: Sat, 19 Jul 2025 15:53:47 +0200 Subject: [PATCH 11/12] Scan all paths suggested by capconvert --- Lib/_pyrepl/terminfo.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Lib/_pyrepl/terminfo.py b/Lib/_pyrepl/terminfo.py index cedc1789574d85..f3cae21c34bd14 100644 --- a/Lib/_pyrepl/terminfo.py +++ b/Lib/_pyrepl/terminfo.py @@ -102,10 +102,14 @@ def _get_terminfo_dirs() -> list[Path]: dirs.extend( [ + "/etc/terminfo", + "/lib/terminfo", + "/usr/lib/terminfo", "/usr/share/terminfo", + "/usr/share/lib/terminfo", "/usr/share/misc/terminfo", + "/usr/local/lib/terminfo", "/usr/local/share/terminfo", - "/etc/terminfo", ] ) From d080941653770abfb52acb724b9ea397252a5e2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Langa?= Date: Sun, 20 Jul 2025 12:48:55 +0200 Subject: [PATCH 12/12] Don't run curses compatibility tests when terminfo not present --- Lib/_pyrepl/terminfo.py | 36 ++++---- Lib/test/test_pyrepl/test_terminfo.py | 116 +++++++++++--------------- 2 files changed, 68 insertions(+), 84 deletions(-) diff --git a/Lib/_pyrepl/terminfo.py b/Lib/_pyrepl/terminfo.py index f3cae21c34bd14..063a285bb9900c 100644 --- a/Lib/_pyrepl/terminfo.py +++ b/Lib/_pyrepl/terminfo.py @@ -167,8 +167,8 @@ def _read_terminfo_file(terminal_name: str) -> bytes: "cud": b"\x1b[%p1%dB", # Move cursor down N rows "cuf": b"\x1b[%p1%dC", # Move cursor right N columns "cuu": b"\x1b[%p1%dA", # Move cursor up N rows - "cub1": b"\x1b[D", # Move cursor left 1 column - "cud1": b"\x1b[B", # Move cursor down 1 row + "cub1": b"\x08", # Move cursor left 1 column + "cud1": b"\n", # Move cursor down 1 row "cuf1": b"\x1b[C", # Move cursor right 1 column "cuu1": b"\x1b[A", # Move cursor up 1 row "cup": b"\x1b[%i%p1%d;%p2%dH", # Move cursor to row, column @@ -180,10 +180,10 @@ def _read_terminfo_file(terminal_name: str) -> bytes: "dch": b"\x1b[%p1%dP", # Delete N characters "dch1": b"\x1b[P", # Delete 1 character "ich": b"\x1b[%p1%d@", # Insert N characters - "ich1": b"\x1b[@", # Insert 1 character + "ich1": b"", # Insert 1 character # Cursor visibility "civis": b"\x1b[?25l", # Make cursor invisible - "cnorm": b"\x1b[?25h", # Make cursor normal (visible) + "cnorm": b"\x1b[?12l\x1b[?25h", # Make cursor normal (visible) # Scrolling "ind": b"\n", # Scroll up one line "ri": b"\x1bM", # Scroll down one line @@ -194,16 +194,16 @@ def _read_terminfo_file(terminal_name: str) -> bytes: "pad": b"", # Function keys and special keys "kdch1": b"\x1b[3~", # Delete key - "kcud1": b"\x1b[B", # Down arrow - "kend": b"\x1b[F", # End key + "kcud1": b"\x1bOB", # Down arrow + "kend": b"\x1bOF", # End key "kent": b"\x1bOM", # Enter key - "khome": b"\x1b[H", # Home key + "khome": b"\x1bOH", # Home key "kich1": b"\x1b[2~", # Insert key - "kcub1": b"\x1b[D", # Left arrow + "kcub1": b"\x1bOD", # Left arrow "knp": b"\x1b[6~", # Page down "kpp": b"\x1b[5~", # Page up - "kcuf1": b"\x1b[C", # Right arrow - "kcuu1": b"\x1b[A", # Up arrow + "kcuf1": b"\x1bOC", # Right arrow + "kcuu1": b"\x1bOA", # Up arrow # Function keys F1-F20 "kf1": b"\x1bOP", "kf2": b"\x1bOQ", @@ -217,14 +217,14 @@ def _read_terminfo_file(terminal_name: str) -> bytes: "kf10": b"\x1b[21~", "kf11": b"\x1b[23~", "kf12": b"\x1b[24~", - "kf13": b"\x1b[25~", - "kf14": b"\x1b[26~", - "kf15": b"\x1b[28~", - "kf16": b"\x1b[29~", - "kf17": b"\x1b[31~", - "kf18": b"\x1b[32~", - "kf19": b"\x1b[33~", - "kf20": b"\x1b[34~", + "kf13": b"\x1b[1;2P", + "kf14": b"\x1b[1;2Q", + "kf15": b"\x1b[1;2R", + "kf16": b"\x1b[1;2S", + "kf17": b"\x1b[15;2~", + "kf18": b"\x1b[17;2~", + "kf19": b"\x1b[18;2~", + "kf20": b"\x1b[19;2~", }, # Dumb terminal - minimal capabilities "dumb": { diff --git a/Lib/test/test_pyrepl/test_terminfo.py b/Lib/test/test_pyrepl/test_terminfo.py index ce3d36de085716..562cf5c905bd67 100644 --- a/Lib/test/test_pyrepl/test_terminfo.py +++ b/Lib/test/test_pyrepl/test_terminfo.py @@ -33,7 +33,8 @@ class TestCursesCompatibility(unittest.TestCase): $TERM in the same process, so we subprocess all `curses` tests to get correctly set up terminfo.""" - def setUp(self): + @classmethod + def setUpClass(cls): if _curses is None: raise unittest.SkipTest( "`curses` capability provided to regrtest but `_curses` not importable" @@ -42,6 +43,11 @@ def setUp(self): if not has_subprocess_support: raise unittest.SkipTest("test module requires subprocess") + # we need to ensure there's a terminfo database on the system and that + # `infocmp` works + cls.infocmp("dumb") + + def setUp(self): self.original_term = os.environ.get("TERM", None) def tearDown(self): @@ -50,6 +56,34 @@ def tearDown(self): elif "TERM" in os.environ: del os.environ["TERM"] + @classmethod + def infocmp(cls, term) -> list[str]: + all_caps = [] + try: + result = subprocess.run( + ["infocmp", "-l1", term], + capture_output=True, + text=True, + check=True, + ) + except Exception: + raise unittest.SkipTest("calling `infocmp` failed on the system") + + for line in result.stdout.splitlines(): + line = line.strip() + if line.startswith("#"): + if "terminfo" not in line and "termcap" in line: + # PyREPL terminfo doesn't parse termcap databases + raise unittest.SkipTest( + "curses using termcap.db: no terminfo database on" + " the system" + ) + elif "=" in line: + cap_name = line.split("=")[0] + all_caps.append(cap_name) + + return all_caps + def test_setupterm_basic(self): """Test basic setupterm functionality.""" # Test with explicit terminal type @@ -79,7 +113,7 @@ def test_setupterm_basic(self): # Set up with PyREPL curses try: - terminfo.TermInfo(term) + terminfo.TermInfo(term, fallback=False) pyrepl_success = True except Exception as e: pyrepl_success = False @@ -120,7 +154,7 @@ def test_setupterm_none(self): std_success = ncurses_data["success"] try: - terminfo.TermInfo(None) + terminfo.TermInfo(None, fallback=False) pyrepl_success = True except Exception: pyrepl_success = False @@ -138,28 +172,7 @@ def test_tigetstr_common_capabilities(self): term = "xterm" # Get ALL capabilities from infocmp - all_caps = [] - try: - result = subprocess.run( - ["infocmp", "-1", term], - capture_output=True, - text=True, - check=True, - ) - for line in result.stdout.splitlines(): - line = line.strip() - if "=" in line and not line.startswith("#"): - cap_name = line.split("=")[0] - all_caps.append(cap_name) - except: - # If infocmp fails, at least test the critical ones - # fmt: off - all_caps = [ - "cup", "clear", "el", "cub1", "cuf1", "cuu1", "cud1", "bel", - "ind", "ri", "civis", "cnorm", "smkx", "rmkx", "cub", "cuf", - "cud", "cuu", "home", "hpa", "vpa", "cr", "nel", "ht" - ] - # fmt: on + all_caps = self.infocmp(term) ncurses_code = dedent( f""" @@ -176,7 +189,7 @@ def test_tigetstr_common_capabilities(self): results[cap] = -1 else: results[cap] = list(val) - except: + except BaseException: results[cap] = "error" print(json.dumps(results)) """ @@ -193,7 +206,7 @@ def test_tigetstr_common_capabilities(self): ncurses_data = json.loads(result.stdout) - ti = terminfo.TermInfo(term) + ti = terminfo.TermInfo(term, fallback=False) # Test every single capability for cap in all_caps: @@ -255,7 +268,7 @@ def test_tigetstr_input_types(self): ncurses_data = json.loads(result.stdout) # PyREPL setup - ti = terminfo.TermInfo(term) + ti = terminfo.TermInfo(term, fallback=False) # PyREPL behavior with string try: @@ -281,7 +294,7 @@ def test_tigetstr_input_types(self): def test_tparm_basic(self): """Test basic tparm functionality.""" term = "xterm" - ti = terminfo.TermInfo(term) + ti = terminfo.TermInfo(term, fallback=False) # Test cursor positioning (cup) cup = ti.get("cup") @@ -357,7 +370,7 @@ def test_tparm_basic(self): def test_tparm_multiple_params(self): """Test tparm with capabilities using multiple parameters.""" term = "xterm" - ti = terminfo.TermInfo(term) + ti = terminfo.TermInfo(term, fallback=False) # Test capabilities that take parameters param_caps = { @@ -472,7 +485,7 @@ def test_tparm_null_handling(self): ncurses_data = json.loads(result.stdout) # PyREPL setup - ti = terminfo.TermInfo(term) + ti = terminfo.TermInfo(term, fallback=False) # Test with None - both should raise TypeError if ncurses_data["raises_typeerror"]: @@ -496,38 +509,9 @@ def test_special_terminals(self): ] # Get all string capabilities from ncurses - all_caps = [] - try: - # Get all capability names from infocmp - result = subprocess.run( - ["infocmp", "-1", "xterm"], - capture_output=True, - text=True, - check=True, - ) - for line in result.stdout.splitlines(): - line = line.strip() - if "=" in line: - cap_name = line.split("=")[0] - all_caps.append(cap_name) - except: - # Fall back to a core set if infocmp fails - # fmt: off - all_caps = [ - "cup", "clear", "el", "cub", "cuf", "cud", "cuu", "cub1", - "cuf1", "cud1", "cuu1", "home", "bel", "ind", "ri", "nel", "cr", - "ht", "hpa", "vpa", "dch", "dch1", "dl", "dl1", "ich", "ich1", - "il", "il1", "sgr0", "smso", "rmso", "smul", "rmul", "bold", - "rev", "blink", "dim", "smacs", "rmacs", "civis", "cnorm", "sc", - "rc", "hts", "tbc", "ed", "kbs", "kcud1", "kcub1", "kcuf1", - "kcuu1", "kdch1", "khome", "kend", "knp", "kpp", "kich1", "kf1", - "kf2", "kf3", "kf4", "kf5", "kf6", "kf7", "kf8", "kf9", "kf10", - "rmkx", "smkx" - ] - # fmt: on - for term in special_terms: with self.subTest(term=term): + all_caps = self.infocmp(term) ncurses_code = dedent( f""" import _curses @@ -547,7 +531,7 @@ def test_special_terminals(self): else: # Convert bytes to list of ints for JSON results[cap] = list(val) - except: + except BaseException: results[cap] = "error" print(json.dumps(results)) except Exception as e: @@ -576,10 +560,10 @@ def test_special_terminals(self): if "error" in ncurses_data and len(ncurses_data) == 1: # ncurses failed to setup this terminal # PyREPL should still work with fallback - ti = terminfo.TermInfo(term) + ti = terminfo.TermInfo(term, fallback=True) continue - ti = terminfo.TermInfo(term) + ti = terminfo.TermInfo(term, fallback=False) # Compare all capabilities for cap in all_caps: @@ -638,9 +622,9 @@ def test_terminfo_fallback(self): # PyREPL should succeed with fallback try: - ti = terminfo.TermInfo(fake_term) + ti = terminfo.TermInfo(fake_term, fallback=True) pyrepl_ok = True - except: + except Exception: pyrepl_ok = False self.assertTrue(