From 25feef48f68ea9275689bb4151743dd4b035d5f5 Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Thu, 9 Jul 2026 10:14:31 +0300 Subject: [PATCH] gh-153395: Accept curses.complexchar in curses.ascii predicates and conversions 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 --- Doc/library/curses.ascii.rst | 23 ++++++++---- Lib/curses/ascii.py | 32 ++++++++++------- Lib/test/test_curses.py | 35 +++++++++++++++++++ ...-07-09-10-14-08.gh-issue-153395.N8FuG3.rst | 4 +++ 4 files changed, 75 insertions(+), 19 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-07-09-10-14-08.gh-issue-153395.N8FuG3.rst diff --git a/Doc/library/curses.ascii.rst b/Doc/library/curses.ascii.rst index 3ac76edd38dcc8..fad35973dada18 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 95acff33925ed7..cb55300e05dd4a 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 5fe2850460717b..1734a4104c0e26 100644 --- a/Lib/test/test_curses.py +++ b/Lib/test/test_curses.py @@ -2740,6 +2740,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 @@ -2764,6 +2767,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 00000000000000..1c44219f710640 --- /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.