Skip to content

Commit

Permalink
Allow stopping running Python code.
Browse files Browse the repository at this point in the history
  • Loading branch information
rblank committed Sep 21, 2024
1 parent 11c892d commit 4349dda
Show file tree
Hide file tree
Showing 6 changed files with 127 additions and 58 deletions.
1 change: 1 addition & 0 deletions docs/demo/python.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ traceback on `sys.stderr`.

```{exec} python
:after: python-setup
:editable:
def outer():
try:
inner()
Expand Down
14 changes: 6 additions & 8 deletions tdoc/common/static/styles/t-doc.css
Original file line number Diff line number Diff line change
Expand Up @@ -77,36 +77,34 @@ div.cm-editor div.cm-scroller {
/* Execution controls */
div[class*=highlight-].tdoc-exec {
flex-direction: row;
column-gap: 0.3rem;
}
div.tdoc-exec div.highlight {
flex-basis: 100%;
}
div.tdoc-exec-controls {
display: flex;
flex-direction: column;
margin-left: 0.3rem;
row-gap: 0.3rem;
}
div.tdoc-exec-controls button {
width: 1.8rem;
height: 1.8rem;
border-radius: 0.25rem;
border: 1px solid var(--pst-color-border);
border-radius: 0.25rem;
background-color: var(--pst-color-surface);
font: var(--fa-font-solid);
transition: background-color .3s;
}
div.tdoc-exec-controls button:hover {
background-color: var(--pst-color-shadow);
}
div.tdoc-exec-controls *:first-child {
margin-top: 0;
}
div.tdoc-exec-controls * {
margin-top: 0.3rem;
}
div.tdoc-exec-controls button.tdoc-exec-run::before {
content: '\f04b';
}
div.tdoc-exec-controls button.tdoc-exec-stop::before {
content: '\f04d';
}
div.tdoc-exec-controls button.tdoc-exec-reset::before {
content: '\f2ea';
}
Expand Down
89 changes: 53 additions & 36 deletions tdoc/common/static/tdoc-exec.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,16 +50,22 @@ function* walkAfterTree(node, seen) {
yield node;
}

// A base class for {exec} node handlers.
// A base class for {exec} block handlers.
export class Executor {
// Apply an {exec} node handler class.
// Return a list of all {exec} blocks with the given handler class.
static query(cls) {
return document.querySelectorAll(`div.tdoc-exec.highlight-${cls.lang}`);
}

// Apply an {exec} block handler class.
static async apply(cls) {
await waitLoaded();
for (const exec of document.querySelectorAll(
`div.tdoc-exec.highlight-${cls.lang}`)) {
const handler = new cls(exec);
for (const node of Executor.query(cls)) {
const handler = node.tdocHandler = new cls(node);
if (handler.editable) handler.addEditor();
handler.addControls();
const controls = element(`<div class="tdoc-exec-controls"></div>`);
handler.addControls(controls);
if (controls.children.length > 0) node.appendChild(controls);

// Execute immediately if requested.
if (handler.when === 'load') handler.tryRun(); // Don't await
Expand All @@ -85,7 +91,7 @@ export class Executor {
this.origText = Executor.preText(this.node).trim();
}

// Add an editor to the {exec} node.
// Add an editor to the {exec} block.
addEditor() {
addEditor(this.node.querySelector('div.highlight'), {
language: this.constructor.lang,
Expand All @@ -95,45 +101,56 @@ export class Executor {
});
}

// Add controls to the {exec} node.
addControls() {
const controls = element(`<div class="tdoc-exec-controls"></div>`);
if (this.when === 'click' || (this.editable && this.when !== 'never')) {
controls.appendChild(element(`\
<button class="tdoc-exec-run"\
title="Run${this.editable ? ' (Shift+Enter)' : ''}">\
</button>`))
.addEventListener('click', async () => {
await this.tryRun();
});
}
// Add controls to the {exec} block.
addControls(controls) {
if (this.editable && this.origText !== '') {
controls.appendChild(element(`\
<button class="tdoc-exec-reset" title="Reset input"></button>`))
.addEventListener('click', () => {
const editor = findEditor(this.node), state = editor.state;
editor.dispatch(state.update({changes: {
from: 0, to: state.doc.length,
insert: this.origText,
}}));
});
controls.appendChild(this.resetControl());
}
if (controls.children.length > 0) this.node.appendChild(controls);
}

// Yield the code from the nodes in the :after: chain of the {exec} node.
runControl() {
const ctrl = element(`\
<button class="tdoc-exec-run"\
title="Run${this.editable ? ' (Shift+Enter)' : ''}">\
</button>`);
ctrl.addEventListener('click', async () => { await this.tryRun(); });
return ctrl;
}

stopControl() {
const ctrl = element(
`<button class="tdoc-exec-stop" title="Stop"></button>`);
ctrl.addEventListener('click', async () => { await this.stop(); });
return ctrl;
}

resetControl() {
const ctrl = element(
`<button class="tdoc-exec-reset" title="Reset input"></button>`);
ctrl.addEventListener('click', () => {
const editor = findEditor(this.node), state = editor.state;
editor.dispatch(state.update({changes: {
from: 0, to: state.doc.length,
insert: this.origText,
}}));
});
return ctrl;
}

// Yield the code from the nodes in the :after: chain of the {exec} block.
*codeBlocks() {
for (const node of walkAfterTree(this.node, new Set())) {
yield [Executor.text(node), node]
}
}

// TODO: Prevent multiple parallel executions of the same node, and disable
// the "Run" button while executing.

// Run the code in the {exec} block.
async run() { throw Error("not implemented"); }

// Run the code in the {exec} node. Catch and log exceptions.
// Stop the running code.
async stop() { throw Error("not implemented"); }

// Run the code in the {exec} block. Catch and log exceptions.
async tryRun() {
try {
await this.run();
Expand All @@ -142,7 +159,7 @@ export class Executor {
}
}

// Append output nodes associated with the {exec} node.
// Append output nodes associated with the {exec} block.
appendOutputs(outputs) {
let prev = this.node;
for (;;) {
Expand All @@ -152,7 +169,7 @@ export class Executor {
}
prev.after(...out);
}
// Replace the output nodes associated with the {exec} node.
// Replace the output nodes associated with the {exec} block.
replaceOutputs(outputs) {
let prev = this.node, i = 0;
for (;; ++i) {
Expand Down
43 changes: 34 additions & 9 deletions tdoc/common/static/tdoc-python.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ const worker = XWorker(import.meta.resolve('./tdoc-python.py'), {
config: {},
});
const {promise: ready, resolve: resolve_ready} = signal();
worker.sync.ready = () => { resolve_ready(); };
worker.sync.ready = (msg) => {
console.info(`[t-doc] ${msg}`);
resolve_ready();
};

const stdio = {};
worker.sync.write = (run_id, stream, data) => {
Expand All @@ -30,10 +33,6 @@ worker.sync.write = (run_id, stream, data) => {
}
};

// TODO: Always display the play button
// TODO: Grey out the play button while the interpreter isn't ready
// TODO: Toggle the play button to a stop button while the code is running.
// Use async cancellation when stopping.
// TODO: Add a button to each {exec} output to remove it
// TODO: Make terminal output configurable. If ":output: always", then create
// the terminal output right away to avoid flickering.
Expand All @@ -42,12 +41,23 @@ class PythonExecutor extends Executor {
static lang = 'python';
static next_run_id = 0;

addControls(controls) {
if (this.when !== 'never') {
this.runCtrl = controls.appendChild(this.runControl());
this.runCtrl.disabled = true;
this.stopCtrl = controls.appendChild(this.stopControl());
this.stopCtrl.disabled = true;
this.stopCtrl.classList.add('hidden');
}
super.addControls(controls);
}

async run() {
const run_id = PythonExecutor.next_run_id++;
this.run_id = PythonExecutor.next_run_id++;
try {
this.replaceOutputs([]);
let pre;
stdio[run_id] = (stream, data) => {
stdio[this.run_id] = (stream, data) => {
if (!pre) {
const output = element(`\
<div class="tdoc-exec-output tdoc-captioned">\
Expand All @@ -72,11 +82,13 @@ class PythonExecutor extends Executor {
pre.appendChild(node);
};
await ready;
this.runCtrl.classList.add('hidden');
this.stopCtrl.classList.remove('hidden');
const blocks = [];
for (const [code, node] of this.codeBlocks()) {
blocks.push([code, node.id]);
}
await worker.sync.run(run_id, blocks)
await worker.sync.run(this.run_id, blocks)
} catch (e) {
console.error(e);
const msg = e.toString();
Expand All @@ -85,9 +97,22 @@ class PythonExecutor extends Executor {
output.appendChild(text(` ${msg}`));
this.appendOutputs([output]);
} finally {
delete stdio[run_id];
delete stdio[this.run_id];
delete this.run_id;
this.runCtrl.classList.remove('hidden');
this.stopCtrl.classList.add('hidden');
}
}

