From aa5aa4db50af56aec8a914e556a7f9d616c91c46 Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Fri, 17 Jul 2026 23:33:36 +0300 Subject: [PATCH 1/2] gh-153862: Fix curses window.inch() for non-ASCII characters on a wide build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a wide build, winch() returns the low 8 bits of the character's code point with no locale conversion, so inch()/mvinch() disagreed with instr() for a non-ASCII character of an 8-bit locale ('€' under ISO-8859-15 gave 0xAC instead of 0xA4). Re-encode the cell to its locale byte via wctob(), as ncurses does for getbkgd(). Co-Authored-By: Claude Opus 4.8 --- Doc/library/curses.rst | 5 +++ Lib/test/test_curses.py | 10 ++--- ...-07-17-20-31-00.gh-issue-153862.X3kjoY.rst | 3 ++ Modules/_cursesmodule.c | 37 +++++++++++++++++++ 4 files changed, 49 insertions(+), 6 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-07-17-20-31-00.gh-issue-153862.X3kjoY.rst diff --git a/Doc/library/curses.rst b/Doc/library/curses.rst index e413cd9d2bef2d..06c847d2a290c0 100644 --- a/Doc/library/curses.rst +++ b/Doc/library/curses.rst @@ -1321,10 +1321,15 @@ Reading window contents The bottom 8 bits are the character proper and the upper bits are the attributes; extract them with the :data:`A_CHARTEXT` and :data:`A_ATTRIBUTES` bit-masks, and the color pair with :func:`pair_number`. + The character byte is the locale-encoded byte of the cell's character, + consistent with :meth:`instr`. It cannot represent a cell holding combining characters, a character that does not fit in a single byte, or a color pair outside the :func:`color_pair` range; use :meth:`in_wch` for those, which returns it as a :class:`complexchar`. + .. versionchanged:: next + On a wide build, a non-ASCII character is returned as its locale-encoded byte. + .. method:: window.in_wch([y, x]) Return the complex character at the given position in the window as a diff --git a/Lib/test/test_curses.py b/Lib/test/test_curses.py index b99e4efbb83ce4..d3fa98c310bda9 100644 --- a/Lib/test/test_curses.py +++ b/Lib/test/test_curses.py @@ -958,10 +958,9 @@ def test_read_from_window(self): self.assertRaises(ValueError, stdscr.instr, -2) self.assertRaises(ValueError, stdscr.instr, 0, 2, -2) # A non-ASCII character of an 8-bit locale reads back as its encoded - # byte (see _encodable for the set). instr() returns the locale bytes - # for any single-byte character; inch() packs the text into a chtype, so - # on a wide build it only round-trips a Latin-1 codepoint (byte == - # codepoint). + # byte (see _encodable for the set). Both instr() and inch() return the + # locale byte for any character that fits the locale's single-byte + # encoding. encoding = stdscr.encoding for ch in ('A', 'é', '¤', '€', 'є'): try: @@ -973,8 +972,7 @@ def test_read_from_window(self): with self.subTest(ch=ch): stdscr.addstr(2, 0, ch) self.assertEqual(stdscr.instr(2, 0, 1), b) - if ord(ch) < 0x100: - self.assertEqual(stdscr.inch(2, 0) & curses.A_CHARTEXT, b[0]) + self.assertEqual(stdscr.inch(2, 0) & curses.A_CHARTEXT, b[0]) def test_coordinate_errors(self): # Addressing a cell outside the window raises curses.error. diff --git a/Misc/NEWS.d/next/Library/2026-07-17-20-31-00.gh-issue-153862.X3kjoY.rst b/Misc/NEWS.d/next/Library/2026-07-17-20-31-00.gh-issue-153862.X3kjoY.rst new file mode 100644 index 00000000000000..aa646dc6c49174 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-07-17-20-31-00.gh-issue-153862.X3kjoY.rst @@ -0,0 +1,3 @@ +On a wide :mod:`curses` build, :meth:`curses.window.inch` now returns the +locale-encoded byte of a non-ASCII character, matching +:meth:`~curses.window.instr`, instead of the low byte of its code point. diff --git a/Modules/_cursesmodule.c b/Modules/_cursesmodule.c index cc68a51f0bed93..5bcbdf66aeb283 100644 --- a/Modules/_cursesmodule.c +++ b/Modules/_cursesmodule.c @@ -774,6 +774,35 @@ curses_getcchar(const cchar_t *wcval, wchar_t *wstr, attr_t *attrs, int *pair) return rtn; } +/* winch() is the X/Open single-byte reader: it returns only the low 8 bits of a + cell's character. On a wide build that low byte is the low byte of the wide + code point, which for a character representable in the locale's single-byte + encoding differs from the locale byte the string readers (instr()) return -- + e.g. U+20AC under ISO-8859-15 yields 0xAC here but its locale byte is 0xA4. + Recover the locale byte from the wide cell when the character maps to exactly + one byte, preserving the attribute and color bits already in RTN. A + character with no single-byte form (a combining cell, or anything multibyte + in the locale) is left as winch() reported it; in_wch() reads those in full. */ +static chtype +curses_cell_locale_byte(chtype rtn, const cchar_t *cell) +{ + wchar_t wstr[CCHARW_MAX + 1]; + attr_t attrs; + int pair; + if (curses_getcchar(cell, wstr, &attrs, &pair) == ERR + || wstr[0] == L'\0' || wstr[1] != L'\0') + { + return rtn; + } + /* wctob() mirrors ncurses' own _nc_to_char(): the single-byte form, or EOF + when the character has none in this locale. */ + int byte = wctob(wstr[0]); + if (byte != EOF) { + rtn = (rtn & ~(chtype)A_CHARTEXT) | (unsigned char)byte; + } + return rtn; +} + /* Hash one cell by value (text, attributes, pair) -- consistent with the equality comparison, not the raw cchar_t whose padding and unused text tail it ignores. Zero the key first so those bytes are deterministic, then @@ -3609,6 +3638,14 @@ _curses_window_inch_impl(PyCursesWindowObject *self, int group_right_1, curses_window_set_error(self, funcname, "inch"); return NULL; } +#ifdef HAVE_NCURSESW + curses_cell_t cell = {0}; + if ((group_right_1 ? mvwin_wch(self->win, y, x, &cell) + : win_wch(self->win, &cell)) != ERR) + { + rtn = curses_cell_locale_byte(rtn, &cell); + } +#endif return PyLong_FromUnsignedLong(rtn); } From 5ebe0c26f7054ab9e87fa7f8646c7cd7ab16921b Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Sat, 18 Jul 2026 00:17:04 +0300 Subject: [PATCH 2/2] Simplify the inch() comment and drop the versionchanged note Co-Authored-By: Claude Opus 4.8 --- Doc/library/curses.rst | 3 --- Modules/_cursesmodule.c | 13 ++++--------- 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/Doc/library/curses.rst b/Doc/library/curses.rst index 06c847d2a290c0..9d0bb239af06df 100644 --- a/Doc/library/curses.rst +++ b/Doc/library/curses.rst @@ -1327,9 +1327,6 @@ Reading window contents not fit in a single byte, or a color pair outside the :func:`color_pair` range; use :meth:`in_wch` for those, which returns it as a :class:`complexchar`. - .. versionchanged:: next - On a wide build, a non-ASCII character is returned as its locale-encoded byte. - .. method:: window.in_wch([y, x]) Return the complex character at the given position in the window as a diff --git a/Modules/_cursesmodule.c b/Modules/_cursesmodule.c index 5bcbdf66aeb283..1e874d65bfca5f 100644 --- a/Modules/_cursesmodule.c +++ b/Modules/_cursesmodule.c @@ -774,15 +774,10 @@ curses_getcchar(const cchar_t *wcval, wchar_t *wstr, attr_t *attrs, int *pair) return rtn; } -/* winch() is the X/Open single-byte reader: it returns only the low 8 bits of a - cell's character. On a wide build that low byte is the low byte of the wide - code point, which for a character representable in the locale's single-byte - encoding differs from the locale byte the string readers (instr()) return -- - e.g. U+20AC under ISO-8859-15 yields 0xAC here but its locale byte is 0xA4. - Recover the locale byte from the wide cell when the character maps to exactly - one byte, preserving the attribute and color bits already in RTN. A - character with no single-byte form (a combining cell, or anything multibyte - in the locale) is left as winch() reported it; in_wch() reads those in full. */ +/* winch() returns the low 8 bits of the character's code point with no locale + conversion, unlike instr(), so recover the locale byte from the wide cell + when the character maps to exactly one byte, keeping the attribute and color + bits in RTN. A character with no single-byte form is left to winch(). */ static chtype curses_cell_locale_byte(chtype rtn, const cchar_t *cell) {