Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -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.
Expand Down
27 changes: 25 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
96 changes: 96 additions & 0 deletions scripts/check-console-browser.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
3 changes: 3 additions & 0 deletions scripts/check-error-hints.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions scripts/check-runtime-hints.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))):
Expand Down
Loading
Loading