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( pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy