https://github.com/python/cpython/commit/abdd7aea18bde039fe35983b5c0d8036bc16f1a7
commit: abdd7aea18bde039fe35983b5c0d8036bc16f1a7
branch: main
author: Serhiy Storchaka <[email protected]>
committer: serhiy-storchaka <[email protected]>
date: 2026-07-17T11:10:21+03:00
summary:

gh-153395: Accept curses.complexchar in curses.ascii predicates and conversions 
(GH-153396)

The curses.ascii predicates and the ctrl() and unctrl() functions now accept
a curses.complexchar, classifying it by its single character.  ctrl() now
returns a non-ASCII argument unchanged instead of masking it to a control
character.

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

files:
A Misc/NEWS.d/next/Library/2026-07-09-10-14-08.gh-issue-153395.N8FuG3.rst
M Doc/library/curses.ascii.rst
M Lib/curses/ascii.py
M Lib/test/test_curses.py

diff --git a/Doc/library/curses.ascii.rst b/Doc/library/curses.ascii.rst
index 3ac76edd38dcc8f..fad35973dada185 100644
--- a/Doc/library/curses.ascii.rst
+++ b/Doc/library/curses.ascii.rst
@@ -176,15 +176,18 @@ C library:
 
    Checks for a non-ASCII character (ordinal values 0x80 and above).
 
-These functions accept either integers or single-character strings; when the 
argument is a
-string, it is first converted using the built-in function :func:`ord`.
+These functions accept an integer, a single-character string, or a
+:class:`curses.complexchar`.
+A string is converted using the built-in function :func:`ord`, and a
+complexchar by the code of its single character; a complexchar that holds
+combining characters is not a single character and matches no class.
 
 Note that all these functions check ordinal bit values derived from the
 character of the string you pass in; they do not actually know anything about
 the host machine's character encoding.
 
-The following two functions take either a single-character string or integer
-byte value; they return a value of the same type.
+The following three functions take either a single-character string or an
+integer byte value; they return a value of the same type.
 
 
 .. function:: ascii(c)
@@ -194,8 +197,13 @@ byte value; they return a value of the same type.
 
 .. function:: ctrl(c)
 
-   Return the control character corresponding to the given character (the 
character
-   bit value is bitwise-anded with 0x1f).
+   Return the control character corresponding to the given ASCII character (the
+   character bit value is bitwise-anded with 0x1f).  A non-ASCII character has 
no
+   control character and is returned unchanged.
+
+   .. versionchanged:: next
+      A non-ASCII argument is now returned unchanged instead of masked to a
+      control character.
 
 
 .. function:: alt(c)
@@ -203,7 +211,8 @@ byte value; they return a value of the same type.
    Return the 8-bit character corresponding to the given ASCII character (the
    character bit value is bitwise-ored with 0x80).
 
-The following function takes either a single-character string or integer value;
+The following function takes a single-character string, an integer value, or a
+:class:`curses.complexchar`;
 it returns a string.
 
 
diff --git a/Lib/curses/ascii.py b/Lib/curses/ascii.py
index 95acff33925ed7a..cb55300e05dd4ad 100644
--- a/Lib/curses/ascii.py
+++ b/Lib/curses/ascii.py
@@ -1,5 +1,8 @@
 """Constants and membership tests for ASCII characters"""
 
+# A character-cell type, present on wide and narrow builds.
+from _curses import complexchar as _complexchar
+
 NUL     = 0x00  # ^@
 SOH     = 0x01  # ^A
 STX     = 0x02  # ^B
@@ -48,8 +51,12 @@
 def _ctoi(c):
     if isinstance(c, str):
         return ord(c)
-    else:
-        return c
+    if isinstance(c, _complexchar):
+        # A character cell: its single character, or -1 (matches no class)
+        # for a cell with combining characters.
+        s = str(c)
+        return ord(s) if len(s) == 1 else -1
+    return c
 
 def isalnum(c): return isalpha(c) or isdigit(c)
 def isalpha(c): return isupper(c) or islower(c)
@@ -69,22 +76,23 @@ def isctrl(c): return 0 <= _ctoi(c) < 32
 def ismeta(c): return _ctoi(c) > 127
 
 def ascii(c):
-    if isinstance(c, str):
-        return chr(_ctoi(c) & 0x7f)
-    else:
+    if isinstance(c, int):
         return _ctoi(c) & 0x7f
