Add contextual exceptions (#2290)

This commit is contained in:
Adam Hopkins 2021-11-18 17:47:27 +02:00 committed by GitHub
parent 95631b9686
commit 523db190a7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 220 additions and 46 deletions

View File

@ -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 = (
"<div class=tb-header>{exc_name}: {exc_value}</div>"
@ -138,6 +143,11 @@ class HTMLRenderer(BaseRenderer):
"<p class=frame-code><code>{0.line}</code>"
"</div>"
)
OBJECT_WRAPPER_HTML = (
"<div class=obj-header>{title}</div>"
"<dl class={obj_type}>{display_html}</dl>"
)
OBJECT_DISPLAY_HTML = "<dt>{key}</dt><dd><code>{value}</code></dd>"
OUTPUT_HTML = (
"<!DOCTYPE html><html lang=en>"
"<meta charset=UTF-8><title>{title}</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,7 +187,9 @@ class HTMLRenderer(BaseRenderer):
def title(self):
return escape(f"⚠️ {super().title}")
def _generate_body(self):
def _generate_body(self, *, full):
lines = []
if full:
_, exc_value, __ = sys.exc_info()
exceptions = []
while exc_value:
@ -189,15 +201,35 @@ class HTMLRenderer(BaseRenderer):
name = escape(self.exception.__class__.__name__)
value = escape(self.exception)
path = escape(self.request.path)
lines = [
f"<h2>Traceback of {appname} (most recent call last):</h2>",
lines += [
f"<h2>Traceback of {appname} " "(most recent call last):</h2>",
f"{traceback_html}",
"<div class=summary><p>",
f"<b>{name}: {value}</b> while handling path <code>{path}</code>",
f"<b>{name}: {value}</b> "
f"while handling path <code>{path}</code>",
"</div>",
]
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))
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):
def _generate_body(self, *, full):
lines = []
if full:
_, exc_value, __ = sys.exc_info()
exceptions = []
lines = [
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",
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__
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

View File

@ -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

View File

@ -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"}