replace fastapi by Quart
FastAPI is mostly uvicorn+starlette. As such, the documentation is spread over multiple places. Also, pydantic parsing of arguments is a pain to work with. Since we don't need powerful performance, we switch to Quart. Quart is preferred over Flask because of its async capabilities, which we need for log streaming in websockets. In progress: execute tutor commands and stream logs.
This commit is contained in:
parent
46222154da
commit
ee8c9c4362
2
Makefile
2
Makefile
@ -4,7 +4,7 @@ SRC_DIRS = ./tutordash
|
||||
BLACK_OPTS = --exclude templates ${SRC_DIRS}
|
||||
|
||||
runserver: ## Run a development server
|
||||
tutor dash run --reload
|
||||
tutor dash run --dev
|
||||
|
||||
scss: ## Compile SCSS files
|
||||
sass ${SASS_OPTS} tutordash/server/static/scss/:tutordash/server/static/css/
|
||||
|
||||
3
setup.py
3
setup.py
@ -44,7 +44,8 @@ setup(
|
||||
python_requires=">=3.9",
|
||||
install_requires=[
|
||||
"tutor>=18.0.0,<19.0.0",
|
||||
"fastapi[standard]",
|
||||
"quart",
|
||||
"aiofiles"
|
||||
],
|
||||
extras_require={
|
||||
"dev": [
|
||||
|
||||
@ -1,14 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import click
|
||||
import uvicorn
|
||||
|
||||
from tutor import hooks
|
||||
from tutor.commands.context import Context
|
||||
|
||||
from .__about__ import __version__
|
||||
from .server import app
|
||||
|
||||
########################################
|
||||
# CONFIGURATION
|
||||
@ -22,22 +20,23 @@ hooks.Filters.CONFIG_DEFAULTS.add_items(
|
||||
|
||||
|
||||
@click.group()
|
||||
@click.pass_obj
|
||||
def dash(obj: Context) -> None:
|
||||
# Pass project root to dash. This is the only way we have to pass data to the
|
||||
# fastapi app.
|
||||
os.environ["DASH_TUTOR_ROOT"] = obj.root
|
||||
def dash() -> None:
|
||||
pass
|
||||
|
||||
|
||||
@dash.command(name="run")
|
||||
@click.option("--host", default="127.0.0.1", show_default=True)
|
||||
@click.option("-p", "--port", default=3274, type=int, show_default=True)
|
||||
@click.option("--reload/--no-reload", help="Enable auto-reload.")
|
||||
def dash_run(host: str, port: int, reload: bool) -> None:
|
||||
@click.option(
|
||||
"--dev/--no-dev",
|
||||
help="Enable development mode, with auto-reload and debug templates.",
|
||||
)
|
||||
@click.pass_obj
|
||||
def dash_run(obj: Context, host: str, port: int, dev: bool) -> None:
|
||||
"""
|
||||
Run the dash server.
|
||||
"""
|
||||
uvicorn.run("tutordash.server.app:app", host=host, port=port, reload=reload)
|
||||
app.run(obj.root, host=host, port=port, debug=dev, use_reloader=dev)
|
||||
|
||||
|
||||
hooks.Filters.CLI_COMMANDS.add_item(dash)
|
||||
|
||||
@ -1,33 +1,29 @@
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
# import sys
|
||||
# import time
|
||||
import typing as t
|
||||
|
||||
from fastapi import FastAPI, Form, Request
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
import importlib_resources
|
||||
import aiofiles
|
||||
|
||||
# from contextlib import contextmanager
|
||||
|
||||
import tutor.env
|
||||
from quart import Quart, render_template, request, websocket
|
||||
from tutor import hooks
|
||||
|
||||
# from tutor.commands.cli import cli
|
||||
from tutor.types import Config
|
||||
|
||||
|
||||
class TutorProject:
|
||||
"""
|
||||
This big god class is not very elegant.
|
||||
TODO This big god class is not very elegant.
|
||||
"""
|
||||
|
||||
CONFIG: dict[str, t.Any] = {}
|
||||
|
||||
@classmethod
|
||||
def bootstrap(cls):
|
||||
# This is how we guarantee that all necessary modules are loaded
|
||||
# pylint: disable=import-outside-toplevel,unused-import
|
||||
import tutor.commands.cli
|
||||
|
||||
hooks.Actions.CORE_READY.do() # discover plugins
|
||||
# Don't you dare write os.environ.get() here: we want to crash if the
|
||||
# environment variable is missing.
|
||||
tutor_root = os.environ["DASH_TUTOR_ROOT"]
|
||||
hooks.Actions.PROJECT_ROOT_READY.do(tutor_root)
|
||||
ROOT: str = ""
|
||||
|
||||
@staticmethod
|
||||
def installed_plugins() -> list[str]:
|
||||
@ -37,63 +33,138 @@ class TutorProject:
|
||||
def enabled_plugins() -> list[str]:
|
||||
return sorted(set(hooks.Filters.PLUGINS_LOADED.iterate()))
|
||||
|
||||
@hooks.Actions.CONFIG_LOADED.add()
|
||||
@staticmethod
|
||||
def _dash_update_tutor_config(config: Config) -> None:
|
||||
TutorProject.CONFIG = config
|
||||
|
||||
@hooks.Actions.CONFIG_LOADED.add()
|
||||
def _dash_update_tutor_config(config: Config):
|
||||
TutorProject.CONFIG = config
|
||||
@hooks.Actions.PROJECT_ROOT_READY.add()
|
||||
@staticmethod
|
||||
def _dash_update_tutor_root(root: str) -> None:
|
||||
TutorProject.ROOT = root
|
||||
|
||||
@classmethod
|
||||
def tutor_stdout_path(cls):
|
||||
return tutor.env.data_path(cls.ROOT, "dash", "tutor.log")
|
||||
|
||||
|
||||
TutorProject.bootstrap()
|
||||
|
||||
|
||||
app = FastAPI()
|
||||
app.mount(
|
||||
"/static",
|
||||
StaticFiles(
|
||||
directory=importlib_resources.files("tutordash")
|
||||
.joinpath("server")
|
||||
.joinpath("static")
|
||||
),
|
||||
name="static",
|
||||
)
|
||||
templates = Jinja2Templates(
|
||||
directory=importlib_resources.files("tutordash")
|
||||
.joinpath("server")
|
||||
.joinpath("templates")
|
||||
app = Quart(
|
||||
__name__,
|
||||
static_url_path="/static",
|
||||
static_folder="static",
|
||||
)
|
||||
|
||||
|
||||
def run(root: str, **app_kwargs: t.Any) -> None:
|
||||
hooks.Actions.CORE_READY.do() # discover plugins
|
||||
hooks.Actions.PROJECT_ROOT_READY.do(root)
|
||||
app.logger.info("Dash tutor logs location: %s", TutorProject.tutor_stdout_path())
|
||||
app.run(**app_kwargs)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def home(request: Request):
|
||||
return templates.TemplateResponse(request=request, name="index.html", context={})
|
||||
async def home():
|
||||
return await render_template("index.html")
|
||||
|
||||
|
||||
@app.get("/sidebar/plugins")
|
||||
def sidebar_plugins(request: Request):
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="sidebar/_plugins.html",
|
||||
context={
|
||||
"installed_plugins": sorted(set(hooks.Filters.PLUGINS_INSTALLED.iterate())),
|
||||
},
|
||||
async def sidebar_plugins():
|
||||
return await render_template(
|
||||
"sidebar/_plugins.html",
|
||||
installed_plugins=sorted(set(hooks.Filters.PLUGINS_INSTALLED.iterate())),
|
||||
)
|
||||
|
||||
|
||||
@app.get("/plugin/{name}")
|
||||
def plugin(request: Request, name: str):
|
||||
@app.get("/plugin/<name>")
|
||||
async def plugin(name: str):
|
||||
# TODO check that plugin exists
|
||||
is_enabled = name in TutorProject.enabled_plugins()
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="plugin.html",
|
||||
context={
|
||||
"plugin_name": name,
|
||||
"is_enabled": is_enabled,
|
||||
},
|
||||
)
|
||||
return await render_template("plugin.html", plugin_name=name, is_enabled=is_enabled)
|
||||
|
||||
|
||||
@app.post("/plugin/{name}/toggle")
|
||||
def toggle_plugin(request: Request, name: str, enabled: t.Annotated[bool, Form]):
|
||||
# TODO I am unable to parse the "enabled" form parameter.
|
||||
pass
|
||||
@app.post("/plugin/<name>/toggle")
|
||||
async def toggle_plugin(name: str):
|
||||
# TODO check plugin exists
|
||||
form = await request.form
|
||||
enabled = form.get("enabled")
|
||||
if enabled not in ["on", "off"]:
|
||||
# TODO request validation. Can't we validate requests with a proper tool, such
|
||||
# as pydantic or a rest framework?
|
||||
return {}
|
||||
|
||||
return {}
|
||||
|
||||
|
||||
# @app.post("/tutor")
|
||||
# async def run_tutor():
|
||||
# """
|
||||
# Run an arbitrary tutor command.
|
||||
# """
|
||||
# try:
|
||||
# with capture_stdout() as stdout:
|
||||
# # pylint: disable=no-value-for-parameter
|
||||
# cli(["config", "printvalue", "DOCKER_IMAGE_OPENEDX"])
|
||||
# except SystemExit as e:
|
||||
# if e.code == 0:
|
||||
# # success!
|
||||
# return {}
|
||||
# else:
|
||||
# # TODO Return 500?
|
||||
# return {}
|
||||
# # TODO
|
||||
|
||||
|
||||
@app.get("/tutor/logs")
|
||||
async def tutor_logs():
|
||||
return await render_template("tutor_logs.html")
|
||||
|
||||
|
||||
@app.websocket("/tutor/logs/stream")
|
||||
async def tutor_logs_stream():
|
||||
while True:
|
||||
async for content in stream_file(TutorProject.tutor_stdout_path()):
|
||||
try:
|
||||
await websocket.send(content)
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
# Exiting the loop means that the file no longer exists, so we wait a little
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
|
||||
async def stream_file(path: str) -> t.Iterator[str]:
|
||||
"""
|
||||
Async stream content from file.
|
||||
|
||||
This will handle gracefully file deletion. Note however that if the file is
|
||||
truncated, all contents added to the beginning until the current position will be
|
||||
missed.
|
||||
"""
|
||||
if not os.path.exists(path):
|
||||
return
|
||||
async with aiofiles.open(path, "r", encoding="utf8") as f:
|
||||
while True:
|
||||
if not os.path.exists(path):
|
||||
break
|
||||
content = await f.read()
|
||||
if content:
|
||||
yield content
|
||||
else:
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
|
||||
# @contextmanager
|
||||
# def capture_stdout():
|
||||
# sys_stdout = sys.stdout
|
||||
# try:
|
||||
# while os.path.exists(TutorProject.tutor_stdout_path()):
|
||||
# # TODO thread-safe, lock-based implementation that does not use sleep()
|
||||
# await asyncio.sleep(0.1)
|
||||
# with open(TutorProject.tutor_stdout_path(), "wb", encoding="utf8") as stdout:
|
||||
# sys.stdout = stdout
|
||||
# sys.stderr = stdout
|
||||
# yield stdout
|
||||
# finally:
|
||||
# if os.path.exists(TutorProject.tutor_stdout_path()):
|
||||
# # TODO more reliable implementation
|
||||
# os.remove(TutorProject.tutor_stdout_path())
|
||||
# sys.stdout = sys_stdout
|
||||
|
||||
@ -14,9 +14,11 @@
|
||||
<!-- CSS -->
|
||||
<!-- <link rel="stylesheet" href="css/normalize.css"> -->
|
||||
<!-- <link rel="stylesheet" href="css/styles.css"> -->
|
||||
<link href="{{ url_for('static', path='/css/dash.css') }}" rel="stylesheet">
|
||||
<link href="{{ url_for('static', filename='/css/dash.css') }}" rel="stylesheet">
|
||||
<!-- TODO self-host -->
|
||||
<script src="https://unpkg.com/htmx.org@2.0.3/dist/htmx.min.js"></script>
|
||||
<!-- WS extension https://htmx.org/extensions/ws/ TODO self host -->
|
||||
<script src="https://unpkg.com/htmx-ext-ws@2.0.1/ws.js"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
@ -29,7 +31,7 @@
|
||||
<div class="content">
|
||||
<ul>
|
||||
<li>Configuration</li>
|
||||
<li>Some other stuff</li>
|
||||
<li><a href="{{ url_for('tutor_logs') }}">Command logs</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@ -55,6 +57,8 @@
|
||||
<div class="status-bar">
|
||||
<span>Modified: Just now</span>
|
||||
</div>
|
||||
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
19
tutordash/server/templates/tutor_logs.html
Normal file
19
tutordash/server/templates/tutor_logs.html
Normal file
@ -0,0 +1,19 @@
|
||||
{% extends 'index.html' %}
|
||||
|
||||
{% block workspace_header %}Tutor command logs{% endblock %}
|
||||
|
||||
{% block workspace_content %}
|
||||
<pre id="tutor-log" hx-ext="ws" ws-connect="{{ url_for('tutor_logs_stream') }}"></pre>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
// TODO this is absolutely going to break html. We need to html-escape all the things.
|
||||
htmx.on("htmx:wsAfterMessage", function(evt) {
|
||||
const logs = document.getElementById("tutor-log");
|
||||
const text = document.createTextNode(evt.detail.message);
|
||||
logs.appendChild(text);
|
||||
logs.scrollTop = logs.scrollHeight;
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
Loading…
x
Reference in New Issue
Block a user