+    else:
+        return chr(_ctoi(c) & 0x7f)
 
 def ctrl(c):
-    if isinstance(c, str):
-        return chr(_ctoi(c) & 0x1f)
-    else:
-        return _ctoi(c) & 0x1f
+    code = _ctoi(c)
+    if not 0 <= code < 128:
+        # No control character outside ASCII: return c unchanged.
+        return c if isinstance(c, int) else str(c)
+    return code & 0x1f if isinstance(c, int) else chr(code & 0x1f)
 
 def alt(c):
-    if isinstance(c, str):
-        return chr(_ctoi(c) | 0x80)
-    else:
+    if isinstance(c, int):
         return _ctoi(c) | 0x80
+    else:
+        return chr(_ctoi(c) | 0x80)
 
 def unctrl(c):
     bits = _ctoi(c)
diff --git a/Lib/test/test_curses.py b/Lib/test/test_curses.py
index 870393be5209df0..b99e4efbb83ce41 100644
--- a/Lib/test/test_curses.py
+++ b/Lib/test/test_curses.py
@@ -2761,6 +2761,9 @@ def test_ctrl(self):
         self.assertEqual(ctrl('\n'), '\n')
         self.assertEqual(ctrl('@'), '\0')
         self.assertEqual(ctrl(ord('J')), ord('\n'))
+        # A non-ASCII argument is returned unchanged (no control character).
+        self.assertEqual(ctrl('\xe9'), '\xe9')
+        self.assertEqual(ctrl(0xe9), 0xe9)
 
     def test_alt(self):
         alt = curses.ascii.alt
@@ -2785,6 +2788,38 @@ def test_unctrl(self):
         self.assertEqual(unctrl(ord('\x8a')), '!^J')
         self.assertEqual(unctrl(ord('\xc1')), '!A')
 
+    @unittest.skipUnless(hasattr(curses, 'complexchar'),
+                         'requires the curses.complexchar type')
+    def test_complexchar(self):
+        # The predicates, ctrl() and unctrl() accept a complexchar too, using
+        # its single character.  A narrow build just forms fewer cells.
+        cc = curses.complexchar
+        def storable(s):
+            try:
+                cc(s)
+            except ValueError:
+                return False
+            return True
+
+        self.assertTrue(curses.ascii.isupper(cc('A')))
+        self.assertTrue(curses.ascii.isalpha(cc('A', curses.A_BOLD)))
+        self.assertFalse(curses.ascii.isdigit(cc('A')))
+        self.assertTrue(curses.ascii.isdigit(cc('7')))
+        self.assertTrue(curses.ascii.iscntrl(cc('\n')))
+        self.assertEqual(curses.ascii.ctrl(cc('J')), '\n')
+        self.assertEqual(curses.ascii.unctrl(cc('\n')), '^J')
+        self.assertEqual(curses.ascii.unctrl(cc('A')), 'A')
+        # A non-ASCII character: classified by code point, no control 
character.
+        if storable('\xe9'):
+            self.assertFalse(curses.ascii.isascii(cc('\xe9')))
+            self.assertTrue(curses.ascii.ismeta(cc('\xe9')))
+            self.assertEqual(curses.ascii.ctrl(cc('\xe9')), '\xe9')
+        # A cell with combining marks is not a single character, so no
+        # predicate matches it (needs a wide build to store).
+        if storable('e\u0301'):
+            self.assertFalse(curses.ascii.isalpha(cc('e\u0301')))
+            self.assertFalse(curses.ascii.isascii(cc('e\u0301')))
+
 
 def lorem_ipsum(win):
     text = [
diff --git 
a/Misc/NEWS.d/next/Library/2026-07-09-10-14-08.gh-issue-153395.N8FuG3.rst 
b/Misc/NEWS.d/next/Library/2026-07-09-10-14-08.gh-issue-153395.N8FuG3.rst
new file mode 100644
index 000000000000000..1c44219f710640d
--- /dev/null
+++ b/Misc/NEWS.d/next/Library/2026-07-09-10-14-08.gh-issue-153395.N8FuG3.rst
@@ -0,0 +1,4 @@
+:mod:`curses.ascii` predicates and the :func:`~curses.ascii.ctrl` and
+:func:`~curses.ascii.unctrl` functions now accept a 
:class:`curses.complexchar`.
+:func:`~curses.ascii.ctrl` now returns a non-ASCII argument unchanged instead
+of masking it to a control character.

_______________________________________________
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