https://github.com/python/cpython/commit/a83aaa91490d5885949eb0780a37b15963b6d0f3
commit: a83aaa91490d5885949eb0780a37b15963b6d0f3
branch: main
author: Serhiy Storchaka <[email protected]>
committer: serhiy-storchaka <[email protected]>
date: 2026-07-19T10:43:22+03:00
summary:

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

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 <[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 e413cd9d2bef2d..9d0bb239af06df 100644
--- a/Doc/library/curses.rst
+++ b/Doc/library/curses.rst
@@ -1321,6 +1321,8 @@ 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`.
diff --git a/Lib/test/test_curses.py b/Lib/test/test_curses.py
index 915eae5ec233d5..2a222ecf5272ff 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..1e874d65bfca5f 100644
--- a/Modules/_cursesmodule.c
+++ b/Modules/_cursesmodule.c
@@ -774,6 +774,30 @@ curses_getcchar(const cchar_t *wcval, wchar_t *wstr, 
attr_t *attrs, int *pair)
     return rtn;
 }
 
+/* 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;
+    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 +3633,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);
 }
 

_______________________________________________
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]

Reply via email to