mirror of
https://github.com/python/cpython.git
synced 2025-12-08 06:10:17 +00:00
[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:
parent
4f6f3ee8d3
commit
031645a884
11 changed files with 1229 additions and 160 deletions
651
Lib/test/test_pyrepl/test_terminfo.py
Normal file
651
Lib/test/test_pyrepl/test_terminfo.py
Normal 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue