Skip to content

gh-135621: Remove dependency on curses from PyREPL #136758

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 12 commits into from
Jul 21, 2025
Prev Previous commit
Don't run curses compatibility tests when terminfo not present
  • Loading branch information
ambv committed Jul 20, 2025
commit d080941653770abfb52acb724b9ea397252a5e2b
36 changes: 18 additions & 18 deletions Lib/_pyrepl/terminfo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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",
Expand All @@ -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": {
Expand Down
116 changes: 50 additions & 66 deletions Lib/test/test_pyrepl/test_terminfo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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):
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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"""
Expand All @@ -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))
"""
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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")
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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"]:
Expand All @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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(
Expand Down
Loading
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