From dba4513236ce27968af220aa25f4ac3a5cd239e2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 07:41:04 +0000 Subject: [PATCH 1/3] Remove py3compat module and replace with native Python 3 equivalents - Delete IPython/utils/py3compat.py which was marked as deprecated - Replace py3compat.decode() with direct .decode() calls - Replace py3compat.encode() with direct .encode() calls - Replace py3compat.cast_unicode() with inline type checks and decoding - Replace py3compat.input() with builtin input() - Replace py3compat.safe_unicode() with try/except str() fallback - Replace py3compat.execfile() with inline exec() or local helpers - Replace py3compat.PYPY with platform.python_implementation() == 'PyPy' - Import DEFAULT_ENCODING directly where needed for encoding operations Files updated: - IPython/utils/_process_win32.py - IPython/utils/_process_common.py - IPython/lib/editorhooks.py - IPython/lib/demo.py - IPython/lib/clipboard.py - IPython/lib/pretty.py - IPython/core/page.py - IPython/core/tbtools.py - IPython/core/interactiveshell.py - IPython/terminal/magics.py - IPython/testing/tools.py - tests/test_debugger.py Co-Authored-By: Claude Haiku 4.5 Claude-Session: https://claude.ai/code/session_01NyXpP8SsNDxTCEKnZ5MpF8 --- IPython/core/interactiveshell.py | 19 ++++++--- IPython/core/page.py | 3 +- IPython/core/tbtools.py | 8 ++-- IPython/lib/clipboard.py | 10 ++--- IPython/lib/demo.py | 5 +-- IPython/lib/editorhooks.py | 3 +- IPython/lib/pretty.py | 5 +-- IPython/terminal/interactiveshell.py | 1 - IPython/terminal/magics.py | 3 +- IPython/testing/tools.py | 6 +-- IPython/utils/_process_common.py | 6 +-- IPython/utils/_process_win32.py | 3 +- IPython/utils/py3compat.py | 60 ---------------------------- tests/test_debugger.py | 15 +++++-- 14 files changed, 48 insertions(+), 99 deletions(-) delete mode 100644 IPython/utils/py3compat.py diff --git a/IPython/core/interactiveshell.py b/IPython/core/interactiveshell.py index df325148710..795ff8b4ebe 100644 --- a/IPython/core/interactiveshell.py +++ b/IPython/core/interactiveshell.py @@ -87,7 +87,7 @@ from IPython.display import display from IPython.paths import get_ipython_dir from IPython.testing.skipdoctest import skip_doctest -from IPython.utils import PyColorize, io, openpy, py3compat +from IPython.utils import PyColorize, io, openpy from IPython.utils.decorators import undoc from IPython.utils.io import ask_yes_no from IPython.utils.ipstruct import Struct @@ -2866,11 +2866,19 @@ def _user_obj_error(self): etype, evalue, tb = self._get_exc_info() stb = self.InteractiveTB.get_exception_only(etype, evalue) + try: + evalue_str = str(evalue) + except UnicodeError: + try: + evalue_str = repr(evalue) + except UnicodeError: + evalue_str = "Unrecoverably corrupt evalue" + exc_info = { "status": "error", "traceback": stb, "ename": etype.__name__, - "evalue": py3compat.safe_unicode(evalue), + "evalue": evalue_str, } return exc_info @@ -2977,9 +2985,10 @@ def safe_execfile(self, fname, *where, exit_ignore=False, raise_exceptions=False with prepended_to_syspath(dname), self.builtin_trap: try: glob, loc = (where + (None, ))[:2] - py3compat.execfile( - fname, glob, loc, - self.compile if shell_futures else None) + loc = loc if (loc is not None) else glob + with open(fname, "rb") as f: + compiler_fn = self.compile if shell_futures else compile + exec(compiler_fn(f.read(), fname, "exec"), glob, loc) except SystemExit as status: # If the call was made with 0 or None exit status (sys.exit(0) # or sys.exit() ), don't bother showing a traceback, as both of diff --git a/IPython/core/page.py b/IPython/core/page.py index 63ad7b36bf3..3ace57a47a3 100644 --- a/IPython/core/page.py +++ b/IPython/core/page.py @@ -29,7 +29,6 @@ from IPython.utils.data import chop from IPython.utils.process import system from IPython.utils.terminal import get_terminal_size -from IPython.utils import py3compat def display_page(strng, start=0, screen_lines=25): @@ -340,7 +339,7 @@ def page_more(): return result else: def page_more(): - ans = py3compat.input('---Return to continue, q to quit--- ') + ans = input('---Return to continue, q to quit--- ') if ans.lower().startswith('q'): return False else: diff --git a/IPython/core/tbtools.py b/IPython/core/tbtools.py index 19510ab1a03..b4af501c4dd 100644 --- a/IPython/core/tbtools.py +++ b/IPython/core/tbtools.py @@ -14,7 +14,6 @@ from IPython import get_ipython from IPython.core import debugger from IPython.utils import path as util_path -from IPython.utils import py3compat from IPython.utils.PyColorize import Theme, TokenStream, theme_table _sentinel = object() @@ -202,9 +201,10 @@ def _tokens_filename( (Filename, f", line {lineno}"), ] else: - name = util_path.compress_user( - py3compat.cast_unicode(file or "", util_path.fs_encoding) - ) + file_str = file or "" + if isinstance(file_str, bytes): + file_str = file_str.decode(util_path.fs_encoding, "replace") + name = util_path.compress_user(file_str) if lineno is None: return [ (Normal, "File "), diff --git a/IPython/lib/clipboard.py b/IPython/lib/clipboard.py index e0bf80b0755..c6a8688c92c 100644 --- a/IPython/lib/clipboard.py +++ b/IPython/lib/clipboard.py @@ -5,7 +5,7 @@ import subprocess from IPython.core.error import TryNext -import IPython.utils.py3compat as py3compat +from IPython.utils.encoding import DEFAULT_ENCODING class ClipboardEmpty(ValueError): @@ -28,7 +28,7 @@ def win32_clipboard_get(): except (TypeError, win32clipboard.error): try: text = win32clipboard.GetClipboardData(win32clipboard.CF_TEXT) - text = py3compat.cast_unicode(text, py3compat.DEFAULT_ENCODING) + text = text if isinstance(text, str) else text.decode(DEFAULT_ENCODING, "replace") except (TypeError, win32clipboard.error) as e: raise ClipboardEmpty from e finally: @@ -44,7 +44,7 @@ def osx_clipboard_get() -> str: bytes_, stderr = p.communicate() # Text comes in with old Mac \r line endings. Change them to \n. bytes_ = bytes_.replace(b'\r', b'\n') - text = py3compat.decode(bytes_) + text = bytes_.decode(DEFAULT_ENCODING, "replace") return text @@ -68,7 +68,7 @@ def tkinter_clipboard_get(): raise ClipboardEmpty from e finally: root.destroy() - text = py3compat.cast_unicode(text, py3compat.DEFAULT_ENCODING) + text = text if isinstance(text, str) else text.decode(DEFAULT_ENCODING, "replace") return text @@ -95,7 +95,7 @@ def wayland_clipboard_get(): raise ClipboardEmpty try: - text = py3compat.decode(raw) + text = raw.decode(DEFAULT_ENCODING, "replace") except UnicodeDecodeError as e: raise ClipboardEmpty from e diff --git a/IPython/lib/demo.py b/IPython/lib/demo.py index 08c0f54b5ce..27facd5d745 100644 --- a/IPython/lib/demo.py +++ b/IPython/lib/demo.py @@ -188,7 +188,6 @@ from IPython.utils.text import marquee from IPython.utils import openpy -from IPython.utils import py3compat __all__ = ['Demo','IPythonDemo','LineDemo','IPythonLineDemo','DemoError'] class DemoError(Exception): pass @@ -475,7 +474,7 @@ def __call__(self,index=None): print(marquee('output:')) else: print(marquee('Press to quit, to execute...'), end=' ') - ans = py3compat.input().strip() + ans = input().strip() if ans: print(marquee('Block NOT executed')) return @@ -643,7 +642,7 @@ def slide(file_path, noclear=False, format_rst=True, formatter="terminal", while not demo.finished: demo() try: - py3compat.input('\n' + delimiter) + input('\n' + delimiter) except KeyboardInterrupt: exit(1) diff --git a/IPython/lib/editorhooks.py b/IPython/lib/editorhooks.py index 24e00212a4e..959395ec61c 100644 --- a/IPython/lib/editorhooks.py +++ b/IPython/lib/editorhooks.py @@ -12,7 +12,6 @@ from IPython import get_ipython from IPython.core.error import TryNext -from IPython.utils import py3compat def install_editor(template, wait=False): @@ -55,7 +54,7 @@ def call_editor(self, filename, line=0): if proc.wait() != 0: raise TryNext() if wait: - py3compat.input("Press Enter when done editing:") + input("Press Enter when done editing:") get_ipython().set_hook('editor', call_editor) get_ipython().editor = template diff --git a/IPython/lib/pretty.py b/IPython/lib/pretty.py index c060815ca20..03942b73bb5 100644 --- a/IPython/lib/pretty.py +++ b/IPython/lib/pretty.py @@ -96,6 +96,7 @@ def _repr_pretty_(self, p, cycle): from contextlib import contextmanager import datetime import os +import platform import re import sys import types @@ -103,8 +104,6 @@ def _repr_pretty_(self, p, cycle): from inspect import signature from io import StringIO -from IPython.utils.py3compat import PYPY - from typing import Dict # Allow pretty-printing of functions with PEP-649 annotations @@ -708,7 +707,7 @@ def _super_pprint(obj, p, cycle): p.pretty(obj.__thisclass__) p.text(',') p.breakable() - if PYPY: # In PyPy, super() objects don't have __self__ attributes + if platform.python_implementation() == "PyPy": # In PyPy, super() objects don't have __self__ attributes dself = obj.__repr__.__self__ p.pretty(None if dself is obj else dself) else: diff --git a/IPython/terminal/interactiveshell.py b/IPython/terminal/interactiveshell.py index ae84af06509..2efd92bb882 100644 --- a/IPython/terminal/interactiveshell.py +++ b/IPython/terminal/interactiveshell.py @@ -12,7 +12,6 @@ display_formatter_default_active_types, terminal_default_mime_renderers, ) -from IPython.utils.py3compat import input from IPython.utils.PyColorize import theme_table from IPython.utils.terminal import toggle_set_term_title, set_term_title, restore_term_title from IPython.utils.process import abbrev_cwd diff --git a/IPython/terminal/magics.py b/IPython/terminal/magics.py index dc64918881e..aa9e3a1301b 100644 --- a/IPython/terminal/magics.py +++ b/IPython/terminal/magics.py @@ -13,9 +13,8 @@ from IPython.lib.clipboard import ClipboardEmpty from IPython.testing.skipdoctest import skip_doctest from IPython.utils.text import SList, strip_email_quotes -from IPython.utils import py3compat -def get_pasted_lines(sentinel, l_input=py3compat.input, quiet=False): +def get_pasted_lines(sentinel, l_input=input, quiet=False): """ Yield pasted lines until the user enters the given sentinel value. """ if not quiet: diff --git a/IPython/testing/tools.py b/IPython/testing/tools.py index 0457a27107f..8f64a97d863 100644 --- a/IPython/testing/tools.py +++ b/IPython/testing/tools.py @@ -24,7 +24,7 @@ from traitlets.config.loader import Config from IPython.utils.process import get_output_error_code from IPython.utils.io import temp_pyfile, Tee -from IPython.utils import py3compat +from IPython.utils.encoding import DEFAULT_ENCODING from . import decorators as dec from . import skipdoctest @@ -207,8 +207,8 @@ def ipexec(fname: str, options: list[str] | None=None, commands: tuple[str, ...] if not isinstance(v, str): print(k, v) p = Popen(full_cmd, stdout=PIPE, stderr=PIPE, stdin=PIPE, env=env) - out, err = p.communicate(input=py3compat.encode('\n'.join(commands)) or None) - out, err = py3compat.decode(out), py3compat.decode(err) + out, err = p.communicate(input=('\n'.join(commands).encode(DEFAULT_ENCODING, "replace")) or None) + out, err = out.decode(DEFAULT_ENCODING, "replace"), err.decode(DEFAULT_ENCODING, "replace") # `import readline` causes 'ESC[?1034h' to be output sometimes, # so strip that out before doing comparisons if out: diff --git a/IPython/utils/_process_common.py b/IPython/utils/_process_common.py index 5017636ac0d..f4b850b940f 100644 --- a/IPython/utils/_process_common.py +++ b/IPython/utils/_process_common.py @@ -23,7 +23,7 @@ _T = TypeVar("_T") -from IPython.utils import py3compat +from .encoding import DEFAULT_ENCODING #----------------------------------------------------------------------------- # Function definitions @@ -140,7 +140,7 @@ def getoutput(cmd: str | list[str]) -> str: out = process_handler(cmd, lambda p: p.communicate()[0], subprocess.STDOUT) if out is None: return '' - return py3compat.decode(out) + return out.decode(DEFAULT_ENCODING, "replace") def getoutputerror(cmd: str | list[str]) -> tuple[str, str]: @@ -183,7 +183,7 @@ def get_output_error_code(cmd: str | list[str]) -> tuple[str, str, int | None]: if result is None: return '', '', None (out, err), p = result - return py3compat.decode(out), py3compat.decode(err), p.returncode + return out.decode(DEFAULT_ENCODING, "replace"), err.decode(DEFAULT_ENCODING, "replace"), p.returncode def arg_split(commandline: str, posix: bool = False, strict: bool = True) -> list[str]: """Split a command line's arguments in a shell-like manner. diff --git a/IPython/utils/_process_win32.py b/IPython/utils/_process_win32.py index 063a3bf74eb..47e6958eca0 100644 --- a/IPython/utils/_process_win32.py +++ b/IPython/utils/_process_win32.py @@ -15,7 +15,6 @@ from types import TracebackType from typing import List, Optional -from . import py3compat from ._process_common import arg_split as py_arg_split from ._process_common import process_handler, read_no_interrupt @@ -156,7 +155,7 @@ def getoutput(cmd: str) -> str: if out is None: out = b"" - return py3compat.decode(out) + return out.decode(DEFAULT_ENCODING, "replace") try: diff --git a/IPython/utils/py3compat.py b/IPython/utils/py3compat.py deleted file mode 100644 index 6e8173d70b0..00000000000 --- a/IPython/utils/py3compat.py +++ /dev/null @@ -1,60 +0,0 @@ -"""Compatibility tricks for Python 3. Mainly to do with unicode. - -This file is deprecated and will be removed in a future version. -""" - -import platform -import builtins as builtin_mod - -from .encoding import DEFAULT_ENCODING -from typing import Optional - - -def decode(s: bytes, encoding: str | None = None) -> str: - encoding = encoding or DEFAULT_ENCODING - return s.decode(encoding, "replace") - - -def encode(u: str, encoding: str | None=None) -> bytes: - encoding = encoding or DEFAULT_ENCODING - return u.encode(encoding, "replace") - - -def cast_unicode(s: str | bytes, encoding: str | None=None) -> str: - if isinstance(s, bytes): - return decode(s, encoding) - return s - - -def safe_unicode(e): - """unicode(e) with various fallbacks. Used for exceptions, which may not be - safe to call unicode() on. - """ - try: - return str(e) - except UnicodeError: - pass - - try: - return repr(e) - except UnicodeError: - pass - - return "Unrecoverably corrupt evalue" - - -# keep reference to builtin_mod because the kernel overrides that value -# to forward requests to a frontend. -def input(prompt=""): - return builtin_mod.input(prompt) - - -def execfile(fname, glob, loc=None, compiler=None): - __tracebackhide__ = "__ipython_bottom__" - loc = loc if (loc is not None) else glob - with open(fname, "rb") as f: - compiler = compiler or compile - exec(compiler(f.read(), fname, "exec"), glob, loc) - - -PYPY = platform.python_implementation() == "PyPy" diff --git a/tests/test_debugger.py b/tests/test_debugger.py index 60cbf50e8d1..3bb4a9dd689 100644 --- a/tests/test_debugger.py +++ b/tests/test_debugger.py @@ -16,12 +16,19 @@ from IPython.core import debugger from IPython.testing import IPYTHON_TESTING_TIMEOUT_SCALE from IPython.testing.decorators import skip_win32 -from IPython.utils import py3compat import pytest -# ----------------------------------------------------------------------------- +# Helper for executing files with proper debugger frame marking +def execfile(fname, glob, loc=None, compiler=None): + __tracebackhide__ = "__ipython_bottom__" + loc = loc if (loc is not None) else glob + with open(fname, "rb") as f: + compiler_fn = compiler or compile + exec(compiler_fn(f.read(), fname, "exec"), glob, loc) + + # Helper classes, from CPython's Pdb test suite -# ----------------------------------------------------------------------------- +# class _FakeInput(object): @@ -304,7 +311,7 @@ def test_execfile_marks_debugger_internal_frames_hidden(): ) with pytest.raises(RuntimeError) as exc_info: - py3compat.execfile(script, {}) + execfile(script, {}) ipdb = debugger.Pdb() stack, _ = ipdb.get_stack(None, exc_info.value.__traceback__) From 0a9909571cef297bffd51e71f94484c5057e21c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 08:17:46 +0000 Subject: [PATCH 2/3] Restore execfile wrapper frame in safe_execfile The py3compat removal flattened safe_execfile's exec() call, dropping the intermediate stack frame that tracebacks (and the doctest_tb_plain /doctest_tb_sysexit tests) expect to see as "in execfile", and which tb_offset=2 relies on to skip the right number of frames. --- IPython/core/interactiveshell.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/IPython/core/interactiveshell.py b/IPython/core/interactiveshell.py index 795ff8b4ebe..679f9464950 100644 --- a/IPython/core/interactiveshell.py +++ b/IPython/core/interactiveshell.py @@ -2982,13 +2982,19 @@ def safe_execfile(self, fname, *where, exit_ignore=False, raise_exceptions=False # Python inserts the script's directory into sys.path dname = str(fname.parent) + def execfile(fname, glob, loc=None, compiler=None): + __tracebackhide__ = "__ipython_bottom__" + loc = loc if (loc is not None) else glob + with open(fname, "rb") as f: + compiler = compiler or compile + exec(compiler(f.read(), fname, "exec"), glob, loc) + with prepended_to_syspath(dname), self.builtin_trap: try: glob, loc = (where + (None, ))[:2] - loc = loc if (loc is not None) else glob - with open(fname, "rb") as f: - compiler_fn = self.compile if shell_futures else compile - exec(compiler_fn(f.read(), fname, "exec"), glob, loc) + execfile( + fname, glob, loc, + self.compile if shell_futures else None) except SystemExit as status: # If the call was made with 0 or None exit status (sys.exit(0) # or sys.exit() ), don't bother showing a traceback, as both of From 10101d11386dfae982233e1669684fed24789bec Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 09:12:27 +0000 Subject: [PATCH 3/3] Simplify _tokens_filename: file is always str, not bytes Every call site passes a str filename (co_filename or a traceback record's filename), matching the str | None type annotation, so the bytes-decoding branch was dead code left over from cast_unicode(). --- IPython/core/tbtools.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/IPython/core/tbtools.py b/IPython/core/tbtools.py index b4af501c4dd..6e0a4865867 100644 --- a/IPython/core/tbtools.py +++ b/IPython/core/tbtools.py @@ -179,6 +179,7 @@ def _tokens_filename( em: wether bold or not file : str """ + assert file is None or isinstance(file, str) Normal = Token.NormalEm if em else Token.Normal Filename = Token.FilenameEm if em else Token.Filename ipinst = get_ipython() @@ -202,8 +203,6 @@ def _tokens_filename( ] else: file_str = file or "" - if isinstance(file_str, bytes): - file_str = file_str.decode(util_path.fs_encoding, "replace") name = util_path.compress_user(file_str) if lineno is None: return [