fix: refactor tutor cli

This commit is contained in:
Régis Behmo 2024-12-05 16:36:44 +01:00
parent 2c113f5267
commit b7ff461da2

View File

@ -1,5 +1,5 @@
import asyncio
from contextlib import contextmanager
import contextlib
import logging
import os
import shlex
@ -60,9 +60,120 @@ class TutorProject:
@classmethod
def tutor_stdout_path(cls) -> str:
# TODO IMPORTANT remove me
return tutor.env.data_path(cls.ROOT, "dash", "tutor.log")
class TutorCli:
"""
Run Tutor commands and capture the output in a file.
Output must be a file because subprocess.Popen requires stdout.fileno() to be
available. This file must be unique because it is accessed from a different thread.
Basically, the log file is the API between threads.
"""
@classmethod
def run_parallel(cls, args: list[str]) -> None:
"""
Run a command in a separate thread.
"""
tutor_cli_runner = cls()
thread = threading.Thread(
target=tutor_cli_runner.run,
args=[args],
)
thread.start()
@property
def _log_path(self) -> str:
return tutor.env.data_path(TutorProject.ROOT, "dash", "tutor.log")
def run(self, args: list[str]) -> None:
"""
Execute some arbitrary tutor command.
Output will be captured in the log file.
TODO Return the exit code?
"""
log_path = self._log_path
with open(log_path, "w", encoding="utf8") as stdout:
# TODO useless because overwritten by Popen
stdout.write(f"$ tutor {shlex.join(args)}\n")
# TODO refactor this ugly mocking
def _mock_click_echo(text: str, **_kwargs: t.Any) -> None:
with open(log_path, "a", encoding="utf8") as stdout:
stdout.write(text)
stdout.write("\n")
def _mock_click_style(text: str, **_kwargs: t.Any) -> str:
"""
Strip ANSI colors
TODO convert to HTML color codes?
"""
return text
def _mock_execute(*command: str) -> int:
"""
TODO refactor this
"""
with open(log_path, "ab") as stdout:
with subprocess.Popen(command, stdout=stdout, stderr=stdout) as p:
try:
result = p.wait(timeout=None)
except Exception as e:
p.kill()
p.wait()
raise TutorError(f"Command failed: {' '.join(command)}") from e
if result > 0:
raise TutorError(
f"Command failed with status {result}: {' '.join(command)}"
)
return result
# Override execute function
with patch_objects(
[
(tutor.utils, "execute", _mock_execute),
(fmt.click, "echo", _mock_click_echo),
(fmt.click, "style", _mock_click_style),
]
):
try:
# Call tutor command
cli(args) # pylint: disable=no-value-for-parameter
except TutorError as e:
with open(log_path, "a", encoding="utf8") as stdout:
stdout.write(e.args[0])
except SystemExit:
# TODO what to do with e.code?
pass
async def iter_logs(self) -> t.AsyncGenerator[str, None]:
"""
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.
"""
# TODO super ugly. Any way to do better?
path = self._log_path
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)
app = Quart(
__name__,
static_url_path="/static",
@ -75,7 +186,6 @@ def run(root: str, **app_kwargs: t.Any) -> None:
Bootstrap the Quart app and run it.
"""
TutorProject.connect(root)
app.logger.info("Dash tutor logs location: %s", TutorProject.tutor_stdout_path())
app.run(**app_kwargs)
@ -121,48 +231,16 @@ async def tutor_cli() -> WerkzeugResponse:
# Run command asynchronously
# TODO return 400 if thread is active
# TODO parse command from JSON request body
thread = threading.Thread(
target=run_tutor_cli,
TutorCli.run_parallel(
# args=[["dev", "dc", "run", "pouac"]],
# args=[["config", "printvalue", "DOCKER_IMAGE_OPENEDX"]],
# args=[["config", "printvalue", "POUAC"]],
args=[["local", "launch", "--non-interactive"]],
# ["config", "printvalue", "DOCKER_IMAGE_OPENEDX"],
# ["config", "printvalue", "POUAC"],
["local", "launch", "--non-interactive"],
)
thread.start()
return redirect(url_for("tutor_logs"))
def run_tutor_cli(args: list[str]) -> None:
"""
Execute some arbitrary tutor command. Capture the output in a dedicated file.
TODO Return the exit code?
TODO Refactor this
"""
with open(TutorProject.tutor_stdout_path(), "w", encoding="utf8") as stdout:
# useless because overwritten by Popen
stdout.write(f"$ tutor {shlex.join(args)}\n")
# Override execute function
with patch_objects(
[
(tutor.utils, "execute", execute),
(fmt.click, "echo", click_echo),
(fmt.click, "style", click_style),
]
):
try:
# Call tutor command
cli(args) # pylint: disable=no-value-for-parameter
except TutorError as e:
with open(TutorProject.tutor_stdout_path(), "a", encoding="utf8") as stdout:
stdout.write(e.args[0])
except SystemExit:
# TODO what to do with e.code?
pass
@contextmanager
@contextlib.contextmanager
def patch_objects(
refs: list[tuple[object, str, t.Callable[[t.Any], t.Any]]]
) -> t.Iterator[None]:
@ -180,40 +258,6 @@ def patch_objects(
setattr(module, object_name, old_object)
def click_echo(text: str, **_kwargs: t.Any) -> None:
with open(TutorProject.tutor_stdout_path(), "a", encoding="utf8") as stdout:
stdout.write(text)
stdout.write("\n")
def click_style(text: str, **_kwargs: t.Any) -> str:
"""
Strip ANSI colors
TODO convert to HTML color codes?
"""
return text
def execute(*command: str) -> int:
"""
TODO refactor this
"""
with open(TutorProject.tutor_stdout_path(), "ab") as stdout:
with subprocess.Popen(command, stdout=stdout, stderr=stdout) as p:
try:
result = p.wait(timeout=None)
except Exception as e:
p.kill()
p.wait()
raise TutorError(f"Command failed: {' '.join(command)}") from e
if result > 0:
raise TutorError(
f"Command failed with status {result}: {' '.join(command)}"
)
return result
@app.get("/tutor/logs")
async def tutor_logs() -> str:
return await render_template("tutor_logs.html")
@ -221,32 +265,12 @@ async def tutor_logs() -> str:
@app.websocket("/tutor/logs/stream")
async def tutor_logs_stream() -> None:
tutor_cli_runner = TutorCli()
while True:
async for content in stream_file(TutorProject.tutor_stdout_path()):
async for content in tutor_cli_runner.iter_logs():
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.AsyncGenerator[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)