From 3a8cbff5412ec15dee0448b2fad2f312f7cae7fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Vogel?= <104342347+cedricvogel-leo@users.noreply.github.com> Date: Mon, 2 Nov 2026 21:39:32 +0100 Subject: [PATCH] input() asks in the console (P36) While a program waits in input(), the console's >>> line becomes its input line with the prompt in front; prompt and answer stay in the transcript as one line. Stop (or Ctrl+D) ends the wait with EOFError. The dialog stays only for hosts that hide the console. Without cross-origin isolation, input() raises RuntimeError with a hint that points to the IDE. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 6 +- README.md | 27 +++++- package.json | 2 +- scripts/check-console-browser.mjs | 96 +++++++++++++++++++++ scripts/check-error-hints.mjs | 3 + scripts/check-runtime-hints.py | 3 + scripts/check-runtime-input.py | 136 ++++++++++++++++++++++++++++++ src/GlPythonWorkspace.tsx | 55 ++++++++++-- src/errorHints.ts | 1 + src/i18n.ts | 6 ++ src/pyodide-worker.js | 26 ++++-- src/runtime/hints.py | 4 + src/runtime/input_bridge.py | 18 +++- src/style.css | 3 + 14 files changed, 362 insertions(+), 24 deletions(-) create mode 100644 scripts/check-runtime-input.py diff --git a/CLAUDE.md b/CLAUDE.md index fc34b36..f5cc0a6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,7 +43,7 @@ There is no unit test framework. The automated checks are `test:protocol` (plain **Python execution lives in a web worker.** `src/pyodide-worker.js` loads Pyodide from the jsDelivr CDN and inlines the Python files under `src/runtime/` via `?raw` imports: - `session.py` — invalidates cached modules between runs and runs the main file by `exec` in a plain dict, `_glpython_repl_namespace`, which REPL inputs (`_glpython_repl`) reuse. It's not `runpy`, so the file's functions and the REPL share one global namespace. Also has `_glpython_error_message` (a Python-style last traceback line), which `visualizer.py` uses too. - `gturtle.py` — the `gturtle` module students import; it just records drawing commands, which are returned as JSON and rendered by `GraphicsWindow.tsx`. -- `input_bridge.py` — patches `input()` to block the worker via `Atomics.wait` on a `SharedArrayBuffer`. Only works when the whole browsing context is cross-origin isolated, so it's reported as an optional capability (`capabilities.input`) — don't assume it's available. +- `input_bridge.py` — patches `input()` to block the worker via `Atomics.wait` on a `SharedArrayBuffer`. Only works when the whole browsing context is cross-origin isolated, so it's reported as an optional capability (`capabilities.input`) — don't assume it's available; without it, `input()` raises `RuntimeError: input() is not available here` (hint `input-unavailable`). The page asks in the console (`input_request` → the `>>>` line becomes the program's input line, `programInput`); after the answer the bridge prints prompt + answer as one line (Pyodide's `batched` stdout only passes whole lines, so the prompt isn't printed before the wait), and a cancelled wait (Stop, Ctrl+D) raises `EOFError`. Before blocking, the worker sends the output it still holds (`activeStream.flush()`). `check-runtime-input.py` checks the bridge with a stub for `js`. - `visualizer.py` — a hand-written tree-walking interpreter over `ast.parse()` output. Real CPython tracing can't expose intermediate expression values, so this interpreter records fine-grained trace steps (keyed by AST `nodeId`) for supported node types via `_STMT_HANDLERS` / `_EXPR_HANDLERS` dispatch dicts; unsupported nodes fall back to compiling/exec'ing that single node (correct result, but one opaque step). Adding fine-grained support for a construct = adding one handler. - Exceptions get tagged with the innermost student node and frame that raised them. A failed run then ends in one `error` step, and `error` holds a traceback of the learner's own lines (`errorMessage` is its last line). - A call into a function from another file switches `_glpython_current_path` while its body runs. @@ -61,7 +61,7 @@ Worker messages: - `repl` → `repl-result` - `run-visual-repl` (a doctest example) → `trace-result` with `rootPath`/`sources`, or `repl-result` if the input doesn't parse - `visualize-repl-entry` (`id` of a REPL input) → `trace-result` with `rootPath`/`sources`, or `repl-replay-note` (`note: 'input' | 'differs'`) -- `init-input-channel`; `input_request` +- `init-input-channel`; `input_request` (`{prompt}`; the answer goes back through the shared buffer, state 2 = line, 3 = cancelled) **UI side.** - `src/GlPythonWorkspace.tsx` is the single large component: editor, run/visualize state, worker lifecycle, and the visualizer rendering (`Frame`, value substitution, animated value "flights", closing-box ghosts, connector SVG). Much of its JSX is packed onto very long single lines — edit with targeted string replacements rather than reformatting. @@ -72,7 +72,7 @@ Worker messages: - **Rendering always replays the full trace up to the current step's `step` number.** In substitution mode the scrubber moves through a filtered step list, so use list indices only for the scrubber, never as step numbers. - **Visualizer modes:** in `box` mode every call is a `Frame` box at its call site. In `substitution` mode (the default; prop `visualizerMode`, embed `?visualizer=`), a call with `shortForm` renders as `Frame` scope `'inline'`: its return expression with parameters substituted, in parentheses in operator context. - **Layout and scrolling:** nested boxes show their variables below their code (CSS). The scroll effect follows the active box horizontally. It sets `scrollLeft`/`scrollTop` on the visualizer view only; don't use `scrollIntoView`, which also scrolls the LiaScript page around the embed and moves the step buttons. -- **Console and Stop:** output, REPL inputs, Tests reports and notes form one transcript (`ConsoleEntry` list after `output`). While Python is busy for more than `STOP_BUTTON_DELAY`, the run button is **Stop**: `stopPython` terminates the worker and calls `startWorker` again, so the REPL namespace is gone after a stop. The embed reports this to its parent (protocol version 4, `glpython:busy`/`glpython:stop`). +- **Console and Stop:** output, REPL inputs, Tests reports and notes form one transcript (`ConsoleEntry` list after `output`). While Python is busy for more than `STOP_BUTTON_DELAY`, the run button is **Stop**: `stopPython` terminates the worker and calls `startWorker` again, so the REPL namespace is gone after a stop - except while `input()` waits: then Stop only cancels the wait (`EOFError`). The embed reports this to its parent (protocol version 4, `glpython:busy`/`glpython:stop`). - **One Monaco model for all files:** a tab switch replaces the editor's text. `updateCode` ignores flush events (`setValue`), since `@monaco-editor/react` shows a read-only file (`ProjectFile.readOnly`) with `setValue` before its `onChange` knows the new tab - the text would land in the previous file. - **Unicode marks in the editor:** Monaco boxes look-alike and invisible characters (`EDITOR_UNICODE_HIGHLIGHT`), except the typographic ones of a German text (dashes, quotes, ×, an allow-list), also in strings and comments. Invisible ones stay marked everywhere: in a doctest's expected output a non-breaking space fails with an Expected/Got that look the same. - **UI strings** come in via the `t: CoreStrings` prop. `src/i18n.ts` is their single source: `coreStrings` holds German and English, and `resolveUiLocale` picks the language. The embed uses them directly; the glpython IDE imports them and adds only its shell strings. New strings have been added as *optional* `CoreStrings` keys with an English fallback, so older hosts keep compiling; add both languages to `coreStrings`. Python values, reprs and error messages are never translated. diff --git a/README.md b/README.md index aed95c1..210f984 100644 --- a/README.md +++ b/README.md @@ -193,8 +193,9 @@ docstring, which don't change what runs; the file itself is shown as written. Below an error, glpython adds one short hint for the unit's common mistakes: an undefined name, missing or too many arguments, a `None` operand (a function that prints instead of returning), text plus a number, a division -by zero, endless recursion, a list index out of range, and a doctest example -without a blank after `>>>`. Python's message and traceback stay exactly as +by zero, endless recursion, a list index out of range, a doctest example +without a blank after `>>>`, and `input()` where the page can't wait for an +answer (see `input()` below). Python's message and traceback stay exactly as they are, and a failing test still fails. A hint is matched on the exception's type and message (`src/runtime/hints.py`); anything else gets no hint. It shows in the Run output, the REPL, the Tests report and below the @@ -243,6 +244,28 @@ it was. An input that doesn't compile has no button. Disable the REPL with `capabilities={{ repl: false }}`; the embed shows it in `eval` mode only, and resetting the embed discards the namespace. +### `input()` + +A program (or a REPL input such as `repl()`) that calls `input(prompt)` asks in +the console, as in a terminal: while it waits, the `>>>` line becomes the +program's input line, with the prompt in front of it and the cursor in it +(`programInputLabel` for screen readers). Enter sends the line; the prompt and +the answer then stay in the transcript as one line (`> 2 * 3`), followed by +what the program prints. Python's `input()` and the transcript otherwise behave +as in a terminal: the answer comes without the newline, a recursive REPL of +the learner's own works, and **Stop** (or Ctrl+D in an empty line) ends the +wait with `EOFError: EOF when reading a line`, which the program can catch - +Python isn't restarted then. Tests ask the same way (the file runs first, as +with Run), and the visualizer asks while it records the run, then shows the +steps; the `input(...)` call is one step with the typed text as its value. +Only a host that hides the console (no REPL and no `emptyOutput`, during a +visualization without output) asks in a dialog (`inputDialogTitle`, …) instead. + +`input()` needs cross-origin isolation (see below). Without it, the program +stops with `RuntimeError: input() is not available here` and the hint +`input-unavailable` («`input()` funktioniert hier nicht … In der IDE fragt die +Konsole danach.»). That is the case in an embed inside LiaScript. + ### Stacked layout (the IDE) Without `compact`, the workspace is stacked: the editor (or, while diff --git a/package.json b/package.json index ac63184..60d6889 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "test:protocol": "npm run build && node scripts/check-embed-protocol.mjs", "test:hints": "node scripts/check-error-hints.mjs", "test:console": "node scripts/check-console-browser.mjs", - "test:runtime": "python3 -B scripts/check-runtime-columns.py && python3 -B scripts/check-runtime-values.py && python3 -B scripts/check-runtime-doctests.py && python3 -B scripts/check-runtime-coverage.py && python3 -B scripts/check-runtime-calls.py && python3 -B scripts/check-runtime-hints.py && python3 -B scripts/check-runtime-fidelity.py && python3 -B scripts/check-runtime-replay.py && python3 -B scripts/check-runtime-imports.py && python3 -B scripts/check-runtime-repl.py", + "test:runtime": "python3 -B scripts/check-runtime-columns.py && python3 -B scripts/check-runtime-values.py && python3 -B scripts/check-runtime-doctests.py && python3 -B scripts/check-runtime-coverage.py && python3 -B scripts/check-runtime-calls.py && python3 -B scripts/check-runtime-hints.py && python3 -B scripts/check-runtime-fidelity.py && python3 -B scripts/check-runtime-replay.py && python3 -B scripts/check-runtime-imports.py && python3 -B scripts/check-runtime-repl.py && python3 -B scripts/check-runtime-input.py", "prepare": "npm run build" }, "peerDependencies": { diff --git a/scripts/check-console-browser.mjs b/scripts/check-console-browser.mjs index e292f77..e2a22df 100644 --- a/scripts/check-console-browser.mjs +++ b/scripts/check-console-browser.mjs @@ -7,6 +7,9 @@ // With "coverage" in features (P23), a report that all passed lists the // lines no example executed - which also checks that Pyodide's Python has // sys.monitoring - and without it, or with a failing example, it doesn't. +// input() (P36) asks in the console's input line where the parent page is +// cross-origin isolated (a recursive REPL, Stop and Ctrl+D → EOFError, the +// visualizer), and without isolation stops with an error and its hint. // // Drives the built embed (dist-embed/, from npm run build:embed) in Chrome // through its protocol, as the LiaScript macro does; Pyodide comes from its @@ -147,6 +150,99 @@ try { } await page.close() } + + // input() asks in the console's input line (P36). The parent page sends + // COOP/COEP, so the embed is cross-origin isolated and can wait, as the IDE. + const isolated = { 'Cross-Origin-Opener-Policy': 'same-origin', 'Cross-Origin-Embedder-Policy': 'require-corp' } + const language = 'def berechnen(text):\n return eval(text)\n\ndef repl():\n text = input("> ")\n if text == "ende":\n return\n print(berechnen(text))\n repl()\n' + const openEmbed = async (headers) => { + const page = await browser.newPage({ viewport: { width: 800, height: 950 } }) + await page.route((url) => url.pathname === '/check-console.html', (route) => route.fulfill({ contentType: 'text/html', body: harness, headers })) + await page.goto(`${origin}/check-console.html`) + await page.waitForFunction(() => window.ready, null, { timeout: 120000 }) + return page + } + const consoleText = (frame) => frame.locator('.console').innerText() + const programLine = (frame) => frame.locator('.repl-line--program .repl-input') + // A REPL input, once the REPL is free again (until then it keeps the text). + const enterRepl = async (frame, text) => { + const repl = frame.locator('.repl-line .repl-input') + await frame.getByRole('textbox', { name: 'Python-Eingabe' }).fill(text) + for (let attempt = 0; attempt < 100 && await repl.inputValue() === text; attempt++) { + await repl.press('Enter') + await new Promise((resolve) => setTimeout(resolve, 100)) + } + } + const answer = async (frame, text, prompt = '> ') => { + await programLine(frame).waitFor({ timeout: 30000 }) + assert.equal(await frame.locator('.repl-line--program .repl-prompt').textContent(), prompt, 'the input line shows the prompt') + // While it waits, the REPL's >>> line is the program's input line. + assert.equal(await frame.locator('.repl-line:not(.repl-line--program)').count(), 0, 'no >>> line while the program waits') + await programLine(frame).fill(text) + await programLine(frame).press('Enter') + } + { + const page = await openEmbed(isolated) + const frame = page.frameLocator('#embed') + try { + await page.evaluate((source) => window.send('run', source), language) + await frame.locator('.console').getByText('(Keine Ausgabe)').waitFor({ timeout: 60000 }) + await enterRepl(frame, 'repl()') + // The input line takes the focus (and keeps the >>> line's). + await programLine(frame).waitFor({ timeout: 30000 }) + assert.ok(await programLine(frame).evaluate((element) => element === document.activeElement), 'the input line has the focus') + await answer(frame, '1 + 2') + await answer(frame, '2 * (3 + 4)') + await answer(frame, 'ende') + await frame.locator('.repl-line:not(.repl-line--program)').waitFor({ timeout: 30000 }) + assert.match(await consoleText(frame), />>> repl\(\)\n> 1 \+ 2\n3\n> 2 \* \(3 \+ 4\)\n14\n> ende/, 'transcript of the recursive REPL') + console.log('input(): a recursive REPL in the console: ok') + + // Stop while it waits: EOFError, Python keeps its session. + await enterRepl(frame, 'repl()') + await answer(frame, '5 * 5') + await programLine(frame).waitFor({ timeout: 30000 }) + await page.evaluate(() => document.querySelector('#embed').contentWindow.postMessage({ type: 'glpython:stop', version: 4 }, location.origin)) + await frame.locator('.repl-line:not(.repl-line--program)').waitFor({ timeout: 30000 }) + const stopped = await consoleText(frame) + assert.match(stopped, /> 5 \* 5\n25\n> \n[\s\S]*EOFError: EOF when reading a line/, 'Stop → EOFError after the prompt') + assert.doesNotMatch(stopped, /Python wurde neu gestartet/, 'Stop while waiting does not restart Python') + // Ctrl+D in the empty line does the same. + await enterRepl(frame, 'input("Name: ")') + await programLine(frame).waitFor({ timeout: 30000 }) + await programLine(frame).press('Control+d') + await frame.locator('.repl-line:not(.repl-line--program)').waitFor({ timeout: 30000 }) + assert.match(await consoleText(frame), /Name: \n[\s\S]*EOFError/, 'Ctrl+D → EOFError') + console.log('input(): Stop and Ctrl+D end the wait with EOFError: ok') + + // The visualizer asks while it records, then shows the steps. + await page.evaluate((source) => window.send('visualize', source), 'text = input("> ")\nprint(eval(text))\n') + await answer(frame, '2 * 3') + await frame.locator('.visualizer-step-count').waitFor({ timeout: 60000 }) + console.log('input(): the visualizer asks, then shows the steps: ok') + } catch (error) { + failed += 1 + console.error(error.message) + console.error('console:', JSON.stringify(await consoleText(frame).catch(() => '?'))) + } + await page.close() + } + // Without isolation (as in LiaScript): Python's error and the hint. + { + const page = await openEmbed({}) + const frame = page.frameLocator('#embed') + try { + await page.evaluate((source) => window.send('run', source), 'x = input("> ")\n') + await frame.locator('.console').getByText('RuntimeError: input() is not available here').waitFor({ timeout: 60000 }) + assert.match(await consoleText(frame), /`?input\(\)`? funktioniert hier nicht/, 'the hint') + assert.equal(await frame.locator('.input-dialog').count(), 0, 'no dialog') + console.log('input() without isolation: error and hint: ok') + } catch (error) { + failed += 1 + console.error(error.message) + } + await page.close() + } } finally { await browser.close() stopServer() diff --git a/scripts/check-error-hints.mjs b/scripts/check-error-hints.mjs index 4be60b8..5c7610f 100644 --- a/scripts/check-error-hints.mjs +++ b/scripts/check-error-hints.mjs @@ -50,6 +50,9 @@ const cases = [ [{ kind: 'doctest-blank', line: 3 }, 'Nach `>>>` fehlt ein Leerzeichen (Zeile 3).', 'A space is missing after `>>>` (line 3).'], + [{ kind: 'input-unavailable' }, + '`input()` funktioniert hier nicht: Diese Seite kann nicht auf eine Eingabe warten. In der IDE fragt die Konsole danach.', + "`input()` doesn't work here: this page can't wait for an answer. In the IDE, the console asks for it."], ] for (const [hint, german, english] of cases) { assert.equal(de.errorHint(hint, 'other'), german) diff --git a/scripts/check-runtime-hints.py b/scripts/check-runtime-hints.py index 3d46e12..0eaaf20 100644 --- a/scripts/check-runtime-hints.py +++ b/scripts/check-runtime-hints.py @@ -74,6 +74,9 @@ def visualize(source): {"kind": "zero-division"}), ("IndexError", "zahlen = [1, 2, 3]\nprint(zahlen[3])\n", "IndexError: list index out of range", {"kind": "list-index"}), + # input_bridge.py raises this where the page can't wait for an answer. + ("input unavailable", "raise RuntimeError('input() is not available here')\n", "RuntimeError: input() is not available here", + {"kind": "input-unavailable"}), ] for label, source, message, hint in CASES: for where, (got_message, got_hint) in (("run", run(source)), ("visualizer", visualize(source))): diff --git a/scripts/check-runtime-input.py b/scripts/check-runtime-input.py new file mode 100644 index 0000000..158a9d0 --- /dev/null +++ b/scripts/check-runtime-input.py @@ -0,0 +1,136 @@ +"""Regression test: input() (src/runtime/input_bridge.py) - the console asks, +and the prompt with the answer stays in the output as one line, as in a +terminal; a cancelled wait (Stop) raises EOFError as Python does; where the +page can't wait, a RuntimeError with its hint. Also a learner's recursive +REPL called from the REPL, and the visualizer's one step for input(...). +The page's side (the worker's shared memory) is replaced by a stub. + +Runs the runtime with CPython (no Pyodide needed): + python3 -B scripts/check-runtime-input.py +""" +import contextlib +import io +import json +import os +import sys +import tempfile +import types + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +RUNTIME = os.path.join(ROOT, "src", "runtime") +sys.path.insert(0, RUNTIME) # for "import gturtle" + +# The page: answers in order, then the worker's markers for a cancelled wait +# or a page that can't wait. +answers = [] +prompts = [] + + +def request_input(prompt): + prompts.append(prompt) + answer = answers.pop(0) + if answer in ("__GLPYTHON_INPUT_CANCELLED__", "__GLPYTHON_INPUT_UNAVAILABLE__"): + raise Exception(answer) + return answer + + +sys.modules["js"] = types.SimpleNamespace(requestInputFromMainThread=request_input) +namespace = {"__name__": "glpython_runtime"} +for name in ("input_bridge.py", "hints.py", "session.py", "visualizer.py", "doctests.py"): + with open(os.path.join(RUNTIME, name), encoding="utf-8") as handle: + exec(compile(handle.read(), name, "exec"), namespace) + +failures = [] +folder = tempfile.mkdtemp(prefix="glpython-input-") + + +def expect(label, got, expected): + if got != expected: + failures.append(f"{label}: got {got!r}, expected {expected!r}") + + +def run(source, given): + """A Run with these answers: its output, Python's message, the hint.""" + answers[:] = given + prompts.clear() + path = os.path.join(folder, "main.py") + with open(path, "w", encoding="utf-8") as handle: + handle.write(source) + printed = io.StringIO() + message = None + with contextlib.redirect_stdout(printed): + try: + namespace["_glpython_run_project"](path) + except BaseException as error: + message = namespace["_glpython_error_message"](error) + report = json.loads(namespace["_glpython_take_error_report"]() or "null") + return printed.getvalue(), message, report["errorHint"] if report else None + + +def repl(source, given): + answers[:] = given + printed = io.StringIO() + with contextlib.redirect_stdout(printed): + result = json.loads(namespace["_glpython_repl"](source)) + return printed.getvalue(), result + + +# The prompt and the answer as one line; the program's output follows. +output, message, _ = run('name = input("Name: ")\nprint("Hallo", name)\n', ["Zoë"]) +expect("one input", (output, message, prompts), ("Name: Zoë\nHallo Zoë\n", None, ["Name: "])) +output, _, _ = run('x = input()\nprint(len(x))\n', [""]) +expect("no prompt, empty answer", output, "\n0\n") +# print(..., end="") before input(): on one line, as in a terminal. +output, _, _ = run('print("Zahl", end=": ")\nx = input()\nprint(x)\n', ["4"]) +expect("output before the prompt", output, "Zahl: 4\n4\n") + +# A learner's REPL of their own language: recursive, called in the REPL +# after the Run. +LANGUAGE = '''def berechnen(text): + return eval(text) + +def repl(): + text = input("> ") + if text == "ende": + return + print(berechnen(text)) + repl() +''' +output, message, _ = run(LANGUAGE, []) +expect("language: run", (output, message), ("", None)) +output, result = repl("repl()", ["1 + 2", "2 * (3 + 4)", "ende"]) +expect("language: transcript", output, "> 1 + 2\n3\n> 2 * (3 + 4)\n14\n> ende\n") +expect("language: no value, no error", (result.get("value"), result.get("error")), (None, None)) +# 200 inputs deep: the recursion keeps working. +output, result = repl("repl()", [str(n) for n in range(200)] + ["ende"]) +expect("language: 200 inputs", (output.count("\n"), result.get("error")), (401, None)) + +# Stop while input() waits: EOFError as in Python, after the prompt's line. +output, message, hint = run('x = input("> ")\nprint(x)\n', ["__GLPYTHON_INPUT_CANCELLED__"]) +expect("cancelled", (output.split("Traceback")[0], message, hint), ("> \n", "EOFError: EOF when reading a line", None)) +run(LANGUAGE, []) +output, result = repl("repl()", ["1 + 1", "__GLPYTHON_INPUT_CANCELLED__"]) +expect("cancelled in the REPL", (output.startswith("> 1 + 1\n2\n> \n"), result.get("error", "").splitlines()[-1:]), (True, ["EOFError: EOF when reading a line"])) +# A program can catch it, as in Python. +output, message, _ = run('try:\n input("> ")\nexcept EOFError:\n print("Ende")\n', ["__GLPYTHON_INPUT_CANCELLED__"]) +expect("EOFError caught", (output, message), ("> \nEnde\n", None)) + +# Where the page can't wait: a RuntimeError and its hint. +output, message, hint = run('x = input("> ")\n', ["__GLPYTHON_INPUT_UNAVAILABLE__"]) +expect("unavailable", (message, hint), ("RuntimeError: input() is not available here", {"kind": "input-unavailable"})) + +# The visualizer asks while it records: input(...) is one step, its value +# the answer, and the output has the prompt line. +answers[:] = ["2 * 3"] +result = json.loads(namespace["_glpython_trace_program"]("main.py", {"main.py": 'text = input("> ")\nprint(eval(text))\n'}, None)) +steps = [(step["label"], step["valueText"]) for step in result["trace"] if step["label"].startswith("input(")] +expect("visualizer: input step", steps, [("input(...)", "'2 * 3'")]) +expect("visualizer: output", (result["output"], result["error"]), ("> 2 * 3\n6\n", None)) + +# Behaves as Python's own input() otherwise. +expect("name", (namespace["_glpython_input"].__name__, __import__("builtins").input.__name__), ("input", "input")) + +if failures: + print("\n".join(failures)) + sys.exit(f"{len(failures)} input check(s) failed.") +print("Runtime input checks passed.") diff --git a/src/GlPythonWorkspace.tsx b/src/GlPythonWorkspace.tsx index e624562..6ffe57c 100644 --- a/src/GlPythonWorkspace.tsx +++ b/src/GlPythonWorkspace.tsx @@ -133,6 +133,8 @@ type CoreStrings = { graphicsEmptyAfter: string graphicsCollapsedLabel: string inputDialogTitle: string + // The console's input line while the program waits in input() (its label for screen readers). + programInputLabel?: string inputDialogFallbackPrompt: string inputPlaceholder: string inputCancel: string @@ -971,8 +973,12 @@ function GlPythonWorkspace(props: GlPythonWorkspaceProps) { const [runtimeState, setRuntimeState] = useState<'loading' | 'ready' | 'failed'>('loading') const [graphics, setGraphics] = useState([]) const [isRunning, setIsRunning] = useState(false) + // input(): the prompt while the program waits for a line (the console's + // input line asks for it), and the lines answered since, shown until the + // program's own output - which repeats them, as a terminal does - arrives. const [inputRequest, setInputRequest] = useState(null) const [inputValue, setInputValue] = useState('') + const [answeredInputLines, setAnsweredInputLines] = useState([]) const [isVisualizerLoading, setIsVisualizerLoading] = useState(false) const [visualTrace, setVisualTrace] = useState(null) const [visualSources, setVisualSources] = useState>({}) @@ -1033,6 +1039,7 @@ function GlPythonWorkspace(props: GlPythonWorkspaceProps) { const byInput = new Map() for (const chunk of chunks) if (chunk.id !== undefined) byInput.set(chunk.id, (byInput.get(chunk.id) ?? '') + chunk.text) if (byInput.size > 0) setConsoleEntries((entries) => entries.map((entry) => entry.kind === 'repl' && !entry.result && byInput.has(entry.id) ? { ...entry, live: keepConsoleTail((entry.live ?? '') + byInput.get(entry.id)) } : entry)) + if (chunks.length > 0) setAnsweredInputLines([]) } const replSourcesRef = useRef(new Map()) const outputRef = useRef(null) @@ -1095,7 +1102,15 @@ function GlPythonWorkspace(props: GlPythonWorkspaceProps) { if (event.data.type === 'repl-result') streamedOutputRef.current = streamedOutputRef.current.filter((chunk) => chunk.id !== event.data.id) if (event.data.type === 'error') setRuntimeState((state) => state === 'loading' ? 'failed' : state) if (event.data.type === 'result' || event.data.type === 'error') { setOutput(event.data.output || currentTranslation.noOutput); setOutputErrorHint(event.data.type === 'error' ? event.data.errorHint ?? null : null); setGraphics(event.data.graphics ?? []); setGraphicsDismissed(false); setIsRunning(false) } - if (event.data.type === 'input_request') { setInputValue(''); setInputRequest(event.data.prompt ?? '') } + // What was printed before shows first; the console follows to the + // input line. + if (event.data.type === 'input_request') { + if (streamFlushTimerRef.current !== null) window.clearTimeout(streamFlushTimerRef.current) + flushStreamedOutput() + consoleAtBottomRef.current = true + setInputValue(''); setInputRequest(event.data.prompt ?? '') + } + if (['result', 'error', 'repl-result', 'trace-result', 'test-result'].includes(event.data.type)) setAnsweredInputLines([]) if (event.data.type === 'test-result' && event.data.skipped) setIsTesting(false) else if (event.data.type === 'test-result') { const id = ++replIdRef.current @@ -1153,8 +1168,11 @@ function GlPythonWorkspace(props: GlPythonWorkspaceProps) { if (inputRequest !== null) inputFieldRef.current?.focus() }, [inputRequest]) + // Sends the line (or, cancelled, EOFError) to the waiting program. Until its + // output brings it, the prompt and the line show as answered. const respondToInput = (cancelled: boolean) => { const channel = inputChannelRef.current + if (inputRequest !== null) setAnsweredInputLines((lines) => [...lines, inputRequest + (cancelled ? '' : inputValue)]) if (channel) { const encoded = cancelled ? new Uint8Array(0) : new TextEncoder().encode(inputValue).slice(0, channel.payload.length) channel.payload.fill(0) @@ -1175,8 +1193,10 @@ function GlPythonWorkspace(props: GlPythonWorkspaceProps) { if (coverageMarks?.path === activeFile) setCoverageMarks(null) onFileCodeChange(activeFile, code ?? '') } + // While input() waits, the worker is blocked: nothing else starts until + // the line is sent or the wait stopped. const runCode = () => { - if (!workerRef.current) return + if (!workerRef.current || inputRequest !== null) return if (workspaceCapabilities.graphics && projectImportsGturtle(project)) onGraphicsOpenChange(true) runStreamStartedRef.current = false consoleAtBottomRef.current = true @@ -1190,7 +1210,7 @@ function GlPythonWorkspace(props: GlPythonWorkspaceProps) { }) } const runVisualize = () => { - if (!workerRef.current) return + if (!workerRef.current || inputRequest !== null) return if (workspaceCapabilities.graphics && projectImportsGturtle(project)) onGraphicsOpenChange(true) setExampleExpectation(null) setIsVisualizerLoading(true) @@ -1205,7 +1225,7 @@ function GlPythonWorkspace(props: GlPythonWorkspaceProps) { // current files - so the REPL afterwards has exactly the definitions just // tested - then the doctests' report (see the worker's "test"). const runTests = () => { - if (!workerRef.current || isTesting) return + if (!workerRef.current || isTesting || inputRequest !== null) return if (workspaceCapabilities.graphics && projectImportsGturtle(project)) onGraphicsOpenChange(true) runStreamStartedRef.current = false consoleAtBottomRef.current = true @@ -1222,7 +1242,7 @@ function GlPythonWorkspace(props: GlPythonWorkspaceProps) { } // Visualizes a failing doctest example against the Tests run's definitions. const visualizeExample = (failure: { source: string; want?: string; got?: string }) => { - if (!workerRef.current || isVisualizerLoading) return + if (!workerRef.current || isVisualizerLoading || inputRequest !== null) return const { source } = failure setExampleExpectation(failure.got !== undefined && failure.want !== undefined ? { got: failure.got, want: failure.want } : null) setIsVisualizerLoading(true) @@ -1244,7 +1264,13 @@ function GlPythonWorkspace(props: GlPythonWorkspaceProps) { setRuntimeState('loading') startWorker() } + // Stop while input() waits ends the wait as Ctrl+D does in Python: the + // program gets EOFError and goes on (or ends with its traceback). const stopPython = () => { + if (inputRequest !== null) { + respondToInput(true) + return + } restartPython() setOutput((current) => current === t.runningOutput(project.mainFile) ? '' : current) setConsoleEntries((entries) => [ @@ -1288,13 +1314,21 @@ function GlPythonWorkspace(props: GlPythonWorkspaceProps) { // to it (see replayReplEntry): the visualization starts in the state the // entry ran in, or the entry gets a note why it can't. const visualizeReplEntry = (id: number) => { - if (!workerRef.current || isRunning || isReplBusy || isVisualizerLoading) return + if (!workerRef.current || isRunning || isReplBusy || isVisualizerLoading || inputRequest !== null) return setConsoleEntries((entries) => entries.map((entry) => entry.kind === 'repl' && entry.id === id && entry.note ? { ...entry, note: undefined } : entry)) setExampleExpectation(null) setIsVisualizerLoading(true) workerRef.current.postMessage({ type: 'visualize-repl-entry', id }) } const handleReplKeyDown = (event: KeyboardEvent) => { + // The program's input line: Ctrl+D ends the input (EOFError), as in a terminal. + if (inputRequest !== null) { + if (event.key === 'd' && event.ctrlKey && !inputValue) { + event.preventDefault() + respondToInput(true) + } + return + } if (event.key === 'ArrowUp' && replHistory.length > 0) { event.preventDefault() const index = replHistoryIndex === null ? replHistory.length - 1 : Math.max(0, replHistoryIndex - 1) @@ -1399,6 +1433,9 @@ function GlPythonWorkspace(props: GlPythonWorkspaceProps) { // line lives in the panel, so it stays wherever there is one. const hideOutputPanel = !workspaceCapabilities.emptyOutput && !workspaceCapabilities.repl && visualTrace !== null && !visualTrace.output && !visualTrace.error + // input() asks in the console's input line, where the REPL's is; only + // without a console (see hideOutputPanel) in a dialog. + const programInput = inputRequest !== null && !hideOutputPanel // The steps the scrubber and the step buttons move through: every step of // the trace in the box mode, only the visible substitutions in the // substitution mode (see substitutionModeSteps). Rendering always replays @@ -2096,13 +2133,13 @@ function GlPythonWorkspace(props: GlPythonWorkspaceProps) { // keeps its width as ▶ becomes ■. const runFromActions = () => { if (isVisualizing) exitVisualizer(); runCode() } const testsFromActions = () => { if (isVisualizing) exitVisualizer(); runTests() } - const actionsNode = workspaceCapabilities.actions ?
{workspaceCapabilities.tests && }{workspaceCapabilities.visualization && }{workspaceCapabilities.resetOutput && }
: null + const actionsNode = workspaceCapabilities.actions ?
{workspaceCapabilities.tests && }{workspaceCapabilities.visualization && }{workspaceCapabilities.resetOutput && }
: null const breadcrumbsNode =
{project.name}/{selectedFile.path}
const visualizerBarNode =
{visualTraceSteps.length > 0 ? <>
{ setVisualStepIndex(Number(event.target.value)) }} />{errorTraceStep && }
{clampedStepIndex === -1 ? t.visualizerBeforeStart : t.visualizerStepOf(clampedStepIndex + 1, visualTraceSteps.length)}{visualTrace?.truncated && {t.visualizerTruncated}} : {t.visualizerNoSteps}}
{workspaceCapabilities.export && <>}{workspaceCapabilities.exitVisualizer && }
const visualizerWarningNode = <>{isVisualizing && visualTrace?.error && !errorTraceStep &&
{t.visualizerError(visualTrace.error)}
} const editorTabsNode =
{openFiles.map((path) => { const file = project.files[path]; return })}
const editorWrapNode =
{isVisualizing ?
: null} />{animationsEnabled && closingBoxes.length > 0 &&
{closingBoxes.map((box) =>
)}
}{(connectorPaths.length > 0 || flights.length > 0) && {connectorPaths.map((connector, index) => )}{animationsEnabled && flights.map((flight) => )}}{animationsEnabled && flights.length > 0 &&
{flights.map((flight) => {flight.value})}
}
: { registerGlpythonPython(instance); defineGlpythonTheme(instance) }} value={selectedFile.code} onChange={updateCode} onMount={setNormalEditor} options={{ minimap: { enabled: false }, bracketPairColorization: { enabled: false }, fontSize: 15, lineHeight: 24, padding: { top: 22 }, fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace', scrollBeyondLastLine: false, smoothScrolling: true, automaticLayout: true, tabSize: 4, insertSpaces: true, detectIndentation: false, placeholder: t.editorPlaceholder ?? 'Type code here', unicodeHighlight: EDITOR_UNICODE_HIGHLIGHT, readOnly: selectedFile.readOnly === true, domReadOnly: selectedFile.readOnly === true, readOnlyMessage: { value: t.readOnlyFile ?? 'Read-only: this file can be read but not changed.' } }} />}
- const consoleNode = <>{!hideOutputPanel &&
{t.output}{runtimeState !== 'ready' && {t.pyodideRuntime}}
{Object.keys(project.files).length > 1 && {t.runsFile(project.mainFile)}}
{ const element = event.currentTarget; consoleAtBottomRef.current = element.scrollHeight - element.scrollTop - element.clientHeight < 24 }}>{isVisualizing ?
{isAtErrorStep ? <>{visualStdout && !visualStdout.endsWith('\n') ? `${visualStdout}\n` : visualStdout}{visualTrace?.error} : visualStdout || t.noOutput}
: <>
{output}
{consoleEntries.map((entry) => entry.kind === 'tests' ?
: entry.kind === 'note' ?

{entry.text}

:
 visualizeReplEntry(entry.id) : undefined} busy={isBusy} />
)}}
{workspaceCapabilities.repl &&
{ event.preventDefault(); submitRepl() }}> { setReplInput(event.target.value); setReplHistoryIndex(null) }} onKeyDown={handleReplKeyDown} aria-label={t.replInputLabel ?? 'Python input'} spellCheck={false} autoComplete="off" autoCapitalize="off" />
}
} + const consoleNode = <>{!hideOutputPanel &&
{t.output}{runtimeState !== 'ready' && {t.pyodideRuntime}}
{Object.keys(project.files).length > 1 && {t.runsFile(project.mainFile)}}
{ const element = event.currentTarget; consoleAtBottomRef.current = element.scrollHeight - element.scrollTop - element.clientHeight < 24 }}>{isVisualizing ?
{isAtErrorStep ? <>{visualStdout && !visualStdout.endsWith('\n') ? `${visualStdout}\n` : visualStdout}{visualTrace?.error} : visualStdout || t.noOutput}
: <>
{output}
{consoleEntries.map((entry) => entry.kind === 'tests' ?
: entry.kind === 'note' ?

{entry.text}

:
 visualizeReplEntry(entry.id) : undefined} busy={isBusy} />
)}}{answeredInputLines.length > 0 &&
{answeredInputLines.join('\n')}
}
{(workspaceCapabilities.repl || programInput) &&
{ event.preventDefault(); if (programInput) respondToInput(false); else submitRepl() }}> { if (programInput) { setInputValue(event.target.value) } else { setReplInput(event.target.value); setReplHistoryIndex(null) } }} onKeyDown={handleReplKeyDown} aria-label={programInput ? `${t.programInputLabel ?? 'Input for the program'}${inputRequest ? `: ${inputRequest}` : ''}` : t.replInputLabel ?? 'Python input'} spellCheck={false} autoComplete="off" autoCapitalize="off" autoCorrect="off" enterKeyHint="send" />
}
} // In the stacked layout the graphics show only while the program draws - // for a visualization: if it draws at all, so the panel doesn't appear in // the middle of stepping. Its ✕ hides it until the next drawing. @@ -2127,7 +2164,7 @@ function GlPythonWorkspace(props: GlPythonWorkspaceProps) { return (
{compact ?
{breadcrumbsNode}{isVisualizing || !actionsNode ?
: actionsNode}
{isVisualizing && visualizerBarNode}{visualizerWarningNode}{(workspaceCapabilities.editor || isVisualizing) &&
{editorTabsNode}{editorWrapNode}
}{consoleNode}
{isGraphicsOpen ? : }
: <>
{!actionsContainer && (actionsNode || workspaceCapabilities.breadcrumbs) &&
{workspaceCapabilities.breadcrumbs && breadcrumbsNode}{actionsNode}
}
{isVisualizing ? visualizerBarNode : workspaceCapabilities.fileTabs ? editorTabsNode : null}
{visualizerWarningNode}{editorWrapNode}
{stackedGraphicsNode}
{consoleNode}
{actionsContainer && actionsNode && createPortal(actionsNode, actionsContainer)}} - {inputRequest !== null &&
respondToInput(true)}>
event.stopPropagation()} onSubmit={(event) => { event.preventDefault(); respondToInput(false) }}>
{t.inputDialogTitle}

