From ef220447fb8e4aa8428ae98110e46b60a48fd4fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Sat, 27 Sep 2025 12:06:53 +0200 Subject: [PATCH 01/12] prevent altering `PYTHON_HISTORY` file when running REPL tests --- Lib/test/test_pyrepl/test_pyrepl.py | 15 +++++++++------ ...2025-09-27-12-02-49.gh-issue-139352.dblQRA.rst | 2 ++ 2 files changed, 11 insertions(+), 6 deletions(-) create mode 100644 Misc/NEWS.d/next/Tests/2025-09-27-12-02-49.gh-issue-139352.dblQRA.rst diff --git a/Lib/test/test_pyrepl/test_pyrepl.py b/Lib/test/test_pyrepl/test_pyrepl.py index 47d384a209e9ac..b605e869717fad 100644 --- a/Lib/test/test_pyrepl/test_pyrepl.py +++ b/Lib/test/test_pyrepl/test_pyrepl.py @@ -68,7 +68,7 @@ def run_repl( try: return self._run_repl( repl_input, - env=env, + env=os.environ.copy() if env is None else env, cmdline_args=cmdline_args, cwd=cwd, skip=skip, @@ -83,7 +83,7 @@ def _run_repl( self, repl_input: str | list[str], *, - env: dict | None, + env: dict[str, str], cmdline_args: list[str] | None, cwd: str, skip: bool, @@ -93,11 +93,14 @@ def _run_repl( assert pty master_fd, slave_fd = pty.openpty() cmd = [sys.executable, "-i", "-u"] - if env is None: - cmd.append("-I") - elif "PYTHON_HISTORY" not in env: + if "PYTHON_HISTORY" not in env: env["PYTHON_HISTORY"] = os.path.join(cwd, ".regrtest_history") if cmdline_args is not None: + if "PYTHON_HISTORY" in env: + self.assertNotIn( + "-I", cmdline_args, + "PYTHON_HISTORY will be ignored by -I" + ) cmd.extend(cmdline_args) try: @@ -118,7 +121,7 @@ def _run_repl( cwd=cwd, text=True, close_fds=True, - env=env if env else os.environ, + env=env, ) os.close(slave_fd) if isinstance(repl_input, list): diff --git a/Misc/NEWS.d/next/Tests/2025-09-27-12-02-49.gh-issue-139352.dblQRA.rst b/Misc/NEWS.d/next/Tests/2025-09-27-12-02-49.gh-issue-139352.dblQRA.rst new file mode 100644 index 00000000000000..b5aa6ca48e0cd2 --- /dev/null +++ b/Misc/NEWS.d/next/Tests/2025-09-27-12-02-49.gh-issue-139352.dblQRA.rst @@ -0,0 +1,2 @@ +Prevent altering the content of the :envvar:`PYTHON_HISTORY` file after +running REPL tests. Patch by Bénédikt Tran. From 244cd0b6da23e63c8ded4c22a1f61770aecf4ba4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Sat, 27 Sep 2025 13:39:13 +0200 Subject: [PATCH 02/12] ensure that PYTHON_HISTORY is not altered --- Lib/test/support/script_helper.py | 50 +++++++++++++++++++++ Lib/test/test_cmd_line_script.py | 7 ++- Lib/test/test_pyrepl/test_pyrepl.py | 12 ++--- Lib/test/test_repl.py | 70 +++++++++++++++-------------- 4 files changed, 98 insertions(+), 41 deletions(-) diff --git a/Lib/test/support/script_helper.py b/Lib/test/support/script_helper.py index 46ce950433ddf6..1c1c746f7cae42 100644 --- a/Lib/test/support/script_helper.py +++ b/Lib/test/support/script_helper.py @@ -3,11 +3,15 @@ import collections import importlib +import site +import stat import sys import os import os.path import subprocess import py_compile +import unittest +import unittest.mock as mock from importlib.util import source_from_cache from test import support @@ -322,3 +326,49 @@ def title(text): raise AssertionError(f"{name} failed") else: assert_python_ok("-u", script, "-v") + + +_site_gethistoryfile = site.gethistoryfile +def _gethistoryfile(): + """Patch site.gethistoryfile() to ignore -I for PYTHON_HISTORY. + + The -I option is necessary for test_no_memory() but using it + forbids using a custom PYTHON_HISTORY. + """ + history = os.environ.get("PYTHON_HISTORY") + return history or os.path.join(os.path.expanduser('~'), '.python_history') + + +def patch_gethistoryfile(sitemodule=site): + return mock.patch.object(sitemodule, "gethistoryfile", _gethistoryfile) + + +def _file_signature(file): + st = os.stat(file) + return (stat.S_IFMT(st.st_mode), st.st_size) + + +class EnsureSafeUserHistory(unittest.TestCase): + + @classmethod + def setUpClass(cls): + if history_file := _site_gethistoryfile(): + if os.path.exists(history_file): + cls.__history_file = history_file + cls.__history_stat = _file_signature(history_file) + else: + cls.__history_file = cls.__history_stat = None + + def tearDown(self): + super().tearDown() + if self.__history_file is None: + return + self.assertTrue( + os.path.exists(self.__history_file), + f"PYTHON_HISTORY file ({self.__history_file!r}) was deleted" + ) + self.assertEqual( + self.__history_stat, + _file_signature(self.__history_file), + f"PYTHON_HISTORY file ({self.__history_file!r}) was altered" + ) diff --git a/Lib/test/test_cmd_line_script.py b/Lib/test/test_cmd_line_script.py index 784c45aa96f8a7..d31be54d90f4dc 100644 --- a/Lib/test/test_cmd_line_script.py +++ b/Lib/test/test_cmd_line_script.py @@ -17,7 +17,9 @@ from test.support import import_helper, is_apple, os_helper from test.support.script_helper import ( make_pkg, make_script, make_zip_pkg, make_zip_script, - assert_python_ok, assert_python_failure, spawn_python, kill_python) + assert_python_ok, assert_python_failure, spawn_python, kill_python, + patch_gethistoryfile, EnsureSafeUserHistory +) verbose = support.verbose @@ -90,7 +92,8 @@ def _make_test_zip_pkg(zip_dir, zip_basename, pkg_name, script_basename, @support.force_not_colorized_test_class -class CmdLineTest(unittest.TestCase): +@patch_gethistoryfile() +class CmdLineTest(EnsureSafeUserHistory, unittest.TestCase): def _check_output(self, script_name, exit_code, data, expected_file, expected_argv0, expected_path0, expected_package, diff --git a/Lib/test/test_pyrepl/test_pyrepl.py b/Lib/test/test_pyrepl/test_pyrepl.py index b605e869717fad..7c5f7e29c23383 100644 --- a/Lib/test/test_pyrepl/test_pyrepl.py +++ b/Lib/test/test_pyrepl/test_pyrepl.py @@ -16,6 +16,7 @@ from test.support import has_subprocess_support, SHORT_TIMEOUT, STDLIB_DIR from test.support.import_helper import import_module from test.support.os_helper import EnvironmentVarGuard, unlink +from test.support.script_helper import EnsureSafeUserHistory from .support import ( FakeConsole, @@ -45,7 +46,7 @@ pty = None -class ReplTestCase(TestCase): +class ReplTestCase(EnsureSafeUserHistory, TestCase): def setUp(self): if not has_subprocess_support: raise SkipTest("test module requires subprocess") @@ -97,10 +98,11 @@ def _run_repl( env["PYTHON_HISTORY"] = os.path.join(cwd, ".regrtest_history") if cmdline_args is not None: if "PYTHON_HISTORY" in env: - self.assertNotIn( - "-I", cmdline_args, - "PYTHON_HISTORY will be ignored by -I" - ) + for bad_option in ('-I', '-E'): + self.assertNotIn( + bad_option, cmdline_args, + f"PYTHON_HISTORY will be ignored by {bad_option}" + ) cmd.extend(cmdline_args) try: diff --git a/Lib/test/test_repl.py b/Lib/test/test_repl.py index 54e69277282c30..e3b4e7bad1c248 100644 --- a/Lib/test/test_repl.py +++ b/Lib/test/test_repl.py @@ -14,7 +14,7 @@ SuppressCrashReport, SHORT_TIMEOUT, ) -from test.support.script_helper import kill_python +from test.support.script_helper import kill_python, EnsureSafeUserHistory from test.support.import_helper import import_module try: @@ -71,7 +71,7 @@ def run_on_interactive_mode(source): @support.force_not_colorized_test_class -class TestInteractiveInterpreter(unittest.TestCase): +class TestInteractiveInterpreter(EnsureSafeUserHistory, unittest.TestCase): @cpython_only # Python built with Py_TRACE_REFS fail with a fatal error in @@ -303,42 +303,44 @@ def test_asyncio_repl_reaches_python_startup_script(self): @unittest.skipUnless(pty, "requires pty") def test_asyncio_repl_is_ok(self): - m, s = pty.openpty() - cmd = [sys.executable, "-I", "-m", "asyncio"] - env = os.environ.copy() - proc = subprocess.Popen( - cmd, - stdin=s, - stdout=s, - stderr=s, - text=True, - close_fds=True, - env=env, - ) - os.close(s) - os.write(m, b"await asyncio.sleep(0)\n") - os.write(m, b"exit()\n") - output = [] - while select.select([m], [], [], SHORT_TIMEOUT)[0]: - try: - data = os.read(m, 1024).decode("utf-8") - if not data: + with os_helper.temp_dir() as tmpdir: + m, s = pty.openpty() + cmd = [sys.executable, "-m", "asyncio"] + env = os.environ.copy() + env["PYTHON_HISTORY"] = os.path.join(tmpdir, ".asyncio_history") + proc = subprocess.Popen( + cmd, + stdin=s, + stdout=s, + stderr=s, + text=True, + close_fds=True, + env=env, + ) + os.close(s) + os.write(m, b"await asyncio.sleep(0)\n") + os.write(m, b"exit()\n") + output = [] + while select.select([m], [], [], SHORT_TIMEOUT)[0]: + try: + data = os.read(m, 1024).decode("utf-8") + if not data: + break + except OSError: break - except OSError: - break - output.append(data) - os.close(m) - try: - exit_code = proc.wait(timeout=SHORT_TIMEOUT) - except subprocess.TimeoutExpired: - proc.kill() - exit_code = proc.wait() + output.append(data) + os.close(m) + try: + exit_code = proc.wait(timeout=SHORT_TIMEOUT) + except subprocess.TimeoutExpired: + proc.kill() + exit_code = proc.wait() - self.assertEqual(exit_code, 0, "".join(output)) + self.assertEqual(exit_code, 0, "".join(output)) @support.force_not_colorized_test_class -class TestInteractiveModeSyntaxErrors(unittest.TestCase): +class TestInteractiveModeSyntaxErrors(EnsureSafeUserHistory, unittest.TestCase): def test_interactive_syntax_error_correct_line(self): output = run_on_interactive_mode(dedent("""\ @@ -356,7 +358,7 @@ def f(): self.assertEqual(traceback_lines, expected_lines) -class TestAsyncioREPL(unittest.TestCase): +class TestAsyncioREPL(EnsureSafeUserHistory, unittest.TestCase): def test_multiple_statements_fail_early(self): user_input = "1 / 0; print(f'afterwards: {1+1}')" p = spawn_repl("-m", "asyncio") From 70ba65a9e134ee87f341e713b7d099cfce6d2e98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Sat, 27 Sep 2025 13:52:04 +0200 Subject: [PATCH 03/12] fix WASI build --- Lib/test/support/script_helper.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/Lib/test/support/script_helper.py b/Lib/test/support/script_helper.py index 1c1c746f7cae42..bbdce7e0ba20e9 100644 --- a/Lib/test/support/script_helper.py +++ b/Lib/test/support/script_helper.py @@ -352,12 +352,11 @@ class EnsureSafeUserHistory(unittest.TestCase): @classmethod def setUpClass(cls): - if history_file := _site_gethistoryfile(): - if os.path.exists(history_file): - cls.__history_file = history_file - cls.__history_stat = _file_signature(history_file) - else: - cls.__history_file = cls.__history_stat = None + cls.__history_file = cls.__history_stat = None + history_file = _site_gethistoryfile() + if os.path.exists(history_file): + cls.__history_file = history_file + cls.__history_stat = _file_signature(history_file) def tearDown(self): super().tearDown() From 41ceb15cf2dad1805838f6f8ddf5fddae736c614 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Sat, 27 Sep 2025 13:53:15 +0200 Subject: [PATCH 04/12] correctly call setUp/tearDown hooks --- Lib/test/support/script_helper.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/Lib/test/support/script_helper.py b/Lib/test/support/script_helper.py index bbdce7e0ba20e9..abf73525c9cce2 100644 --- a/Lib/test/support/script_helper.py +++ b/Lib/test/support/script_helper.py @@ -352,6 +352,7 @@ class EnsureSafeUserHistory(unittest.TestCase): @classmethod def setUpClass(cls): + super().setUpClass() cls.__history_file = cls.__history_stat = None history_file = _site_gethistoryfile() if os.path.exists(history_file): @@ -359,15 +360,14 @@ def setUpClass(cls): cls.__history_stat = _file_signature(history_file) def tearDown(self): + if self.__history_file is not None: + self.assertTrue( + os.path.exists(self.__history_file), + f"PYTHON_HISTORY file ({self.__history_file!r}) was deleted" + ) + self.assertEqual( + self.__history_stat, + _file_signature(self.__history_file), + f"PYTHON_HISTORY file ({self.__history_file!r}) was altered" + ) super().tearDown() - if self.__history_file is None: - return - self.assertTrue( - os.path.exists(self.__history_file), - f"PYTHON_HISTORY file ({self.__history_file!r}) was deleted" - ) - self.assertEqual( - self.__history_stat, - _file_signature(self.__history_file), - f"PYTHON_HISTORY file ({self.__history_file!r}) was altered" - ) From a574767b711079715048fa84030b9cb0ee2ba927 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Sun, 28 Sep 2025 11:53:27 +0200 Subject: [PATCH 05/12] run checks after tearDown() --- Lib/test/support/script_helper.py | 34 ++++++++++++++++++++++--------- 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/Lib/test/support/script_helper.py b/Lib/test/support/script_helper.py index abf73525c9cce2..e290ed0dc41efd 100644 --- a/Lib/test/support/script_helper.py +++ b/Lib/test/support/script_helper.py @@ -330,10 +330,10 @@ def title(text): _site_gethistoryfile = site.gethistoryfile def _gethistoryfile(): - """Patch site.gethistoryfile() to ignore -I for PYTHON_HISTORY. + """Patch site.gethistoryfile() to ignore -I/-E for PYTHON_HISTORY. - The -I option is necessary for test_no_memory() but using it - forbids using a custom PYTHON_HISTORY. + Some tests (e.g, test_repl.test_no_memory) require -I/-E, + but those options forbid using a custom PYTHON_HISTORY. """ history = os.environ.get("PYTHON_HISTORY") return history or os.path.join(os.path.expanduser('~'), '.python_history') @@ -351,16 +351,22 @@ def _file_signature(file): class EnsureSafeUserHistory(unittest.TestCase): @classmethod - def setUpClass(cls): - super().setUpClass() - cls.__history_file = cls.__history_stat = None - history_file = _site_gethistoryfile() + def __history_setup_check(cls): + # Ensure that the system-wide history file is not altered by tests. + history_file = os.path.join(os.path.expanduser('~'), '.python_history') + cls.__history_file = history_file if os.path.exists(history_file): - cls.__history_file = history_file cls.__history_stat = _file_signature(history_file) + else: + cls.__history_stat = None - def tearDown(self): - if self.__history_file is not None: + def __history_teardown_check(self): + if self.__history_stat is None: + self.assertFalse( + os.path.exists(self.__history_file), + f"PYTHON_HISTORY file ({self.__history_file!r}) was created" + ) + else: self.assertTrue( os.path.exists(self.__history_file), f"PYTHON_HISTORY file ({self.__history_file!r}) was deleted" @@ -370,4 +376,12 @@ def tearDown(self): _file_signature(self.__history_file), f"PYTHON_HISTORY file ({self.__history_file!r}) was altered" ) + + @classmethod + def setUpClass(cls): + cls.__history_setup_check() + super().setUpClass() + + def tearDown(self): super().tearDown() + self.__history_teardown_check() From e44596e975fe3d49e72ca8d235e966724b9f2fc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Sun, 28 Sep 2025 11:56:02 +0200 Subject: [PATCH 06/12] add eager checks in regrtest --- Lib/test/libregrtest/main.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/Lib/test/libregrtest/main.py b/Lib/test/libregrtest/main.py index a2d01b157ac89b..2dff7aa45239eb 100644 --- a/Lib/test/libregrtest/main.py +++ b/Lib/test/libregrtest/main.py @@ -2,6 +2,7 @@ import random import re import shlex +import stat import sys import sysconfig import time @@ -160,6 +161,14 @@ def __init__(self, ns: Namespace, _add_python_opts: bool = False): self.next_single_test: TestName | None = None self.next_single_filename: StrPath | None = None + history_file = os.path.join(os.path.expanduser('~'), '.python_history') + self.__history_file = history_file + if os.path.exists(history_file): + st = os.stat(history_file) + self.__history_stat = (stat.S_IFMT(st.st_mode), st.st_size) + else: + self.__history_stat = None + def log(self, line: str = '') -> None: self.logger.log(line) @@ -392,6 +401,16 @@ def run_test( else: result = run_single_test(test_name, runtests) + if self.__history_stat is None: + if os.path.exists(self.__history_file): + raise AssertionError(f"{test_name}: created history file") + else: + if not os.path.exists(self.__history_file): + raise AssertionError(f"{test_name}: deleted history file") + st = os.stat(self.__history_file) + if self.__history_stat != (stat.S_IFMT(st.st_mode), st.st_size): + raise AssertionError(f"{test_name}: altered history file") + self.results.accumulate_result(result, runtests) return result From 5126bf0a815d783e6797f653abc1cd4d9eced731 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Mon, 29 Sep 2025 11:27:06 +0200 Subject: [PATCH 07/12] fix editline analysis on macOS --- Lib/_pyrepl/readline.py | 40 ++++++++++++++++++++++++++++------------ 1 file changed, 28 insertions(+), 12 deletions(-) diff --git a/Lib/_pyrepl/readline.py b/Lib/_pyrepl/readline.py index 23b8fa6b9c7625..cb3a63389dcca2 100644 --- a/Lib/_pyrepl/readline.py +++ b/Lib/_pyrepl/readline.py @@ -50,6 +50,8 @@ from .unix_console import UnixConsole as Console, _error ENCODING = sys.getdefaultencoding() or "latin1" +_EDITLINE_MARKER = "_HiStOrY_V2_" +_EDITLINE_BYTES_MARKER = b"_HiStOrY_V2_" # types @@ -60,7 +62,7 @@ TYPE_CHECKING = False if TYPE_CHECKING: - from typing import Any, Mapping + from typing import Any, IO, Mapping MoreLinesCallable = Callable[[str], bool] @@ -425,6 +427,19 @@ def set_history_length(self, length: int) -> None: def get_current_history_length(self) -> int: return len(self.get_reader().history) + @staticmethod + def _analyze_history_file(filename: str | IO[bytes]) -> tuple[bool, str]: + if isinstance(filename, str): + if not os.path.exists(filename): + return False, "utf-8" + with open(filename, "rb") as f: + is_editline = f.readline().startswith(_EDITLINE_BYTES_MARKER) + else: + is_editline = f.readline().startswith(_EDITLINE_BYTES_MARKER) + if is_editline: + return True, "unicode-escape" + return False, "utf-8" + def read_history_file(self, filename: str = gethistoryfile()) -> None: # multiline extension (really a hack) for the end of lines that # are actually continuations inside a single multiline_input() @@ -433,12 +448,9 @@ def read_history_file(self, filename: str = gethistoryfile()) -> None: history = self.get_reader().history with open(os.path.expanduser(filename), 'rb') as f: - is_editline = f.readline().startswith(b"_HiStOrY_V2_") - if is_editline: - encoding = "unicode-escape" - else: + is_editline, encoding = self._analyze_history_file(f) + if not is_editline: f.seek(0) - encoding = "utf-8" lines = [line.decode(encoding, errors='replace') for line in f.read().split(b'\n')] buffer = [] @@ -457,9 +469,12 @@ def read_history_file(self, filename: str = gethistoryfile()) -> None: def write_history_file(self, filename: str = gethistoryfile()) -> None: maxlength = self.saved_history_length history = self.get_reader().get_trimmed_history(maxlength) - f = open(os.path.expanduser(filename), "w", - encoding="utf-8", newline="\n") - with f: + + filename = os.path.expanduser(filename) + is_editline, encoding = self._analyze_history_file(filename) + with open(filename, "w", encoding=encoding, newline="\n") as f: + if is_editline: + f.write(f"{_EDITLINE_MARKER}\n") for entry in history: entry = entry.replace("\n", "\r\n") # multiline history support f.write(entry + "\n") @@ -469,9 +484,10 @@ def append_history_file(self, filename: str = gethistoryfile()) -> None: saved_length = self.get_history_length() length = self.get_current_history_length() - saved_length history = reader.get_trimmed_history(length) - f = open(os.path.expanduser(filename), "a", - encoding="utf-8", newline="\n") - with f: + + filename = os.path.expanduser(filename) + _, encoding = self._analyze_history_file(filename) + with open(filename, "a", encoding=encoding, newline="\n") as f: for entry in history: entry = entry.replace("\n", "\r\n") # multiline history support f.write(entry + "\n") From 6237cc779fbd43cc790eba7a2c284510f0550c71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Mon, 29 Sep 2025 11:27:57 +0200 Subject: [PATCH 08/12] fix mypy --- Lib/test/libregrtest/main.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Lib/test/libregrtest/main.py b/Lib/test/libregrtest/main.py index 2dff7aa45239eb..b85c105c3f4160 100644 --- a/Lib/test/libregrtest/main.py +++ b/Lib/test/libregrtest/main.py @@ -163,11 +163,10 @@ def __init__(self, ns: Namespace, _add_python_opts: bool = False): history_file = os.path.join(os.path.expanduser('~'), '.python_history') self.__history_file = history_file + self.__history_stat: tuple[int, int] | None = None if os.path.exists(history_file): st = os.stat(history_file) self.__history_stat = (stat.S_IFMT(st.st_mode), st.st_size) - else: - self.__history_stat = None def log(self, line: str = '') -> None: self.logger.log(line) From 9d318985eac5b4768ab68f6a05bc920b57083761 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Mon, 29 Sep 2025 11:29:58 +0200 Subject: [PATCH 09/12] fix possible unbound errors --- Lib/_pyrepl/readline.py | 1 + 1 file changed, 1 insertion(+) diff --git a/Lib/_pyrepl/readline.py b/Lib/_pyrepl/readline.py index cb3a63389dcca2..87f5c664cf1dc2 100644 --- a/Lib/_pyrepl/readline.py +++ b/Lib/_pyrepl/readline.py @@ -429,6 +429,7 @@ def get_current_history_length(self) -> int: @staticmethod def _analyze_history_file(filename: str | IO[bytes]) -> tuple[bool, str]: + is_editline = False if isinstance(filename, str): if not os.path.exists(filename): return False, "utf-8" From 1c7e2d9b083382254ad5c7c24bdd679ffbd94ddc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Mon, 29 Sep 2025 12:23:32 +0200 Subject: [PATCH 10/12] fixup! --- Lib/_pyrepl/readline.py | 27 ++++++++++++--------------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/Lib/_pyrepl/readline.py b/Lib/_pyrepl/readline.py index 87f5c664cf1dc2..ce175b0ebbd57c 100644 --- a/Lib/_pyrepl/readline.py +++ b/Lib/_pyrepl/readline.py @@ -428,18 +428,14 @@ def get_current_history_length(self) -> int: return len(self.get_reader().history) @staticmethod - def _analyze_history_file(filename: str | IO[bytes]) -> tuple[bool, str]: - is_editline = False + def _is_editline_history(filename: str | IO[bytes]) -> bool: if isinstance(filename, str): if not os.path.exists(filename): - return False, "utf-8" + return False with open(filename, "rb") as f: - is_editline = f.readline().startswith(_EDITLINE_BYTES_MARKER) - else: - is_editline = f.readline().startswith(_EDITLINE_BYTES_MARKER) - if is_editline: - return True, "unicode-escape" - return False, "utf-8" + return f.readline().startswith(_EDITLINE_BYTES_MARKER) + return False + return filename.readline().startswith(_EDITLINE_BYTES_MARKER) def read_history_file(self, filename: str = gethistoryfile()) -> None: # multiline extension (really a hack) for the end of lines that @@ -449,9 +445,11 @@ def read_history_file(self, filename: str = gethistoryfile()) -> None: history = self.get_reader().history with open(os.path.expanduser(filename), 'rb') as f: - is_editline, encoding = self._analyze_history_file(f) - if not is_editline: + if self._is_editline_history(f): + encoding = "unicode-escape" + else: f.seek(0) + encoding = "utf-8" lines = [line.decode(encoding, errors='replace') for line in f.read().split(b'\n')] buffer = [] @@ -472,8 +470,8 @@ def write_history_file(self, filename: str = gethistoryfile()) -> None: history = self.get_reader().get_trimmed_history(maxlength) filename = os.path.expanduser(filename) - is_editline, encoding = self._analyze_history_file(filename) - with open(filename, "w", encoding=encoding, newline="\n") as f: + is_editline = self._is_editline_history(filename) + with open(filename, "w", encoding="utf-8", newline="\n") as f: if is_editline: f.write(f"{_EDITLINE_MARKER}\n") for entry in history: @@ -487,8 +485,7 @@ def append_history_file(self, filename: str = gethistoryfile()) -> None: history = reader.get_trimmed_history(length) filename = os.path.expanduser(filename) - _, encoding = self._analyze_history_file(filename) - with open(filename, "a", encoding=encoding, newline="\n") as f: + with open(filename, "a", encoding="utf-8", newline="\n") as f: for entry in history: entry = entry.replace("\n", "\r\n") # multiline history support f.write(entry + "\n") From 21b308371db3005e9cae2976ab9e7c3d29e57e99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Mon, 29 Sep 2025 12:24:19 +0200 Subject: [PATCH 11/12] simplify! --- Lib/_pyrepl/readline.py | 1 - 1 file changed, 1 deletion(-) diff --git a/Lib/_pyrepl/readline.py b/Lib/_pyrepl/readline.py index ce175b0ebbd57c..e10202eb0ac27d 100644 --- a/Lib/_pyrepl/readline.py +++ b/Lib/_pyrepl/readline.py @@ -434,7 +434,6 @@ def _is_editline_history(filename: str | IO[bytes]) -> bool: return False with open(filename, "rb") as f: return f.readline().startswith(_EDITLINE_BYTES_MARKER) - return False return filename.readline().startswith(_EDITLINE_BYTES_MARKER) def read_history_file(self, filename: str = gethistoryfile()) -> None: From 8939460235bedd2c432acf169d0db2315d2839d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Sun, 5 Oct 2025 12:21:59 +0200 Subject: [PATCH 12/12] fix C readline --- Modules/readline.c | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/Modules/readline.c b/Modules/readline.c index 630a6879990949..91705c65b8e787 100644 --- a/Modules/readline.c +++ b/Modules/readline.c @@ -316,6 +316,7 @@ readline_read_history_file_impl(PyObject *module, PyObject *filename_obj) } static int _history_length = -1; /* do not truncate history by default */ +static int _py_get_history_length_lock_held(void); /* Exported function to save a readline history file */ @@ -354,6 +355,18 @@ readline_write_history_file_impl(PyObject *module, PyObject *filename_obj) return NULL; } } + + /* + * If the current history is empty, we do not re-create an empty + * file we want to preserve the first line marker '_HiStOrY_V2_'. + * + * See https://github.com/python/cpython/issues/139352. + */ + if (_py_get_history_length_lock_held() == 0) { + Py_XDECREF(filename_bytes); + Py_RETURN_NONE; + } + errno = err = write_history(filename); int history_length = FT_ATOMIC_LOAD_INT_RELAXED(_history_length); if (!err && history_length >= 0)