[3.14] gh-153862: Fix curses window.inch() for non-ASCII characters on a wide build (GH-153863) (GH-154151)

serhiy-storchaka <[email protected]>
Newsgroups gmane.comp.python.cvs
Message-ID <[email protected]>
https://github.com/python/cpython/commit/1933f77772b8bcbbb308a0efc9767c096015e688
commit: 1933f77772b8bcbbb308a0efc9767c096015e688
branch: 3.14
author: Serhiy Storchaka <[email protected]>
committer: serhiy-storchaka <[email protected]>
date: 2026-07-19T15:19:15Z
summary:

[3.14] gh-153862: Fix curses window.inch() for non-ASCII characters on a wide build (GH-153863) (GH-154151)

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().

(cherry picked from commit a83aaa91490d5885949eb0780a37b15963b6d0f3)

Co-authored-by: Claude Opus 4.8 <[email protected]>

files:
A Misc/NEWS.d/next/Library/2026-07-17-20-31-00.gh-issue-153862.X3kjoY.rst
M Doc/library/curses.rst
M Lib/test/test_curses.py
M Modules/_cursesmodule.c

diff --git a/Doc/library/curses.rst b/Doc/library/curses.rst
index 77c9397faebaf1..853ff6ab7332a6 100644
--- a/Doc/library/curses.rst
+++ b/Doc/library/curses.rst
@@ -1082,6 +1082,8 @@ Window objects
    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`.
 
 
 .. method:: window.insch(ch[, attr])
diff --git a/Lib/test/test_curses.py b/Lib/test/test_curses.py
index e15817ce2e7b0f..f4b30ac9957a2a 100644
--- a/Lib/test/test_curses.py
+++ b/Lib/test/test_curses.py
@@ -506,10 +506,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:
@@ -521,8 +520,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 51c4854aa5da52..41c20a7f96cd9f 100644
--- a/Modules/_cursesmodule.c
+++ b/Modules/_cursesmodule.c
@@ -1881,6 +1881,34 @@ _curses_window_insch_impl(PyCursesWindowObject *self, int group_left_1,
     return PyCursesCheckERR_ForWin(self, rtn, "insch");
 }
 
+#ifdef HAVE_NCURSESW
+/* 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)
+{
+    wchar_t wstr[CCHARW_MAX + 1];
+    attr_t attrs;
+    short pair;
+    /* getcchar() is not guaranteed to write the text of an empty cell. */
+    wstr[0] = L'\0';
+    if (getcchar(cell, wstr, &attrs, &pair, NULL) == 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;
+}
+#endif
+
 /*[clinic input]
 _curses.window.inch -> unsigned_long
 
@@ -1911,7 +1939,14 @@ _curses_window_inch_impl(PyCursesWindowObject *self, int group_right_1,
     else {
         rtn = mvwinch(self->win, y, x);
     }
-
+#ifdef HAVE_NCURSESW
+    cchar_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 rtn;
 }
 

_______________________________________________
Python-checkins mailing list -- [email protected]
To unsubscribe send an email to [email protected]
https://mail.python.org/mailman3//lists/python-checkins.python.org
Member address: [email protected]
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.