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 = (
"
"
@@ -138,6 +143,11 @@ class HTMLRenderer(BaseRenderer):
"{0.line}
"
""
)
+ OBJECT_WRAPPER_HTML = (
+ "
"
+ "{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"}