Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add REPL plugin hooks; Add output, output-mode, stderr attributes #1106

Merged
merged 29 commits into from Mar 23, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
54d10d3
Add before, after REPL hooks
JeffersGlass Jan 11, 2023
62c1595
Basic stdout/stderr handling
JeffersGlass Jan 11, 2023
52d3c5d
Re-introduce 'output-mode' attribute for py-repl
JeffersGlass Jan 11, 2023
7692b56
Adjust to use options/kwargs
JeffersGlass Jan 25, 2023
be949d4
runtime -> interpreter
JeffersGlass Jan 26, 2023
b2d2d9c
Add plugin execution tests
JeffersGlass Jan 26, 2023
dd2cc99
Add test for output attribute on repl
JeffersGlass Jan 26, 2023
0a84e07
Add async test
JeffersGlass Jan 26, 2023
859cb4a
Stub remaining integration tests
JeffersGlass Jan 26, 2023
91c25e0
Remove dedent
JeffersGlass Jan 27, 2023
813bb48
Add dynamic-tag test
JeffersGlass Jan 27, 2023
cc4bbad
More tests
JeffersGlass Jan 27, 2023
92f720a
'Output=' doesn't affect display()
JeffersGlass Jan 29, 2023
48043e5
Adjust behavior of repl results
JeffersGlass Jan 29, 2023
ae0bdcf
Documentation
JeffersGlass Jan 29, 2023
47896ab
Changelog
JeffersGlass Jan 29, 2023
c4381f7
Cleanup
JeffersGlass Feb 6, 2023
eee2fe1
Merge from main
JeffersGlass Feb 6, 2023
2168944
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Feb 6, 2023
56b7770
Address comments
JeffersGlass Mar 20, 2023
856d61b
Merge from main
JeffersGlass Mar 20, 2023
a8deeea
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Mar 22, 2023
2e904a8
Remove unused imports
JeffersGlass Mar 22, 2023
982f477
Formats changelog
marimeireles Mar 22, 2023
d775649
Linting
marimeireles Mar 22, 2023
5bf2da8
Linting correctly
marimeireles Mar 22, 2023
9d84d86
Adjust changelog formatting
JeffersGlass Mar 22, 2023
49faecf
Merge branch 'main' into pyrepl-hooks
JeffersGlass Mar 22, 2023
41884c0
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Mar 22, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
18 changes: 15 additions & 3 deletions docs/changelog.md
Expand Up @@ -6,8 +6,21 @@
Features
--------


### <py-terminal>
- Added a `docked` field and attribute for the `<py-terminal>` custom element, enabled by default when the terminal is in `auto` mode, and able to dock the terminal at the bottom of the page with auto scroll on new code execution.

