Lite: Add methods to run Python files mounted on a virtual file system, and unmount() method (#4785)

* Create WorkerProxy.runPythonFile() to run a mounted Python file and rename .runPythonAsync() to .runPythonCode()

* Fix lite/index.html and app/lite/index.ts adjusting the new option name

* Add changeset

* Add controller.unmount()

* Add changeset

---------

Co-authored-by: pngwn <hello@pngwn.io>
This commit is contained in:
Yuichiro Tachibana (Tsuchiya) 2023-07-06 19:43:14 +09:00 committed by GitHub
parent 8d0d4e0a8e
commit da0e94479a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 199 additions and 16 deletions

View File

@ -0,0 +1,5 @@
---
"@gradio/lite": minor
---
Add methods to execute mounted Python files

View File

@ -0,0 +1,5 @@
---
"@gradio/lite": patch
---
Add controller.unmount()

View File

@ -29,18 +29,21 @@ declare let GRADIO_VERSION: string;
// const ENTRY_CSS = "__ENTRY_CSS__";
interface GradioAppController {
rerun: (code: string) => Promise<void>;
run_code: (code: string) => Promise<void>;
run_file: (path: string) => Promise<void>;
write: (path: string, data: string | ArrayBufferView, opts: any) => Promise<void>;
rename: (old_path: string, new_path: string) => Promise<void>;
unlink: (path: string) => Promise<void>;
install: (requirements: string[]) => Promise<void>;
unmount: () => void;
}
interface Options {
target: HTMLElement;
files?: WorkerProxyOptions["files"];
requirements?: WorkerProxyOptions["requirements"];
pyCode: string;
code?: string;
entrypoint?: string;
info: boolean;
container: boolean;
isEmbed: boolean;
@ -67,11 +70,17 @@ export function create(options: Options): GradioAppController {
requirements: options.requirements ?? [],
});
// Internally, the execution of `runPythonAsync()` is queued
// Internally, the execution of `runPythonCode()` or `runPythonFile()` is queued
// and its promise will be resolved after the Pyodide is loaded and the worker initialization is done
// (see the await in the `onmessage` callback in the webworker code)
// So we don't await this promise because we want to mount the `Index` immediately and start the app initialization asynchronously.
worker_proxy.runPythonAsync(options.pyCode);
if (options.code != null) {
worker_proxy.runPythonCode(options.code);
} else if (options.entrypoint != null) {
worker_proxy.runPythonFile(options.entrypoint);
} else {
throw new Error("Either code or entrypoint must be provided.");
}
mount_prebuilt_css(document.head);
@ -122,8 +131,12 @@ export function create(options: Options): GradioAppController {
launchNewApp();
return {
rerun: async (code: string): Promise<void> => {
await worker_proxy.runPythonAsync(code);
run_code: async (code: string): Promise<void> => {
await worker_proxy.runPythonCode(code);
launchNewApp();
},
run_file: async (path: string): Promise<void> => {
await worker_proxy.runPythonFile(path);
launchNewApp();
},
write(path, data, opts) {
@ -137,6 +150,10 @@ export function create(options: Options): GradioAppController {
},
install(requirements) {
return worker_proxy.install(requirements);
},
unmount() {
app.$destroy();
worker_proxy.terminate();
}
};
}
@ -186,7 +203,7 @@ if (BUILD_MODE === "dev") {
url: "https://raw.githubusercontent.com/gradio-app/gradio/main/guides/assets/logo.png"
}
},
pyCode: initial_code,
code: initial_code,
requirements: initial_requirements,
info: true,
container: true,
@ -201,7 +218,7 @@ if (BUILD_MODE === "dev") {
exec_button.onclick = () => {
console.debug("exec_button.onclick");
controller.rerun(code_input.value);
controller.run_code(code_input.value);
console.debug("Rerun finished")
}

View File

@ -25,7 +25,7 @@
<script type="module"> // type="module" is necessary to use `createGradioApp()`, which is loaded with <script type="module" /> tag above.
createGradioApp({
target: document.getElementById("gradio-app"),
pyCode: `
code: `
import gradio as gr
def greet(name):

View File

@ -33,12 +33,18 @@ export interface InMessageInit extends InMessageBase {
requirements: string[];
};
}
export interface InMessageRunPython extends InMessageBase {
type: "run-python";
export interface InMessageRunPythonCode extends InMessageBase {
type: "run-python-code";
data: {
code: string;
};
}
export interface InMessageRunPythonFile extends InMessageBase {
type: "run-python-file";
data: {
path: string;
};
}
export interface InMessageHttpRequest extends InMessageBase {
type: "http-request";
data: {
@ -81,7 +87,8 @@ export interface InMessageEcho extends InMessageBase {
export type InMessage =
| InMessageInit
| InMessageRunPython
| InMessageRunPythonCode
| InMessageRunPythonFile
| InMessageHttpRequest
| InMessageFileWrite
| InMessageFileRename

View File

@ -11,6 +11,7 @@ import type {
import { writeFileWithParents, renameWithParents } from "./file";
import { verifyRequirements } from "./requirements";
import { makeHttpRequest } from "./http";
import scriptRunnerPySource from "./py/script_runner.py?raw"
importScripts("https://cdn.jsdelivr.net/pyodide/v0.23.2/full/pyodide.js");
@ -23,6 +24,7 @@ let call_asgi_app_from_js: (
receive: () => Promise<unknown>,
send: (event: any) => Promise<void>
) => Promise<void>;
let run_script: (path: string) => void;
async function loadPyodideAndPackages(options: InMessageInit["data"]): Promise<void> {
console.debug("Loading Pyodide.");
@ -156,6 +158,11 @@ import matplotlib
matplotlib.use("agg")
`);
console.debug("matplotlib backend is set.");
console.debug("Set up a script runner");
await pyodide.runPythonAsync(scriptRunnerPySource);
run_script = pyodide.globals.get("_run_script");
console.debug("A script runner is set up.");
}
self.onmessage = async (event: MessageEvent<InMessage>): Promise<void> => {
@ -191,7 +198,7 @@ self.onmessage = async (event: MessageEvent<InMessage>): Promise<void> => {
messagePort.postMessage(replyMessage);
break;
}
case "run-python": {
case "run-python-code": {
await pyodide.runPythonAsync(msg.data.code);
const replyMessage: ReplyMessageSuccess = {
type: "reply:success",
@ -200,6 +207,16 @@ self.onmessage = async (event: MessageEvent<InMessage>): Promise<void> => {
messagePort.postMessage(replyMessage);
break;
}
case "run-python-file": {
run_script(msg.data.path);
const replyMessage: ReplyMessageSuccess = {
type: "reply:success",
data: null
};
messagePort.postMessage(replyMessage);
break;
}
case "http-request": {
const request = msg.data.request;
const response = await makeHttpRequest(call_asgi_app_from_js, request);

View File

@ -0,0 +1,3 @@
[*.py]
indent_style = space
indent_size = 4

View File

@ -0,0 +1,120 @@
import tokenize
import types
import sys
# BSD 3-Clause License
#
# - Copyright (c) 2008-Present, IPython Development Team
# - Copyright (c) 2001-2007, Fernando Perez <fernando.perez@colorado.edu>
# - Copyright (c) 2001, Janko Hauser <jhauser@zscout.de>
# - Copyright (c) 2001, Nathaniel Gray <n8gray@caltech.edu>
#
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
# * Redistributions of source code must retain the above copyright notice, this
# list of conditions and the following disclaimer.
# * Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
# * Neither the name of the copyright holder nor the names of its
# contributors may be used to endorse or promote products derived from
# this software without specific prior written permission.
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
# Code modified from IPython (BSD license)
# Source: https://github.com/ipython/ipython/blob/master/IPython/utils/syspathcontext.py#L42
class modified_sys_path:
"""A context for prepending a directory to sys.path for a second."""
def __init__(self, script_path: str):
self._script_path = script_path
self._added_path = False
def __enter__(self):
if self._script_path not in sys.path:
sys.path.insert(0, self._script_path)
self._added_path = True
def __exit__(self, type, value, traceback):
if self._added_path:
try:
sys.path.remove(self._script_path)
except ValueError:
# It's already removed.
pass
# Returning False causes any exceptions to be re-raised.
return False
# Copyright (c) Streamlit Inc. (2018-2022) Snowflake Inc. (2022)
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
def _new_module(name: str) -> types.ModuleType:
"""Create a new module with the given name."""
return types.ModuleType(name)
def _run_script(script_path: str) -> None:
# This function is based on the following code from Streamlit:
# https://github.com/streamlit/streamlit/blob/1.24.0/lib/streamlit/runtime/scriptrunner/script_runner.py#L519-L554
with tokenize.open(script_path) as f:
filebody = f.read()
# NOTE: In Streamlit, the bytecode caching mechanism has been introduced.
# However, we skipped it here for simplicity and because Gradio doesn't need to rerun the script so frequently,
# while we may do it in the future.
bytecode = compile( # type: ignore
filebody,
# Pass in the file path so it can show up in exceptions.
script_path,
# We're compiling entire blocks of Python, so we need "exec"
# mode (as opposed to "eval" or "single").
mode="exec",
# Don't inherit any flags or "future" statements.
flags=0,
dont_inherit=1,
# Use the default optimization options.
optimize=-1,
)
module = _new_module("__main__")
# Install the fake module as the __main__ module. This allows
# the pickle module to work inside the user's code, since it now
# can know the module where the pickled objects stem from.
# IMPORTANT: This means we can't use "if __name__ == '__main__'" in
# our code, as it will point to the wrong module!!!
sys.modules["__main__"] = module
# Add special variables to the module's globals dict.
module.__dict__["__file__"] = script_path
with modified_sys_path(script_path):
exec(bytecode, module.__dict__)

View File

@ -33,22 +33,31 @@ export class WorkerProxy {
gradioWheelUrl: options.gradioWheelUrl,
gradioClientWheelUrl: options.gradioClientWheelUrl,
files: options.files,
requirements: options.requirements
requirements: options.requirements,
}
}).then(() => {
console.debug("WorkerProxy.constructor(): Initialization is done.");
});
}
public async runPythonAsync(code: string): Promise<void> {
public async runPythonCode(code: string): Promise<void> {
await this.postMessageAsync({
type: "run-python",
type: "run-python-code",
data: {
code
}
});
}
public async runPythonFile(path: string): Promise<void> {
await this.postMessageAsync({
type: "run-python-file",
data: {
path,
},
});
}
// A wrapper for this.worker.postMessage(). Unlike that function, which
// returns void immediately, this function returns a promise, which resolves
// when a ReplyMessage is received from the worker.