diff --git a/Lib/_pyrepl/readline.py b/Lib/_pyrepl/readline.py index 23b8fa6b9c7625..e10202eb0ac27d 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,15 @@ def set_history_length(self, length: int) -> None: def get_current_history_length(self) -> int: return len(self.get_reader().history) + @staticmethod + def _is_editline_history(filename: str | IO[bytes]) -> bool: + if isinstance(filename, str): + if not os.path.exists(filename): + return False + with open(filename, "rb") as f: + return f.readline().startswith(_EDITLINE_BYTES_MARKER) + 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 # are actually continuations inside a single multiline_input() @@ -433,8 +444,7 @@ 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: + if self._is_editline_history(f): encoding = "unicode-escape" else: f.seek(0) @@ -457,9 +467,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 = 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: entry = entry.replace("\n", "\r\n") # multiline history support f.write(entry + "\n") @@ -469,9 +482,9 @@ 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) + 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") diff --git a/Lib/test/libregrtest/main.py b/Lib/test/libregrtest/main.py index a2d01b157ac89b..b85c105c3f4160 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,13 @@ 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 + 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) + def log(self, line: str = '') -> None: self.logger.log(line) @@ -392,6 +400,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 diff --git a/Lib/test/support/script_helper.py b/Lib/test/support/script_helper.py index 46ce950433ddf6..e290ed0dc41efd 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,62 @@ 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/-E for 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') + + +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 __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_stat = _file_signature(history_file) + else: + cls.__history_stat = 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" + ) + self.assertEqual( + self.__history_stat, + _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() 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 47d384a209e9ac..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") @@ -68,7 +69,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 +84,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 +94,15 @@ 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: + 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: @@ -118,7 +123,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/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") 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. 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)