[3.14] gh-135621: Remove dependency on curses from PyREPL (GH-136758) (GH-136915)

(cherry picked from commit 09dfb50f1b)

Co-authored-by: Łukasz Langa <lukasz@langa.pl>
This commit is contained in:
Miss Islington (bot) 2025-07-21 13:02:41 +02:00 committed by GitHub
parent 4f6f3ee8d3
commit 031645a884
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 1229 additions and 160 deletions

View file

@ -0,0 +1,651 @@
"""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, has_subprocess_support
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
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.
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."""
@classmethod
def setUpClass(cls):
if _curses is None:
raise unittest.SkipTest(
"`curses` capability provided to regrtest but `_curses` not importable"
)
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):
if self.original_term is not None:
os.environ["TERM"] = self.original_term
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
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, fallback=False)
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, fallback=False)
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 = self.infocmp(term)
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 BaseException:
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, fallback=False)
# 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, fallback=False)
# 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, fallback=False)
# Test cursor positioning (cup)
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
(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, fallback=False)
# 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 not in {
ABSENT_STRING,
CANCELLED_STRING,
}:
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, fallback=False)
# 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
for term in special_terms:
with self.subTest(term=term):
all_caps = self.infocmp(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 BaseException:
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, fallback=True)
continue
ti = terminfo.TermInfo(term, fallback=False)
# 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, fallback=True)
pyrepl_ok = True
except Exception:
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"
)
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)