From 83531fd39f24f873671c38b981061afe1730613f Mon Sep 17 00:00:00 2001 From: Vyron Vasileiadis Date: Thu, 20 Aug 2026 22:08:50 +0300 Subject: [PATCH 1/2] gh-155974: Restore the window attributes when a curses write fails (GH-155975) addstr(), addnstr(), insstr() and insnstr() set the window rendition to the caller's attr, write, then restore the previous rendition. Since 30dde1eeb3fa the restore sits below an early return taken when the write fails, so a failed write leaves the caller's attr on the window and drops whatever the application had set with attrset(). Restore the rendition first and report the write error afterwards. A wattrset() failure is still reported when the write itself succeeded. --- Lib/test/test_curses.py | 15 ++++++ ...-08-17-22-51-22.gh-issue-155974.Qz3Vb7.rst | 4 ++ Modules/_cursesmodule.c | 52 +++++++++---------- 3 files changed, 43 insertions(+), 28 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-08-17-22-51-22.gh-issue-155974.Qz3Vb7.rst diff --git a/Lib/test/test_curses.py b/Lib/test/test_curses.py index f582336fae17344..6ab951ad2786ea8 100644 --- a/Lib/test/test_curses.py +++ b/Lib/test/test_curses.py @@ -895,6 +895,21 @@ def test_output_string_embedded_null_chars(self): self.assertRaises(ValueError, stdscr.insstr, arg) self.assertRaises(ValueError, stdscr.insnstr, arg, 1) + def test_output_string_attr_restored(self): + # A write with an attr restores the window rendition afterwards, + # whether it succeeded or failed. + win = curses.newwin(2, 10, 0, 0) + for func, args in [(win.addstr, ('x',)), (win.addnstr, ('x', 1)), + (win.insstr, ('x',)), (win.insnstr, ('x', 1))]: + with self.subTest(func.__qualname__): + win.attrset(curses.A_UNDERLINE) + # y=100 is outside the window, so the write fails. + self.assertRaises(curses.error, func, 100, 0, *args, + curses.A_BOLD) + self.assertEqual(win.getattrs(), curses.A_UNDERLINE) + func(0, 0, *args, curses.A_BOLD) + self.assertEqual(win.getattrs(), curses.A_UNDERLINE) + def test_add_string_behavior(self): # addstr() advances the cursor past the written text; addnstr() # writes at most n characters. diff --git a/Misc/NEWS.d/next/Library/2026-08-17-22-51-22.gh-issue-155974.Qz3Vb7.rst b/Misc/NEWS.d/next/Library/2026-08-17-22-51-22.gh-issue-155974.Qz3Vb7.rst new file mode 100644 index 000000000000000..8621bc49a03ccab --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-08-17-22-51-22.gh-issue-155974.Qz3Vb7.rst @@ -0,0 +1,4 @@ +Fix a regression in Python 3.15: :meth:`~curses.window.addstr`, +:meth:`~curses.window.addnstr`, :meth:`~curses.window.insstr` and +:meth:`~curses.window.insnstr` again restore the window attributes when the +write fails, instead of leaving the temporary *attr* applied. diff --git a/Modules/_cursesmodule.c b/Modules/_cursesmodule.c index 006e27d55d8925d..0bab30184a357ad 100644 --- a/Modules/_cursesmodule.c +++ b/Modules/_cursesmodule.c @@ -2262,15 +2262,14 @@ _curses_window_addstr_impl(PyCursesWindowObject *self, int group_left_1, } Py_DECREF(bytesobj); } - if (rtn == ERR) { - curses_window_set_error(self, funcname, "addstr"); - return NULL; - } if (use_attr) { - rtn = wattrset(self->win, attr_old); - return curses_window_check_err(self, rtn, "wattrset", "addstr"); + int attr_rtn = wattrset(self->win, attr_old); + if (rtn != ERR) { + rtn = attr_rtn; + funcname = "wattrset"; + } } - Py_RETURN_NONE; + return curses_window_check_err(self, rtn, funcname, "addstr"); } /*[clinic input] @@ -2373,15 +2372,14 @@ _curses_window_addnstr_impl(PyCursesWindowObject *self, int group_left_1, } Py_DECREF(bytesobj); } - if (rtn == ERR) { - curses_window_set_error(self, funcname, "addnstr"); - return NULL; - } if (use_attr) { - rtn = wattrset(self->win, attr_old); - return curses_window_check_err(self, rtn, "wattrset", "addnstr"); + int attr_rtn = wattrset(self->win, attr_old); + if (rtn != ERR) { + rtn = attr_rtn; + funcname = "wattrset"; + } } - Py_RETURN_NONE; + return curses_window_check_err(self, rtn, funcname, "addnstr"); } /*[clinic input] @@ -4094,15 +4092,14 @@ _curses_window_insstr_impl(PyCursesWindowObject *self, int group_left_1, } Py_DECREF(bytesobj); } - if (rtn == ERR) { - curses_window_set_error(self, funcname, "insstr"); - return NULL; - } if (use_attr) { - rtn = wattrset(self->win, attr_old); - return curses_window_check_err(self, rtn, "wattrset", "insstr"); + int attr_rtn = wattrset(self->win, attr_old); + if (rtn != ERR) { + rtn = attr_rtn; + funcname = "wattrset"; + } } - Py_RETURN_NONE; + return curses_window_check_err(self, rtn, funcname, "insstr"); } /*[clinic input] @@ -4206,15 +4203,14 @@ _curses_window_insnstr_impl(PyCursesWindowObject *self, int group_left_1, } Py_DECREF(bytesobj); } - if (rtn == ERR) { - curses_window_set_error(self, funcname, "insnstr"); - return NULL; - } if (use_attr) { - rtn = wattrset(self->win, attr_old); - return curses_window_check_err(self, rtn, "wattrset", "insnstr"); + int attr_rtn = wattrset(self->win, attr_old); + if (rtn != ERR) { + rtn = attr_rtn; + funcname = "wattrset"; + } } - Py_RETURN_NONE; + return curses_window_check_err(self, rtn, funcname, "insnstr"); } /*[clinic input] From 04242c027feeff726acb15b6463422897b489bcf Mon Sep 17 00:00:00 2001 From: Brett Cannon Date: Thu, 20 Aug 2026 12:24:45 -0700 Subject: [PATCH 2/2] Don't leak exceptions when a subprocess fails when run by Platforms/WASI (#156030) --- Platforms/WASI/_build.py | 17 ++++++-- Platforms/WASI/_package.py | 84 +++++++++++++++++++++----------------- 2 files changed, 61 insertions(+), 40 deletions(-) diff --git a/Platforms/WASI/_build.py b/Platforms/WASI/_build.py index 66e2ae64e181d70..fff7e3f9f8cdada 100644 --- a/Platforms/WASI/_build.py +++ b/Platforms/WASI/_build.py @@ -129,7 +129,19 @@ def call(command, *, context=None, quiet=False, **kwargs): stderr = subprocess.STDOUT _shared.log("📝", f"Logging output to {stdout.name} (--quiet)...") - subprocess.check_call(command, **kwargs, stdout=stdout, stderr=stderr) + try: + subprocess.check_call(command, **kwargs, stdout=stdout, stderr=stderr) + except subprocess.CalledProcessError as error: + if quiet: + _shared.log("❌", f"Exit code {error.returncode}") + separator() + with open(stdout.name, encoding="utf-8") as file: + lines = file.readlines() + # Inefficient, but the log shouldn't be dramatically large. + print("".join(lines[-10:]), end="") + if not lines[-1].endswith("\n"): + print() + sys.exit(error.returncode) @subdir("build_python_path", clean_ok=True) @@ -163,8 +175,7 @@ def make_build_python(context, _working_dir): cmd = [ binary, "-c", - "import sys; " - "print(f'{sys.version_info.major}.{sys.version_info.minor}')", + "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')", ] version = subprocess.check_output(cmd, encoding="utf-8").strip() diff --git a/Platforms/WASI/_package.py b/Platforms/WASI/_package.py index 37119799c233a23..d1d43e5da2843c1 100644 --- a/Platforms/WASI/_package.py +++ b/Platforms/WASI/_package.py @@ -4,6 +4,7 @@ "pathlib", "shutil", "subprocess", + "sys", "_shared", ] @@ -12,6 +13,7 @@ import pathlib import shutil import subprocess +import sys import _shared @@ -376,45 +378,53 @@ def archive(context): int(source_date_epoch), datetime.UTC ).strftime(mtime_format) else: - mtime = subprocess.run( + try: + mtime = subprocess.run( + [ + "git", + "log", + "-1", + "--format=tformat:%cd", + f"--date=format:{mtime_format}", + os.fsdecode(context.checkout), + ], + env={"TZ": "UTC0"}, + capture_output=True, + text=True, + check=True, + ).stdout.strip() + except subprocess.CalledProcessError as error: + print(error.output) + sys.exit(error.returncode) + + try: + subprocess.run( [ - "git", - "log", - "-1", - "--format=tformat:%cd", - f"--date=format:{mtime_format}", - os.fsdecode(context.checkout), + "tar", + "-c", + "-f", + os.fsdecode(file_path), + "--sort=name", + "--mtime", + mtime, + "--clamp-mtime", + "--owner=0", + "--group=0", + "--numeric-owner", + "--pax-option=exthdr.name=%d/PaxHeaders/%f,delete=atime,delete=ctime", + "--mode=go+u,go-w", + # Explicitly using `-T` because if you don't compress with threads you can't + # uncompress with them and the size difference is negligible when using + # single-threaded compression. + "--use-compress-program", + "xz -T 0", + to_compress.name, ], - env={"TZ": "UTC0"}, + cwd=to_compress.parent, capture_output=True, text=True, check=True, - ).stdout.strip() - - subprocess.run( - [ - "tar", - "-c", - "-f", - os.fsdecode(file_path), - "--sort=name", - "--mtime", - mtime, - "--clamp-mtime", - "--owner=0", - "--group=0", - "--numeric-owner", - "--pax-option=exthdr.name=%d/PaxHeaders/%f,delete=atime,delete=ctime", - "--mode=go+u,go-w", - # Explicitly using `-T` because if you don't compress with threads you can't - # uncompress with them and the size difference is negligible when using - # single-threaded compression. - "--use-compress-program", - "xz -T 0", - to_compress.name, - ], - cwd=to_compress.parent, - capture_output=True, - text=True, - check=True, - ) + ) + except subprocess.CalledProcessError as error: + print(error.output) + sys.exit(error.returncode)