Add contextual exceptions (#2290)
This commit is contained in:
parent
95631b9686
commit
523db190a7
|
@ -25,12 +25,13 @@ from sanic.request import Request
|
||||||
from sanic.response import HTTPResponse, html, json, text
|
from sanic.response import HTTPResponse, html, json, text
|
||||||
|
|
||||||
|
|
||||||
|
dumps: t.Callable[..., str]
|
||||||
try:
|
try:
|
||||||
from ujson import dumps
|
from ujson import dumps
|
||||||
|
|
||||||
dumps = partial(dumps, escape_forward_slashes=False)
|
dumps = partial(dumps, escape_forward_slashes=False)
|
||||||
except ImportError: # noqa
|
except ImportError: # noqa
|
||||||
from json import dumps # type: ignore
|
from json import dumps
|
||||||
|
|
||||||
|
|
||||||
FALLBACK_TEXT = (
|
FALLBACK_TEXT = (
|
||||||
|
@ -45,6 +46,8 @@ class BaseRenderer:
|
||||||
Base class that all renderers must inherit from.
|
Base class that all renderers must inherit from.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
dumps = staticmethod(dumps)
|
||||||
|
|
||||||
def __init__(self, request, exception, debug):
|
def __init__(self, request, exception, debug):
|
||||||
self.request = request
|
self.request = request
|
||||||
self.exception = exception
|
self.exception = exception
|
||||||
|
@ -112,14 +115,16 @@ class HTMLRenderer(BaseRenderer):
|
||||||
TRACEBACK_STYLE = """
|
TRACEBACK_STYLE = """
|
||||||
html { font-family: sans-serif }
|
html { font-family: sans-serif }
|
||||||
h2 { color: #888; }
|
h2 { color: #888; }
|
||||||
.tb-wrapper p { margin: 0 }
|
.tb-wrapper p, dl, dd { margin: 0 }
|
||||||
.frame-border { margin: 1rem }
|
.frame-border { margin: 1rem }
|
||||||
.frame-line > * { padding: 0.3rem 0.6rem }
|
.frame-line > *, dt, dd { padding: 0.3rem 0.6rem }
|
||||||
.frame-line { margin-bottom: 0.3rem }
|
.frame-line, dl { margin-bottom: 0.3rem }
|
||||||
.frame-code { font-size: 16px; padding-left: 4ch }
|
.frame-code, dd { font-size: 16px; padding-left: 4ch }
|
||||||
.tb-wrapper { border: 1px solid #eee }
|
.tb-wrapper, dl { border: 1px solid #eee }
|
||||||
.tb-header { background: #eee; padding: 0.3rem; font-weight: bold }
|
.tb-header,.obj-header {
|
||||||
.frame-descriptor { background: #e2eafb; font-size: 14px }
|
background: #eee; padding: 0.3rem; font-weight: bold
|
||||||
|
}
|
||||||
|
.frame-descriptor, dt { background: #e2eafb; font-size: 14px }
|
||||||
"""
|
"""
|
||||||
TRACEBACK_WRAPPER_HTML = (
|
TRACEBACK_WRAPPER_HTML = (
|
||||||
"<div class=tb-header>{exc_name}: {exc_value}</div>"
|
"<div class=tb-header>{exc_name}: {exc_value}</div>"
|
||||||
|
@ -138,6 +143,11 @@ class HTMLRenderer(BaseRenderer):
|
||||||
"<p class=frame-code><code>{0.line}</code>"
|
"<p class=frame-code><code>{0.line}</code>"
|
||||||
"</div>"
|
"</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 = (
|
OUTPUT_HTML = (
|
||||||
"<!DOCTYPE html><html lang=en>"
|
"<!DOCTYPE html><html lang=en>"
|
||||||
"<meta charset=UTF-8><title>{title}</title>\n"
|
"<meta charset=UTF-8><title>{title}</title>\n"
|
||||||
|
@ -152,7 +162,7 @@ class HTMLRenderer(BaseRenderer):
|
||||||
title=self.title,
|
title=self.title,
|
||||||
text=self.text,
|
text=self.text,
|
||||||
style=self.TRACEBACK_STYLE,
|
style=self.TRACEBACK_STYLE,
|
||||||
body=self._generate_body(),
|
body=self._generate_body(full=True),
|
||||||
),
|
),
|
||||||
status=self.status,
|
status=self.status,
|
||||||
)
|
)
|
||||||
|
@ -163,7 +173,7 @@ class HTMLRenderer(BaseRenderer):
|
||||||
title=self.title,
|
title=self.title,
|
||||||
text=self.text,
|
text=self.text,
|
||||||
style=self.TRACEBACK_STYLE,
|
style=self.TRACEBACK_STYLE,
|
||||||
body="",
|
body=self._generate_body(full=False),
|
||||||
),
|
),
|
||||||
status=self.status,
|
status=self.status,
|
||||||
headers=self.headers,
|
headers=self.headers,
|
||||||
|
@ -177,27 +187,49 @@ class HTMLRenderer(BaseRenderer):
|
||||||
def title(self):
|
def title(self):
|
||||||
return escape(f"⚠️ {super().title}")
|
return escape(f"⚠️ {super().title}")
|
||||||
|
|
||||||
def _generate_body(self):
|
def _generate_body(self, *, full):
|
||||||
_, exc_value, __ = sys.exc_info()
|
lines = []
|
||||||
exceptions = []
|
if full:
|
||||||
while exc_value:
|
_, exc_value, __ = sys.exc_info()
|
||||||
exceptions.append(self._format_exc(exc_value))
|
exceptions = []
|
||||||
exc_value = exc_value.__cause__
|
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"<h2>Traceback of {appname} " "(most recent call last):</h2>",
|
||||||
|
f"{traceback_html}",
|
||||||
|
"<div class=summary><p>",
|
||||||
|
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))
|
||||||
|
|
||||||
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"<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>",
|
|
||||||
"</div>",
|
|
||||||
]
|
|
||||||
return "\n".join(lines)
|
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):
|
def _format_exc(self, exc):
|
||||||
frames = extract_tb(exc.__traceback__)
|
frames = extract_tb(exc.__traceback__)
|
||||||
frame_html = "".join(
|
frame_html = "".join(
|
||||||
|
@ -224,7 +256,7 @@ class TextRenderer(BaseRenderer):
|
||||||
title=self.title,
|
title=self.title,
|
||||||
text=self.text,
|
text=self.text,
|
||||||
bar=("=" * len(self.title)),
|
bar=("=" * len(self.title)),
|
||||||
body=self._generate_body(),
|
body=self._generate_body(full=True),
|
||||||
),
|
),
|
||||||
status=self.status,
|
status=self.status,
|
||||||
)
|
)
|
||||||
|
@ -235,7 +267,7 @@ class TextRenderer(BaseRenderer):
|
||||||
title=self.title,
|
title=self.title,
|
||||||
text=self.text,
|
text=self.text,
|
||||||
bar=("=" * len(self.title)),
|
bar=("=" * len(self.title)),
|
||||||
body="",
|
body=self._generate_body(full=False),
|
||||||
),
|
),
|
||||||
status=self.status,
|
status=self.status,
|
||||||
headers=self.headers,
|
headers=self.headers,
|
||||||
|
@ -245,21 +277,31 @@ class TextRenderer(BaseRenderer):
|
||||||
def title(self):
|
def title(self):
|
||||||
return f"⚠️ {super().title}"
|
return f"⚠️ {super().title}"
|
||||||
|
|
||||||
def _generate_body(self):
|
def _generate_body(self, *, full):
|
||||||
_, exc_value, __ = sys.exc_info()
|
lines = []
|
||||||
exceptions = []
|
if full:
|
||||||
|
_, exc_value, __ = sys.exc_info()
|
||||||
|
exceptions = []
|
||||||
|
|
||||||
lines = [
|
lines += [
|
||||||
f"{self.exception.__class__.__name__}: {self.exception} while "
|
f"{self.exception.__class__.__name__}: {self.exception} while "
|
||||||
f"handling path {self.request.path}",
|
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:
|
while exc_value:
|
||||||
exceptions.append(self._format_exc(exc_value))
|
exceptions.append(self._format_exc(exc_value))
|
||||||
exc_value = exc_value.__cause__
|
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):
|
def _format_exc(self, exc):
|
||||||
frames = "\n\n".join(
|
frames = "\n\n".join(
|
||||||
|
@ -272,6 +314,13 @@ class TextRenderer(BaseRenderer):
|
||||||
)
|
)
|
||||||
return f"{self.SPACER}{exc.__class__.__name__}: {exc}\n{frames}"
|
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):
|
class JSONRenderer(BaseRenderer):
|
||||||
"""
|
"""
|
||||||
|
@ -280,11 +329,11 @@ class JSONRenderer(BaseRenderer):
|
||||||
|
|
||||||
def full(self) -> HTTPResponse:
|
def full(self) -> HTTPResponse:
|
||||||
output = self._generate_output(full=True)
|
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:
|
def minimal(self) -> HTTPResponse:
|
||||||
output = self._generate_output(full=False)
|
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):
|
def _generate_output(self, *, full):
|
||||||
output = {
|
output = {
|
||||||
|
@ -293,6 +342,11 @@ class JSONRenderer(BaseRenderer):
|
||||||
"message": self.text,
|
"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:
|
if full:
|
||||||
_, exc_value, __ = sys.exc_info()
|
_, exc_value, __ = sys.exc_info()
|
||||||
exceptions = []
|
exceptions = []
|
||||||
|
@ -383,7 +437,6 @@ def exception_response(
|
||||||
"""
|
"""
|
||||||
content_type = None
|
content_type = None
|
||||||
|
|
||||||
print("exception_response", fallback)
|
|
||||||
if not renderer:
|
if not renderer:
|
||||||
# Make sure we have something set
|
# Make sure we have something set
|
||||||
renderer = base
|
renderer = base
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
from typing import Optional, Union
|
from typing import Any, Dict, Optional, Union
|
||||||
|
|
||||||
from sanic.helpers import STATUS_CODES
|
from sanic.helpers import STATUS_CODES
|
||||||
|
|
||||||
|
@ -11,7 +11,11 @@ class SanicException(Exception):
|
||||||
message: Optional[Union[str, bytes]] = None,
|
message: Optional[Union[str, bytes]] = None,
|
||||||
status_code: Optional[int] = None,
|
status_code: Optional[int] = None,
|
||||||
quiet: Optional[bool] = None,
|
quiet: Optional[bool] = None,
|
||||||
|
context: Optional[Dict[str, Any]] = None,
|
||||||
|
extra: Optional[Dict[str, Any]] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
self.context = context
|
||||||
|
self.extra = extra
|
||||||
if message is None:
|
if message is None:
|
||||||
if self.message:
|
if self.message:
|
||||||
message = self.message
|
message = self.message
|
||||||
|
|
|
@ -18,6 +18,16 @@ from sanic.exceptions import (
|
||||||
from sanic.response import text
|
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):
|
class SanicExceptionTestException(Exception):
|
||||||
pass
|
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"]
|
error_logs = [r for r in caplog.record_tuples if r[0] == "sanic.error"]
|
||||||
assert error_logs[1][1] == logging.ERROR
|
assert error_logs[1][1] == logging.ERROR
|
||||||
assert "Exception occurred while handling uri:" in error_logs[1][2]
|
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"}
|
||||||
|
|
Loading…
Reference in New Issue
Block a user