Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
faa22f4
Handle `PYTHONSTARTUP` script exceptions
johnslavik Oct 18, 2025
5b3ad2c
Add blurb
johnslavik Oct 18, 2025
00edac4
Use `console.showtraceback()` instead of `sys.excepthook()`
johnslavik Oct 18, 2025
e396622
Properly run asyncio REPL in REPL tests
johnslavik Oct 18, 2025
4079074
Move comment to a better place
johnslavik Oct 18, 2025
0440d0e
Add tests
johnslavik Oct 23, 2025
af6f657
Merge branch 'properly-run-asyncio-repl-in-repl-tests' into asyncio-r…
johnslavik Oct 23, 2025
848638d
Merge branch 'main' into asyncio-repl-handle-python-startup
johnslavik Oct 24, 2025
d158dbf
Improve test structure
johnslavik Oct 27, 2025
9355da7
Use `SHORT_TIMEOUT`
johnslavik Oct 27, 2025
3dc4cac
Revert "Use `SHORT_TIMEOUT`"
johnslavik Oct 27, 2025
c786584
Force no colorization
johnslavik Oct 27, 2025
b76db67
Linecache doesn't matter and shouldn't break tests
johnslavik Oct 28, 2025
cad1748
Don't rely on line numbering on Windows...
johnslavik Oct 29, 2025
d114ed5
Different names
johnslavik Oct 29, 2025
e6e10ad
Idiomatize and simplify
johnslavik Oct 29, 2025
ac6cd83
Merge branch 'main' into asyncio-repl-handle-python-startup
johnslavik Oct 30, 2025
149740a
Purifying `sys.path` is implied by `-P`
johnslavik Oct 30, 2025
bffac1c
Merge branch 'main' into asyncio-repl-handle-python-startup
johnslavik Nov 7, 2025
a4c307e
Separate tests for regular and asyncio REPL
johnslavik Nov 8, 2025
a774da5
Remove duplicate assertion
johnslavik Nov 8, 2025
b3ed3d4
Document `new_startup_env`
johnslavik Nov 8, 2025
df6dfd8
Fix `new_startup_env` docs
johnslavik Nov 8, 2025
b004839
More meaningful line breaks
johnslavik Nov 8, 2025
ce03cce
Better variables
johnslavik Nov 8, 2025
9e92510
Use default histfile
johnslavik Nov 8, 2025
0a50a50
Fix newline
johnslavik Nov 8, 2025
f8b8d53
Use `TestCase.enterContext`
johnslavik Nov 8, 2025
5701fef
Remove lines with ps1
johnslavik Nov 8, 2025
File filter

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion Lib/asyncio/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,12 @@ def run(self):
import tokenize
with tokenize.open(startup_path) as f:
startup_code = compile(f.read(), startup_path, "exec")
exec(startup_code, console.locals)
try:
exec(startup_code, console.locals)
except SystemExit:
raise
except BaseException:
console.showtraceback()

ps1 = getattr(sys, "ps1", ">>> ")
if CAN_USE_PYREPL:
Expand Down
187 changes: 106 additions & 81 deletions Lib/test/test_repl.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import subprocess
import sys
import unittest
from contextlib import contextmanager
from functools import partial
from textwrap import dedent
from test import support
Expand All @@ -28,7 +29,7 @@
raise unittest.SkipTest("test module requires subprocess")


