From 523db190a732177eda5a641768667173ba2e2452 Mon Sep 17 00:00:00 2001 From: Adam Hopkins Date: Thu, 18 Nov 2021 17:47:27 +0200 Subject: [PATCH] Add contextual exceptions (#2290) --- sanic/errorpages.py | 143 +++++++++++++++++++++++++++------------ sanic/exceptions.py | 6 +- tests/test_exceptions.py | 117 ++++++++++++++++++++++++++++++++ 3 files changed, 220 insertions(+), 46 deletions(-) diff --git a/sanic/errorpages.py b/sanic/errorpages.py index d046c29d..66ff6c95 100644 --- a/sanic/errorpages.py +++ b/sanic/errorpages.py @@ -25,12 +25,13 @@ from sanic.request import Request from sanic.response import HTTPResponse, html, json, text +dumps: t.Callable[..., str] try: from ujson import dumps dumps = partial(dumps, escape_forward_slashes=False) except ImportError: # noqa - from json import dumps # type: ignore + from json import dumps FALLBACK_TEXT = ( @@ -45,6 +46,8 @@ class BaseRenderer: Base class that all renderers must inherit from. """ + dumps = staticmethod(dumps) + def __init__(self, request, exception, debug): self.request = request self.exception = exception @@ -112,14 +115,16 @@ class HTMLRenderer(BaseRenderer): TRACEBACK_STYLE = """ html { font-family: sans-serif } h2 { color: #888; } - .tb-wrapper p { margin: 0 } + .tb-wrapper p, dl, dd { margin: 0 } .frame-border { margin: 1rem } - .frame-line > * { padding: 0.3rem 0.6rem } - .frame-line { margin-bottom: 0.3rem } - .frame-code { font-size: 16px; padding-left: 4ch } - .tb-wrapper { border: 1px solid #eee } - .tb-header { background: #eee; padding: 0.3rem; font-weight: bold } - .frame-descriptor { background: #e2eafb; font-size: 14px } + .frame-line > *, dt, dd { padding: 0.3rem 0.6rem } + .frame-line, dl { margin-bottom: 0.3rem } + .frame-code, dd { font-size: 16px; padding-left: 4ch } + .tb-wrapper, dl { border: 1px solid #eee } + .tb-header,.obj-header { + background: #eee; padding: 0.3rem; font-weight: bold + } + .frame-descriptor, dt { background: #e2eafb; font-size: 14px } """ TRACEBACK_WRAPPER_HTML = ( "
{exc_name}: {exc_value}
" @@ -138,6 +143,11 @@ class HTMLRenderer(BaseRenderer): "

{0.line}" "" ) + OBJECT_WRAPPER_HTML = ( + "

{title}
" + "
{display_html}
" + ) + OBJECT_DISPLAY_HTML = "
{key}
{value}
" OUTPUT_HTML = ( "" "{title}\n" @@ -152,7 +162,7 @@ class HTMLRenderer(BaseRenderer): title=self.title, text=self.text, style=self.TRACEBACK_STYLE, - body=self._generate_body(), + body=self._generate_body(full=True), ), status=self.status, ) @@ -163,7 +173,7 @@ class HTMLRenderer(BaseRenderer): title=self.title, text=self.text, style=self.TRACEBACK_STYLE, - body="", + body=self._generate_body(full=False), ), status=self.status, headers=self.headers, @@ -177,27 +187,49 @@ class HTMLRenderer(BaseRenderer): def title(self): return escape(f"⚠️ {super().title}") - def _generate_body(self): - _, exc_value, __ = sys.exc_info() - exceptions = [] - while exc_value: - exceptions.append(self._format_exc(exc_value)) - exc_value = exc_value.__cause__ + def _generate_body(self, *, full): + lines = [] + if full: + _, exc_value, __ = sys.exc_info() + exceptions = [] + while exc_value: + exceptions.append(self._format_exc(exc_value)) + exc_value = exc_value.__cause__ + + traceback_html = self.TRACEBACK_BORDER.join(reversed(exceptions)) + appname = escape(self.request.app.name) + name = escape(self.exception.__class__.__name__) + value = escape(self.exception) + path = escape(self.request.path) + lines += [ + f"

Traceback of {appname} " "(most recent call last):

", + f"{traceback_html}", + "

", + f"{name}: {value} " + f"while handling path {path}", + "

", + ] + + for attr, display in (("context", True), ("extra", bool(full))): + info = getattr(self.exception, attr, None) + if info and display: + lines.append(self._generate_object_display(info, attr)) - traceback_html = self.TRACEBACK_BORDER.join(reversed(exceptions)) - appname = escape(self.request.app.name) - name = escape(self.exception.__class__.__name__) - value = escape(self.exception) - path = escape(self.request.path) - lines = [ - f"

Traceback of {appname} (most recent call last):

", - f"{traceback_html}", - "

", - f"{name}: {value} while handling path {path}", - "

", - ] return "\n".join(lines) + def _generate_object_display( + self, obj: t.Dict[str, t.Any], descriptor: str + ) -> str: + display = "".join( + self.OBJECT_DISPLAY_HTML.format(key=key, value=value) + for key, value in obj.items() + ) + return self.OBJECT_WRAPPER_HTML.format( + title=descriptor.title(), + display_html=display, + obj_type=descriptor.lower(), + ) + def _format_exc(self, exc): frames = extract_tb(exc.__traceback__) frame_html = "".join( @@ -224,7 +256,7 @@ class TextRenderer(BaseRenderer): title=self.title, text=self.text, bar=("=" * len(self.title)), - body=self._generate_body(), + body=self._generate_body(full=True), ), status=self.status, ) @@ -235,7 +267,7 @@ class TextRenderer(BaseRenderer): title=self.title, text=self.text, bar=("=" * len(self.title)), - body="", + body=self._generate_body(full=False), ), status=self.status, headers=self.headers, @@ -245,21 +277,31 @@ class TextRenderer(BaseRenderer): def title(self): return f"⚠️ {super().title}" - def _generate_body(self): - _, exc_value, __ = sys.exc_info() - exceptions = [] + def _generate_body(self, *, full): + lines = [] + if full: + _, exc_value, __ = sys.exc_info() + exceptions = [] - lines = [ - f"{self.exception.__class__.__name__}: {self.exception} while " - f"handling path {self.request.path}", - f"Traceback of {self.request.app.name} (most recent call last):\n", - ] + lines += [ + f"{self.exception.__class__.__name__}: {self.exception} while " + f"handling path {self.request.path}", + f"Traceback of {self.request.app.name} " + "(most recent call last):\n", + ] - while exc_value: - exceptions.append(self._format_exc(exc_value)) - exc_value = exc_value.__cause__ + while exc_value: + exceptions.append(self._format_exc(exc_value)) + exc_value = exc_value.__cause__ - return "\n".join(lines + exceptions[::-1]) + lines += exceptions[::-1] + + for attr, display in (("context", True), ("extra", bool(full))): + info = getattr(self.exception, attr, None) + if info and display: + lines += self._generate_object_display_list(info, attr) + + return "\n".join(lines) def _format_exc(self, exc): frames = "\n\n".join( @@ -272,6 +314,13 @@ class TextRenderer(BaseRenderer): ) return f"{self.SPACER}{exc.__class__.__name__}: {exc}\n{frames}" + def _generate_object_display_list(self, obj, descriptor): + lines = [f"\n{descriptor.title()}"] + for key, value in obj.items(): + display = self.dumps(value) + lines.append(f"{self.SPACER * 2}{key}: {display}") + return lines + class JSONRenderer(BaseRenderer): """ @@ -280,11 +329,11 @@ class JSONRenderer(BaseRenderer): def full(self) -> HTTPResponse: output = self._generate_output(full=True) - return json(output, status=self.status, dumps=dumps) + return json(output, status=self.status, dumps=self.dumps) def minimal(self) -> HTTPResponse: output = self._generate_output(full=False) - return json(output, status=self.status, dumps=dumps) + return json(output, status=self.status, dumps=self.dumps) def _generate_output(self, *, full): output = { @@ -293,6 +342,11 @@ class JSONRenderer(BaseRenderer): "message": self.text, } + for attr, display in (("context", True), ("extra", bool(full))): + info = getattr(self.exception, attr, None) + if info and display: + output[attr] = info + if full: _, exc_value, __ = sys.exc_info() exceptions = [] @@ -383,7 +437,6 @@ def exception_response( """ content_type = None - print("exception_response", fallback) if not renderer: # Make sure we have something set renderer = base diff --git a/sanic/exceptions.py b/sanic/exceptions.py index 1bb06f1d..6459f15a 100644 --- a/sanic/exceptions.py +++ b/sanic/exceptions.py @@ -1,4 +1,4 @@ -from typing import Optional, Union +from typing import Any, Dict, Optional, Union from sanic.helpers import STATUS_CODES @@ -11,7 +11,11 @@ class SanicException(Exception): message: Optional[Union[str, bytes]] = None, status_code: Optional[int] = None, quiet: Optional[bool] = None, + context: Optional[Dict[str, Any]] = None, + extra: Optional[Dict[str, Any]] = None, ) -> None: + self.context = context + self.extra = extra if message is None: if self.message: message = self.message diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index 0485137a..eea97935 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -18,6 +18,16 @@ from sanic.exceptions import ( from sanic.response import text +def dl_to_dict(soup, css_class): + keys, values = [], [] + for dl in soup.find_all("dl", {"class": css_class}): + for dt in dl.find_all("dt"): + keys.append(dt.text.strip()) + for dd in dl.find_all("dd"): + values.append(dd.text.strip()) + return dict(zip(keys, values)) + + class SanicExceptionTestException(Exception): pass @@ -264,3 +274,110 @@ def test_exception_in_ws_logged(caplog): error_logs = [r for r in caplog.record_tuples if r[0] == "sanic.error"] assert error_logs[1][1] == logging.ERROR assert "Exception occurred while handling uri:" in error_logs[1][2] + + +@pytest.mark.parametrize("debug", (True, False)) +def test_contextual_exception_context(debug): + app = Sanic(__name__) + + class TeapotError(SanicException): + status_code = 418 + message = "Sorry, I cannot brew coffee" + + def fail(): + raise TeapotError(context={"foo": "bar"}) + + app.post("/coffee/json", error_format="json")(lambda _: fail()) + app.post("/coffee/html", error_format="html")(lambda _: fail()) + app.post("/coffee/text", error_format="text")(lambda _: fail()) + + _, response = app.test_client.post("/coffee/json", debug=debug) + assert response.status == 418 + assert response.json["message"] == "Sorry, I cannot brew coffee" + assert response.json["context"] == {"foo": "bar"} + + _, response = app.test_client.post("/coffee/html", debug=debug) + soup = BeautifulSoup(response.body, "html.parser") + dl = dl_to_dict(soup, "context") + assert response.status == 418 + assert "Sorry, I cannot brew coffee" in soup.find("p").text + assert dl == {"foo": "bar"} + + _, response = app.test_client.post("/coffee/text", debug=debug) + lines = list(map(lambda x: x.decode(), response.body.split(b"\n"))) + idx = lines.index("Context") + 1 + assert response.status == 418 + assert lines[2] == "Sorry, I cannot brew coffee" + assert lines[idx] == ' foo: "bar"' + + +@pytest.mark.parametrize("debug", (True, False)) +def test_contextual_exception_extra(debug): + app = Sanic(__name__) + + class TeapotError(SanicException): + status_code = 418 + + @property + def message(self): + return f"Found {self.extra['foo']}" + + def fail(): + raise TeapotError(extra={"foo": "bar"}) + + app.post("/coffee/json", error_format="json")(lambda _: fail()) + app.post("/coffee/html", error_format="html")(lambda _: fail()) + app.post("/coffee/text", error_format="text")(lambda _: fail()) + + _, response = app.test_client.post("/coffee/json", debug=debug) + assert response.status == 418 + assert response.json["message"] == "Found bar" + if debug: + assert response.json["extra"] == {"foo": "bar"} + else: + assert "extra" not in response.json + + _, response = app.test_client.post("/coffee/html", debug=debug) + soup = BeautifulSoup(response.body, "html.parser") + dl = dl_to_dict(soup, "extra") + assert response.status == 418 + assert "Found bar" in soup.find("p").text + if debug: + assert dl == {"foo": "bar"} + else: + assert not dl + + _, response = app.test_client.post("/coffee/text", debug=debug) + lines = list(map(lambda x: x.decode(), response.body.split(b"\n"))) + assert response.status == 418 + assert lines[2] == "Found bar" + if debug: + idx = lines.index("Extra") + 1 + assert lines[idx] == ' foo: "bar"' + else: + assert "Extra" not in lines + + +@pytest.mark.parametrize("override", (True, False)) +def test_contextual_exception_functional_message(override): + app = Sanic(__name__) + + class TeapotError(SanicException): + status_code = 418 + + @property + def message(self): + return f"Received foo={self.context['foo']}" + + @app.post("/coffee", error_format="json") + async def make_coffee(_): + error_args = {"context": {"foo": "bar"}} + if override: + error_args["message"] = "override" + raise TeapotError(**error_args) + + _, response = app.test_client.post("/coffee", debug=True) + error_message = "override" if override else "Received foo=bar" + assert response.status == 418 + assert response.json["message"] == error_message + assert response.json["context"] == {"foo": "bar"}