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