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:
Régis Behmo 2024-11-21 18:06:05 +01:00
parent 46222154da
commit ee8c9c4362
6 changed files with 169 additions and 75 deletions

View File

@ -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/

View File

@ -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": [

View File

@ -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)

View File

@ -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

View File

@ -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>

View 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 %}