Skip to content

Commit 0028058

Browse files
gh-90092: Support multiple terminals in the curses module
Add the X/Open Curses SCREEN API for driving more than one terminal: newterm() and set_term(), plus the ncurses extension new_prescr(). A new screen object wraps the C SCREEN. It exposes the terminal's standard window as screen.stdscr. Each window keeps a reference to its screen (like a subwindow does to its parent window), so the screen is deleted automatically once it and all of its windows are unreferenced. The ncurses use_screen()/use_window() locking helpers are exposed as the screen.use() and window.use() methods. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 2e5843e commit 0028058

11 files changed

Lines changed: 1124 additions & 78 deletions

File tree

Doc/library/curses.rst

Lines changed: 96 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -207,8 +207,9 @@ The module :mod:`!curses` defines the following functions:
207207
.. function:: nofilter()
208208

209209
Undo the effect of a previous :func:`.filter` call.
210-
Like :func:`.filter`, it must be called before :func:`initscr` so that the
211-
next initialization uses the full screen again.
210+
Like :func:`.filter`, it must be called before :func:`initscr` (or
211+
:func:`newterm`) so that the next initialization uses the full screen
212+
again.
212213

213214
Availability: if the underlying curses library provides ``nofilter()``.
214215

@@ -442,6 +443,36 @@ The module :mod:`!curses` defines the following functions:
442443
right corner of the screen.
443444

444445

446+
.. function:: newterm(type=None, fd=None, infd=None, /)
447+
448+
Initialize a new terminal in addition to the one initialized by
449+
:func:`initscr`,
450+
and return a :ref:`screen <curses-screen-objects>` for it.
451+
This allows a program to drive more than one terminal.
452+
453+
*type* is the terminal name, as in :func:`setupterm`;
454+
if ``None``, the value of the :envvar:`TERM` environment variable is used.
455+
*fd* and *infd* are the output and input files for the terminal:
456+
either a file object or a file descriptor.
457+
They default to :data:`sys.stdout` and :data:`sys.stdin`.
458+
459+
The new screen becomes the current one.
460+
Use :func:`set_term` to switch between screens.
461+
462+
.. versionadded:: next
463+
464+
465+
.. function:: new_prescr()
466+
467+
Return a new :ref:`screen <curses-screen-objects>`
468+
that can be used to call functions that affect global state
469+
before :func:`initscr` or :func:`newterm` is called.
470+
471+
Availability: if the underlying curses library provides ``new_prescr()``.
472+
473+
.. versionadded:: next
474+
475+
445476
.. function:: nl(flag=True)
446477

447478
Enter newline mode. This mode translates the return key into newline on input,
@@ -586,6 +617,17 @@ The module :mod:`!curses` defines the following functions:
586617

587618
.. versionadded:: 3.9
588619

620+
621+
.. function:: set_term(screen, /)
622+
623+
Make *screen*, a :ref:`screen <curses-screen-objects>` returned by
624+
:func:`newterm`, the current terminal,
625+
and return the previously current screen.
626+
Returns ``None`` if the previous screen was the one created by
627+
:func:`initscr`.
628+
629+
.. versionadded:: next
630+
589631
.. function:: setsyx(y, x)
590632

591633
Set the virtual screen cursor to *y*, *x*. If *y* and *x* are both ``-1``, then
@@ -1380,13 +1422,65 @@ Window objects
13801422
:meth:`refresh`.
13811423

13821424

1425+
.. method:: window.use(func, /, *args, **kwargs)
1426+
1427+
Call ``func(window, *args, **kwargs)`` with the lock of the window held,
1428+
and return its result.
1429+
This provides automatic protection for the window
1430+
against concurrent access from another thread.
1431+
1432+
Availability: if the underlying curses library provides ``use_window()``.
1433+
1434+
.. versionadded:: next
1435+
1436+
13831437
.. method:: window.vline(ch, n[, attr])
13841438
window.vline(y, x, ch, n[, attr])
13851439

13861440
Display a vertical line starting at ``(y, x)`` with length *n* consisting of the
13871441
character *ch* with attributes *attr*.
13881442

13891443

