cpython/Lib/test/test_pyrepl/test_terminfo.py

652 lines
22 KiB
Python
Raw Normal View History

"""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)