import asyncio
import html
import inspect
import os
import traceback
from pathlib import Path
from starlette.concurrency import run_in_threadpool
from starlette.middleware.errors import ServerErrorMiddleware, STYLES, JS
from starlette.requests import Request
from starlette.types import Message, Receive, Scope, Send
from otree.templating.errors import (
TemplateRenderingError,
TemplateLexingError,
ErrorWithToken,
)
from otree.templating.loader import ibis_loader
CWD_PATH = Path(os.getcwd())
IBIS_LINE = """
{lineno}. {line}
"""
IBIS_CENTER_LINE = """
{lineno}. {line}
"""
IBIS_TEMPLATE = """
File {template_id},
line {line_number},
in {tag_name}
{code_context}
"""
FRAME_TEMPLATE = """
File {frame_filename},
line {frame_lineno},
in {frame_name}
{collapse_button}
{code_context}
{locals_table}
""" # noqa: E501
TEMPLATE = """
{tab_title}
Application error (500)
{error}
{ibis_html}
{js}
"""
OTREE_STYLES = """
.locals-table {
border-collapse: collapse;
}
.locals-table td, th {
border: 1px solid #999;
padding: 0.5rem;
text-align: left;
}
.faded {
color: #888888;
}
"""
class OTreeServerErrorMiddleware(ServerErrorMiddleware):
def generate_ibis_html(self, template_id, line_number, tag_name=""):
path = ibis_loader.search_template(template_id)
html_lines = []
for i, line in enumerate(path.open(encoding='utf-8'), start=1):
if (i >= line_number - 3) and (i <= line_number + 3):
values = {
# HTML escape - line could contain < or >
"line": html.escape(line).replace(" ", " "),
"lineno": i,
}
tpl = IBIS_CENTER_LINE if i == line_number else IBIS_LINE
html_lines.append(tpl.format(**values))
return IBIS_TEMPLATE.format(
template_id=template_id,
line_number=line_number,
tag_name=tag_name,
code_context=''.join(html_lines),
)
def generate_html(self, exc: Exception, limit: int = 7) -> str:
if isinstance(exc, ErrorWithToken):
token = exc.token
ibis_html = self.generate_ibis_html(
template_id=token.template_id,
line_number=token.line_number,
tag_name=token.keyword,
)
elif isinstance(exc, TemplateLexingError):
ibis_html = self.generate_ibis_html(
template_id=exc.template_id, line_number=exc.line_number
)
else:
ibis_html = ''
while isinstance(exc, TemplateRenderingError) and exc.__cause__:
exc = exc.__cause__
traceback_obj = traceback.TracebackException.from_exception(
exc, capture_locals=True
)
exc_html = ""
exc_traceback = exc.__traceback__
if exc_traceback is not None:
frames = inspect.getinnerframes(exc_traceback, limit)
for frame in reversed(frames):
exc_html += self.generate_frame_html(frame)
# escape error class and text
error = (
f"{html.escape(traceback_obj.exc_type.__name__)}: "
f"{html.escape(str(traceback_obj))}"
)
return TEMPLATE.format(
styles=STYLES,
js=JS,
tab_title=error,
error=error,
exc_html=exc_html,
ibis_html=ibis_html,
otree_styles=OTREE_STYLES,
)
def generate_frame_html(self, frame: inspect.FrameInfo) -> str:
code_context = "".join(
self.format_line(index, line, frame.lineno, frame.index) # type: ignore
for index, line in enumerate(frame.code_context or [])
)
path = Path(frame.filename)
# if it's in the user's project, except for a venv folder
is_expanded = CWD_PATH in path.parents and not 'site-packages' in frame.filename
# only show locals if is expanded. reduces the risk of issues happening during __repr__
if is_expanded:
try:
locals = []
for k, v in frame.frame.f_locals.items():
# need to escape, e.g. if it's
locals.append(
f'| {k} | {html.escape(repr(v)[:100])} |
'
)
locals_table = (
''
)
except Exception:
locals_table = ''
path = path.relative_to(CWD_PATH)
else:
locals_table = ''
values = {
# HTML escape - filename could contain < or >, especially if it's a virtual
# file e.g. in the REPL
"frame_filename": html.escape(str(path)),
"frame_lineno": frame.lineno,
# HTML escape - if you try very hard it's possible to name a function with <
# or >
"frame_name": html.escape(frame.function),
"code_context": code_context,
"collapsed": "" if is_expanded else "collapsed",
"faded": "" if is_expanded else "faded",
"collapse_button": "‒" if is_expanded else "+",
"locals_table": locals_table,
}
return FRAME_TEMPLATE.format(**values)
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
"""oTree just removed the 'from None'. everything else is the same
Need this until https://github.com/encode/starlette/issues/1114 is fixed"""
if scope["type"] != "http":
await self.app(scope, receive, send)
return
response_started = False
async def _send(message: Message) -> None:
nonlocal response_started, send
if message["type"] == "http.response.start":
response_started = True
await send(message)
try:
await self.app(scope, receive, _send)
except Exception as exc:
if not response_started:
request = Request(scope)
if self.debug:
# In debug mode, return traceback responses.
response = self.debug_response(request, exc)
elif self.handler is None:
# Use our default 500 error handler.
response = self.error_response(request, exc)
else:
# Use an installed 500 error handler.
if asyncio.iscoroutinefunction(self.handler):
response = await self.handler(request, exc)
else:
response = await run_in_threadpool(self.handler, request, exc)
await response(scope, receive, send)
# oTree modified this line
raise exc # from None