1444+
.. _curses-screen-objects:
1445+
1446+
Screen objects
1447+
--------------
1448+
1449+
.. class:: screen
1450+
1451+
A *screen* object represents a terminal initialized by :func:`newterm`
1452+
(or :func:`new_prescr`),
1453+
in addition to the default screen created by :func:`initscr`.
1454+
Screen objects are returned by those functions;
1455+
they cannot be instantiated directly.
1456+
1457+
A screen is freed automatically once it is no longer referenced,
1458+
either directly or through one of its windows.
1459+
Each window keeps its screen alive,
1460+
so a screen remains valid as long as any of its windows does.
1461+
1462+
.. versionadded:: next
1463+
1464+
1465+
.. attribute:: screen.stdscr
1466+
1467+
The standard :ref:`window <curses-window-objects>` of the screen,
1468+
covering the whole terminal,
1469+
or ``None`` for a screen created by :func:`new_prescr`.
1470+
1471+
1472+
.. method:: screen.use(func, /, *args, **kwargs)
1473+
1474+
Call ``func(screen, *args, **kwargs)`` with the lock of the screen held,
1475+
and return its result.
1476+
This provides automatic protection for the screen
1477+
against concurrent access from another thread.
1478+
1479+
Availability: if the underlying curses library provides ``use_screen()``.
1480+
1481+
.. versionadded:: next
1482+
1483+
13901484
Constants
13911485
---------
13921486

Doc/whatsnew/3.16.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,13 @@ Improved modules
8989
curses
9090
------
9191

92+
* Add support for multiple terminals to the :mod:`curses` module:
93+
the new functions :func:`curses.newterm`, :func:`curses.set_term`
94+
and :func:`curses.new_prescr`,
95+
the corresponding :ref:`screen <curses-screen-objects>` object,
96+
and the :meth:`window.use` method.
97+
(Contributed by Serhiy Storchaka in :gh:`90092`.)
98+
9299
* Add :func:`curses.nofilter`, which undoes the effect of :func:`curses.filter`.
93100
(Contributed by Serhiy Storchaka in :gh:`151744`.)
94101

Include/py_curses.h

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,8 +80,18 @@ typedef struct PyCursesWindowObject {
8080
WINDOW *win;
8181
char *encoding;
8282
struct PyCursesWindowObject *orig;
83+
PyObject *screen; /* the screen the window belongs to, or NULL,
84+
kept alive for the lifetime of the window */
8385
} PyCursesWindowObject;
8486

87+
typedef struct {
88+
PyObject_HEAD
89+
SCREEN *screen; /* NULL after the screen has been deleted */
90+
FILE *outfp; /* owned output stream, or NULL */
91+
FILE *infp; /* owned input stream, or NULL */
92+
PyObject *stdscr; /* the screen's standard window, or NULL */
93+
} PyCursesScreenObject;
94+
8595
#define PyCurses_CAPSULE_NAME "_curses._C_API"
8696

8797

Lib/curses/__init__.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,23 @@ def initscr():
3434
setattr(curses, key, value)
3535
return stdscr
3636

37+
# newterm() is wrapped for the same reason as initscr(): the ACS_* constants
38+
# and LINES/COLS only become available once a terminal is initialized, and are
39+
# then copied to the curses package's dictionary.
40+
41+
try:
42+
newterm
43+
except NameError:
44+
pass
45+
else:
46+
def newterm(type=None, fd=None, infd=None, /):
47+
import _curses, curses
48+
screen = _curses.newterm(type, fd, infd)
49+
for key, value in _curses.__dict__.items():
50+
if key.startswith('ACS_') or key in ('LINES', 'COLS'):
51+
setattr(curses, key, value)
52+
return screen
53+
3754
# This is a similar wrapper for start_color(), which adds the COLORS and
3855
# COLOR_PAIRS variables which are only available after start_color() is
3956
# called.

Lib/test/test_curses.py

Lines changed: 144 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -53,9 +53,11 @@ def wrapped(self, *args, **kwargs):
5353
term = os.environ.get('TERM')
5454
SHORT_MAX = 0x7fff
5555

