diff --git a/Doc/library/curses.rst b/Doc/library/curses.rst index c1afdb71c89e88..b395433e9fbef7 100644 --- a/Doc/library/curses.rst +++ b/Doc/library/curses.rst @@ -1299,6 +1299,8 @@ Reading input Read a bytes object from the user, with primitive line editing capacity. At most *n* characters are read; *n* defaults to and cannot exceed 2047. + Unlike the window readers such as :meth:`instr`, this limit is on keyboard + input and is not derived from the window geometry. A multibyte character is returned as its encoded bytes; use :meth:`get_wstr` to read the input as a :class:`str`. @@ -1357,23 +1359,30 @@ Reading window contents Return a bytes object of characters, extracted from the window starting at the current cursor position, or at *y*, *x* if specified, and stopping at the end of the line. Attributes and color information are stripped - from the characters. If *n* is specified, :meth:`instr` returns a string - at most *n* characters long (exclusive of the trailing NUL). - The maximum value for *n* is 2047. + from the characters. If *n* is specified and nonnegative, :meth:`instr` + returns a string at most *n* characters long (exclusive of the trailing NUL); + by default, or if *n* is negative, the rest of the line is returned. A character not representable in the window's encoding cannot be returned; use :meth:`in_wstr` for those. .. versionchanged:: 3.14 The maximum value for *n* was increased from 1023 to 2047. + .. versionchanged:: next + The 2047 limit was removed: the read is bounded by the window, so a line + longer than 2047 characters is no longer silently truncated. *n* now + defaults to ``-1``, meaning the rest of the line. + .. method:: window.in_wstr([n]) window.in_wstr(y, x[, n]) Return a string of characters, extracted from the window starting at the - current cursor position, or at *y*, *x* if specified. Unlike :meth:`instr`, - it can return characters that are not representable in the window's encoding. - Attributes and color information are stripped from the characters. The - maximum value for *n* is 2047. + current cursor position, or at *y*, *x* if specified, and stopping at the end + of the line. Unlike :meth:`instr`, it can return characters that are not + representable in the window's encoding. Attributes and color information are + stripped from the characters. If *n* is specified and nonnegative, at most *n* + characters are returned; by default, or if *n* is negative, the rest of the line + is returned. .. versionadded:: next @@ -1384,8 +1393,9 @@ Reading window contents starting at the current cursor position, or at *y*, *x* if specified, and stopping at the end of the line. This is the variant of :meth:`instr` and :meth:`in_wstr` that *keeps* each cell's attributes and color pair (those - methods strip the rendition). If *n* is specified, at most *n* cells are - returned. The maximum value for *n* is 2047. + methods strip the rendition). If *n* is specified and nonnegative, at most + *n* cells are returned; by default, or if *n* is negative, the rest of the + line is returned. The result can be written back unchanged with :meth:`addstr` (a read and a re-write is a round-trip that preserves every cell's rendition). diff --git a/Lib/test/test_curses.py b/Lib/test/test_curses.py index ea2dcd76b585a9..7bca7b6ab470c5 100644 --- a/Lib/test/test_curses.py +++ b/Lib/test/test_curses.py @@ -1188,8 +1188,9 @@ def test_read_from_window(self): self.assertEqual(stdscr.instr(3)[:6], b' AB') self.assertEqual(stdscr.instr(0, 2)[:4], b'BCD ') self.assertEqual(stdscr.instr(0, 2, 4), b'BCD ') - self.assertRaises(ValueError, stdscr.instr, -2) - self.assertRaises(ValueError, stdscr.instr, 0, 2, -2) + # A negative n means the rest of the line, as it does for chgat(). + self.assertEqual(stdscr.instr(-2), stdscr.instr()) + self.assertEqual(stdscr.instr(0, 2, -2), stdscr.instr(0, 2)) # instr(y, x, 1) reads a single cell byte, so only a character that the # window encoding maps to one byte is checked. inch() returns the cell # value, which is the locale byte. @@ -1206,6 +1207,27 @@ def test_read_from_window(self): self.assertEqual(stdscr.instr(2, 0, 1), b) self.assertEqual(stdscr.inch(2, 0), v) + def test_read_wide_pad(self): + # gh-156230: a window read is bounded by the window, not by a fixed + # buffer size, so a pad wider than 2047 columns is not truncated. + width = 3000 + pad = curses.newpad(1, width) + pad.addstr(0, 2500, 'X') + + line = pad.instr(0, 0) + self.assertEqual(len(line), width) + self.assertEqual(line[2500:2501], b'X') + # The count is clamped to the columns left on the line, and an + # explicit count still limits the read. + self.assertEqual(len(pad.instr(0, 2900)), 100) + self.assertEqual(len(pad.instr(0, 0, 5)), 5) + self.assertEqual(len(pad.instr(0, 0, width + 1000)), width) + + self.assertEqual(len(pad.in_wstr(0, 0)), width) + self.assertEqual(pad.in_wstr(0, 0)[2500], 'X') + self.assertEqual(len(pad.in_wchstr(0, 0)), width) + self.assertEqual(str(pad.in_wchstr(0, 2500, 1)), 'X') + def test_coordinate_errors(self): # Addressing a cell outside the window raises curses.error. win = curses.newwin(5, 10, 0, 0) diff --git a/Misc/NEWS.d/next/Library/2026-08-23-09-08-55.gh-issue-156230.nliia3.rst b/Misc/NEWS.d/next/Library/2026-08-23-09-08-55.gh-issue-156230.nliia3.rst new file mode 100644 index 00000000000000..0b950706150080 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-08-23-09-08-55.gh-issue-156230.nliia3.rst @@ -0,0 +1,2 @@ +Fix :meth:`curses.window.instr`, :meth:`curses.window.in_wstr` and :meth:`curses.window.in_wchstr` silently truncating at 2047. The count is +now bounded by the window, so a pad wider than 2047 columns is read in full, and *n* defaults to ``-1``, meaning the rest of the line. diff --git a/Modules/_cursesmodule.c b/Modules/_cursesmodule.c index 7cc72b96d0a46d..003a71c672845e 100644 --- a/Modules/_cursesmodule.c +++ b/Modules/_cursesmodule.c @@ -3801,28 +3801,82 @@ _curses_window_inch_impl(PyCursesWindowObject *self, int group_right_1, return PyLong_FromUnsignedLongLong(rtn); } +/* Maximum number of characters, and of bytes, that one cell can hold: a + spacing character optionally followed by CCHARW_MAX -1 combining + characters, each encoding to at most MB_CUR_MAX bytes. A narrow build + stores a single byte per call. */ +#ifdef HAVE_NCURSESW +# define CURSES_CELL_CHARS ((size_t)CCHARW_MAX) +# define CURSES_CELL_BYTES (CURSES_CELL_CHARS * (size_t)MB_CUR_MAX) +#else +# define CURSES_CELL_CHARS ((size_t)1) +# define CURSES_CELL_BYTES ((size_t)1) +#endif + +/* Number of units that can be read from the read position to the end of the + line, where units_per_cell is what the caller counts per cell: 1 for cells, + CURSES_CELL_CHARS for wide characters, CURSES_CELL_BYTES for bytes. + Return 0 if the position lies outside the window: the curses call then fails + and the caller returns an empty result. */ +static size_t +curses_window_line_size(PyCursesWindowObject *self, int use_xy, int y, int x, + size_t units_per_cell) +{ + int cury, curx, maxy, maxx; + + getmaxyx(self->win, maxy, maxx); + if (use_xy) { + cury = y; + curx = x; + } + else { + getyx(self->win, cury, curx); + } + if (cury < 0 || cury >= maxy || curx < 0 || curx >= maxx) { + return 0; + } + size_t cells = (size_t)maxx - (size_t)curx; + if (cells > (size_t)INT_MAX / units_per_cell) { + return (size_t)INT_MAX; + } + return cells * units_per_cell; +} + +/* Clamp the requested count to what is left on the line; a negative count + means the whole rest of the line. */ +static size_t +curses_window_read_size(PyCursesWindowObject *self, int use_xy, int y, int x, + int n, size_t units_per_cell) +{ + size_t max_size = curses_window_line_size(self, use_xy, y, x, + units_per_cell); + return n < 0 ? max_size : Py_MIN((size_t)n, max_size); +} + /* Extract characters from the window into a new bytes object (empty on ERR), with attributes and color stripped. Shared by instr() and, without the wide library, by in_wstr(). */ static PyObject * curses_window_instr_bytes(PyCursesWindowObject *self, int use_xy, - int y, int x, unsigned int n) + int y, int x, int n) { int rtn; - unsigned int max_buf_size = 2048; + size_t size = curses_window_read_size(self, use_xy, y, x, n, + CURSES_CELL_BYTES); - n = Py_MIN(n, max_buf_size - 1); - PyBytesWriter *writer = PyBytesWriter_Create(n + 1); + PyBytesWriter *writer = PyBytesWriter_Create((Py_ssize_t)size + 1); if (writer == NULL) { return NULL; } char *buf = PyBytesWriter_GetData(writer); + /* curses does not terminate the buffer when nothing is read. */ + buf[0] = '\0'; if (use_xy) { - rtn = mvwinnstr(self->win, y, x, buf, n); + rtn = mvwinnstr(self->win, y, x, buf, (int)size); } else { - rtn = winnstr(self->win, buf, n); + rtn = winnstr(self->win, buf, (int)size); } if (rtn == ERR) { @@ -3841,8 +3895,8 @@ _curses.window.instr x: int X-coordinate. ] - n: unsigned_int = 2047 - Maximal number of characters. + n: int = -1 + Maximal number of characters; negative means to the end of the line. / Return a string of characters, extracted from the window. @@ -3850,15 +3904,16 @@ Return a string of characters, extracted from the window. Return a string of characters, extracted from the window starting at the current cursor position, or at y, x if specified, and stopping at the end of the line. Attributes and color -information are stripped from the characters. If n is specified, -instr() returns a string at most n characters long (exclusive of -the trailing NUL). +information are stripped from the characters. If n is specified +and nonnegative, instr() returns a string at most n characters +long (exclusive of the trailing NUL); by default, or if n is +negative, the rest of the line is returned. [clinic start generated code]*/ static PyObject * _curses_window_instr_impl(PyCursesWindowObject *self, int group_left_1, - int y, int x, unsigned int n) -/*[clinic end generated code: output=40081f67070132da input=85e62048d2d92642]*/ + int y, int x, int n) +/*[clinic end generated code: output=55246cfe039301bb input=6c052d79ef35ce11]*/ { return curses_window_instr_bytes(self, group_left_1, y, x, n); } @@ -3956,8 +4011,8 @@ _curses.window.in_wstr x: int X-coordinate. ] - n: unsigned_int = 2047 - Maximal number of characters. + n: int = -1 + Maximal number of characters; negative means to the end of the line. / Return a string of characters, extracted from the window. @@ -3967,24 +4022,24 @@ This is the wide-character variant of instr(); it returns a str. static PyObject * _curses_window_in_wstr_impl(PyCursesWindowObject *self, int group_left_1, - int y, int x, unsigned int n) -/*[clinic end generated code: output=e3db72a1f10b9875 input=196703989dc57361]*/ + int y, int x, int n) +/*[clinic end generated code: output=5137f0184e742a46 input=5fb03546802762cb]*/ { #ifdef HAVE_NCURSESW int rtn; - unsigned int max_buf_size = 2048; + size_t size = curses_window_read_size(self, group_left_1, y, x, n, + CURSES_CELL_CHARS); - n = Py_MIN(n, max_buf_size - 1); - wchar_t *buf = PyMem_New(wchar_t, n + 1); + wchar_t *buf = PyMem_New(wchar_t, size + 1); if (buf == NULL) { return PyErr_NoMemory(); } if (group_left_1) { - rtn = mvwinnwstr(self->win, y, x, buf, n); + rtn = mvwinnwstr(self->win, y, x, buf, (int)size); } else { - rtn = winnwstr(self->win, buf, n); + rtn = winnwstr(self->win, buf, (int)size); } if (rtn == ERR) { @@ -4018,8 +4073,8 @@ _curses.window.in_wchstr x: int X-coordinate. ] - n: unsigned_int = 2047 - Maximal number of cells. + n: int = -1 + Maximal number of cells; negative means to the end of the line. / Return a complexstr of the styled cells extracted from the window. @@ -4031,28 +4086,27 @@ complexstr. static PyObject * _curses_window_in_wchstr_impl(PyCursesWindowObject *self, int group_left_1, - int y, int x, unsigned int n) -/*[clinic end generated code: output=7fb5216f2088835b input=b725c0b8abff62c2]*/ + int y, int x, int n) +/*[clinic end generated code: output=ce23cd8f85982884 input=8a1138d6fae0e389]*/ { int rtn; - unsigned int max_buf_size = 2048; + size_t size = curses_window_read_size(self, group_left_1, y, x, n, 1); - n = Py_MIN(n, max_buf_size - 1); cursesmodule_state *state = get_cursesmodule_state_by_win(self); /* Zero the cells: reading a cell back through getcchar() relies on the cchar_t text array being NUL-terminated, which some curses libraries only guarantee for the characters they actually write. */ - curses_cell_t *buf = PyMem_Calloc(n + 1, sizeof(curses_cell_t)); + curses_cell_t *buf = PyMem_Calloc(size + 1, sizeof(curses_cell_t)); if (buf == NULL) { return PyErr_NoMemory(); } #ifdef HAVE_NCURSESW if (group_left_1) { - rtn = mvwin_wchnstr(self->win, y, x, buf, n); + rtn = mvwin_wchnstr(self->win, y, x, buf, (int)size); } else { - rtn = win_wchnstr(self->win, buf, n); + rtn = win_wchnstr(self->win, buf, (int)size); } if (rtn == ERR) { @@ -4064,7 +4118,7 @@ _curses_window_in_wchstr_impl(PyCursesWindowObject *self, int group_left_1, the actual count; every real cell holds at least a space, so the first empty cell marks the end of the run. */ Py_ssize_t count = 0; - while (count < (Py_ssize_t)n) { + while (count < (Py_ssize_t)size) { wchar_t wstr[CCHARW_MAX + 1]; attr_t attrs; int pair; @@ -4079,12 +4133,12 @@ _curses_window_in_wchstr_impl(PyCursesWindowObject *self, int group_left_1, /* winchnstr() is not guaranteed (SVr4) to terminate the array, so pre-zero it and stop at the first empty cell; a painted cell always holds at least a space, never 0. */ - memset(buf, 0, ((size_t)n + 1) * sizeof(curses_cell_t)); + memset(buf, 0, (size + 1) * sizeof(curses_cell_t)); if (group_left_1) { - rtn = mvwinchnstr(self->win, y, x, buf, n); + rtn = mvwinchnstr(self->win, y, x, buf, (int)size); } else { - rtn = winchnstr(self->win, buf, n); + rtn = winchnstr(self->win, buf, (int)size); } if (rtn == ERR) { @@ -4093,7 +4147,7 @@ _curses_window_in_wchstr_impl(PyCursesWindowObject *self, int group_left_1, } Py_ssize_t count = 0; - while (count < (Py_ssize_t)n && buf[count] != 0) { + while (count < (Py_ssize_t)size && buf[count] != 0) { count++; } #endif diff --git a/Modules/clinic/_cursesmodule.c.h b/Modules/clinic/_cursesmodule.c.h index d2f30178b1c33c..7f9d95fc4aa788 100644 --- a/Modules/clinic/_cursesmodule.c.h +++ b/Modules/clinic/_cursesmodule.c.h @@ -1706,7 +1706,7 @@ _curses_window_inch(PyObject *self, PyObject *args) } PyDoc_STRVAR(_curses_window_instr__doc__, -"instr([y, x,] n=2047)\n" +"instr([y, x,] n=-1)\n" "Return a string of characters, extracted from the window.\n" "\n" " y\n" @@ -1714,21 +1714,22 @@ PyDoc_STRVAR(_curses_window_instr__doc__, " x\n" " X-coordinate.\n" " n\n" -" Maximal number of characters.\n" +" Maximal number of characters; negative means to the end of the line.\n" "\n" "Return a string of characters, extracted from the window starting\n" "at the current cursor position, or at y, x if specified, and\n" "stopping at the end of the line. Attributes and color\n" -"information are stripped from the characters. If n is specified,\n" -"instr() returns a string at most n characters long (exclusive of\n" -"the trailing NUL)."); +"information are stripped from the characters. If n is specified\n" +"and nonnegative, instr() returns a string at most n characters\n" +"long (exclusive of the trailing NUL); by default, or if n is\n" +"negative, the rest of the line is returned."); #define _CURSES_WINDOW_INSTR_METHODDEF \ {"instr", (PyCFunction)_curses_window_instr, METH_VARARGS, _curses_window_instr__doc__}, static PyObject * _curses_window_instr_impl(PyCursesWindowObject *self, int group_left_1, - int y, int x, unsigned int n); + int y, int x, int n); static PyObject * _curses_window_instr(PyObject *self, PyObject *args) @@ -1737,18 +1738,18 @@ _curses_window_instr(PyObject *self, PyObject *args) int group_left_1 = 0; int y = 0; int x = 0; - unsigned int n = 2047; + int n = -1; switch (PyTuple_GET_SIZE(args)) { case 0: case 1: - if (!PyArg_ParseTuple(args, "|O&:instr", _PyLong_UnsignedInt_Converter, &n)) { + if (!PyArg_ParseTuple(args, "|i:instr", &n)) { goto exit; } break; case 2: case 3: - if (!PyArg_ParseTuple(args, "ii|O&:instr", &y, &x, _PyLong_UnsignedInt_Converter, &n)) { + if (!PyArg_ParseTuple(args, "ii|i:instr", &y, &x, &n)) { goto exit; } group_left_1 = 1; @@ -1817,7 +1818,7 @@ _curses_window_get_wstr(PyObject *self, PyObject *args) } PyDoc_STRVAR(_curses_window_in_wstr__doc__, -"in_wstr([y, x,] n=2047)\n" +"in_wstr([y, x,] n=-1)\n" "Return a string of characters, extracted from the window.\n" "\n" " y\n" @@ -1825,7 +1826,7 @@ PyDoc_STRVAR(_curses_window_in_wstr__doc__, " x\n" " X-coordinate.\n" " n\n" -" Maximal number of characters.\n" +" Maximal number of characters; negative means to the end of the line.\n" "\n" "This is the wide-character variant of instr(); it returns a str."); @@ -1834,7 +1835,7 @@ PyDoc_STRVAR(_curses_window_in_wstr__doc__, static PyObject * _curses_window_in_wstr_impl(PyCursesWindowObject *self, int group_left_1, - int y, int x, unsigned int n); + int y, int x, int n); static PyObject * _curses_window_in_wstr(PyObject *self, PyObject *args) @@ -1843,18 +1844,18 @@ _curses_window_in_wstr(PyObject *self, PyObject *args) int group_left_1 = 0; int y = 0; int x = 0; - unsigned int n = 2047; + int n = -1; switch (PyTuple_GET_SIZE(args)) { case 0: case 1: - if (!PyArg_ParseTuple(args, "|O&:in_wstr", _PyLong_UnsignedInt_Converter, &n)) { + if (!PyArg_ParseTuple(args, "|i:in_wstr", &n)) { goto exit; } break; case 2: case 3: - if (!PyArg_ParseTuple(args, "ii|O&:in_wstr", &y, &x, _PyLong_UnsignedInt_Converter, &n)) { + if (!PyArg_ParseTuple(args, "ii|i:in_wstr", &y, &x, &n)) { goto exit; } group_left_1 = 1; @@ -1870,7 +1871,7 @@ _curses_window_in_wstr(PyObject *self, PyObject *args) } PyDoc_STRVAR(_curses_window_in_wchstr__doc__, -"in_wchstr([y, x,] n=2047)\n" +"in_wchstr([y, x,] n=-1)\n" "Return a complexstr of the styled cells extracted from the window.\n" "\n" " y\n" @@ -1878,7 +1879,7 @@ PyDoc_STRVAR(_curses_window_in_wchstr__doc__, " x\n" " X-coordinate.\n" " n\n" -" Maximal number of cells.\n" +" Maximal number of cells; negative means to the end of the line.\n" "\n" "This is the wide-character variant of instr() and in_wstr() that\n" "keeps each cell\'s attributes and color pair; it returns a\n" @@ -1889,7 +1890,7 @@ PyDoc_STRVAR(_curses_window_in_wchstr__doc__, static PyObject * _curses_window_in_wchstr_impl(PyCursesWindowObject *self, int group_left_1, - int y, int x, unsigned int n); + int y, int x, int n); static PyObject * _curses_window_in_wchstr(PyObject *self, PyObject *args) @@ -1898,18 +1899,18 @@ _curses_window_in_wchstr(PyObject *self, PyObject *args) int group_left_1 = 0; int y = 0; int x = 0; - unsigned int n = 2047; + int n = -1; switch (PyTuple_GET_SIZE(args)) { case 0: case 1: - if (!PyArg_ParseTuple(args, "|O&:in_wchstr", _PyLong_UnsignedInt_Converter, &n)) { + if (!PyArg_ParseTuple(args, "|i:in_wchstr", &n)) { goto exit; } break; case 2: case 3: - if (!PyArg_ParseTuple(args, "ii|O&:in_wchstr", &y, &x, _PyLong_UnsignedInt_Converter, &n)) { + if (!PyArg_ParseTuple(args, "ii|i:in_wchstr", &y, &x, &n)) { goto exit; } group_left_1 = 1; @@ -6582,4 +6583,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=4e98ddbfb69f2c04 input=a9049054013a1b77]*/ +/*[clinic end generated code: output=67d785b0c77d6608 input=a9049054013a1b77]*/