From cefa3f316126b03b54ebff7cdc96f96ff491fee4 Mon Sep 17 00:00:00 2001 From: Michael Simacek Date: Fri, 4 Sep 2026 16:05:10 +0200 Subject: [PATCH 01/11] Implement sys._xoptions --- .../graal/python/shell/GraalPythonMain.java | 7 +++++ .../src/tests/test_cmd_line.py | 10 +++++++ .../builtins/modules/SysModuleBuiltins.java | 16 ++++++++++ .../graal/python/runtime/PythonOptions.java | 29 +++++++++++++++++++ 4 files changed, 62 insertions(+) diff --git a/graalpython/com.oracle.graal.python.shell/src/com/oracle/graal/python/shell/GraalPythonMain.java b/graalpython/com.oracle.graal.python.shell/src/com/oracle/graal/python/shell/GraalPythonMain.java index 22cbfe7d7d..446c081973 100644 --- a/graalpython/com.oracle.graal.python.shell/src/com/oracle/graal/python/shell/GraalPythonMain.java +++ b/graalpython/com.oracle.graal.python.shell/src/com/oracle/graal/python/shell/GraalPythonMain.java @@ -162,6 +162,7 @@ private static void appendDecodedChars(StringBuilder builder, CharBuffer out) { private int repeatedRuns = 1; private boolean snaptshotStartup = false; private boolean warnDefaultEncoding = false; + private final List xOptions = new ArrayList<>(); private int intMaxStrDigits = -1; private int cpuCount = -1; private boolean cpuCountXOptionSet = false; @@ -462,6 +463,7 @@ protected List preprocessArguments(List givenArgs, Map OrigArgv = new OptionKey<>(T_EMPTY_STRING, TS_OPTION_TYPE); + @Option(category = OptionCategory.INTERNAL, help = "The list of -X options passed to the Python executable.") // + public static final OptionKey XOptions = new OptionKey<>(T_EMPTY_STRING, TS_OPTION_TYPE); + @EngineOption @Option(category = OptionCategory.INTERNAL, usageSyntax = "true|false", help = "If true, uses native storage strategy for primitive types") // public static final OptionKey UseNativePrimitiveStorageStrategy = new OptionKey<>(false); @@ -620,6 +623,32 @@ public static TruffleString[] getOrigArgv(PythonContext context) { TruffleString.SubstringNode.getUncached(), TruffleString.EqualNode.getUncached()); } + @TruffleBoundary + public static TruffleString[] getXOptions(PythonContext context) { + String serialized = context.getOption(XOptions).toJavaStringUncached(); + List options = new ArrayList<>(); + int offset = 0; + while (offset < serialized.length()) { + int colon = serialized.indexOf(':', offset); + if (colon < 0) { + throw new IllegalArgumentException("Invalid serialized XOptions"); + } + int length; + try { + length = Integer.parseInt(serialized.substring(offset, colon)); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("Invalid serialized XOptions", e); + } + offset = colon + 1; + if (length < 0 || length > serialized.length() - offset) { + throw new IllegalArgumentException("Invalid serialized XOptions"); + } + options.add(PythonUtils.toTruffleStringUncached(serialized.substring(offset, offset + length))); + offset += length; + } + return options.toArray(PythonUtils.EMPTY_TRUFFLESTRING_ARRAY); + } + /** * Marks an @Option as being per-engine rather than per-context */ From 93cb4ee0f144d25f1dd65046e4fc4f4836f62d01 Mon Sep 17 00:00:00 2001 From: Michael Simacek Date: Fri, 4 Sep 2026 16:33:15 +0200 Subject: [PATCH 02/11] Add dummy os._is_inputhook_installed --- .../builtins/modules/PosixModuleBuiltins.java | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/graalpython/com.oracle.graal.python/src/com/oracle/graal/python/builtins/modules/PosixModuleBuiltins.java b/graalpython/com.oracle.graal.python/src/com/oracle/graal/python/builtins/modules/PosixModuleBuiltins.java index 8b15d52df6..92cc28d5a3 100644 --- a/graalpython/com.oracle.graal.python/src/com/oracle/graal/python/builtins/modules/PosixModuleBuiltins.java +++ b/graalpython/com.oracle.graal.python/src/com/oracle/graal/python/builtins/modules/PosixModuleBuiltins.java @@ -611,6 +611,27 @@ static long getPid(@Bind PythonContext context) { } } + @Builtin(name = "_is_inputhook_installed", minNumOfPositionalArgs = 0) + @GenerateNodeFactory + abstract static class IsInputHookInstalledNode extends PythonBuiltinNode { + + @Specialization + static boolean isInputHookInstalled() { + // TOOD we don't support PyOS_InputHook at the moment + return false; + } + } + + @Builtin(name = "_inputhook", minNumOfPositionalArgs = 0) + @GenerateNodeFactory + abstract static class InputHookNode extends PythonBuiltinNode { + + @Specialization + static int inputHook() { + return 0; + } + } + @Builtin(name = "getuid", minNumOfPositionalArgs = 0) @GenerateNodeFactory public abstract static class GetUidNode extends PythonBuiltinNode { From 3f763a593a4defb9732c7c979149b5c84550b25f Mon Sep 17 00:00:00 2001 From: Michael Simacek Date: Thu, 17 Sep 2026 13:56:13 +0200 Subject: [PATCH 03/11] Propagate DONT_IMPLY_DEDENT parser flag --- .../python/pegparser/AbstractParser.java | 3 ++- .../python/test/compiler/UnparserTests.java | 6 +++++- .../src/tests/test_parser.py | 14 +++++++++++++ .../oracle/graal/python/PythonLanguage.java | 18 ++++++++-------- .../graal/python/builtins/Python3Core.java | 2 +- .../builtins/modules/BuiltinFunctions.java | 21 ++++++++++++------- .../bytecode_dsl/BytecodeDSLCompiler.java | 11 ---------- 7 files changed, 44 insertions(+), 31 deletions(-) diff --git a/graalpython/com.oracle.graal.python.pegparser/src/com/oracle/graal/python/pegparser/AbstractParser.java b/graalpython/com.oracle.graal.python.pegparser/src/com/oracle/graal/python/pegparser/AbstractParser.java index 9e422d821e..3f2f7c4b30 100644 --- a/graalpython/com.oracle.graal.python.pegparser/src/com/oracle/graal/python/pegparser/AbstractParser.java +++ b/graalpython/com.oracle.graal.python.pegparser/src/com/oracle/graal/python/pegparser/AbstractParser.java @@ -113,6 +113,7 @@ public enum Flags { INTERACTIVE_TERMINAL, ASYNC_HACKS, ALLOW_INCOMPLETE_INPUT, + DONT_IMPLY_DEDENT, } private static final String BARRY_AS_BDFL = "with Barry as BDFL, use '<>' instead of '!='"; @@ -358,7 +359,7 @@ public Token getAndInitializeToken() { if (startRule == InputType.SINGLE && token.type == Token.Kind.ENDMARKER && parsingStarted) { token.type = Token.Kind.NEWLINE; parsingStarted = false; - if (tokenizer.getCurrentIndentIndex() > 0) { + if (tokenizer.getCurrentIndentIndex() > 0 && !flags.contains(Flags.DONT_IMPLY_DEDENT)) { tokenizer.setPendingIndents(-tokenizer.getCurrentIndentIndex()); tokenizer.setCurrentIndentIndex(0); } diff --git a/graalpython/com.oracle.graal.python.test/src/com/oracle/graal/python/test/compiler/UnparserTests.java b/graalpython/com.oracle.graal.python.test/src/com/oracle/graal/python/test/compiler/UnparserTests.java index fc88665f0a..c9d1628f8c 100644 --- a/graalpython/com.oracle.graal.python.test/src/com/oracle/graal/python/test/compiler/UnparserTests.java +++ b/graalpython/com.oracle.graal.python.test/src/com/oracle/graal/python/test/compiler/UnparserTests.java @@ -42,10 +42,14 @@ import static org.junit.Assert.assertEquals; +import java.util.EnumSet; + import org.junit.Test; +import com.oracle.graal.python.PythonLanguage; import com.oracle.graal.python.compiler.Unparser; import com.oracle.graal.python.compiler.bytecode_dsl.BytecodeDSLCompiler; +import com.oracle.graal.python.pegparser.AbstractParser; import com.oracle.graal.python.pegparser.InputType; import com.oracle.graal.python.pegparser.Parser; import com.oracle.graal.python.pegparser.ParserCallbacks; @@ -93,7 +97,7 @@ public void testUnparseConstant() { private static void checkRoundTrip(String source) { ParserCallbacks parserCallbacks = new TestParserCallbacksImpl(); - Parser parser = BytecodeDSLCompiler.createParser(source, parserCallbacks, InputType.EVAL, false, false); + Parser parser = BytecodeDSLCompiler.createParser(source, parserCallbacks, InputType.EVAL, EnumSet.noneOf(AbstractParser.Flags.class), PythonLanguage.MINOR); ModTy.Expression result = (ModTy.Expression) parser.parse(); assertEquals(source, unparse(result.body)); } diff --git a/graalpython/com.oracle.graal.python.test/src/tests/test_parser.py b/graalpython/com.oracle.graal.python.test/src/tests/test_parser.py index 96d1f70b19..6617c7d47f 100644 --- a/graalpython/com.oracle.graal.python.test/src/tests/test_parser.py +++ b/graalpython/com.oracle.graal.python.test/src/tests/test_parser.py @@ -82,6 +82,20 @@ def test_compile_unrecognised_flags_message(): assert False, "ValueError was not raised" +def test_dont_imply_dedent(): + flags = 0x4000 | 0x200 # PyCF_ALLOW_INCOMPLETE_INPUT | PyCF_DONT_IMPLY_DEDENT + source = "def foo():\n return 42" + + try: + compile(source, "", "single", flags) + except _IncompleteInputError: + pass + else: + assert False, "_IncompleteInputError was not raised" + + assert compile(source + "\n", "", "single", flags) + + def test_lambdas_as_function_default_argument_values(): globs = {} exec(""" diff --git a/graalpython/com.oracle.graal.python/src/com/oracle/graal/python/PythonLanguage.java b/graalpython/com.oracle.graal.python/src/com/oracle/graal/python/PythonLanguage.java index 1f627fd87a..f8cb761b45 100644 --- a/graalpython/com.oracle.graal.python/src/com/oracle/graal/python/PythonLanguage.java +++ b/graalpython/com.oracle.graal.python/src/com/oracle/graal/python/PythonLanguage.java @@ -91,6 +91,7 @@ import com.oracle.graal.python.nodes.frame.GetFrameLocalsNode; import com.oracle.graal.python.nodes.frame.MaterializeFrameNode; import com.oracle.graal.python.nodes.function.BuiltinFunctionRootNode; +import com.oracle.graal.python.pegparser.AbstractParser; import com.oracle.graal.python.pegparser.FutureFeature; import com.oracle.graal.python.pegparser.InputType; import com.oracle.graal.python.pegparser.NodeFactory; @@ -563,14 +564,14 @@ protected CallTarget parse(ParsingRequest request) { EnumSet futureFeatures; boolean topLevel; List argumentNames; - boolean interactiveTerminal; + EnumSet parserFlags = EnumSet.noneOf(AbstractParser.Flags.class); if (source.isInteractive()) { inputType = InputType.SINGLE; optimize = 0; futureFeatures = EnumSet.noneOf(FutureFeature.class); topLevel = true; argumentNames = request.getArgumentNames(); - interactiveTerminal = true; + parserFlags.add(AbstractParser.Flags.INTERACTIVE_TERMINAL); } else { var sourceOptions = source.getOptions(this); String kind = sourceOptions.get(PythonSourceOptions.Kind); @@ -585,9 +586,8 @@ protected CallTarget parse(ParsingRequest request) { int flags = sourceOptions.get(PythonSourceOptions.Flags); futureFeatures = FutureFeature.fromFlags(flags); argumentNames = request.getArgumentNames().isEmpty() ? null : request.getArgumentNames(); - interactiveTerminal = false; } - return parse(context, source, inputType, topLevel, optimize, interactiveTerminal, argumentNames, futureFeatures); + return parse(context, source, inputType, topLevel, optimize, parserFlags, argumentNames, futureFeatures); } public RootCallTarget callTargetFromBytecode(Source source, BytecodeDSLCodeUnit code) { @@ -599,19 +599,19 @@ public RootCallTarget callTargetFromBytecode(Source source, BytecodeDSLCodeUnit return PythonUtils.getOrCreateCallTarget(rootNode); } - public RootCallTarget parse(PythonContext context, Source source, InputType type, boolean topLevel, int optimize, boolean interactiveTerminal, List argumentNames, + public RootCallTarget parse(PythonContext context, Source source, InputType type, boolean topLevel, int optimize, List argumentNames, EnumSet futureFeatures) { - return parse(context, source, type, topLevel, optimize, interactiveTerminal, false, argumentNames, futureFeatures); + return parse(context, source, type, topLevel, optimize, EnumSet.noneOf(AbstractParser.Flags.class), argumentNames, futureFeatures); } - public RootCallTarget parse(PythonContext context, Source source, InputType type, boolean topLevel, int optimize, boolean interactiveTerminal, boolean allowIncompleteInput, + public RootCallTarget parse(PythonContext context, Source source, InputType type, boolean topLevel, int optimize, EnumSet parserFlags, List argumentNames, EnumSet futureFeatures) { ParserCallbacksImpl errorCb = new ParserCallbacksImpl(source, PythonOptions.isPExceptionWithJavaStacktrace(this)); try { if (context.getEnv().getOptions().get(PythonOptions.ParserLogFiles)) { LOGGER.log(Level.FINE, () -> "parse '" + source.getName() + "'"); } - Parser parser = BytecodeDSLCompiler.createParser(source.getCharacters().toString(), errorCb, type, interactiveTerminal, allowIncompleteInput); + Parser parser = BytecodeDSLCompiler.createParser(source.getCharacters().toString(), errorCb, type, parserFlags, PythonLanguage.MINOR); ModTy mod = (ModTy) parser.parse(); assert mod != null; return compileModule(context, mod, source, topLevel, optimize, argumentNames, errorCb, futureFeatures); @@ -743,7 +743,7 @@ public static RootNode unwrapRootNode(RootNode rootNode) { @Override public ExecutableNode parse(InlineParsingRequest request) { PythonContext context = PythonContext.get(null); - RootCallTarget callTarget = parse(context, request.getSource(), InputType.EVAL, false, 0, false, null, EnumSet.noneOf(FutureFeature.class)); + RootCallTarget callTarget = parse(context, request.getSource(), InputType.EVAL, false, 0, null, EnumSet.noneOf(FutureFeature.class)); return new ExecutableNode(this) { @Child private GilNode gilNode = GilNode.create(); @Child private CallDispatchers.SimpleIndirectInvokeNode invokeNode = CallDispatchers.SimpleIndirectInvokeNode.create(); diff --git a/graalpython/com.oracle.graal.python/src/com/oracle/graal/python/builtins/Python3Core.java b/graalpython/com.oracle.graal.python/src/com/oracle/graal/python/builtins/Python3Core.java index 7b89e82162..b9957f1b7f 100644 --- a/graalpython/com.oracle.graal.python/src/com/oracle/graal/python/builtins/Python3Core.java +++ b/graalpython/com.oracle.graal.python/src/com/oracle/graal/python/builtins/Python3Core.java @@ -1381,7 +1381,7 @@ private void loadFile(TruffleString s, TruffleString sourceName, TruffleString p LOGGER.log(Level.FINE, () -> "import '" + s + "'"); Source source = getInternalSource(sourceName, prefix); TruffleString sourceText = toTruffleStringUncached(source.getCharacters().toString()); - Supplier createTarget = () -> getLanguage().parse(getContext(), source, InputType.FILE, false, 0, false, null, EnumSet.noneOf(FutureFeature.class)); + Supplier createTarget = () -> getLanguage().parse(getContext(), source, InputType.FILE, false, 0, null, EnumSet.noneOf(FutureFeature.class)); RootCallTarget callTarget = (RootCallTarget) getLanguage().cacheSourceTarget(sourceName, sourceText, InputType.FILE, 0, 0, createTarget); PCode code = PFactory.createCode(language, callTarget); CallDispatchers.SimpleIndirectInvokeNode.executeUncached(callTarget, PArguments.withGlobals(code, mod)); diff --git a/graalpython/com.oracle.graal.python/src/com/oracle/graal/python/builtins/modules/BuiltinFunctions.java b/graalpython/com.oracle.graal.python/src/com/oracle/graal/python/builtins/modules/BuiltinFunctions.java index f49816a17d..684e2fb625 100644 --- a/graalpython/com.oracle.graal.python/src/com/oracle/graal/python/builtins/modules/BuiltinFunctions.java +++ b/graalpython/com.oracle.graal.python/src/com/oracle/graal/python/builtins/modules/BuiltinFunctions.java @@ -1016,6 +1016,16 @@ Object compile(TruffleString expression, TruffleString filename, TruffleString m TruffleString code = expression; PythonContext context = getContext(); + EnumSet parserFlags = EnumSet.noneOf(AbstractParser.Flags.class); + if ((flags & PyCF_TYPE_COMMENTS) != 0) { + parserFlags.add(AbstractParser.Flags.TYPE_COMMENTS); + } + if ((flags & PyCF_ALLOW_INCOMPLETE_INPUT) != 0) { + parserFlags.add(AbstractParser.Flags.ALLOW_INCOMPLETE_INPUT); + } + if ((flags & PyCF_DONT_IMPLY_DEDENT) != 0) { + parserFlags.add(AbstractParser.Flags.DONT_IMPLY_DEDENT); + } if (lstrip && !code.isEmpty()) { int c = code.codePointAtIndexUncached(0, TS_ENCODING); if (c == ' ' || c == '\t') { @@ -1026,20 +1036,16 @@ Object compile(TruffleString expression, TruffleString filename, TruffleString m Source source = PythonLanguage.newSource(context, code, filename, mayBeFromFile, InputType.FILE, optimize, flags); ParserCallbacksImpl parserCb = new ParserCallbacksImpl(source, PythonOptions.isPExceptionWithJavaStacktrace(getLanguage())); - EnumSet compilerFlags = EnumSet.noneOf(AbstractParser.Flags.class); - if ((flags & PyCF_TYPE_COMMENTS) != 0) { - compilerFlags.add(AbstractParser.Flags.TYPE_COMMENTS); - } if (featureVersion < 0) { featureVersion = PythonLanguage.MINOR; } if (featureVersion < 7) { - compilerFlags.add(AbstractParser.Flags.ASYNC_HACKS); + parserFlags.add(AbstractParser.Flags.ASYNC_HACKS); } if (context.getEnv().getOptions().get(PythonOptions.ParserLogFiles)) { PythonLanguage.LOGGER.log(Level.FINE, () -> "parse '" + source.getName() + "'"); } - Parser parser = BytecodeDSLCompiler.createParser(code.toJavaStringUncached(), parserCb, type, compilerFlags, featureVersion); + Parser parser = BytecodeDSLCompiler.createParser(code.toJavaStringUncached(), parserCb, type, parserFlags, featureVersion); ModTy mod = (ModTy) parser.parse(); parserCb.triggerDeprecationWarnings(); int astOptimizationLevel = (flags & PyCF_OPTIMIZED_AST) == PyCF_OPTIMIZED_AST ? resolveOptimizeLevel(context, optimize) : -2; @@ -1053,8 +1059,7 @@ Object compile(TruffleString expression, TruffleString filename, TruffleString m if (type != InputType.SINGLE) { ct = context.getEnv().parsePublic(source); } else { - boolean allowIncomplete = (flags & PyCF_ALLOW_INCOMPLETE_INPUT) != 0; - ct = context.getLanguage().parse(context, source, InputType.SINGLE, false, optimize, false, allowIncomplete, null, FutureFeature.fromFlags(flags)); + ct = context.getLanguage().parse(context, source, InputType.SINGLE, false, optimize, parserFlags, null, FutureFeature.fromFlags(flags)); } return wrapRootCallTarget((RootCallTarget) ct, filename); } diff --git a/graalpython/com.oracle.graal.python/src/com/oracle/graal/python/compiler/bytecode_dsl/BytecodeDSLCompiler.java b/graalpython/com.oracle.graal.python/src/com/oracle/graal/python/compiler/bytecode_dsl/BytecodeDSLCompiler.java index 7b80fd9c5f..36959a192c 100644 --- a/graalpython/com.oracle.graal.python/src/com/oracle/graal/python/compiler/bytecode_dsl/BytecodeDSLCompiler.java +++ b/graalpython/com.oracle.graal.python/src/com/oracle/graal/python/compiler/bytecode_dsl/BytecodeDSLCompiler.java @@ -176,17 +176,6 @@ private static Object findDocstring(StmtTy[] body) { return null; } - public static Parser createParser(String src, ParserCallbacks errorCb, InputType inputType, boolean interactiveTerminal, boolean allowIncompleteInput) { - EnumSet flags = EnumSet.noneOf(AbstractParser.Flags.class); - if (interactiveTerminal) { - flags.add(AbstractParser.Flags.INTERACTIVE_TERMINAL); - } - if (allowIncompleteInput) { - flags.add(AbstractParser.Flags.ALLOW_INCOMPLETE_INPUT); - } - return createParser(src, errorCb, inputType, flags, PythonLanguage.MINOR); - } - public static Parser createParser(String src, ParserCallbacks errorCb, InputType inputType, EnumSet flags, int featureVersion) { return new Parser(src, errorCb, inputType, flags, featureVersion); } From 3a63c25f675d767aef91cf7bbadf20b106229881 Mon Sep 17 00:00:00 2001 From: Michael Simacek Date: Fri, 18 Sep 2026 09:53:21 +0200 Subject: [PATCH 04/11] Implement PyREPL support --- .../graal/python/shell/GraalPythonMain.java | 71 +++++++++-- .../src/tests/test_repl.py | 119 +++++++++++++++--- 2 files changed, 159 insertions(+), 31 deletions(-) diff --git a/graalpython/com.oracle.graal.python.shell/src/com/oracle/graal/python/shell/GraalPythonMain.java b/graalpython/com.oracle.graal.python.shell/src/com/oracle/graal/python/shell/GraalPythonMain.java index 446c081973..067a652712 100644 --- a/graalpython/com.oracle.graal.python.shell/src/com/oracle/graal/python/shell/GraalPythonMain.java +++ b/graalpython/com.oracle.graal.python.shell/src/com/oracle/graal/python/shell/GraalPythonMain.java @@ -897,6 +897,7 @@ protected void launch(Builder contextBuilder) { contextBuilder.arguments(getLanguageId(), programArgs.toArray(new String[programArgs.size()])); contextBuilder.in(consoleHandler.createInputStream()); boolean tty = isTTY(); + boolean basicReplRequested = !tty || (!ignoreEnv && getBoolEnv("PYTHON_BASIC_REPL")); contextBuilder.option("python.TerminalIsInteractive", Boolean.toString(tty)); contextBuilder.option("python.CheckHashPycsMode", checkHashPycsMode); @@ -937,7 +938,7 @@ protected void launch(Builder contextBuilder) { } sysModule.putMember("_console_handler", consoleHandler); - if (tty && !isolateFlag && (inspectFlag || (commandString == null && inputFile == null))) { + if (basicReplRequested && tty && !isolateFlag && (inspectFlag || (commandString == null && inputFile == null))) { evalInternal(context, "import readline, rlcompleter"); } @@ -956,8 +957,14 @@ protected void launch(Builder contextBuilder) { } } if ((tty && commandString == null && inputFile == null) || inspectFlag) { + boolean inspecting = commandString != null || inputFile != null; inspectFlag = false; - rc = readEvalPrint(context, consoleHandler, sysModule); + runInteractiveHook(sysModule); + if (!basicReplRequested && canUsePyRepl(context)) { + rc = readEvalPrintPyRepl(context, inspecting); + } else { + rc = readEvalPrint(context, consoleHandler, sysModule); + } } } catch (RuntimeException e) { if (e.getMessage() != null && e.getMessage().contains("did not complete all polyglot threads")) { @@ -1350,6 +1357,55 @@ private static ConsoleHandler createConsoleHandler(InputStream inStream, OutputS } } + private boolean canUsePyRepl(Context context) { + try { + return evalInternal(context, """ + from _pyrepl.main import CAN_USE_PYREPL, FAIL_REASON + if not CAN_USE_PYREPL and FAIL_REASON: + import sys + from _pyrepl.trace import trace + trace(FAIL_REASON) + print(FAIL_REASON, file=sys.stderr) + CAN_USE_PYREPL""").asBoolean(); + } catch (PolyglotException e) { + return false; + } + } + + private int readEvalPrintPyRepl(Context context, boolean inspecting) { + String source; + if (inspecting) { + source = """ + from _pyrepl.main import interactive_console + interactive_console()"""; + } else { + source = """ + import runpy + runpy._run_module_as_main('_pyrepl')"""; + } + try { + evalInternal(context, source); + return 0; + } catch (PolyglotException e) { + if (e.isExit()) { + return e.getExitStatus(); + } + throw e; + } + } + + private static void runInteractiveHook(Value sysModule) { + Value hook = null; + try { + hook = sysModule.getMember("__interactivehook__"); + } catch (PolyglotException e) { + // ignore + } + if (hook != null) { + hook.execute(); + } + } + /** * The read-eval-print loop, which can take input from a console, command line expression or a * file. There are two ways the repl can terminate: @@ -1362,15 +1418,6 @@ private static ConsoleHandler createConsoleHandler(InputStream inStream, OutputS */ private int readEvalPrint(Context context, ConsoleHandler consoleHandler, Value sysModule) { try { - Value hook = null; - try { - hook = sysModule.getMember("__interactivehook__"); - } catch (PolyglotException e) { - // ignore - } - if (hook != null) { - hook.execute(); - } while (true) { // processing inputs boolean doEcho = doEcho(context); String ps1 = doEcho ? sysModule.getMember("ps1").asString() : null; @@ -1505,7 +1552,7 @@ private static boolean canSkipFromEval(String input) { } private Value evalInternal(Context context, String code) { - return context.eval(Source.newBuilder(getLanguageId(), code, "").internal(true).buildLiteral()); + return context.eval(Source.newBuilder(getLanguageId(), code, "").internal(true).option("python.NewGlobals", "true").buildLiteral()); } /** diff --git a/graalpython/com.oracle.graal.python.test/src/tests/test_repl.py b/graalpython/com.oracle.graal.python.test/src/tests/test_repl.py index 60ff111295..de5e625aed 100644 --- a/graalpython/com.oracle.graal.python.test/src/tests/test_repl.py +++ b/graalpython/com.oracle.graal.python.test/src/tests/test_repl.py @@ -61,9 +61,9 @@ def decorated(*args, **kwargs): try: fn(*args, **kwargs) return - except Exception: + except Exception as e: retries -= 1 - print("Retrying test") + print(f"Retrying test after {e!r}") continue fn(*args, **kwargs) @@ -75,16 +75,41 @@ class ExpectedInOutItem: prompt: str input: str output: str + keys: str | None = None + autoindent: int | None = None + + + ANSI_ESCAPE = re.compile(r'\x1b(?:[=>]|\[[0-?]*[ -/]*[@-~])') + + + def normalize_terminal_output(output): + output = ANSI_ESCAPE.sub('', output) + output = re.sub(r'\r+\n', '\n', output).replace('\r', '') + return re.sub(r'', '', output) @autoretry - def validate_repl(stdin, python_args=(), ignore_preamble=True, extra_input_and_output=()): + def validate_repl(stdin, python_args=(), ignore_preamble=True, extra_input_and_output=(), + repls=('basic', 'pyrepl'), extra_env=None): + for repl in repls: + try: + _validate_repl(stdin, repl, python_args, ignore_preamble, extra_input_and_output, + extra_env) + except Exception as e: + e.add_note(f'while testing the {repl} REPL') + raise + + + def _validate_repl(stdin, repl, python_args, ignore_preamble, extra_input_and_output, extra_env): env = { 'TERM': 'ansi', 'NO_COLOR': '1', 'PYTHONIOENCODING': 'utf-8', - 'PYTHON_BASIC_REPL': '1', } + if repl == 'basic': + env['PYTHON_BASIC_REPL'] = '1' + if extra_env: + env.update(extra_env) pty_parent, pty_child = os.openpty() try: import termios @@ -96,6 +121,7 @@ def validate_repl(stdin, python_args=(), ignore_preamble=True, extra_input_and_o stdout=pty_child, stderr=pty_child, ) + raw_out = '' out = '' input_and_output: list[ExpectedInOutItem] = [] expected_preamble = '' @@ -113,37 +139,56 @@ def validate_repl(stdin, python_args=(), ignore_preamble=True, extra_input_and_o while True: rlist, _, _ = select.select([pty_parent], [], [], 60) assert pty_parent in rlist, f"Timed out waiting for REPL output. Output: {whole_out}{out}" - out += os.read(pty_parent, 1024).decode('utf-8') - # Strip terminal control sequences, including the mode 2027 and device-attributes - # probes emitted by JLine 4 while detecting grapheme-cluster support. - out = re.sub(r'\x1b\[(?:\?2004[hl]|\?2027\$p|c|\d+[A-G])', '', out) - out = re.sub(r'\r+\n', '\n', out) - if out.endswith(('\n... ', '>>> ')): + raw_out += os.read(pty_parent, 1024).decode('utf-8') + out = normalize_terminal_output(raw_out) + prompt_match = re.search(r'(>>> |\.\.\. )( *)$', out) + if prompt_match and select.select([pty_parent], [], [], 0.05)[0]: + # Both line editors can emit cursor-control sequences after the visible prompt. + # Wait for the terminal update to settle before sending the next input. + continue + if prompt_match and (index < 0 or prompt_match.start() > 0): prompt = out[:3] - actual_end = len(out) - 4 + autoindent = len(prompt_match.group(2)) + actual_end = prompt_match.start() if actual_end > 0 and out[actual_end - 1] == '\n': actual_end -= 1 actual = out[:actual_end] if index >= 0: current = input_and_output[index] - assert prompt == current.prompt, f"Actual prompt: {prompt}\nExpected prompt: {current.prompt}" + assert prompt == current.prompt, f"Actual prompt: {prompt}\nExpected prompt: {current.prompt}\nOutput: {whole_out}{out}" expected = f'{current.prompt} {current.input}{current.output}' else: expected = expected_preamble if index >= 0 or not ignore_preamble: assert actual == expected, f'Actual:\n{actual!r}\nExpected:\n{expected!r}\nWhole output:\n{whole_out}{out}' index += 1 - whole_out += out[:-4] - out = out[-4:] + if index < len(input_and_output): + expected_autoindent = input_and_output[index].autoindent + if expected_autoindent is not None: + assert autoindent == expected_autoindent, \ + f'Actual autoindent: {autoindent}\nExpected autoindent: {expected_autoindent}' + whole_out += out[:actual_end] + raw_out = prompt_match.group(1) if index >= len(input_and_output): os.write(pty_parent, b'\x04') # Ctrl-D / EOF proc.wait(timeout=60) out = os.read(pty_parent, 1024).decode('utf-8') - out = re.sub(r'\x1b\[(?:\?2004[hl]|\?2027\$p|c)', '', out) + out = normalize_terminal_output(out) assert not out.strip(), f"Garbage after EOF:\n{out!r}" return else: - next_in = input_and_output[index].input + current = input_and_output[index] + next_in = current.keys if current.keys is not None else current.input + if repl == 'pyrepl' and autoindent: + expected_input = current.input + indentation = expected_input[:len(expected_input) - len(expected_input.lstrip())] + raw_out += indentation + if next_in.strip() and current.keys is None: + next_in = next_in[len(indentation):] + elif not next_in.strip(): + # Clear PyREPL's automatic indentation so an empty line terminates + # the compound statement. + next_in = '\x15' os.write(pty_parent, next_in.encode('utf-8') + b'\r') finally: os.close(pty_child) @@ -162,6 +207,17 @@ def test_basic_repl(): """)) + def test_repl_selection(): + validate_repl(dedent("""\ + >>> hasattr(__import__('__main__'), '__file__') + False + """), repls=('basic',)) + validate_repl(dedent("""\ + >>> __import__('__main__').__file__.endswith('/_pyrepl/__main__.py') + True + """), repls=('pyrepl',)) + + @unittest.skipIf(sys.platform == 'darwin', "Fails with garbage after EOF") def test_basic_repl_no_readline(): validate_repl(dedent("""\ @@ -176,11 +232,36 @@ def test_basic_repl_no_readline(): @autoretry - def test_repl_flushes_std_streams(): + def test_basic_repl_flushes_std_streams(): validate_repl("", extra_input_and_output=[ ExpectedInOutItem('>>>', '40 + 2', '\n42'), - ExpectedInOutItem('>>>', '_ = __import__("sys").stderr.write("stderr")', '\nstderr'), - ExpectedInOutItem('>>>', '_ = __import__("sys").stdout.write("stdout")', '\nstdout'), + ExpectedInOutItem('>>>', 'import sys; _ = sys.stderr.write("stderr")', '\nstderr'), + ExpectedInOutItem('>>>', 'import sys; _ = sys.stdout.write("stdout")', '\nstdout'), + ], repls=('basic',)) + + + @autoretry + def test_pyrepl_flushes_stdout(): + # CPython's PyREPL flushes stdout, but not stderr, before reading the next line. + validate_repl("", extra_input_and_output=[ + ExpectedInOutItem('>>>', '40 + 2', '\n42'), + ExpectedInOutItem('>>>', 'import sys; _ = sys.stdout.write("stdout")', '\nstdout'), + ], repls=('pyrepl',)) + + + def test_pyrepl_autoindent(): + validate_repl("", extra_input_and_output=[ + ExpectedInOutItem('>>>', 'def answer():', ''), + ExpectedInOutItem('...', ' return 42', '', autoindent=4), + ExpectedInOutItem('...', '', ''), + ExpectedInOutItem('>>>', 'answer()', '\n42'), + ], repls=('pyrepl',)) + + + def test_repl_tab_completion(): + validate_repl("", extra_input_and_output=[ + ExpectedInOutItem('>>>', "print('completion-marker')", '\ncompletion-marker', + keys="prin\t'completion-marker')"), ]) From 932482fa8fe01fd8657b1fcf046133a625877dc6 Mon Sep 17 00:00:00 2001 From: Michael Simacek Date: Fri, 18 Sep 2026 17:28:12 +0200 Subject: [PATCH 05/11] Try to fix test_repl on Darwin --- .../src/tests/test_repl.py | 64 +++++++++++-------- 1 file changed, 38 insertions(+), 26 deletions(-) diff --git a/graalpython/com.oracle.graal.python.test/src/tests/test_repl.py b/graalpython/com.oracle.graal.python.test/src/tests/test_repl.py index de5e625aed..3d671fe6e6 100644 --- a/graalpython/com.oracle.graal.python.test/src/tests/test_repl.py +++ b/graalpython/com.oracle.graal.python.test/src/tests/test_repl.py @@ -37,6 +37,7 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. +import errno import os import platform import re @@ -44,32 +45,14 @@ import subprocess import sys import tempfile +import time import unittest from dataclasses import dataclass -from functools import wraps from textwrap import dedent if (sys.platform != 'win32' and (sys.platform != 'linux' or platform.machine() != 'aarch64')) and ( sys.implementation.name != 'graalpy' or __graalpython__.posix_module_backend() != 'java'): - # The terminal tests can be flaky - def autoretry(fn): - @wraps(fn) - def decorated(*args, **kwargs): - retries = 3 - while retries: - try: - fn(*args, **kwargs) - return - except Exception as e: - retries -= 1 - print(f"Retrying test after {e!r}") - continue - fn(*args, **kwargs) - - return decorated - - @dataclass class ExpectedInOutItem: prompt: str @@ -88,7 +71,6 @@ def normalize_terminal_output(output): return re.sub(r'', '', output) - @autoretry def validate_repl(stdin, python_args=(), ignore_preamble=True, extra_input_and_output=(), repls=('basic', 'pyrepl'), extra_env=None): for repl in repls: @@ -111,6 +93,7 @@ def _validate_repl(stdin, repl, python_args, ignore_preamble, extra_input_and_ou if extra_env: env.update(extra_env) pty_parent, pty_child = os.openpty() + proc = None try: import termios termios.tcsetwinsize(pty_parent, (60, 80)) @@ -121,6 +104,8 @@ def _validate_repl(stdin, repl, python_args, ignore_preamble, extra_input_and_ou stdout=pty_child, stderr=pty_child, ) + os.close(pty_child) + pty_child = None raw_out = '' out = '' input_and_output: list[ExpectedInOutItem] = [] @@ -171,8 +156,29 @@ def _validate_repl(stdin, repl, python_args, ignore_preamble, extra_input_and_ou raw_out = prompt_match.group(1) if index >= len(input_and_output): os.write(pty_parent, b'\x04') # Ctrl-D / EOF - proc.wait(timeout=60) - out = os.read(pty_parent, 1024).decode('utf-8') + # Keep draining the PTY while waiting for the child. In particular, PyREPL + # restores the terminal with TCSADRAIN, which can block on macOS until the + # master consumes all pending terminal-control output. + eof_output = bytearray() + deadline = time.monotonic() + 60 + while True: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise subprocess.TimeoutExpired(proc.args, 60, output=bytes(eof_output)) + rlist, _, _ = select.select([pty_parent], [], [], remaining) + if not rlist: + raise subprocess.TimeoutExpired(proc.args, 60, output=bytes(eof_output)) + try: + chunk = os.read(pty_parent, 1024) + except OSError as e: + if e.errno == errno.EIO: + break + raise + if not chunk: + break + eof_output.extend(chunk) + proc.wait(timeout=max(0, deadline - time.monotonic())) + out = eof_output.decode('utf-8') out = normalize_terminal_output(out) assert not out.strip(), f"Garbage after EOF:\n{out!r}" return @@ -189,10 +195,18 @@ def _validate_repl(stdin, repl, python_args, ignore_preamble, extra_input_and_ou # Clear PyREPL's automatic indentation so an empty line terminates # the compound statement. next_in = '\x15' - os.write(pty_parent, next_in.encode('utf-8') + b'\r') + os.write(pty_parent, next_in.encode('utf-8') + b'\n') finally: - os.close(pty_child) os.close(pty_parent) + if pty_child is not None: + os.close(pty_child) + if proc is not None and proc.poll() is None: + proc.terminate() + try: + proc.wait(timeout=10) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait() def test_basic_repl(): @@ -231,7 +245,6 @@ def test_basic_repl_no_readline(): """), python_args=['-S']) - @autoretry def test_basic_repl_flushes_std_streams(): validate_repl("", extra_input_and_output=[ ExpectedInOutItem('>>>', '40 + 2', '\n42'), @@ -240,7 +253,6 @@ def test_basic_repl_flushes_std_streams(): ], repls=('basic',)) - @autoretry def test_pyrepl_flushes_stdout(): # CPython's PyREPL flushes stdout, but not stderr, before reading the next line. validate_repl("", extra_input_and_output=[ From b7ec717bf84ce2123f32aad12b434080ed5c9e91 Mon Sep 17 00:00:00 2001 From: Michael Simacek Date: Wed, 7 Oct 2026 11:11:36 +0200 Subject: [PATCH 06/11] Allow inspect flag to override PYTHON_BASIC_REPL --- .../graal/python/shell/GraalPythonMain.java | 6 +++++- .../src/tests/test_repl.py | 20 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/graalpython/com.oracle.graal.python.shell/src/com/oracle/graal/python/shell/GraalPythonMain.java b/graalpython/com.oracle.graal.python.shell/src/com/oracle/graal/python/shell/GraalPythonMain.java index 067a652712..bbc921eb98 100644 --- a/graalpython/com.oracle.graal.python.shell/src/com/oracle/graal/python/shell/GraalPythonMain.java +++ b/graalpython/com.oracle.graal.python.shell/src/com/oracle/graal/python/shell/GraalPythonMain.java @@ -897,7 +897,6 @@ protected void launch(Builder contextBuilder) { contextBuilder.arguments(getLanguageId(), programArgs.toArray(new String[programArgs.size()])); contextBuilder.in(consoleHandler.createInputStream()); boolean tty = isTTY(); - boolean basicReplRequested = !tty || (!ignoreEnv && getBoolEnv("PYTHON_BASIC_REPL")); contextBuilder.option("python.TerminalIsInteractive", Boolean.toString(tty)); contextBuilder.option("python.CheckHashPycsMode", checkHashPycsMode); @@ -937,6 +936,7 @@ protected void launch(Builder contextBuilder) { } } + boolean basicReplRequested = !tty || (!ignoreEnv && getBoolEnv("PYTHON_BASIC_REPL")); sysModule.putMember("_console_handler", consoleHandler); if (basicReplRequested && tty && !isolateFlag && (inspectFlag || (commandString == null && inputFile == null))) { evalInternal(context, "import readline, rlcompleter"); @@ -960,6 +960,10 @@ protected void launch(Builder contextBuilder) { boolean inspecting = commandString != null || inputFile != null; inspectFlag = false; runInteractiveHook(sysModule); + if (inspecting) { + // The inspected program may have changed os.environ since the launcher started. + basicReplRequested = !tty || (!ignoreEnv && evalInternal(context, "import os; bool(os.environ.get('PYTHON_BASIC_REPL'))").asBoolean()); + } if (!basicReplRequested && canUsePyRepl(context)) { rc = readEvalPrintPyRepl(context, inspecting); } else { diff --git a/graalpython/com.oracle.graal.python.test/src/tests/test_repl.py b/graalpython/com.oracle.graal.python.test/src/tests/test_repl.py index 3d671fe6e6..065be37215 100644 --- a/graalpython/com.oracle.graal.python.test/src/tests/test_repl.py +++ b/graalpython/com.oracle.graal.python.test/src/tests/test_repl.py @@ -352,6 +352,26 @@ def test_inspect_flag(): """), python_args=['-i', f.name], ignore_preamble=False) + def test_inspect_flag_repl_selection(): + for initial_value, code, flags, repl in [ + ('', "os.environ['PYTHON_BASIC_REPL'] = '1'", [], 'basic'), + ('1', "del os.environ['PYTHON_BASIC_REPL']", [], 'pyrepl'), + ('1', "os.environ['PYTHON_BASIC_REPL'] = ''", [], 'pyrepl'), + ('', "os.environ['PYTHON_BASIC_REPL'] = '1'", ['-E'], 'pyrepl'), + ('', "os.environ['PYTHON_BASIC_REPL'] = '1'", ['-I'], 'pyrepl'), + ]: + # Autoindent distinguishes the selected REPL even when inspecting an existing __main__. + validate_repl("", python_args=[*flags, '-i', '-c', f'import os; {code}'], + repls=(repl,), extra_env={'PYTHON_BASIC_REPL': initial_value}, + extra_input_and_output=[ + ExpectedInOutItem('>>>', 'def answer():', ''), + ExpectedInOutItem('...', ' return 42', '', + autoindent=4 if repl == 'pyrepl' else 0), + ExpectedInOutItem('...', '', ''), + ExpectedInOutItem('>>>', 'answer()', '\n42'), + ]) + + def test_inspect_flag_exit(): with tempfile.NamedTemporaryFile('w') as f: f.write('a = 1\nimport sys\nsys.exit(1)\n') From f9740be84ab7cada5d6093c98e55515535188f83 Mon Sep 17 00:00:00 2001 From: Michael Simacek Date: Wed, 7 Oct 2026 11:00:12 +0200 Subject: [PATCH 07/11] Implement msvcrt.kbhit --- .../builtins/modules/MsvcrtModuleBuiltins.java | 16 ++++++++++++++++ .../python/runtime/EmulatedPosixSupport.java | 6 ++++++ .../python/runtime/LoggingPosixSupport.java | 11 +++++++++++ .../graal/python/runtime/NativePosixSupport.java | 13 +++++++++++++ .../graal/python/runtime/PosixSupport.java | 3 +++ .../python/runtime/PreInitPosixSupport.java | 7 +++++++ graalpython/python-libposix/src/posix.c | 11 +++++++++++ 7 files changed, 67 insertions(+) diff --git a/graalpython/com.oracle.graal.python/src/com/oracle/graal/python/builtins/modules/MsvcrtModuleBuiltins.java b/graalpython/com.oracle.graal.python/src/com/oracle/graal/python/builtins/modules/MsvcrtModuleBuiltins.java index d89c46b6fe..028e9cf7af 100644 --- a/graalpython/com.oracle.graal.python/src/com/oracle/graal/python/builtins/modules/MsvcrtModuleBuiltins.java +++ b/graalpython/com.oracle.graal.python/src/com/oracle/graal/python/builtins/modules/MsvcrtModuleBuiltins.java @@ -53,6 +53,7 @@ import com.oracle.graal.python.builtins.objects.PNone; import com.oracle.graal.python.nodes.PConstructAndRaiseNode; import com.oracle.graal.python.nodes.function.PythonBuiltinBaseNode; +import com.oracle.graal.python.nodes.function.PythonBuiltinNode; import com.oracle.graal.python.nodes.function.builtins.PythonBinaryClinicBuiltinNode; import com.oracle.graal.python.nodes.function.builtins.PythonTernaryClinicBuiltinNode; import com.oracle.graal.python.nodes.function.builtins.PythonUnaryClinicBuiltinNode; @@ -91,6 +92,21 @@ public void initialize(Python3Core core) { addBuiltinConstant("LK_NBRLCK", PosixConstants._LK_NBRLCK.getValueIfDefined()); } + @Builtin(name = "kbhit", minNumOfPositionalArgs = 0) + @GenerateNodeFactory + public abstract static class KbhitNode extends PythonBuiltinNode { + @Specialization + int kbhit(VirtualFrame frame, + @Bind Node inliningTarget, + @Cached PConstructAndRaiseNode.Lazy constructAndRaiseNode) { + try { + return getPosixSupport().msvcrtKbhit(); + } catch (PosixSupport.PosixException e) { + throw constructAndRaiseNode.get(inliningTarget).raiseOSErrorFromPosixException(frame, e); + } + } + } + @Builtin(name = "locking", minNumOfPositionalArgs = 3, parameterNames = {"fd", "mode", "nbytes"}) @ArgumentClinic(name = "fd", conversion = ArgumentClinic.ClinicConversion.Int) @ArgumentClinic(name = "mode", conversion = ArgumentClinic.ClinicConversion.Int) diff --git a/graalpython/com.oracle.graal.python/src/com/oracle/graal/python/runtime/EmulatedPosixSupport.java b/graalpython/com.oracle.graal.python/src/com/oracle/graal/python/runtime/EmulatedPosixSupport.java index 119db224e2..25edf6eda7 100644 --- a/graalpython/com.oracle.graal.python/src/com/oracle/graal/python/runtime/EmulatedPosixSupport.java +++ b/graalpython/com.oracle.graal.python/src/com/oracle/graal/python/runtime/EmulatedPosixSupport.java @@ -605,6 +605,12 @@ public void msvcrtLocking(int fd, int mode, long nbytes) throws PosixException { doLockOperation(fd, channel, unlock, false, blocking, SEEK_CUR.value, 0, nbytes); } + @Override + @TruffleBoundary + public int msvcrtKbhit() throws UnsupportedPosixFeatureException { + throw createUnsupportedFeature("msvcrt.kbhit"); + } + @Override @TruffleBoundary public int[] pipe() throws PosixException { diff --git a/graalpython/com.oracle.graal.python/src/com/oracle/graal/python/runtime/LoggingPosixSupport.java b/graalpython/com.oracle.graal.python/src/com/oracle/graal/python/runtime/LoggingPosixSupport.java index 17f02f7a88..a5bbd2484b 100644 --- a/graalpython/com.oracle.graal.python/src/com/oracle/graal/python/runtime/LoggingPosixSupport.java +++ b/graalpython/com.oracle.graal.python/src/com/oracle/graal/python/runtime/LoggingPosixSupport.java @@ -300,6 +300,17 @@ public final void msvcrtLocking(int fd, int mode, long nbytes) throws PosixExcep } } + @Override + @TruffleBoundary + public final int msvcrtKbhit() throws PosixException { + logEnter("msvcrtKbhit", ""); + try { + return logExit("msvcrtKbhit", "%d", delegate.msvcrtKbhit()); + } catch (PosixException e) { + throw logException("msvcrtKbhit", e); + } + } + @Override @TruffleBoundary public final int[] pipe() throws PosixException { diff --git a/graalpython/com.oracle.graal.python/src/com/oracle/graal/python/runtime/NativePosixSupport.java b/graalpython/com.oracle.graal.python/src/com/oracle/graal/python/runtime/NativePosixSupport.java index b7da1d6cc2..5700c6e814 100644 --- a/graalpython/com.oracle.graal.python/src/com/oracle/graal/python/runtime/NativePosixSupport.java +++ b/graalpython/com.oracle.graal.python/src/com/oracle/graal/python/runtime/NativePosixSupport.java @@ -233,6 +233,9 @@ abstract static class PosixNativeFunctionInvoker { @DowncallSignature(returnType = SINT32, argumentTypes = {SINT32, SINT32, SINT64}) abstract int call_msvcrt_locking(int fd, int mode, long nbytes); + @DowncallSignature(returnType = SINT32, argumentTypes = {}) + abstract int call_msvcrt_kbhit(); + @DowncallSignature(returnType = SINT32, argumentTypes = {POINTER}) abstract int call_pipe2(long pipefd); @@ -889,6 +892,16 @@ public void msvcrtLocking(int fd, int mode, long nbytes) throws PosixException { } } + @Override + @TruffleBoundary + public int msvcrtKbhit() throws PosixException { + int result = posixNativeFunctionInvoker.call_msvcrt_kbhit(); + if (result < 0) { + throw getErrnoAndThrowPosixException(); + } + return result; + } + @Override @TruffleBoundary public int[] pipe() throws PosixException { diff --git a/graalpython/com.oracle.graal.python/src/com/oracle/graal/python/runtime/PosixSupport.java b/graalpython/com.oracle.graal.python/src/com/oracle/graal/python/runtime/PosixSupport.java index 97a5f718e1..4da2bf45fb 100644 --- a/graalpython/com.oracle.graal.python/src/com/oracle/graal/python/runtime/PosixSupport.java +++ b/graalpython/com.oracle.graal.python/src/com/oracle/graal/python/runtime/PosixSupport.java @@ -983,6 +983,9 @@ public abstract int forkExec(Object[] executables, Object[] args, Object cwd, Ob @TruffleBoundary public abstract void msvcrtLocking(int fd, int mode, long nbytes) throws PosixException; + @TruffleBoundary + public abstract int msvcrtKbhit() throws PosixException; + @TruffleBoundary public abstract int[] pipe() throws PosixException; diff --git a/graalpython/com.oracle.graal.python/src/com/oracle/graal/python/runtime/PreInitPosixSupport.java b/graalpython/com.oracle.graal.python/src/com/oracle/graal/python/runtime/PreInitPosixSupport.java index 993e875763..bd41b55594 100644 --- a/graalpython/com.oracle.graal.python/src/com/oracle/graal/python/runtime/PreInitPosixSupport.java +++ b/graalpython/com.oracle.graal.python/src/com/oracle/graal/python/runtime/PreInitPosixSupport.java @@ -272,6 +272,13 @@ public final void msvcrtLocking(int fd, int mode, long nbytes) throws PosixExcep nativePosixSupport.msvcrtLocking(fd, mode, nbytes); } + @Override + @TruffleBoundary + public final int msvcrtKbhit() throws PosixException { + checkNotInPreInitialization(); + return nativePosixSupport.msvcrtKbhit(); + } + @Override @TruffleBoundary public final int[] pipe() throws PosixException { diff --git a/graalpython/python-libposix/src/posix.c b/graalpython/python-libposix/src/posix.c index ae205e8e31..9b6725fae7 100644 --- a/graalpython/python-libposix/src/posix.c +++ b/graalpython/python-libposix/src/posix.c @@ -49,6 +49,7 @@ #include #include #include +#include #include #include #include @@ -1366,6 +1367,10 @@ GP_EXPORT int32_t call_msvcrt_locking(int32_t fd, int32_t mode, int64_t nbytes) return result; } +GP_EXPORT int32_t call_msvcrt_kbhit(void) { + return _kbhit(); +} + GP_EXPORT int32_t call_pipe2(int32_t *pipefd) { int result = _pipe(pipefd, 8192, _O_BINARY | _O_NOINHERIT); if (result < 0) { @@ -2627,6 +2632,12 @@ int32_t call_msvcrt_locking(int32_t fd, int32_t mode, int64_t nbytes) { return -1; } +int32_t call_msvcrt_kbhit(void) { + errno = ENOSYS; + capture_errno(); + return -1; +} + int32_t call_pipe2(int32_t *pipefd) { #ifdef __gnu_linux__ CAPTURE_ERRNO_AND_RETURN(-1, pipe2(pipefd, O_CLOEXEC)); From e6c537070b54dcffe4442762cd3064058f908224 Mon Sep 17 00:00:00 2001 From: Michael Simacek Date: Thu, 8 Oct 2026 10:05:47 +0200 Subject: [PATCH 08/11] Pause JLine's input pump when not reading --- .../graal/python/shell/JLineConsoleHandler.java | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/graalpython/com.oracle.graal.python.shell/src/com/oracle/graal/python/shell/JLineConsoleHandler.java b/graalpython/com.oracle.graal.python.shell/src/com/oracle/graal/python/shell/JLineConsoleHandler.java index e59015a08c..83c97f3ff2 100644 --- a/graalpython/com.oracle.graal.python.shell/src/com/oracle/graal/python/shell/JLineConsoleHandler.java +++ b/graalpython/com.oracle.graal.python.shell/src/com/oracle/graal/python/shell/JLineConsoleHandler.java @@ -100,7 +100,8 @@ public void initializeReadline(Value readlineModule) { try { // The grapheme capability probe consumes pending input, including commands sent through // a pseudo-terminal before readline is initialized (GR-80639). - terminal = TerminalBuilder.builder().streams(inputStream, outputStream).system(true).graphemeCluster(false).build(); + // PyREPL also imports readline. Do not consume terminal input until readLine is used. + terminal = TerminalBuilder.builder().streams(inputStream, outputStream).system(true).graphemeCluster(false).paused(true).build(); } catch (IOException ex) { throw new RuntimeException("unexpected error opening console reader", ex); } finally { @@ -236,7 +237,14 @@ public String readLine(String prompt) { if (lineBuffer.isEmpty()) { Object[] signalHandlers = stashSignalHandlers(); try { - String lines = reader.readLine(prompt); + Terminal terminal = reader.getTerminal(); + String lines; + terminal.resume(); + try { + lines = reader.readLine(prompt); + } finally { + terminal.pause(true); + } Collections.addAll(lineBuffer, lines.split("\n")); } catch (UserInterruptException e) { throw e; From 7f87192952e915c2c6cb41abe4cd11a97cb1a5d6 Mon Sep 17 00:00:00 2001 From: Michael Simacek Date: Thu, 8 Oct 2026 11:21:04 +0200 Subject: [PATCH 09/11] Implement _nt._supports_virtual_terminal --- .../python/builtins/modules/PosixModuleBuiltins.java | 9 +++++++++ .../graal/python/runtime/EmulatedPosixSupport.java | 6 ++++++ .../graal/python/runtime/LoggingPosixSupport.java | 7 +++++++ .../graal/python/runtime/NativePosixSupport.java | 9 +++++++++ .../com/oracle/graal/python/runtime/PosixSupport.java | 3 +++ .../graal/python/runtime/PreInitPosixSupport.java | 7 +++++++ graalpython/python-libposix/src/posix.c | 10 ++++++++++ 7 files changed, 51 insertions(+) diff --git a/graalpython/com.oracle.graal.python/src/com/oracle/graal/python/builtins/modules/PosixModuleBuiltins.java b/graalpython/com.oracle.graal.python/src/com/oracle/graal/python/builtins/modules/PosixModuleBuiltins.java index 92cc28d5a3..a6bdaebacc 100644 --- a/graalpython/com.oracle.graal.python/src/com/oracle/graal/python/builtins/modules/PosixModuleBuiltins.java +++ b/graalpython/com.oracle.graal.python/src/com/oracle/graal/python/builtins/modules/PosixModuleBuiltins.java @@ -611,6 +611,15 @@ static long getPid(@Bind PythonContext context) { } } + @Builtin(name = "_supports_virtual_terminal", minNumOfPositionalArgs = 0, os = PythonOS.PLATFORM_WIN32) + @GenerateNodeFactory + abstract static class SupportsVirtualTerminalNode extends PythonBuiltinNode { + @Specialization + static boolean supportsVirtualTerminal(@Bind PythonContext context) { + return context.getPosixSupport().supportsVirtualTerminal(); + } + } + @Builtin(name = "_is_inputhook_installed", minNumOfPositionalArgs = 0) @GenerateNodeFactory abstract static class IsInputHookInstalledNode extends PythonBuiltinNode { diff --git a/graalpython/com.oracle.graal.python/src/com/oracle/graal/python/runtime/EmulatedPosixSupport.java b/graalpython/com.oracle.graal.python/src/com/oracle/graal/python/runtime/EmulatedPosixSupport.java index 25edf6eda7..d06805f182 100644 --- a/graalpython/com.oracle.graal.python/src/com/oracle/graal/python/runtime/EmulatedPosixSupport.java +++ b/graalpython/com.oracle.graal.python/src/com/oracle/graal/python/runtime/EmulatedPosixSupport.java @@ -611,6 +611,12 @@ public int msvcrtKbhit() throws UnsupportedPosixFeatureException { throw createUnsupportedFeature("msvcrt.kbhit"); } + @Override + @TruffleBoundary + public boolean supportsVirtualTerminal() { + return false; + } + @Override @TruffleBoundary public int[] pipe() throws PosixException { diff --git a/graalpython/com.oracle.graal.python/src/com/oracle/graal/python/runtime/LoggingPosixSupport.java b/graalpython/com.oracle.graal.python/src/com/oracle/graal/python/runtime/LoggingPosixSupport.java index a5bbd2484b..0e661eacec 100644 --- a/graalpython/com.oracle.graal.python/src/com/oracle/graal/python/runtime/LoggingPosixSupport.java +++ b/graalpython/com.oracle.graal.python/src/com/oracle/graal/python/runtime/LoggingPosixSupport.java @@ -311,6 +311,13 @@ public final int msvcrtKbhit() throws PosixException { } } + @Override + @TruffleBoundary + public final boolean supportsVirtualTerminal() { + logEnter("supportsVirtualTerminal", ""); + return logExit("supportsVirtualTerminal", "%s", delegate.supportsVirtualTerminal()); + } + @Override @TruffleBoundary public final int[] pipe() throws PosixException { diff --git a/graalpython/com.oracle.graal.python/src/com/oracle/graal/python/runtime/NativePosixSupport.java b/graalpython/com.oracle.graal.python/src/com/oracle/graal/python/runtime/NativePosixSupport.java index 5700c6e814..6ae8ca523d 100644 --- a/graalpython/com.oracle.graal.python/src/com/oracle/graal/python/runtime/NativePosixSupport.java +++ b/graalpython/com.oracle.graal.python/src/com/oracle/graal/python/runtime/NativePosixSupport.java @@ -236,6 +236,9 @@ abstract static class PosixNativeFunctionInvoker { @DowncallSignature(returnType = SINT32, argumentTypes = {}) abstract int call_msvcrt_kbhit(); + @DowncallSignature(returnType = SINT32) + abstract int supports_virtual_terminal(); + @DowncallSignature(returnType = SINT32, argumentTypes = {POINTER}) abstract int call_pipe2(long pipefd); @@ -902,6 +905,12 @@ public int msvcrtKbhit() throws PosixException { return result; } + @Override + @TruffleBoundary + public boolean supportsVirtualTerminal() { + return posixNativeFunctionInvoker.supports_virtual_terminal() != 0; + } + @Override @TruffleBoundary public int[] pipe() throws PosixException { diff --git a/graalpython/com.oracle.graal.python/src/com/oracle/graal/python/runtime/PosixSupport.java b/graalpython/com.oracle.graal.python/src/com/oracle/graal/python/runtime/PosixSupport.java index 4da2bf45fb..c544c12f01 100644 --- a/graalpython/com.oracle.graal.python/src/com/oracle/graal/python/runtime/PosixSupport.java +++ b/graalpython/com.oracle.graal.python/src/com/oracle/graal/python/runtime/PosixSupport.java @@ -986,6 +986,9 @@ public abstract int forkExec(Object[] executables, Object[] args, Object cwd, Ob @TruffleBoundary public abstract int msvcrtKbhit() throws PosixException; + @TruffleBoundary + public abstract boolean supportsVirtualTerminal(); + @TruffleBoundary public abstract int[] pipe() throws PosixException; diff --git a/graalpython/com.oracle.graal.python/src/com/oracle/graal/python/runtime/PreInitPosixSupport.java b/graalpython/com.oracle.graal.python/src/com/oracle/graal/python/runtime/PreInitPosixSupport.java index bd41b55594..7063169eef 100644 --- a/graalpython/com.oracle.graal.python/src/com/oracle/graal/python/runtime/PreInitPosixSupport.java +++ b/graalpython/com.oracle.graal.python/src/com/oracle/graal/python/runtime/PreInitPosixSupport.java @@ -279,6 +279,13 @@ public final int msvcrtKbhit() throws PosixException { return nativePosixSupport.msvcrtKbhit(); } + @Override + @TruffleBoundary + public final boolean supportsVirtualTerminal() { + checkNotInPreInitialization(); + return nativePosixSupport.supportsVirtualTerminal(); + } + @Override @TruffleBoundary public final int[] pipe() throws PosixException { diff --git a/graalpython/python-libposix/src/posix.c b/graalpython/python-libposix/src/posix.c index 9b6725fae7..d8a7f05fbc 100644 --- a/graalpython/python-libposix/src/posix.c +++ b/graalpython/python-libposix/src/posix.c @@ -1371,6 +1371,12 @@ GP_EXPORT int32_t call_msvcrt_kbhit(void) { return _kbhit(); } +GP_EXPORT int32_t supports_virtual_terminal(void) { + DWORD mode = 0; + HANDLE handle = GetStdHandle(STD_ERROR_HANDLE); + return GetConsoleMode(handle, &mode) && (mode & ENABLE_VIRTUAL_TERMINAL_PROCESSING) != 0; +} + GP_EXPORT int32_t call_pipe2(int32_t *pipefd) { int result = _pipe(pipefd, 8192, _O_BINARY | _O_NOINHERIT); if (result < 0) { @@ -2632,6 +2638,10 @@ int32_t call_msvcrt_locking(int32_t fd, int32_t mode, int64_t nbytes) { return -1; } +int32_t supports_virtual_terminal(void) { + return 0; +} + int32_t call_msvcrt_kbhit(void) { errno = ENOSYS; capture_errno(); From 69e71bd8b6d4a1327aecb375f6d533d6f25dbe61 Mon Sep 17 00:00:00 2001 From: Michael Simacek Date: Thu, 8 Oct 2026 22:13:17 +0200 Subject: [PATCH 10/11] Allow internet access in bisect job --- ci/python-bench.libsonnet | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ci/python-bench.libsonnet b/ci/python-bench.libsonnet index d8fe557905..830af58762 100644 --- a/ci/python-bench.libsonnet +++ b/ci/python-bench.libsonnet @@ -260,7 +260,8 @@ ruby: "==3.1.2", papi: "==5.5.1", }, - environment +: environment(self.os, self.arch) + { + // Benchmarks may download build dependencies, such as Rust for pydantic-core. + environment +: environment(self.os, self.arch) + $.overlay_imports.INTERNET_ACCESS_ENV + { BISECT_BENCHMARK_CONFIG: "bisect-benchmark.ini", ENABLE_POLYBENCH_HPC: "yes", POLYBENCH_HPC_EXTRA_HEADERS: "/cm/shared/apps/papi/papi-5.5.1/include", From 769634cb0dc07b9e2d011b9f56ad15c0bdfa94c0 Mon Sep 17 00:00:00 2001 From: Michael Simacek Date: Fri, 18 Sep 2026 14:53:32 +0200 Subject: [PATCH 11/11] Add changelog entry --- CHANGELOG.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a8bee2902f..29c7121ce2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,11 +4,12 @@ This changelog summarizes major changes between GraalVM versions of the Python language runtime. The main focus is on user-observable behavior of the engine. ## Version 25.6 -- New Java-based `decimal` module with significanlty better performance +* Add support for PyREPL and make it the default. It can be turned off by setting `PYTHON_BASIC_REPL=1` environment variable +* New Java-based `decimal` module with significantly better performance ## Version 25.5 -- Added `select.poll()` on Linux and Mac OS -- Better Windows console support and Windows version reporting +* Added `select.poll()` on Linux and Mac OS +* Better Windows console support and Windows version reporting ## Version 25.4 * Remove BouncyCastle entirely. BouncyCastle was only needed for legacy RSA, DSA, and EC private keys versions 0 and 1. To support these from Python embeddings, BouncyCastle must now be explicitly enabled by adding the `org.graalvm.python:python-bouncycastle-support` Maven _and_ adding `bcprov-jdk18on`, `bcpkix-jdk18on`, and `bcutil-jdk18on` dependencies.