### &lt;py-script&gt;
- Restored the `output` attribute of `py-script` tags to route `sys.stdout` to a DOM element with the given ID. ([#1063](https://github.com/pyscript/pyscript/pull/1063))
- Added a `stderr` attribute of `py-script` tags to route `sys.stderr` to a DOM element with the given ID. ([#1063](https://github.com/pyscript/pyscript/pull/1063))

### &lt;py-repl&gt;
- The `output` attribute of `py-repl` tags now specifies the id of the DOM element that `sys.stdout`, `sys.stderr`, and the results of a REPL execution are written to. It no longer affects the location of calls to `display()`
- Added a `stderr` attribute of `py-repl` tags to route `sys.stderr` to a DOM element with the given ID. ([#1106](https://github.com/pyscript/pyscript/pull/1106))
- Resored the `output-mode` attribute of `py-repl` tags. If `output-mode` == 'append', the DOM element where output is printed is _not_ cleared before writing new results.

### Plugins
- Plugins may now implement the `beforePyReplExec()` and `afterPyReplExec()` hooks, which are called immediately before and after code in a `py-repl` tag is executed. ([#1106](https://github.com/pyscript/pyscript/pull/1106))

Bug fixes
---------
Expand All @@ -24,12 +37,10 @@ Enhancements
2023.01.1
=========


Features
--------

- Restored the `output` attribute of &lt;py-script&gt; tags to route `sys.stdout` to a DOM element with the given ID. ([#1063](https://github.com/pyscript/pyscript/pull/1063))
- Added a `stderr` attribute of &lt;py-script&gt; tags to route `sys.stderr` to a DOM element with the given ID. ([#1063](https://github.com/pyscript/pyscript/pull/1063))

Bug fixes
---------

Expand All @@ -39,6 +50,7 @@ Bug fixes

Enhancements
------------

- When adding a `py-` attribute to an element but didn't added an `id` attribute, PyScript will now generate a random ID for the element instead of throwing an error which caused the splash screen to not shutdown. ([#1122](https://github.com/pyscript/pyscript/pull/1122))
- You can now disable the splashscreen by setting `enabled = false` in your `py-config` under the `[splashscreen]` configuration section. ([#1138](https://github.com/pyscript/pyscript/pull/1138))

Expand Down
30 changes: 24 additions & 6 deletions docs/reference/elements/py-repl.md
Expand Up @@ -7,24 +7,42 @@ The `<py-repl>` element provides a REPL(Read Eval Print Loop) to evaluate multi-
| attribute | type | default | description |
|-------------------|---------|---------|---------------------------------------|
| **auto-generate** | boolean | | Auto-generates REPL after evaluation |
| **output** | string | | The element to write output into |
| **output-mode** | string | "" | Determines whether the output element is cleared prior to writing output |
| **output** | string | | The id of the element to write `stdout` and `stderr` to |
| **stderr** | string | | The id of the element to write `stderr` to |

### Examples

#### `<py-repl>` element set to auto-generate
### `auto-generate`
If a \<py-repl\> tag has the `auto-generate` attribute, upon execution, another \<pr-repl\> tag will be created and added to the DOM as a sibling of the current tag.

### `output-mode`
By default, the element which displays the output from a REPL is cleared (`innerHTML` set to "") prior to each new execution of the REPL. If `output-mode` == "append", that element is not cleared, and the output is appended instead.
JeffersGlass marked this conversation as resolved.
Show resolved Hide resolved

### `output`
The ID of an element in the DOM that `stdout` (e.g. `print()`), `stderr`, and the results of executing the repl are written to. Defaults to an automatically-generated \<div\> as the next sibling of the REPL itself.

### `stderr`
The ID of an element in the DOM that `stderr` will be written to. Defaults to None, though writes to `stderr` will still appear in the location specified by `output`.

## Examples

### `<py-repl>` element set to auto-generate

```html
<py-repl auto-generate="true"> </py-repl>
```

#### `<py-repl>` element with output
### `<py-repl>` element with output

The following will write "Hello! World!" to the div with id `replOutput`.

```html
<div id="replOutput"></div>
<py-repl output="replOutput">
hello = "Hello world!"
print("Hello!")
hello = "World!"
hello
</py-repl>
```

Note that if we `print` any element in the repl, the output will be printed in the [`py-terminal`](../plugins/py-terminal.md) if is enabled.
Note that if we `print` from the REPL (or otherwise write to `sys.stdout`), the output will be printed in the [`py-terminal`](../plugins/py-terminal.md) if is enabled.
5 changes: 3 additions & 2 deletions pyscriptjs/src/components/elements.ts
@@ -1,10 +1,11 @@
import { InterpreterClient } from '../interpreter_client';
import type { PyScriptApp } from '../main';
import { make_PyRepl } from './pyrepl';
import { make_PyWidget } from './pywidget';

function createCustomElements(interpreter: InterpreterClient) {
function createCustomElements(interpreter: InterpreterClient, app: PyScriptApp) {
const PyWidget = make_PyWidget(interpreter);
const PyRepl = make_PyRepl(interpreter);
const PyRepl = make_PyRepl(interpreter, app);

/* eslint-disable @typescript-eslint/no-unused-vars */
const xPyRepl = customElements.define('py-repl', PyRepl);
Expand Down
73 changes: 24 additions & 49 deletions pyscriptjs/src/components/pyrepl.ts
Expand Up @@ -7,14 +7,16 @@ import { defaultKeymap } from '@codemirror/commands';
import { oneDarkTheme } from '@codemirror/theme-one-dark';

import { getAttribute, ensureUniqueId, htmlDecode } from '../utils';
import { pyExec, pyDisplay } from '../pyexec';
import { pyExec } from '../pyexec';
import { getLogger } from '../logger';
import { InterpreterClient } from '../interpreter_client';
import type { PyScriptApp } from '../main';
import { Stdio } from '../stdio';

const logger = getLogger('py-repl');
const RUNBUTTON = `<svg style="height:20px;width:20px;vertical-align:-.125em;transform-origin:center;overflow:visible;color:green" viewBox="0 0 384 512" aria-hidden="true" role="img" xmlns="http://www.w3.org/2000/svg"><g transform="translate(192 256)" transform-origin="96 0"><g transform="translate(0,0) scale(1,1)"><path d="M361 215C375.3 223.8 384 239.3 384 256C384 272.7 375.3 288.2 361 296.1L73.03 472.1C58.21 482 39.66 482.4 24.52 473.9C9.377 465.4 0 449.4 0 432V80C0 62.64 9.377 46.63 24.52 38.13C39.66 29.64 58.21 29.99 73.03 39.04L361 215z" fill="currentColor" transform="translate(-192 -256)"></path></g></g></svg>`;

export function make_PyRepl(interpreter: InterpreterClient) {
export function make_PyRepl(interpreter: InterpreterClient, app: PyScriptApp) {
/* High level structure of py-repl DOM, and the corresponding JS names.

this <py-repl>
Expand All @@ -31,6 +33,8 @@ export function make_PyRepl(interpreter: InterpreterClient) {
shadow: ShadowRoot;
outDiv: HTMLElement;
editor: EditorView;
stdout_manager: Stdio | null;
stderr_manager: Stdio | null;

constructor() {
super();
Expand Down Expand Up @@ -152,27 +156,19 @@ export function make_PyRepl(interpreter: InterpreterClient) {
*/
async execute(): Promise<void> {
const pySrc = this.getPySrc();

// determine the output element
const outEl = this.getOutputElement();
if (outEl === undefined) {
// this happens if we specified output="..." but we couldn't
// find the ID. We already displayed an error message inside
// getOutputElement, stop the execution.
return;
}

// clear the old output before executing the new code
outEl.innerHTML = '';
const outEl = this.outDiv;

// execute the python code
app.plugins.beforePyReplExec({ interpreter: interpreter, src: pySrc, outEl: outEl, pyReplTag: this });
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const pyResult = (await pyExec(interpreter, pySrc, outEl)).result;

// display the value of the last evaluated expression (REPL-style)
if (pyResult !== undefined) {
pyDisplay(interpreter, pyResult, { target: outEl.id });
}
app.plugins.afterPyReplExec({
interpreter: interpreter,
src: pySrc,
outEl: outEl,
pyReplTag: this,
result: pyResult, // eslint-disable-line @typescript-eslint/no-unsafe-assignment
});

this.autogenerateMaybe();
}
Expand All @@ -181,21 +177,6 @@ export function make_PyRepl(interpreter: InterpreterClient) {
return this.editor.state.doc.toString();
}

getOutputElement(): HTMLElement {
const outputID = getAttribute(this, 'output');
if (outputID !== null) {
const el = document.getElementById(outputID);
if (el === null) {
const err = `py-repl ERROR: cannot find the output element #${outputID} in the DOM`;
this.outDiv.innerText = err;
return undefined;
}
return el;
} else {
return this.outDiv;
}
}

// XXX the autogenerate logic is very messy. We should redo it, and it
// should be the default.
autogenerateMaybe(): void {
Expand All @@ -206,27 +187,21 @@ export function make_PyRepl(interpreter: InterpreterClient) {
const nextExecId = parseInt(lastExecId) + 1;

const newPyRepl = document.createElement('py-repl');
newPyRepl.setAttribute('root', this.getAttribute('root'));
newPyRepl.id = this.getAttribute('root') + '-' + nextExecId.toString();

if (this.hasAttribute('auto-generate')) {
newPyRepl.setAttribute('auto-generate', '');
this.removeAttribute('auto-generate');
}

const outputMode = getAttribute(this, 'output-mode');
if (outputMode) {
newPyRepl.setAttribute('output-mode', outputMode);
}

const addReplAttribute = (attribute: string) => {
//Attributes to be copied from old REPL to auto-generated REPL
for (const attribute of ['root', 'output-mode', 'output', 'stderr']) {
const attr = getAttribute(this, attribute);
if (attr) {
newPyRepl.setAttribute(attribute, attr);
}
};
}

addReplAttribute('output');
newPyRepl.id = this.getAttribute('root') + '-' + nextExecId.toString();

if (this.hasAttribute('auto-generate')) {
newPyRepl.setAttribute('auto-generate', '');
this.removeAttribute('auto-generate');
}

newPyRepl.setAttribute('exec-id', nextExecId.toString());
if (this.parentElement) {
Expand Down
3 changes: 2 additions & 1 deletion pyscriptjs/src/main.ts
Expand Up @@ -189,8 +189,9 @@ export class PyScriptApp {

this.logStatus('Initializing web components...');
// lifecycle (8)
createCustomElements(interpreter);

//Takes a runtime and a reference to the PyScriptApp (to access plugins)
createCustomElements(interpreter, this);
await initHandlers(interpreter);

// NOTE: interpreter message is used by integration tests to know that
Expand Down
46 changes: 44 additions & 2 deletions pyscriptjs/src/plugin.ts
Expand Up @@ -55,7 +55,7 @@ export class Plugin {
/** The source of a <py-script>> tag has been fetched, and we're about
* to evaluate that source using the provided interpreter.
*
* @param options.interpreter The Interpreter object that will be used to evaluated the Python source code
* @param options.interpreter The Interpreter object that will be used to evaluate the Python source code
* @param options.src {string} The Python source code to be evaluated
* @param options.pyScriptTag The <py-script> HTML tag that originated the evaluation
*/
Expand All @@ -66,7 +66,7 @@ export class Plugin {
/** The Python in a <py-script> has just been evaluated, but control
* has not been ceded back to the JavaScript event loop yet
*
* @param options.interpreter The Interpreter object that will be used to evaluated the Python source code
* @param options.interpreter The Interpreter object that will be used to evaluate the Python source code
* @param options.src {string} The Python source code to be evaluated
* @param options.pyScriptTag The <py-script> HTML tag that originated the evaluation
* @param options.result The returned result of evaluating the Python (if any)
Expand All @@ -80,6 +80,36 @@ export class Plugin {
/* empty */
}

/** The source of the <py-repl> tag has been fetched and its output-element determined;
* we're about to evaluate the source using the provided interpreter
*
* @param options.interpreter The interpreter object that will be used to evaluated the Python source code
* @param options.src {string} The Python source code to be evaluated
* @param options.outEl The element that the result of the REPL evaluation will be output to.
* @param options.pyReplTag The <py-repl> HTML tag the originated the evaluation
*/
beforePyReplExec(options: { interpreter: InterpreterClient; src: string; outEl: HTMLElement; pyReplTag: any }) {
/* empty */
}

/**
*
* @param options.interpreter The interpreter object that will be used to evaluated the Python source code
* @param options.src {string} The Python source code to be evaluated
* @param options.outEl The element that the result of the REPL evaluation will be output to.
* @param options.pyReplTag The <py-repl> HTML tag the originated the evaluation
* @param options.result The result of evaluating the Python (if any)
*/
afterPyReplExec(options: {
interpreter: InterpreterClient;
src: string;
outEl: HTMLElement;
pyReplTag: HTMLElement;
result: any;
}) {
/* empty */
}

/** Startup complete. The interpreter is initialized and ready, user
* scripts have been executed: the main initialization logic ends here and
* the page is ready to accept user interactions.
Expand Down Expand Up @@ -158,6 +188,18 @@ export class PluginManager {
for (const p of this._pythonPlugins) p.afterPyScriptExec?.callKwargs(options);
}

beforePyReplExec(options: { interpreter: InterpreterClient; src: string; outEl: HTMLElement; pyReplTag: any }) {
for (const p of this._plugins) p.beforePyReplExec(options);

for (const p of this._pythonPlugins) p.beforePyReplExec?.callKwargs(options);
}

afterPyReplExec(options: { interpreter: InterpreterClient; src: string; outEl; pyReplTag; result }) {
for (const p of this._plugins) p.afterPyReplExec(options);

for (const p of this._pythonPlugins) p.afterPyReplExec?.callKwargs(options);
}

onUserError(error: UserError) {
for (const p of this._plugins) p.onUserError?.(error);

Expand Down
72 changes: 71 additions & 1 deletion pyscriptjs/src/plugins/stdiodirector.ts
@@ -1,7 +1,10 @@
import { Plugin } from '../plugin';
import { TargetedStdio, StdioMultiplexer } from '../stdio';
import type { InterpreterClient } from '../interpreter_client';
import { createSingularWarning } from '../utils';
import { make_PyScript } from '../components/pyscript';
import { InterpreterClient } from '../interpreter_client';
import { pyDisplay } from '../pyexec';
import { make_PyRepl } from '../components/pyrepl';

type PyScriptTag = InstanceType<ReturnType<typeof make_PyScript>>;

Expand Down Expand Up @@ -58,4 +61,71 @@ export class StdioDirector extends Plugin {
options.pyScriptTag.stderr_manager = null;
}
}

beforePyReplExec(options: {
interpreter: InterpreterClient;
src: string;
outEl: HTMLElement;
pyReplTag: InstanceType<ReturnType<typeof make_PyRepl>>;
}): void {
//Handle 'output-mode' attribute (removed in PR #881/f9194cc8, restored here)
//If output-mode == 'append', don't clear target tag before writing
if (options.pyReplTag.getAttribute('output-mode') != 'append') {
options.outEl.innerHTML = '';
}

// Handle 'output' attribute; defaults to writing stdout to the existing outEl
// If 'output' attribute is used, the DOM element with the specified ID receives
// -both- sys.stdout and sys.stderr
let output_targeted_io: TargetedStdio;
if (options.pyReplTag.hasAttribute('output')) {
output_targeted_io = new TargetedStdio(options.pyReplTag, 'output', true, true);
} else {
output_targeted_io = new TargetedStdio(options.pyReplTag.outDiv, 'id', true, true);
}
options.pyReplTag.stdout_manager = output_targeted_io;
this._stdioMultiplexer.addListener(output_targeted_io);

//Handle 'stderr' attribute;
if (options.pyReplTag.hasAttribute('stderr')) {
const stderr_targeted_io = new TargetedStdio(options.pyReplTag, 'stderr', false, true);
options.pyReplTag.stderr_manager = stderr_targeted_io;
this._stdioMultiplexer.addListener(stderr_targeted_io);
}
}

afterPyReplExec(options: {
interpreter: InterpreterClient;
src: string;
outEl: HTMLElement;
pyReplTag: InstanceType<ReturnType<typeof make_PyRepl>>;
result: any; // eslint-disable-line @typescript-eslint/no-explicit-any
}): void {
// display the value of the last-evaluated expression in the REPL
if (options.result !== undefined) {
const outputId: string | undefined = options.pyReplTag.getAttribute('output');
if (outputId) {
// 'output' attribute also used as location to send
// result of REPL
if (document.getElementById(outputId)) {
pyDisplay(options.interpreter, options.result, { target: outputId });
} else {
//no matching element on page
createSingularWarning(`output = "${outputId}" does not match the id of any element on the page.`);
}
} else {
// 'otuput atribuite not provided
JeffersGlass marked this conversation as resolved.
Show resolved Hide resolved
pyDisplay(options.interpreter, options.result, { target: options.outEl.id });
}
}

if (options.pyReplTag.stdout_manager != null) {
this._stdioMultiplexer.removeListener(options.pyReplTag.stdout_manager);
options.pyReplTag.stdout_manager = null;
}
if (options.pyReplTag.stderr_manager != null) {
this._stdioMultiplexer.removeListener(options.pyReplTag.stderr_manager);
options.pyReplTag.stderr_manager = null;
}
}
}