56-
# If newterm was supported we could use it instead of initscr and not exit
56+
# newterm() is used when available (it reports errors instead of exiting), but
57+
# initscr() is still the fallback, and an unusable $TERM has no terminal to
58+
# drive either way.
5759
@unittest.skipIf(not term or term == 'unknown',
58-
"$TERM=%r, calling initscr() may cause exit" % term)
60+
"$TERM=%r, no usable terminal" % term)
5961
@unittest.skipIf(sys.platform == "cygwin",
6062
"cygwin's curses mostly just hangs")
6163
class TestCurses(unittest.TestCase):
@@ -110,7 +112,23 @@ def setUp(self):
110112
sys.stderr.flush()
111113
sys.stdout.flush()
112114
print(file=self.output, flush=True)
113-
self.stdscr = curses.initscr()
115+
if hasattr(curses, 'newterm'):
116+
# Use newterm() rather than initscr(): it reports errors instead of
117+
# exiting, and gives each test a fresh screen, which also lets
118+
# ScreenTests run newterm()/set_term() in the same process.
119+
try:
120+
infd = sys.__stdin__.fileno()
121+
except (AttributeError, ValueError, OSError):
122+
infd = stdout_fd
123+
self.screen = curses.newterm(term, stdout_fd, infd)
124+
self.stdscr = self.screen.stdscr
125+
# Drop the screen after the test so the screens do not pile up: a
126+
# window keeps its screen alive through a reference cycle, and
127+
# unittest keeps every test instance for the whole run.
128+
self.addCleanup(setattr, self, 'screen', None)
129+
self.addCleanup(setattr, self, 'stdscr', None)
130+
else:
131+
self.stdscr = curses.initscr()
114132
if self.isatty:
115133
curses.savetty()
116134
self.addCleanup(curses.endwin)
@@ -119,10 +137,12 @@ def setUp(self):
119137

120138
@requires_curses_func('filter')
121139
def test_filter(self):
122-
# TODO: Should be called before initscr() or newterm() are called.
140+
# filter() must be called before initscr()/newterm(); it confines
141+
# curses to a single line. Undo it with nofilter() afterwards so that
142+
# it does not shrink the screens created by later tests.
123143
curses.filter()
124144
if hasattr(curses, 'nofilter'):
125-
curses.nofilter()
145+
self.addCleanup(curses.nofilter)
126146

127147
@requires_curses_func('use_env')
128148
def test_use_env(self):
@@ -1089,6 +1109,22 @@ def test_use_default_colors(self):
10891109
self.skipTest('cannot change color (use_default_colors() failed)')
10901110
self.assertEqual(curses.pair_content(0), (-1, -1))
10911111

1112+
@requires_curses_window_meth('use')
1113+
def test_use_window(self):
1114+
win = self.stdscr
1115+
self.assertEqual(win.use(lambda w, a, b: (w is win, a, b), 5, b=6),
1116+
(True, 5, 6))
1117+
with self.assertRaises(ZeroDivisionError):
1118+
win.use(lambda w: 1 / 0)
1119+
1120+
@unittest.skipUnless(hasattr(curses.screen, 'use'),
1121+
'requires screen.use()')
1122+
def test_use_screen(self):
1123+
screen = self.screen
1124+
self.assertEqual(
1125+
screen.use(lambda sc, flag: (sc is screen, flag), flag=True),
1126+
(True, True))
1127+
10921128
@requires_curses_func('assume_default_colors')
10931129
@requires_colors
10941130
def test_assume_default_colors(self):
@@ -1387,9 +1423,11 @@ def test_resize_term(self):
13871423
curses.resize_term(35000, 1)
13881424
with self.assertRaises(OverflowError):
13891425
curses.resize_term(1, 35000)
1390-
# GH-120378: Overflow failure in resize_term() causes refresh to fail
1391-
tmp = curses.initscr()
1392-
tmp.erase()
1426+
# GH-120378: a failed resize can leave refresh broken; restore the
1427+
# original size to recover. Avoid initscr(), which would switch away
1428+
# from the shared newterm() screen and corrupt later tests.
1429+
curses.resize_term(lines, cols)
1430+
self.stdscr.erase()
13931431

13941432
@requires_curses_func('resizeterm')
13951433
def test_resizeterm(self):
@@ -1409,9 +1447,11 @@ def test_resizeterm(self):
14091447
curses.resizeterm(35000, 1)
14101448
with self.assertRaises(OverflowError):
14111449
curses.resizeterm(1, 35000)
1412-
# GH-120378: Overflow failure in resizeterm() causes refresh to fail
1413-
tmp = curses.initscr()
1414-
tmp.erase()
1450+
# GH-120378: a failed resize can leave refresh broken; restore the
1451+
# original size to recover. Avoid initscr(), which would switch away
1452+
# from the shared newterm() screen and corrupt later tests.
1453+
curses.resizeterm(lines, cols)
1454+
self.stdscr.erase()
14151455

