From 81959a0364f5bc15414d5bd61c5c0d019d486fe5 Mon Sep 17 00:00:00 2001 From: DeepWzh Date: Thu, 9 Oct 2025 22:16:48 +0800 Subject: [PATCH 1/5] gh-133400: Fixed Ctrl+D (^D) behavior in :mod:`_pyrepl` module (GH-133883) Co-authored-by: adam j hartz --- Lib/_pyrepl/commands.py | 19 +++-- Lib/test/test_pyrepl/test_pyrepl.py | 83 +++++++++++++++++++ ...-05-11-09-40-19.gh-issue-133400.zkWla8.rst | 1 + 3 files changed, 95 insertions(+), 8 deletions(-) create mode 100644 Misc/NEWS.d/next/Core_and_Builtins/2025-05-11-09-40-19.gh-issue-133400.zkWla8.rst diff --git a/Lib/_pyrepl/commands.py b/Lib/_pyrepl/commands.py index 50c824995d85b8b..10127e58897a580 100644 --- a/Lib/_pyrepl/commands.py +++ b/Lib/_pyrepl/commands.py @@ -420,14 +420,17 @@ class delete(EditCommand): def do(self) -> None: r = self.reader b = r.buffer - if ( - r.pos == 0 - and len(b) == 0 # this is something of a hack - and self.event[-1] == "\004" - ): - r.update_screen() - r.console.finish() - raise EOFError + if self.event[-1] == "\004": + if b and b[-1].endswith("\n"): + self.finish = True + elif ( + r.pos == 0 + and len(b) == 0 # this is something of a hack + ): + r.update_screen() + r.console.finish() + raise EOFError + for i in range(r.get_arg()): if r.pos != len(b): del b[r.pos] diff --git a/Lib/test/test_pyrepl/test_pyrepl.py b/Lib/test/test_pyrepl/test_pyrepl.py index 47d384a209e9aca..ad7464dc3d37c6b 100644 --- a/Lib/test/test_pyrepl/test_pyrepl.py +++ b/Lib/test/test_pyrepl/test_pyrepl.py @@ -1825,3 +1825,86 @@ def test_detect_pip_usage_in_repl(self): " outside of the Python REPL" ) self.assertIn(hint, output) + +class TestPyReplCtrlD(TestCase): + """Test Ctrl+D behavior in _pyrepl to match old pre-3.13 REPL behavior. + + Ctrl+D should: + - Exit on empty buffer (raises EOFError) + - Delete character when cursor is in middle of line + - Perform no operation when cursor is at end of line without newline + - Exit multiline mode when cursor is at end with trailing newline + - Run code up to that point when pressed on blank line with preceding lines + """ + def prepare_reader(self, events): + console = FakeConsole(events) + config = ReadlineConfig(readline_completer=None) + reader = ReadlineAlikeReader(console=console, config=config) + return reader + + def test_ctrl_d_empty_line(self): + """Test that pressing Ctrl+D on empty line exits the program""" + events = [ + Event(evt="key", data="\x04", raw=bytearray(b"\x04")), # Ctrl+D + ] + reader = self.prepare_reader(events) + with self.assertRaises(EOFError): + multiline_input(reader) + + def test_ctrl_d_multiline_with_new_line(self): + """Test that pressing Ctrl+D in multiline mode with trailing newline exits multiline mode""" + events = itertools.chain( + code_to_events("def f():\n pass\n"), # Enter multiline mode with trailing newline + [ + Event(evt="key", data="\x04", raw=bytearray(b"\x04")), # Ctrl+D + ], + ) + reader, _ = handle_all_events(events) + self.assertTrue(reader.finished) + self.assertEqual("def f():\n pass\n", "".join(reader.buffer)) + + def test_ctrl_d_multiline_middle_of_line(self): + """Test that pressing Ctrl+D in multiline mode with cursor in middle deletes character""" + events = itertools.chain( + code_to_events("def f():\n hello world"), # Enter multiline mode + [ + Event(evt="key", data="left", raw=bytearray(b"\x1bOD")) + ] * 5, # move cursor to 'w' in "world" + [ + Event(evt="key", data="\x04", raw=bytearray(b"\x04")) + ], # Ctrl+D should delete 'w' + ) + reader, _ = handle_all_events(events) + self.assertFalse(reader.finished) + self.assertEqual("def f():\n hello orld", "".join(reader.buffer)) + + def test_ctrl_d_multiline_end_of_line_no_newline(self): + """Test that pressing Ctrl+D at end of line without newline performs no operation""" + events = itertools.chain( + code_to_events("def f():\n hello"), # Enter multiline mode, no trailing newline + [ + Event(evt="key", data="\x04", raw=bytearray(b"\x04")) + ], # Ctrl+D should be no-op + ) + reader, _ = handle_all_events(events) + self.assertFalse(reader.finished) + self.assertEqual("def f():\n hello", "".join(reader.buffer)) + + def test_ctrl_d_single_line_middle_of_line(self): + """Test that pressing Ctrl+D in single line mode deletes current character""" + events = itertools.chain( + code_to_events("hello"), + [Event(evt="key", data="left", raw=bytearray(b"\x1bOD"))], # move left + [Event(evt="key", data="\x04", raw=bytearray(b"\x04"))], # Ctrl+D + ) + reader, _ = handle_all_events(events) + self.assertEqual("hell", "".join(reader.buffer)) + + def test_ctrl_d_single_line_end_no_newline(self): + """Test that pressing Ctrl+D at end of single line without newline does nothing""" + events = itertools.chain( + code_to_events("hello"), # cursor at end of line + [Event(evt="key", data="\x04", raw=bytearray(b"\x04"))], # Ctrl+D + ) + reader, _ = handle_all_events(events) + self.assertEqual("hello", "".join(reader.buffer)) diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2025-05-11-09-40-19.gh-issue-133400.zkWla8.rst b/Misc/NEWS.d/next/Core_and_Builtins/2025-05-11-09-40-19.gh-issue-133400.zkWla8.rst new file mode 100644 index 000000000000000..2498d6ebaa543e9 --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2025-05-11-09-40-19.gh-issue-133400.zkWla8.rst @@ -0,0 +1 @@ +Fixed Ctrl+D (^D) behavior in _pyrepl module to match old pre-3.13 REPL behavior. From b8c8b8f1d30767e5358ffa93d41e6e27ca60570d Mon Sep 17 00:00:00 2001 From: yihong Date: Thu, 9 Oct 2025 22:58:01 +0800 Subject: [PATCH 2/5] gh-139391: properly handle `signal.signal()` in `UnixConsole` when called from a non-main thread (#139392) --- Lib/_pyrepl/unix_console.py | 7 ++++++- Lib/test/test_pyrepl/test_unix_console.py | 14 +++++++++++++- .../2025-09-28-16-34-11.gh-issue-139391.nRFnmx.rst | 3 +++ 3 files changed, 22 insertions(+), 2 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2025-09-28-16-34-11.gh-issue-139391.nRFnmx.rst diff --git a/Lib/_pyrepl/unix_console.py b/Lib/_pyrepl/unix_console.py index fe45b4eb3840679..09247de748ee3bd 100644 --- a/Lib/_pyrepl/unix_console.py +++ b/Lib/_pyrepl/unix_console.py @@ -390,7 +390,12 @@ def restore(self): os.write(self.output_fd, b"\033[?7h") if hasattr(self, "old_sigwinch"): - signal.signal(signal.SIGWINCH, self.old_sigwinch) + try: + signal.signal(signal.SIGWINCH, self.old_sigwinch) + except ValueError as e: + import threading + if threading.current_thread() is threading.main_thread(): + raise e del self.old_sigwinch def push_char(self, char: int | bytes) -> None: diff --git a/Lib/test/test_pyrepl/test_unix_console.py b/Lib/test/test_pyrepl/test_unix_console.py index 9ac2e9961df2dc6..f4fb9237ffdfd0d 100644 --- a/Lib/test/test_pyrepl/test_unix_console.py +++ b/Lib/test/test_pyrepl/test_unix_console.py @@ -4,11 +4,12 @@ import signal import subprocess import sys +import threading import unittest from functools import partial from test import support from test.support import os_helper, force_not_colorized_test_class -from test.support import script_helper +from test.support import script_helper, threading_helper from unittest import TestCase from unittest.mock import MagicMock, call, patch, ANY, Mock @@ -318,6 +319,17 @@ def test_restore_with_invalid_environ_on_macos(self, _os_write): console.prepare() # needed to call restore() console.restore() # this should succeed + @threading_helper.reap_threads + @threading_helper.requires_working_threading() + def test_restore_in_thread(self, _os_write): + # gh-139391: ensure that console.restore() silently suppresses + # exceptions when calling signal.signal() from a non-main thread. + console = unix_console([]) + console.old_sigwinch = signal.SIG_DFL + thread = threading.Thread(target=console.restore) + thread.start() + thread.join() # this should not raise + @unittest.skipIf(sys.platform == "win32", "No Unix console on Windows") class TestUnixConsoleEIOHandling(TestCase): diff --git a/Misc/NEWS.d/next/Library/2025-09-28-16-34-11.gh-issue-139391.nRFnmx.rst b/Misc/NEWS.d/next/Library/2025-09-28-16-34-11.gh-issue-139391.nRFnmx.rst new file mode 100644 index 000000000000000..93d1ce613bc2d6d --- /dev/null +++ b/Misc/NEWS.d/next/Library/2025-09-28-16-34-11.gh-issue-139391.nRFnmx.rst @@ -0,0 +1,3 @@ +Fix an issue when, on non-Windows platforms, it was not possible to +gracefully exit a ``python -m asyncio`` process suspended by Ctrl+Z +and later resumed by :manpage:`fg` other than with :manpage:`kill`. From a310b3a99d05e107963023a5736b67afe4ae1968 Mon Sep 17 00:00:00 2001 From: yihong Date: Thu, 9 Oct 2025 23:24:52 +0800 Subject: [PATCH 3/5] gh-139845: do not print twice in default asyncio REPL (#139846) Co-authored-by: Kumar Aditya --- Lib/asyncio/__main__.py | 3 ++- .../Library/2025-10-09-21-37-20.gh-issue-139845.dzx5UP.rst | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) create mode 100644 Misc/NEWS.d/next/Library/2025-10-09-21-37-20.gh-issue-139845.dzx5UP.rst diff --git a/Lib/asyncio/__main__.py b/Lib/asyncio/__main__.py index 12d6f7714ee4f4f..10bfca3cf96b3e7 100644 --- a/Lib/asyncio/__main__.py +++ b/Lib/asyncio/__main__.py @@ -74,7 +74,8 @@ def callback(): return except BaseException: if keyboard_interrupted: - self.write("\nKeyboardInterrupt\n") + if not CAN_USE_PYREPL: + self.write("\nKeyboardInterrupt\n") else: self.showtraceback() return self.STATEMENT_FAILED diff --git a/Misc/NEWS.d/next/Library/2025-10-09-21-37-20.gh-issue-139845.dzx5UP.rst b/Misc/NEWS.d/next/Library/2025-10-09-21-37-20.gh-issue-139845.dzx5UP.rst new file mode 100644 index 000000000000000..3cd294e49cdda36 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2025-10-09-21-37-20.gh-issue-139845.dzx5UP.rst @@ -0,0 +1 @@ +Fix to not print KeyboardInterrupt twice in default asyncio REPL. From 9fc4366f09904fd9aac797d542e332e8a4c1a921 Mon Sep 17 00:00:00 2001 From: Savannah Ostrowski Date: Thu, 9 Oct 2025 09:53:14 -0700 Subject: [PATCH 4/5] GH-139809: Fix argparse subcommand prog not respecting color environment variables (#139818) --- Lib/argparse.py | 4 +++- Lib/test/test_argparse.py | 15 ++++++++++++++- ...2025-10-09-03-06-19.gh-issue-139809.lzHJNu.rst | 1 + 3 files changed, 18 insertions(+), 2 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2025-10-09-03-06-19.gh-issue-139809.lzHJNu.rst diff --git a/Lib/argparse.py b/Lib/argparse.py index d71e551401cb023..1ddb7abbb353553 100644 --- a/Lib/argparse.py +++ b/Lib/argparse.py @@ -1959,7 +1959,9 @@ def add_subparsers(self, **kwargs): # prog defaults to the usage message of this parser, skipping # optional arguments and with no "usage:" prefix if kwargs.get('prog') is None: - formatter = self._get_formatter() + # Create formatter without color to avoid storing ANSI codes in prog + formatter = self.formatter_class(prog=self.prog) + formatter._set_color(False) positionals = self._get_positional_actions() groups = self._mutually_exclusive_groups formatter.add_usage(None, positionals, groups, '') diff --git a/Lib/test/test_argparse.py b/Lib/test/test_argparse.py index 27e38040a98d10e..6a07f190285c370 100644 --- a/Lib/test/test_argparse.py +++ b/Lib/test/test_argparse.py @@ -22,6 +22,7 @@ captured_stderr, force_not_colorized, force_not_colorized_test_class, + swap_attr, ) from test.support import import_helper from test.support import os_helper @@ -7128,7 +7129,8 @@ class TestColorized(TestCase): def setUp(self): super().setUp() # Ensure color even if ran with NO_COLOR=1 - _colorize.can_colorize = lambda *args, **kwargs: True + self.enterContext(swap_attr(_colorize, 'can_colorize', + lambda *args, **kwargs: True)) self.theme = _colorize.get_theme(force_color=True).argparse def test_argparse_color(self): @@ -7355,6 +7357,17 @@ def __init__(self, prog): {short_b}+f{reset}, {long_b}++foo{reset} {label_b}FOO{reset} foo help ''')) + def test_subparser_prog_is_stored_without_color(self): + parser = argparse.ArgumentParser(prog='complex', color=True) + sub = parser.add_subparsers(dest='command') + demo_parser = sub.add_parser('demo') + + self.assertNotIn('\x1b[', demo_parser.prog) + + demo_parser.color = False + help_text = demo_parser.format_help() + self.assertNotIn('\x1b[', help_text) + class TestModule(unittest.TestCase): def test_deprecated__version__(self): diff --git a/Misc/NEWS.d/next/Library/2025-10-09-03-06-19.gh-issue-139809.lzHJNu.rst b/Misc/NEWS.d/next/Library/2025-10-09-03-06-19.gh-issue-139809.lzHJNu.rst new file mode 100644 index 000000000000000..498b8f7fd27bd3f --- /dev/null +++ b/Misc/NEWS.d/next/Library/2025-10-09-03-06-19.gh-issue-139809.lzHJNu.rst @@ -0,0 +1 @@ +Prevent premature colorization of subparser ``prog`` in :meth:`argparse.ArgumentParser.add_subparsers` to respect color environment variable changes after parser creation. From 04461510fb8bedc054477c2634ffd0e575485b12 Mon Sep 17 00:00:00 2001 From: Kirill Podoprigora Date: Thu, 9 Oct 2025 20:13:38 +0300 Subject: [PATCH 5/5] gh-139672: Remove references to `passlib` (#139673) --- Doc/library/crypt.rst | 2 +- Doc/whatsnew/3.13.rst | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/Doc/library/crypt.rst b/Doc/library/crypt.rst index 9ff37196ccf69ff..647cb4925f31dad 100644 --- a/Doc/library/crypt.rst +++ b/Doc/library/crypt.rst @@ -13,7 +13,7 @@ being deprecated in Python 3.11. The removal was decided in :pep:`594`. Applications can use the :mod:`hashlib` module from the standard library. Other possible replacements are third-party libraries from PyPI: -:pypi:`legacycrypt`, :pypi:`bcrypt`, :pypi:`argon2-cffi`, or :pypi:`passlib`. +:pypi:`legacycrypt`, :pypi:`bcrypt`, or :pypi:`argon2-cffi`. These are not supported or maintained by the Python core team. The last version of Python that provided the :mod:`!crypt` module was diff --git a/Doc/whatsnew/3.13.rst b/Doc/whatsnew/3.13.rst index 1548f128b5bdac1..499f6f0ff4eaca2 100644 --- a/Doc/whatsnew/3.13.rst +++ b/Doc/whatsnew/3.13.rst @@ -1569,8 +1569,6 @@ and are now removed: * :pypi:`bcrypt`: Modern password hashing for your software and your servers. - * :pypi:`passlib`: - Comprehensive password hashing framework supporting over 30 schemes. * :pypi:`argon2-cffi`: The secure Argon2 password hashing algorithm. * :pypi:`legacycrypt`: