From b5e50a51732e2dfc6e202d2f54a5c64e15b45e47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Vogel?= Date: Mon, 26 Oct 2026 15:13:41 +0100 Subject: [PATCH] =?UTF-8?q?REPL:=20no=20echo=20for=20None,=20as=20in=20CPy?= =?UTF-8?q?thon;=20no=20=C2=ABVisualisieren=C2=BB=20for=20help/dir?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CPython's interactive REPL never displays a None result (sys.displayhook skips it): help(f), a call of a function without return and print(...) show only what they print. glpython showed a trailing greyed-out None. _glpython_repl now reports no value for None (the visualizer's trace of a REPL input likewise, so the replay check still compares like with like). A call of the builtin help or dir only prints documentation or names, so its entry has no «Visualisieren» ("untraced"); a function of the learner's called help still has one. check-runtime-repl.py compares the REPL with `python3 -i` on the same file, input by input: help(f), a function without return, print(1), None, a value, an assignment, a list containing None. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +- package.json | 2 +- scripts/check-runtime-repl.py | 107 ++++++++++++++++++++++++++++++++++ src/GlPythonWorkspace.tsx | 12 ++-- src/runtime/session.py | 27 +++++++-- src/runtime/visualizer.py | 4 +- src/style.css | 1 - 7 files changed, 141 insertions(+), 16 deletions(-) create mode 100644 scripts/check-runtime-repl.py diff --git a/README.md b/README.md index 411c4db..40e8d4b 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/package.json b/package.json index cde83fd..91fb72a 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/scripts/check-runtime-repl.py b/scripts/check-runtime-repl.py new file mode 100644 index 0000000..6b798ca --- /dev/null +++ b/scripts/check-runtime-repl.py @@ -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"), 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.") diff --git a/src/GlPythonWorkspace.tsx b/src/GlPythonWorkspace.tsx index dd275b5..2bdfed5 100644 --- a/src/GlPythonWorkspace.tsx +++ b/src/GlPythonWorkspace.tsx @@ -282,7 +282,7 @@ function storedVisualizerMode(): VisualizerMode { 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 @@ -888,7 +888,7 @@ function ReplTranscriptEntry({ entry, definitionHint, t, onVisualize, busy }: { {'>>> '}{entry.source}{onVisualize && } {printed && {printed}} - {result?.value !== undefined && {result.value}} + {result?.value !== undefined && {result.value}} {result?.error && {result.error}} {result?.hint === 'definition' && {definitionHint}} @@ -1019,7 +1019,7 @@ function GlPythonWorkspace(props: GlPythonWorkspaceProps) { // 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 } & Partial>) => { + worker.onmessage = (event: MessageEvent } & Partial>) => { 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') } @@ -1044,8 +1044,8 @@ function GlPythonWorkspace(props: GlPythonWorkspaceProps) { 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) @@ -2033,7 +2033,7 @@ function GlPythonWorkspace(props: GlPythonWorkspaceProps) { 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} 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' }} />}
- 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} />
)}}
{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" />
}
} // 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. diff --git a/src/runtime/session.py b/src/runtime/session.py index 65bcc5c..6baa46c 100644 --- a/src/runtime/session.py +++ b/src/runtime/session.py @@ -115,12 +115,14 @@ def _glpython_graphics_json(): return json.dumps(gturtle.commands) # Runs one REPL input in the namespace of the last Run (or in `namespace`, -# see the replay below). A single expression reports its value - including -# None, which the UI shows on purpose (every call evaluates to a value) - -# while statements report nothing. Definitions are allowed, but flagged so +# see the replay below). A single expression reports its value, except None, +# which CPython's REPL doesn't show either (sys.displayhook skips it: a call +# of a function without return, print(...), help(...) show only what they +# print); statements report nothing. Definitions are allowed, but flagged so # the UI can point out that they belong in the file. Errors report only the # traceback's last line; an input that doesn't compile is flagged, as it has -# nothing to visualize. +# nothing to visualize - and so is a call of help or dir, which only prints +# documentation or names. def _glpython_repl(source, namespace=None): import ast if namespace is None: @@ -135,7 +137,11 @@ def _glpython_repl(source, namespace=None): return json.dumps({"error": _glpython_error_message(error), "errorHint": _glpython_error_hint(error), "unparsed": True}) try: if mode == "eval": - return json.dumps(_glpython_repl_value(eval(code, namespace))) + value = eval(code, namespace) + result = {"hint": None} if value is None else _glpython_repl_value(value) + if _glpython_repl_is_lookup(tree.body[0].value, namespace): + result["untraced"] = True + return json.dumps(result) exec(code, namespace) defines = any(isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)) for node in tree.body) return json.dumps({"hint": "definition" if defines else None}) @@ -146,7 +152,16 @@ def _glpython_repl_value(value): text = repr(value) if len(text) > _GLPYTHON_REPL_MAX_REPR: text = text[:_GLPYTHON_REPL_MAX_REPR] + "…" - return {"value": text, "isNone": value is None} + return {"value": text} + +# Whether an expression is a call of the builtin help or dir (not a function +# of the learner's that happens to have that name). +def _glpython_repl_is_lookup(expression, namespace): + import ast + if not (isinstance(expression, ast.Call) and isinstance(expression.func, ast.Name) and expression.func.id in ("help", "dir")): + return False + builtin = getattr(builtins, expression.func.id, None) + return builtin is not None and namespace.get(expression.func.id, builtin) is builtin # Visualizing an earlier REPL input replays the session up to it instead of # storing Python's state: the worker runs the file as it was at the Run diff --git a/src/runtime/visualizer.py b/src/runtime/visualizer.py index 03b97b2..7c01d48 100644 --- a/src/runtime/visualizer.py +++ b/src/runtime/visualizer.py @@ -1545,7 +1545,9 @@ def _glpython_trace_program(main_path, sources, fill_scope, report_value=False): if main_tree is None: raise SyntaxError(f"could not parse {main_path}") if report_value and len(main_tree.body) == 1 and isinstance(main_tree.body[0], ast.Expr): - value = _glpython_repl_value(_glpython_exec_stmt(main_tree.body[0], _glpython_module_scope)) + # As the REPL (_glpython_repl): no value for None. + result = _glpython_exec_stmt(main_tree.body[0], _glpython_module_scope) + value = None if result is None else _glpython_repl_value(result) else: _glpython_exec_stmts(main_tree.body, _glpython_module_scope) except _GlpythonTraceLimit: diff --git a/src/style.css b/src/style.css index 420baf8..9088f3e 100644 --- a/src/style.css +++ b/src/style.css @@ -247,7 +247,6 @@ .repl-entry-visualize:disabled { color: var(--glp-text-muted); cursor: not-allowed; } .repl-note { color: var(--glp-text-muted); font: italic 13px/1.5 var(--glp-font-ui); white-space: normal; } .repl-value { color: var(--glp-text); } -.repl-value--none { color: var(--glp-text-muted); } .repl-hint { color: var(--glp-warning); font-style: italic; } .repl-line { display: flex; align-items: center; gap: 8px; padding: 12px; background: var(--glp-surface); border-top: 1px solid var(--glp-border); }