{inputRequest || t.inputDialogFallbackPrompt}

setInputValue(event.target.value)} placeholder={t.inputPlaceholder} />
} + {inputRequest !== null && !programInput &&
respondToInput(true)}>
event.stopPropagation()} onSubmit={(event) => { event.preventDefault(); respondToInput(false) }}>
{t.inputDialogTitle}

{inputRequest || t.inputDialogFallbackPrompt}

setInputValue(event.target.value)} placeholder={t.inputPlaceholder} />
}
) } diff --git a/src/errorHints.ts b/src/errorHints.ts index abd003d..7108cde 100644 --- a/src/errorHints.ts +++ b/src/errorHints.ts @@ -11,6 +11,7 @@ export type ErrorHint = | { kind: 'recursion' } | { kind: 'list-index' } | { kind: 'doctest-blank'; line: number } + | { kind: 'input-unavailable' } // Where the error showed up: the REPL adds a question for an undefined name. export type ErrorHintContext = 'repl' | 'other' diff --git a/src/i18n.ts b/src/i18n.ts index 616f2dd..0584ad3 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -82,6 +82,7 @@ export const coreStrings: Record = { graphicsEmptyAfter: 'runs.', graphicsCollapsedLabel: 'Graphics', inputDialogTitle: 'Program input', + programInputLabel: 'Input for the program', inputDialogFallbackPrompt: 'This program is asking for input:', inputPlaceholder: 'Type your answer...', inputCancel: 'Cancel', @@ -160,6 +161,7 @@ export const coreStrings: Record = { graphicsEmptyAfter: 'importiert.', graphicsCollapsedLabel: 'Grafik', inputDialogTitle: 'Programmeingabe', + programInputLabel: 'Eingabe für das Programm', inputDialogFallbackPrompt: 'Dieses Programm bittet um eine Eingabe:', inputPlaceholder: 'Antwort eingeben...', inputCancel: 'Abbrechen', @@ -198,6 +200,8 @@ function germanErrorHint(hint: ErrorHint, context: ErrorHintContext): string | n return 'Die Liste hat weniger Elemente, als der Index verlangt. Die Zählung beginnt bei 0.' case 'doctest-blank': return `Nach \`>>>\` fehlt ein Leerzeichen (Zeile ${hint.line}).` + case 'input-unavailable': + return '`input()` funktioniert hier nicht: Diese Seite kann nicht auf eine Eingabe warten. In der IDE fragt die Konsole danach.' default: return null } @@ -225,6 +229,8 @@ function englishErrorHint(hint: ErrorHint, context: ErrorHintContext): string | return 'The list has fewer elements than the index asks for. Counting starts at 0.' case 'doctest-blank': return `A space is missing after \`>>>\` (line ${hint.line}).` + case 'input-unavailable': + return "`input()` doesn't work here: this page can't wait for an answer. In the IDE, the console asks for it." default: return null } diff --git a/src/pyodide-worker.js b/src/pyodide-worker.js index 0b553a9..82f5a83 100644 --- a/src/pyodide-worker.js +++ b/src/pyodide-worker.js @@ -28,6 +28,10 @@ const REPL_PATH = '' let inputControl = null let inputPayload = null const INPUT_CANCELLED_MARKER = '__GLPYTHON_INPUT_CANCELLED__' +const INPUT_UNAVAILABLE_MARKER = '__GLPYTHON_INPUT_UNAVAILABLE__' +// The stream of what the running program or REPL input prints (see +// outputStream), sent on before input() waits. +let activeStream = null // Python source shared by both run modes: monkeypatches builtins.input to bridge @@ -76,13 +80,14 @@ async function mountProjectFiles(pyodide, files) { callPython(pyodide, '_glpython_invalidate_modules', JSON.stringify([...mountedFiles, 'gturtle.py'].map((path) => `/${path}`))) } -// Bridges Python's input() to a dialog on the main page. Blocks this worker -// thread (via Atomics.wait) until the main thread writes a response into the -// shared buffer set up by the 'init-input-channel' message below. +// Bridges Python's input() to the page, which asks in the console. Blocks +// this worker thread (via Atomics.wait) until the main thread writes a +// response into the shared buffer set up by the 'init-input-channel' message +// below. What was printed before goes out first: while the thread waits, the +// stream's held-back text couldn't. function requestInputFromMainThread(promptText) { - if (!inputControl || !inputPayload) { - throw new Error('Program input is not available in this browser session. Reload the page and try again.') - } + if (!inputControl || !inputPayload) throw new Error(INPUT_UNAVAILABLE_MARKER) + activeStream?.flush() Atomics.store(inputControl, 0, 1) self.postMessage({ type: 'input_request', prompt: promptText }) Atomics.wait(inputControl, 0, 1) @@ -180,7 +185,7 @@ function outputStream(id) { let pending = '' let windowStart = 0 let sent = 0 - return (text) => { + const stream = (text) => { pending += text const now = Date.now() if (now - windowStart >= 50) { @@ -193,6 +198,13 @@ function outputStream(id) { sent += 1 } } + stream.flush = () => { + if (!pending) return + self.postMessage({ type: 'output', id, text: pending }) + pending = '' + } + activeStream = stream + return stream } // Run: mounts the project's files and runs its main file in a fresh REPL diff --git a/src/runtime/hints.py b/src/runtime/hints.py index 349f337..cac7a40 100644 --- a/src/runtime/hints.py +++ b/src/runtime/hints.py @@ -39,4 +39,8 @@ def _glpython_error_hint(error): elif kind == "IndexError": if message == "list index out of range": return {"kind": "list-index"} + elif kind == "RuntimeError": + # input_bridge.py, where the page can't wait for an answer. + if message == "input() is not available here": + return {"kind": "input-unavailable"} return None diff --git a/src/runtime/input_bridge.py b/src/runtime/input_bridge.py index 213bb56..0aecfc1 100644 --- a/src/runtime/input_bridge.py +++ b/src/runtime/input_bridge.py @@ -1,12 +1,26 @@ import builtins from js import requestInputFromMainThread +# input() asks in the console: the page shows the prompt with an input line, +# and the answer comes back here. Then the prompt and the answer are printed +# as one line, as a terminal shows them ("> 2 * 3"). Cancelling (Stop) ends +# the wait with EOFError, as Ctrl+D does in Python. Where the page can't +# wait for an answer (no shared memory, e.g. in an embed that isn't +# cross-origin isolated), input() raises RuntimeError (hints.py words it). def _glpython_input(prompt=""): + prompt = str(prompt) try: - return requestInputFromMainThread(str(prompt)) + answer = requestInputFromMainThread(prompt) except Exception as error: if "__GLPYTHON_INPUT_CANCELLED__" in str(error): - raise EOFError("Input was cancelled.") from None + print(prompt) + raise EOFError("EOF when reading a line") from None + if "__GLPYTHON_INPUT_UNAVAILABLE__" in str(error): + raise RuntimeError("input() is not available here") from None raise + print(prompt + answer) + return answer +# As Python's own, e.g. in the visualizer's step "input(...)". +_glpython_input.__name__ = _glpython_input.__qualname__ = "input" builtins.input = _glpython_input diff --git a/src/style.css b/src/style.css index 22a01ac..ec150ad 100644 --- a/src/style.css +++ b/src/style.css @@ -255,6 +255,9 @@ .repl-line { display: flex; align-items: center; gap: 8px; padding: 12px; background: var(--glp-surface); border-top: 1px solid var(--glp-border); } .repl-prompt { flex-shrink: 0; color: var(--glp-text-muted); font: 600 15px/1.6 var(--glp-font-code); } .repl-input { flex: 1; min-width: 0; padding: 4px 8px; color: var(--glp-text); background: var(--glp-bg); border: 1px solid var(--glp-border-control); border-radius: var(--glp-radius); font: 15px/1.6 var(--glp-font-code); } +/* The same line while the program waits in input(): its prompt instead of >>>. */ +.repl-line--program .repl-prompt { max-width: 60%; overflow: hidden; color: var(--glp-text); font-weight: 400; white-space: pre; text-overflow: ellipsis; } +.repl-line--program .repl-input { border-color: var(--glp-accent); } /* Nested call boxes list their variables in a row below their code instead of a side panel: each level of nesting would otherwise add a whole panel's width,