14161456
def test_ungetch(self):
14171457
curses.ungetch(b'A')
@@ -1717,5 +1757,98 @@ def test_move_down(self):
17171757
self.mock_win.reset_mock()
17181758

17191759

1760+
@unittest.skipUnless(hasattr(curses, 'newterm'), 'requires curses.newterm()')
1761+
@unittest.skipIf(not term or term == 'unknown',
1762+
"$TERM=%r, newterm() may not work" % term)
1763+
@unittest.skipIf(sys.platform == "cygwin",
1764+
"cygwin's curses mostly just hangs")
1765+
class ScreenTests(unittest.TestCase):
1766+
# newterm()/set_term() mutate global curses state, but each test drives its
1767+
# own pseudo-terminal(s) and never touches the screen shared by TestCurses,
1768+
# whose setUp() makes that screen current again. So these can run in this
1769+
# process, without a real terminal and without a subprocess.
1770+
1771+
def setUp(self):
1772+
# newterm() may install signal handlers; restore them afterwards.
1773+
self.save_signals = SaveSignals()
1774+
self.save_signals.save()
1775+
self.addCleanup(self.save_signals.restore)
1776+
1777+
def tearDown(self):
1778+
# Leave visual mode and reclaim the screens the test created, while
1779+
# their pseudo-terminals are still open (closing them happens later,
1780+
# via the make_pty() cleanups).
1781+
try:
1782+
curses.endwin()
1783+
except curses.error:
1784+
pass
1785+
gc_collect()
1786+
1787+
def make_pty(self):
1788+
master, slave = os.openpty()
1789+
self.addCleanup(os.close, master)
1790+
self.addCleanup(os.close, slave)
1791+
return slave
1792+
1793+
def test_newterm(self):
1794+
s = self.make_pty()
1795+
screen = curses.newterm('xterm', s, s)
1796+
self.assertIsInstance(screen, curses.screen)
1797+
win = screen.stdscr
1798+
self.assertIsInstance(win, curses.window)
1799+
self.assertEqual(win.getmaxyx(), (24, 80))
1800+
win.addstr(0, 0, 'hello')
1801+
win.refresh()
1802+
1803+
def test_newterm_file_object(self):
1804+
# type=None uses $TERM; the file arguments accept file objects too.
1805+
s = self.make_pty()
1806+
out = os.fdopen(os.dup(s), 'wb', buffering=0)
1807+
self.addCleanup(out.close)
1808+
screen = curses.newterm(None, out, s)
1809+
self.assertIsInstance(screen, curses.screen)
1810+
1811+
def test_set_term(self):
1812+
s = self.make_pty()
1813+
s2 = self.make_pty()
1814+
a = curses.newterm('xterm', s, s) # current screen is a
1815+
b = curses.newterm('xterm', s2, s2) # current screen is b
1816+
self.assertIs(curses.set_term(a), b) # returns the previous one
1817+
self.assertIs(curses.set_term(b), a)
1818+
1819+
def test_window_keeps_screen_alive(self):
1820+
# The standard window keeps its screen alive; dropping every other
1821+
# reference and collecting must not invalidate the window.
1822+
s = self.make_pty()
1823+
win = curses.newterm('xterm', s, s).stdscr
1824+
gc_collect()
1825+
win.addstr(0, 0, 'still alive')
1826+
win.refresh()
1827+
1828+
def test_screen_freed(self):
1829+
# Dropping all references to a (non-current) screen and its windows
1830+
# frees it without error.
1831+
s = self.make_pty()
1832+
s2 = self.make_pty()
1833+
a = curses.newterm('xterm', s, s)
1834+
b = curses.newterm('xterm', s2, s2) # a is no longer current
1835+
del a
1836+
gc_collect()
1837+
1838+
@unittest.skipUnless(hasattr(curses, 'new_prescr'),
1839+
'requires curses.new_prescr()')
1840+
def test_new_prescr(self):
1841+
screen = curses.new_prescr()
1842+
self.assertIsInstance(screen, curses.screen)
1843+
self.assertIsNone(screen.stdscr)
1844+
del screen
1845+
gc_collect()
1846+
1847+
@cpython_only
1848+
def test_disallow_instantiation(self):
1849+
# The screen type cannot be instantiated directly (bpo-43916).
1850+
check_disallow_instantiation(self, curses.screen)
1851+
1852+
17201853
if __name__ == '__main__':
17211854
unittest.main()

0 commit comments

Comments
 (0)