def spawn_repl(*args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, custom=False, **kw):
def spawn_repl(*args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, custom=False, isolated=True, **kw):
"""Run the Python REPL with the given arguments.

kw is extra keyword args to pass to subprocess.Popen. Returns a Popen
Expand All @@ -42,7 +43,10 @@ def spawn_repl(*args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, custom=F
# path may be used by PyConfig_Get("module_search_paths") to build the
# default module search path.
stdin_fname = os.path.join(os.path.dirname(sys.executable), "<stdin>")
cmd_line = [stdin_fname, '-I']
cmd_line = [stdin_fname]
# Isolated mode implies -EPs and ignores PYTHON* variables.
if isolated:
cmd_line.append('-I')
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To reuse this routine and spawn_asyncio_repl below, we need to have a knob to not pass -I since that ignores PYTHONSTARTUP completely.

The asyncio REPL currently doesn't comply to that, which is a separate issue I'll report having learnt this.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tracking the -I edge case in #140648.

# Don't re-run the built-in REPL from interactive mode
# if we're testing a custom REPL (such as the asyncio REPL).
if not custom:
Expand All @@ -64,6 +68,16 @@ def spawn_repl(*args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, custom=F
spawn_asyncio_repl = partial(spawn_repl, "-m", "asyncio", custom=True)


@contextmanager
def new_startup_env(*, code: str, histfile: str = ".pythonhist"):
"""Create environment variables for a PYTHONSTARTUP script in a temporary directory."""
with os_helper.temp_dir() as tmpdir:
filename = os.path.join(tmpdir, "pythonstartup.py")
with open(filename, "w") as f:
f.write('\n'.join(code.splitlines()))
yield {"PYTHONSTARTUP": filename, "PYTHON_HISTORY": os.path.join(tmpdir, histfile)}


def run_on_interactive_mode(source):
"""Spawn a new Python interpreter, pass the given
input source code from the stdin and return the
Expand Down Expand Up @@ -197,68 +211,6 @@ def foo(x):
]
self.assertEqual(traceback_lines, expected_lines)

def test_pythonstartup_error_reporting(self):
# errors based on https://github.com/python/cpython/issues/137576

def make_repl(env):
return subprocess.Popen(
[os.path.join(os.path.dirname(sys.executable), '<stdin>'), "-i"],
executable=sys.executable,
text=True,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
env=env,
)

# case 1: error in user input, but PYTHONSTARTUP is fine
with os_helper.temp_dir() as tmpdir:
script = os.path.join(tmpdir, "pythonstartup.py")
with open(script, "w") as f:
f.write("print('from pythonstartup')" + os.linesep)

env = os.environ.copy()
env['PYTHONSTARTUP'] = script
env["PYTHON_HISTORY"] = os.path.join(tmpdir, ".pythonhist")
p = make_repl(env)
p.stdin.write("1/0")
output = kill_python(p)
expected = dedent("""
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
1/0
~^~
ZeroDivisionError: division by zero
""")
self.assertIn("from pythonstartup", output)
self.assertIn(expected, output)

# case 2: error in PYTHONSTARTUP triggered by user input
with os_helper.temp_dir() as tmpdir:
script = os.path.join(tmpdir, "pythonstartup.py")
with open(script, "w") as f:
f.write("def foo():\n 1/0\n")

env = os.environ.copy()
env['PYTHONSTARTUP'] = script
env["PYTHON_HISTORY"] = os.path.join(tmpdir, ".pythonhist")
p = make_repl(env)
p.stdin.write('foo()')
output = kill_python(p)
expected = dedent("""
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
foo()
~~~^^
File "%s", line 2, in foo
1/0
~^~
ZeroDivisionError: division by zero
""") % script
self.assertIn(expected, output)



def test_runsource_show_syntax_error_location(self):
user_input = dedent("""def f(x, x): ...
""")
Expand Down Expand Up @@ -292,23 +244,46 @@ def bar(x):
expected = "(30, None, [\'def foo(x):\\n\', \' return x + 1\\n\', \'\\n\'], \'<stdin>\')"
self.assertIn(expected, output, expected)

def test_asyncio_repl_reaches_python_startup_script(self):
with os_helper.temp_dir() as tmpdir:
script = os.path.join(tmpdir, "pythonstartup.py")
with open(script, "w") as f:
f.write("print('pythonstartup done!')" + os.linesep)
f.write("exit(0)" + os.linesep)