async stop() {
if (this.run_id) await worker.sync.stop(this.run_id);
}
}

Executor.apply(PythonExecutor);
await ready;
for (const node of Executor.query(PythonExecutor)) {
const h = node.tdocHandler;
if (h.runCtrl) h.runCtrl.disabled = false;
if (h.stopCtrl) h.stopCtrl.disabled = false;
}
18 changes: 16 additions & 2 deletions tdoc/common/static/tdoc-python.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,17 @@
# SPDX-License-Identifier: MIT

import ast
import asyncio
import contextvars
import io
import platform
import sys
import traceback

from polyscript import xworker

run_id_var = contextvars.ContextVar('run_id', default=None)
tasks = {}


def export(fn):
Expand Down Expand Up @@ -42,8 +45,9 @@ def write(self, data, /):
@export
async def run(run_id, blocks):
run_id_var.set(run_id)
tasks[run_id] = asyncio.current_task()
try:
blocks = [compile(b, name or '<block>', 'exec',
blocks = [compile(b, name or '<unnamed>', 'exec',
flags=ast.PyCF_ALLOW_TOP_LEVEL_AWAIT)
for b, name in blocks]
g = {'__name__': '__main__'}
Expand All @@ -57,6 +61,16 @@ async def run(run_id, blocks):
# manipulations have undesirable side-effects.
if line.startswith(' File "<exec>", line'): continue
print(line, file=sys.stderr, end='')
finally:
del tasks[run_id]


xworker.sync.ready()
@export
def stop(run_id):
if (task := tasks.get(run_id)) is not None:
task.cancel()


xworker.sync.ready(f"{platform.python_implementation()}"
f" {'.'.join(platform.python_version_tuple())}"
f" on {platform.platform()}")
20 changes: 17 additions & 3 deletions tdoc/common/static/tdoc-sql.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,21 @@ class Database {

class SqlExecutor extends Executor {
static lang = 'sql';
static db_num = 0;
static next_run_id = 0;

addControls(controls) {
if (this.when === 'click' || (this.editable && this.when !== 'never')) {
this.runCtrl = controls.appendChild(this.runControl());
this.runCtrl.disabled = true;
}
super.addControls(controls);
}

async run() {
let output, tbody;
const db = await Database.open(
`file:db-${SqlExecutor.db_num++}?vfs=memdb`);
`file:db-${SqlExecutor.next_run_id++}?vfs=memdb`);
if (this.runCtrl) this.runCtrl.disabled = true;
try {
for (const [code, node] of this.codeBlocks()) {
await db.exec(code, res => {
Expand Down Expand Up @@ -93,12 +102,17 @@ class SqlExecutor extends Executor {
<div class="tdoc-exec-output tdoc-error"><strong>Error:</strong></div>`);
output.appendChild(text(` ${msg}`));
} finally {
if (this.runCtrl) this.runCtrl.disabled = false;
await db.close();
}
this.replaceOutputs(output ? [output] : []);
}
}

Executor.apply(SqlExecutor);
const config = await Database.config();
console.info(`[t-doc] SQLite version: ${config.version.libVersion}`);
Executor.apply(SqlExecutor);
for (const node of Executor.query(SqlExecutor)) {
const h = node.tdocHandler;
if (h.runCtrl) h.runCtrl.disabled = false;
}

0 comments on commit 4349dda

Please sign in to comment.