fix: refactor tutor cli
This commit is contained in:
parent
2c113f5267
commit
b7ff461da2
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user