From 43ebf26b72b26dbd118b399afdd6aa737164f835 Mon Sep 17 00:00:00 2001 From: winniederidder Date: Sat, 12 Mar 2022 21:57:48 +0100 Subject: [PATCH 01/12] Add first version of debuggin --- package.json | 1 + src/Backend.ts | 28 ++++++- src/CodeEditor.ts | 26 ++++++- src/Constants.ts | 1 + src/Papyros.ts | 17 ++++- src/RunStateManager.ts | 14 +++- src/Translations.js | 2 + src/extensions/Breakpoints.ts | 73 +++++++++++++++++++ .../javascript/JavaScriptWorker.worker.ts | 6 +- src/workers/python/PythonWorker.worker.ts | 20 ++++- tsconfig.json | 4 +- yarn.lock | 7 ++ 12 files changed, 183 insertions(+), 16 deletions(-) create mode 100644 src/extensions/Breakpoints.ts diff --git a/package.json b/package.json index 2f719068..6a964650 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "@codemirror/lang-python": "^0.19.2", "@codemirror/language": "^0.19.5", "@codemirror/panel": "^0.19.0", + "@codemirror/rangeset": "^0.19.9", "@codemirror/rectangular-selection": "^0.19.1", "@codemirror/search": "^0.19.3", "@codemirror/state": "^0.19.6", diff --git a/src/Backend.ts b/src/Backend.ts index 37356a86..0899dbaa 100644 --- a/src/Backend.ts +++ b/src/Backend.ts @@ -71,7 +71,7 @@ export abstract class Backend { * Results or Errors must be passed by using the onEvent-callback * @param code The code to run */ - protected abstract _runCodeInternal(code: string): Promise; + protected abstract runCodeInternal(code: string): Promise; /** * Executes the given code @@ -79,11 +79,33 @@ export abstract class Backend { * @param {string} runId The uuid for this execution * @return {Promise} Promise of execution */ - async runCode(code: string, runId: number): Promise { + async runCode(code: string, runId: number): Promise { this.runId = runId; - return await this._runCodeInternal(code); + return await this.runCodeInternal(code); } + /** + * Run a piece of code in debug mode, allowing the user to figure out + * why things do or do not work + * @param {string} code The code to debug + * @param {number} runId The internal identifier for this code run + * @param {Set} breakpoints The line numbers where the user put a breakpoint + * @return {Promise} Promise of debugging + */ + debugCode(code: string, runId: number, breakpoints: Set): Promise { + this.runId = runId; + return this.debugCodeInternal(code, breakpoints); + } + + /** + * Internal helper method that actually debugs the code + * Communication is done by using the onEvent-callback + * @param {string} code The code to debug + * @param {Set} breakpoints The line numbers where the user put a breakpoint + * @return {Promise} Promise of debugging + */ + protected abstract debugCodeInternal(code: string, breakpoints: Set): Promise; + /** * Converts the context to a cloneable object containing useful properties * to generate autocompletion suggestions with diff --git a/src/CodeEditor.ts b/src/CodeEditor.ts index 7bb3a89a..c01bac2f 100644 --- a/src/CodeEditor.ts +++ b/src/CodeEditor.ts @@ -27,7 +27,7 @@ import { defaultHighlightStyle } from "@codemirror/highlight"; import { lintKeymap } from "@codemirror/lint"; import { showPanel } from "@codemirror/panel"; import { RenderOptions, renderWithOptions } from "./util/Util"; - +import { breakpoints } from "./extensions/Breakpoints"; /** * Component that provides useful features to users writing code */ @@ -56,6 +56,10 @@ export class CodeEditor { * Compartment to configure the autocompletion at runtime */ autocompletionCompartment: Compartment = new Compartment(); + /** + * Indices of lines in the editor that have breakpoints + */ + readonly breakpointLines: Set; /** * Construct a new CodeEditor @@ -66,12 +70,15 @@ export class CodeEditor { */ constructor(language: ProgrammingLanguage, editorPlaceHolder: string, initialCode = "", indentLength = 4) { + this.breakpointLines = new Set(); this.editorView = new EditorView( { state: EditorState.create({ doc: initialCode, extensions: [ + breakpoints((lineNr: number, active: boolean) => + this.toggleBreakpoint(lineNr, active)), this.languageCompartment.of(CodeEditor.getLanguageSupport(language)), this.autocompletionCompartment.of( autocompletion() @@ -82,12 +89,25 @@ export class CodeEditor { keymap.of([indentWithTab]), this.placeholderCompartment.of(placeholder(editorPlaceHolder)), this.panelCompartment.of(showPanel.of(null)), - ...CodeEditor.getExtensions() + ...CodeEditor.getDefaultExtensions() ] }) }); } + /** + * Update the breakpoint status of the given line + * @param {number} lineNr The index of the line + * @param {boolean} active Whether the line has a breakpoint + */ + toggleBreakpoint(lineNr: number, active: boolean): void { + if (active) { + this.breakpointLines.add(lineNr); + } else { + this.breakpointLines.delete(lineNr); + } + } + /** * Render the editor with the given options and panel * @param {RenderOptions} options Options for rendering @@ -221,7 +241,7 @@ export class CodeEditor { * - [indenting with tab](#commands.indentWithTab) * @return {Array { + static getDefaultExtensions(): Array { return [ lineNumbers(), highlightActiveLineGutter(), diff --git a/src/Constants.ts b/src/Constants.ts index e02e4d99..e7f91ca1 100644 --- a/src/Constants.ts +++ b/src/Constants.ts @@ -20,6 +20,7 @@ export const STATE_SPINNER_ID = addPapyrosPrefix("state-spinner"); export const APPLICATION_STATE_TEXT_ID = addPapyrosPrefix("application-state-text"); export const RUN_BTN_ID = addPapyrosPrefix("run-code-btn"); export const STOP_BTN_ID = addPapyrosPrefix("stop-btn"); +export const DEBUG_BTN_ID = addPapyrosPrefix("debug-btn"); export const SEND_INPUT_BTN_ID = addPapyrosPrefix("send-input-btn"); export const SWITCH_INPUT_MODE_A_ID = addPapyrosPrefix("switch-input-mode"); export const EXAMPLE_SELECT_ID = addPapyrosPrefix("example-select"); diff --git a/src/Papyros.ts b/src/Papyros.ts index 341f91a3..41a7fc43 100644 --- a/src/Papyros.ts +++ b/src/Papyros.ts @@ -155,7 +155,7 @@ export class Papyros { runId: 0 }; this.outputManager = new OutputManager(); - this.stateManager = new RunStateManager(() => this.runCode(), () => this.stop()); + this.stateManager = new RunStateManager(() => this.runCode(false), () => this.stop(), () => this.runCode(true)); this.inputManager = new InputManager(() => this.stateManager.setState(RunState.Running), config.inputMode); this.addRunListener(this.inputManager); this.addRunListener(this.outputManager); @@ -327,9 +327,10 @@ export class Papyros { /** * Run the code that is currently present in the editor + * @param {boolean} debug Whether the run happens in debug mode * @return {Promise} Promise of running the code */ - async runCode(): Promise { + async runCode(debug: boolean): Promise { if (this.state !== RunState.Ready) { papyrosLog(LogType.Error, `Run code called from invalid state: ${this.state}`); return; @@ -342,7 +343,17 @@ export class Papyros { papyrosLog(LogType.Debug, "Running code in Papyros, sending to backend"); const start = new Date().getTime(); try { - await this.codeState.backend.runCode(this.getCode(), this.codeState.runId); + const { + backend, + runId, + editor + } = this.codeState; + if (debug) { + console.log("Breakpoints are: ", editor.breakpointLines); + await backend.debugCode(this.getCode(), runId, editor.breakpointLines); + } else { + await backend.runCode(this.getCode(), runId); + } } catch (error: any) { this.onError(error); } finally { diff --git a/src/RunStateManager.ts b/src/RunStateManager.ts index 61e3039b..81a3cc0e 100644 --- a/src/RunStateManager.ts +++ b/src/RunStateManager.ts @@ -1,4 +1,7 @@ -import { APPLICATION_STATE_TEXT_ID, RUN_BTN_ID, STATE_SPINNER_ID, STOP_BTN_ID } from "./Constants"; +import { + APPLICATION_STATE_TEXT_ID, RUN_BTN_ID, + STATE_SPINNER_ID, STOP_BTN_ID, DEBUG_BTN_ID +} from "./Constants"; import { svgCircle } from "./util/HTMLShapes"; import { addListener, ButtonOptions, renderButton, @@ -40,8 +43,10 @@ export class RunStateManager { * Construct a new RunStateManager with the given listeners * @param {function} onRunClicked Callback for when the run button is clicked * @param {function} onStopClicked Callback for when the stop button is clicked + * @param {function} onDebugClicked Callback for when the debug button is clicked */ - constructor(onRunClicked: () => void, onStopClicked: () => void) { + constructor(onRunClicked: () => void, onStopClicked: () => void, + onDebugClicked: () => void) { this.buttons = []; this.addButton({ id: RUN_BTN_ID, @@ -53,6 +58,11 @@ export class RunStateManager { buttonText: t("Papyros.stop"), extraClasses: "text-white bg-red-500" }, onStopClicked); + this.addButton({ + id: DEBUG_BTN_ID, + buttonText: t("Papyros.debug"), + extraClasses: "text-white bg-green-500" + }, onDebugClicked); this.state = RunState.Ready; } diff --git a/src/Translations.js b/src/Translations.js index 0349b93e..4ddb9050 100644 --- a/src/Translations.js +++ b/src/Translations.js @@ -17,6 +17,7 @@ const ENGLISH_TRANSLATION = { "output_placeholder": "The output of your code will appear here", "run": "Run", "stop": "Stop", + "debug": "Debug", "finished": "Code executed in %{time} s", "states": { "running": "Running", @@ -57,6 +58,7 @@ const DUTCH_TRANSLATION = { "output_placeholder": "Hier komt de uitvoer van je code", "run": "Run", "stop": "Stop", + "debug": "Debug", "states": { "running": "Aan het uitvoeren", "stopping": "Aan het stoppen", diff --git a/src/extensions/Breakpoints.ts b/src/extensions/Breakpoints.ts new file mode 100644 index 00000000..1c46053c --- /dev/null +++ b/src/extensions/Breakpoints.ts @@ -0,0 +1,73 @@ +import { RangeSet } from "@codemirror/rangeset"; +import { Extension, StateEffect, StateField } from "@codemirror/state"; +import { gutter, GutterMarker } from "@codemirror/gutter"; +import { + EditorView, +} + from "@codemirror/view"; +export function breakpoints(onToggle: (pos: number, value: boolean) => void): Extension { + const breakpointEffect = StateEffect.define<{ pos: number, on: boolean }>({ + map: (val, mapping) => ({ pos: mapping.mapPos(val.pos), on: val.on }) + }); + + const breakpointState = StateField.define>({ + create() { + return RangeSet.empty; + }, + update(set, transaction) { + let returnSet = set.map(transaction.changes); + for (const e of transaction.effects) { + if (e.is(breakpointEffect)) { + if (e.value.on) { + returnSet = returnSet.update( + { add: [breakpointMarker.range(e.value.pos)] } + ); + } else { + returnSet = returnSet.update({ filter: from => from != e.value.pos }); + } + } + } + return returnSet; + } + }); + + function toggleBreakpoint(view: EditorView, pos: number): void { + const breakpoints = view.state.field(breakpointState); + let hasBreakpoint = false; + breakpoints.between(pos, pos, () => { + hasBreakpoint = true; + }); + onToggle(pos, !hasBreakpoint); + view.dispatch({ + effects: breakpointEffect.of({ pos, on: !hasBreakpoint }) + }); + } + + const breakpointMarker = new class extends GutterMarker { + toDOM(): Text { + return document.createTextNode("🔴"); + } + }; + + return [ + breakpointState, + gutter({ + class: "cm-breakpoint-gutter", + markers: v => v.state.field(breakpointState), + initialSpacer: () => breakpointMarker, + domEventHandlers: { + mousedown(view, line) { + toggleBreakpoint(view, line.from); + return true; + } + } + }), + EditorView.baseTheme({ + ".cm-breakpoint-gutter .cm-gutterElement": { + color: "red", + paddingLeft: "5px", + cursor: "default" + } + }) + ]; +} diff --git a/src/workers/javascript/JavaScriptWorker.worker.ts b/src/workers/javascript/JavaScriptWorker.worker.ts index 0aa6c50a..0a1e2278 100644 --- a/src/workers/javascript/JavaScriptWorker.worker.ts +++ b/src/workers/javascript/JavaScriptWorker.worker.ts @@ -121,7 +121,7 @@ class JavaScriptWorker extends Backend { return ret; } - override _runCodeInternal(code: string): Promise { + override runCodeInternal(code: string): Promise { // Builtins to store before execution and restore afterwards // Workers do not have access to prompt const oldContent = { @@ -159,6 +159,10 @@ class JavaScriptWorker extends Backend { )(oldContent); } } + + protected debugCodeInternal(code: string): Promise { + return this.runCodeInternal(code); + } } // Default export to be recognized as a TS module diff --git a/src/workers/python/PythonWorker.worker.ts b/src/workers/python/PythonWorker.worker.ts index 7e9afbfc..3fa6dff4 100644 --- a/src/workers/python/PythonWorker.worker.ts +++ b/src/workers/python/PythonWorker.worker.ts @@ -46,7 +46,7 @@ class PythonWorker extends Backend { fullStdLib: false }); // Load our own modules to connect Papyros and Pyodide - await this._runCodeInternal(initPythonString); + await this.runCodeInternal(initPythonString); // Python calls our function with a PyProxy dict or a Js Map, // These must be converted to a PapyrosEvent (JS Object) to allow message passing const eventCallback = (data: any): void => { @@ -57,7 +57,7 @@ class PythonWorker extends Backend { this.initialized = true; } - override async _runCodeInternal(code: string): Promise { + override async runCodeInternal(code: string): Promise { if (this.initialized) { try { // Sometimes a SyntaxError can cause imports to fail @@ -74,6 +74,22 @@ class PythonWorker extends Backend { } } + protected debugCodeInternal(code: string, breakpoints: Set): Promise { + if (breakpoints.size === 0) { + return this.runCodeInternal(code); + } + const pdbCode = code.split("\n").reduce( + (acc: Array, current: string, currentIndex: number) => { + if (breakpoints.has(currentIndex)) { + // extra lines for PDB to know when to halt + acc.push("breakpoint()"); + } + acc.push(current); + return acc; + }, []).join("\n"); + return this.runCodeInternal(pdbCode); + } + override async autocomplete(context: WorkerAutocompleteContext): Promise { // Do not await as not strictly required to compute autocompletions diff --git a/tsconfig.json b/tsconfig.json index 91dacf7f..ef22bc89 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "target": "es5", + "target": "es6", "lib": [ "dom", "dom.iterable", @@ -14,7 +14,7 @@ "strict": true, "forceConsistentCasingInFileNames": true, "noFallthroughCasesInSwitch": true, - "module": "esnext", + "module": "es6", "moduleResolution": "node", "resolveJsonModule": true, "noEmit": false, diff --git a/yarn.lock b/yarn.lock index 2ca00d5c..4b8a4e57 100644 --- a/yarn.lock +++ b/yarn.lock @@ -544,6 +544,13 @@ dependencies: "@codemirror/state" "^0.19.0" +"@codemirror/rangeset@^0.19.9": + version "0.19.9" + resolved "https://registry.yarnpkg.com/@codemirror/rangeset/-/rangeset-0.19.9.tgz#e80895de93c39dc7899f5be31d368c9d88aa4efc" + integrity sha512-V8YUuOvK+ew87Xem+71nKcqu1SXd5QROMRLMS/ljT5/3MCxtgrRie1Cvild0G/Z2f1fpWxzX78V0U4jjXBorBQ== + dependencies: + "@codemirror/state" "^0.19.0" + "@codemirror/rectangular-selection@^0.19.1": version "0.19.1" resolved "https://registry.yarnpkg.com/@codemirror/rectangular-selection/-/rectangular-selection-0.19.1.tgz#5a88ece4fb68ce5682539497db8a64fc015aae63" From 7ccd0d00f8e5e73a9772f4a5bd6370ba0266b0f3 Mon Sep 17 00:00:00 2001 From: winniederidder Date: Sat, 12 Mar 2022 22:40:20 +0100 Subject: [PATCH 02/12] Also disable/enable debug button --- src/Papyros.ts | 4 ++-- src/RunStateManager.ts | 9 +++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/Papyros.ts b/src/Papyros.ts index 41a7fc43..047fe5e8 100644 --- a/src/Papyros.ts +++ b/src/Papyros.ts @@ -235,13 +235,13 @@ export class Papyros { const programmingLanguage = this.codeState.programmingLanguage; this.stateManager.setState(RunState.Loading); const backend = startBackend(programmingLanguage); - // Allow passing messages between worker and main thread - await backend.launch(proxy(e => this.onMessage(e)), this.inputManager.channel); this.codeState.backend = backend; this.codeState.editor.setLanguage(programmingLanguage, async context => await this.codeState.backend.autocomplete(Backend.convertCompletionContext(context)), t("Papyros.code_placeholder", { programmingLanguage }) ); + // Allow passing messages between worker and main thread + await backend.launch(proxy(e => this.onMessage(e)), this.inputManager.channel); this.stateManager.setState(RunState.Ready); } diff --git a/src/RunStateManager.ts b/src/RunStateManager.ts index 81a3cc0e..eeb9d820 100644 --- a/src/RunStateManager.ts +++ b/src/RunStateManager.ts @@ -73,6 +73,13 @@ export class RunStateManager { return getElement(RUN_BTN_ID); } + /** + * Get the button to debug the code + */ + get debugButton(): HTMLButtonElement { + return getElement(DEBUG_BTN_ID); + } + /** * Get the button to interrupt the code */ @@ -100,9 +107,11 @@ export class RunStateManager { if (state === RunState.Ready) { this.showSpinner(false); this.runButton.disabled = false; + this.debugButton.disabled = false; } else { this.showSpinner(true); this.runButton.disabled = true; + this.debugButton.disabled = true; } getElement(APPLICATION_STATE_TEXT_ID).innerText = message || t(`Papyros.states.${state}`); From 64f0e4bf67aee74c1fd471d975fdd2f455d7558c Mon Sep 17 00:00:00 2001 From: winniederidder Date: Sun, 13 Mar 2022 20:13:27 +0100 Subject: [PATCH 03/12] First step of refactoring code running --- src/CodeEditor.ts | 17 +++---- src/{RunStateManager.ts => CodeRunner.ts} | 57 ++++++++++++++++++++--- src/Library.ts | 4 +- src/Papyros.ts | 18 +++---- 4 files changed, 68 insertions(+), 28 deletions(-) rename src/{RunStateManager.ts => CodeRunner.ts} (71%) diff --git a/src/CodeEditor.ts b/src/CodeEditor.ts index c01bac2f..8e11b293 100644 --- a/src/CodeEditor.ts +++ b/src/CodeEditor.ts @@ -26,7 +26,7 @@ import { rectangularSelection } from "@codemirror/rectangular-selection"; import { defaultHighlightStyle } from "@codemirror/highlight"; import { lintKeymap } from "@codemirror/lint"; import { showPanel } from "@codemirror/panel"; -import { RenderOptions, renderWithOptions } from "./util/Util"; +import { RenderOptions, renderWithOptions, t } from "./util/Util"; import { breakpoints } from "./extensions/Breakpoints"; /** * Component that provides useful features to users writing code @@ -63,13 +63,10 @@ export class CodeEditor { /** * Construct a new CodeEditor - * @param {ProgrammingLanguage} language The used programming language - * @param {string} editorPlaceHolder The placeholder for the editor * @param {string} initialCode The initial code to display * @param {number} indentLength The length in spaces for the indent unit */ - constructor(language: ProgrammingLanguage, - editorPlaceHolder: string, initialCode = "", indentLength = 4) { + constructor(initialCode = "", indentLength = 4) { this.breakpointLines = new Set(); this.editorView = new EditorView( { @@ -79,7 +76,7 @@ export class CodeEditor { [ breakpoints((lineNr: number, active: boolean) => this.toggleBreakpoint(lineNr, active)), - this.languageCompartment.of(CodeEditor.getLanguageSupport(language)), + this.languageCompartment.of([]), this.autocompletionCompartment.of( autocompletion() ), @@ -87,7 +84,7 @@ export class CodeEditor { indentUnit.of(CodeEditor.getIndentUnit(indentLength)) ), keymap.of([indentWithTab]), - this.placeholderCompartment.of(placeholder(editorPlaceHolder)), + this.placeholderCompartment.of([]), this.panelCompartment.of(showPanel.of(null)), ...CodeEditor.getDefaultExtensions() ] @@ -130,15 +127,15 @@ export class CodeEditor { * @param {CompletionSource} completionSource Function to generate autocomplete results * @param {string} editorPlaceHolder Placeholder when empty */ - setLanguage(language: ProgrammingLanguage, completionSource: CompletionSource, - editorPlaceHolder: string): void { + setLanguage(language: ProgrammingLanguage, completionSource: CompletionSource): void { this.editorView.dispatch({ effects: [ this.languageCompartment.reconfigure(CodeEditor.getLanguageSupport(language)), this.autocompletionCompartment.reconfigure( autocompletion({ override: [completionSource] }) ), - this.placeholderCompartment.reconfigure(placeholder(editorPlaceHolder)) + this.placeholderCompartment.reconfigure(placeholder(t("Papyros.code_placeholder", + { programmingLanguage: language }))) ] }); } diff --git a/src/RunStateManager.ts b/src/CodeRunner.ts similarity index 71% rename from src/RunStateManager.ts rename to src/CodeRunner.ts index eeb9d820..3a3fd320 100644 --- a/src/RunStateManager.ts +++ b/src/CodeRunner.ts @@ -1,7 +1,11 @@ +import { Remote } from "comlink"; +import { Backend } from "./Backend"; +import { CodeEditor } from "./CodeEditor"; import { APPLICATION_STATE_TEXT_ID, RUN_BTN_ID, STATE_SPINNER_ID, STOP_BTN_ID, DEBUG_BTN_ID } from "./Constants"; +import { ProgrammingLanguage } from "./ProgrammingLanguage"; import { svgCircle } from "./util/HTMLShapes"; import { addListener, ButtonOptions, renderButton, @@ -26,10 +30,37 @@ export enum RunState { Ready = "ready" } +/** + * All listeners that should be configured externally + */ +interface ExternalClickListeners { + /* Callback for when the run button is clicked */ + onRunClicked: () => void; + /* onStopClicked Callback for when the stop button is clicked */ + onStopClicked: () => void; + /* onDebugClicked Callback for when the debug button is clicked */ + onDebugClicked: () => void; +} /** * Helper component to manage and visualize the current RunState */ -export class RunStateManager { +export class CodeRunner { + /** + * The currently used programming language + */ + programmingLanguage: ProgrammingLanguage; + /** + * The editor in which the code is written + */ + readonly editor: CodeEditor; + /** + * The backend that executes the code asynchronously + */ + backend: Remote; + /** + * The identifier for the current run + */ + runId: number; /** * Current state of the program */ @@ -41,31 +72,44 @@ export class RunStateManager { /** * Construct a new RunStateManager with the given listeners + * @param {ProgrammingLanguage} programmingLanguage The language to use + * @param {Object} clickListeners * @param {function} onRunClicked Callback for when the run button is clicked * @param {function} onStopClicked Callback for when the stop button is clicked * @param {function} onDebugClicked Callback for when the debug button is clicked */ - constructor(onRunClicked: () => void, onStopClicked: () => void, - onDebugClicked: () => void) { + constructor(programmingLanguage: ProgrammingLanguage, + clickListeners: ExternalClickListeners) { + this.programmingLanguage = programmingLanguage; + this.editor = new CodeEditor(); + this.backend = {} as Remote; + this.runId = 0; this.buttons = []; this.addButton({ id: RUN_BTN_ID, buttonText: t("Papyros.run"), extraClasses: "text-white bg-blue-500" - }, onRunClicked); + }, clickListeners.onRunClicked); this.addButton({ id: STOP_BTN_ID, buttonText: t("Papyros.stop"), extraClasses: "text-white bg-red-500" - }, onStopClicked); + }, clickListeners.onStopClicked); this.addButton({ id: DEBUG_BTN_ID, buttonText: t("Papyros.debug"), extraClasses: "text-white bg-green-500" - }, onDebugClicked); + }, clickListeners.onDebugClicked); this.state = RunState.Ready; } + async setProgrammingLanguage(programmingLanguage: ProgrammingLanguage): Promise { + this.editor.setLanguage(programmingLanguage, + async context => await this.backend.autocomplete( + Backend.convertCompletionContext(context) + )); + } + /** * Get the button to run the code */ @@ -95,7 +139,6 @@ export class RunStateManager { getElement(STATE_SPINNER_ID).style.display = show ? "" : "none"; } - /** * Show the current state of the program to the user * @param {RunState} state The current state of the run diff --git a/src/Library.ts b/src/Library.ts index aae23b6d..df093eb0 100644 --- a/src/Library.ts +++ b/src/Library.ts @@ -3,7 +3,7 @@ import { InputManager, InputMode } from "./InputManager"; import { OutputManager } from "./OutputManager"; import { Papyros } from "./Papyros"; import { PapyrosEvent } from "./PapyrosEvent"; -import { RunStateManager, RunState } from "./RunStateManager"; +import { CodeRunner, RunState } from "./CodeRunner"; import { InputWorker } from "./workers/input/InputWorker"; export * from "./ProgrammingLanguage"; @@ -12,7 +12,7 @@ export { Papyros, CodeEditor, RunState, - RunStateManager, + CodeRunner as RunStateManager, InputManager, InputMode, OutputManager, diff --git a/src/Papyros.ts b/src/Papyros.ts index 047fe5e8..70d26d60 100644 --- a/src/Papyros.ts +++ b/src/Papyros.ts @@ -19,7 +19,7 @@ import { RenderOptions, renderWithOptions, addListener, ButtonOptions, getElement } from "./util/Util"; -import { RunState, RunStateManager } from "./RunStateManager"; +import { RunState, CodeRunner } from "./CodeRunner"; import { getCodeForExample, getExampleNames } from "./examples/Examples"; import { OutputManager } from "./OutputManager"; import { makeChannel } from "sync-message"; @@ -111,7 +111,7 @@ export class Papyros { /** * Component to manage and visualize the state of the program */ - stateManager: RunStateManager; + stateManager: CodeRunner; /** * Component to request and handle input from the user */ @@ -148,14 +148,16 @@ export class Papyros { const { programmingLanguage } = this.config; this.codeState = { programmingLanguage: programmingLanguage, - editor: new CodeEditor( - programmingLanguage, - t("Papyros.code_placeholder", { programmingLanguage })), + editor: new CodeEditor(), backend: {} as Remote, runId: 0 }; this.outputManager = new OutputManager(); - this.stateManager = new RunStateManager(() => this.runCode(false), () => this.stop(), () => this.runCode(true)); + this.stateManager = new CodeRunner(programmingLanguage, { + onRunClicked: () => this.runCode(false), + onStopClicked: () => this.stop(), + onDebugClicked: () => this.runCode(true) + }); this.inputManager = new InputManager(() => this.stateManager.setState(RunState.Running), config.inputMode); this.addRunListener(this.inputManager); this.addRunListener(this.outputManager); @@ -237,9 +239,7 @@ export class Papyros { const backend = startBackend(programmingLanguage); this.codeState.backend = backend; this.codeState.editor.setLanguage(programmingLanguage, - async context => await this.codeState.backend.autocomplete(Backend.convertCompletionContext(context)), - t("Papyros.code_placeholder", { programmingLanguage }) - ); + async context => await this.codeState.backend.autocomplete(Backend.convertCompletionContext(context))); // Allow passing messages between worker and main thread await backend.launch(proxy(e => this.onMessage(e)), this.inputManager.channel); this.stateManager.setState(RunState.Ready); From a592efbfaca4d33a76c4128b605422ed30419390 Mon Sep 17 00:00:00 2001 From: winniederidder Date: Fri, 18 Mar 2022 13:36:18 +0100 Subject: [PATCH 04/12] Use pdb to set breakpoints --- src/extensions/Breakpoints.ts | 3 +- src/workers/python/PythonWorker.worker.ts | 18 +---- src/workers/python/init.py | 85 ++++++++++++++++++----- 3 files changed, 71 insertions(+), 35 deletions(-) diff --git a/src/extensions/Breakpoints.ts b/src/extensions/Breakpoints.ts index 1c46053c..2e7ddc71 100644 --- a/src/extensions/Breakpoints.ts +++ b/src/extensions/Breakpoints.ts @@ -37,7 +37,8 @@ export function breakpoints(onToggle: (pos: number, value: boolean) => void): Ex breakpoints.between(pos, pos, () => { hasBreakpoint = true; }); - onToggle(pos, !hasBreakpoint); + // Internal position uses 0-based indexing, but line numbers start at 1 + onToggle(pos+1, !hasBreakpoint); view.dispatch({ effects: breakpointEffect.of({ pos, on: !hasBreakpoint }) }); diff --git a/src/workers/python/PythonWorker.worker.ts b/src/workers/python/PythonWorker.worker.ts index 3fa6dff4..37be0472 100644 --- a/src/workers/python/PythonWorker.worker.ts +++ b/src/workers/python/PythonWorker.worker.ts @@ -53,7 +53,7 @@ class PythonWorker extends Backend { return this.onEvent(this.convert(data)); }; // Initialize our loaded Papyros module with the callback - this.pyodide.globals.get("init_papyros")(eventCallback); + await this.pyodide.globals.get("init_papyros")(eventCallback); this.initialized = true; } @@ -74,20 +74,8 @@ class PythonWorker extends Backend { } } - protected debugCodeInternal(code: string, breakpoints: Set): Promise { - if (breakpoints.size === 0) { - return this.runCodeInternal(code); - } - const pdbCode = code.split("\n").reduce( - (acc: Array, current: string, currentIndex: number) => { - if (breakpoints.has(currentIndex)) { - // extra lines for PDB to know when to halt - acc.push("breakpoint()"); - } - acc.push(current); - return acc; - }, []).join("\n"); - return this.runCodeInternal(pdbCode); + override async debugCodeInternal(code: string, breakpoints: Set): Promise { + return await this.pyodide.globals.get("debug_code")(code, breakpoints); } override async autocomplete(context: WorkerAutocompleteContext): diff --git a/src/workers/python/init.py b/src/workers/python/init.py index df5a8e54..f858c17a 100644 --- a/src/workers/python/init.py +++ b/src/workers/python/init.py @@ -1,6 +1,8 @@ +import builtins +import pdb +import os import sys import json -import os from collections.abc import Awaitable import micropip @@ -9,19 +11,50 @@ await micropip.install("python_runner") import python_runner +# Install modules asynchronously to use when the user needs them ft = micropip.install("friendly_traceback") jedi_install = micropip.install("jedi") -# Otherwise `import matplotlib` fails while assuming a browser backend -os.environ["MPLBACKEND"] = "AGG" -# Code is executed in a worker with less resources than ful environment -sys.setrecursionlimit(500) + # Global Papyros instance papyros = None class Papyros(python_runner.PatchedStdinRunner): + + def __init__( + self, + *, + callback=None, + limit=500 + ): + super().__init__(callback=callback) + self.limit = limit + self.debugger = None + self.debugging = False + self.override_globals() + + def set_file(self, filename, code): + self.filename = os.path.normcase(os.path.abspath(filename)) + with open(self.filename, "w") as f: + f.write(code) + + def override_globals(self): + # Code is executed in a worker with less resources than ful environment + sys.setrecursionlimit(self.limit) + # Otherwise `import matplotlib` fails while assuming a browser backend + os.environ["MPLBACKEND"] = "AGG" + sys.stdin.readline = self.readline + builtins.input = self.input + try: + import matplotlib + except ModuleNotFoundError: + pass + else: + # Only override matplotlib when required by the code + self.override_matplotlib() + def override_matplotlib(self): # workaround from https://github.com/pyodide/pyodide/issues/1518 import base64 @@ -39,6 +72,13 @@ def show(): matplotlib.pyplot.show = show + def pre_run(self, source_code, mode="exec", top_level_await=False): + self.override_globals() + return super().pre_run(source_code, mode=mode, top_level_await=top_level_await) + + def execute(self, code_obj, source_code, mode=None): # noqa + return eval(code_obj, self.console.locals) # noqa + async def run_async(self, source_code, mode="exec", top_level_await=True): """ Mostly a copy of the parent `run_async` with `await ft` in case of an exception, @@ -58,6 +98,21 @@ async def run_async(self, source_code, mode="exec", top_level_await=True): # Let `_execute_context` and `serialize_traceback` # handle the exception raise + + async def debug_code(self, source_code, breakpoints): + with self._execute_context(source_code): + code_obj = self.pre_run(source_code, "exec", True) + if not code_obj: + return + self.debugger = pdb.Pdb(stdin=sys.stdin, stdout=sys.stdout) + for line_nr in breakpoints: + self.debugger.set_break(filename=self.filename, lineno=line_nr) + self.line = "c\n" # Ensure first interrupt is continued until breakpoint + self.debugger.set_trace() + result = self.execute(code_obj, source_code, "exec") + while isinstance(result, Awaitable): + result = await result + return result def serialize_syntax_error(self, exc, source_code): raise # Rethrow to ensure FriendlyTraceback library is imported correctly @@ -105,7 +160,7 @@ def serialize_traceback(self, exc, source_code): ) -def init_papyros(event_callback): +async def init_papyros(event_callback): global papyros def runner_callback(event_type, data): @@ -133,22 +188,14 @@ def cb(typ, dat, **kwargs): papyros = Papyros(callback=runner_callback) - async def process_code(code, filename="my_code.py"): - with open(filename, "w") as f: - f.write(code) - papyros.filename = filename - - try: - import matplotlib - except ModuleNotFoundError: - pass - else: - # Only override matplotlib when required by the code - papyros.override_matplotlib() - + papyros.set_file(filename, code) await papyros.run_async(code) +async def debug_code(code, breakpoints, filename="my_code.py"): + papyros.set_file(filename, code) + await papyros.debug_code(code, breakpoints.to_py()) + def convert_completion(completion, index): converted = dict(type=completion.type, label=completion.name_with_symbols) # if completion.get_signatures(): From 70ddd1e5cf50a1bca35c833654b3d156d73f0020 Mon Sep 17 00:00:00 2001 From: winniederidder Date: Fri, 18 Mar 2022 22:23:11 +0100 Subject: [PATCH 05/12] Add first version of debuggin input handler --- scripts/ValidateTranslations.js | 3 +- src/Constants.ts | 5 +- src/InputManager.ts | 40 ++++++++--- src/Papyros.ts | 41 +++++------- src/Translations.js | 20 ++++-- src/input/DebuggingInputHandler.ts | 99 ++++++++++++++++++++++++++++ src/input/InteractiveInputHandler.ts | 2 +- src/util/Util.ts | 7 +- src/workers/python/init.py | 2 +- 9 files changed, 175 insertions(+), 44 deletions(-) create mode 100644 src/input/DebuggingInputHandler.ts diff --git a/scripts/ValidateTranslations.js b/scripts/ValidateTranslations.js index 0e6b3534..7f6c6164 100644 --- a/scripts/ValidateTranslations.js +++ b/scripts/ValidateTranslations.js @@ -35,7 +35,8 @@ const checks = [ "Papyros.locales.*", "Papyros.states.*", "Papyros.input_modes.switch_to_*", - "Papyros.input_placeholder.*" + "Papyros.input_placeholder.*", + "Papyros.debugging_command.*" ] } ]; diff --git a/src/Constants.ts b/src/Constants.ts index e7f91ca1..21fa3f13 100644 --- a/src/Constants.ts +++ b/src/Constants.ts @@ -8,7 +8,7 @@ import { ProgrammingLanguage } from "./ProgrammingLanguage"; function addPapyrosPrefix(s: string): string { return `__papyros-${s}`; } -/* Default HT%M ids for various elements */ +/* Default HTML ids for various elements */ export const MAIN_APP_ID = addPapyrosPrefix("papyros"); export const OUTPUT_TA_ID = addPapyrosPrefix("code-output-area"); export const INPUT_AREA_WRAPPER_ID = addPapyrosPrefix("code-input-area-wrapper"); @@ -18,11 +18,14 @@ export const EDITOR_WRAPPER_ID = addPapyrosPrefix("code-area"); export const PANEL_WRAPPER_ID = addPapyrosPrefix("code-status-panel"); export const STATE_SPINNER_ID = addPapyrosPrefix("state-spinner"); export const APPLICATION_STATE_TEXT_ID = addPapyrosPrefix("application-state-text"); + export const RUN_BTN_ID = addPapyrosPrefix("run-code-btn"); export const STOP_BTN_ID = addPapyrosPrefix("stop-btn"); export const DEBUG_BTN_ID = addPapyrosPrefix("debug-btn"); export const SEND_INPUT_BTN_ID = addPapyrosPrefix("send-input-btn"); export const SWITCH_INPUT_MODE_A_ID = addPapyrosPrefix("switch-input-mode"); +export const DEBUGGING_INTERACTIVE_WRAPPER_ID = addPapyrosPrefix("debugging-interactive-wrapper"); + export const EXAMPLE_SELECT_ID = addPapyrosPrefix("example-select"); export const LOCALE_SELECT_ID = addPapyrosPrefix("locale-select"); export const PROGRAMMING_LANGUAGE_SELECT_ID = addPapyrosPrefix("programming-language-select"); diff --git a/src/InputManager.ts b/src/InputManager.ts index 043bf8cd..734d0210 100644 --- a/src/InputManager.ts +++ b/src/InputManager.ts @@ -14,10 +14,13 @@ import { InteractiveInputHandler } from "./input/InteractiveInputHandler"; import { UserInputHandler } from "./input/UserInputHandler"; import { BatchInputHandler } from "./input/BatchInputHandler"; import { RunListener } from "./RunListener"; +import { DebuggingInputHandler } from "./input/DebuggingInputHandler"; +import { ProgrammingLanguage } from "./ProgrammingLanguage"; export enum InputMode { Interactive = "interactive", - Batch = "batch" + Batch = "batch", + Debugging = "debugging" } export const INPUT_MODES = [InputMode.Batch, InputMode.Interactive]; @@ -28,6 +31,7 @@ export interface InputData { } export class InputManager implements RunListener { + private previousInputMode: InputMode; private _inputMode: InputMode; private inputHandlers: Map; private renderOptions: RenderOptions; @@ -40,6 +44,7 @@ export class InputManager implements RunListener { constructor(onSend: () => void, inputMode: InputMode) { this._inputMode = inputMode; + this.previousInputMode = inputMode; this.channel = makeChannel()!; // by default we try to use Atomics this.onSend = onSend; this._waiting = false; @@ -53,9 +58,13 @@ export class InputManager implements RunListener { new InteractiveInputHandler(() => this.sendLine(), INPUT_TA_ID, SEND_INPUT_BTN_ID); const batchInputHandler: UserInputHandler = new BatchInputHandler(() => this.sendLine(), INPUT_TA_ID); + const debuggingInputHandler: UserInputHandler = + new DebuggingInputHandler(() => this.sendLine(), + INPUT_TA_ID, SEND_INPUT_BTN_ID, ProgrammingLanguage.Python); return new Map([ [InputMode.Interactive, interactiveInputHandler], - [InputMode.Batch, batchInputHandler] + [InputMode.Batch, batchInputHandler], + [InputMode.Debugging, debuggingInputHandler] ]); } @@ -76,17 +85,24 @@ export class InputManager implements RunListener { render(options: RenderOptions): void { this.renderOptions = options; - const otherMode = this.inputMode === InputMode.Interactive ? - InputMode.Batch : InputMode.Interactive; + let switchMode = ""; + if (this.inputMode !== InputMode.Debugging) { + const otherMode = this.inputMode === InputMode.Interactive ? + InputMode.Batch : InputMode.Interactive; + switchMode = ` + ${t(`Papyros.input_modes.switch_to_${otherMode}`)} + `; + } + renderWithOptions(options, `
- - ${t(`Papyros.input_modes.switch_to_${otherMode}`)} -`); - addListener(SWITCH_INPUT_MODE_A_ID, im => this.inputMode = im, - "click", "data-value"); +${switchMode}`); + if (this.inputMode !== InputMode.Debugging) { + addListener(SWITCH_INPUT_MODE_A_ID, im => this.inputMode = im, + "click", "data-value"); + } this.inputHandler.render({ parentElementId: USER_INPUT_WRAPPER_ID }); this.inputHandler.waitWithPrompt(this._waiting, this.prompt); } @@ -124,6 +140,7 @@ class="flex flex-row-reverse hover:cursor-pointer text-blue-500"> onRunStart(): void { this.waiting = false; + this.previousInputMode = this.inputMode; this.inputHandler.onRunStart(); } @@ -131,5 +148,8 @@ class="flex flex-row-reverse hover:cursor-pointer text-blue-500"> this.prompt = ""; this.inputHandler.onRunEnd(); this.waiting = false; + if (this.previousInputMode !== this.inputMode) { + this.inputMode = this.previousInputMode; + } } } diff --git a/src/Papyros.ts b/src/Papyros.ts index 70d26d60..e8b84aed 100644 --- a/src/Papyros.ts +++ b/src/Papyros.ts @@ -109,9 +109,9 @@ export class Papyros { */ config: PapyrosConfig; /** - * Component to manage and visualize the state of the program + * Component to run code entered by the user */ - stateManager: CodeRunner; + codeRunner: CodeRunner; /** * Component to request and handle input from the user */ @@ -153,12 +153,12 @@ export class Papyros { runId: 0 }; this.outputManager = new OutputManager(); - this.stateManager = new CodeRunner(programmingLanguage, { + this.codeRunner = new CodeRunner(programmingLanguage, { onRunClicked: () => this.runCode(false), onStopClicked: () => this.stop(), onDebugClicked: () => this.runCode(true) }); - this.inputManager = new InputManager(() => this.stateManager.setState(RunState.Running), config.inputMode); + this.inputManager = new InputManager(() => this.codeRunner.setState(RunState.Running), config.inputMode); this.addRunListener(this.inputManager); this.addRunListener(this.outputManager); } @@ -187,7 +187,7 @@ export class Papyros { * Getter for the current state of the program */ get state(): RunState { - return this.stateManager.state; + return this.codeRunner.state; } /** @@ -235,14 +235,14 @@ export class Papyros { */ private async startBackend(): Promise { const programmingLanguage = this.codeState.programmingLanguage; - this.stateManager.setState(RunState.Loading); + this.codeRunner.setState(RunState.Loading); const backend = startBackend(programmingLanguage); this.codeState.backend = backend; this.codeState.editor.setLanguage(programmingLanguage, async context => await this.codeState.backend.autocomplete(Backend.convertCompletionContext(context))); // Allow passing messages between worker and main thread await backend.launch(proxy(e => this.onMessage(e)), this.inputManager.channel); - this.stateManager.setState(RunState.Ready); + this.codeRunner.setState(RunState.Ready); } /** @@ -293,7 +293,6 @@ export class Papyros { * @param {PapyrosEvent} e The error-event */ private onError(e: PapyrosEvent): void { - papyrosLog(LogType.Debug, "Got error in Papyros: ", e); this.outputManager.showError(e); } /** @@ -301,8 +300,7 @@ export class Papyros { * @param {PapyrosEvent} e The input-event */ private async onInput(e: PapyrosEvent): Promise { - papyrosLog(LogType.Debug, "Received onInput event in Papyros: ", e); - this.stateManager.setState(RunState.AwaitingInput); + this.codeRunner.setState(RunState.AwaitingInput); await this.inputManager.onInput(e); } @@ -337,7 +335,7 @@ export class Papyros { } // Setup pre-run this.codeState.runId += 1; - this.stateManager.setState(RunState.Running); + this.codeRunner.setState(RunState.Running); this.notifyListeners(true); papyrosLog(LogType.Debug, "Running code in Papyros, sending to backend"); @@ -349,7 +347,7 @@ export class Papyros { editor } = this.codeState; if (debug) { - console.log("Breakpoints are: ", editor.breakpointLines); + this.inputManager.inputMode = InputMode.Debugging; await backend.debugCode(this.getCode(), runId, editor.breakpointLines); } else { await backend.runCode(this.getCode(), runId); @@ -358,7 +356,7 @@ export class Papyros { this.onError(error); } finally { const end = new Date().getTime(); - this.stateManager.setState(RunState.Ready, t("Papyros.finished", { time: (end - start) / 1000 })); + this.codeRunner.setState(RunState.Ready, t("Papyros.finished", { time: (end - start) / 1000 })); this.notifyListeners(false); } } @@ -368,13 +366,8 @@ export class Papyros { * @return {Promise} Promise of stopping */ async stop(): Promise { - if (![RunState.Running, RunState.AwaitingInput].includes(this.state)) { - papyrosLog(LogType.Error, `Stop called from invalid state: ${this.state}`); - return; - } - papyrosLog(LogType.Debug, "Stopping backend!"); this.codeState.runId += 1; // ignore messages coming from last run - this.stateManager.setState(RunState.Stopping); + this.codeRunner.setState(RunState.Stopping); this.notifyListeners(false); // Since we use workers, the old one must be entirely replaced to interrupt it stopBackend(this.codeState.backend); @@ -458,21 +451,21 @@ export class Papyros { removeSelection(EXAMPLE_SELECT_ID); // Modify search query params without reloading page history.pushState(null, "", `?locale=${I18n.locale}&language=${pl}`); - } + }, "change", "value" ); addListener(LOCALE_SELECT_ID, locale => { document.location.href = `?locale=${locale}&language=${this.codeState.programmingLanguage}`; - }); + }, "change", "value"); addListener(EXAMPLE_SELECT_ID, name => { const code = getCodeForExample(this.codeState.programmingLanguage, name); this.setCode(code); - }, "input"); + }, "change", "value"); // Ensure there is no initial selection removeSelection(EXAMPLE_SELECT_ID); } this.inputManager.render(renderOptions.inputOptions); - const runStatePanel = this.stateManager.render(renderOptions.statusPanelOptions); + const runStatePanel = this.codeRunner.render(renderOptions.statusPanelOptions); this.codeState.editor.render(renderOptions.codeEditorOptions, runStatePanel); this.outputManager.render(renderOptions.outputOptions); } @@ -483,7 +476,7 @@ export class Papyros { * @param {function} onClick Listener for click events on the button */ addButton(options: ButtonOptions, onClick: () => void): void { - this.stateManager.addButton(options, onClick); + this.codeRunner.addButton(options, onClick); } /** diff --git a/src/Translations.js b/src/Translations.js index 4ddb9050..0df1fa27 100644 --- a/src/Translations.js +++ b/src/Translations.js @@ -10,7 +10,8 @@ const ENGLISH_TRANSLATION = { "input_placeholder": { "interactive": "Provide input and press enter to send", "batch": "Provide all input required by your code here.\n" + - "You can enter multiple lines by pressing enter." + "You can enter multiple lines by pressing enter.", + "debugging": "Provide a command for the debugger here." }, "input_disabled": "You can only provide input when your code requires it in interactive mode", "output": "Output", @@ -40,7 +41,12 @@ const ENGLISH_TRANSLATION = { "switch_to_batch": "Switch to batch input" }, "enter": "Enter", - "examples": "Examples" + "examples": "Examples", + "debugging_command": { + "step_over": "Step over", + "step_into": "Step into", + "continue": "Continue" + } }; const DUTCH_TRANSLATION = { @@ -51,7 +57,8 @@ const DUTCH_TRANSLATION = { "input_placeholder": { "interactive": "Geef invoer in en druk op enter", "batch": "Geef hier alle invoer die je code nodig heeft vooraf in.\n" + - "Je kan verschillende lijnen ingeven door op enter te drukken." + "Je kan verschillende lijnen ingeven door op enter te drukken.", + "debugging": "Geef hier een commando in voor de debugger." }, "input_disabled": "Je kan enkel invoer invullen als je code erom vraagt in interactieve modus", "output": "Uitvoer", @@ -81,7 +88,12 @@ const DUTCH_TRANSLATION = { "switch_to_batch": "Geef invoer vooraf in" }, "enter": "Enter", - "examples": "Voorbeelden" + "examples": "Voorbeelden", + "debugging_command": { + "step_over": "Stap over", + "step_into": "Stap in", + "continue": "Ga verder" + } }; const TRANSLATIONS = { diff --git a/src/input/DebuggingInputHandler.ts b/src/input/DebuggingInputHandler.ts new file mode 100644 index 00000000..19f159cf --- /dev/null +++ b/src/input/DebuggingInputHandler.ts @@ -0,0 +1,99 @@ +import { DEBUGGING_INTERACTIVE_WRAPPER_ID } from "../Constants"; +import { InputMode } from "../InputManager"; +import { ProgrammingLanguage } from "../ProgrammingLanguage"; +import { + addListener, + renderButton, RenderOptions, renderWithOptions, t +} from "../util/Util"; +import { InteractiveInputHandler } from "./InteractiveInputHandler"; + +export enum DebuggingCommand { + StepOver = "step_over", + StepInto = "step_into", + Continue = "continue" +} +const DEBUGGING_COMMANDS = [ + DebuggingCommand.StepOver, DebuggingCommand.StepInto, DebuggingCommand.Continue +]; + +export class DebuggingInputHandler extends InteractiveInputHandler { + protected programmingLanguage: ProgrammingLanguage; + private commandMap: Map>; + private command: DebuggingCommand | undefined; + + getInputMode(): InputMode { + return InputMode.Debugging; + } + /** + * Construct a new InteractiveInputHandler + * @param {function()} onInput Callback for when the user has entered a value + * @param {string} inputAreaId HTML identifier for the used HTML input field + * @param {string} sendButtonId HTML identifier for the used HTML button + * @param {ProgrammingLanguage} programmingLanguage The currently used language + */ + constructor(onInput: () => void, inputAreaId: string, + sendButtonId: string, programmingLanguage: ProgrammingLanguage) { + super(onInput, inputAreaId, sendButtonId); + this.programmingLanguage = programmingLanguage; + this.commandMap = DebuggingInputHandler.buildCommandMap(); + this.command = undefined; + } + + static buildCommandMap(): Map> { + const commandMap = new Map(); + commandMap.set(ProgrammingLanguage.Python, new Map([ + [DebuggingCommand.StepInto, "s\n"], + [DebuggingCommand.StepOver, "n\n"], + [DebuggingCommand.Continue, "c\n"] + ])); + commandMap.set(ProgrammingLanguage.JavaScript, new Map()); + return commandMap; + } + + hasNext(): boolean { + return this.command !== undefined || this.inputArea.value !== ""; + } + + next(): string { + let nextValue = ""; + if (this.command) { + const m = this.commandMap.get(this.programmingLanguage) || new Map(); + nextValue = m.get(this.command) || ""; + } else { + nextValue = this.inputArea.value; + } + this.reset(); + return nextValue; + } + + protected reset(): void { + if (this.command !== undefined) { + this.command = undefined; + } else { + super.reset(); + } + } + + render(options: RenderOptions): HTMLElement { + const buttons = DEBUGGING_COMMANDS.map(command => { + return renderButton({ + id: command, + buttonText: t(`Papyros.debugging_command.${command}`) + }); + }).join("\n"); + const rendered = renderWithOptions(options, ` +
+ ${buttons} +
+
+
`); + super.render({ parentElementId: DEBUGGING_INTERACTIVE_WRAPPER_ID }); + DEBUGGING_COMMANDS.forEach(command => { + addListener(command, () => { + this.command = command; + this.onInput(); + }, "click"); + }); + return rendered; + } +} diff --git a/src/input/InteractiveInputHandler.ts b/src/input/InteractiveInputHandler.ts index 988e7353..96ed76a1 100644 --- a/src/input/InteractiveInputHandler.ts +++ b/src/input/InteractiveInputHandler.ts @@ -6,7 +6,7 @@ export class InteractiveInputHandler extends UserInputHandler { /** * HTML identifier for the used HTML button */ - private sendButtonId: string; + protected sendButtonId: string; /** * Construct a new InteractiveInputHandler * @param {function()} onInput Callback for when the user has entered a value diff --git a/src/util/Util.ts b/src/util/Util.ts index f44b965d..a66b7fd4 100644 --- a/src/util/Util.ts +++ b/src/util/Util.ts @@ -115,11 +115,14 @@ type ElementIdentifier = string | HTMLElement; * @param {string} attribute The attribute affected by the event */ export function addListener( - elementId: ElementIdentifier, onEvent: (e: T) => void, eventType = "change", attribute = "value" + elementId: ElementIdentifier, onEvent: (e: T) => void, eventType: string, attribute?: string ): void { const element = getElement(elementId); element.addEventListener(eventType, () => { - onEvent((element as any)[attribute] || element.getAttribute(attribute) as T); + const value = attribute ? + ((element as any)[attribute] || element.getAttribute(attribute) as T) : + ""; + onEvent(value); }); } diff --git a/src/workers/python/init.py b/src/workers/python/init.py index f858c17a..9087f5dd 100644 --- a/src/workers/python/init.py +++ b/src/workers/python/init.py @@ -104,7 +104,7 @@ async def debug_code(self, source_code, breakpoints): code_obj = self.pre_run(source_code, "exec", True) if not code_obj: return - self.debugger = pdb.Pdb(stdin=sys.stdin, stdout=sys.stdout) + self.debugger = pdb.Pdb() for line_nr in breakpoints: self.debugger.set_break(filename=self.filename, lineno=line_nr) self.line = "c\n" # Ensure first interrupt is continued until breakpoint From 55225e5990305d8ca84776874a568025f85e956a Mon Sep 17 00:00:00 2001 From: winniederidder Date: Sat, 19 Mar 2022 00:23:53 +0100 Subject: [PATCH 06/12] Refactor and docs --- src/Backend.ts | 1 + src/InputManager.ts | 22 ++-- src/Papyros.ts | 7 +- src/input/BatchInputHandler.ts | 24 ++--- src/input/DebuggingInputHandler.ts | 117 ++++++++++++++-------- src/input/InteractiveInputHandler.ts | 49 ++++----- src/input/PdbInputHandler.ts | 21 ++++ src/input/UserInputHandler.ts | 34 ++++--- src/workers/python/PythonWorker.worker.ts | 2 + 9 files changed, 162 insertions(+), 115 deletions(-) create mode 100644 src/input/PdbInputHandler.ts diff --git a/src/Backend.ts b/src/Backend.ts index 0899dbaa..219cbbca 100644 --- a/src/Backend.ts +++ b/src/Backend.ts @@ -3,6 +3,7 @@ import { Channel, readMessage, uuidv4 } from "sync-message"; import { parseData } from "./util/Util"; import { CompletionContext, CompletionResult } from "@codemirror/autocomplete"; import { LogType, papyrosLog } from "./util/Logging"; +import { DebuggingInputHandler } from "./input/DebuggingInputHandler"; export interface WorkerAutocompleteContext { explicit: boolean; diff --git a/src/InputManager.ts b/src/InputManager.ts index 734d0210..0e953d64 100644 --- a/src/InputManager.ts +++ b/src/InputManager.ts @@ -1,7 +1,7 @@ import { t } from "i18n-js"; import { SWITCH_INPUT_MODE_A_ID, - INPUT_TA_ID, SEND_INPUT_BTN_ID, USER_INPUT_WRAPPER_ID + USER_INPUT_WRAPPER_ID } from "./Constants"; import { PapyrosEvent } from "./PapyrosEvent"; import { papyrosLog, LogType } from "./util/Logging"; @@ -14,8 +14,7 @@ import { InteractiveInputHandler } from "./input/InteractiveInputHandler"; import { UserInputHandler } from "./input/UserInputHandler"; import { BatchInputHandler } from "./input/BatchInputHandler"; import { RunListener } from "./RunListener"; -import { DebuggingInputHandler } from "./input/DebuggingInputHandler"; -import { ProgrammingLanguage } from "./ProgrammingLanguage"; +import { PdbInputHandler } from "./input/PdbInputHandler"; export enum InputMode { Interactive = "interactive", @@ -43,24 +42,24 @@ export class InputManager implements RunListener { messageId = ""; constructor(onSend: () => void, inputMode: InputMode) { + this.inputHandlers = this.buildInputHandlerMap(); this._inputMode = inputMode; + this.inputHandler.addInputListener(this); this.previousInputMode = inputMode; this.channel = makeChannel()!; // by default we try to use Atomics this.onSend = onSend; this._waiting = false; this.prompt = ""; - this.inputHandlers = this.buildInputHandlerMap(); this.renderOptions = {} as RenderOptions; } private buildInputHandlerMap(): Map { const interactiveInputHandler: UserInputHandler = - new InteractiveInputHandler(() => this.sendLine(), INPUT_TA_ID, SEND_INPUT_BTN_ID); + new InteractiveInputHandler(); const batchInputHandler: UserInputHandler = - new BatchInputHandler(() => this.sendLine(), INPUT_TA_ID); + new BatchInputHandler(); const debuggingInputHandler: UserInputHandler = - new DebuggingInputHandler(() => this.sendLine(), - INPUT_TA_ID, SEND_INPUT_BTN_ID, ProgrammingLanguage.Python); + new PdbInputHandler(); return new Map([ [InputMode.Interactive, interactiveInputHandler], [InputMode.Batch, batchInputHandler], @@ -77,6 +76,7 @@ export class InputManager implements RunListener { this._inputMode = inputMode; this.render(this.renderOptions); this.inputHandler.onToggle(true); + this.inputHandler.addInputListener(this); } get inputHandler(): UserInputHandler { @@ -112,7 +112,7 @@ ${switchMode}`); this.inputHandler.waitWithPrompt(waiting, this.prompt); } - async sendLine(): Promise { + async onUserInput(): Promise { if (this.inputHandler.hasNext()) { const line = this.inputHandler.next(); papyrosLog(LogType.Debug, "Sending input to user: " + line); @@ -130,12 +130,12 @@ ${switchMode}`); * @param {PapyrosEvent} e Event containing the input data * @return {Promise} Promise of handling the request */ - async onInput(e: PapyrosEvent): Promise { + async onInputRequest(e: PapyrosEvent): Promise { papyrosLog(LogType.Debug, "Handling input request in Papyros"); const data = parseData(e.data, e.contentType) as InputData; this.messageId = data.messageId; this.prompt = data.prompt; - return this.sendLine(); + return this.onUserInput(); } onRunStart(): void { diff --git a/src/Papyros.ts b/src/Papyros.ts index e8b84aed..69156ca3 100644 --- a/src/Papyros.ts +++ b/src/Papyros.ts @@ -239,7 +239,10 @@ export class Papyros { const backend = startBackend(programmingLanguage); this.codeState.backend = backend; this.codeState.editor.setLanguage(programmingLanguage, - async context => await this.codeState.backend.autocomplete(Backend.convertCompletionContext(context))); + async context => { + const completionContext = Backend.convertCompletionContext(context); + return await this.codeState.backend.autocomplete(completionContext); + }); // Allow passing messages between worker and main thread await backend.launch(proxy(e => this.onMessage(e)), this.inputManager.channel); this.codeRunner.setState(RunState.Ready); @@ -301,7 +304,7 @@ export class Papyros { */ private async onInput(e: PapyrosEvent): Promise { this.codeRunner.setState(RunState.AwaitingInput); - await this.inputManager.onInput(e); + await this.inputManager.onInputRequest(e); } /** diff --git a/src/input/BatchInputHandler.ts b/src/input/BatchInputHandler.ts index ba6c9958..194dd91a 100644 --- a/src/input/BatchInputHandler.ts +++ b/src/input/BatchInputHandler.ts @@ -1,3 +1,4 @@ +import { INPUT_TA_ID } from "../Constants"; import { InputMode } from "../InputManager"; import { RenderOptions, renderWithOptions } from "../util/Util"; import { UserInputHandler } from "./UserInputHandler"; @@ -16,15 +17,14 @@ export class BatchInputHandler extends UserInputHandler { /** * Construct a new BatchInputHandler * @param {function()} onInput Callback for when the user has entered a value - * @param {string} inputAreaId HTML identifier for the used HTML input field */ - constructor(onInput: () => void, inputAreaId: string) { - super(onInput, inputAreaId); + constructor() { + super(); this.lineNr = 0; this.previousInput = ""; } - onToggle(active: boolean): void { + override onToggle(active: boolean): void { if (active) { this.inputArea.value = this.previousInput; } else { @@ -32,14 +32,14 @@ export class BatchInputHandler extends UserInputHandler { } } - getInputMode(): InputMode { + override getInputMode(): InputMode { return InputMode.Batch; } /** * Retrieve the lines of input that the user has given so far * @return {Array} The entered lines */ - private get lines(): Array { + protected get lines(): Array { const l = this.inputArea.value.split("\n"); if (!l[l.length - 1]) { // last line is empty l.splice(l.length - 1); // do not consider it valid input @@ -47,27 +47,27 @@ export class BatchInputHandler extends UserInputHandler { return l; } - hasNext(): boolean { + override hasNext(): boolean { return this.lineNr < this.lines.length; } - next(): string { + override next(): string { const nextLine = this.lines[this.lineNr]; this.lineNr += 1; return nextLine; } - onRunStart(): void { + override onRunStart(): void { this.lineNr = 0; } - onRunEnd(): void { + override onRunEnd(): void { // Intentionally empty } render(options: RenderOptions): HTMLElement { const rendered = renderWithOptions(options, ` -`); @@ -77,7 +77,7 @@ focus:outline-none focus:ring-1 focus:ring-blue-500" rows="5"> if (this.lines.length < this.lineNr) { this.lineNr = this.lines.length - 1; } - this.onInput(); + this.onUserInput(); } }); return rendered; diff --git a/src/input/DebuggingInputHandler.ts b/src/input/DebuggingInputHandler.ts index 19f159cf..b9fb7959 100644 --- a/src/input/DebuggingInputHandler.ts +++ b/src/input/DebuggingInputHandler.ts @@ -1,76 +1,107 @@ import { DEBUGGING_INTERACTIVE_WRAPPER_ID } from "../Constants"; import { InputMode } from "../InputManager"; -import { ProgrammingLanguage } from "../ProgrammingLanguage"; import { addListener, renderButton, RenderOptions, renderWithOptions, t } from "../util/Util"; import { InteractiveInputHandler } from "./InteractiveInputHandler"; +/** + * Enum representing commands the user can give during an interactive debugging session + */ export enum DebuggingCommand { + /** + * The step over command runs code until the next code line in the source file is reached. + * With next line, we mean the actual source code line following the current line + * This will not follow function calls or return to callers. + */ StepOver = "step_over", + /** + * The step into command runs until another line in the source file is executed. + * This command will e.g. go into function calls or return back to the caller + */ StepInto = "step_into", + /** + * This command runs until the next breakpoint in the source file is reached. + */ Continue = "continue" } const DEBUGGING_COMMANDS = [ DebuggingCommand.StepOver, DebuggingCommand.StepInto, DebuggingCommand.Continue ]; -export class DebuggingInputHandler extends InteractiveInputHandler { - protected programmingLanguage: ProgrammingLanguage; - private commandMap: Map>; +/** + * Base class for interactive debugging input handlers + * This allows communication between an interactive debugger that uses + * textual commands and the user. + */ +export abstract class DebuggingInputHandler extends InteractiveInputHandler { + /** + * Whether we are handling a debugging command or an actual input call + */ + protected debugging: boolean; + /** + * Maps a command to its textual representation for the debugger + * The command does not need to end with \n, this will be added if missing + */ + private commandMap: Map; + /** + * The command the user picked + */ private command: DebuggingCommand | undefined; - getInputMode(): InputMode { - return InputMode.Debugging; - } /** - * Construct a new InteractiveInputHandler - * @param {function()} onInput Callback for when the user has entered a value - * @param {string} inputAreaId HTML identifier for the used HTML input field - * @param {string} sendButtonId HTML identifier for the used HTML button - * @param {ProgrammingLanguage} programmingLanguage The currently used language + * Construct a new DebuggingInputHandler */ - constructor(onInput: () => void, inputAreaId: string, - sendButtonId: string, programmingLanguage: ProgrammingLanguage) { - super(onInput, inputAreaId, sendButtonId); - this.programmingLanguage = programmingLanguage; - this.commandMap = DebuggingInputHandler.buildCommandMap(); + constructor() { + super(); + this.commandMap = this.buildCommandMap(); this.command = undefined; + this.debugging = false; } - static buildCommandMap(): Map> { - const commandMap = new Map(); - commandMap.set(ProgrammingLanguage.Python, new Map([ - [DebuggingCommand.StepInto, "s\n"], - [DebuggingCommand.StepOver, "n\n"], - [DebuggingCommand.Continue, "c\n"] - ])); - commandMap.set(ProgrammingLanguage.JavaScript, new Map()); - return commandMap; + override getInputMode(): InputMode { + return InputMode.Debugging; } - hasNext(): boolean { - return this.command !== undefined || this.inputArea.value !== ""; + /** + * Build the map to convert DebuggingCommands to strings + */ + protected abstract buildCommandMap(): Map; + + override hasNext(): boolean { + return (this.debugging && this.command !== undefined) || + super.hasNext(); } - next(): string { + override next(): string { let nextValue = ""; - if (this.command) { - const m = this.commandMap.get(this.programmingLanguage) || new Map(); - nextValue = m.get(this.command) || ""; - } else { - nextValue = this.inputArea.value; + if (this.command) { // Process debugging command + nextValue = this.commandMap.get(this.command) || ""; + if (!nextValue.endsWith("\n")) { + nextValue += "\n"; + } + this.reset(); + } else { // Process regular input prompt + nextValue = super.next(); } - this.reset(); return nextValue; } - protected reset(): void { - if (this.command !== undefined) { - this.command = undefined; - } else { - super.reset(); + protected override reset(): void { + this.command = undefined; + this.debugging = false; + super.reset(); + } + + /** + * Callback for when a specific command button is clicked + * @param {DebuggingCommand} command The command attached to the button + */ + protected onCommandButtonClicked(command: DebuggingCommand): void { + if (this.debugging) { + this.command = command; + this.onUserInput(); } } @@ -88,11 +119,9 @@ export class DebuggingInputHandler extends InteractiveInputHandler {
`); super.render({ parentElementId: DEBUGGING_INTERACTIVE_WRAPPER_ID }); + // Add listeners after buttons are rendered DEBUGGING_COMMANDS.forEach(command => { - addListener(command, () => { - this.command = command; - this.onInput(); - }, "click"); + addListener(command, () => this.onCommandButtonClicked(command), "click"); }); return rendered; } diff --git a/src/input/InteractiveInputHandler.ts b/src/input/InteractiveInputHandler.ts index 96ed76a1..ebe0cba2 100644 --- a/src/input/InteractiveInputHandler.ts +++ b/src/input/InteractiveInputHandler.ts @@ -1,46 +1,34 @@ +import { INPUT_TA_ID, SEND_INPUT_BTN_ID } from "../Constants"; import { InputMode } from "../InputManager"; -import { getElement, RenderOptions, renderWithOptions, t } from "../util/Util"; +import { addListener, getElement, RenderOptions, renderWithOptions, t } from "../util/Util"; import { UserInputHandler } from "./UserInputHandler"; +/** + * Input handler that takes input from the user in an interactive fashion + */ export class InteractiveInputHandler extends UserInputHandler { - /** - * HTML identifier for the used HTML button - */ - protected sendButtonId: string; - /** - * Construct a new InteractiveInputHandler - * @param {function()} onInput Callback for when the user has entered a value - * @param {string} inputAreaId HTML identifier for the used HTML input field - * @param {string} sendButtonId HTML identifier for the used HTML button - */ - constructor(onInput: () => void, inputAreaId: string, - sendButtonId: string) { - super(onInput, inputAreaId); - this.sendButtonId = sendButtonId; - } - /** * Retrieve the button that users can click to send their input */ get sendButton(): HTMLButtonElement { - return getElement(this.sendButtonId); + return getElement(SEND_INPUT_BTN_ID); } - getInputMode(): InputMode { + override getInputMode(): InputMode { return InputMode.Interactive; } - hasNext(): boolean { + override hasNext(): boolean { return this.waiting; // Allow sending empty lines when the user does this explicitly } - next(): string { + override next(): string { const value = this.inputArea.value; - this.inputArea.value = ""; + this.reset(); return value; } - waitWithPrompt(waiting: boolean, prompt?: string): void { + override waitWithPrompt(waiting: boolean, prompt?: string): void { super.waitWithPrompt(waiting, prompt); this.sendButton.disabled = !waiting; this.inputArea.disabled = !waiting; @@ -51,36 +39,35 @@ export class InteractiveInputHandler extends UserInputHandler { } } - onToggle(): void { + override onToggle(): void { this.reset(); } - onRunStart(): void { + override onRunStart(): void { this.reset(); } - onRunEnd(): void { + override onRunEnd(): void { // Intentionally empty } render(options: RenderOptions): HTMLElement { const rendered = renderWithOptions(options, `
- -
`); - getElement(this.sendButtonId) - .addEventListener("click", () => this.onInput()); + addListener(SEND_INPUT_BTN_ID, () => this.onUserInput(), "click"); this.inputArea.addEventListener("keydown", (ev: KeyboardEvent) => { if (this.waiting && ev.key.toLowerCase() === "enter") { - this.onInput(); + this.onUserInput(); } }); return rendered; diff --git a/src/input/PdbInputHandler.ts b/src/input/PdbInputHandler.ts new file mode 100644 index 00000000..5f87c3cb --- /dev/null +++ b/src/input/PdbInputHandler.ts @@ -0,0 +1,21 @@ +import { DebuggingCommand, DebuggingInputHandler } from "./DebuggingInputHandler"; + +/** + * Implementation of a DebuggingInputHandler for Python with Pdb + */ +export class PdbInputHandler extends DebuggingInputHandler { + protected override buildCommandMap(): Map { + return new Map([ + [DebuggingCommand.StepInto, "s"], + [DebuggingCommand.StepOver, "n"], + [DebuggingCommand.Continue, "c"] + ]); + } + + override waitWithPrompt(waiting: boolean, prompt?: string): void { + if (waiting) { + this.debugging = /^\(Pdb\)/.test(prompt || ""); + } + super.waitWithPrompt(waiting, prompt); + } +} diff --git a/src/input/UserInputHandler.ts b/src/input/UserInputHandler.ts index 594b36d4..7f77dfdc 100644 --- a/src/input/UserInputHandler.ts +++ b/src/input/UserInputHandler.ts @@ -1,7 +1,12 @@ +import { INPUT_TA_ID } from "../Constants"; import { InputMode } from "../InputManager"; import { RunListener } from "../RunListener"; import { getElement, RenderOptions, t } from "../util/Util"; +export interface InputListener { + onUserInput(): void; +} + /** * Base class for components that handle input from the user */ @@ -10,24 +15,23 @@ export abstract class UserInputHandler implements RunListener { * Whether we are waiting for the user to input data */ protected waiting: boolean; - /** - * Callback for when the user has entered a value - */ - protected onInput: () => void; - /** - * HTML identifier for the used HTML input field - */ - protected inputAreaId: string; + + protected inputListeners: Set; /** * Construct a new UserInputHandler - * @param {function()} onInput Callback for when the user has entered a value - * @param {string} inputAreaId HTML identifier for the used HTML input field */ - constructor(onInput: () => void, inputAreaId: string) { + constructor() { this.waiting = false; - this.onInput = onInput; - this.inputAreaId = inputAreaId; + this.inputListeners = new Set(); + } + + public addInputListener(listener: InputListener): void { + this.inputListeners.add(listener); + } + + protected onUserInput(): void { + this.inputListeners.forEach(l => l.onUserInput()); } /** * Whether this handler has input ready @@ -66,7 +70,7 @@ export abstract class UserInputHandler implements RunListener { * Retrieve the HTMLInputElement for this InputHandler */ get inputArea(): HTMLInputElement { - return getElement(this.inputAreaId); + return getElement(INPUT_TA_ID); } /** @@ -89,7 +93,7 @@ export abstract class UserInputHandler implements RunListener { } /** - * Helper method to reset internal state when needed + * Helper method to reset internal state */ protected reset(): void { this.inputArea.value = ""; diff --git a/src/workers/python/PythonWorker.worker.ts b/src/workers/python/PythonWorker.worker.ts index 37be0472..a2d1f626 100644 --- a/src/workers/python/PythonWorker.worker.ts +++ b/src/workers/python/PythonWorker.worker.ts @@ -6,6 +6,8 @@ import { Pyodide, PYODIDE_INDEX_URL, PYODIDE_JS_URL } from "./Pyodide"; import { Channel } from "sync-message"; import { CompletionResult } from "@codemirror/autocomplete"; import { parseData } from "../../util/Util"; +import { DebuggingInputHandler } from "../../input/DebuggingInputHandler"; +import { PdbInputHandler } from "../../input/PdbInputHandler"; /* eslint-disable-next-line */ const initPythonString = require("!!raw-loader!./init.py").default; From 60156554656fe77f5be3948754588a119b7319d1 Mon Sep 17 00:00:00 2001 From: winniederidder Date: Sat, 19 Mar 2022 14:46:43 +0100 Subject: [PATCH 07/12] Use correct line number --- src/extensions/Breakpoints.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/extensions/Breakpoints.ts b/src/extensions/Breakpoints.ts index 2e7ddc71..2c306f10 100644 --- a/src/extensions/Breakpoints.ts +++ b/src/extensions/Breakpoints.ts @@ -37,8 +37,9 @@ export function breakpoints(onToggle: (pos: number, value: boolean) => void): Ex breakpoints.between(pos, pos, () => { hasBreakpoint = true; }); - // Internal position uses 0-based indexing, but line numbers start at 1 - onToggle(pos+1, !hasBreakpoint); + // Line numbers start at 1 + const lineNr = view.state.doc.lineAt(pos).number; + onToggle(lineNr, !hasBreakpoint); view.dispatch({ effects: breakpointEffect.of({ pos, on: !hasBreakpoint }) }); From c3f80ad4ab0416c4306d594113af822e2da067b8 Mon Sep 17 00:00:00 2001 From: winniederidder Date: Sat, 19 Mar 2022 18:29:40 +0100 Subject: [PATCH 08/12] Cleanup and refactoring to handle debug messages --- src/CodeRunner.ts | 5 +- src/Papyros.ts | 2 + src/PapyrosEvent.ts | 2 +- src/input/DebuggingInputHandler.ts | 3 +- src/workers/python/init.py | 79 +++++++++++++++++++----------- 5 files changed, 56 insertions(+), 35 deletions(-) diff --git a/src/CodeRunner.ts b/src/CodeRunner.ts index 3a3fd320..1edda364 100644 --- a/src/CodeRunner.ts +++ b/src/CodeRunner.ts @@ -73,10 +73,7 @@ export class CodeRunner { /** * Construct a new RunStateManager with the given listeners * @param {ProgrammingLanguage} programmingLanguage The language to use - * @param {Object} clickListeners - * @param {function} onRunClicked Callback for when the run button is clicked - * @param {function} onStopClicked Callback for when the stop button is clicked - * @param {function} onDebugClicked Callback for when the debug button is clicked + * @param {Object} clickListeners Callbacks for the standard buttons */ constructor(programmingLanguage: ProgrammingLanguage, clickListeners: ExternalClickListeners) { diff --git a/src/Papyros.ts b/src/Papyros.ts index 69156ca3..e79632d3 100644 --- a/src/Papyros.ts +++ b/src/Papyros.ts @@ -320,6 +320,8 @@ export class Papyros { this.onInput(e); } else if (e.type === "error") { this.onError(e); + } else if (e.type === "debug") { + console.log("Got debug message:", e); } } else { papyrosLog(LogType.Debug, "Received event with outdated runId: ", e); diff --git a/src/PapyrosEvent.ts b/src/PapyrosEvent.ts index 7b97eae9..bf29bbc0 100644 --- a/src/PapyrosEvent.ts +++ b/src/PapyrosEvent.ts @@ -5,7 +5,7 @@ export interface PapyrosEvent { /** * The type of action generating this event */ - type: "input" | "output" | "success" | "error"; + type: "input" | "output" | "success" | "error" | "debug"; /** * The identifier for the run this message is associated with * This allows discarding outdated events that were delayed diff --git a/src/input/DebuggingInputHandler.ts b/src/input/DebuggingInputHandler.ts index b9fb7959..573ef193 100644 --- a/src/input/DebuggingInputHandler.ts +++ b/src/input/DebuggingInputHandler.ts @@ -109,7 +109,8 @@ export abstract class DebuggingInputHandler extends InteractiveInputHandler { const buttons = DEBUGGING_COMMANDS.map(command => { return renderButton({ id: command, - buttonText: t(`Papyros.debugging_command.${command}`) + buttonText: t(`Papyros.debugging_command.${command}`), + extraClasses: `btn-debugging-${command.replaceAll("_", "-")}` }); }).join("\n"); const rendered = renderWithOptions(options, ` diff --git a/src/workers/python/init.py b/src/workers/python/init.py index 9087f5dd..7a7c2eb6 100644 --- a/src/workers/python/init.py +++ b/src/workers/python/init.py @@ -3,6 +3,7 @@ import os import sys import json +import re from collections.abc import Awaitable import micropip @@ -15,7 +16,7 @@ ft = micropip.install("friendly_traceback") jedi_install = micropip.install("jedi") - +SYS_RECURSION_LIMIT = 500 # Global Papyros instance papyros = None @@ -27,13 +28,55 @@ def __init__( self, *, callback=None, - limit=500 + limit=SYS_RECURSION_LIMIT ): - super().__init__(callback=callback) + super().__init__() self.limit = limit self.debugger = None self.debugging = False self.override_globals() + self.set_event_callback(callback) + + def process_debugging_message(self, message): + if self.filename not in message: + return None + nr_match = re.search("\((\d+)\)", message) + if nr_match: + return dict(action="highlight", line_nr=nr_match.group(1)) + return dict(action="unknown", data="No action specified yet for message: " + message) + + def set_event_callback(self, event_callback): + if event_callback is None: + raise ValueError("Event callback must not be None") + + def runner_callback(event_type, data): + def cb(typ, dat, **kwargs): + return event_callback(to_js(dict(type=typ, data=dat, **kwargs))) + + # Translate python_runner events to papyros events + if event_type == "output": + for part in data["parts"]: + typ = part["type"] + if typ in ["stderr", "traceback", "syntax_error"]: + cb("error", part["text"], contentType="text/json") + elif typ == "stdout": + if self.debugging and re.search("> .*\(\)", part["text"]): + data = self.process_debugging_message(part["text"]) + if data: + cb("debug", json.dumps(data), contentType="text/json") + else: + cb("output", part["text"], contentType="text/plain") + elif typ == "img": + cb("output", part["text"], contentType=part["contentType"]) + elif typ in ["input", "input_prompt"]: + continue + else: + raise ValueError(f"Unknown output part type {typ}") + elif event_type == "input": + return cb("input", json.dumps(dict(prompt=data["prompt"])), contentType="text/json") + else: + raise ValueError(f"Unknown event type {event_type}") + self.set_callback(runner_callback) def set_file(self, filename, code): self.filename = os.path.normcase(os.path.abspath(filename)) @@ -107,11 +150,13 @@ async def debug_code(self, source_code, breakpoints): self.debugger = pdb.Pdb() for line_nr in breakpoints: self.debugger.set_break(filename=self.filename, lineno=line_nr) + self.debugging = True self.line = "c\n" # Ensure first interrupt is continued until breakpoint self.debugger.set_trace() result = self.execute(code_obj, source_code, "exec") while isinstance(result, Awaitable): result = await result + self.debugging = False return result def serialize_syntax_error(self, exc, source_code): @@ -160,33 +205,9 @@ def serialize_traceback(self, exc, source_code): ) -async def init_papyros(event_callback): +async def init_papyros(event_callback, limit=SYS_RECURSION_LIMIT): global papyros - - def runner_callback(event_type, data): - def cb(typ, dat, **kwargs): - return event_callback(to_js(dict(type=typ, data=dat, **kwargs))) - - # Translate python_runner events to papyros events - if event_type == "output": - for part in data["parts"]: - typ = part["type"] - if typ in ["stderr", "traceback", "syntax_error"]: - cb("error", part["text"], contentType="text/json") - elif typ == "stdout": - cb("output", part["text"], contentType="text/plain") - elif typ == "img": - cb("output", part["text"], contentType=part["contentType"]) - elif typ in ["input", "input_prompt"]: - continue - else: - raise ValueError(f"Unknown output part type {typ}") - elif event_type == "input": - return cb("input", json.dumps(dict(prompt=data["prompt"])), contentType="text/json") - else: - raise ValueError(f"Unknown event type {event_type}") - - papyros = Papyros(callback=runner_callback) + papyros = Papyros(callback=event_callback, limit=limit) async def process_code(code, filename="my_code.py"): papyros.set_file(filename, code) From 8057fcd9b6ad4419196360d922b910dbc2a523af Mon Sep 17 00:00:00 2001 From: winniederidder Date: Sat, 19 Mar 2022 23:24:54 +0100 Subject: [PATCH 09/12] Refactor to use better pubsub system --- src/Backend.ts | 13 +- src/BackendEvent.ts | 41 ++++ src/BackendManager.ts | 111 ++++++--- src/CodeRunner.ts | 171 ++++++++++--- src/InputManager.ts | 19 +- src/Library.ts | 4 +- src/OutputManager.ts | 31 ++- src/Papyros.ts | 229 ++---------------- src/PapyrosEvent.ts | 22 -- src/RunListener.ts | 13 - src/input/InteractiveInputHandler.ts | 2 +- src/input/UserInputHandler.ts | 3 +- .../javascript/JavaScriptWorker.worker.ts | 9 +- src/workers/python/PythonWorker.worker.ts | 4 +- 14 files changed, 324 insertions(+), 348 deletions(-) create mode 100644 src/BackendEvent.ts delete mode 100644 src/PapyrosEvent.ts delete mode 100644 src/RunListener.ts diff --git a/src/Backend.ts b/src/Backend.ts index 219cbbca..973e3d32 100644 --- a/src/Backend.ts +++ b/src/Backend.ts @@ -1,9 +1,8 @@ -import { PapyrosEvent } from "./PapyrosEvent"; +import { BackendEvent } from "./BackendEvent"; import { Channel, readMessage, uuidv4 } from "sync-message"; import { parseData } from "./util/Util"; import { CompletionContext, CompletionResult } from "@codemirror/autocomplete"; import { LogType, papyrosLog } from "./util/Logging"; -import { DebuggingInputHandler } from "./input/DebuggingInputHandler"; export interface WorkerAutocompleteContext { explicit: boolean; @@ -19,7 +18,7 @@ export interface WorkerAutocompleteContext { } export abstract class Backend { - protected onEvent: (e: PapyrosEvent) => any; + protected onEvent: (e: BackendEvent) => any; protected runId: number; /** @@ -35,19 +34,19 @@ export abstract class Backend { /** * Initialize the backend by doing all setup-related work - * @param {function(PapyrosEvent):void} onEvent Callback for when events occur + * @param {function(BackendEvent):void} onEvent Callback for when events occur * @param {Channel} channel for communication with the main thread * @return {Promise} Promise of launching */ launch( - onEvent: (e: PapyrosEvent) => void, + onEvent: (e: BackendEvent) => void, channel: Channel ): Promise { // Input messages are handled in a special way // In order to link input requests to their responses // An ID is required to make the connection // The message must be read in the worker to not stall the main thread - const onInput = (e: PapyrosEvent): string => { + const onInput = (e: BackendEvent): string => { const inputData = parseData(e.data, e.contentType); const messageId = uuidv4(); inputData.messageId = messageId; @@ -56,7 +55,7 @@ export abstract class Backend { onEvent(e); return readMessage(channel, messageId); }; - this.onEvent = (e: PapyrosEvent) => { + this.onEvent = (e: BackendEvent) => { e.runId = this.runId; if (e.type === "input") { return onInput(e); diff --git a/src/BackendEvent.ts b/src/BackendEvent.ts new file mode 100644 index 00000000..2bff99ce --- /dev/null +++ b/src/BackendEvent.ts @@ -0,0 +1,41 @@ +/** + * Enum representing all possible types for supported events + */ +export enum BackendEventType { + Start = "start", + Input = "input", + Output = "output", + Error = "error", + Debug = "debug", + End = "end" +} +/** + * All possible types for ease of iteration + */ +export const BACKEND_EVENT_TYPES = [ + BackendEventType.Input, BackendEventType.Output, + BackendEventType.Error, BackendEventType.Debug, + BackendEventType.Start, BackendEventType.End +]; +/** + * Interface for events used for communication between threads + */ +export interface BackendEvent { + /** + * The type of action generating this event + */ + type: BackendEventType; + /** + * The identifier for the run this message is associated with + * This allows discarding outdated events that were delayed + */ + runId: number; + /** + * The actual data stored in this event + */ + data: string; + /** + * The format used for the data to help with parsing + */ + contentType: string; +} diff --git a/src/BackendManager.ts b/src/BackendManager.ts index 17b408b2..dc85c920 100644 --- a/src/BackendManager.ts +++ b/src/BackendManager.ts @@ -1,45 +1,92 @@ +/* eslint-disable valid-jsdoc */ // Some parts are incorrectly marked as functions import { releaseProxy, Remote, wrap } from "comlink"; import { Backend } from "./Backend"; import { ProgrammingLanguage } from "./ProgrammingLanguage"; import PythonWorker from "./workers/python/PythonWorker.worker"; import JavaScriptWorker from "./workers/javascript/JavaScriptWorker.worker"; -// Store Worker per Backend as Comlink proxy has no explicit reference to the Worker -// We need the Worker itself to be able to terminate it (@see stopBackend) -const BACKEND_MAP: Map, Worker> = new Map(); - -const CREATE_WORKER_MAP: Map Worker> = new Map([ - [ProgrammingLanguage.Python, () => new PythonWorker()], - [ProgrammingLanguage.JavaScript, () => new JavaScriptWorker()] -]); +import { BackendEvent, BackendEventType } from "./BackendEvent"; /** - * Start a backend for the given language, while storing the worker - * @param {ProgrammingLanguage} language The programming language supported by the backend - * @return {Remote} A Comlink proxy for the Backend + * Callback type definition for subscribers + * @param {BackendEvent} e The published event */ -export function startBackend(language: ProgrammingLanguage): Remote { - if (CREATE_WORKER_MAP.has(language)) { - const worker = CREATE_WORKER_MAP.get(language)!(); - const backend = wrap(worker); - // store worker itself in the map - BACKEND_MAP.set(backend, worker); - return backend; - } else { - throw new Error(`${language} is not yet supported.`); - } -} +type BackendEventListener = (e: BackendEvent) => void; /** - * Stop a backend by terminating the worker and releasing memory - * @param {Remote} backend The proxy for the backend to stop + * Abstract class to implement the singleton pattern + * Static methods group functionality */ -export function stopBackend(backend: Remote): void { - if (BACKEND_MAP.has(backend)) { - const toStop = BACKEND_MAP.get(backend)!; - toStop.terminate(); - backend[releaseProxy](); - BACKEND_MAP.delete(backend); - } else { - throw new Error(`Unknown backend supplied for backend ${JSON.stringify(backend)}`); +export abstract class BackendManager { + /** + * Store Worker per Backend as Comlink proxy has no explicit reference to the Worker + * We need the Worker itself to be able to terminate it (@see stopBackend) + */ + static backendMap: Map, Worker> = new Map(); + static createWorkerMap: Map Worker> = new Map([ + [ProgrammingLanguage.Python, () => new PythonWorker()], + [ProgrammingLanguage.JavaScript, () => new JavaScriptWorker()] + ]); + /** + * Map an event type to interested subscribers + * Uses an Array to maintain order of subscription + */ + static subscriberMap: Map> = new Map(); + /** + * Start a backend for the given language, while storing the worker + * @param {ProgrammingLanguage} language The programming language supported by the backend + * @return {Remote} A Comlink proxy for the Backend + */ + static startBackend(language: ProgrammingLanguage): Remote { + if (this.createWorkerMap.has(language)) { + const worker = this.createWorkerMap.get(language)!(); + const backend = wrap(worker); + // store worker itself in the map + this.backendMap.set(backend, worker); + return backend; + } else { + throw new Error(`${language} is not yet supported.`); + } + } + + /** + * Stop a backend by terminating the worker and releasing memory + * @param {Remote} backend The proxy for the backend to stop + */ + static stopBackend(backend: Remote): void { + if (this.backendMap.has(backend)) { + const toStop = this.backendMap.get(backend)!; + toStop.terminate(); + backend[releaseProxy](); + this.backendMap.delete(backend); + } else { + throw new Error(`Unknown backend supplied for backend ${JSON.stringify(backend)}`); + } + } + + /** + * Register a callback for when an event of a certain type is published + * @param {BackendEventType} type The type of event to subscribe to + * @param {BackendEventListener} subscriber Callback for when an event + * of the given type is published + */ + static subscribe(type: BackendEventType, subscriber: BackendEventListener): void { + if (!this.subscriberMap.has(type)) { + this.subscriberMap.set(type, []); + } + const subscribers = this.subscriberMap.get(type)!; + if (!subscribers.includes(subscriber)) { + subscribers.push(subscriber); + } + } + + /** + * Publish an event, notifying all listeners for its type + * @param {BackendEventType} e The event to publish + */ + static publish(e: BackendEvent): void { + if (this.subscriberMap.has(e.type)) { + this.subscriberMap.get(e.type)!.forEach(cb => cb(e)); + } } } + diff --git a/src/CodeRunner.ts b/src/CodeRunner.ts index 1edda364..73380cf7 100644 --- a/src/CodeRunner.ts +++ b/src/CodeRunner.ts @@ -1,12 +1,16 @@ -import { Remote } from "comlink"; +import { proxy, Remote } from "comlink"; import { Backend } from "./Backend"; +import { BackendEvent, BackendEventType } from "./BackendEvent"; +import { BackendManager } from "./BackendManager"; import { CodeEditor } from "./CodeEditor"; import { APPLICATION_STATE_TEXT_ID, RUN_BTN_ID, STATE_SPINNER_ID, STOP_BTN_ID, DEBUG_BTN_ID } from "./Constants"; +import { InputManager, InputMode } from "./InputManager"; import { ProgrammingLanguage } from "./ProgrammingLanguage"; import { svgCircle } from "./util/HTMLShapes"; +import { LogType, papyrosLog } from "./util/Logging"; import { addListener, ButtonOptions, renderButton, RenderOptions, renderWithOptions, getElement, @@ -29,18 +33,6 @@ export enum RunState { Stopping = "stopping", Ready = "ready" } - -/** - * All listeners that should be configured externally - */ -interface ExternalClickListeners { - /* Callback for when the run button is clicked */ - onRunClicked: () => void; - /* onStopClicked Callback for when the stop button is clicked */ - onStopClicked: () => void; - /* onDebugClicked Callback for when the debug button is clicked */ - onDebugClicked: () => void; -} /** * Helper component to manage and visualize the current RunState */ @@ -48,37 +40,40 @@ export class CodeRunner { /** * The currently used programming language */ - programmingLanguage: ProgrammingLanguage; + private programmingLanguage: ProgrammingLanguage; /** * The editor in which the code is written */ readonly editor: CodeEditor; + /** + * Component to request and handle input from the user + */ + readonly inputManager: InputManager; /** * The backend that executes the code asynchronously */ - backend: Remote; + private backend: Remote; /** * The identifier for the current run */ - runId: number; + private runId: number; /** * Current state of the program */ - state: RunState; + private state: RunState; /** * Buttons managed by this component */ - buttons: Array; + private buttons: Array; /** * Construct a new RunStateManager with the given listeners * @param {ProgrammingLanguage} programmingLanguage The language to use - * @param {Object} clickListeners Callbacks for the standard buttons */ - constructor(programmingLanguage: ProgrammingLanguage, - clickListeners: ExternalClickListeners) { + constructor(programmingLanguage: ProgrammingLanguage) { this.programmingLanguage = programmingLanguage; this.editor = new CodeEditor(); + this.inputManager = new InputManager(() => this.setState(RunState.Running)); this.backend = {} as Remote; this.runId = 0; this.buttons = []; @@ -86,25 +81,79 @@ export class CodeRunner { id: RUN_BTN_ID, buttonText: t("Papyros.run"), extraClasses: "text-white bg-blue-500" - }, clickListeners.onRunClicked); + }, () => this.runCode(false)); this.addButton({ id: STOP_BTN_ID, buttonText: t("Papyros.stop"), extraClasses: "text-white bg-red-500" - }, clickListeners.onStopClicked); + }, () => this.stop()); this.addButton({ id: DEBUG_BTN_ID, buttonText: t("Papyros.debug"), extraClasses: "text-white bg-green-500" - }, clickListeners.onDebugClicked); + }, () => this.runCode(true)); + BackendManager.subscribe(BackendEventType.Input, + () => this.setState(RunState.AwaitingInput)); this.state = RunState.Ready; } + /** + * Start the backend to enable running code + */ + async start(): Promise { + this.setState(RunState.Loading); + this.backend = BackendManager.startBackend(this.programmingLanguage); + this.editor.setLanguage(this.programmingLanguage, + async context => { + const completionContext = Backend.convertCompletionContext(context); + return await this.backend.autocomplete(completionContext); + }); + // Allow passing messages between worker and main thread + await this.backend.launch(proxy(e => this.publishEvent(e)), this.inputManager.channel); + this.editor.focus(); + this.setState(RunState.Ready); + } + + /** + * Interrupt the currently running code + * @return {Promise} Promise of stopping + */ + async stop(): Promise { + this.runId += 1; // ignore messages coming from last run + this.setState(RunState.Stopping); + this.publishEvent({ + type: BackendEventType.End, + runId: this.runId, + data: "User cancelled run", contentType: "text/plain" + }); + // Since we use workers, the old one must be entirely replaced to interrupt it + BackendManager.stopBackend(this.backend); + return this.start(); + } + + /** + * Helper method to publish events, if they are still relevant + * @param {BackendEvent} e The event to publish + */ + publishEvent(e: BackendEvent): void { + if (e.runId === this.runId) { + BackendManager.publish(e); + } + } + /** + * Set the used programming language to the given one to allow editing and running code + * @param {ProgrammingLanguage} programmingLanguage The language to use + */ async setProgrammingLanguage(programmingLanguage: ProgrammingLanguage): Promise { - this.editor.setLanguage(programmingLanguage, - async context => await this.backend.autocomplete( - Backend.convertCompletionContext(context) - )); + if (this.programmingLanguage !== programmingLanguage) { // Expensive, so ensure it is needed + BackendManager.stopBackend(this.backend); + this.programmingLanguage = programmingLanguage; + await this.start(); + } + } + + getProgrammingLanguage(): ProgrammingLanguage { + return this.programmingLanguage; } /** @@ -157,6 +206,10 @@ export class CodeRunner { message || t(`Papyros.states.${state}`); } + getState(): RunState { + return this.state; + } + /** * Add a button to display to the user * @param {ButtonOptions} options Options for rendering the button @@ -171,12 +224,16 @@ export class CodeRunner { } /** - * Render the RunStateManager with the given options - * @param {RenderOptions} options Options for rendering - * @return {HTMLElement} The rendered RunStateManager - */ - render(options: RenderOptions): HTMLElement { - const rendered = renderWithOptions(options, ` + * Render the RunStateManager with the given options + * @param {RenderOptions} statusPanelOptions Options for rendering the panel + * @param {RenderOptions} inputOptions Options for rendering the InputManager + * @param {RenderOptions} codeEditorOptions Options for rendering the editor + * @return {HTMLElement} The rendered RunStateManager + */ + render(statusPanelOptions: RenderOptions, + inputOptions: RenderOptions, + codeEditorOptions: RenderOptions): HTMLElement { + const rendered = renderWithOptions(statusPanelOptions, `
${this.buttons.map(b => b.buttonHTML).join("\n")} @@ -188,6 +245,52 @@ export class CodeRunner {
`); // Buttons are freshly added to the DOM, so attach listeners now this.buttons.forEach(b => addListener(b.id, b.onClick, "click")); + this.inputManager.render(inputOptions); + this.editor.render(codeEditorOptions, rendered); return rendered; } + + /** + * Run the code that is currently present in the editor + * @param {boolean} debug Whether the run happens in debug mode + * @return {Promise} Promise of running the code + */ + async runCode(debug: boolean): Promise { + // Setup pre-run + this.runId += 1; + this.setState(RunState.Running); + this.publishEvent({ + type: BackendEventType.Start, + runId: this.runId, + data: "User started run", contentType: "text/plain" + }); + papyrosLog(LogType.Debug, "Running code in Papyros, sending to backend"); + const start = new Date().getTime(); + let endMessage = "Program finishd normally"; + try { + if (debug) { + this.inputManager.inputMode = InputMode.Debugging; + await this.backend.debugCode( + this.editor.getCode(), this.runId, this.editor.breakpointLines); + } else { + await this.backend.runCode(this.editor.getCode(), this.runId); + } + } catch (error: any) { + this.publishEvent({ + type: BackendEventType.Error, + data: JSON.stringify(error), + runId: this.runId, + contentType: "text/json" + }); + endMessage = "Program terminated due to error: " + error.constructor.name; + } finally { + const end = new Date().getTime(); + this.setState(RunState.Ready, t("Papyros.finished", { time: (end - start) / 1000 })); + this.publishEvent({ + type: BackendEventType.End, + runId: this.runId, + data: endMessage, contentType: "text/plain" + }); + } + } } diff --git a/src/InputManager.ts b/src/InputManager.ts index 0e953d64..c7df3776 100644 --- a/src/InputManager.ts +++ b/src/InputManager.ts @@ -3,7 +3,7 @@ import { SWITCH_INPUT_MODE_A_ID, USER_INPUT_WRAPPER_ID } from "./Constants"; -import { PapyrosEvent } from "./PapyrosEvent"; +import { BackendEvent, BackendEventType } from "./BackendEvent"; import { papyrosLog, LogType } from "./util/Logging"; import { addListener, parseData, @@ -13,8 +13,8 @@ import { Channel, makeChannel, writeMessage } from "sync-message"; import { InteractiveInputHandler } from "./input/InteractiveInputHandler"; import { UserInputHandler } from "./input/UserInputHandler"; import { BatchInputHandler } from "./input/BatchInputHandler"; -import { RunListener } from "./RunListener"; import { PdbInputHandler } from "./input/PdbInputHandler"; +import { BackendManager } from "./BackendManager"; export enum InputMode { Interactive = "interactive", @@ -29,7 +29,7 @@ export interface InputData { messageId: string; } -export class InputManager implements RunListener { +export class InputManager { private previousInputMode: InputMode; private _inputMode: InputMode; private inputHandlers: Map; @@ -41,16 +41,19 @@ export class InputManager implements RunListener { channel: Channel; messageId = ""; - constructor(onSend: () => void, inputMode: InputMode) { + constructor(onSend: () => void) { this.inputHandlers = this.buildInputHandlerMap(); - this._inputMode = inputMode; + this._inputMode = InputMode.Interactive; this.inputHandler.addInputListener(this); - this.previousInputMode = inputMode; + this.previousInputMode = this._inputMode; this.channel = makeChannel()!; // by default we try to use Atomics this.onSend = onSend; this._waiting = false; this.prompt = ""; this.renderOptions = {} as RenderOptions; + BackendManager.subscribe(BackendEventType.Start, () => this.onRunStart()); + BackendManager.subscribe(BackendEventType.End, () => this.onRunEnd()); + BackendManager.subscribe(BackendEventType.Input, e => this.onInputRequest(e)); } private buildInputHandlerMap(): Map { @@ -127,10 +130,10 @@ ${switchMode}`); /** * Asynchronously handle an input request by prompting the user for input - * @param {PapyrosEvent} e Event containing the input data + * @param {BackendEvent} e Event containing the input data * @return {Promise} Promise of handling the request */ - async onInputRequest(e: PapyrosEvent): Promise { + async onInputRequest(e: BackendEvent): Promise { papyrosLog(LogType.Debug, "Handling input request in Papyros"); const data = parseData(e.data, e.contentType) as InputData; this.messageId = data.messageId; diff --git a/src/Library.ts b/src/Library.ts index df093eb0..79468d62 100644 --- a/src/Library.ts +++ b/src/Library.ts @@ -2,12 +2,12 @@ import { CodeEditor } from "./CodeEditor"; import { InputManager, InputMode } from "./InputManager"; import { OutputManager } from "./OutputManager"; import { Papyros } from "./Papyros"; -import { PapyrosEvent } from "./PapyrosEvent"; +import { BackendEvent } from "./BackendEvent"; import { CodeRunner, RunState } from "./CodeRunner"; import { InputWorker } from "./workers/input/InputWorker"; export * from "./ProgrammingLanguage"; -export type { PapyrosEvent }; +export type { BackendEvent as PapyrosEvent }; export { Papyros, CodeEditor, diff --git a/src/OutputManager.ts b/src/OutputManager.ts index 39982b7a..3ea3ff2b 100644 --- a/src/OutputManager.ts +++ b/src/OutputManager.ts @@ -1,6 +1,6 @@ import escapeHTML from "escape-html"; -import { PapyrosEvent } from "./PapyrosEvent"; -import { RunListener } from "./RunListener"; +import { BackendEvent, BackendEventType } from "./BackendEvent"; +import { BackendManager } from "./BackendManager"; import { inCircle } from "./util/HTMLShapes"; import { getElement, parseData, @@ -40,9 +40,16 @@ export interface FriendlyError { /** * Component for displaying code output or errors to the user */ -export class OutputManager implements RunListener { +export class OutputManager { // Store options to allow re-rendering - options: RenderOptions = { parentElementId: "" }; + options: RenderOptions; + + constructor() { + BackendManager.subscribe(BackendEventType.Start, () => this.reset()); + BackendManager.subscribe(BackendEventType.Output, e => this.showOutput(e)); + BackendManager.subscribe(BackendEventType.Error, e => this.showError(e)); + this.options = { parentElementId: "" }; + } /** * Retrieve the parent element containing all output parts @@ -78,9 +85,9 @@ export class OutputManager implements RunListener { /** * Display output to the user, based on its content type - * @param {PapyrosEvent} output Event containing the output data + * @param {BackendEvent} output Event containing the output data */ - showOutput(output: PapyrosEvent): void { + showOutput(output: BackendEvent): void { const data = parseData(output.data, output.contentType); if (output.contentType === "img/png;base64") { this.renderNextElement(``); @@ -91,9 +98,9 @@ export class OutputManager implements RunListener { /** * Display an error to the user - * @param {PapyrosEvent} error Event containing the error data + * @param {BackendEvent} error Event containing the error data */ - showError(error: PapyrosEvent): void { + showError(error: BackendEvent): void { let errorHTML = ""; const errorData = parseData(error.data, error.contentType); if (typeof (errorData) === "string") { @@ -144,12 +151,4 @@ export class OutputManager implements RunListener { reset(): void { this.render(this.options); } - - onRunStart(): void { - this.reset(); - } - - onRunEnd(): void { - // currently empty - } } diff --git a/src/Papyros.ts b/src/Papyros.ts index e79632d3..854226cf 100644 --- a/src/Papyros.ts +++ b/src/Papyros.ts @@ -1,16 +1,11 @@ /* eslint-disable max-len */ import "./Papyros.css"; -import { proxy, Remote } from "comlink"; import I18n from "i18n-js"; -import { Backend } from "./Backend"; -import { startBackend, stopBackend } from "./BackendManager"; -import { CodeEditor } from "./CodeEditor"; import { EDITOR_WRAPPER_ID, PROGRAMMING_LANGUAGE_SELECT_ID, OUTPUT_TA_ID, LOCALE_SELECT_ID, INPUT_AREA_WRAPPER_ID, EXAMPLE_SELECT_ID, PANEL_WRAPPER_ID } from "./Constants"; -import { InputManager, InputMode } from "./InputManager"; -import { PapyrosEvent } from "./PapyrosEvent"; +import { InputMode } from "./InputManager"; import { ProgrammingLanguage } from "./ProgrammingLanguage"; import { LogType, papyrosLog } from "./util/Logging"; import { @@ -23,35 +18,12 @@ import { RunState, CodeRunner } from "./CodeRunner"; import { getCodeForExample, getExampleNames } from "./examples/Examples"; import { OutputManager } from "./OutputManager"; import { makeChannel } from "sync-message"; -import { RunListener } from "./RunListener"; const LANGUAGE_MAP = new Map([ ["python", ProgrammingLanguage.Python], ["javascript", ProgrammingLanguage.JavaScript] ]); -/** - * Groups values related to running code - */ -interface PapyrosCodeState { - /** - * The currently used programming language - */ - programmingLanguage: ProgrammingLanguage; - /** - * The editor in which the code is written - */ - editor: CodeEditor; - /** - * The backend that executes the code asynchronously - */ - backend: Remote; - /** - * The identifier for the current run - */ - runId: number; -} - /** * Configuration options for this instance of Papyros */ @@ -112,22 +84,10 @@ export class Papyros { * Component to run code entered by the user */ codeRunner: CodeRunner; - /** - * Component to request and handle input from the user - */ - inputManager: InputManager; /** * Component to handle output generated by the user's code */ outputManager: OutputManager; - /** - * Groups all internal properties related to running code - */ - codeState: PapyrosCodeState; - /** - * Listeners to changes in the running state - */ - runListeners: Array; /** * Whether this instance has been launched */ @@ -139,55 +99,21 @@ export class Papyros { */ constructor(config: PapyrosConfig) { this.launched = false; - this.runListeners = []; this.config = config; // Load translations as other components depend on them loadTranslations(); I18n.locale = config.locale; const { programmingLanguage } = this.config; - this.codeState = { - programmingLanguage: programmingLanguage, - editor: new CodeEditor(), - backend: {} as Remote, - runId: 0 - }; this.outputManager = new OutputManager(); - this.codeRunner = new CodeRunner(programmingLanguage, { - onRunClicked: () => this.runCode(false), - onStopClicked: () => this.stop(), - onDebugClicked: () => this.runCode(true) - }); - this.inputManager = new InputManager(() => this.codeRunner.setState(RunState.Running), config.inputMode); - this.addRunListener(this.inputManager); - this.addRunListener(this.outputManager); - } - - /** - * Register a listener to be notified when code runs start or end - * @param {RunListener} listener The new listener - */ - addRunListener(listener: RunListener): void { - this.runListeners.push(listener); - } - - /** - * Inform the listeners about the current run - * @param {boolean} start Whether the run started or ended - */ - private notifyListeners(start: boolean): void { - if (start) { - this.runListeners.forEach(l => l.onRunStart()); - } else { - this.runListeners.forEach(l => l.onRunEnd()); - } + this.codeRunner = new CodeRunner(programmingLanguage); } /** - * Getter for the current state of the program + * @return {RunState} The current state of the user's code */ - get state(): RunState { - return this.codeRunner.state; + getState(): RunState { + return this.codeRunner.getState(); } /** @@ -197,9 +123,9 @@ export class Papyros { async launch(): Promise { if (!this.launched) { const start = new Date().getTime(); - await this.startBackend(); - papyrosLog(LogType.Important, `Finished loading backend after ${new Date().getTime() - start} ms`); - this.codeState.editor.focus(); + await this.codeRunner.start(); + papyrosLog(LogType.Important, + `Finished loading backend after ${new Date().getTime() - start} ms`); } return this; } @@ -209,43 +135,21 @@ export class Papyros { * @param {ProgrammingLanguage} programmingLanguage The language to use */ async setProgrammingLanguage(programmingLanguage: ProgrammingLanguage): Promise { - if (this.codeState.programmingLanguage !== programmingLanguage) { // Expensive, so ensure it is needed - stopBackend(this.codeState.backend); - this.codeState.programmingLanguage = programmingLanguage; - await this.startBackend(); - } + await this.codeRunner.setProgrammingLanguage(programmingLanguage); } /** * @param {string} code The code to use in the editor */ setCode(code: string): void { - this.codeState.editor.setCode(code); + this.codeRunner.editor.setCode(code); } /** * @return {string} The currently written code */ getCode(): string { - return this.codeState.editor.getCode(); - } - - /** - * Start up the backend for the current programming language - */ - private async startBackend(): Promise { - const programmingLanguage = this.codeState.programmingLanguage; - this.codeRunner.setState(RunState.Loading); - const backend = startBackend(programmingLanguage); - this.codeState.backend = backend; - this.codeState.editor.setLanguage(programmingLanguage, - async context => { - const completionContext = Backend.convertCompletionContext(context); - return await this.codeState.backend.autocomplete(completionContext); - }); - // Allow passing messages between worker and main thread - await backend.launch(proxy(e => this.onMessage(e)), this.inputManager.channel); - this.codeRunner.setState(RunState.Ready); + return this.codeRunner.editor.getCode(); } /** @@ -270,16 +174,21 @@ export class Papyros { if (typeof SharedArrayBuffer === "undefined") { papyrosLog(LogType.Important, "SharedArrayBuffers are not available. "); if (!serviceWorkerRoot || !serviceWorkerName || !("serviceWorker" in navigator)) { - papyrosLog(LogType.Important, "Unable to register service worker. Please specify all required parameters and ensure service workers are supported."); + papyrosLog(LogType.Important, + "Unable to register service worker. Please specify all required parameters and ensure service workers are supported."); return false; } // Ensure there is a slash at the end to allow the script to be resolved - const rootWithSlash = serviceWorkerRoot.endsWith("/") ? serviceWorkerRoot : serviceWorkerRoot + "/"; + const rootWithSlash = serviceWorkerRoot.endsWith("/") ? + serviceWorkerRoot : serviceWorkerRoot + "/"; const serviceWorkerUrl = rootWithSlash + serviceWorkerName; papyrosLog(LogType.Important, `Registering service worker: ${serviceWorkerUrl}`); await window.navigator.serviceWorker.register(serviceWorkerUrl); - this.inputManager.channel = makeChannel({ serviceWorker: { scope: rootWithSlash } })!; - if (allowReload) { // Store that we are reloading, to prevent the next load from doing all this again + this.codeRunner.inputManager.channel = + makeChannel({ serviceWorker: { scope: rootWithSlash } })!; + if (allowReload) { + // Store that we are reloading + // to prevent the next load from doing all this again window.localStorage.setItem(RELOAD_STORAGE_KEY, RELOAD_STORAGE_KEY); // service worker adds new headers that may allow SharedArrayBuffers to be used window.location.reload(); @@ -291,94 +200,6 @@ export class Papyros { } } - /** - * Process PapyrosEvents with type="error" - * @param {PapyrosEvent} e The error-event - */ - private onError(e: PapyrosEvent): void { - this.outputManager.showError(e); - } - /** - * Process PapyrosEvents with type="input" - * @param {PapyrosEvent} e The input-event - */ - private async onInput(e: PapyrosEvent): Promise { - this.codeRunner.setState(RunState.AwaitingInput); - await this.inputManager.onInputRequest(e); - } - - /** - * Generic handler function to pass PapyrosEvents to the relevant method - * @param {PapyrosEvent} e The event ro process - */ - onMessage(e: PapyrosEvent): void { - papyrosLog(LogType.Debug, "received event in onMessage", e); - if (e.runId === this.codeState.runId) { // Only process relevant messages - if (e.type === "output") { - this.outputManager.showOutput(e); - } else if (e.type === "input") { - this.onInput(e); - } else if (e.type === "error") { - this.onError(e); - } else if (e.type === "debug") { - console.log("Got debug message:", e); - } - } else { - papyrosLog(LogType.Debug, "Received event with outdated runId: ", e); - } - } - - /** - * Run the code that is currently present in the editor - * @param {boolean} debug Whether the run happens in debug mode - * @return {Promise} Promise of running the code - */ - async runCode(debug: boolean): Promise { - if (this.state !== RunState.Ready) { - papyrosLog(LogType.Error, `Run code called from invalid state: ${this.state}`); - return; - } - // Setup pre-run - this.codeState.runId += 1; - this.codeRunner.setState(RunState.Running); - this.notifyListeners(true); - - papyrosLog(LogType.Debug, "Running code in Papyros, sending to backend"); - const start = new Date().getTime(); - try { - const { - backend, - runId, - editor - } = this.codeState; - if (debug) { - this.inputManager.inputMode = InputMode.Debugging; - await backend.debugCode(this.getCode(), runId, editor.breakpointLines); - } else { - await backend.runCode(this.getCode(), runId); - } - } catch (error: any) { - this.onError(error); - } finally { - const end = new Date().getTime(); - this.codeRunner.setState(RunState.Ready, t("Papyros.finished", { time: (end - start) / 1000 })); - this.notifyListeners(false); - } - } - - /** - * Interrupt the currently running code - * @return {Promise} Promise of stopping - */ - async stop(): Promise { - this.codeState.runId += 1; // ignore messages coming from last run - this.codeRunner.setState(RunState.Stopping); - this.notifyListeners(false); - // Since we use workers, the old one must be entirely replaced to interrupt it - stopBackend(this.codeState.backend); - return this.startBackend(); - } - /** * Render Papyros with the given options * @param {PapyrosRenderOptions} renderOptions Options to use @@ -459,19 +280,17 @@ export class Papyros { }, "change", "value" ); addListener(LOCALE_SELECT_ID, locale => { - document.location.href = `?locale=${locale}&language=${this.codeState.programmingLanguage}`; + document.location.href = `?locale=${locale}&language=${this.codeRunner.getProgrammingLanguage()}`; }, "change", "value"); addListener(EXAMPLE_SELECT_ID, name => { - const code = getCodeForExample(this.codeState.programmingLanguage, name); + const code = getCodeForExample(this.codeRunner.getProgrammingLanguage(), name); this.setCode(code); }, "change", "value"); // Ensure there is no initial selection removeSelection(EXAMPLE_SELECT_ID); } - - this.inputManager.render(renderOptions.inputOptions); - const runStatePanel = this.codeRunner.render(renderOptions.statusPanelOptions); - this.codeState.editor.render(renderOptions.codeEditorOptions, runStatePanel); + this.codeRunner.render(renderOptions.statusPanelOptions, + renderOptions.inputOptions, renderOptions.codeEditorOptions); this.outputManager.render(renderOptions.outputOptions); } diff --git a/src/PapyrosEvent.ts b/src/PapyrosEvent.ts deleted file mode 100644 index bf29bbc0..00000000 --- a/src/PapyrosEvent.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Interface for events used for communication between threads - */ -export interface PapyrosEvent { - /** - * The type of action generating this event - */ - type: "input" | "output" | "success" | "error" | "debug"; - /** - * The identifier for the run this message is associated with - * This allows discarding outdated events that were delayed - */ - runId: number; - /** - * The actual data stored in this event - */ - data: string; - /** - * The format used for the data to help with parsing - */ - contentType: string; -} diff --git a/src/RunListener.ts b/src/RunListener.ts deleted file mode 100644 index 86ed0e21..00000000 --- a/src/RunListener.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * Interface for components that maintain state based on runs of code - */ -export interface RunListener { - /** - * Inform this listener that a new run started - */ - onRunStart(): void; - /** - * Inform this listener that the run ended - */ - onRunEnd(): void; -} diff --git a/src/input/InteractiveInputHandler.ts b/src/input/InteractiveInputHandler.ts index ebe0cba2..a4726ca8 100644 --- a/src/input/InteractiveInputHandler.ts +++ b/src/input/InteractiveInputHandler.ts @@ -66,7 +66,7 @@ export class InteractiveInputHandler extends UserInputHandler {
`); addListener(SEND_INPUT_BTN_ID, () => this.onUserInput(), "click"); this.inputArea.addEventListener("keydown", (ev: KeyboardEvent) => { - if (this.waiting && ev.key.toLowerCase() === "enter") { + if (this.waiting && ev.key && ev.key.toLowerCase() === "enter") { this.onUserInput(); } }); diff --git a/src/input/UserInputHandler.ts b/src/input/UserInputHandler.ts index 7f77dfdc..1dcb555d 100644 --- a/src/input/UserInputHandler.ts +++ b/src/input/UserInputHandler.ts @@ -1,6 +1,5 @@ import { INPUT_TA_ID } from "../Constants"; import { InputMode } from "../InputManager"; -import { RunListener } from "../RunListener"; import { getElement, RenderOptions, t } from "../util/Util"; export interface InputListener { @@ -10,7 +9,7 @@ export interface InputListener { /** * Base class for components that handle input from the user */ -export abstract class UserInputHandler implements RunListener { +export abstract class UserInputHandler { /** * Whether we are waiting for the user to input data */ diff --git a/src/workers/javascript/JavaScriptWorker.worker.ts b/src/workers/javascript/JavaScriptWorker.worker.ts index 0a1e2278..b13fb552 100644 --- a/src/workers/javascript/JavaScriptWorker.worker.ts +++ b/src/workers/javascript/JavaScriptWorker.worker.ts @@ -2,6 +2,7 @@ import { expose } from "comlink"; import { Backend, WorkerAutocompleteContext } from "../../Backend"; import { CompletionResult } from "@codemirror/autocomplete"; import { javascriptLanguage } from "@codemirror/lang-javascript"; +import { BackendEventType } from "../../BackendEvent"; /** * Implementation of a JavaScript backend for Papyros @@ -41,7 +42,7 @@ class JavaScriptWorker extends Backend { */ private prompt(text = ""): string { return this.onEvent({ - type: "input", + type: BackendEventType.Input, data: JavaScriptWorker.stringify({ prompt: text }), contentType: "text/json", runId: this.runId @@ -54,7 +55,7 @@ class JavaScriptWorker extends Backend { */ private consoleLog(...args: any[]): void { this.onEvent({ - type: "output", + type: BackendEventType.Output, data: JavaScriptWorker.stringify(...args) + "\n", runId: this.runId, contentType: "text/plain" @@ -67,7 +68,7 @@ class JavaScriptWorker extends Backend { */ private consoleError(...args: any[]): void { this.onEvent({ - type: "error", + type: BackendEventType.Error, data: JavaScriptWorker.stringify(...args) + "\n", contentType: "text/plain", runId: this.runId @@ -144,7 +145,7 @@ class JavaScriptWorker extends Backend { } catch (error: any) { // try to create a friendly traceback Error.captureStackTrace(error); return Promise.resolve(this.onEvent({ - type: "error", + type: BackendEventType.Error, runId: this.runId, contentType: "text/json", data: JSON.stringify({ diff --git a/src/workers/python/PythonWorker.worker.ts b/src/workers/python/PythonWorker.worker.ts index a2d1f626..a6ce2621 100644 --- a/src/workers/python/PythonWorker.worker.ts +++ b/src/workers/python/PythonWorker.worker.ts @@ -1,6 +1,6 @@ import { expose } from "comlink"; import { Backend, WorkerAutocompleteContext } from "../../Backend"; -import { PapyrosEvent } from "../../PapyrosEvent"; +import { BackendEvent } from "../../BackendEvent"; import { LogType, papyrosLog } from "../../util/Logging"; import { Pyodide, PYODIDE_INDEX_URL, PYODIDE_JS_URL } from "./Pyodide"; import { Channel } from "sync-message"; @@ -39,7 +39,7 @@ class PythonWorker extends Backend { } override async launch( - onEvent: (e: PapyrosEvent) => void, + onEvent: (e: BackendEvent) => void, channel: Channel ): Promise { await super.launch(onEvent, channel); From 5521c0aa42670a5aa2795b9bf64d9aca2bb5c099 Mon Sep 17 00:00:00 2001 From: winniederidder Date: Sun, 20 Mar 2022 00:00:01 +0100 Subject: [PATCH 10/12] Highlight current line while debuggin --- src/CodeEditor.ts | 12 ++++++++++++ src/CodeRunner.ts | 17 ++++++++++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/CodeEditor.ts b/src/CodeEditor.ts index 8e11b293..99132415 100644 --- a/src/CodeEditor.ts +++ b/src/CodeEditor.ts @@ -105,6 +105,18 @@ export class CodeEditor { } } + /** + * Highlight the given line + * @param {number} lineNr The 1-based number of the line to highlight + */ + highlight(lineNr: number): void { + this.editorView.dispatch( + { + selection: { anchor: this.editorView.state.doc.line(lineNr).from } + } + ); + } + /** * Render the editor with the given options and panel * @param {RenderOptions} options Options for rendering diff --git a/src/CodeRunner.ts b/src/CodeRunner.ts index 73380cf7..01d5482a 100644 --- a/src/CodeRunner.ts +++ b/src/CodeRunner.ts @@ -14,7 +14,8 @@ import { LogType, papyrosLog } from "./util/Logging"; import { addListener, ButtonOptions, renderButton, RenderOptions, renderWithOptions, getElement, - t + t, + parseData } from "./util/Util"; interface DynamicButton { @@ -23,6 +24,11 @@ interface DynamicButton { onClick: () => void; } +interface DebugAction { + action: string; + data: string; +} + /** * Enum representing the possible states while processing code */ @@ -94,6 +100,8 @@ export class CodeRunner { }, () => this.runCode(true)); BackendManager.subscribe(BackendEventType.Input, () => this.setState(RunState.AwaitingInput)); + BackendManager.subscribe(BackendEventType.Debug, + e => this.onDebug(e)); this.state = RunState.Ready; } @@ -293,4 +301,11 @@ export class CodeRunner { }); } } + + onDebug(e: BackendEvent): void { + const data: DebugAction = parseData(e.data, e.contentType); + if (data.action === "highlight") { + this.editor.highlight(parseInt(data.data)); + } + } } From 4ed0735661655612ff10f8c24a312358939d1534 Mon Sep 17 00:00:00 2001 From: winniederidder Date: Sun, 20 Mar 2022 22:00:20 +0100 Subject: [PATCH 11/12] Fix Pdb output issues --- src/BackendManager.ts | 2 ++ src/CodeRunner.ts | 1 + src/util/Util.ts | 35 +++++++++++++----------- src/workers/python/init.py | 56 ++++++++++++++++++++++---------------- 4 files changed, 54 insertions(+), 40 deletions(-) diff --git a/src/BackendManager.ts b/src/BackendManager.ts index dc85c920..f5a559fe 100644 --- a/src/BackendManager.ts +++ b/src/BackendManager.ts @@ -5,6 +5,7 @@ import { ProgrammingLanguage } from "./ProgrammingLanguage"; import PythonWorker from "./workers/python/PythonWorker.worker"; import JavaScriptWorker from "./workers/javascript/JavaScriptWorker.worker"; import { BackendEvent, BackendEventType } from "./BackendEvent"; +import { LogType, papyrosLog } from "./util/Logging"; /** * Callback type definition for subscribers @@ -84,6 +85,7 @@ export abstract class BackendManager { * @param {BackendEventType} e The event to publish */ static publish(e: BackendEvent): void { + papyrosLog(LogType.Debug, "Publishing event: ", e); if (this.subscriberMap.has(e.type)) { this.subscriberMap.get(e.type)!.forEach(cb => cb(e)); } diff --git a/src/CodeRunner.ts b/src/CodeRunner.ts index 01d5482a..af0a847f 100644 --- a/src/CodeRunner.ts +++ b/src/CodeRunner.ts @@ -284,6 +284,7 @@ export class CodeRunner { await this.backend.runCode(this.editor.getCode(), this.runId); } } catch (error: any) { + papyrosLog(LogType.Error, error); this.publishEvent({ type: BackendEventType.Error, data: JSON.stringify(error), diff --git a/src/util/Util.ts b/src/util/Util.ts index a66b7fd4..7e7a47ba 100644 --- a/src/util/Util.ts +++ b/src/util/Util.ts @@ -200,26 +200,29 @@ export function renderWithOptions( * @return {any} The parsed data */ export function parseData(data: unknown, contentType: string): any { - const [baseType, specificType] = contentType.split("/"); - switch (baseType) { - case "text": { - switch (specificType) { - case "plain": { - return data; - } - case "json": { - return JSON.parse(data as string); + const typeParts = contentType ? contentType.split("/") : []; + if (typeParts.length === 2) { + const [baseType, specificType] = typeParts; + switch (baseType) { + case "text": { + switch (specificType) { + case "plain": { + return data; + } + case "json": { + return JSON.parse(data as string); + } } + break; } - break; - } - case "img": { - switch (specificType) { - case "png;base64": { - return data; + case "img": { + switch (specificType) { + case "png;base64": { + return data; + } } + break; } - break; } } papyrosLog(LogType.Important, `Unhandled content type: ${contentType}`); diff --git a/src/workers/python/init.py b/src/workers/python/init.py index 7a7c2eb6..8952b7eb 100644 --- a/src/workers/python/init.py +++ b/src/workers/python/init.py @@ -21,9 +21,12 @@ # Global Papyros instance papyros = None +# Helper output stream to capture Pdb output +class PdbOutputStream(python_runner.output.SysStream): + def __init__(self, output_buffer): + super().__init__("debug", output_buffer) class Papyros(python_runner.PatchedStdinRunner): - def __init__( self, *, @@ -39,11 +42,11 @@ def __init__( def process_debugging_message(self, message): if self.filename not in message: - return None + return None, dict(action="unknown", data="No action specified yet for different file: " + message) nr_match = re.search("\((\d+)\)", message) if nr_match: - return dict(action="highlight", line_nr=nr_match.group(1)) - return dict(action="unknown", data="No action specified yet for message: " + message) + return None, dict(action="highlight", data=nr_match.group(1)) + return None, dict(action="unknown", data="No action specified yet for message: " + message) def set_event_callback(self, event_callback): if event_callback is None: @@ -60,16 +63,17 @@ def cb(typ, dat, **kwargs): if typ in ["stderr", "traceback", "syntax_error"]: cb("error", part["text"], contentType="text/json") elif typ == "stdout": - if self.debugging and re.search("> .*\(\)", part["text"]): - data = self.process_debugging_message(part["text"]) - if data: - cb("debug", json.dumps(data), contentType="text/json") - else: - cb("output", part["text"], contentType="text/plain") + cb("output", part["text"], contentType="text/plain", debugging=str(self.debugging), matched=str(bool(re.search("> .*\(\)", part["text"])))) elif typ == "img": cb("output", part["text"], contentType=part["contentType"]) elif typ in ["input", "input_prompt"]: continue + elif typ == "debug": + output, debug = self.process_debugging_message(part["text"]) + if output: + cb("output", output, contentType="text/plain") + if debug: + cb("debug", json.dumps(debug), contentType="text/json") else: raise ValueError(f"Unknown output part type {typ}") elif event_type == "input": @@ -143,21 +147,25 @@ async def run_async(self, source_code, mode="exec", top_level_await=True): raise async def debug_code(self, source_code, breakpoints): + code_obj = self.pre_run(source_code, "exec", True) + if not code_obj: + return with self._execute_context(source_code): - code_obj = self.pre_run(source_code, "exec", True) - if not code_obj: - return - self.debugger = pdb.Pdb() - for line_nr in breakpoints: - self.debugger.set_break(filename=self.filename, lineno=line_nr) - self.debugging = True - self.line = "c\n" # Ensure first interrupt is continued until breakpoint - self.debugger.set_trace() - result = self.execute(code_obj, source_code, "exec") - while isinstance(result, Awaitable): - result = await result - self.debugging = False - return result + self.debugger = pdb.Pdb(stdout=PdbOutputStream(self.output_buffer)) + self.debugger.use_rawinput = True + for line_nr in breakpoints: + self.debugger.set_break(filename=self.filename, lineno=line_nr) + self.debugging = True + self.line = "c\n" # Ensure first interrupt is continued until breakpoint + self.debugger.set_trace() + self.output_buffer.flush_length = 1 + result = self.execute(code_obj, source_code, "exec") + while isinstance(result, Awaitable): + result = await result + self.debugger.set_quit() + self.debugger.clear_all_breaks() + self.debugging = False + return result def serialize_syntax_error(self, exc, source_code): raise # Rethrow to ensure FriendlyTraceback library is imported correctly From ceb84d415d1b44949c38e9501fad128162fd9a79 Mon Sep 17 00:00:00 2001 From: winniederidder Date: Sun, 20 Mar 2022 22:51:58 +0100 Subject: [PATCH 12/12] Add print action, ignore some messages --- src/CodeRunner.ts | 10 +++++ src/examples/PythonExamples.ts | 9 ++++- src/input/PdbInputHandler.ts | 2 +- src/workers/python/init.py | 72 +++++++++++++++++++++------------- 4 files changed, 63 insertions(+), 30 deletions(-) diff --git a/src/CodeRunner.ts b/src/CodeRunner.ts index af0a847f..d283e674 100644 --- a/src/CodeRunner.ts +++ b/src/CodeRunner.ts @@ -307,6 +307,16 @@ export class CodeRunner { const data: DebugAction = parseData(e.data, e.contentType); if (data.action === "highlight") { this.editor.highlight(parseInt(data.data)); + } else if (data.action === "print") { + if (!data.data.endsWith("\n")) { + data.data += "\n"; + } + this.publishEvent({ + type: BackendEventType.Output, + runId: this.runId, + data: data.data, + contentType: "text/plain" + }); } } } diff --git a/src/examples/PythonExamples.ts b/src/examples/PythonExamples.ts index 7cda4061..60575ae4 100644 --- a/src/examples/PythonExamples.ts +++ b/src/examples/PythonExamples.ts @@ -128,5 +128,12 @@ x = np.linspace(0, 10, 1000) plt.plot(x, np.sin(x)); plt.show() -` +`, + "Debugging": `def fibonacci(n): + if n <= 1: + return n + else: + return fibonacci(n-2) + fibonacci(n-1) + +print(fibonacci(3))` }; diff --git a/src/input/PdbInputHandler.ts b/src/input/PdbInputHandler.ts index 5f87c3cb..ed390015 100644 --- a/src/input/PdbInputHandler.ts +++ b/src/input/PdbInputHandler.ts @@ -6,8 +6,8 @@ import { DebuggingCommand, DebuggingInputHandler } from "./DebuggingInputHandler export class PdbInputHandler extends DebuggingInputHandler { protected override buildCommandMap(): Map { return new Map([ - [DebuggingCommand.StepInto, "s"], [DebuggingCommand.StepOver, "n"], + [DebuggingCommand.StepInto, "s"], [DebuggingCommand.Continue, "c"] ]); } diff --git a/src/workers/python/init.py b/src/workers/python/init.py index 8952b7eb..14b44df3 100644 --- a/src/workers/python/init.py +++ b/src/workers/python/init.py @@ -27,6 +27,7 @@ def __init__(self, output_buffer): super().__init__("debug", output_buffer) class Papyros(python_runner.PatchedStdinRunner): + DEFAULT_PDB_MESSAGES = ["--Return--", "--Call--", "--KeyboardInterrupt--"] def __init__( self, *, @@ -36,17 +37,33 @@ def __init__( super().__init__() self.limit = limit self.debugger = None - self.debugging = False + self.last_highlight = None self.override_globals() self.set_event_callback(callback) def process_debugging_message(self, message): - if self.filename not in message: - return None, dict(action="unknown", data="No action specified yet for different file: " + message) - nr_match = re.search("\((\d+)\)", message) - if nr_match: - return None, dict(action="highlight", data=nr_match.group(1)) - return None, dict(action="unknown", data="No action specified yet for message: " + message) + message = message.rstrip() + if not message: + return None + # Pdb file position starts with >, followed by a file name without brackets + # Then line number between brackets, followed by a module name without brackets + # And ends with another set of brackets. We capture the line number + line_nr_match = re.search("> [^\(\)]*\((\d+)[\(\)]*\)", message) + if line_nr_match: + line_nr = int(line_nr_match.group(1)) + if self.filename in message: + if self.last_highlight != line_nr: + self.last_highlight = line_nr + return dict(action="highlight", data=line_nr) + else: # Highlighting same line twice means Pdb reached the end of the file + self.last_highlight = None + self.debugger.set_quit() + else: + return dict(action="unknown", data="No action specified yet for different file: " + message) + elif message not in self.DEFAULT_PDB_MESSAGES: + return dict(action="print", data=message) + else: # Currently just ignore default messages + return None def set_event_callback(self, event_callback): if event_callback is None: @@ -63,15 +80,13 @@ def cb(typ, dat, **kwargs): if typ in ["stderr", "traceback", "syntax_error"]: cb("error", part["text"], contentType="text/json") elif typ == "stdout": - cb("output", part["text"], contentType="text/plain", debugging=str(self.debugging), matched=str(bool(re.search("> .*\(\)", part["text"])))) + cb("output", part["text"], contentType="text/plain") elif typ == "img": cb("output", part["text"], contentType=part["contentType"]) elif typ in ["input", "input_prompt"]: continue elif typ == "debug": - output, debug = self.process_debugging_message(part["text"]) - if output: - cb("output", output, contentType="text/plain") + debug = self.process_debugging_message(part["text"]) if debug: cb("debug", json.dumps(debug), contentType="text/json") else: @@ -80,6 +95,7 @@ def cb(typ, dat, **kwargs): return cb("input", json.dumps(dict(prompt=data["prompt"])), contentType="text/json") else: raise ValueError(f"Unknown event type {event_type}") + self.set_callback(runner_callback) def set_file(self, filename, code): @@ -128,8 +144,10 @@ def execute(self, code_obj, source_code, mode=None): # noqa async def run_async(self, source_code, mode="exec", top_level_await=True): """ - Mostly a copy of the parent `run_async` with `await ft` in case of an exception, - because `serialize_traceback` isn't async. + Mostly a copy of the parent `run_async` with some key differences + We use `await ft` in case of an exception, because `serialize_traceback` isn't async. + We call pre_run within to handle its exceptions within this try-block too + As it will rethrow the SyntaxError (@see serialize_syntax_error) """ with self._execute_context(source_code): try: @@ -151,21 +169,19 @@ async def debug_code(self, source_code, breakpoints): if not code_obj: return with self._execute_context(source_code): - self.debugger = pdb.Pdb(stdout=PdbOutputStream(self.output_buffer)) - self.debugger.use_rawinput = True - for line_nr in breakpoints: - self.debugger.set_break(filename=self.filename, lineno=line_nr) - self.debugging = True - self.line = "c\n" # Ensure first interrupt is continued until breakpoint - self.debugger.set_trace() - self.output_buffer.flush_length = 1 - result = self.execute(code_obj, source_code, "exec") - while isinstance(result, Awaitable): - result = await result - self.debugger.set_quit() - self.debugger.clear_all_breaks() - self.debugging = False - return result + self.debugger = pdb.Pdb(stdout=PdbOutputStream(self.output_buffer)) + self.debugger.use_rawinput = True + for line_nr in breakpoints: + self.debugger.set_break(filename=self.filename, lineno=line_nr) + self.line = "c\n" # Ensure first interrupt is continued until breakpoint + self.debugger.set_trace() + self.output_buffer.flush_length = 1 + result = self.execute(code_obj, source_code, "exec") + while isinstance(result, Awaitable): + result = await result + self.debugger.set_quit() + self.debugger.clear_all_breaks() + return result def serialize_syntax_error(self, exc, source_code): raise # Rethrow to ensure FriendlyTraceback library is imported correctly