Unable to interrupt a running server...

I'm quitting, waiting for the upstream issue to be resolved.
This commit is contained in:
Régis Behmo 2024-12-19 14:51:43 +01:00
parent 0565781c15
commit 556f0bf8f3
9 changed files with 90 additions and 50 deletions

View File

@ -6,11 +6,11 @@ BLACK_OPTS = --exclude templates ${SRC_DIRS}
runserver: ## Run a development server
tutor dash run --dev
scss: ## Compile SCSS files
css: ## Compile SCSS files to CSS
sass ${SASS_OPTS} tutordash/server/static/scss/:tutordash/server/static/css/
scss-watch: ## Compile SCSS files and watch for changes
$(MAKE) scss SASS_OPTS="--watch"
css-watch: ## Compile SCSS files to CSS and watch for changes
$(MAKE) css SASS_OPTS="--watch"
# Warning: These checks are not necessarily run on every PR.
test: test-lint test-types test-format # Run some static checks.

View File

@ -11,6 +11,8 @@ Installation
pip install git+https://github.com/overhangio/tutor-dash
.. TODO how to package css files?
Usage
*****

View File

@ -6,13 +6,13 @@ from setuptools import find_packages, setup
HERE = os.path.abspath(os.path.dirname(__file__))
def load_readme():
def load_readme() -> str:
with io.open(os.path.join(HERE, "README.rst"), "rt", encoding="utf8") as f:
return f.read()
def load_about():
about = {}
def load_about() -> dict[str, str]:
about: dict[str, str] = {}
with io.open(
os.path.join(HERE, "tutordash", "__about__.py"),
"rt",
@ -43,13 +43,13 @@ setup(
include_package_data=True,
python_requires=">=3.9",
install_requires=[
"tutor>=18.0.0,<19.0.0",
"tutor>=19.0.0,<20.0.0",
"quart",
"aiofiles",
],
extras_require={
"dev": [
"tutor[dev]>=18.0.0,<19.0.0",
"tutor[dev]>=19.0.0,<20.0.0",
"quart",
"aiofiles",
"types-aiofiles",

View File

@ -1 +1 @@
__version__ = "18.0.0"
__version__ = "19.0.0"

View File

@ -1,5 +1,6 @@
import asyncio
import contextlib
import json
import shlex
import subprocess
import tempfile
@ -16,6 +17,7 @@ from quart import (
url_for,
)
from quart.helpers import WerkzeugResponse
from quart.typing import ResponseTypes
import tutor.env
from tutor.exceptions import TutorError
@ -280,6 +282,7 @@ app = Quart(
static_url_path="/static",
static_folder="static",
)
SHUTDOWN_EVENT = asyncio.Event()
def run(root: str, **app_kwargs: t.Any) -> None:
@ -287,9 +290,32 @@ def run(root: str, **app_kwargs: t.Any) -> None:
Bootstrap the Quart app and run it.
"""
TutorProject.connect(root)
# Manually shutdown application on SIGINT/SIGTERM
# This does not work in development because the signal handler is overridden by app.run().
# loop = asyncio.new_event_loop()
# asyncio.set_event_loop(loop)
# Monitor disconnection
# loop.background_tasks.add(task)
# app.background_tasks.add(task)
# import signal
# for s in (signal.SIGINT, signal.SIGTERM):
# loop.add_signal_handler(s, shutdown)
# TODO app.run() should be called only in development
# app.run(loop=loop, **app_kwargs)
app.run(**app_kwargs)
def shutdown() -> None:
"""
For now, this function is not called anywhere
"""
app.logger.info("Shutdown requested...")
SHUTDOWN_EVENT.set()
@app.get("/")
async def home() -> str:
return await render_template("index.html", **shared_template_context())
@ -308,34 +334,19 @@ async def plugin(name: str) -> str:
@app.post("/plugin/<name>/toggle")
async def toggle_plugin(name: str) -> dict[str, str]:
async def toggle_plugin(name: str) -> WerkzeugResponse:
# 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 {}
# TODO actually toggle plugin
app.logger.info("Toggling plugin %s", name)
return {}
enable_plugin = form.get("enabled") == "on"
return tutor_cli(["plugins", "enable" if enable_plugin else "disable", name])
@app.post("/tutor/cli")
async def tutor_cli() -> WerkzeugResponse:
def tutor_cli(command: list[str]) -> WerkzeugResponse:
# Run command asynchronously
# if TutorCli.is_thread_alive():
# TODO return 400 if thread is active
# TODO parse command from JSON request body
TutorCliPool.run_parallel(
["dev", "start"],
# ["dev", "dc", "run", "--no-deps", "lms", "bash"],
# ["config", "printvalue", "DOCKER_IMAGE_OPENEDX"],
# ["config", "printvalue", "POUAC"],
# ["local", "launch", "--non-interactive"],
)
TutorCliPool.run_parallel(command)
return redirect(url_for("tutor_cli_logs"))
@ -351,18 +362,36 @@ async def tutor_cli_logs() -> str:
@app.get("/tutor/cli/logs/stream")
async def tutor_cli_logs_stream() -> None:
# Websockets were not working for us in dev mode, we were unable to stop the server
# as long as there were open connection. We only need single-direction
# communication, so we use server-sent events
# https://github.com/pallets/quart/issues/333
# https://quart.palletsprojects.com/en/latest/how_to_guides/server_sent_events.html
async def send_events():
async def tutor_cli_logs_stream() -> ResponseTypes:
"""
We only need single-direction communication, so we use server-sent events, and not
websockets.
https://quart.palletsprojects.com/en/latest/how_to_guides/server_sent_events.html
Note that server interruption with ctrl+c does not work in Python 3.12 and 3.13
because of this bug:
https://github.com/pallets/quart/issues/333
https://github.com/python/cpython/issues/123720
Events are sent with the following format:
data: "json-encoded string..."
event: logs
Data is JSON-encoded such that we can sent newline characters, etc.
"""
# TODO check that request accepts event stream (see howto)
async def send_events() -> t.AsyncIterator[bytes]:
while True:
if SHUTDOWN_EVENT.is_set():
# TODO explain this
break
# TODO this is again causing the stream to never stop...
async for data in TutorCliPool.iter_logs():
event = f"data: {data}\nevent: logs\n"
# TODO encode one way or another to be able to send EOL characters and other weird chars
# TODO important encode one way or another to be able to send EOL characters and other weird chars
json_data = json.dumps(data)
event = f"data: {json_data}\nevent: logs\n\n"
yield event.encode()
await asyncio.sleep(SHORT_SLEEP_SECONDS)
@ -374,7 +403,7 @@ async def tutor_cli_logs_stream() -> None:
"Transfer-Encoding": "chunked",
},
)
response.timeout = None
setattr(response, "timeout", None)
return response
@ -386,4 +415,5 @@ def shared_template_context() -> dict[str, t.Any]:
"""
return {
"installed_plugins": sorted(set(hooks.Filters.PLUGINS_INSTALLED.iterate())),
"enabled_plugins": sorted(set(hooks.Filters.PLUGINS_LOADED.iterate())),
}

View File

@ -55,7 +55,7 @@ body {
.workspace {
flex: 1;
padding: 20px;
overflow: scroll;
overflow: hidden;
.header {
margin-bottom: 16px;
@ -66,6 +66,14 @@ body {
.content {
outline: none;
min-height: 200px;
height: 100%;
overflow: hidden;
padding-bottom: 20px; // necessary to display the bottom scrollbar
pre {
overflow: scroll;
height: calc(100% - 20px); // must match .content padding-bottom to display scrollbar
}
}
}
}

View File

@ -43,13 +43,8 @@
<div class="content">
<ul>
{% for plugin in installed_plugins %}
<li><a href="{{ url_for('plugin', name=plugin) }}">{{ plugin }}</a></li>
<li><a href="{{ url_for('plugin', name=plugin) }}">{{ plugin }} {% if plugin in enabled_plugins %} ✅{% endif %}</a></li>
{% endfor %}
<form action="{{ url_for('tutor_cli') }}" method="POST">
<input type="hidden" name="args[]" value="local">
<input type="hidden" name="args[]" value="launch">
<button type="submit">Apply changes</button>
</form>
<form action="{{ url_for('tutor_cli_stop') }}" method="POST">
<button type="submit">Stop</button>
</form>

View File

@ -3,6 +3,7 @@
{% block workspace_header %}{{ plugin_name }}{% endblock %}
{% block workspace_content %}
Enabled: <input type="checkbox" name="enabled" {% if is_enabled %}checked{% endif %}
hx-post="{{ url_for('toggle_plugin', name=plugin_name) }}" />
Enabled: <form method="POST" action="{{ url_for('toggle_plugin', name=plugin_name) }}">
<input type="checkbox" name="enabled" onchange="this.form.submit()" {% if is_enabled %}checked{% endif %} />
</form>
{% endblock %}

View File

@ -8,14 +8,18 @@
{% block scripts %}
<script>
// TODO fix me
htmx.on("htmx:sseBeforeMessage", function(evt) {
// Don't swap content, we want to append
evt.preventDefault();
// Parse JSON
const stdout = JSON.parse(evt.detail.data);
// Note that HTML is automatically escaped
const text = document.createTextNode(evt.detail.data);
const text = document.createTextNode(stdout);
evt.detail.elt.appendChild(text);
// Scroll to bottom
evt.detail.elt.scrollTop = evt.detail.elt.scrollHeight;
});
</script>