env = os.environ.copy()
env["PYTHON_HISTORY"] = os.path.join(tmpdir, ".asyncio_history")
env["PYTHONSTARTUP"] = script
subprocess.check_call(
[sys.executable, "-m", "asyncio"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env=env,
timeout=SHORT_TIMEOUT,
)
Comment on lines -295 to -311
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Covered by TestPythonStartup now, so not needed here.

def test_pythonstartup_success(self):
# errors based on https://github.com/python/cpython/issues/137576
# case 1: error in user input, but PYTHONSTARTUP is fine
startup_code = "print('notice from pythonstartup')"
startup_env = self.enterContext(new_startup_env(code=startup_code))
# -q to suppress noise
p = spawn_repl("-q", env=os.environ | startup_env, isolated=False)
p.stdin.write("1/0")
output_lines = kill_python(p).splitlines()
self.assertEqual(output_lines[0], 'notice from pythonstartup')

traceback_lines = output_lines[2:-1]
expected_lines = [
'Traceback (most recent call last):',
' File "<stdin>", line 1, in <module>',
' 1/0',
' ~^~',
'ZeroDivisionError: division by zero',
]
self.assertEqual(traceback_lines, expected_lines)

def test_pythonstartup_failure(self):
# case 2: error in PYTHONSTARTUP triggered by user input
startup_code = "def foo():\n 1/0\n"
startup_env = self.enterContext(new_startup_env(code=startup_code))
# -q to suppress noise
p = spawn_repl("-q", env=os.environ | startup_env, isolated=False)
p.stdin.write("foo()")
traceback_lines = kill_python(p).splitlines()[1:-1]
expected_lines = [
'Traceback (most recent call last):',
' File "<stdin>", line 1, in <module>',
' foo()',
' ~~~^^',
f' File "{startup_env['PYTHONSTARTUP']}", line 2, in foo',
' 1/0',
' ~^~',
'ZeroDivisionError: division by zero',
]
self.assertEqual(traceback_lines, expected_lines)

@unittest.skipUnless(pty, "requires pty")
def test_asyncio_repl_is_ok(self):
Expand Down Expand Up @@ -365,6 +340,7 @@ def f():
self.assertEqual(traceback_lines, expected_lines)


@support.force_not_colorized_test_class
Copy link
Contributor Author

@johnslavik johnslavik Nov 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure this is needed, but I think it's OK to keep it as is.

class TestAsyncioREPL(unittest.TestCase):
def test_multiple_statements_fail_early(self):
user_input = "1 / 0; print(f'afterwards: {1+1}')"
Expand Down Expand Up @@ -409,6 +385,55 @@ def test_toplevel_contextvars_async(self):
expected = "toplevel contextvar test: ok"
self.assertIn(expected, output, expected)

def test_pythonstartup_success(self):
startup_code = "import sys\nprint('notice from pythonstartup in asyncio repl', file=sys.stderr)"
startup_env = self.enterContext(new_startup_env(code=startup_code, histfile=".asyncio_history"))
p = spawn_asyncio_repl(env=os.environ | startup_env, stderr=subprocess.PIPE, isolated=False)
p.stdin.write("1/0")
kill_python(p)
output_lines = p.stderr.read().splitlines()
p.stderr.close()

self.assertEqual(output_lines[3], 'notice from pythonstartup in asyncio repl')

tb_start_lines = output_lines[5:6]
tb_final_lines = output_lines[13:]
expected_lines = [
'Traceback (most recent call last):',
' File "<stdin>", line 1, in <module>',
' 1/0',
' ~^~',
'ZeroDivisionError: division by zero',
'',
'exiting asyncio REPL...',
]
self.assertEqual(tb_start_lines + tb_final_lines, expected_lines)

def test_pythonstartup_failure(self):
startup_code = "def foo():\n 1/0\n"
startup_env = self.enterContext(new_startup_env(code=startup_code, histfile=".asyncio_history"))
p = spawn_asyncio_repl(env=os.environ | startup_env, stderr=subprocess.PIPE, isolated=False)
p.stdin.write("foo()")
kill_python(p)
output_lines = p.stderr.read().splitlines()
p.stderr.close()

tb_start_lines = output_lines[4:5]
tb_final_lines = output_lines[12:]
expected_lines = [
'Traceback (most recent call last):',
' File "<stdin>", line 1, in <module>',
' foo()',
' ~~~^^',
f' File "{startup_env['PYTHONSTARTUP']}", line 2, in foo',
' 1/0',
' ~^~',
'ZeroDivisionError: division by zero',
'',
'exiting asyncio REPL...',
]
self.assertEqual(tb_start_lines + tb_final_lines, expected_lines)


if __name__ == "__main__":
unittest.main()
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
The asyncio REPL now properly handles exceptions in ``PYTHONSTARTUP``
scripts. Patch by Bartosz Sławecki in :gh:`140287`.
Loading