Commit 3b5c5a22 by Christian Hammond

Add error output for compiler errors within the browser.

Debugging CSS/JavaScript compilation errors can be difficult when viewed
within the context of a Django error page, and if there's a wider
problem with other CSS/JavaScript packages, they won't show up until the
first compilation error is fixed. This is worse if running in a mode
where Pipeline is running but Django error pages aren't being shown.

This change introduces an option, PIPELINE.SHOW_ERRORS_INLINE (now
enabled by default) that outputs any compilation errors at the top of
the page, visually distinguished from the rest of the page, and
containing information on the failing package, command line, and output.
This makes it much easier to debug what went wrong.

The errors are outputted into a hidden div, and then once the page has
fully loaded, they're placed at the top. This allows Pipeline usage
anywhere on the page to promote errors to the top, in the order in which
they're loaded. These also have a default style, but that can be
overridden (and in fact the whole error template overridden) by the
consuming application.
parent f2366b5e
...@@ -145,6 +145,18 @@ Defaults to ``True`` ...@@ -145,6 +145,18 @@ Defaults to ``True``
this only work when PIPELINE_ENABLED is False. this only work when PIPELINE_ENABLED is False.
``SHOW_ERRORS_INLINE``
......................
``True`` if errors compiling CSS/JavaScript files should be shown inline at
the top of the browser window, or ``False`` if they should trigger exceptions
(the older behavior).
This only applies when compiling through the ``{% stylesheet %}`` or
``{% javascript %}`` template tags. It won't impact ``collectstatic``.
Defaults to ``settings.DEBUG``.
``CSS_COMPRESSOR`` ``CSS_COMPRESSOR``
.................. ..................
......
...@@ -8,7 +8,7 @@ from django.contrib.staticfiles import finders ...@@ -8,7 +8,7 @@ from django.contrib.staticfiles import finders
from django.contrib.staticfiles.storage import staticfiles_storage from django.contrib.staticfiles.storage import staticfiles_storage
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.utils.encoding import smart_bytes from django.utils.encoding import smart_bytes
from django.utils.six import string_types from django.utils.six import string_types, text_type
from pipeline.conf import settings from pipeline.conf import settings
from pipeline.exceptions import CompilerError from pipeline.exceptions import CompilerError
...@@ -125,7 +125,9 @@ class SubProcessCompiler(CompilerBase): ...@@ -125,7 +125,9 @@ class SubProcessCompiler(CompilerBase):
if compiling.returncode != 0: if compiling.returncode != 0:
stdout_captured = None # Don't save erroneous result. stdout_captured = None # Don't save erroneous result.
raise CompilerError( raise CompilerError(
"{0!r} exit code {1}\n{2}".format(argument_list, compiling.returncode, stderr)) "{0!r} exit code {1}\n{2}".format(argument_list, compiling.returncode, stderr),
command=argument_list,
error_output=stderr)
# User wants to see everything that happened. # User wants to see everything that happened.
if self.verbose: if self.verbose:
...@@ -134,7 +136,8 @@ class SubProcessCompiler(CompilerBase): ...@@ -134,7 +136,8 @@ class SubProcessCompiler(CompilerBase):
print(stderr) print(stderr)
except OSError as e: except OSError as e:
stdout_captured = None # Don't save erroneous result. stdout_captured = None # Don't save erroneous result.
raise CompilerError(e) raise CompilerError(e, command=argument_list,
error_output=text_type(e))
finally: finally:
# Decide what to do with captured stdout. # Decide what to do with captured stdout.
if stdout: if stdout:
......
...@@ -23,6 +23,8 @@ DEFAULTS = { ...@@ -23,6 +23,8 @@ DEFAULTS = {
'PIPELINE_ROOT': _settings.STATIC_ROOT, 'PIPELINE_ROOT': _settings.STATIC_ROOT,
'PIPELINE_URL': _settings.STATIC_URL, 'PIPELINE_URL': _settings.STATIC_URL,
'SHOW_ERRORS_INLINE': _settings.DEBUG,
'CSS_COMPRESSOR': 'pipeline.compressors.yuglify.YuglifyCompressor', 'CSS_COMPRESSOR': 'pipeline.compressors.yuglify.YuglifyCompressor',
'JS_COMPRESSOR': 'pipeline.compressors.yuglify.YuglifyCompressor', 'JS_COMPRESSOR': 'pipeline.compressors.yuglify.YuglifyCompressor',
'COMPILERS': [], 'COMPILERS': [],
......
...@@ -10,7 +10,11 @@ class PackageNotFound(PipelineException): ...@@ -10,7 +10,11 @@ class PackageNotFound(PipelineException):
class CompilerError(PipelineException): class CompilerError(PipelineException):
pass def __init__(self, msg, command=None, error_output=None):
super(CompilerError, self).__init__(msg)
self.command = command
self.error_output = error_output.strip()
class CompressorError(PipelineException): class CompressorError(PipelineException):
......
...@@ -34,7 +34,7 @@ class PipelineExtension(PipelineMixin, Extension): ...@@ -34,7 +34,7 @@ class PipelineExtension(PipelineMixin, Extension):
package = self.package_for(package_name, 'css') package = self.package_for(package_name, 'css')
except PackageNotFound: except PackageNotFound:
return '' # fail silently, do not return anything if an invalid group is specified return '' # fail silently, do not return anything if an invalid group is specified
return self.render_compressed(package, 'css') return self.render_compressed(package, package_name, 'css')
def render_css(self, package, path): def render_css(self, package, path):
template_name = package.template_name or "pipeline/css.jinja" template_name = package.template_name or "pipeline/css.jinja"
...@@ -55,7 +55,7 @@ class PipelineExtension(PipelineMixin, Extension): ...@@ -55,7 +55,7 @@ class PipelineExtension(PipelineMixin, Extension):
package = self.package_for(package_name, 'js') package = self.package_for(package_name, 'js')
except PackageNotFound: except PackageNotFound:
return '' # fail silently, do not return anything if an invalid group is specified return '' # fail silently, do not return anything if an invalid group is specified
return self.render_compressed(package, 'js') return self.render_compressed(package, package_name, 'js')
def render_js(self, package, path): def render_js(self, package, path):
template_name = package.template_name or "pipeline/js.jinja" template_name = package.template_name or "pipeline/js.jinja"
......
<div id="django-pipeline-error-{{package_name}}" class="django-pipeline-error"
style="display: none; border: 2px #DD0000 solid; margin: 1em; padding: 1em; background: white;">
<h1>Error compiling {{package_type}} package "{{package_name}}"</h1>
<p><strong>Command:</strong></p>
<pre style="white-space: pre-wrap;">{{command}}</pre>
<p><strong>Errors:</strong></p>
<pre style="white-space: pre-wrap;">{{errors}}</pre>
</div>
<script>
document.addEventListener('readystatechange', function() {
var el,
container;
if (document.readyState !== 'interactive') {
return;
}
el = document.getElementById('django-pipeline-error-{{package_name}}');
container = document.getElementById('django-pipeline-errors');
if (!container) {
container = document.createElement('div');
container.id = 'django-pipeline-errors';
document.body.insertBefore(container, document.body.firstChild);
}
container.appendChild(el);
el.style.display = 'block';
});
</script>
from __future__ import unicode_literals from __future__ import unicode_literals
import logging import logging
import subprocess
from django.contrib.staticfiles.storage import staticfiles_storage from django.contrib.staticfiles.storage import staticfiles_storage
from django import template from django import template
from django.template.base import VariableDoesNotExist from django.template.base import Context, VariableDoesNotExist
from django.template.loader import render_to_string from django.template.loader import render_to_string
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from ..collector import default_collector from ..collector import default_collector
from ..conf import settings from ..conf import settings
from ..exceptions import CompilerError
from ..packager import Packager, PackageNotFound from ..packager import Packager, PackageNotFound
from ..utils import guess_type from ..utils import guess_type
...@@ -51,7 +53,7 @@ class PipelineMixin(object): ...@@ -51,7 +53,7 @@ class PipelineMixin(object):
except VariableDoesNotExist: except VariableDoesNotExist:
pass pass
def render_compressed(self, package, package_type): def render_compressed(self, package, package_name, package_type):
if settings.PIPELINE_ENABLED: if settings.PIPELINE_ENABLED:
method = getattr(self, "render_{0}".format(package_type)) method = getattr(self, "render_{0}".format(package_type))
return method(package, package.output_filename) return method(package, package.output_filename)
...@@ -61,10 +63,29 @@ class PipelineMixin(object): ...@@ -61,10 +63,29 @@ class PipelineMixin(object):
packager = Packager() packager = Packager()
method = getattr(self, "render_individual_{0}".format(package_type)) method = getattr(self, "render_individual_{0}".format(package_type))
paths = packager.compile(package.paths)
try:
paths = packager.compile(package.paths)
except CompilerError as e:
if settings.SHOW_ERRORS_INLINE:
method = getattr(self, 'render_error_{0}'.format(
package_type))
return method(package_name, e)
else:
raise
templates = packager.pack_templates(package) templates = packager.pack_templates(package)
return method(package, paths, templates=templates) return method(package, paths, templates=templates)
def render_error(self, package_type, package_name, e):
return render_to_string('pipeline/compile_error.html', Context({
'package_type': package_type,
'package_name': package_name,
'command': subprocess.list2cmdline(e.command),
'errors': e.error_output,
}))
class StylesheetNode(PipelineMixin, template.Node): class StylesheetNode(PipelineMixin, template.Node):
def __init__(self, name): def __init__(self, name):
...@@ -79,7 +100,7 @@ class StylesheetNode(PipelineMixin, template.Node): ...@@ -79,7 +100,7 @@ class StylesheetNode(PipelineMixin, template.Node):
except PackageNotFound: except PackageNotFound:
logger.warn("Package %r is unknown. Check PIPELINE_CSS in your settings.", package_name) logger.warn("Package %r is unknown. Check PIPELINE_CSS in your settings.", package_name)
return '' # fail silently, do not return anything if an invalid group is specified return '' # fail silently, do not return anything if an invalid group is specified
return self.render_compressed(package, 'css') return self.render_compressed(package, package_name, 'css')
def render_css(self, package, path): def render_css(self, package, path):
template_name = package.template_name or "pipeline/css.html" template_name = package.template_name or "pipeline/css.html"
...@@ -94,6 +115,10 @@ class StylesheetNode(PipelineMixin, template.Node): ...@@ -94,6 +115,10 @@ class StylesheetNode(PipelineMixin, template.Node):
tags = [self.render_css(package, path) for path in paths] tags = [self.render_css(package, path) for path in paths]
return '\n'.join(tags) return '\n'.join(tags)
def render_error_css(self, package_name, e):
return super(StylesheetNode, self).render_error(
'CSS', package_name, e)
class JavascriptNode(PipelineMixin, template.Node): class JavascriptNode(PipelineMixin, template.Node):
def __init__(self, name): def __init__(self, name):
...@@ -108,7 +133,7 @@ class JavascriptNode(PipelineMixin, template.Node): ...@@ -108,7 +133,7 @@ class JavascriptNode(PipelineMixin, template.Node):
except PackageNotFound: except PackageNotFound:
logger.warn("Package %r is unknown. Check PIPELINE_JS in your settings.", package_name) logger.warn("Package %r is unknown. Check PIPELINE_JS in your settings.", package_name)
return '' # fail silently, do not return anything if an invalid group is specified return '' # fail silently, do not return anything if an invalid group is specified
return self.render_compressed(package, 'js') return self.render_compressed(package, package_name, 'js')
def render_js(self, package, path): def render_js(self, package, path):
template_name = package.template_name or "pipeline/js.html" template_name = package.template_name or "pipeline/js.html"
...@@ -132,6 +157,10 @@ class JavascriptNode(PipelineMixin, template.Node): ...@@ -132,6 +157,10 @@ class JavascriptNode(PipelineMixin, template.Node):
tags.append(self.render_inline(package, templates)) tags.append(self.render_inline(package, templates))
return '\n'.join(tags) return '\n'.join(tags)
def render_error_js(self, package_name, e):
return super(JavascriptNode, self).render_error(
'JavaScript', package_name, e)
@register.tag @register.tag
def stylesheet(parser, token): def stylesheet(parser, token):
......
...@@ -156,7 +156,16 @@ class InvalidCompilerTest(TestCase): ...@@ -156,7 +156,16 @@ class InvalidCompilerTest(TestCase):
self.compiler = Compiler() self.compiler = Compiler()
def test_compile(self): def test_compile(self):
self.assertRaises(CompilerError, self.compiler.compile, [_('pipeline/js/dummy.coffee')]) with self.assertRaises(CompilerError) as cm:
self.compiler.compile([_('pipeline/js/dummy.coffee')])
e = cm.exception
self.assertEqual(
e.command,
['this-exists-nowhere-as-a-command-and-should-fail',
'pipeline/js/dummy.coffee',
'pipeline/js/dummy.junk'])
self.assertEqual(e.error_output, '')
def tearDown(self): def tearDown(self):
default_collector.clear() default_collector.clear()
...@@ -170,7 +179,12 @@ class FailingCompilerTest(TestCase): ...@@ -170,7 +179,12 @@ class FailingCompilerTest(TestCase):
self.compiler = Compiler() self.compiler = Compiler()
def test_compile(self): def test_compile(self):
self.assertRaises(CompilerError, self.compiler.compile, [_('pipeline/js/dummy.coffee')]) with self.assertRaises(CompilerError) as cm:
self.compiler.compile([_('pipeline/js/dummy.coffee')])
e = cm.exception
self.assertEqual(e.command, ['/usr/bin/env', 'false'])
self.assertEqual(e.error_output, '')
def tearDown(self): def tearDown(self):
default_collector.clear() default_collector.clear()
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment