Skip to content

Commit

Permalink
A different approach to template stack traces:
Browse files Browse the repository at this point in the history
Include the template source line as a comment in the generated code,
and add the magic necessary for python's normal stack traces to print
the (generated) source.
  • Loading branch information
bdarnell committed Dec 7, 2011
1 parent 45ca697 commit 597f70b
Show file tree
Hide file tree
Showing 4 changed files with 80 additions and 137 deletions.
73 changes: 30 additions & 43 deletions tornado/template.py
Expand Up @@ -173,16 +173,15 @@ def add(x, y):


import cStringIO import cStringIO
import datetime import datetime
import linecache
import logging import logging
import os.path import os.path
import posixpath import posixpath
import re import re
import threading import threading
import sys
import traceback


from tornado import escape from tornado import escape
from tornado.util import bytes_type from tornado.util import bytes_type, ObjectDict


_DEFAULT_AUTOESCAPE = "xhtml_escape" _DEFAULT_AUTOESCAPE = "xhtml_escape"
_UNSET = object() _UNSET = object()
Expand All @@ -195,7 +194,6 @@ class Template(object):
""" """
def __init__(self, template_string, name="<string>", loader=None, def __init__(self, template_string, name="<string>", loader=None,
compress_whitespace=None, autoescape=_UNSET): compress_whitespace=None, autoescape=_UNSET):
self.template_string = template_string
self.name = name self.name = name
if compress_whitespace is None: if compress_whitespace is None:
compress_whitespace = name.endswith(".html") or \ compress_whitespace = name.endswith(".html") or \
Expand All @@ -207,14 +205,17 @@ def __init__(self, template_string, name="<string>", loader=None,
else: else:
self.autoescape = _DEFAULT_AUTOESCAPE self.autoescape = _DEFAULT_AUTOESCAPE
self.namespace = loader.namespace if loader else {} self.namespace = loader.namespace if loader else {}
reader = _TemplateReader(name, escape.native_str(self.template_string)) reader = _TemplateReader(name, escape.native_str(template_string))
self.file = _File(self, _parse(reader, self)) self.file = _File(self, _parse(reader, self))
self.code, self.line_numbers = self._generate_python( self.code = self._generate_python(loader, compress_whitespace)
loader, compress_whitespace)
self.loader = loader self.loader = loader
try: try:
self.compiled = compile(escape.to_unicode(self.code), # Under python2.5, the fake filename used here must match
"<template %s>" % self.name, "exec") # the module name used in __name__ below.
self.compiled = compile(
escape.to_unicode(self.code),
"%s.generated.py" % self.name.replace('.','_'),
"exec")
except Exception: except Exception:
formatted_code = _format_code(self.code).rstrip() formatted_code = _format_code(self.code).rstrip()
logging.error("%s code:\n%s", self.name, formatted_code) logging.error("%s code:\n%s", self.name, formatted_code)
Expand All @@ -232,39 +233,25 @@ def generate(self, **kwargs):
"datetime": datetime, "datetime": datetime,
"_utf8": escape.utf8, # for internal use "_utf8": escape.utf8, # for internal use
"_string_types": (unicode, bytes_type), "_string_types": (unicode, bytes_type),
# __name__ and __loader__ allow the traceback mechanism to find
# the generated source code.
"__name__": self.name.replace('.', '_'),
"__loader__": ObjectDict(get_source=lambda name: self.code),
} }
namespace.update(self.namespace) namespace.update(self.namespace)
namespace.update(kwargs) namespace.update(kwargs)
exec self.compiled in namespace exec self.compiled in namespace
execute = namespace["_execute"] execute = namespace["_execute"]
# Clear the traceback module's cache of source data now that
# we've generated a new template (mainly for this module's
# unittests, where different tests reuse the same name).
linecache.clearcache()
try: try:
return execute() return execute()
except Exception: except Exception:
exc_type, exc_value, exc_traceback = sys.exc_info() formatted_code = _format_code(self.code).rstrip()
if self.loader and not hasattr(exc_value, "_logged"): logging.error("%s code:\n%s", self.name, formatted_code)
frame = exc_traceback.tb_next.tb_frame raise
code_msg = ""
code_args = []
trace_msg = ""
trace_args = []
while frame is not None:
match = re.match(r"\<template ([^\>]+)\>", frame.f_code.co_filename)
if match:
template = self.loader.templates[match.groups()[0]]
code_msg = "%s code:\n%s\n\n" + code_msg
code_args = [template.name, _format_code(self.code).rstrip()] + code_args
include_trace_msg = ""
include_trace_args = []
for file, line_number in template.line_numbers[frame.f_lineno]:
lines = self.loader.templates[file.name].template_string.split("\n")
include_trace_msg += "%s:%i:%s\n"
include_trace_args.extend([file.name, line_number, lines[line_number-1]])
trace_msg = include_trace_msg + trace_msg
trace_args = include_trace_args + trace_args
frame = frame.f_back
logging.error("\n" + code_msg + trace_msg, *(code_args + trace_args))
exc_value._logged = None
raise exc_type, exc_value, exc_traceback


def _generate_python(self, loader, compress_whitespace): def _generate_python(self, loader, compress_whitespace):
buffer = cStringIO.StringIO() buffer = cStringIO.StringIO()
Expand All @@ -279,7 +266,7 @@ def _generate_python(self, loader, compress_whitespace):
writer = _CodeWriter(buffer, named_blocks, loader, ancestors[0].template, writer = _CodeWriter(buffer, named_blocks, loader, ancestors[0].template,
compress_whitespace) compress_whitespace)
ancestors[0].generate(writer) ancestors[0].generate(writer)
return buffer.getvalue(), writer.line_numbers return buffer.getvalue()
finally: finally:
buffer.close() buffer.close()


Expand Down Expand Up @@ -580,8 +567,6 @@ def __init__(self, file, named_blocks, loader, current_template,
self.compress_whitespace = compress_whitespace self.compress_whitespace = compress_whitespace
self.apply_counter = 0 self.apply_counter = 0
self.include_stack = [] self.include_stack = []
self.line_numbers = {}
self._current_line = 1
self._indent = 0 self._indent = 0


def indent_size(self): def indent_size(self):
Expand All @@ -600,7 +585,7 @@ def __exit__(_, *args):
return Indenter() return Indenter()


def include(self, template, line): def include(self, template, line):
self.include_stack.append((self.current_template, line+1)) self.include_stack.append((self.current_template, line))
self.current_template = template self.current_template = template


class IncludeTemplate(object): class IncludeTemplate(object):
Expand All @@ -615,17 +600,19 @@ def __exit__(_, *args):
def write_line(self, line, line_number, indent=None): def write_line(self, line, line_number, indent=None):
if indent == None: if indent == None:
indent = self._indent indent = self._indent
print >> self.file, " "*indent + line line_comment = ' # %s:%d' % (self.current_template.name, line_number)

if self.include_stack:
self.line_numbers[self._current_line] = self.include_stack[:]+[(self.current_template, line_number+1)] ancestors = ["%s:%d" % (tmpl.name, lineno)
self._current_line += 1 for (tmpl, lineno) in self.include_stack]
line_comment += ' (via %s)' % ', '.join(reversed(ancestors))
print >> self.file, " "*indent + line + line_comment




class _TemplateReader(object): class _TemplateReader(object):
def __init__(self, name, text): def __init__(self, name, text):
self.name = name self.name = name
self.text = text self.text = text
self.line = 0 self.line = 1
self.pos = 0 self.pos = 0


def find(self, needle, start=0, end=None): def find(self, needle, start=0, end=None):
Expand Down
101 changes: 50 additions & 51 deletions tornado/test/template_test.py
@@ -1,12 +1,13 @@
from __future__ import with_statement

import traceback

from tornado.escape import utf8, native_str from tornado.escape import utf8, native_str
from tornado.template import Template, DictLoader, ParseError from tornado.template import Template, DictLoader, ParseError
from tornado.testing import LogCaptureTestCase, LogTrapTestCase from tornado.testing import LogTrapTestCase
from tornado.util import b, bytes_type, ObjectDict, LogCaptureHandler from tornado.util import b, bytes_type, ObjectDict


def _error_log(loader, name, line_number): class TemplateTest(LogTrapTestCase):
return (name, line_number, loader.templates[name].template_string.split('\n')[line_number-1])

class TemplateTest(LogTrapTestCase, LogCaptureTestCase):
def test_simple(self): def test_simple(self):
template = Template("Hello {{ name }}!") template = Template("Hello {{ name }}!")
self.assertEqual(template.generate(name="Ben"), self.assertEqual(template.generate(name="Ben"),
Expand Down Expand Up @@ -96,72 +97,61 @@ def test_comment(self):
template = Template(utf8("{% comment blah blah %}foo")) template = Template(utf8("{% comment blah blah %}foo"))
self.assertEqual(template.generate(), b("foo")) self.assertEqual(template.generate(), b("foo"))


class StackTraceTest(LogTrapTestCase):
def test_error_line_number_expression(self): def test_error_line_number_expression(self):
loader = DictLoader({"test.html": """one loader = DictLoader({"test.html": """one
two{{1/0}} two{{1/0}}
three three
"""}) """})
with LogCaptureHandler() as handler: try:
try: loader.load("test.html").generate()
loader.load("test.html").generate() except ZeroDivisionError:
except ZeroDivisionError: self.assertTrue("# test.html:2" in traceback.format_exc())
pass
self.assertInLog(handler, lambda record: self.assertEqual(record.args[2:], _error_log(loader, "test.html", 2)))


def test_error_line_number_directive(self): def test_error_line_number_directive(self):
loader = DictLoader({"test.html": """one loader = DictLoader({"test.html": """one
two{%if 1/0%} two{%if 1/0%}
three{%end%} three{%end%}
"""}) """})
with LogCaptureHandler() as handler: try:
try: loader.load("test.html").generate()
loader.load("test.html").generate() except ZeroDivisionError:
except ZeroDivisionError: self.assertTrue("# test.html:2" in traceback.format_exc())
pass
self.assertInLog(handler, lambda record: self.assertEqual(record.args[2:], _error_log(loader, "test.html", 2)))


def test_error_line_number_module(self): def test_error_line_number_module(self):
loader = DictLoader({ loader = DictLoader({
"base.html": "{% module Template('sub.html') %}", "base.html": "{% module Template('sub.html') %}",
"sub.html": "{{1/0}}", "sub.html": "{{1/0}}",
}, namespace={"_modules": ObjectDict({"Template": lambda path, **kwargs: loader.load(path).generate(**kwargs)})}) }, namespace={"_modules": ObjectDict({"Template": lambda path, **kwargs: loader.load(path).generate(**kwargs)})})
with LogCaptureHandler() as handler: try:
try: loader.load("base.html").generate()
loader.load("base.html").generate() except ZeroDivisionError:
except ZeroDivisionError: exc_stack = traceback.format_exc()
pass self.assertTrue('# base.html:1' in exc_stack)
self.assertInLog(handler, lambda record: self.assertEqual(record.args[0], "base.html") and self.assertTrue('# sub.html:1' in exc_stack)
self.assertEqual(record.args[2], "sub.html") and
self.assertEqual(record.args[4:],
_error_log(loader, "base.html", 1) +
_error_log(loader, "sub.html", 1)))


def test_error_line_number_include(self): def test_error_line_number_include(self):
loader = DictLoader({ loader = DictLoader({
"base.html": "{% include 'sub.html' %}", "base.html": "{% include 'sub.html' %}",
"sub.html": "{{1/0}}", "sub.html": "{{1/0}}",
}) })
with LogCaptureHandler() as handler: try:
try: loader.load("base.html").generate()
loader.load("base.html").generate() except ZeroDivisionError:
except ZeroDivisionError: self.assertTrue("# sub.html:1 (via base.html:1)" in
pass traceback.format_exc())
self.assertInLog(handler, lambda record: self.assertEqual(record.args[2:],
_error_log(loader, "base.html", 1) +
_error_log(loader, "sub.html", 1)))


def test_error_line_number_extends_base_error(self): def test_error_line_number_extends_base_error(self):
loader = DictLoader({ loader = DictLoader({
"base.html": "{{1/0}}", "base.html": "{{1/0}}",
"sub.html": "{% extends 'base.html' %}", "sub.html": "{% extends 'base.html' %}",
}) })
with LogCaptureHandler() as handler: try:
try: loader.load("sub.html").generate()
loader.load("sub.html").generate() except ZeroDivisionError:
except ZeroDivisionError: exc_stack = traceback.format_exc()
pass self.assertTrue("# base.html:1" in exc_stack)
self.assertInLog(handler, lambda record: self.assertEqual(record.args[2:],
_error_log(loader, "base.html", 1)))


def test_error_line_number_extends_sub_error(self): def test_error_line_number_extends_sub_error(self):
loader = DictLoader({ loader = DictLoader({
Expand All @@ -172,14 +162,23 @@ def test_error_line_number_extends_sub_error(self):
{{1/0}} {{1/0}}
{% end %} {% end %}
"""}) """})
with LogCaptureHandler() as handler: try:
try: loader.load("sub.html").generate()
loader.load("sub.html").generate() except ZeroDivisionError:
except ZeroDivisionError: self.assertTrue("# sub.html:4 (via base.html:1)" in
pass traceback.format_exc())
self.assertInLog(handler, lambda record: self.assertEqual(record.args[2:],
_error_log(loader, "base.html", 1) + def test_multi_includes(self):
_error_log(loader, "sub.html", 4))) loader = DictLoader({
"a.html": "{% include 'b.html' %}",
"b.html": "{% include 'c.html' %}",
"c.html": "{{1/0}}",
})
try:
loader.load("a.html").generate()
except ZeroDivisionError:
self.assertTrue("# c.html:1 (via b.html:1, a.html:1)" in
traceback.format_exc())




class AutoEscapeTest(LogTrapTestCase): class AutoEscapeTest(LogTrapTestCase):
Expand Down
22 changes: 0 additions & 22 deletions tornado/testing.py
Expand Up @@ -301,28 +301,6 @@ def run(self, result=None):
finally: finally:
handler.stream = old_stream handler.stream = old_stream



class LogCaptureTestCase(unittest.TestCase):
def assertInLog(self, handler, asserts):
for record in handler.buffer:
try:
asserts(record)
except AssertionError as err:
pass
else:
return
self.fail("No matching record found in log: %s" % handler.prettyPrintBuffer())

def assertNotInLog(self, handler, asserts):
for record in handler.buffer:
try:
asserts(record)
except AssertionError as err:
pass
else:
self.fail("No matching record found in log: %s" % handler.prettyPrintBuffer())


def main(): def main():
"""A simple test runner. """A simple test runner.
Expand Down
21 changes: 0 additions & 21 deletions tornado/util.py
@@ -1,9 +1,5 @@
"""Miscellaneous utility functions.""" """Miscellaneous utility functions."""


from logging.handlers import MemoryHandler
import json
import logging

class ObjectDict(dict): class ObjectDict(dict):
"""Makes a dictionary behave like an object.""" """Makes a dictionary behave like an object."""
def __getattr__(self, name): def __getattr__(self, name):
Expand All @@ -16,23 +12,6 @@ def __setattr__(self, name, value):
self[name] = value self[name] = value




class LogCaptureHandler(MemoryHandler):
def __init__(self):
MemoryHandler.__init__(self, capacity=0, flushLevel=100)
self.logger = logging.getLogger()

def __enter__(self):
self.logger.addHandler(self)
return self

def __exit__(self, type, value, traceback):
self.logger.removeHandler(self)
self.close()

def prettyPrintBuffer(self):
return json.dumps([record.__dict__ for record in self.buffer], sort_keys=True, indent=4)


def import_object(name): def import_object(name):
"""Imports an object by name. """Imports an object by name.
Expand Down

0 comments on commit 597f70b

Please sign in to comment.