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}

Traceback

{exc_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 = ( '' + ''.join(locals) + '
' ) 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