From 00280581e4423f77e01ca23d471783cde4a7dfbc Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Fri, 19 Jun 2026 15:25:47 +0300 Subject: [PATCH 1/8] 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) --- Doc/library/curses.rst | 98 ++- Doc/whatsnew/3.16.rst | 7 + Include/py_curses.h | 10 + Lib/curses/__init__.py | 17 + Lib/test/test_curses.py | 155 ++++- ...6-06-19-12-19-30.gh-issue-90092.yBVc0C.rst | 4 + Modules/_cursesmodule.c | 601 ++++++++++++++++-- Modules/clinic/_cursesmodule.c.h | 118 +++- configure | 180 ++++++ configure.ac | 3 + pyconfig.h.in | 9 + 11 files changed, 1124 insertions(+), 78 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-06-19-12-19-30.gh-issue-90092.yBVc0C.rst diff --git a/Doc/library/curses.rst b/Doc/library/curses.rst index d7873054d6b9154..2089b76092129e7 100644 --- a/Doc/library/curses.rst +++ b/Doc/library/curses.rst @@ -207,8 +207,9 @@ The module :mod:`!curses` defines the following functions: .. function:: nofilter() Undo the effect of a previous :func:`.filter` call. - Like :func:`.filter`, it must be called before :func:`initscr` so that the - next initialization uses the full screen again. + Like :func:`.filter`, it must be called before :func:`initscr` (or + :func:`newterm`) so that the next initialization uses the full screen + again. Availability: if the underlying curses library provides ``nofilter()``. @@ -442,6 +443,36 @@ The module :mod:`!curses` defines the following functions: right corner of the screen. +.. function:: newterm(type=None, fd=None, infd=None, /) + + Initialize a new terminal in addition to the one initialized by + :func:`initscr`, + and return a :ref:`screen ` for it. + This allows a program to drive more than one terminal. + + *type* is the terminal name, as in :func:`setupterm`; + if ``None``, the value of the :envvar:`TERM` environment variable is used. + *fd* and *infd* are the output and input files for the terminal: + either a file object or a file descriptor. + They default to :data:`sys.stdout` and :data:`sys.stdin`. + + The new screen becomes the current one. + Use :func:`set_term` to switch between screens. + + .. versionadded:: next + + +.. function:: new_prescr() + + Return a new :ref:`screen ` + that can be used to call functions that affect global state + before :func:`initscr` or :func:`newterm` is called. + + Availability: if the underlying curses library provides ``new_prescr()``. + + .. versionadded:: next + + .. function:: nl(flag=True) 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: .. versionadded:: 3.9 + +.. function:: set_term(screen, /) + + Make *screen*, a :ref:`screen ` returned by + :func:`newterm`, the current terminal, + and return the previously current screen. + Returns ``None`` if the previous screen was the one created by + :func:`initscr`. + + .. versionadded:: next + .. function:: setsyx(y, x) Set the virtual screen cursor to *y*, *x*. If *y* and *x* are both ``-1``, then @@ -1380,6 +1422,18 @@ Window objects :meth:`refresh`. +.. method:: window.use(func, /, *args, **kwargs) + + Call ``func(window, *args, **kwargs)`` with the lock of the window held, + and return its result. + This provides automatic protection for the window + against concurrent access from another thread. + + Availability: if the underlying curses library provides ``use_window()``. + + .. versionadded:: next + + .. method:: window.vline(ch, n[, attr]) window.vline(y, x, ch, n[, attr]) @@ -1387,6 +1441,46 @@ Window objects character *ch* with attributes *attr*. +.. _curses-screen-objects: + +Screen objects +-------------- + +.. class:: screen + + A *screen* object represents a terminal initialized by :func:`newterm` + (or :func:`new_prescr`), + in addition to the default screen created by :func:`initscr`. + Screen objects are returned by those functions; + they cannot be instantiated directly. + + A screen is freed automatically once it is no longer referenced, + either directly or through one of its windows. + Each window keeps its screen alive, + so a screen remains valid as long as any of its windows does. + + .. versionadded:: next + + +.. attribute:: screen.stdscr + + The standard :ref:`window ` of the screen, + covering the whole terminal, + or ``None`` for a screen created by :func:`new_prescr`. + + +.. method:: screen.use(func, /, *args, **kwargs) + + Call ``func(screen, *args, **kwargs)`` with the lock of the screen held, + and return its result. + This provides automatic protection for the screen + against concurrent access from another thread. + + Availability: if the underlying curses library provides ``use_screen()``. + + .. versionadded:: next + + Constants --------- diff --git a/Doc/whatsnew/3.16.rst b/Doc/whatsnew/3.16.rst index 0a110795f371eb7..7baa8bba398a899 100644 --- a/Doc/whatsnew/3.16.rst +++ b/Doc/whatsnew/3.16.rst @@ -89,6 +89,13 @@ Improved modules curses ------ +* Add support for multiple terminals to the :mod:`curses` module: + the new functions :func:`curses.newterm`, :func:`curses.set_term` + and :func:`curses.new_prescr`, + the corresponding :ref:`screen ` object, + and the :meth:`window.use` method. + (Contributed by Serhiy Storchaka in :gh:`90092`.) + * Add :func:`curses.nofilter`, which undoes the effect of :func:`curses.filter`. (Contributed by Serhiy Storchaka in :gh:`151744`.) diff --git a/Include/py_curses.h b/Include/py_curses.h index 0948aabedd49939..3d2ca278f809cb2 100644 --- a/Include/py_curses.h +++ b/Include/py_curses.h @@ -80,8 +80,18 @@ typedef struct PyCursesWindowObject { WINDOW *win; char *encoding; struct PyCursesWindowObject *orig; + PyObject *screen; /* the screen the window belongs to, or NULL, + kept alive for the lifetime of the window */ } PyCursesWindowObject; +typedef struct { + PyObject_HEAD + SCREEN *screen; /* NULL after the screen has been deleted */ + FILE *outfp; /* owned output stream, or NULL */ + FILE *infp; /* owned input stream, or NULL */ + PyObject *stdscr; /* the screen's standard window, or NULL */ +} PyCursesScreenObject; + #define PyCurses_CAPSULE_NAME "_curses._C_API" diff --git a/Lib/curses/__init__.py b/Lib/curses/__init__.py index 605d5fcbec5499d..e150c7f932385eb 100644 --- a/Lib/curses/__init__.py +++ b/Lib/curses/__init__.py @@ -34,6 +34,23 @@ def initscr(): setattr(curses, key, value) return stdscr +# newterm() is wrapped for the same reason as initscr(): the ACS_* constants +# and LINES/COLS only become available once a terminal is initialized, and are +# then copied to the curses package's dictionary. + +try: + newterm +except NameError: + pass +else: + def newterm(type=None, fd=None, infd=None, /): + import _curses, curses + screen = _curses.newterm(type, fd, infd) + for key, value in _curses.__dict__.items(): + if key.startswith('ACS_') or key in ('LINES', 'COLS'): + setattr(curses, key, value) + return screen + # This is a similar wrapper for start_color(), which adds the COLORS and # COLOR_PAIRS variables which are only available after start_color() is # called. diff --git a/Lib/test/test_curses.py b/Lib/test/test_curses.py index 98f1a7c8a0a2c5c..6cb895c8b2c1ce8 100644 --- a/Lib/test/test_curses.py +++ b/Lib/test/test_curses.py @@ -53,9 +53,11 @@ def wrapped(self, *args, **kwargs): term = os.environ.get('TERM') SHORT_MAX = 0x7fff -# If newterm was supported we could use it instead of initscr and not exit +# newterm() is used when available (it reports errors instead of exiting), but +# initscr() is still the fallback, and an unusable $TERM has no terminal to +# drive either way. @unittest.skipIf(not term or term == 'unknown', - "$TERM=%r, calling initscr() may cause exit" % term) + "$TERM=%r, no usable terminal" % term) @unittest.skipIf(sys.platform == "cygwin", "cygwin's curses mostly just hangs") class TestCurses(unittest.TestCase): @@ -110,7 +112,23 @@ def setUp(self): sys.stderr.flush() sys.stdout.flush() print(file=self.output, flush=True) - self.stdscr = curses.initscr() + if hasattr(curses, 'newterm'): + # Use newterm() rather than initscr(): it reports errors instead of + # exiting, and gives each test a fresh screen, which also lets + # ScreenTests run newterm()/set_term() in the same process. + try: + infd = sys.__stdin__.fileno() + except (AttributeError, ValueError, OSError): + infd = stdout_fd + self.screen = curses.newterm(term, stdout_fd, infd) + self.stdscr = self.screen.stdscr + # Drop the screen after the test so the screens do not pile up: a + # window keeps its screen alive through a reference cycle, and + # unittest keeps every test instance for the whole run. + self.addCleanup(setattr, self, 'screen', None) + self.addCleanup(setattr, self, 'stdscr', None) + else: + self.stdscr = curses.initscr() if self.isatty: curses.savetty() self.addCleanup(curses.endwin) @@ -119,10 +137,12 @@ def setUp(self): @requires_curses_func('filter') def test_filter(self): - # TODO: Should be called before initscr() or newterm() are called. + # filter() must be called before initscr()/newterm(); it confines + # curses to a single line. Undo it with nofilter() afterwards so that + # it does not shrink the screens created by later tests. curses.filter() if hasattr(curses, 'nofilter'): - curses.nofilter() + self.addCleanup(curses.nofilter) @requires_curses_func('use_env') def test_use_env(self): @@ -1089,6 +1109,22 @@ def test_use_default_colors(self): self.skipTest('cannot change color (use_default_colors() failed)') self.assertEqual(curses.pair_content(0), (-1, -1)) + @requires_curses_window_meth('use') + def test_use_window(self): + win = self.stdscr + self.assertEqual(win.use(lambda w, a, b: (w is win, a, b), 5, b=6), + (True, 5, 6)) + with self.assertRaises(ZeroDivisionError): + win.use(lambda w: 1 / 0) + + @unittest.skipUnless(hasattr(curses.screen, 'use'), + 'requires screen.use()') + def test_use_screen(self): + screen = self.screen + self.assertEqual( + screen.use(lambda sc, flag: (sc is screen, flag), flag=True), + (True, True)) + @requires_curses_func('assume_default_colors') @requires_colors def test_assume_default_colors(self): @@ -1387,9 +1423,11 @@ def test_resize_term(self): curses.resize_term(35000, 1) with self.assertRaises(OverflowError): curses.resize_term(1, 35000) - # GH-120378: Overflow failure in resize_term() causes refresh to fail - tmp = curses.initscr() - tmp.erase() + # GH-120378: a failed resize can leave refresh broken; restore the + # original size to recover. Avoid initscr(), which would switch away + # from the shared newterm() screen and corrupt later tests. + curses.resize_term(lines, cols) + self.stdscr.erase() @requires_curses_func('resizeterm') def test_resizeterm(self): @@ -1409,9 +1447,11 @@ def test_resizeterm(self): curses.resizeterm(35000, 1) with self.assertRaises(OverflowError): curses.resizeterm(1, 35000) - # GH-120378: Overflow failure in resizeterm() causes refresh to fail - tmp = curses.initscr() - tmp.erase() + # GH-120378: a failed resize can leave refresh broken; restore the + # original size to recover. Avoid initscr(), which would switch away + # from the shared newterm() screen and corrupt later tests. + curses.resizeterm(lines, cols) + self.stdscr.erase() def test_ungetch(self): curses.ungetch(b'A') @@ -1717,5 +1757,98 @@ def test_move_down(self): self.mock_win.reset_mock() +@unittest.skipUnless(hasattr(curses, 'newterm'), 'requires curses.newterm()') +@unittest.skipIf(not term or term == 'unknown', + "$TERM=%r, newterm() may not work" % term) +@unittest.skipIf(sys.platform == "cygwin", + "cygwin's curses mostly just hangs") +class ScreenTests(unittest.TestCase): + # newterm()/set_term() mutate global curses state, but each test drives its + # own pseudo-terminal(s) and never touches the screen shared by TestCurses, + # whose setUp() makes that screen current again. So these can run in this + # process, without a real terminal and without a subprocess. + + def setUp(self): + # newterm() may install signal handlers; restore them afterwards. + self.save_signals = SaveSignals() + self.save_signals.save() + self.addCleanup(self.save_signals.restore) + + def tearDown(self): + # Leave visual mode and reclaim the screens the test created, while + # their pseudo-terminals are still open (closing them happens later, + # via the make_pty() cleanups). + try: + curses.endwin() + except curses.error: + pass + gc_collect() + + def make_pty(self): + master, slave = os.openpty() + self.addCleanup(os.close, master) + self.addCleanup(os.close, slave) + return slave + + def test_newterm(self): + s = self.make_pty() + screen = curses.newterm('xterm', s, s) + self.assertIsInstance(screen, curses.screen) + win = screen.stdscr + self.assertIsInstance(win, curses.window) + self.assertEqual(win.getmaxyx(), (24, 80)) + win.addstr(0, 0, 'hello') + win.refresh() + + def test_newterm_file_object(self): + # type=None uses $TERM; the file arguments accept file objects too. + s = self.make_pty() + out = os.fdopen(os.dup(s), 'wb', buffering=0) + self.addCleanup(out.close) + screen = curses.newterm(None, out, s) + self.assertIsInstance(screen, curses.screen) + + def test_set_term(self): + s = self.make_pty() + s2 = self.make_pty() + a = curses.newterm('xterm', s, s) # current screen is a + b = curses.newterm('xterm', s2, s2) # current screen is b + self.assertIs(curses.set_term(a), b) # returns the previous one + self.assertIs(curses.set_term(b), a) + + def test_window_keeps_screen_alive(self): + # The standard window keeps its screen alive; dropping every other + # reference and collecting must not invalidate the window. + s = self.make_pty() + win = curses.newterm('xterm', s, s).stdscr + gc_collect() + win.addstr(0, 0, 'still alive') + win.refresh() + + def test_screen_freed(self): + # Dropping all references to a (non-current) screen and its windows + # frees it without error. + s = self.make_pty() + s2 = self.make_pty() + a = curses.newterm('xterm', s, s) + b = curses.newterm('xterm', s2, s2) # a is no longer current + del a + gc_collect() + + @unittest.skipUnless(hasattr(curses, 'new_prescr'), + 'requires curses.new_prescr()') + def test_new_prescr(self): + screen = curses.new_prescr() + self.assertIsInstance(screen, curses.screen) + self.assertIsNone(screen.stdscr) + del screen + gc_collect() + + @cpython_only + def test_disallow_instantiation(self): + # The screen type cannot be instantiated directly (bpo-43916). + check_disallow_instantiation(self, curses.screen) + + if __name__ == '__main__': unittest.main() diff --git a/Misc/NEWS.d/next/Library/2026-06-19-12-19-30.gh-issue-90092.yBVc0C.rst b/Misc/NEWS.d/next/Library/2026-06-19-12-19-30.gh-issue-90092.yBVc0C.rst new file mode 100644 index 000000000000000..6cc9d0c516ea882 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-06-19-12-19-30.gh-issue-90092.yBVc0C.rst @@ -0,0 +1,4 @@ +Add support for multiple terminals to the :mod:`curses` module: the new +functions :func:`curses.newterm`, :func:`curses.set_term` and +:func:`curses.new_prescr`, the corresponding :ref:`screen +` object, and the :meth:`window.use` method. diff --git a/Modules/_cursesmodule.c b/Modules/_cursesmodule.c index e60cba3ef87ead1..192264209a19ee0 100644 --- a/Modules/_cursesmodule.c +++ b/Modules/_cursesmodule.c @@ -41,12 +41,12 @@ Here's a list of currently unsupported functions: addchnstr addchstr color_set define_key - del_curterm delscreen dupwin inchnstr inchstr innstr keyok + del_curterm dupwin inchnstr inchstr innstr keyok mcprint mvaddchnstr mvaddchstr mvcur mvinchnstr mvinchstr mvinnstr mmvwaddchnstr mvwaddchstr - mvwinchnstr mvwinchstr mvwinnstr newterm + mvwinchnstr mvwinchstr mvwinnstr restartterm ripoffline scr_dump - scr_init scr_restore scr_set scrl set_curterm set_term setterm + scr_init scr_restore scr_set scrl set_curterm setterm tgetent tgetflag tgetnum tgetstr tgoto timeout tputs vidattr vidputs waddchnstr waddchstr wcolor_set winchnstr winchstr winnstr wmouse_trafo wscrl @@ -108,7 +108,7 @@ static const char PyCursesVersion[] = "2.2"; #include "pycore_capsule.h" // _PyCapsule_SetTraverse() #include "pycore_long.h" // _PyLong_GetZero() #include "pycore_structseq.h" // _PyStructSequence_NewType() -#include "pycore_fileutils.h" // _Py_set_inheritable +#include "pycore_fileutils.h" // _Py_dup(), _Py_set_inheritable() #ifdef __hpux #define STRICT_SYSV_CURSES @@ -164,6 +164,9 @@ typedef chtype attr_t; /* No attr_t type is available */ typedef struct { PyObject *error; // curses exception type PyTypeObject *window_type; // exposed by PyCursesWindow_Type + PyTypeObject *screen_type; // _curses.screen + PyObject *topscreen; // owned ref to the current screen object, + // or NULL for the initscr() screen } cursesmodule_state; static inline cursesmodule_state * @@ -189,12 +192,14 @@ get_cursesmodule_state_by_win(PyCursesWindowObject *win) } #define _PyCursesWindowObject_CAST(op) ((PyCursesWindowObject *)(op)) +#define _PyCursesScreenObject_CAST(op) ((PyCursesScreenObject *)(op)) /*[clinic input] module _curses class _curses.window "PyCursesWindowObject *" "clinic_state()->window_type" +class _curses.screen "PyCursesScreenObject *" "clinic_state()->screen_type" [clinic start generated code]*/ -/*[clinic end generated code: output=da39a3ee5e6b4b0d input=ae6cb623018f2cbc]*/ +/*[clinic end generated code: output=da39a3ee5e6b4b0d input=4b027ab105ab94e1]*/ /* Indicate whether the module has already been loaded or not. */ static int curses_module_loaded = 0; @@ -888,7 +893,7 @@ Window_TwoArgNoReturnFunction(wresize, int, "ii;lines,columns") static PyObject * PyCursesWindow_New(cursesmodule_state *state, WINDOW *win, const char *encoding, - PyCursesWindowObject *orig) + PyCursesWindowObject *orig, PyObject *screen) { if (encoding == NULL) { #if defined(MS_WINDOWS) @@ -916,14 +921,14 @@ PyCursesWindow_New(cursesmodule_state *state, return NULL; } wo->win = win; + wo->orig = (PyCursesWindowObject *)Py_XNewRef((PyObject *)orig); + wo->screen = Py_XNewRef(screen); wo->encoding = _PyMem_Strdup(encoding); if (wo->encoding == NULL) { Py_DECREF(wo); PyErr_NoMemory(); return NULL; } - wo->orig = orig; - Py_XINCREF(orig); PyObject_GC_Track((PyObject *)wo); return (PyObject *)wo; } @@ -944,6 +949,7 @@ PyCursesWindow_dealloc(PyObject *self) PyMem_Free(wo->encoding); } Py_XDECREF(wo->orig); + Py_XDECREF(wo->screen); window_type->tp_free(self); Py_DECREF(window_type); } @@ -954,6 +960,7 @@ PyCursesWindow_traverse(PyObject *self, visitproc visit, void *arg) Py_VISIT(Py_TYPE(self)); PyCursesWindowObject *wo = (PyCursesWindowObject *)self; Py_VISIT(wo->orig); + Py_VISIT(wo->screen); return 0; } @@ -1639,7 +1646,7 @@ _curses_window_derwin_impl(PyCursesWindowObject *self, int group_left_1, } cursesmodule_state *state = get_cursesmodule_state_by_win(self); - return PyCursesWindow_New(state, win, NULL, self); + return PyCursesWindow_New(state, win, NULL, self, self->screen); } /*[clinic input] @@ -2764,7 +2771,7 @@ _curses_window_subwin_impl(PyCursesWindowObject *self, int group_left_1, } cursesmodule_state *state = get_cursesmodule_state_by_win(self); - return PyCursesWindow_New(state, win, self->encoding, self); + return PyCursesWindow_New(state, win, self->encoding, self, self->screen); } /*[clinic input] @@ -2924,6 +2931,84 @@ PyCursesWindow_set_encoding(PyObject *op, PyObject *value, void *Py_UNUSED(ignor #include "clinic/_cursesmodule.c.h" #undef clinic_state +#if defined(HAVE_CURSES_USE_SCREEN) || defined(HAVE_CURSES_USE_WINDOW) +/* Shared trampoline for window.use()/screen.use(): call + func(obj, *extra, **kwargs) and store the result (NULL on exception) in + data->result. */ +typedef struct { + PyObject *obj; /* the window or screen object */ + PyObject *func; /* the callable */ + PyObject *extra; /* extra positional arguments (a tuple) */ + PyObject *kwargs; /* keyword arguments (a dict), or NULL */ + PyObject *result; /* output: the call result, or NULL */ +} curses_use_data; + +static void +curses_use_call(curses_use_data *data) +{ + Py_ssize_t n = PyTuple_GET_SIZE(data->extra); + PyObject *callargs = PyTuple_New(n + 1); + if (callargs == NULL) { + data->result = NULL; + return; + } + PyTuple_SET_ITEM(callargs, 0, Py_NewRef(data->obj)); + for (Py_ssize_t i = 0; i < n; i++) { + PyTuple_SET_ITEM(callargs, i + 1, + Py_NewRef(PyTuple_GET_ITEM(data->extra, i))); + } + data->result = PyObject_Call(data->func, callargs, data->kwargs); + Py_DECREF(callargs); +} + +/* Parse (func, *extra) from a use() method's argument tuple. */ +static int +curses_use_parse(PyObject *args, PyObject **func, PyObject **extra) +{ + Py_ssize_t nargs = PyTuple_GET_SIZE(args); + if (nargs < 1) { + PyErr_SetString(PyExc_TypeError, + "use() missing required argument 'func'"); + return -1; + } + *func = PyTuple_GET_ITEM(args, 0); + if (!PyCallable_Check(*func)) { + PyErr_SetString(PyExc_TypeError, "use(): func must be callable"); + return -1; + } + *extra = PyTuple_GetSlice(args, 1, nargs); + return *extra == NULL ? -1 : 0; +} +#endif + +#ifdef HAVE_CURSES_USE_WINDOW +static int +curses_use_window_cb(WINDOW *Py_UNUSED(win), void *data) +{ + curses_use_call((curses_use_data *)data); + return 0; +} + +PyDoc_STRVAR(PyCursesWindow_use__doc__, +"use($self, func, /, *args, **kwargs)\n--\n\n" +"Call func(win, *args, **kwargs) with the window locked,\n" +"and return its result."); + +static PyObject * +PyCursesWindow_use(PyObject *self, PyObject *args, PyObject *kwargs) +{ + PyCursesWindowObject *wo = _PyCursesWindowObject_CAST(self); + PyObject *func, *extra; + if (curses_use_parse(args, &func, &extra) < 0) { + return NULL; + } + curses_use_data data = {self, func, extra, kwargs, NULL}; + use_window(wo->win, curses_use_window_cb, &data); + Py_DECREF(extra); + return data.result; +} +#endif /* HAVE_CURSES_USE_WINDOW */ + static PyMethodDef PyCursesWindow_methods[] = { _CURSES_WINDOW_ADDCH_METHODDEF _CURSES_WINDOW_ADDNSTR_METHODDEF @@ -3086,6 +3171,10 @@ static PyMethodDef PyCursesWindow_methods[] = { "untouchwin($self, /)\n--\n\n" "Mark all lines in the window as unchanged since last refresh()."}, _CURSES_WINDOW_VLINE_METHODDEF +#ifdef HAVE_CURSES_USE_WINDOW + {"use", _PyCFunction_CAST(PyCursesWindow_use), + METH_VARARGS | METH_KEYWORDS, PyCursesWindow_use__doc__}, +#endif {NULL, NULL} /* sentinel */ }; @@ -3118,6 +3207,166 @@ static PyType_Spec PyCursesWindow_Type_spec = { .slots = PyCursesWindow_Type_slots }; +/* -------------------------------------------------------*/ +/* Screen objects (multiple terminals) */ +/* -------------------------------------------------------*/ + +static PyObject * +PyCursesScreen_New(cursesmodule_state *state, SCREEN *screen, + FILE *outfp, FILE *infp, PyObject *stdscr) +{ + PyCursesScreenObject *so = PyObject_GC_New(PyCursesScreenObject, + state->screen_type); + if (so == NULL) { + return NULL; + } + so->screen = screen; + so->outfp = outfp; + so->infp = infp; + so->stdscr = Py_XNewRef(stdscr); + PyObject_GC_Track((PyObject *)so); + return (PyObject *)so; +} + +/* Free the C SCREEN and the FILE* streams owned by a screen object. + Safe to call more than once. + + This must run by reference counting (from the dealloc), not from tp_clear: + it has to happen only once every window on the screen is gone, and thus + after del_panel() for any panel built on one of those windows. delscreen() + tears down the screen that del_panel() needs, so a panel outliving its + screen would crash. */ +static void +curses_screen_close(PyCursesScreenObject *so) +{ + if (so->screen != NULL) { + delscreen(so->screen); + so->screen = NULL; + } + if (so->outfp != NULL) { + fclose(so->outfp); + so->outfp = NULL; + } + if (so->infp != NULL) { + fclose(so->infp); + so->infp = NULL; + } +} + +static PyObject * +PyCursesScreen_get_stdscr(PyObject *self, void *Py_UNUSED(closure)) +{ + PyCursesScreenObject *so = _PyCursesScreenObject_CAST(self); + if (so->stdscr == NULL) { + Py_RETURN_NONE; + } + return Py_NewRef(so->stdscr); +} + +static int +PyCursesScreen_traverse(PyObject *self, visitproc visit, void *arg) +{ + Py_VISIT(Py_TYPE(self)); + Py_VISIT(_PyCursesScreenObject_CAST(self)->stdscr); + return 0; +} + +static int +PyCursesScreen_clear(PyObject *self) +{ + PyCursesScreenObject *so = _PyCursesScreenObject_CAST(self); + /* Break the reference cycle between a screen and its standard window by + dropping the reference to that window. Do NOT delscreen() here: that is + deferred to the dealloc so it runs after every window (see + curses_screen_close()). delscreen() will free the standard window, so + detach it from its wrapper first: the wrapper must not delwin() a window + that delscreen() frees. Any further use of the wrapper operates on a + NULL window and fails cleanly. */ + if (so->stdscr != NULL) { + ((PyCursesWindowObject *)so->stdscr)->win = NULL; + } + Py_CLEAR(so->stdscr); + return 0; +} + +static void +PyCursesScreen_dealloc(PyObject *self) +{ + PyTypeObject *tp = Py_TYPE(self); + PyObject_GC_UnTrack(self); + (void)PyCursesScreen_clear(self); + curses_screen_close(_PyCursesScreenObject_CAST(self)); + tp->tp_free(self); + Py_DECREF(tp); +} + +static PyGetSetDef PyCursesScreen_getsets[] = { + {"stdscr", PyCursesScreen_get_stdscr, NULL, + "the screen's standard window (stdscr)", NULL}, + {NULL, NULL, NULL, NULL, NULL} /* sentinel */ +}; + +#ifdef HAVE_CURSES_USE_SCREEN +static int +curses_use_screen_cb(SCREEN *Py_UNUSED(sp), void *data) +{ + curses_use_call((curses_use_data *)data); + return 0; +} + +PyDoc_STRVAR(PyCursesScreen_use__doc__, +"use($self, func, /, *args, **kwargs)\n--\n\n" +"Call func(screen, *args, **kwargs) with the screen locked,\n" +"and return its result."); + +static PyObject * +PyCursesScreen_use(PyObject *self, PyObject *args, PyObject *kwargs) +{ + PyCursesScreenObject *so = _PyCursesScreenObject_CAST(self); + if (so->screen == NULL) { + cursesmodule_state *state = get_cursesmodule_state_by_cls(Py_TYPE(self)); + PyErr_SetString(state->error, "the screen has been deleted"); + return NULL; + } + PyObject *func, *extra; + if (curses_use_parse(args, &func, &extra) < 0) { + return NULL; + } + curses_use_data data = {self, func, extra, kwargs, NULL}; + use_screen(so->screen, curses_use_screen_cb, &data); + Py_DECREF(extra); + return data.result; +} +#endif /* HAVE_CURSES_USE_SCREEN */ + +static PyMethodDef PyCursesScreen_methods[] = { +#ifdef HAVE_CURSES_USE_SCREEN + {"use", _PyCFunction_CAST(PyCursesScreen_use), + METH_VARARGS | METH_KEYWORDS, PyCursesScreen_use__doc__}, +#endif + {NULL, NULL} /* sentinel */ +}; + +static PyType_Slot PyCursesScreen_Type_slots[] = { + {Py_tp_methods, PyCursesScreen_methods}, + {Py_tp_getset, PyCursesScreen_getsets}, + {Py_tp_dealloc, PyCursesScreen_dealloc}, + {Py_tp_traverse, PyCursesScreen_traverse}, + {Py_tp_clear, PyCursesScreen_clear}, + {0, NULL} +}; + +static PyType_Spec PyCursesScreen_Type_spec = { + .name = "_curses.screen", + .basicsize = sizeof(PyCursesScreenObject), + .flags = Py_TPFLAGS_DEFAULT + | Py_TPFLAGS_DISALLOW_INSTANTIATION + | Py_TPFLAGS_IMMUTABLETYPE + | Py_TPFLAGS_HEAPTYPE + | Py_TPFLAGS_HAVE_GC, + .slots = PyCursesScreen_Type_slots +}; + /* -------------------------------------------------------*/ /* @@ -3226,14 +3475,14 @@ _curses.nofilter Undo the effect of a preceding filter() call. -Must be called before initscr(). It restores the normal behaviour -disabled by filter(), so that the next initscr() uses the full screen -rather than a single line. +Must be called before initscr(). It restores the normal behaviour that +filter() disables, so that the next initscr() or newterm() uses the full +screen rather than a single line. [clinic start generated code]*/ static PyObject * _curses_nofilter_impl(PyObject *module) -/*[clinic end generated code: output=d95ca4d48a6bdbdf input=58aea83b1a5c969f]*/ +/*[clinic end generated code: output=d95ca4d48a6bdbdf input=53183055c0901ab7]*/ { /* not checking for PyCursesInitialised here since nofilter() must be called before initscr() */ @@ -3666,7 +3915,7 @@ _curses_getwin(PyObject *module, PyObject *file) goto error; } cursesmodule_state *state = get_cursesmodule_state(module); - res = PyCursesWindow_New(state, win, NULL, NULL); + res = PyCursesWindow_New(state, win, NULL, NULL, state->topscreen); error: fclose(fp); @@ -3840,50 +4089,16 @@ curses_update_screen_encoding(PyObject *winobj) return 0; } -/*[clinic input] -_curses.initscr - -Initialize the library. - -Return a WindowObject which represents the whole screen. -[clinic start generated code]*/ - -static PyObject * -_curses_initscr_impl(PyObject *module) -/*[clinic end generated code: output=619fb68443810b7b input=514f4bce1821f6b5]*/ +/* Populate the module dictionary with the ACS_* line-drawing constants and + LINES/COLS. These are only meaningful once a screen exists (after + initscr() or newterm()), which is why this is not done at module + initialisation. Returns 0 on success, -1 with an exception set. */ +static int +curses_init_dict(PyObject *module) { - WINDOW *win; - - if (curses_initscr_called) { - cursesmodule_state *state = get_cursesmodule_state(module); - int code = wrefresh(stdscr); - if (code == ERR) { - _curses_set_null_error(state, "wrefresh", "initscr"); - return NULL; - } - PyObject *winobj = PyCursesWindow_New(state, stdscr, NULL, NULL); - if (winobj == NULL) { - return NULL; - } - if (curses_update_screen_encoding(winobj) < 0) { - Py_DECREF(winobj); - return NULL; - } - return winobj; - } - - win = initscr(); - - if (win == NULL) { - curses_set_null_error(module, "initscr", NULL); - return NULL; - } - - curses_initscr_called = curses_setupterm_called = TRUE; - PyObject *module_dict = PyModule_GetDict(module); // borrowed if (module_dict == NULL) { - return NULL; + return -1; } /* This was moved from initcurses() because it core dumped on SGI, where they're not defined until you've called initscr() */ @@ -3891,12 +4106,12 @@ _curses_initscr_impl(PyObject *module) do { \ PyObject *value = PyLong_FromLong((long)(VALUE)); \ if (value == NULL) { \ - return NULL; \ + return -1; \ } \ int rc = PyDict_SetItemString(module_dict, (NAME), value); \ Py_DECREF(value); \ if (rc < 0) { \ - return NULL; \ + return -1; \ } \ } while (0) @@ -3970,9 +4185,56 @@ _curses_initscr_impl(PyObject *module) SetDictInt("LINES", LINES); SetDictInt("COLS", COLS); #undef SetDictInt + return 0; +} + +/*[clinic input] +_curses.initscr + +Initialize the library. + +Return a WindowObject which represents the whole screen. +[clinic start generated code]*/ + +static PyObject * +_curses_initscr_impl(PyObject *module) +/*[clinic end generated code: output=619fb68443810b7b input=514f4bce1821f6b5]*/ +{ + WINDOW *win; + + if (curses_initscr_called) { + cursesmodule_state *state = get_cursesmodule_state(module); + int code = wrefresh(stdscr); + if (code == ERR) { + _curses_set_null_error(state, "wrefresh", "initscr"); + return NULL; + } + PyObject *winobj = PyCursesWindow_New(state, stdscr, NULL, NULL, NULL); + if (winobj == NULL) { + return NULL; + } + if (curses_update_screen_encoding(winobj) < 0) { + Py_DECREF(winobj); + return NULL; + } + return winobj; + } + + win = initscr(); + + if (win == NULL) { + curses_set_null_error(module, "initscr", NULL); + return NULL; + } + + curses_initscr_called = curses_setupterm_called = TRUE; + + if (curses_init_dict(module) < 0) { + return NULL; + } cursesmodule_state *state = get_cursesmodule_state(module); - PyObject *winobj = PyCursesWindow_New(state, win, NULL, NULL); + PyObject *winobj = PyCursesWindow_New(state, win, NULL, NULL, NULL); if (winobj == NULL) { return NULL; } @@ -4042,6 +4304,206 @@ _curses_setupterm_impl(PyObject *module, const char *term, int fd) Py_RETURN_NONE; } +static int update_lines_cols(PyObject *private_module); /* defined below */ + +/* Return a file descriptor for obj, or, if obj is NULL or None, for the + sys. stream. Returns -1 with an exception set on error. */ +static int +curses_fileno(PyObject *module, PyObject *obj, const char *stdname) +{ + if (obj != NULL && obj != Py_None) { + return PyObject_AsFileDescriptor(obj); + } + PyObject *stream; + if (PySys_GetOptionalAttrString(stdname, &stream) < 0) { + return -1; + } + if (stream == NULL || stream == Py_None) { + cursesmodule_state *state = get_cursesmodule_state(module); + PyErr_Format(state->error, "lost sys.%s", stdname); + Py_XDECREF(stream); + return -1; + } + int fd = PyObject_AsFileDescriptor(stream); + Py_DECREF(stream); + return fd; +} + +/* Duplicate fd and wrap it in a new (non-inheritable) stdio stream that the + screen object will own. Duplicating means closing the stream later does + not close the caller's fd. Returns NULL with an exception set on error. */ +static FILE * +curses_fdopen_dup(int fd, const char *mode) +{ + /* _Py_dup() duplicates the descriptor and makes the copy non-inheritable + atomically (and sets the error on failure). */ + int dfd = _Py_dup(fd); + if (dfd < 0) { + return NULL; + } + FILE *stream = fdopen(dfd, mode); + if (stream == NULL) { + PyErr_SetFromErrno(PyExc_OSError); + close(dfd); + return NULL; + } + return stream; +} + +/*[clinic input] +_curses.newterm + + type: str(accept={str, NoneType}) = None + Terminal name; if None, the TERM environment variable is used. + fd: object = None + Output file object or descriptor (default: sys.stdout). + infd: object = None + Input file object or descriptor (default: sys.stdin). + / + +Return a new screen for the terminal, in addition to the initial screen. + +This is an alternative to initscr() for programs running on more than +one terminal. Use set_term() to switch between the screens. +[clinic start generated code]*/ + +static PyObject * +_curses_newterm_impl(PyObject *module, const char *type, PyObject *fd, + PyObject *infd) +/*[clinic end generated code: output=62663c31909d796c input=98507fe48c2e93cb]*/ +{ + /* Duplicate each descriptor right after resolving it: resolving the other + one runs arbitrary Python code (e.g. a fileno() method) that could close + this one before it is duplicated. */ + int out_fd = curses_fileno(module, fd, "stdout"); + if (out_fd < 0) { + return NULL; + } + FILE *outfp = curses_fdopen_dup(out_fd, "wb"); + if (outfp == NULL) { + return NULL; + } + + int in_fd = curses_fileno(module, infd, "stdin"); + if (in_fd < 0) { + fclose(outfp); + return NULL; + } + FILE *infp = curses_fdopen_dup(in_fd, "rb"); + if (infp == NULL) { + fclose(outfp); + return NULL; + } + + SCREEN *screen = newterm((char *)type, outfp, infp); + if (screen == NULL) { + curses_set_null_error(module, "newterm", NULL); + fclose(outfp); + fclose(infp); + return NULL; + } + /* newterm() makes the new screen the current one, so stdscr now refers + to its standard window. */ + curses_initscr_called = curses_setupterm_called = TRUE; + + cursesmodule_state *state = get_cursesmodule_state(module); + /* The screen object owns the SCREEN and the streams; deleting it (when it + is no longer referenced) calls delscreen() and closes the streams. */ + PyObject *screenobj = PyCursesScreen_New(state, screen, outfp, infp, NULL); + if (screenobj == NULL) { + delscreen(screen); + fclose(outfp); + fclose(infp); + return NULL; + } + /* The standard window keeps the screen alive for its own lifetime. */ + PyObject *win = PyCursesWindow_New(state, stdscr, NULL, NULL, screenobj); + if (win == NULL || + curses_update_screen_encoding(win) < 0 || + curses_init_dict(module) < 0) + { + Py_XDECREF(win); + Py_DECREF(screenobj); + return NULL; + } + ((PyCursesScreenObject *)screenobj)->stdscr = Py_NewRef(win); + Py_DECREF(win); + Py_XSETREF(state->topscreen, Py_NewRef(screenobj)); + return screenobj; +} + +/* Check that obj is an open screen object; returns it cast, or NULL with + TypeError/curses.error set. */ +static PyCursesScreenObject * +curses_check_screen(PyObject *module, PyObject *obj) +{ + cursesmodule_state *state = get_cursesmodule_state(module); + if (!PyObject_TypeCheck(obj, state->screen_type)) { + PyErr_Format(PyExc_TypeError, + "expected a curses screen, got %T", obj); + return NULL; + } + PyCursesScreenObject *so = _PyCursesScreenObject_CAST(obj); + if (so->screen == NULL) { + PyErr_SetString(state->error, "the screen has been deleted"); + return NULL; + } + return so; +} + +/*[clinic input] +_curses.set_term + + screen: object + / + +Switch to the given screen and return the previously current screen. + +Returns None if the previous screen was the one created by initscr(). +[clinic start generated code]*/ + +static PyObject * +_curses_set_term(PyObject *module, PyObject *screen) +/*[clinic end generated code: output=204cf9c40523bdef input=ed4dba18dd9adf6a]*/ +{ + PyCursesScreenObject *so = curses_check_screen(module, screen); + if (so == NULL) { + return NULL; + } + set_term(so->screen); + if (!update_lines_cols(module)) { + return NULL; + } + cursesmodule_state *state = get_cursesmodule_state(module); + PyObject *prev = state->topscreen; /* steal the owned reference */ + state->topscreen = Py_NewRef(screen); + return prev != NULL ? prev : Py_NewRef(Py_None); +} + +#ifdef HAVE_CURSES_NEW_PRESCR +/*[clinic input] +_curses.new_prescr + +Create a screen and return it, without initializing a terminal. + +The screen can be used to call functions that affect the screen before +calling newterm() or initscr(). +[clinic start generated code]*/ + +static PyObject * +_curses_new_prescr_impl(PyObject *module) +/*[clinic end generated code: output=e7de5031da7511e2 input=1a3a89d630b641c3]*/ +{ + SCREEN *screen = new_prescr(); + if (screen == NULL) { + curses_set_null_error(module, "new_prescr", NULL); + return NULL; + } + cursesmodule_state *state = get_cursesmodule_state(module); + return PyCursesScreen_New(state, screen, NULL, NULL, NULL); +} +#endif /* HAVE_CURSES_NEW_PRESCR */ + #if defined(NCURSES_EXT_FUNCS) && NCURSES_EXT_FUNCS >= 20081102 // https://invisible-island.net/ncurses/NEWS.html#index-t20080119 @@ -4368,7 +4830,7 @@ _curses_newpad_impl(PyObject *module, int nlines, int ncols) } cursesmodule_state *state = get_cursesmodule_state(module); - return PyCursesWindow_New(state, win, NULL, NULL); + return PyCursesWindow_New(state, win, NULL, NULL, state->topscreen); } /*[clinic input] @@ -4408,7 +4870,7 @@ _curses_newwin_impl(PyObject *module, int nlines, int ncols, } cursesmodule_state *state = get_cursesmodule_state(module); - return PyCursesWindow_New(state, win, NULL, NULL); + return PyCursesWindow_New(state, win, NULL, NULL, state->topscreen); } /*[clinic input] @@ -5370,7 +5832,11 @@ static PyMethodDef cursesmodule_methods[] = { _CURSES_MOUSEMASK_METHODDEF _CURSES_NAPMS_METHODDEF _CURSES_NEWPAD_METHODDEF + _CURSES_NEWTERM_METHODDEF _CURSES_NEWWIN_METHODDEF +#ifdef HAVE_CURSES_NEW_PRESCR + _CURSES_NEW_PRESCR_METHODDEF +#endif _CURSES_NL_METHODDEF _CURSES_NOCBREAK_METHODDEF _CURSES_NOECHO_METHODDEF @@ -5394,6 +5860,7 @@ static PyMethodDef cursesmodule_methods[] = { #endif _CURSES_GET_TABSIZE_METHODDEF _CURSES_SET_TABSIZE_METHODDEF + _CURSES_SET_TERM_METHODDEF _CURSES_SETSYX_METHODDEF _CURSES_SETUPTERM_METHODDEF _CURSES_START_COLOR_METHODDEF @@ -5517,6 +5984,8 @@ cursesmodule_traverse(PyObject *mod, visitproc visit, void *arg) cursesmodule_state *state = get_cursesmodule_state(mod); Py_VISIT(state->error); Py_VISIT(state->window_type); + Py_VISIT(state->screen_type); + Py_VISIT(state->topscreen); return 0; } @@ -5526,6 +5995,8 @@ cursesmodule_clear(PyObject *mod) cursesmodule_state *state = get_cursesmodule_state(mod); Py_CLEAR(state->error); Py_CLEAR(state->window_type); + Py_CLEAR(state->screen_type); + Py_CLEAR(state->topscreen); return 0; } @@ -5558,6 +6029,14 @@ cursesmodule_exec(PyObject *module) if (PyModule_AddType(module, state->window_type) < 0) { return -1; } + state->screen_type = (PyTypeObject *)PyType_FromModuleAndSpec( + module, &PyCursesScreen_Type_spec, NULL); + if (state->screen_type == NULL) { + return -1; + } + if (PyModule_AddType(module, state->screen_type) < 0) { + return -1; + } /* Add some symbolic constants to the module */ PyObject *module_dict = PyModule_GetDict(module); diff --git a/Modules/clinic/_cursesmodule.c.h b/Modules/clinic/_cursesmodule.c.h index f577368680ef572..fcb0ee0cf61f6af 100644 --- a/Modules/clinic/_cursesmodule.c.h +++ b/Modules/clinic/_cursesmodule.c.h @@ -1874,9 +1874,9 @@ PyDoc_STRVAR(_curses_nofilter__doc__, "\n" "Undo the effect of a preceding filter() call.\n" "\n" -"Must be called before initscr(). It restores the normal behaviour\n" -"disabled by filter(), so that the next initscr() uses the full screen\n" -"rather than a single line."); +"Must be called before initscr(). It restores the normal behaviour that\n" +"filter() disables, so that the next initscr() or newterm() uses the full\n" +"screen rather than a single line."); #define _CURSES_NOFILTER_METHODDEF \ {"nofilter", (PyCFunction)_curses_nofilter, METH_NOARGS, _curses_nofilter__doc__}, @@ -2818,6 +2818,112 @@ _curses_setupterm(PyObject *module, PyObject *const *args, Py_ssize_t nargs, PyO return return_value; } +PyDoc_STRVAR(_curses_newterm__doc__, +"newterm($module, type=None, fd=None, infd=None, /)\n" +"--\n" +"\n" +"Return a new screen for the terminal, in addition to the initial screen.\n" +"\n" +" type\n" +" Terminal name; if None, the TERM environment variable is used.\n" +" fd\n" +" Output file object or descriptor (default: sys.stdout).\n" +" infd\n" +" Input file object or descriptor (default: sys.stdin).\n" +"\n" +"This is an alternative to initscr() for programs running on more than\n" +"one terminal. Use set_term() to switch between the screens."); + +#define _CURSES_NEWTERM_METHODDEF \ + {"newterm", _PyCFunction_CAST(_curses_newterm), METH_FASTCALL, _curses_newterm__doc__}, + +static PyObject * +_curses_newterm_impl(PyObject *module, const char *type, PyObject *fd, + PyObject *infd); + +static PyObject * +_curses_newterm(PyObject *module, PyObject *const *args, Py_ssize_t nargs) +{ + PyObject *return_value = NULL; + const char *type = NULL; + PyObject *fd = Py_None; + PyObject *infd = Py_None; + + if (!_PyArg_CheckPositional("newterm", nargs, 0, 3)) { + goto exit; + } + if (nargs < 1) { + goto skip_optional; + } + if (args[0] == Py_None) { + type = NULL; + } + else if (PyUnicode_Check(args[0])) { + Py_ssize_t type_length; + type = PyUnicode_AsUTF8AndSize(args[0], &type_length); + if (type == NULL) { + goto exit; + } + if (strlen(type) != (size_t)type_length) { + PyErr_SetString(PyExc_ValueError, "embedded null character"); + goto exit; + } + } + else { + _PyArg_BadArgument("newterm", "argument 1", "str or None", args[0]); + goto exit; + } + if (nargs < 2) { + goto skip_optional; + } + fd = args[1]; + if (nargs < 3) { + goto skip_optional; + } + infd = args[2]; +skip_optional: + return_value = _curses_newterm_impl(module, type, fd, infd); + +exit: + return return_value; +} + +PyDoc_STRVAR(_curses_set_term__doc__, +"set_term($module, screen, /)\n" +"--\n" +"\n" +"Switch to the given screen and return the previously current screen.\n" +"\n" +"Returns None if the previous screen was the one created by initscr()."); + +#define _CURSES_SET_TERM_METHODDEF \ + {"set_term", (PyCFunction)_curses_set_term, METH_O, _curses_set_term__doc__}, + +#if defined(HAVE_CURSES_NEW_PRESCR) + +PyDoc_STRVAR(_curses_new_prescr__doc__, +"new_prescr($module, /)\n" +"--\n" +"\n" +"Create a screen and return it, without initializing a terminal.\n" +"\n" +"The screen can be used to call functions that affect the screen before\n" +"calling newterm() or initscr()."); + +#define _CURSES_NEW_PRESCR_METHODDEF \ + {"new_prescr", (PyCFunction)_curses_new_prescr, METH_NOARGS, _curses_new_prescr__doc__}, + +static PyObject * +_curses_new_prescr_impl(PyObject *module); + +static PyObject * +_curses_new_prescr(PyObject *module, PyObject *Py_UNUSED(ignored)) +{ + return _curses_new_prescr_impl(module); +} + +#endif /* defined(HAVE_CURSES_NEW_PRESCR) */ + #if (defined(NCURSES_EXT_FUNCS) && NCURSES_EXT_FUNCS >= 20081102) PyDoc_STRVAR(_curses_get_escdelay__doc__, @@ -4453,6 +4559,10 @@ _curses_has_extended_color_support(PyObject *module, PyObject *Py_UNUSED(ignored #define _CURSES_HAS_KEY_METHODDEF #endif /* !defined(_CURSES_HAS_KEY_METHODDEF) */ +#ifndef _CURSES_NEW_PRESCR_METHODDEF + #define _CURSES_NEW_PRESCR_METHODDEF +#endif /* !defined(_CURSES_NEW_PRESCR_METHODDEF) */ + #ifndef _CURSES_GET_ESCDELAY_METHODDEF #define _CURSES_GET_ESCDELAY_METHODDEF #endif /* !defined(_CURSES_GET_ESCDELAY_METHODDEF) */ @@ -4516,4 +4626,4 @@ _curses_has_extended_color_support(PyObject *module, PyObject *Py_UNUSED(ignored #ifndef _CURSES_ASSUME_DEFAULT_COLORS_METHODDEF #define _CURSES_ASSUME_DEFAULT_COLORS_METHODDEF #endif /* !defined(_CURSES_ASSUME_DEFAULT_COLORS_METHODDEF) */ -/*[clinic end generated code: output=7494804bf2c4d1f5 input=a9049054013a1b77]*/ +/*[clinic end generated code: output=222110438e8a55e3 input=a9049054013a1b77]*/ diff --git a/configure b/configure index 12fd0d15698ac1d..a978b613514f124 100755 --- a/configure +++ b/configure @@ -30524,6 +30524,186 @@ fi + + + { printf "%s\n" "$as_me:${as_lineno-$LINENO}: checking for curses function new_prescr" >&5 +printf %s "checking for curses function new_prescr... " >&6; } +if test ${ac_cv_lib_curses_new_prescr+y} +then : + printf %s "(cached) " >&6 +else case e in #( + e) cat confdefs.h - <<_ACEOF >conftest.$ac_ext +/* end confdefs.h. */ + +#define NCURSES_OPAQUE 0 +#if defined(HAVE_NCURSESW_NCURSES_H) +# include +#elif defined(HAVE_NCURSESW_CURSES_H) +# include +#elif defined(HAVE_NCURSES_NCURSES_H) +# include +#elif defined(HAVE_NCURSES_CURSES_H) +# include +#elif defined(HAVE_NCURSES_H) +# include +#elif defined(HAVE_CURSES_H) +# include +#endif + +int +main (void) +{ + + #ifndef new_prescr + void *x=new_prescr + #endif + + ; + return 0; +} +_ACEOF +if ac_fn_c_try_compile "$LINENO" +then : + ac_cv_lib_curses_new_prescr=yes +else case e in #( + e) ac_cv_lib_curses_new_prescr=no ;; +esac +fi +rm -f core conftest.err conftest.$ac_objext conftest.beam conftest.$ac_ext + ;; +esac +fi +{ printf "%s\n" "$as_me:${as_lineno-$LINENO}: result: $ac_cv_lib_curses_new_prescr" >&5 +printf "%s\n" "$ac_cv_lib_curses_new_prescr" >&6; } + if test "x$ac_cv_lib_curses_new_prescr" = xyes +then : + +printf "%s\n" "#define HAVE_CURSES_NEW_PRESCR 1" >>confdefs.h + +fi + + + + + + { printf "%s\n" "$as_me:${as_lineno-$LINENO}: checking for curses function use_screen" >&5 +printf %s "checking for curses function use_screen... " >&6; } +if test ${ac_cv_lib_curses_use_screen+y} +then : + printf %s "(cached) " >&6 +else case e in #( + e) cat confdefs.h - <<_ACEOF >conftest.$ac_ext +/* end confdefs.h. */ + +#define NCURSES_OPAQUE 0 +#if defined(HAVE_NCURSESW_NCURSES_H) +# include +#elif defined(HAVE_NCURSESW_CURSES_H) +# include +#elif defined(HAVE_NCURSES_NCURSES_H) +# include +#elif defined(HAVE_NCURSES_CURSES_H) +# include +#elif defined(HAVE_NCURSES_H) +# include +#elif defined(HAVE_CURSES_H) +# include +#endif + +int +main (void) +{ + + #ifndef use_screen + void *x=use_screen + #endif + + ; + return 0; +} +_ACEOF +if ac_fn_c_try_compile "$LINENO" +then : + ac_cv_lib_curses_use_screen=yes +else case e in #( + e) ac_cv_lib_curses_use_screen=no ;; +esac +fi +rm -f core conftest.err conftest.$ac_objext conftest.beam conftest.$ac_ext + ;; +esac +fi +{ printf "%s\n" "$as_me:${as_lineno-$LINENO}: result: $ac_cv_lib_curses_use_screen" >&5 +printf "%s\n" "$ac_cv_lib_curses_use_screen" >&6; } + if test "x$ac_cv_lib_curses_use_screen" = xyes +then : + +printf "%s\n" "#define HAVE_CURSES_USE_SCREEN 1" >>confdefs.h + +fi + + + + + + { printf "%s\n" "$as_me:${as_lineno-$LINENO}: checking for curses function use_window" >&5 +printf %s "checking for curses function use_window... " >&6; } +if test ${ac_cv_lib_curses_use_window+y} +then : + printf %s "(cached) " >&6 +else case e in #( + e) cat confdefs.h - <<_ACEOF >conftest.$ac_ext +/* end confdefs.h. */ + +#define NCURSES_OPAQUE 0 +#if defined(HAVE_NCURSESW_NCURSES_H) +# include +#elif defined(HAVE_NCURSESW_CURSES_H) +# include +#elif defined(HAVE_NCURSES_NCURSES_H) +# include +#elif defined(HAVE_NCURSES_CURSES_H) +# include +#elif defined(HAVE_NCURSES_H) +# include +#elif defined(HAVE_CURSES_H) +# include +#endif + +int +main (void) +{ + + #ifndef use_window + void *x=use_window + #endif + + ; + return 0; +} +_ACEOF +if ac_fn_c_try_compile "$LINENO" +then : + ac_cv_lib_curses_use_window=yes +else case e in #( + e) ac_cv_lib_curses_use_window=no ;; +esac +fi +rm -f core conftest.err conftest.$ac_objext conftest.beam conftest.$ac_ext + ;; +esac +fi +{ printf "%s\n" "$as_me:${as_lineno-$LINENO}: result: $ac_cv_lib_curses_use_window" >&5 +printf "%s\n" "$ac_cv_lib_curses_use_window" >&6; } + if test "x$ac_cv_lib_curses_use_window" = xyes +then : + +printf "%s\n" "#define HAVE_CURSES_USE_WINDOW 1" >>confdefs.h + +fi + + + CPPFLAGS=$ac_save_cppflags fi diff --git a/configure.ac b/configure.ac index 3b42fdfe40385dc..a2729c22386a951 100644 --- a/configure.ac +++ b/configure.ac @@ -7196,6 +7196,9 @@ PY_CHECK_CURSES_FUNC([nofilter]) PY_CHECK_CURSES_FUNC([has_key]) PY_CHECK_CURSES_FUNC([typeahead]) PY_CHECK_CURSES_FUNC([use_env]) +PY_CHECK_CURSES_FUNC([new_prescr]) +PY_CHECK_CURSES_FUNC([use_screen]) +PY_CHECK_CURSES_FUNC([use_window]) CPPFLAGS=$ac_save_cppflags ])dnl have_curses != no ])dnl save env diff --git a/pyconfig.h.in b/pyconfig.h.in index 2bef8d38497c547..a84b74299e159b1 100644 --- a/pyconfig.h.in +++ b/pyconfig.h.in @@ -218,6 +218,9 @@ /* Define if you have the 'is_term_resized' function. */ #undef HAVE_CURSES_IS_TERM_RESIZED +/* Define if you have the 'new_prescr' function. */ +#undef HAVE_CURSES_NEW_PRESCR + /* Define if you have the 'nofilter' function. */ #undef HAVE_CURSES_NOFILTER @@ -236,6 +239,12 @@ /* Define if you have the 'use_env' function. */ #undef HAVE_CURSES_USE_ENV +/* Define if you have the 'use_screen' function. */ +#undef HAVE_CURSES_USE_SCREEN + +/* Define if you have the 'use_window' function. */ +#undef HAVE_CURSES_USE_WINDOW + /* Define if you have the 'wchgat' function. */ #undef HAVE_CURSES_WCHGAT From d27487e2f39e34b9572c17e2bfdd65a93c0e91c5 Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Sat, 20 Jun 2026 01:34:44 +0300 Subject: [PATCH 2/8] Drain the pty masters in ScreenTests and release the GIL in endwin() ScreenTests drives curses over pseudo-terminals whose master ends are never read. On macOS (unlike Linux) the tcdrain() that curses performs inside endwin(), and even a plain write(), blocks once the unread output fills the pty buffer, so the test hung until the timeout. Drain the masters synchronously before endwin(), leaving room for its output. Also release the GIL around the endwin() call, so that it no longer blocks other threads while it talks to the terminal. Co-Authored-By: Claude Opus 4.8 (1M context) --- Lib/test/test_curses.py | 17 +++++++++++++++++ Modules/_cursesmodule.c | 13 ++++++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/Lib/test/test_curses.py b/Lib/test/test_curses.py index 6cb895c8b2c1ce8..ec1e812abbf034b 100644 --- a/Lib/test/test_curses.py +++ b/Lib/test/test_curses.py @@ -1773,11 +1773,26 @@ def setUp(self): self.save_signals = SaveSignals() self.save_signals.save() self.addCleanup(self.save_signals.restore) + self.pty_masters = [] + + def drain_ptys(self): + # Discard whatever curses has written to the screens. Nothing reads + # the master end, so on platforms such as macOS (but not Linux) the + # tcdrain() that curses performs inside endwin() -- and even a plain + # write() -- blocks once the unread output fills the pty buffer. + # Draining here, before endwin(), leaves room for its output to drain. + for master in self.pty_masters: + try: + while os.read(master, 65536): + pass + except BlockingIOError: + pass def tearDown(self): # Leave visual mode and reclaim the screens the test created, while # their pseudo-terminals are still open (closing them happens later, # via the make_pty() cleanups). + self.drain_ptys() try: curses.endwin() except curses.error: @@ -1786,6 +1801,8 @@ def tearDown(self): def make_pty(self): master, slave = os.openpty() + os.set_blocking(master, False) + self.pty_masters.append(master) self.addCleanup(os.close, master) self.addCleanup(os.close, slave) return slave diff --git a/Modules/_cursesmodule.c b/Modules/_cursesmodule.c index 192264209a19ee0..038c1a9583ae7ac 100644 --- a/Modules/_cursesmodule.c +++ b/Modules/_cursesmodule.c @@ -3723,7 +3723,18 @@ De-initialize the library, and return terminal to normal status. static PyObject * _curses_endwin_impl(PyObject *module) /*[clinic end generated code: output=c0150cd96d2f4128 input=e172cfa43062f3fa]*/ -NoArgNoReturnFunctionBody(endwin) +{ + PyCursesStatefulInitialised(module); + + /* endwin() writes to the terminal and may call tcdrain(), which can block + (e.g. on a pty whose output is not being read); release the GIL so other + threads -- including one draining that terminal -- can run meanwhile. */ + int code; + Py_BEGIN_ALLOW_THREADS + code = endwin(); + Py_END_ALLOW_THREADS + return curses_check_err(module, code, "endwin", NULL); +} /*[clinic input] _curses.erasechar From 6d4823707cd5642830a61990fd9595612a0c3bc3 Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Sat, 20 Jun 2026 10:47:41 +0300 Subject: [PATCH 3/8] Drain the pty masters continuously from a background thread The synchronous drain before endwin() is not enough on macOS: endwin()'s own output can again exceed the pty buffer, so its tcdrain() blocks even after the buffer was emptied. Drain each master continuously from a background thread instead; the endwin() GIL release lets that thread run while endwin() blocks. Co-Authored-By: Claude Opus 4.8 (1M context) --- Lib/test/test_curses.py | 42 ++++++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/Lib/test/test_curses.py b/Lib/test/test_curses.py index ec1e812abbf034b..40fe558182071ec 100644 --- a/Lib/test/test_curses.py +++ b/Lib/test/test_curses.py @@ -4,12 +4,13 @@ import string import sys import tempfile +import threading import unittest from unittest.mock import MagicMock from test.support import (requires, verbose, SaveSignals, cpython_only, check_disallow_instantiation, MISSING_C_DOCSTRINGS, - gc_collect) + gc_collect, SHORT_TIMEOUT) from test.support.import_helper import import_module # Optionally test curses module. This currently requires that the @@ -1773,36 +1774,35 @@ def setUp(self): self.save_signals = SaveSignals() self.save_signals.save() self.addCleanup(self.save_signals.restore) - self.pty_masters = [] - - def drain_ptys(self): - # Discard whatever curses has written to the screens. Nothing reads - # the master end, so on platforms such as macOS (but not Linux) the - # tcdrain() that curses performs inside endwin() -- and even a plain - # write() -- blocks once the unread output fills the pty buffer. - # Draining here, before endwin(), leaves room for its output to drain. - for master in self.pty_masters: - try: - while os.read(master, 65536): - pass - except BlockingIOError: - pass def tearDown(self): - # Leave visual mode and reclaim the screens the test created, while - # their pseudo-terminals are still open (closing them happens later, - # via the make_pty() cleanups). - self.drain_ptys() + # Leave visual mode and reclaim the test's screens while their + # pseudo-terminals are still open (make_pty() closes them later). try: curses.endwin() except curses.error: pass gc_collect() + @staticmethod + def _drain_pty(master): + # Read and discard whatever curses writes to the screen. + try: + while os.read(master, 1024): + pass + except OSError: + pass + def make_pty(self): master, slave = os.openpty() - os.set_blocking(master, False) - self.pty_masters.append(master) + # Nothing reads the master end, so writing to the slave -- and the + # tcdrain() inside endwin() -- can block once the pty buffer fills (on + # macOS, not Linux). Drain it from a background thread; endwin() + # releases the GIL so the thread runs while endwin() blocks. + reader = threading.Thread(target=self._drain_pty, args=(master,), + daemon=True) + reader.start() + self.addCleanup(reader.join, SHORT_TIMEOUT) self.addCleanup(os.close, master) self.addCleanup(os.close, slave) return slave From 228f9771d6bf52a42bd6273b3215ef5a4c96b8a8 Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Sat, 20 Jun 2026 11:28:55 +0300 Subject: [PATCH 4/8] Close the pty master before the slave in ScreenTests On macOS, closing the pty slave while the master is still open blocks until the slave's pending output drains. Closing the master first stops the reader thread and leaves nothing for the slave close to wait for. Co-Authored-By: Claude Opus 4.8 (1M context) --- Lib/test/test_curses.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/Lib/test/test_curses.py b/Lib/test/test_curses.py index 40fe558182071ec..34bfed99f2102d7 100644 --- a/Lib/test/test_curses.py +++ b/Lib/test/test_curses.py @@ -1795,16 +1795,17 @@ def _drain_pty(master): def make_pty(self): master, slave = os.openpty() - # Nothing reads the master end, so writing to the slave -- and the - # tcdrain() inside endwin() -- can block once the pty buffer fills (on - # macOS, not Linux). Drain it from a background thread; endwin() - # releases the GIL so the thread runs while endwin() blocks. + # Nothing reads the master end, so writing to the slave and the + # tcdrain() in endwin() can block on macOS once the pty buffer fills; + # drain it from a background thread (endwin() releases the GIL). reader = threading.Thread(target=self._drain_pty, args=(master,), daemon=True) reader.start() self.addCleanup(reader.join, SHORT_TIMEOUT) - self.addCleanup(os.close, master) + # Close the master first (cleanups run in reverse): on macOS, closing + # the slave first blocks until its pending output drains. self.addCleanup(os.close, slave) + self.addCleanup(os.close, master) return slave def test_newterm(self): From 2165c4962356540202bcbc0c6a40ab3ee72de854 Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Sat, 20 Jun 2026 12:00:39 +0300 Subject: [PATCH 5/8] Stop the pty reader thread before closing the fds in ScreenTests On macOS, closing either end of a pty while a thread is blocked in read() on the master hangs. Drain with a poll() loop that a stop Event can interrupt, and stop and join the reader before closing the descriptors. Co-Authored-By: Claude Opus 4.8 (1M context) --- Lib/test/test_curses.py | 38 ++++++++++++++++++++++++++------------ 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/Lib/test/test_curses.py b/Lib/test/test_curses.py index 34bfed99f2102d7..aaa3c29029cc71f 100644 --- a/Lib/test/test_curses.py +++ b/Lib/test/test_curses.py @@ -1,6 +1,7 @@ import functools import inspect import os +import select import string import sys import tempfile @@ -1785,27 +1786,40 @@ def tearDown(self): gc_collect() @staticmethod - def _drain_pty(master): - # Read and discard whatever curses writes to the screen. - try: - while os.read(master, 1024): - pass - except OSError: - pass + def _drain_pty(master, stop): + # Read and discard whatever curses writes to the screen, until asked to + # stop and nothing more is pending. poll() rather than a blocking + # read() so we can stop without closing the fd (closing it while this + # thread is blocked in read() hangs on macOS). + poller = select.poll() + poller.register(master, select.POLLIN) + while True: + if poller.poll(100): + try: + if not os.read(master, 1024): + break # EOF + except OSError: + break + elif stop.is_set(): + break def make_pty(self): master, slave = os.openpty() # Nothing reads the master end, so writing to the slave and the # tcdrain() in endwin() can block on macOS once the pty buffer fills; # drain it from a background thread (endwin() releases the GIL). - reader = threading.Thread(target=self._drain_pty, args=(master,), + stop = threading.Event() + reader = threading.Thread(target=self._drain_pty, args=(master, stop), daemon=True) reader.start() - self.addCleanup(reader.join, SHORT_TIMEOUT) - # Close the master first (cleanups run in reverse): on macOS, closing - # the slave first blocks until its pending output drains. - self.addCleanup(os.close, slave) + # Stop and join the reader before closing the fds: on macOS, closing + # either end while the reader is blocked in read() hangs. + def stop_reader(): + stop.set() + reader.join(SHORT_TIMEOUT) self.addCleanup(os.close, master) + self.addCleanup(os.close, slave) + self.addCleanup(stop_reader) return slave def test_newterm(self): From 03f0d8c9835e796f5108f524aee9f21296d79c87 Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Sat, 20 Jun 2026 12:55:09 +0300 Subject: [PATCH 6/8] Collect each test's screen reference cycle in TestCurses Each test's newterm() screen forms a window<->screen reference cycle, reclaimed only by the cyclic GC. Collect it while the screen is still current, so the windows' delwin() succeeds; collected later, on a non-current screen, it fails (an unraisable error that trips --fail-env-changed on macOS). Co-Authored-By: Claude Opus 4.8 (1M context) --- Lib/test/test_curses.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/Lib/test/test_curses.py b/Lib/test/test_curses.py index aaa3c29029cc71f..9fbda318820880b 100644 --- a/Lib/test/test_curses.py +++ b/Lib/test/test_curses.py @@ -124,9 +124,11 @@ def setUp(self): infd = stdout_fd self.screen = curses.newterm(term, stdout_fd, infd) self.stdscr = self.screen.stdscr - # Drop the screen after the test so the screens do not pile up: a - # window keeps its screen alive through a reference cycle, and - # unittest keeps every test instance for the whole run. + # Drop the screen after the test, and collect the window<->screen + # reference cycle while the screen is still current, so delwin() + # succeeds; collected later, on a non-current screen, it fails + # (unraisable on macOS). + self.addCleanup(gc_collect) self.addCleanup(setattr, self, 'screen', None) self.addCleanup(setattr, self, 'stdscr', None) else: From 6fb6465daf6c296defcfd390a07e2dc0552e51b8 Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Sat, 20 Jun 2026 13:28:37 +0300 Subject: [PATCH 7/8] Add screen.close() to break the screen/window reference cycle A screen and its standard window reference each other, so a screen is only reclaimed by the cyclic garbage collector. screen.close() detaches the standard window -- clearing the cycle and the window so its delwin() is skipped (delscreen() frees it instead) -- letting the screen be released by reference counting. Afterwards screen.stdscr is None and the old window raises curses.error. Use it in the tests instead of an explicit gc.collect(). Co-Authored-By: Claude Opus 4.8 (1M context) --- Doc/library/curses.rst | 14 ++++++++++++++ Lib/test/test_curses.py | 24 +++++++++++++++++++----- Modules/_cursesmodule.c | 16 ++++++++++++++++ 3 files changed, 49 insertions(+), 5 deletions(-) diff --git a/Doc/library/curses.rst b/Doc/library/curses.rst index 2089b76092129e7..2808b6259497aa3 100644 --- a/Doc/library/curses.rst +++ b/Doc/library/curses.rst @@ -1462,6 +1462,20 @@ Screen objects .. versionadded:: next +.. method:: screen.close() + + Detach the screen's standard window, + breaking the reference cycle between them + so the screen can be reclaimed promptly instead of waiting for a + garbage collection. + Afterwards :attr:`~screen.stdscr` is ``None`` + and the window it returned earlier can no longer be used. + The screen's resources are released + once it and all its windows are no longer referenced. + + .. versionadded:: next + + .. attribute:: screen.stdscr The standard :ref:`window ` of the screen, diff --git a/Lib/test/test_curses.py b/Lib/test/test_curses.py index 9fbda318820880b..3475a689ec6e866 100644 --- a/Lib/test/test_curses.py +++ b/Lib/test/test_curses.py @@ -124,11 +124,12 @@ def setUp(self): infd = stdout_fd self.screen = curses.newterm(term, stdout_fd, infd) self.stdscr = self.screen.stdscr - # Drop the screen after the test, and collect the window<->screen - # reference cycle while the screen is still current, so delwin() - # succeeds; collected later, on a non-current screen, it fails - # (unraisable on macOS). - self.addCleanup(gc_collect) + # Close the screen after the test: it breaks the window<->screen + # reference cycle and detaches the standard window so its delwin() + # is skipped. Otherwise the window is collected during a later test + # whose screen is no longer current, and delwin() fails (unraisable + # on macOS). + self.addCleanup(self.screen.close) self.addCleanup(setattr, self, 'screen', None) self.addCleanup(setattr, self, 'stdscr', None) else: @@ -1869,6 +1870,19 @@ def test_screen_freed(self): del a gc_collect() + def test_close(self): + s = self.make_pty() + screen = curses.newterm('xterm', s, s) + win = screen.stdscr + self.assertIsInstance(win, curses.window) + screen.close() + # After close() the standard window is detached and unusable, and + # stdscr is None. No reference cycle remains. + self.assertIsNone(screen.stdscr) + self.assertRaises(curses.error, win.addstr, 0, 0, 'x') + # close() is idempotent. + screen.close() + @unittest.skipUnless(hasattr(curses, 'new_prescr'), 'requires curses.new_prescr()') def test_new_prescr(self): diff --git a/Modules/_cursesmodule.c b/Modules/_cursesmodule.c index 038c1a9583ae7ac..e55b6562294ab2d 100644 --- a/Modules/_cursesmodule.c +++ b/Modules/_cursesmodule.c @@ -3339,7 +3339,23 @@ PyCursesScreen_use(PyObject *self, PyObject *args, PyObject *kwargs) } #endif /* HAVE_CURSES_USE_SCREEN */ +PyDoc_STRVAR(PyCursesScreen_close__doc__, +"close($self, /)\n--\n\n" +"Detach the screen's standard window, breaking their reference cycle.\n\n" +"Afterwards the stdscr attribute is None and the window it returned earlier\n" +"can no longer be used. The screen is released once it and its windows are\n" +"no longer referenced."); + +static PyObject * +PyCursesScreen_close(PyObject *self, PyObject *Py_UNUSED(ignored)) +{ + (void)PyCursesScreen_clear(self); + Py_RETURN_NONE; +} + static PyMethodDef PyCursesScreen_methods[] = { + {"close", PyCursesScreen_close, METH_NOARGS, + PyCursesScreen_close__doc__}, #ifdef HAVE_CURSES_USE_SCREEN {"use", _PyCFunction_CAST(PyCursesScreen_use), METH_VARARGS | METH_KEYWORDS, PyCursesScreen_use__doc__}, From b3792c3e7e3b6ced28a8c2072ab9cadfbdad5867 Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Sat, 20 Jun 2026 14:04:32 +0300 Subject: [PATCH 8/8] Clean up the panel created by test_userptr_segfault Unlike the sibling panel tests, test_userptr_segfault never removed its panel, so the window it wrapped was reachable only through a panel<->userptr __del__ cycle and was collected during a much later test, where delwin() failed on macOS (--fail-env-changed). Give it the addCleanup(self._delete_panels, ...) the other panel tests use; the per-test screen.close() in setUp then keeps the teardown free of lingering cycles. Co-Authored-By: Claude Opus 4.8 (1M context) --- Lib/test/test_curses.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/Lib/test/test_curses.py b/Lib/test/test_curses.py index 3475a689ec6e866..796dfe1e5168e1b 100644 --- a/Lib/test/test_curses.py +++ b/Lib/test/test_curses.py @@ -124,11 +124,10 @@ def setUp(self): infd = stdout_fd self.screen = curses.newterm(term, stdout_fd, infd) self.stdscr = self.screen.stdscr - # Close the screen after the test: it breaks the window<->screen - # reference cycle and detaches the standard window so its delwin() - # is skipped. Otherwise the window is collected during a later test - # whose screen is no longer current, and delwin() fails (unraisable - # on macOS). + # Close the screen after the test to break its window<->screen + # reference cycle deterministically, rather than leaving it for the + # cyclic GC to collect during a much later test (where a window's + # delwin() can fail -- an unraisable error on macOS). self.addCleanup(self.screen.close) self.addCleanup(setattr, self, 'screen', None) self.addCleanup(setattr, self, 'stdscr', None) @@ -1196,6 +1195,10 @@ def test_userptr_memory_leak(self): def test_userptr_segfault(self): w = curses.newwin(10, 10) panel = curses.panel.new_panel(w) + # set_userptr(A()) makes a panel<->userptr reference cycle (A.__del__ + # closes over panel); clean it up so the panel and its window do not + # linger until a later test collects them. + self.addCleanup(self._delete_panels, panel) class A: def __del__(self): panel.set_userptr(None)