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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,9 @@ as Reset does, with `{ id, type: 'reset' }`), and learn when Python is busy thro

Below the console, a `>>>` input line runs Python in the namespace of the
last Run, so learners can call the file's functions directly. An expression
shows its value (`None` too, greyed out); a statement such as `a = 5` shows
shows its value, except `None`, as in CPython's REPL (so a call of a function
without `return`, `print(...)` or `help(...)` shows only what it printed); a
statement such as `a = 5` shows
nothing; a `def` runs but adds the hint `replDefinitionHint`; an error shows
Python's last traceback line. Run starts a fresh namespace and clears the
transcript, while arrow up/down still recalls earlier inputs. Every entry of
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,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-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",
"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-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",
"prepare": "npm run build"
},
"peerDependencies": {
Expand Down
107 changes: 107 additions & 0 deletions scripts/check-runtime-repl.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
"""Regression test (P21): the REPL shows what CPython's interactive REPL shows.
A result that is None is not displayed (sys.displayhook skips it): help(f),
a call of a function without return, print(...) and None itself show only
what they print. Compared, input by input, with `python3 -i` on the same
file. Also: help(...) and dir(...) are flagged as having nothing to
visualize - but not a function of the learner's that is called help.

Runs the runtime with CPython (no Pyodide needed):
python3 -B scripts/check-runtime-repl.py
"""
import contextlib
import io
import json
import os
import subprocess
import sys
import tempfile

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
RUNTIME = os.path.join(ROOT, "src", "runtime")

runtime = {"__name__": "glpython_runtime"}
for name in ("hints.py", "session.py"):
with open(os.path.join(RUNTIME, name), encoding="utf-8") as handle:
exec(compile(handle.read(), "<exec>", "exec"), runtime)

MAIN = '''def zeugnis_note(punkte, maximum):
"""Die Note zu den Punkten, auf eine Dezimale."""
return round(punkte / maximum * 5 + 1, 1)


def melde(text):
print("Meldung:", text)


x = 5
'''

INPUTS = [
"help(zeugnis_note)",
"melde('hallo')",
"print(1)",
"None",
"zeugnis_note(18, 24)",
"x = x + 1",
"x",
"[None, melde('in der Liste')]",
"print",
"dir()",
]

folder = tempfile.mkdtemp(prefix="glpython-repl-")
main_path = os.path.join(folder, "main.py")
with open(main_path, "w", encoding="utf-8") as handle:
handle.write(MAIN)


def cpython_transcript(inputs):
"""What `python3 -i main.py` writes to stdout for each input (prompts and
tracebacks go to stderr): what it printed, and the value if not None."""
marker = "----glpython-next-input----"
script = "".join(f"{line}\nprint({marker!r})\n" for line in inputs)
result = subprocess.run([sys.executable, "-q", "-i", main_path], input=script, capture_output=True, text=True, cwd=folder, env={**os.environ, "PYTHONSTARTUP": "", "TERM": "dumb"})
parts = result.stdout.split(marker + "\n")
return parts[:len(inputs)]


def glpython_transcript(inputs):
with contextlib.redirect_stdout(io.StringIO()):
runtime["_glpython_run_project"](main_path)
transcript, flags = [], []
for line in inputs:
printed = io.StringIO()
with contextlib.redirect_stdout(printed):
result = json.loads(runtime["_glpython_repl"](line))
shown = printed.getvalue() + (result["value"] + "\n" if "value" in result else "")
transcript.append(shown)
flags.append(result.get("untraced", False))
return transcript, flags


failures = []
# dir() differs between the two sessions (glpython's namespace has __file__
# and the runtime's own names), so only its flag is compared.
compared = [line for line in INPUTS if line != "dir()"]
expected = cpython_transcript(compared)
got, flags = glpython_transcript(INPUTS)
for line, want, have in zip(compared, expected, [shown for line, shown in zip(INPUTS, got) if line != "dir()"]):
if want != have:
failures.append(f">>> {line}\n--- CPython\n{want!r}\n--- glpython\n{have!r}")

untraced = dict(zip(INPUTS, flags))
for line, want in [("help(zeugnis_note)", True), ("dir()", True), ("print(1)", False), ("zeugnis_note(18, 24)", False), ("melde('hallo')", False)]:
if untraced[line] != want:
failures.append(f">>> {line}: untraced {untraced[line]}, want {want}")
# A function of the learner's called help is visualized like any other.
namespace = {"__name__": "__main__", "__builtins__": __builtins__}
exec("def help(x):\n return x\n", namespace)
with contextlib.redirect_stdout(io.StringIO()):
own = json.loads(runtime["_glpython_repl"]("help(3)", namespace))
if own.get("untraced") or own.get("value") != "3":
failures.append(f"own help(3): {own}")

if failures:
print("\n\n".join(failures))
sys.exit(f"{len(failures)} REPL check(s) failed.")
print("Runtime REPL checks passed.")
12 changes: 6 additions & 6 deletions src/GlPythonWorkspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,7 @@
type ReplEntry = {
id: number
source: string
result: { output: string; value?: string; isNone?: boolean; error?: string; errorHint?: ErrorHint | null; hint?: 'definition' | null; unparsed?: boolean } | null
result: { output: string; value?: string; untraced?: boolean; error?: string; errorHint?: ErrorHint | null; hint?: 'definition' | null; unparsed?: boolean } | null
note?: ReplReplayNote
// What the input has printed so far, while it still runs.
live?: string
Expand Down Expand Up @@ -711,7 +711,7 @@
{parens && '('}
{lines.map((line, index) => {
const lineStart = cursor
cursor += line.length + 1

Check warning on line 714 in src/GlPythonWorkspace.tsx

View workflow job for this annotation

GitHub Actions / check

react(immutability)

src/GlPythonWorkspace.tsx:714:11: Cannot reassign variable after render completes
return <Fragment key={index}>{index > 0 && '\n'}{renderSegments(lineSegments(line, lineStart))}</Fragment>
})}
{parens && ')'}
Expand Down Expand Up @@ -888,7 +888,7 @@
<span className="repl-entry">
<span className="repl-echo"><span>{'>>> '}{entry.source}</span>{onVisualize && <button type="button" className="repl-entry-visualize" onClick={onVisualize} disabled={busy} aria-label={`${visualizeLabel}: ${entry.source}`}>{visualizeLabel}</button>}</span>
{printed && <span>{printed}</span>}
{result?.value !== undefined && <span className={result.isNone ? 'repl-value repl-value--none' : 'repl-value'}>{result.value}</span>}
{result?.value !== undefined && <span className="repl-value">{result.value}</span>}
{result?.error && <span className="output-error">{result.error}</span>}
<ErrorHintNote hint={result?.errorHint} t={t} context="repl" />
{result?.hint === 'definition' && <span className="repl-hint">{definitionHint}</span>}
Expand Down Expand Up @@ -1019,7 +1019,7 @@
// Starts Python in a new worker, at mount and after a stop.
const startWorker = () => {
const worker = new Worker(new URL('./pyodide-worker.js', import.meta.url))
worker.onmessage = (event: MessageEvent<TestReport & { type: string; output?: string; text?: string; graphics?: TurtleCommand[]; prompt?: string; id?: number; value?: string; isNone?: boolean; hint?: 'definition' | null; unparsed?: boolean; note?: ReplReplayNote; rootPath?: string; sources?: Record<string, string> } & Partial<TraceResult>>) => {
worker.onmessage = (event: MessageEvent<TestReport & { type: string; output?: string; text?: string; graphics?: TurtleCommand[]; prompt?: string; id?: number; value?: string; untraced?: boolean; hint?: 'definition' | null; unparsed?: boolean; note?: ReplReplayNote; rootPath?: string; sources?: Record<string, string> } & Partial<TraceResult>>) => {
const currentTranslation = stringsRef.current
// After a stop the console keeps its transcript.
if (event.data.type === 'ready') { if (!keepConsoleOnReadyRef.current) setOutput(currentTranslation.readyOutput); keepConsoleOnReadyRef.current = false; setRuntimeState('ready') }
Expand All @@ -1044,8 +1044,8 @@
setIsTesting(false)
}
if (event.data.type === 'repl-result') {
const { id = -1, output = '', value, isNone, error, errorHint, hint, unparsed, graphics } = event.data
const result = { output, value, isNone, error: error ?? undefined, errorHint, hint, unparsed }
const { id = -1, output = '', value, untraced, error, errorHint, hint, unparsed, graphics } = event.data
const result = { output, value, untraced, error: error ?? undefined, errorHint, hint, unparsed }
// A visualized doctest example that doesn't parse comes back here
// too, and has no transcript entry yet.
setConsoleEntries((entries) => entries.some((entry) => entry.kind === 'repl' && entry.id === id)
Expand Down Expand Up @@ -2033,7 +2033,7 @@
const visualizerWarningNode = <>{isVisualizing && visualTrace?.error && !errorTraceStep && <div className="visualizer-warning"><AlertTriangle size={14} />{t.visualizerError(visualTrace.error)}</div>}</>
const editorTabsNode = <div className="editor-tabs">{openFiles.map((path) => { const file = project.files[path]; return <button className={`editor-tab ${activeFile === path ? 'active' : ''}`} key={path} onClick={() => onOpenFile(path)}><span className={`tab-file-icon ${file.kind === 'python' ? 'python' : 'notes'}`}>{file.kind === 'python' ? <FileCode2 size={14} /> : <BookOpen size={14} />}</span><span>{file.path}</span>{workspaceCapabilities.closeFileTabs && <span className="tab-close" role="button" aria-label={t.closeFile(file.path)} onClick={(event) => { event.stopPropagation(); onCloseFile(path) }}><X size={13} /></span>}</button> })}</div>
const editorWrapNode = <div className="editor-wrap">{isVisualizing ? <div className={`visualizer-view${animationsEnabled ? ' visualizer-animated' : ''}`} ref={visualizerScrollRef}><div className="visualizer-canvas" ref={visualizerCanvasRef}><Frame frameId={0} path={visualRootPath ?? activeFile} scope={{ kind: 'module' }} openFrames={framesForRender} nodeIndexByFile={astIndexByFile} visualSources={visualSources} trace={visualTrace?.trace ?? []} uptoStep={currentTraceStep?.step ?? -1} currentStep={currentTraceStep} nextStep={nextTraceStep} monaco={monaco} valueKindLabels={valueKindLabels} hiddenRanges={visualTrace?.hidden ?? {}} declareTarget={nextStepDeclareTarget} pendingCallLocals={pendingCallLocals} errorStep={isAtErrorStep ? errorTraceStep : null} collapsedCalls={collapsedCalls} collapsedCallsLabel={collapsedCallsLabel} substitutionMode={substitutionMode} boxHint={t.visualizerBoxHint ?? 'The box shows which return is executed.'} insideBox={false} activeHost={activeHost} errorHost={errorHost} finalNote={finalNote} errorHint={isAtErrorStep && visualTrace?.errorHint && t.errorHint?.(visualTrace.errorHint, 'other') ? <ErrorHintNote hint={visualTrace.errorHint} t={t} /> : null} />{animationsEnabled && closingBoxes.length > 0 && <div className="visualizer-closing-boxes">{closingBoxes.map((box) => <div key={box.id} className="visual-closing-box-ghost" style={{ left: box.left, top: box.top, width: box.width, height: box.height }} dangerouslySetInnerHTML={{ __html: box.html }} />)}</div>}{(connectorPaths.length > 0 || flights.length > 0) && <svg className="visualizer-connectors"><defs><marker id="visualizer-arrowhead" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M 0 0 L 10 5 L 0 10 z" /></marker></defs>{connectorPaths.map((connector, index) => <path key={index} d={connector.d} markerEnd="url(#visualizer-arrowhead)" />)}{animationsEnabled && flights.map((flight) => <path key={`flight-${flight.id}`} className="visual-flight-arrow" d={flight.d} markerEnd="url(#visualizer-arrowhead)" />)}</svg>}{animationsEnabled && flights.length > 0 && <div className="visualizer-flights">{flights.map((flight) => <span key={flight.id} className="visual-flight-value" style={{ offsetPath: `path('${flight.d}')` }}>{flight.value}</span>)}</div>}</div></div> : <Editor key="normal-editor" height="100%" language={selectedFile.kind === 'python' ? GLPYTHON_PYTHON : 'markdown'} theme={GLPYTHON_THEME} beforeMount={(instance) => { registerGlpythonPython(instance); defineGlpythonTheme(instance) }} value={selectedFile.code} onChange={updateCode} 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' }} />}</div>
const consoleNode = <>{!hideOutputPanel && <section className="output-panel"><div className="output-heading"><div className="output-title"><SquareTerminal size={16} /><span>{t.output}</span>{runtimeState !== 'ready' && <span className={`runtime-badge${runtimeState === 'failed' ? ' runtime-badge--failed' : ''}`}><span className="pulse" /> {t.pyodideRuntime}</span>}</div>{Object.keys(project.files).length > 1 && <span className="output-hint">{t.runsFile(project.mainFile)}</span>}</div><div className="console" ref={outputRef} role="log" onScroll={(event) => { const element = event.currentTarget; consoleAtBottomRef.current = element.scrollHeight - element.scrollTop - element.clientHeight < 24 }}>{isVisualizing ? <pre>{isAtErrorStep ? <>{visualStdout && !visualStdout.endsWith('\n') ? `${visualStdout}\n` : visualStdout}<span className="output-error">{visualTrace?.error}</span></> : visualStdout || t.noOutput}</pre> : <><pre>{output}<ErrorHintNote hint={outputErrorHint} t={t} /></pre>{consoleEntries.map((entry) => entry.kind === 'tests' ? <div key={entry.id} data-console-entry={entry.id}><TestReportView report={entry.report} t={t} onVisualizeExample={workspaceCapabilities.visualization ? visualizeExample : null} /></div> : entry.kind === 'note' ? <p key={entry.id} className="console-note" role="note" data-console-entry={entry.id}>{entry.text}</p> : <pre key={entry.id} data-console-entry={entry.id}><ReplTranscriptEntry entry={entry} definitionHint={t.replDefinitionHint ?? 'Definitions belong in the file.'} t={t} onVisualize={workspaceCapabilities.visualization && entry.result && !entry.result.unparsed && !entry.stale ? () => visualizeReplEntry(entry.id) : undefined} busy={isBusy} /></pre>)}</>}</div>{workspaceCapabilities.repl && <form className="repl-line" onSubmit={(event) => { event.preventDefault(); submitRepl() }}><span className="repl-prompt" aria-hidden="true">&gt;&gt;&gt;</span><input className="repl-input" value={replInput} onChange={(event) => { setReplInput(event.target.value); setReplHistoryIndex(null) }} onKeyDown={handleReplKeyDown} aria-label={t.replInputLabel ?? 'Python input'} spellCheck={false} autoComplete="off" autoCapitalize="off" /></form>}</section>}</>
const consoleNode = <>{!hideOutputPanel && <section className="output-panel"><div className="output-heading"><div className="output-title"><SquareTerminal size={16} /><span>{t.output}</span>{runtimeState !== 'ready' && <span className={`runtime-badge${runtimeState === 'failed' ? ' runtime-badge--failed' : ''}`}><span className="pulse" /> {t.pyodideRuntime}</span>}</div>{Object.keys(project.files).length > 1 && <span className="output-hint">{t.runsFile(project.mainFile)}</span>}</div><div className="console" ref={outputRef} role="log" onScroll={(event) => { const element = event.currentTarget; consoleAtBottomRef.current = element.scrollHeight - element.scrollTop - element.clientHeight < 24 }}>{isVisualizing ? <pre>{isAtErrorStep ? <>{visualStdout && !visualStdout.endsWith('\n') ? `${visualStdout}\n` : visualStdout}<span className="output-error">{visualTrace?.error}</span></> : visualStdout || t.noOutput}</pre> : <><pre>{output}<ErrorHintNote hint={outputErrorHint} t={t} /></pre>{consoleEntries.map((entry) => entry.kind === 'tests' ? <div key={entry.id} data-console-entry={entry.id}><TestReportView report={entry.report} t={t} onVisualizeExample={workspaceCapabilities.visualization ? visualizeExample : null} /></div> : entry.kind === 'note' ? <p key={entry.id} className="console-note" role="note" data-console-entry={entry.id}>{entry.text}</p> : <pre key={entry.id} data-console-entry={entry.id}><ReplTranscriptEntry entry={entry} definitionHint={t.replDefinitionHint ?? 'Definitions belong in the file.'} t={t} onVisualize={workspaceCapabilities.visualization && entry.result && !entry.result.unparsed && !entry.result.untraced && !entry.stale ? () => visualizeReplEntry(entry.id) : undefined} busy={isBusy} /></pre>)}</>}</div>{workspaceCapabilities.repl && <form className="repl-line" onSubmit={(event) => { event.preventDefault(); submitRepl() }}><span className="repl-prompt" aria-hidden="true">&gt;&gt;&gt;</span><input className="repl-input" value={replInput} onChange={(event) => { setReplInput(event.target.value); setReplHistoryIndex(null) }} onKeyDown={handleReplKeyDown} aria-label={t.replInputLabel ?? 'Python input'} spellCheck={false} autoComplete="off" autoCapitalize="off" /></form>}</section>}</>
// 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.
Expand Down
Loading
Loading