├── tests ├── __init__.py ├── models.py ├── views.py ├── tests │ ├── models.py │ ├── __init__.py │ ├── test_utils.py │ ├── test_packager.py │ ├── test_conf.py │ ├── test_middleware.py │ ├── test_collector.py │ ├── test_views.py │ ├── test_glob.py │ ├── test_storage.py │ ├── test_template.py │ └── test_forms.py ├── templates │ ├── empty.html │ └── index.html ├── assets │ ├── fonts │ │ ├── pipeline.eot │ │ ├── pipeline.svg │ │ ├── pipeline.ttf │ │ └── pipeline.woff │ ├── images │ │ ├── sprite-buttons.png │ │ ├── arrow.png │ │ └── embed │ │ │ └── arrow.png │ ├── compilers │ │ ├── stylus │ │ │ ├── input.styl │ │ │ └── expected.css │ │ ├── less │ │ │ ├── expected.css │ │ │ └── input.less │ │ ├── livescript │ │ │ ├── input.ls │ │ │ └── expected.js │ │ ├── coffee │ │ │ ├── input.coffee │ │ │ └── expected.js │ │ ├── scss │ │ │ ├── expected.css │ │ │ └── input.scss │ │ ├── typescript │ │ │ ├── expected.js │ │ │ └── input.ts │ │ └── es6 │ │ │ ├── input.es6 │ │ │ └── expected.js │ ├── css │ │ ├── first.css │ │ ├── second.css │ │ ├── unicode.css │ │ ├── sourcemap.css │ │ ├── urls.css │ │ └── nested │ │ │ └── nested.css │ ├── compressors │ │ ├── cssmin.css │ │ ├── yui.css │ │ ├── csstidy.css │ │ ├── yuglify.css │ │ ├── csshtmljsminify.css │ │ ├── terser.js │ │ ├── yui.js │ │ ├── jsmin.js │ │ ├── yuglify.js │ │ ├── closure.js │ │ ├── slimit.js │ │ ├── uglifyjs.js │ │ └── csshtmljsminify.js │ ├── js │ │ ├── dummy.coffee │ │ ├── application.js │ │ ├── second.js │ │ ├── first.js │ │ └── sourcemap.js │ └── templates │ │ ├── photo │ │ ├── list.jst │ │ └── detail.jst │ │ └── video │ │ └── detail.jst ├── urls.py ├── utils.py └── settings.py ├── pipeline ├── templatetags │ ├── __init__.py │ └── pipeline.py ├── signals.py ├── jinja2 │ ├── pipeline │ │ ├── js.jinja │ │ ├── inline_js.jinja │ │ └── css.jinja │ └── __init__.py ├── templates │ └── pipeline │ │ ├── js.html │ │ ├── inline_js.html │ │ ├── js.jinja │ │ ├── inline_js.jinja │ │ ├── css.jinja │ │ ├── css.html │ │ └── compile_error.html ├── compressors │ ├── closure.py │ ├── cssmin.py │ ├── jsmin.py │ ├── terser.py │ ├── uglifyjs.py │ ├── csshtmljsminify.py │ ├── yui.py │ ├── yuglify.py │ └── csstidy.py ├── exceptions.py ├── compilers │ ├── stylus.py │ ├── sass.py │ ├── typescript.py │ ├── es6.py │ ├── livescript.py │ ├── less.py │ ├── coffee.py │ └── __init__.py ├── __init__.py ├── middleware.py ├── views.py ├── glob.py ├── utils.py ├── collector.py ├── conf.py ├── finders.py ├── storage.py └── packager.py ├── .flake8 ├── .github ├── dependabot.yml └── workflows │ ├── release.yml │ └── test.yml ├── .readthedocs.yaml ├── MANIFEST.in ├── .gitignore ├── .pre-commit-config.yaml ├── docs ├── signals.rst ├── index.rst ├── using.rst ├── installation.rst ├── templates.rst ├── storages.rst ├── Makefile ├── make.bat ├── usage.rst ├── compilers.rst ├── conf.py └── configuration.rst ├── package.json ├── LICENSE ├── tox.ini ├── CONTRIBUTING.rst ├── CODE_OF_CONDUCT.md ├── pyproject.toml ├── README.rst ├── AUTHORS └── HISTORY.rst /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/models.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/views.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/tests/models.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/templates/empty.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pipeline/templatetags/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/assets/fonts/pipeline.eot: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/assets/fonts/pipeline.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/assets/fonts/pipeline.ttf: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/assets/fonts/pipeline.woff: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/assets/images/sprite-buttons.png: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/assets/compilers/stylus/input.styl: -------------------------------------------------------------------------------- 1 | .a 2 | color: black -------------------------------------------------------------------------------- /tests/assets/css/first.css: -------------------------------------------------------------------------------- 1 | .concat { 2 | display: none; 3 | } 4 | -------------------------------------------------------------------------------- /tests/assets/compilers/less/expected.css: -------------------------------------------------------------------------------- 1 | .a { 2 | width: 1px; 3 | } 4 | -------------------------------------------------------------------------------- /tests/assets/compilers/livescript/input.ls: -------------------------------------------------------------------------------- 1 | times = (x, y) -> 2 | x * y 3 | -------------------------------------------------------------------------------- /tests/assets/compilers/stylus/expected.css: -------------------------------------------------------------------------------- 1 | .a { 2 | color: #000; 3 | } 4 | -------------------------------------------------------------------------------- /tests/assets/css/second.css: -------------------------------------------------------------------------------- 1 | .concatenate { 2 | display: block; 3 | } 4 | -------------------------------------------------------------------------------- /tests/assets/compressors/cssmin.css: -------------------------------------------------------------------------------- 1 | .concat{display:none}.concatenate{display:block} -------------------------------------------------------------------------------- /tests/assets/compressors/yui.css: -------------------------------------------------------------------------------- 1 | .concat{display:none}.concatenate{display:block} -------------------------------------------------------------------------------- /tests/assets/compressors/csstidy.css: -------------------------------------------------------------------------------- 1 | .concat{display:none;}.concatenate{display:block;} -------------------------------------------------------------------------------- /tests/assets/js/dummy.coffee: -------------------------------------------------------------------------------- 1 | square = (x) -> x * x 2 | cube = (x) -> square(x) * x 3 | -------------------------------------------------------------------------------- /tests/assets/compressors/yuglify.css: -------------------------------------------------------------------------------- 1 | .concat{display:none}.concatenate{display:block} 2 | -------------------------------------------------------------------------------- /tests/assets/js/application.js: -------------------------------------------------------------------------------- 1 | function test() { 2 | alert('this is a test'); 3 | } 4 | -------------------------------------------------------------------------------- /tests/assets/compilers/less/input.less: -------------------------------------------------------------------------------- 1 | @a: 1; 2 | 3 | .a { 4 | width: (@a + 0px); 5 | } 6 | -------------------------------------------------------------------------------- /tests/assets/css/unicode.css: -------------------------------------------------------------------------------- 1 | .some_class { 2 | // Some unicode 3 | content: "áéíóú"; 4 | } 5 | -------------------------------------------------------------------------------- /tests/assets/compilers/coffee/input.coffee: -------------------------------------------------------------------------------- 1 | square = (x) -> x * x 2 | cube = (x) -> square(x) * x 3 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 88 3 | exclude = 4 | .tox 5 | node_modules 6 | *env/ 7 | -------------------------------------------------------------------------------- /tests/assets/compressors/csshtmljsminify.css: -------------------------------------------------------------------------------- 1 | @charset "utf-8";.concat{display:none}.concatenate{display:block} -------------------------------------------------------------------------------- /pipeline/signals.py: -------------------------------------------------------------------------------- 1 | from django.dispatch import Signal 2 | 3 | css_compressed = Signal() 4 | js_compressed = Signal() 5 | -------------------------------------------------------------------------------- /tests/assets/images/arrow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-pipeline/master/tests/assets/images/arrow.png -------------------------------------------------------------------------------- /tests/assets/images/embed/arrow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-pipeline/master/tests/assets/images/embed/arrow.png -------------------------------------------------------------------------------- /tests/assets/js/second.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | window.cat = function() { 3 | console.log("hello world"); 4 | } 5 | }()); 6 | -------------------------------------------------------------------------------- /tests/assets/js/first.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | window.concat = function() { 3 | console.log(arguments); 4 | } 5 | }()) // No semicolon 6 | -------------------------------------------------------------------------------- /tests/assets/templates/photo/list.jst: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | <%= caption %> 5 |
6 |
-------------------------------------------------------------------------------- /tests/assets/compilers/livescript/expected.js: -------------------------------------------------------------------------------- 1 | (function(){ 2 | var times; 3 | times = function(x, y){ 4 | return x * y; 5 | }; 6 | }).call(this); 7 | -------------------------------------------------------------------------------- /tests/assets/compressors/terser.js: -------------------------------------------------------------------------------- 1 | (function(){window.concat=function(){console.log(arguments)},window.cat=function(){console.log("hello world")}}).call(this); 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "monthly" 7 | -------------------------------------------------------------------------------- /pipeline/jinja2/pipeline/js.jinja: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /pipeline/templates/pipeline/js.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/assets/templates/video/detail.jst: -------------------------------------------------------------------------------- 1 |
2 |
-------------------------------------------------------------------------------- /pipeline/templates/pipeline/inline_js.html: -------------------------------------------------------------------------------- 1 | 2 | {{ source|safe }} 3 | -------------------------------------------------------------------------------- /pipeline/templates/pipeline/js.jinja: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /pipeline/jinja2/pipeline/inline_js.jinja: -------------------------------------------------------------------------------- 1 | 2 | {{ source|safe }} 3 | 4 | -------------------------------------------------------------------------------- /tests/assets/templates/photo/detail.jst: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | <%= caption %> by <%= author %> 5 |
6 |
-------------------------------------------------------------------------------- /pipeline/templates/pipeline/inline_js.jinja: -------------------------------------------------------------------------------- 1 | 2 | {{ source|safe }} 3 | 4 | -------------------------------------------------------------------------------- /tests/assets/compilers/scss/expected.css: -------------------------------------------------------------------------------- 1 | .a .b { 2 | display: none; 3 | } 4 | 5 | .c .d { 6 | display: block; 7 | } 8 | 9 | /*# sourceMappingURL=input.css.map */ 10 | -------------------------------------------------------------------------------- /tests/assets/compilers/scss/input.scss: -------------------------------------------------------------------------------- 1 | .a { 2 | .b { 3 | display: none; 4 | } 5 | } 6 | .c { 7 | .d { 8 | display: block; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /tests/assets/compressors/yui.js: -------------------------------------------------------------------------------- 1 | (function(){(function(){window.concat=function(){console.log(arguments)}}());(function(){window.cat=function(){console.log("hello world")}}())}).call(this); -------------------------------------------------------------------------------- /tests/assets/compressors/jsmin.js: -------------------------------------------------------------------------------- 1 | (function(){(function(){window.concat=function(){console.log(arguments);}}());(function(){window.cat=function(){console.log("hello world");}}());}).call(this); -------------------------------------------------------------------------------- /tests/assets/compressors/yuglify.js: -------------------------------------------------------------------------------- 1 | (function(){!function(){window.concat=function(){console.log(arguments)}}(),function(){window.cat=function(){console.log("hello world")}}()}).call(this); 2 | -------------------------------------------------------------------------------- /pipeline/jinja2/pipeline/css.jinja: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /pipeline/templates/pipeline/css.jinja: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /tests/assets/compressors/closure.js: -------------------------------------------------------------------------------- 1 | (function(){(function(){window.concat=function(){console.log(arguments)}})();(function(){window.cat=function(){console.log("hello world")}})()}).call(this); 2 | -------------------------------------------------------------------------------- /tests/assets/compressors/slimit.js: -------------------------------------------------------------------------------- 1 | (function(){(function(){window.concat=function(){console.log(arguments);};}());(function(){window.cat=function(){console.log("hello world");};}());}).call(this); -------------------------------------------------------------------------------- /tests/assets/compressors/uglifyjs.js: -------------------------------------------------------------------------------- 1 | (function(){(function(){window.concat=function(){console.log(arguments)}})();(function(){window.cat=function(){console.log("hello world")}})()}).call(this); 2 | -------------------------------------------------------------------------------- /tests/assets/compressors/csshtmljsminify.js: -------------------------------------------------------------------------------- 1 | (function(){(function(){window.concat=function(){console.log(arguments);}}());(function(){window.cat=function(){console.log("hello world");}}());}).call(this); -------------------------------------------------------------------------------- /tests/assets/compilers/typescript/expected.js: -------------------------------------------------------------------------------- 1 | function getName(u) { 2 | return "".concat(u.firstName, " ").concat(u.lastName); 3 | } 4 | var userName = getName({ firstName: "Django", lastName: "Pipeline" }); 5 | -------------------------------------------------------------------------------- /pipeline/templates/pipeline/css.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/assets/compilers/coffee/expected.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | var cube, square; 3 | 4 | square = function(x) { 5 | return x * x; 6 | }; 7 | 8 | cube = function(x) { 9 | return square(x) * x; 10 | }; 11 | 12 | }).call(this); 13 | -------------------------------------------------------------------------------- /tests/templates/index.html: -------------------------------------------------------------------------------- 1 | {% load pipeline %} 2 | 3 | 4 | 5 | Pipeline 6 | {% stylesheet 'screen' %} 7 | {% javascript 'scripts' %} 8 | 9 | 10 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yaml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | 5 | version: 2 6 | 7 | build: 8 | os: ubuntu-22.04 9 | tools: 10 | python: "3.10" 11 | 12 | sphinx: 13 | configuration: docs/conf.py 14 | -------------------------------------------------------------------------------- /pipeline/compressors/closure.py: -------------------------------------------------------------------------------- 1 | from pipeline.compressors import SubProcessCompressor 2 | from pipeline.conf import settings 3 | 4 | 5 | class ClosureCompressor(SubProcessCompressor): 6 | def compress_js(self, js): 7 | command = (settings.CLOSURE_BINARY, settings.CLOSURE_ARGUMENTS) 8 | return self.execute_command(command, js) 9 | -------------------------------------------------------------------------------- /pipeline/compressors/cssmin.py: -------------------------------------------------------------------------------- 1 | from pipeline.compressors import SubProcessCompressor 2 | from pipeline.conf import settings 3 | 4 | 5 | class CSSMinCompressor(SubProcessCompressor): 6 | def compress_css(self, css): 7 | command = (settings.CSSMIN_BINARY, settings.CSSMIN_ARGUMENTS) 8 | return self.execute_command(command, css) 9 | -------------------------------------------------------------------------------- /tests/assets/compilers/typescript/input.ts: -------------------------------------------------------------------------------- 1 | type FullName = string; 2 | 3 | interface User { 4 | firstName: string; 5 | lastName: string; 6 | } 7 | 8 | 9 | function getName(u: User): FullName { 10 | return `${u.firstName} ${u.lastName}`; 11 | } 12 | 13 | let userName: FullName = getName({firstName: "Django", lastName: "Pipeline"}); 14 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.urls import path 3 | from django.views.generic import TemplateView 4 | 5 | urlpatterns = [ 6 | path("", TemplateView.as_view(template_name="index.html"), name="index"), 7 | path("empty/", TemplateView.as_view(template_name="empty.html"), name="empty"), 8 | path("admin/", admin.site.urls), 9 | ] 10 | -------------------------------------------------------------------------------- /pipeline/compressors/jsmin.py: -------------------------------------------------------------------------------- 1 | from pipeline.compressors import CompressorBase 2 | 3 | 4 | class JSMinCompressor(CompressorBase): 5 | """ 6 | JS compressor based on the Python library jsmin 7 | (http://pypi.python.org/pypi/jsmin/). 8 | """ 9 | 10 | def compress_js(self, js): 11 | from jsmin import jsmin # noqa: PLC0415 12 | 13 | return jsmin(js) 14 | -------------------------------------------------------------------------------- /pipeline/compressors/terser.py: -------------------------------------------------------------------------------- 1 | from pipeline.compressors import SubProcessCompressor 2 | from pipeline.conf import settings 3 | 4 | 5 | class TerserCompressor(SubProcessCompressor): 6 | def compress_js(self, js): 7 | command = (settings.TERSER_BINARY, settings.TERSER_ARGUMENTS) 8 | if self.verbose: 9 | command += " --verbose" 10 | return self.execute_command(command, js) 11 | -------------------------------------------------------------------------------- /pipeline/compressors/uglifyjs.py: -------------------------------------------------------------------------------- 1 | from pipeline.compressors import SubProcessCompressor 2 | from pipeline.conf import settings 3 | 4 | 5 | class UglifyJSCompressor(SubProcessCompressor): 6 | def compress_js(self, js): 7 | command = (settings.UGLIFYJS_BINARY, settings.UGLIFYJS_ARGUMENTS) 8 | if self.verbose: 9 | command += " --verbose" 10 | return self.execute_command(command, js) 11 | -------------------------------------------------------------------------------- /tests/assets/js/sourcemap.js: -------------------------------------------------------------------------------- 1 | const abc = 123; 2 | 3 | 4 | //# sourceMappingURL=sourcemap1.js.map 5 | 6 | //@ sourceMappingURL=sourcemap2.js.map 7 | 8 | /*# sourceMappingURL=sourcemap3.js.map */ 9 | 10 | /*@ sourceMappingURL=sourcemap4.js.map */ 11 | 12 | //# sourceURL=sourcemap5.js.map 13 | 14 | //@ sourceURL=sourcemap6.js.map 15 | 16 | /*# sourceURL=sourcemap7.js.map */ 17 | 18 | /*@ sourceURL=sourcemap8.js.map */ 19 | -------------------------------------------------------------------------------- /tests/assets/compilers/es6/input.es6: -------------------------------------------------------------------------------- 1 | // Expression bodies 2 | var odds = evens.map(v => v + 1); 3 | var nums = evens.map((v, i) => v + i); 4 | 5 | // Statement bodies 6 | nums.forEach(v => { 7 | if (v % 5 === 0) 8 | fives.push(v); 9 | }); 10 | 11 | // Lexical this 12 | var bob = { 13 | _name: "Bob", 14 | _friends: [], 15 | printFriends() { 16 | this._friends.forEach(f => 17 | console.log(this._name + " knows " + f)); 18 | } 19 | }; 20 | -------------------------------------------------------------------------------- /pipeline/exceptions.py: -------------------------------------------------------------------------------- 1 | class PipelineException(Exception): 2 | pass 3 | 4 | 5 | class PackageNotFound(PipelineException): 6 | pass 7 | 8 | 9 | class CompilerError(PipelineException): 10 | def __init__(self, msg, command=None, error_output=None): 11 | super().__init__(msg) 12 | 13 | self.command = command 14 | self.error_output = error_output.strip() 15 | 16 | 17 | class CompressorError(PipelineException): 18 | pass 19 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | recursive-include pipeline/templates *.html *.jinja 2 | recursive-include pipeline/jinja2 *.html *.jinja 3 | include AUTHORS LICENSE README.rst HISTORY.rst CONTRIBUTING.rst 4 | recursive-include tests * 5 | recursive-exclude tests *.pyc *.pyo 6 | recursive-exclude tests/node_modules * 7 | recursive-exclude tests/npm-cache * 8 | recursive-exclude tests/npm * 9 | include docs/Makefile docs/make.bat docs/conf.py 10 | recursive-include docs *.rst 11 | exclude package.json requirements.txt tox.ini 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .AppleDouble 2 | *.pyc 3 | :2e_* 4 | *.tmproj 5 | .*.swp 6 | *.swo 7 | build 8 | dist 9 | MANIFEST 10 | docs/_build/ 11 | *.egg-info 12 | .coverage 13 | coverage/ 14 | coverage.xml 15 | tests/static/ 16 | tests/assets/js/dummy.js 17 | tests/node_modules/ 18 | .tox/ 19 | .DS_Store 20 | .idea 21 | .venv 22 | .vscode 23 | .project 24 | .pydevproject 25 | .ropeproject 26 | __pycache__ 27 | npm-debug.log 28 | tests/npm-cache 29 | django-pipeline-*/ 30 | .tags 31 | node_modules/ 32 | package-lock.json 33 | -------------------------------------------------------------------------------- /tests/assets/css/sourcemap.css: -------------------------------------------------------------------------------- 1 | div { 2 | display: inline; 3 | } 4 | 5 | span { 6 | display: block; 7 | } 8 | 9 | 10 | //# sourceMappingURL=sourcemap1.css.map 11 | 12 | //@ sourceMappingURL=sourcemap2.css.map 13 | 14 | /*# sourceMappingURL=sourcemap3.css.map */ 15 | 16 | /*@ sourceMappingURL=sourcemap4.css.map */ 17 | 18 | //# sourceURL=sourcemap5.css.map 19 | 20 | //@ sourceURL=sourcemap6.css.map 21 | 22 | /*# sourceURL=sourcemap7.css.map */ 23 | 24 | /*@ sourceURL=sourcemap8.css.map */ 25 | -------------------------------------------------------------------------------- /tests/tests/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | if sys.platform.startswith("win"): 5 | os.environ.setdefault("NUMBER_OF_PROCESSORS", "1") 6 | 7 | 8 | from .test_collector import * # noqa 9 | from .test_compiler import * # noqa 10 | from .test_compressor import * # noqa 11 | from .test_glob import * # noqa 12 | from .test_middleware import * # noqa 13 | from .test_packager import * # noqa 14 | from .test_storage import * # noqa 15 | from .test_template import * # noqa 16 | from .test_utils import * # noqa 17 | from .test_views import * # noqa 18 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/psf/black 3 | rev: 24.8.0 4 | hooks: 5 | - id: black 6 | 7 | - repo: https://github.com/PyCQA/isort 8 | rev: 5.13.2 9 | hooks: 10 | - id: isort 11 | 12 | - repo: https://github.com/PyCQA/flake8 13 | rev: 7.1.1 14 | hooks: 15 | - id: flake8 16 | 17 | - repo: https://github.com/pre-commit/pre-commit-hooks 18 | rev: v5.0.0 19 | hooks: 20 | - id: check-merge-conflict 21 | - id: check-yaml 22 | 23 | ci: 24 | autoupdate_schedule: quarterly 25 | -------------------------------------------------------------------------------- /pipeline/compilers/stylus.py: -------------------------------------------------------------------------------- 1 | from os.path import dirname 2 | 3 | from pipeline.compilers import SubProcessCompiler 4 | from pipeline.conf import settings 5 | 6 | 7 | class StylusCompiler(SubProcessCompiler): 8 | output_extension = "css" 9 | 10 | def match_file(self, filename): 11 | return filename.endswith(".styl") 12 | 13 | def compile_file(self, infile, outfile, outdated=False, force=False): 14 | command = (settings.STYLUS_BINARY, settings.STYLUS_ARGUMENTS, infile) 15 | return self.execute_command(command, cwd=dirname(infile)) 16 | -------------------------------------------------------------------------------- /pipeline/compilers/sass.py: -------------------------------------------------------------------------------- 1 | from os.path import dirname 2 | 3 | from pipeline.compilers import SubProcessCompiler 4 | from pipeline.conf import settings 5 | 6 | 7 | class SASSCompiler(SubProcessCompiler): 8 | output_extension = "css" 9 | 10 | def match_file(self, filename): 11 | return filename.endswith((".scss", ".sass")) 12 | 13 | def compile_file(self, infile, outfile, outdated=False, force=False): 14 | command = (settings.SASS_BINARY, settings.SASS_ARGUMENTS, infile, outfile) 15 | return self.execute_command(command, cwd=dirname(infile)) 16 | -------------------------------------------------------------------------------- /pipeline/compressors/csshtmljsminify.py: -------------------------------------------------------------------------------- 1 | from pipeline.compressors import CompressorBase 2 | 3 | 4 | class CssHtmlJsMinifyCompressor(CompressorBase): 5 | """ 6 | CSS, HTML and JS compressor based on the Python library css-html-js-minify 7 | (https://pypi.org/project/css-html-js-minify/). 8 | """ 9 | 10 | def compress_css(self, css): 11 | from css_html_js_minify import css_minify # noqa: PLC0415 12 | 13 | return css_minify(css) 14 | 15 | def compress_js(self, js): 16 | from css_html_js_minify import js_minify # noqa: PLC0415 17 | 18 | return js_minify(js) 19 | -------------------------------------------------------------------------------- /pipeline/compressors/yui.py: -------------------------------------------------------------------------------- 1 | from pipeline.compressors import SubProcessCompressor 2 | from pipeline.conf import settings 3 | 4 | 5 | class YUICompressor(SubProcessCompressor): 6 | def compress_common(self, content, compress_type, arguments): 7 | command = (settings.YUI_BINARY, f"--type={compress_type}", arguments) 8 | return self.execute_command(command, content) 9 | 10 | def compress_js(self, js): 11 | return self.compress_common(js, "js", settings.YUI_JS_ARGUMENTS) 12 | 13 | def compress_css(self, css): 14 | return self.compress_common(css, "css", settings.YUI_CSS_ARGUMENTS) 15 | -------------------------------------------------------------------------------- /tests/assets/compilers/es6/expected.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | // Expression bodies 4 | var odds = evens.map(function (v) { 5 | return v + 1; 6 | }); 7 | var nums = evens.map(function (v, i) { 8 | return v + i; 9 | }); 10 | 11 | // Statement bodies 12 | nums.forEach(function (v) { 13 | if (v % 5 === 0) fives.push(v); 14 | }); 15 | 16 | // Lexical this 17 | var bob = { 18 | _name: "Bob", 19 | _friends: [], 20 | printFriends: function printFriends() { 21 | var _this = this; 22 | 23 | this._friends.forEach(function (f) { 24 | return console.log(_this._name + " knows " + f); 25 | }); 26 | } 27 | }; 28 | -------------------------------------------------------------------------------- /tests/tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import mimetypes 2 | 3 | from django.test import TestCase 4 | 5 | from pipeline.utils import guess_type 6 | 7 | 8 | class UtilTest(TestCase): 9 | def test_guess_type(self): 10 | self.assertEqual("text/css", guess_type("stylesheet.css")) 11 | self.assertEqual("text/coffeescript", guess_type("application.coffee")) 12 | self.assertEqual("text/less", guess_type("stylesheet.less")) 13 | 14 | def test_mimetypes_are_str(self): 15 | for ext, mtype in mimetypes.types_map.items(): 16 | self.assertIsInstance(ext, str) 17 | self.assertIsInstance(mtype, str) 18 | -------------------------------------------------------------------------------- /pipeline/compressors/yuglify.py: -------------------------------------------------------------------------------- 1 | from pipeline.compressors import SubProcessCompressor 2 | from pipeline.conf import settings 3 | 4 | 5 | class YuglifyCompressor(SubProcessCompressor): 6 | def compress_common(self, content, compress_type, arguments): 7 | command = (settings.YUGLIFY_BINARY, f"--type={compress_type}", arguments) 8 | return self.execute_command(command, content) 9 | 10 | def compress_js(self, js): 11 | return self.compress_common(js, "js", settings.YUGLIFY_JS_ARGUMENTS) 12 | 13 | def compress_css(self, css): 14 | return self.compress_common(css, "css", settings.YUGLIFY_CSS_ARGUMENTS) 15 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import django 4 | from django.test import override_settings 5 | 6 | 7 | def _(path): 8 | # Make sure the path contains only the correct separator 9 | return path.replace("/", os.sep).replace("\\", os.sep) 10 | 11 | 12 | class pipeline_settings(override_settings): 13 | def __init__(self, **kwargs): 14 | if django.VERSION[:2] >= (1, 10): 15 | # Django 1.10's override_settings inherits from TestContextDecorator 16 | # and its __init__ method calls its superclass' __init__ method too, 17 | # so we must do the same. 18 | super().__init__() 19 | self.options = {"PIPELINE": kwargs} 20 | -------------------------------------------------------------------------------- /pipeline/compressors/csstidy.py: -------------------------------------------------------------------------------- 1 | from django.core.files import temp as tempfile 2 | 3 | from pipeline.compressors import SubProcessCompressor 4 | from pipeline.conf import settings 5 | 6 | 7 | class CSSTidyCompressor(SubProcessCompressor): 8 | def compress_css(self, css): 9 | output_file = tempfile.NamedTemporaryFile(suffix=".pipeline") 10 | 11 | command = ( 12 | settings.CSSTIDY_BINARY, 13 | "-", 14 | settings.CSSTIDY_ARGUMENTS, 15 | output_file.name, 16 | ) 17 | self.execute_command(command, css) 18 | 19 | filtered_css = output_file.read() 20 | output_file.close() 21 | return filtered_css 22 | -------------------------------------------------------------------------------- /pipeline/compilers/typescript.py: -------------------------------------------------------------------------------- 1 | from pipeline.compilers import SubProcessCompiler 2 | from pipeline.conf import settings 3 | 4 | 5 | class TypeScriptCompiler(SubProcessCompiler): 6 | output_extension = "js" 7 | 8 | def match_file(self, path): 9 | return path.endswith(".ts") 10 | 11 | def compile_file(self, infile, outfile, outdated=False, force=False): 12 | if not outdated and not force: 13 | return 14 | command = ( 15 | settings.TYPE_SCRIPT_BINARY, 16 | settings.TYPE_SCRIPT_ARGUMENTS, 17 | infile, 18 | "--outFile", 19 | outfile, 20 | ) 21 | return self.execute_command(command) 22 | -------------------------------------------------------------------------------- /pipeline/compilers/es6.py: -------------------------------------------------------------------------------- 1 | from pipeline.compilers import SubProcessCompiler 2 | from pipeline.conf import settings 3 | 4 | 5 | class ES6Compiler(SubProcessCompiler): 6 | output_extension = "js" 7 | 8 | def match_file(self, path): 9 | return path.endswith(".es6") 10 | 11 | def compile_file(self, infile, outfile, outdated=False, force=False): 12 | if not outdated and not force: 13 | return # File doesn't need to be recompiled 14 | command = ( 15 | settings.BABEL_BINARY, 16 | settings.BABEL_ARGUMENTS, 17 | infile, 18 | "-o", 19 | outfile, 20 | ) 21 | return self.execute_command(command) 22 | -------------------------------------------------------------------------------- /pipeline/compilers/livescript.py: -------------------------------------------------------------------------------- 1 | from pipeline.compilers import SubProcessCompiler 2 | from pipeline.conf import settings 3 | 4 | 5 | class LiveScriptCompiler(SubProcessCompiler): 6 | output_extension = "js" 7 | 8 | def match_file(self, path): 9 | return path.endswith(".ls") 10 | 11 | def compile_file(self, infile, outfile, outdated=False, force=False): 12 | if not outdated and not force: 13 | return # File doesn't need to be recompiled 14 | command = ( 15 | settings.LIVE_SCRIPT_BINARY, 16 | "-cp", 17 | settings.LIVE_SCRIPT_ARGUMENTS, 18 | infile, 19 | ) 20 | return self.execute_command(command, stdout_captured=outfile) 21 | -------------------------------------------------------------------------------- /pipeline/compilers/less.py: -------------------------------------------------------------------------------- 1 | from os.path import dirname 2 | 3 | from pipeline.compilers import SubProcessCompiler 4 | from pipeline.conf import settings 5 | 6 | 7 | class LessCompiler(SubProcessCompiler): 8 | output_extension = "css" 9 | 10 | def match_file(self, filename): 11 | return filename.endswith(".less") 12 | 13 | def compile_file(self, infile, outfile, outdated=False, force=False): 14 | # Pipe to file rather than provide outfile arg due to a bug in lessc 15 | command = ( 16 | settings.LESS_BINARY, 17 | settings.LESS_ARGUMENTS, 18 | infile, 19 | ) 20 | return self.execute_command( 21 | command, cwd=dirname(infile), stdout_captured=outfile 22 | ) 23 | -------------------------------------------------------------------------------- /pipeline/compilers/coffee.py: -------------------------------------------------------------------------------- 1 | from pipeline.compilers import SubProcessCompiler 2 | from pipeline.conf import settings 3 | 4 | 5 | class CoffeeScriptCompiler(SubProcessCompiler): 6 | output_extension = "js" 7 | 8 | def match_file(self, path): 9 | return path.endswith(".coffee") or path.endswith(".litcoffee") 10 | 11 | def compile_file(self, infile, outfile, outdated=False, force=False): 12 | if not outdated and not force: 13 | return # File doesn't need to be recompiled 14 | command = ( 15 | settings.COFFEE_SCRIPT_BINARY, 16 | "-cp", 17 | settings.COFFEE_SCRIPT_ARGUMENTS, 18 | infile, 19 | ) 20 | return self.execute_command(command, stdout_captured=outfile) 21 | -------------------------------------------------------------------------------- /docs/signals.rst: -------------------------------------------------------------------------------- 1 | .. _ref-signals: 2 | 3 | ======= 4 | Signals 5 | ======= 6 | 7 | List of all signals sent by pipeline. 8 | 9 | css_compressed 10 | -------------- 11 | 12 | **pipeline.signals.css_compressed** 13 | 14 | Whenever a css package is compressed, this signal is sent after the compression. 15 | 16 | Arguments sent with this signal : 17 | 18 | :sender: 19 | The ``Packager`` class that compressed the group. 20 | 21 | :package: 22 | The package actually compressed. 23 | 24 | 25 | js_compressed 26 | -------------- 27 | 28 | **pipeline.signals.js_compressed** 29 | 30 | Whenever a js package is compressed, this signal is sent after the compression. 31 | 32 | Arguments sent with this signal : 33 | 34 | :sender: 35 | The ``Packager`` class that compressed the group. 36 | 37 | :package: 38 | The package actually compressed. 39 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Introduction 2 | ============ 3 | 4 | Pipeline is an asset packaging library for Django, providing 5 | both CSS and JavaScript concatenation and compression, built-in JavaScript 6 | template support, and optional data-URI image and font embedding. 7 | 8 | You can report bugs and discuss features on the `issues page `_. 9 | 10 | You can discuss features or ask questions on the IRC channel on freenode : `#django-pipeline `_ 11 | 12 | 13 | Table Of Contents 14 | ================= 15 | 16 | .. toctree:: 17 | :maxdepth: 2 18 | 19 | installation 20 | configuration 21 | usage 22 | compressors 23 | compilers 24 | templates 25 | storages 26 | signals 27 | using 28 | 29 | 30 | Indices and tables 31 | ================== 32 | 33 | * :ref:`search` 34 | -------------------------------------------------------------------------------- /pipeline/__init__.py: -------------------------------------------------------------------------------- 1 | PackageNotFoundError = None 2 | DistributionNotFound = None 3 | try: 4 | from importlib.metadata import PackageNotFoundError 5 | from importlib.metadata import version as get_version 6 | except ImportError: 7 | get_version = None 8 | PackageNotFoundError = None 9 | if get_version is None: 10 | try: 11 | from pkg_resources import DistributionNotFound, get_distribution 12 | 13 | def get_version(x): 14 | return get_distribution(x).version 15 | 16 | except ImportError: 17 | get_version = None 18 | DistributionNotFound = None 19 | get_distribution = None 20 | 21 | __version__ = None 22 | if get_version is not None: 23 | try: 24 | __version__ = get_version("django-pipeline") 25 | except PackageNotFoundError: 26 | pass 27 | except DistributionNotFound: 28 | pass 29 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "django-pipeline-tests", 3 | "private": true, 4 | "version": "1.0.0", 5 | "description": "Pipeline is an asset packaging library for Django.", 6 | "author": "Timothée Peignier ", 7 | "license": "MIT", 8 | "readmeFilename": "../README.rst", 9 | "repository": { 10 | "type": "git", 11 | "url": "git://github.com/jazzband/django-pipeline.git" 12 | }, 13 | "dependencies": { 14 | "babel-cli": "latest", 15 | "babel-preset-es2015": "latest", 16 | "coffeescript": "latest", 17 | "less": "latest", 18 | "livescript": "latest", 19 | "sass": "latest", 20 | "stylus": "latest", 21 | "cssmin": "latest", 22 | "google-closure-compiler": "latest", 23 | "terser": "latest", 24 | "uglify-js": "latest", 25 | "yuglify": "1.0.x", 26 | "yuicompressor": "latest", 27 | "typescript": "latest" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /pipeline/middleware.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import MiddlewareNotUsed 2 | from django.utils.deprecation import MiddlewareMixin 3 | from django.utils.encoding import DjangoUnicodeDecodeError 4 | from django.utils.html import strip_spaces_between_tags as minify_html 5 | 6 | from pipeline.conf import settings 7 | 8 | 9 | class MinifyHTMLMiddleware(MiddlewareMixin): 10 | def __init__(self, *args, **kwargs): 11 | super().__init__(*args, **kwargs) 12 | if not settings.PIPELINE_ENABLED: 13 | raise MiddlewareNotUsed 14 | 15 | def process_response(self, request, response): 16 | if ( 17 | response.has_header("Content-Type") 18 | and "text/html" in response["Content-Type"] 19 | ): 20 | try: 21 | response.content = minify_html(response.content.decode("utf-8").strip()) 22 | response["Content-Length"] = str(len(response.content)) 23 | except DjangoUnicodeDecodeError: 24 | pass 25 | return response 26 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | build: 10 | if: github.repository == 'jazzband/django-pipeline' 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | with: 16 | fetch-depth: 0 17 | 18 | - name: Set up Python 19 | uses: actions/setup-python@v5 20 | with: 21 | python-version: '3.9' 22 | 23 | - name: Install dependencies 24 | run: | 25 | python -m pip install -U pip 26 | python -m pip install -U twine build setuptools-scm 27 | 28 | - name: Build package 29 | run: | 30 | python -m setuptools_scm 31 | python -m build 32 | twine check --strict dist/* 33 | 34 | - name: Upload packages to Jazzband 35 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') 36 | uses: pypa/gh-action-pypi-publish@release/v1 37 | with: 38 | user: jazzband 39 | password: ${{ secrets.JAZZBAND_RELEASE_KEY }} 40 | repository_url: https://jazzband.co/projects/django-pipeline/upload 41 | -------------------------------------------------------------------------------- /pipeline/templates/pipeline/compile_error.html: -------------------------------------------------------------------------------- 1 | 9 | 10 | 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (©) 2008 Andreas Pelme 2 | Copyright (©) 2011-2018 Timothée Peignier 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in 12 | all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | THE SOFTWARE. -------------------------------------------------------------------------------- /tests/assets/css/urls.css: -------------------------------------------------------------------------------- 1 | .embedded-url-svg { 2 | background-image: url("data:image/svg+xml;charset=utf8,%3Csvg viewBox='0 0 32 32' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath stroke='rgba(255, 255, 255, 0.5)' stroke-width='2' stroke-linecap='round' stroke-miterlimit='10' d='M4 8h24M4 16h24M4 24h24'/%3E% 3C/svg%3E"); 3 | } 4 | @font-face { 5 | font-family: 'Pipeline'; 6 | src: url('../fonts/pipeline.eot'); 7 | src: url('../fonts/pipeline.eot?#iefix') format('embedded-opentype'); 8 | src: local('☺'), url('../fonts/pipeline.woff') format('woff'), url('../fonts/pipeline.ttf') format('truetype'), url('../fonts/pipeline.svg#IyfZbseF') format('svg'); 9 | font-weight: normal; 10 | font-style: normal; 11 | } 12 | .relative-url { 13 | background-image: url(../images/sprite-buttons.png); 14 | } 15 | .relative-url-querystring { 16 | background-image: url(../images/sprite-buttons.png?v=1.0#foo=bar); 17 | } 18 | .absolute-url { 19 | background-image: url(/images/sprite-buttons.png); 20 | } 21 | .absolute-full-url { 22 | background-image: url(http://localhost/images/sprite-buttons.png); 23 | } 24 | .no-protocol-url { 25 | background-image: url(//images/sprite-buttons.png); 26 | } 27 | .anchor-tag-url { 28 | background-image: url(#image-gradient); 29 | } 30 | @font-face{src:url(../fonts/pipeline.eot);src:url(../fonts/pipeline.eot?#iefix) format('embedded-opentype'),url(../fonts/pipeline.woff) format('woff'),url(../fonts/pipeline.ttf) format('truetype');} 31 | -------------------------------------------------------------------------------- /tests/tests/test_packager.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | from pipeline.collector import default_collector 4 | from pipeline.packager import PackageNotFound, Packager 5 | from tests.utils import _ 6 | 7 | 8 | class PackagerTest(TestCase): 9 | def setUp(self): 10 | default_collector.collect() 11 | 12 | def test_package_for(self): 13 | packager = Packager() 14 | packager.packages["js"] = packager.create_packages( 15 | { 16 | "application": { 17 | "source_filenames": (_("pipeline/js/application.js"),), 18 | "output_filename": "application.js", 19 | } 20 | } 21 | ) 22 | try: 23 | packager.package_for("js", "application") 24 | except PackageNotFound: 25 | self.fail() 26 | try: 27 | packager.package_for("js", "broken") 28 | self.fail() 29 | except PackageNotFound: 30 | pass 31 | 32 | def test_templates(self): 33 | packager = Packager() 34 | packages = packager.create_packages( 35 | { 36 | "templates": { 37 | "source_filenames": (_("pipeline/templates/photo/list.jst"),), 38 | "output_filename": "templates.js", 39 | } 40 | } 41 | ) 42 | self.assertEqual( 43 | packages["templates"].templates, 44 | [_("pipeline/templates/photo/list.jst")], 45 | ) 46 | 47 | def tearDown(self): 48 | default_collector.clear() 49 | -------------------------------------------------------------------------------- /tests/tests/test_conf.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from unittest import skipIf, skipUnless 3 | 4 | from django.test import TestCase 5 | 6 | from pipeline.conf import PipelineSettings 7 | 8 | 9 | class TestSettings(TestCase): 10 | def test_3unicode(self): 11 | s = PipelineSettings({"FOO_BINARY": "env actualprogram"}) 12 | self.assertEqual(s.FOO_BINARY, ("env", "actualprogram")) 13 | 14 | def test_2unicode(self): 15 | s = PipelineSettings({"FOO_BINARY": "env actualprogram"}) 16 | self.assertEqual(s.FOO_BINARY, ("env", "actualprogram")) 17 | 18 | def test_2bytes(self): 19 | s = PipelineSettings({"FOO_BINARY": "env actualprogram"}) 20 | self.assertEqual(s.FOO_BINARY, ("env", "actualprogram")) 21 | 22 | def test_expected_splitting(self): 23 | s = PipelineSettings({"FOO_BINARY": "env actualprogram"}) 24 | self.assertEqual(s.FOO_BINARY, ("env", "actualprogram")) 25 | 26 | @skipIf(sys.platform.startswith("win"), "requires posix platform") 27 | def test_expected_preservation(self): 28 | s = PipelineSettings({"FOO_BINARY": r"actual\ program"}) 29 | self.assertEqual(s.FOO_BINARY, ("actual program",)) 30 | 31 | @skipUnless(sys.platform.startswith("win"), "requires windows") 32 | def test_win_path_preservation(self): 33 | s = PipelineSettings({"FOO_BINARY": "C:\\Test\\ActualProgram.exe argument"}) 34 | self.assertEqual(s.FOO_BINARY, ("C:\\Test\\ActualProgram.exe", "argument")) 35 | 36 | def test_tuples_are_normal(self): 37 | s = PipelineSettings({"FOO_ARGUMENTS": ("explicit", "with", "args")}) 38 | self.assertEqual(s.FOO_ARGUMENTS, ("explicit", "with", "args")) 39 | -------------------------------------------------------------------------------- /docs/using.rst: -------------------------------------------------------------------------------- 1 | .. _ref-using: 2 | 3 | ==================== 4 | Sites using Pipeline 5 | ==================== 6 | 7 | The following sites are a partial list of people using Pipeline. 8 | 9 | Are you using pipeline and not being in this list? Drop us a line. 10 | 11 | 20 Minutes 12 | ---------- 13 | 14 | For their internal tools: http://www.20minutes.fr 15 | 16 | Borsala 17 | ------- 18 | 19 | Borsala is the social investment platform. You can follow stock markets that are traded in Turkey: http://borsala.com 20 | 21 | 22 | Croisé dans le Métro 23 | -------------------- 24 | 25 | For their main and mobile website: 26 | 27 | * http://www.croisedanslemetro.com 28 | * http://m.croisedanslemetro.com 29 | 30 | Teachoo 31 | ----------------- 32 | 33 | Teachoo uses pipeline for compressing all its static files - https://www.teachoo.com 34 | 35 | The Molly Project 36 | ----------------- 37 | 38 | Molly is a framework for the rapid development of information and service 39 | portals targeted at mobile internet devices: https://github.com/mollyproject/mollyproject 40 | 41 | It powers the University of Oxford's mobile portal: http://m.ox.ac.uk/ 42 | 43 | Mozilla 44 | ------- 45 | 46 | * `mozilla.org `_ (https://github.com/mozilla/bedrock) 47 | * `Mozilla Developer Network `_ (https://github.com/mozilla/kuma) 48 | 49 | Novapost 50 | -------- 51 | 52 | For PeopleDoc suite products: http://www.people-doc.com/ 53 | 54 | Sophicware 55 | ---------- 56 | 57 | Sophicware offers web hosting and DevOps as a service: http://sophicware.com 58 | 59 | Ulule 60 | ----- 61 | 62 | For their main and forum website: 63 | 64 | * http://www.ulule.com 65 | * http://vox.ulule.com 66 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | pypy3-dj{41,42} 4 | py{39,310,311}-dj41 5 | py{39,310,311,312}-dj42 6 | py{310,311,312}-dj50 7 | py{310,311,312}-dj51 8 | py{310,311,312,313}-dj52 9 | py{310,311,312,313}-djmain 10 | docs 11 | 12 | [gh-actions] 13 | python = 14 | 3.9: py39, docs 15 | 3.10: py310 16 | 3.11: py311 17 | 3.12: py312 18 | 3.13: py313 19 | pypy3: pypy3 20 | 21 | [gh-actions:env] 22 | DJANGO = 23 | 4.1: dj41 24 | 4.2: dj42 25 | 5.0: dj50 26 | 5.1: dj51 27 | 5.2: dj52 28 | main: djmain 29 | 30 | [testenv] 31 | basepython = 32 | pypy3: pypy3 33 | py39: python3.9 34 | py310: python3.10 35 | py311: python3.11 36 | py312: python3.12 37 | py313: python3.13 38 | deps = 39 | pypy3: mock 40 | dj32: Django>=3.2,<3.3 41 | dj41: Django>=4.1,<4.2 42 | dj42: Django>=4.2,<4.3 43 | dj50: Django>=5.0,<5.1 44 | dj51: Django>=5.1,<5.2 45 | dj52: Django>=5.2,<5.3 46 | djmain: https://github.com/django/django/archive/main.tar.gz 47 | jinja2 48 | coverage 49 | jsmin==3.0.0 50 | ply==3.4 51 | css-html-js-minify==2.5.5 52 | setenv = 53 | DJANGO_SETTINGS_MODULE = tests.settings 54 | PYTHONPATH = {toxinidir} 55 | commands = 56 | npm install 57 | {envbindir}/coverage run --source pipeline {envbindir}/django-admin test {posargs:tests} 58 | {envbindir}/coverage report 59 | {envbindir}/coverage xml 60 | whitelist_externals = npm 61 | ignore_outcome = 62 | djmain: True 63 | ignore_errors = 64 | djmain: True 65 | allowlist_externals=npm 66 | 67 | [testenv:docs] 68 | basepython = python3.9 69 | changedir = docs 70 | deps = sphinx 71 | commands = 72 | {envbindir}/sphinx-build -W -b html -d {envtmpdir}/doctrees . {envtmpdir}/html 73 | -------------------------------------------------------------------------------- /pipeline/views.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings as django_settings 2 | from django.core.exceptions import ImproperlyConfigured 3 | from django.views.static import serve 4 | 5 | from .collector import default_collector 6 | from .conf import settings 7 | 8 | 9 | def serve_static(request, path, insecure=False, **kwargs): 10 | """Collect and serve static files. 11 | 12 | This view serves up static files, much like Django's 13 | :py:func:`~django.views.static.serve` view, with the addition that it 14 | collects static files first (if enabled). This allows images, fonts, and 15 | other assets to be served up without first loading a page using the 16 | ``{% javascript %}`` or ``{% stylesheet %}`` template tags. 17 | 18 | You can use this view by adding the following to any :file:`urls.py`:: 19 | 20 | urlpatterns += static('static/', view='pipeline.views.serve_static') 21 | """ 22 | # Follow the same logic Django uses for determining access to the 23 | # static-serving view. 24 | if not django_settings.DEBUG and not insecure: 25 | raise ImproperlyConfigured( 26 | "The staticfiles view can only be used in " 27 | "debug mode or if the --insecure " 28 | "option of 'runserver' is used" 29 | ) 30 | 31 | if not settings.PIPELINE_ENABLED and settings.PIPELINE_COLLECTOR_ENABLED: 32 | # Collect only the requested file, in order to serve the result as 33 | # fast as possible. This won't interfere with the template tags in any 34 | # way, as those will still cause Django to collect all media. 35 | default_collector.collect(request, files=[path]) 36 | 37 | return serve(request, path, document_root=django_settings.STATIC_ROOT, **kwargs) 38 | -------------------------------------------------------------------------------- /tests/tests/test_middleware.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | 3 | from django.core.exceptions import MiddlewareNotUsed 4 | from django.http import HttpRequest, HttpResponse 5 | from django.test import TestCase 6 | 7 | from pipeline.middleware import MinifyHTMLMiddleware 8 | 9 | 10 | def dummy_get_response(request): 11 | return None 12 | 13 | 14 | class MiddlewareTest(TestCase): 15 | whitespace = b" " 16 | 17 | def setUp(self): 18 | self.req = HttpRequest() 19 | self.req.META = { 20 | "SERVER_NAME": "testserver", 21 | "SERVER_PORT": 80, 22 | } 23 | self.req.path = self.req.path_info = "/" 24 | self.resp = HttpResponse() 25 | self.resp.status_code = 200 26 | self.resp.content = self.whitespace 27 | 28 | def test_middleware_html(self): 29 | self.resp["Content-Type"] = "text/html; charset=UTF-8" 30 | 31 | response = MinifyHTMLMiddleware(dummy_get_response).process_response( 32 | self.req, self.resp 33 | ) 34 | self.assertIn("text/html", response["Content-Type"]) 35 | self.assertNotIn(self.whitespace, response.content) 36 | 37 | def test_middleware_text(self): 38 | self.resp["Content-Type"] = "text/plain; charset=UTF-8" 39 | 40 | response = MinifyHTMLMiddleware(dummy_get_response).process_response( 41 | self.req, self.resp 42 | ) 43 | self.assertIn("text/plain", response["Content-Type"]) 44 | self.assertIn(self.whitespace, response.content) 45 | 46 | @patch("pipeline.middleware.settings.PIPELINE_ENABLED", False) 47 | def test_middleware_not_used(self): 48 | self.assertRaises(MiddlewareNotUsed, MinifyHTMLMiddleware, dummy_get_response) 49 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | .. image:: https://jazzband.co/static/img/jazzband.svg 2 | :target: https://jazzband.co/ 3 | :alt: Jazzband 4 | 5 | This is a `Jazzband `_ project. By contributing you agree to abide by the `Contributor Code of Conduct `_ and follow the `guidelines `_. 6 | 7 | Contribute 8 | ========== 9 | 10 | #. Check for open issues or open a fresh issue to start a discussion around a 11 | feature idea or a bug. There is a **contribute!** tag for issues that should be 12 | ideal for people who are not very familiar with the codebase yet. 13 | #. Fork the repository on Github to start making your changes on a topic branch. 14 | #. Write a test which shows that the bug was fixed or that the feature works as expected. 15 | #. Send a pull request and bug the maintainer until it gets merged and published. 16 | Make sure to add yourself to *AUTHORS*. 17 | 18 | Otherwise, if you simply wants to suggest a feature or report a bug, create an issue : 19 | https://github.com/jazzband/django-pipeline/issues 20 | 21 | 22 | Running tests 23 | ============= 24 | 25 | We use tox to run the test suite on different versions locally (and GitHub Actions 26 | to automate the check for PRs). 27 | 28 | To tun the test suite locally, please make sure your python environment has 29 | tox and django installed:: 30 | 31 | python3.7 -m pip install tox 32 | 33 | Since we use a number of node.js tools, one should first install the node 34 | dependencies. We recommend using [nvm](https://github.com/nvm-sh/nvm#installation-and-update) , tl;dr:: 35 | 36 | curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.35.2/install.sh | bash 37 | nvm install node 38 | nvm use node 39 | 40 | And then simply execute tox to run the whole test matrix:: 41 | 42 | tox 43 | -------------------------------------------------------------------------------- /pipeline/glob.py: -------------------------------------------------------------------------------- 1 | import fnmatch 2 | import os 3 | import re 4 | 5 | from django.contrib.staticfiles.storage import staticfiles_storage 6 | 7 | __all__ = ["glob", "iglob"] 8 | 9 | 10 | def glob(pathname): 11 | """Return a list of paths matching a pathname pattern. 12 | 13 | The pattern may contain simple shell-style wildcards a la fnmatch. 14 | 15 | """ 16 | return sorted(list(iglob(pathname))) 17 | 18 | 19 | def iglob(pathname): 20 | """Return an iterator which yields the paths matching a pathname pattern. 21 | 22 | The pattern may contain simple shell-style wildcards a la fnmatch. 23 | 24 | """ 25 | if not has_magic(pathname): 26 | yield pathname 27 | return 28 | dirname, basename = os.path.split(pathname) 29 | if not dirname: 30 | for name in glob1(dirname, basename): 31 | yield name 32 | return 33 | if has_magic(dirname): 34 | dirs = iglob(dirname) 35 | else: 36 | dirs = [dirname] 37 | if has_magic(basename): 38 | glob_in_dir = glob1 39 | else: 40 | glob_in_dir = glob0 41 | for dirname in dirs: 42 | for name in glob_in_dir(dirname, basename): 43 | yield os.path.join(dirname, name) 44 | 45 | 46 | # These 2 helper functions non-recursively glob inside a literal directory. 47 | # They return a list of basenames. `glob1` accepts a pattern while `glob0` 48 | # takes a literal basename (so it only has to check for its existence). 49 | 50 | 51 | def glob1(dirname, pattern): 52 | try: 53 | directories, files = staticfiles_storage.listdir(dirname) 54 | names = directories + files 55 | except Exception: 56 | # We are not sure that dirname is a real directory 57 | # and storage implementations are really exotic. 58 | return [] 59 | if pattern[0] != ".": 60 | names = [x for x in names if x[0] != "."] 61 | return fnmatch.filter(names, pattern) 62 | 63 | 64 | def glob0(dirname, basename): 65 | if staticfiles_storage.exists(os.path.join(dirname, basename)): 66 | return [basename] 67 | return [] 68 | 69 | 70 | magic_check = re.compile("[*?[]") 71 | 72 | 73 | def has_magic(s): 74 | return magic_check.search(s) is not None 75 | -------------------------------------------------------------------------------- /pipeline/utils.py: -------------------------------------------------------------------------------- 1 | try: 2 | import fcntl 3 | except ImportError: 4 | # windows 5 | fcntl = None 6 | 7 | import importlib 8 | import mimetypes 9 | import os 10 | import posixpath 11 | import sys 12 | from urllib.parse import quote 13 | 14 | from django.utils.encoding import smart_str 15 | 16 | from pipeline.conf import settings 17 | 18 | 19 | def to_class(class_str): 20 | if not class_str: 21 | return None 22 | 23 | module_bits = class_str.split(".") 24 | module_path, class_name = ".".join(module_bits[:-1]), module_bits[-1] 25 | module = importlib.import_module(module_path) 26 | return getattr(module, class_name, None) 27 | 28 | 29 | def filepath_to_uri(path): 30 | if path is None: 31 | return path 32 | return quote(smart_str(path).replace("\\", "/"), safe="/~!*()'#?") 33 | 34 | 35 | def guess_type(path, default=None): 36 | for type, ext in settings.MIMETYPES: 37 | mimetypes.add_type(type, ext) 38 | mimetype, _ = mimetypes.guess_type(path) 39 | if not mimetype: 40 | return default 41 | return smart_str(mimetype) 42 | 43 | 44 | def relpath(path, start=posixpath.curdir): 45 | """Return a relative version of a path""" 46 | if not path: 47 | raise ValueError("no path specified") 48 | 49 | start_list = posixpath.abspath(start).split(posixpath.sep) 50 | path_list = posixpath.abspath(path).split(posixpath.sep) 51 | 52 | # Work out how much of the filepath is shared by start and path. 53 | i = len(posixpath.commonprefix([start_list, path_list])) 54 | 55 | rel_list = [posixpath.pardir] * (len(start_list) - i) + path_list[i:] 56 | if not rel_list: 57 | return posixpath.curdir 58 | return posixpath.join(*rel_list) 59 | 60 | 61 | def set_std_streams_blocking(): 62 | """ 63 | Set stdout and stderr to be blocking. 64 | 65 | This is called after Popen.communicate() to revert stdout and stderr back 66 | to be blocking (the default) in the event that the process to which they 67 | were passed manipulated one or both file descriptors to be non-blocking. 68 | """ 69 | if not fcntl: 70 | return 71 | for f in (sys.__stdout__, sys.__stderr__): 72 | fileno = f.fileno() 73 | flags = fcntl.fcntl(fileno, fcntl.F_GETFL) 74 | fcntl.fcntl(fileno, fcntl.F_SETFL, flags & ~os.O_NONBLOCK) 75 | -------------------------------------------------------------------------------- /tests/assets/css/nested/nested.css: -------------------------------------------------------------------------------- 1 | .data-url { 2 | background-image: url(data:image/svg+xml;charset=US-ASCII,%3C%3Fxml%20version%3D%221.0%22%20encoding%3D%22iso-8859-1%22%3F%3E%3C!DOCTYPE%20svg%20PUBLIC%20%22-%2F%2FW3C%2F%2FDTD%20SVG%201.1%2F%2FEN%22%20%22http%3A%2F%2Fwww.w3.org%2FGraphics%2FSVG%2F1.1%2FDTD%2Fsvg11.dtd%22%3E%3Csvg%20version%3D%221.1%22%20id%3D%22Layer_1%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20xmlns%3Axlink%3D%22http%3A%2F%2Fwww.w3.org%2F1999%2Fxlink%22%20x%3D%220px%22%20y%3D%220px%22%20%20width%3D%2212px%22%20height%3D%2214px%22%20viewBox%3D%220%200%2012%2014%22%20style%3D%22enable-background%3Anew%200%200%2012%2014%3B%22%20xml%3Aspace%3D%22preserve%22%3E%3Cpath%20d%3D%22M11%2C6V5c0-2.762-2.239-5-5-5S1%2C2.238%2C1%2C5v1H0v8h12V6H11z%20M6.5%2C9.847V12h-1V9.847C5.207%2C9.673%2C5%2C9.366%2C5%2C9%20c0-0.553%2C0.448-1%2C1-1s1%2C0.447%2C1%2C1C7%2C9.366%2C6.793%2C9.673%2C6.5%2C9.847z%20M9%2C6H3V5c0-1.657%2C1.343-3%2C3-3s3%2C1.343%2C3%2C3V6z%22%2F%3E%3Cg%3E%3C%2Fg%3E%3Cg%3E%3C%2Fg%3E%3Cg%3E%3C%2Fg%3E%3Cg%3E%3C%2Fg%3E%3Cg%3E%3C%2Fg%3E%3Cg%3E%3C%2Fg%3E%3Cg%3E%3C%2Fg%3E%3Cg%3E%3C%2Fg%3E%3Cg%3E%3C%2Fg%3E%3Cg%3E%3C%2Fg%3E%3Cg%3E%3C%2Fg%3E%3Cg%3E%3C%2Fg%3E%3Cg%3E%3C%2Fg%3E%3Cg%3E%3C%2Fg%3E%3Cg%3E%3C%2Fg%3E%3C%2Fsvg%3E); 3 | } 4 | .data-url-quoted { 5 | background-image: url('data:image/svg+xml;charset=US-ASCII,%3C%3Fxml%20version%3D%221.0%22%20encoding%3D%22iso-8859-1%22%3F%3E%3C!DOCTYPE%20svg%20PUBLIC%20%22-%2F%2FW3C%2F%2FDTD%20SVG%201.1%2F%2FEN%22%20%22http%3A%2F%2Fwww.w3.org%2FGraphics%2FSVG%2F1.1%2FDTD%2Fsvg11.dtd%22%3E%3Csvg%20version%3D%221.1%22%20id%3D%22Layer_1%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20xmlns%3Axlink%3D%22http%3A%2F%2Fwww.w3.org%2F1999%2Fxlink%22%20x%3D%220px%22%20y%3D%220px%22%20%20width%3D%2212px%22%20height%3D%2214px%22%20viewBox%3D%220%200%2012%2014%22%20style%3D%22enable-background%3Anew%200%200%2012%2014%3B%22%20xml%3Aspace%3D%22preserve%22%3E%3Cpath%20d%3D%22M11%2C6V5c0-2.762-2.239-5-5-5S1%2C2.238%2C1%2C5v1H0v8h12V6H11z%20M6.5%2C9.847V12h-1V9.847C5.207%2C9.673%2C5%2C9.366%2C5%2C9%20c0-0.553%2C0.448-1%2C1-1s1%2C0.447%2C1%2C1C7%2C9.366%2C6.793%2C9.673%2C6.5%2C9.847z%20M9%2C6H3V5c0-1.657%2C1.343-3%2C3-3s3%2C1.343%2C3%2C3V6z%22%2F%3E%3Cg%3E%3C%2Fg%3E%3Cg%3E%3C%2Fg%3E%3Cg%3E%3C%2Fg%3E%3Cg%3E%3C%2Fg%3E%3Cg%3E%3C%2Fg%3E%3Cg%3E%3C%2Fg%3E%3Cg%3E%3C%2Fg%3E%3Cg%3E%3C%2Fg%3E%3Cg%3E%3C%2Fg%3E%3Cg%3E%3C%2Fg%3E%3Cg%3E%3C%2Fg%3E%3Cg%3E%3C%2Fg%3E%3Cg%3E%3C%2Fg%3E%3Cg%3E%3C%2Fg%3E%3Cg%3E%3C%2Fg%3E%3C%2Fsvg%3E'); 6 | } 7 | -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | .. _ref-installation: 2 | 3 | ============ 4 | Installation 5 | ============ 6 | 7 | 1. Either check out Pipeline from GitHub_ or to pull a release off PyPI_ :: 8 | 9 | pip install django-pipeline 10 | 11 | 12 | 2. Add 'pipeline' to your ``INSTALLED_APPS`` :: 13 | 14 | INSTALLED_APPS = ( 15 | 'pipeline', 16 | ) 17 | 18 | 3. Use a pipeline storage for ``STATICFILES_STORAGE`` :: 19 | 20 | STATICFILES_STORAGE = 'pipeline.storage.PipelineManifestStorage' 21 | 22 | 4. Add the ``PipelineFinder`` to ``STATICFILES_FINDERS`` :: 23 | 24 | STATICFILES_FINDERS = ( 25 | 'django.contrib.staticfiles.finders.FileSystemFinder', 26 | 'django.contrib.staticfiles.finders.AppDirectoriesFinder', 27 | 'pipeline.finders.PipelineFinder', 28 | ) 29 | 30 | 31 | .. note:: 32 | You need to use ``Django>=1.11`` to be able to use this version of pipeline. 33 | 34 | .. _GitHub: http://github.com/jazzband/django-pipeline 35 | .. _PyPI: http://pypi.python.org/pypi/django-pipeline 36 | 37 | Upgrading from 1.3 38 | ================== 39 | 40 | To upgrade from pipeline 1.3, you will need to follow these steps: 41 | 42 | 1. Update templates to use the new syntax 43 | 44 | .. code-block:: python 45 | 46 | {# pipeline<1.4 #} 47 | {% load compressed %} 48 | {% compressed_js 'group' %} 49 | {% compressed_css 'group' %} 50 | 51 | .. code-block:: python 52 | 53 | {# pipeline>=1.4 #} 54 | {% load pipeline %} 55 | {% javascript 'group' %} 56 | {% stylesheet 'group' %} 57 | 58 | 2. Add the ``PipelineFinder`` to ``STATICFILES_FINDERS`` :: 59 | 60 | STATICFILES_FINDERS = ( 61 | 'django.contrib.staticfiles.finders.FileSystemFinder', 62 | 'django.contrib.staticfiles.finders.AppDirectoriesFinder', 63 | 'pipeline.finders.PipelineFinder', 64 | ) 65 | 66 | 67 | Upgrading from 1.5 68 | ================== 69 | 70 | To upgrade from pipeline 1.5, you will need update all your ``PIPELINE_*`` 71 | settings and move them under the new ``PIPELINE`` setting. 72 | See :ref:`ref-configuration`. 73 | 74 | Recommendations 75 | =============== 76 | 77 | Pipeline's default CSS and JS compressor is Yuglify. 78 | Yuglify wraps UglifyJS and cssmin, applying the default YUI configurations to them. 79 | It can be downloaded from: https://github.com/yui/yuglify/. 80 | 81 | If you do not install yuglify, make sure to disable the compressor in your settings. 82 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | As contributors and maintainers of the Jazzband projects, and in the interest of 4 | fostering an open and welcoming community, we pledge to respect all people who 5 | contribute through reporting issues, posting feature requests, updating documentation, 6 | submitting pull requests or patches, and other activities. 7 | 8 | We are committed to making participation in the Jazzband a harassment-free experience 9 | for everyone, regardless of the level of experience, gender, gender identity and 10 | expression, sexual orientation, disability, personal appearance, body size, race, 11 | ethnicity, age, religion, or nationality. 12 | 13 | Examples of unacceptable behavior by participants include: 14 | 15 | - The use of sexualized language or imagery 16 | - Personal attacks 17 | - Trolling or insulting/derogatory comments 18 | - Public or private harassment 19 | - Publishing other's private information, such as physical or electronic addresses, 20 | without explicit permission 21 | - Other unethical or unprofessional conduct 22 | 23 | The Jazzband roadies have the right and responsibility to remove, edit, or reject 24 | comments, commits, code, wiki edits, issues, and other contributions that are not 25 | aligned to this Code of Conduct, or to ban temporarily or permanently any contributor 26 | for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 27 | 28 | By adopting this Code of Conduct, the roadies commit themselves to fairly and 29 | consistently applying these principles to every aspect of managing the jazzband 30 | projects. Roadies who do not follow or enforce the Code of Conduct may be permanently 31 | removed from the Jazzband roadies. 32 | 33 | This code of conduct applies both within project spaces and in public spaces when an 34 | individual is representing the project or its community. 35 | 36 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by 37 | contacting the roadies at `roadies@jazzband.co`. All complaints will be reviewed and 38 | investigated and will result in a response that is deemed necessary and appropriate to 39 | the circumstances. Roadies are obligated to maintain confidentiality with regard to the 40 | reporter of an incident. 41 | 42 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 43 | 1.3.0, available at [https://contributor-covenant.org/version/1/3/0/][version] 44 | 45 | [homepage]: https://contributor-covenant.org 46 | [version]: https://contributor-covenant.org/version/1/3/0/ 47 | -------------------------------------------------------------------------------- /tests/tests/test_collector.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.contrib.staticfiles import finders 4 | from django.core.files.storage import FileSystemStorage 5 | from django.test import TestCase 6 | 7 | from pipeline.collector import default_collector 8 | from pipeline.finders import PipelineFinder 9 | 10 | 11 | def local_path(path): 12 | return os.path.abspath(os.path.join(os.path.dirname(__file__), "..", path)) 13 | 14 | 15 | class CollectorTest(TestCase): 16 | def tearDown(self): 17 | super().tearDown() 18 | 19 | default_collector.clear() 20 | 21 | def test_collect(self): 22 | self.assertEqual( 23 | set(default_collector.collect()), set(self._get_collectable_files()) 24 | ) 25 | 26 | def test_collect_with_files(self): 27 | self.assertEqual( 28 | set( 29 | default_collector.collect( 30 | files=[ 31 | "pipeline/js/first.js", 32 | "pipeline/js/second.js", 33 | ] 34 | ) 35 | ), 36 | { 37 | "pipeline/js/first.js", 38 | "pipeline/js/second.js", 39 | }, 40 | ) 41 | 42 | def test_delete_file_with_modified(self): 43 | list(default_collector.collect()) 44 | 45 | storage = FileSystemStorage(local_path("assets")) 46 | new_mtime = os.path.getmtime(storage.path("js/first.js")) - 1000 47 | os.utime( 48 | default_collector.storage.path("pipeline/js/first.js"), 49 | (new_mtime, new_mtime), 50 | ) 51 | 52 | self.assertTrue( 53 | default_collector.delete_file( 54 | "js/first.js", "pipeline/js/first.js", storage 55 | ) 56 | ) 57 | 58 | def test_delete_file_with_unmodified(self): 59 | list(default_collector.collect(files=["pipeline/js/first.js"])) 60 | 61 | self.assertFalse( 62 | default_collector.delete_file( 63 | "js/first.js", 64 | "pipeline/js/first.js", 65 | FileSystemStorage(local_path("assets")), 66 | ) 67 | ) 68 | 69 | def _get_collectable_files(self): 70 | for finder in finders.get_finders(): 71 | if not isinstance(finder, PipelineFinder): 72 | for path, storage in finder.list(["CVS", ".*", "*~"]): 73 | if getattr(storage, "prefix", None): 74 | yield os.path.join(storage.prefix, path) 75 | else: 76 | yield path 77 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=64", "setuptools_scm[toml]>=8"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "django-pipeline" 7 | requires-python = ">=3.9" 8 | version = "4.1.0" 9 | description = "Pipeline is an asset packaging library for Django." 10 | readme = "README.rst" 11 | authors = [{ "name" = "Timothée Peignier", "email" = "timothee.peignier@tryphon.org" }] 12 | license = { text = "MIT" } 13 | classifiers = [ 14 | "Development Status :: 5 - Production/Stable", 15 | "Environment :: Web Environment", 16 | "Framework :: Django", 17 | "Framework :: Django :: 4.0", 18 | "Framework :: Django :: 4.1", 19 | "Framework :: Django :: 4.2", 20 | "Framework :: Django :: 5.0", 21 | "Framework :: Django :: 5.1", 22 | "Framework :: Django :: 5.2", 23 | "Intended Audience :: Developers", 24 | "License :: OSI Approved :: MIT License", 25 | "Operating System :: OS Independent", 26 | "Programming Language :: Python", 27 | "Programming Language :: Python :: 3", 28 | "Programming Language :: Python :: 3 :: Only", 29 | "Programming Language :: Python :: 3.9", 30 | "Programming Language :: Python :: 3.10", 31 | "Programming Language :: Python :: 3.11", 32 | "Programming Language :: Python :: 3.12", 33 | "Programming Language :: Python :: 3.13", 34 | "Programming Language :: Python :: Implementation :: PyPy", 35 | "Topic :: Utilities", 36 | "Topic :: Software Development :: Libraries :: Python Modules", 37 | "Topic :: Internet :: WWW/HTTP", 38 | "Topic :: Internet :: WWW/HTTP :: Dynamic Content", 39 | ] 40 | keywords = [ 41 | "django", 42 | "pipeline", 43 | "asset", 44 | "compiling", 45 | "concatenation", 46 | "compression", 47 | "packaging", 48 | ] 49 | dependencies = [ 50 | # indirect dependencies 51 | "setuptools", 52 | "wheel", 53 | ] 54 | 55 | [project.optional-dependencies] 56 | testing = [ 57 | "coveralls", 58 | "tox", 59 | "wheel", 60 | "django", 61 | ] 62 | 63 | [project.urls] 64 | homepage = "https://github.com/jazzband/django-pipeline/" 65 | documentation = "https://django-pipeline.readthedocs.io/" 66 | repository = "https://github.com/jazzband/django-pipeline" 67 | changelog = "https://github.com/jazzband/django-pipeline/blob/master/HISTORY.rst" 68 | 69 | [tool.setuptools] 70 | include-package-data = true 71 | 72 | [tool.setuptools.packages.find] 73 | exclude = ["tests", "tests.tests"] 74 | 75 | [tool.setuptools_scm] 76 | local_scheme = "dirty-tag" 77 | 78 | [tool.black] 79 | line-length = 88 80 | target-version = ["py39"] 81 | 82 | [tool.isort] 83 | profile = "black" 84 | -------------------------------------------------------------------------------- /tests/tests/test_views.py: -------------------------------------------------------------------------------- 1 | from django.contrib.staticfiles.storage import staticfiles_storage 2 | from django.core.exceptions import ImproperlyConfigured 3 | from django.http import Http404 4 | from django.test import RequestFactory, TestCase 5 | from django.test.utils import override_settings 6 | 7 | from pipeline.collector import default_collector 8 | from pipeline.views import serve_static 9 | from tests.utils import pipeline_settings 10 | 11 | 12 | @override_settings(DEBUG=True) 13 | @pipeline_settings(PIPELINE_COLLECTOR_ENABLED=True, PIPELINE_ENABLED=False) 14 | class ServeStaticViewsTest(TestCase): 15 | def setUp(self): 16 | super().setUp() 17 | 18 | self.filename = "pipeline/js/first.js" 19 | self.storage = staticfiles_storage 20 | self.request = RequestFactory().get(f"/static/{self.filename}") 21 | 22 | default_collector.clear() 23 | 24 | def tearDown(self): 25 | super().tearDown() 26 | 27 | default_collector.clear() 28 | staticfiles_storage._setup() 29 | 30 | def test_found(self): 31 | self._test_found() 32 | 33 | def test_not_found(self): 34 | self._test_not_found("missing-file") 35 | 36 | @override_settings(DEBUG=False) 37 | def test_debug_false(self): 38 | with self.assertRaises(ImproperlyConfigured): 39 | serve_static(self.request, self.filename) 40 | 41 | self.assertFalse(self.storage.exists(self.filename)) 42 | 43 | @override_settings(DEBUG=False) 44 | def test_debug_false_and_insecure(self): 45 | self._test_found(insecure=True) 46 | 47 | @pipeline_settings(PIPELINE_ENABLED=True) 48 | def test_pipeline_enabled_and_found(self): 49 | self._write_content() 50 | self._test_found() 51 | 52 | @pipeline_settings(PIPELINE_ENABLED=True) 53 | def test_pipeline_enabled_and_not_found(self): 54 | self._test_not_found(self.filename) 55 | 56 | @pipeline_settings(PIPELINE_COLLECTOR_ENABLED=False) 57 | def test_collector_disabled_and_found(self): 58 | self._write_content() 59 | self._test_found() 60 | 61 | @pipeline_settings(PIPELINE_COLLECTOR_ENABLED=False) 62 | def test_collector_disabled_and_not_found(self): 63 | self._test_not_found(self.filename) 64 | 65 | def _write_content(self, content="abc123"): 66 | """Write sample content to the test static file.""" 67 | with self.storage.open(self.filename, "w") as f: 68 | f.write(content) 69 | 70 | def _test_found(self, **kwargs): 71 | """Test that a file can be found and contains the correct content.""" 72 | response = serve_static(self.request, self.filename, **kwargs) 73 | self.assertEqual(response.status_code, 200) 74 | self.assertTrue(self.storage.exists(self.filename)) 75 | 76 | if hasattr(response, "streaming_content"): 77 | content = b"".join(response.streaming_content) 78 | else: 79 | content = response.content 80 | 81 | with self.storage.open(self.filename) as f: 82 | self.assertEqual(f.read(), content) 83 | 84 | def _test_not_found(self, filename): 85 | """Test that a file could not be found.""" 86 | self.assertFalse(self.storage.exists(filename)) 87 | 88 | with self.assertRaises(Http404): 89 | serve_static(self.request, filename) 90 | 91 | self.assertFalse(self.storage.exists(filename)) 92 | -------------------------------------------------------------------------------- /pipeline/jinja2/__init__.py: -------------------------------------------------------------------------------- 1 | from django.contrib.staticfiles.storage import staticfiles_storage 2 | from jinja2 import TemplateSyntaxError, nodes 3 | from jinja2.ext import Extension 4 | 5 | from ..packager import PackageNotFound 6 | from ..templatetags.pipeline import PipelineMixin 7 | from ..utils import guess_type 8 | 9 | 10 | class PipelineExtension(PipelineMixin, Extension): 11 | tags = {"stylesheet", "javascript"} 12 | 13 | def parse(self, parser): 14 | tag = next(parser.stream) 15 | 16 | package_name = parser.parse_expression() 17 | if not package_name: 18 | raise TemplateSyntaxError("Bad package name", tag.lineno) 19 | 20 | args = [package_name] 21 | if tag.value == "stylesheet": 22 | return nodes.CallBlock( 23 | self.call_method("package_css", args), [], [], [] 24 | ).set_lineno(tag.lineno) 25 | 26 | if tag.value == "javascript": 27 | return nodes.CallBlock( 28 | self.call_method("package_js", args), [], [], [] 29 | ).set_lineno(tag.lineno) 30 | 31 | return [] 32 | 33 | def package_css(self, package_name, *args, **kwargs): 34 | try: 35 | package = self.package_for(package_name, "css") 36 | except PackageNotFound: 37 | # fail silently, do not return anything if an invalid group is specified 38 | return "" 39 | return self.render_compressed(package, package_name, "css") 40 | 41 | def render_css(self, package, path): 42 | template_name = package.template_name or "pipeline/css.jinja" 43 | context = package.extra_context 44 | context.update( 45 | {"type": guess_type(path, "text/css"), "url": staticfiles_storage.url(path)} 46 | ) 47 | template = self.environment.get_template(template_name) 48 | return template.render(context) 49 | 50 | def render_individual_css(self, package, paths, **kwargs): 51 | tags = [self.render_css(package, path) for path in paths] 52 | return "\n".join(tags) 53 | 54 | def package_js(self, package_name, *args, **kwargs): 55 | try: 56 | package = self.package_for(package_name, "js") 57 | except PackageNotFound: 58 | # fail silently, do not return anything if an invalid group is specified 59 | return "" 60 | return self.render_compressed(package, package_name, "js") 61 | 62 | def render_js(self, package, path): 63 | template_name = package.template_name or "pipeline/js.jinja" 64 | context = package.extra_context 65 | context.update( 66 | { 67 | "type": guess_type(path, "text/javascript"), 68 | "url": staticfiles_storage.url(path), 69 | } 70 | ) 71 | template = self.environment.get_template(template_name) 72 | return template.render(context) 73 | 74 | def render_inline(self, package, js): 75 | context = package.extra_context 76 | context.update({"source": js}) 77 | template = self.environment.get_template("pipeline/inline_js.jinja") 78 | return template.render(context) 79 | 80 | def render_individual_js(self, package, paths, templates=None): 81 | tags = [self.render_js(package, js) for js in paths] 82 | if templates: 83 | tags.append(self.render_inline(package, templates)) 84 | return "\n".join(tags) 85 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | name: build (Python ${{ matrix.python-version }}, Django ${{ matrix.django-version }}) 8 | runs-on: ubuntu-latest 9 | strategy: 10 | fail-fast: false 11 | matrix: 12 | python-version: ['3.9', '3.10', '3.11', '3.12', '3.13', 'pypy-3.10'] 13 | django-version: ['4.1', '4.2', '5.0', '5.1', '5.2', 'main'] 14 | exclude: 15 | - python-version: '3.9' 16 | django-version: '5.0' 17 | - python-version: '3.9' 18 | django-version: '5.1' 19 | - python-version: '3.9' 20 | django-version: '5.2' 21 | - python-version: '3.9' 22 | django-version: 'main' 23 | - python-version: 'pypy-3.10' 24 | django-version: '4.1' 25 | - python-version: 'pypy-3.10' 26 | django-version: '4.2' 27 | - python-version: 'pypy-3.10' 28 | django-version: '5.0' 29 | - python-version: 'pypy-3.10' 30 | django-version: '5.1' 31 | - python-version: 'pypy-3.10' 32 | django-version: '5.2' 33 | - python-version: 'pypy-3.10' 34 | django-version: 'main' 35 | - python-version: '3.12' 36 | django-version: '4.1' 37 | - python-version: '3.13' 38 | django-version: '4.1' 39 | - python-version: '3.13' 40 | django-version: '4.2' 41 | - python-version: '3.13' 42 | django-version: '5.0' 43 | - python-version: '3.13' 44 | django-version: '5.1' 45 | 46 | steps: 47 | - uses: actions/checkout@v4 48 | - uses: actions/setup-java@v4 49 | with: 50 | distribution: 'temurin' 51 | java-version: '21' 52 | 53 | - name: Set up Python ${{ matrix.python-version }} 54 | uses: actions/setup-python@v5 55 | with: 56 | python-version: ${{ matrix.python-version }} 57 | 58 | - name: Set up Node 59 | uses: actions/setup-node@v4 60 | with: 61 | node-version: '16' 62 | 63 | - name: Install Node dependencies 64 | run: npm install 65 | 66 | - name: Get pip cache dir 67 | id: pip-cache 68 | run: echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT 69 | 70 | - name: Cache 71 | uses: actions/cache@v4 72 | with: 73 | path: ${{ steps.pip-cache.outputs.dir }} 74 | key: 75 | ${{ matrix.python-version }}-v1-${{ hashFiles('**/pyproject.toml') }}-${{ hashFiles('**/tox.ini') }} 76 | restore-keys: | 77 | ${{ matrix.python-version }}-v1- 78 | 79 | - name: Install Python dependencies 80 | run: | 81 | python -m pip install --upgrade pip 82 | python -m pip install --upgrade tox tox-gh-actions 83 | 84 | - name: Tox tests 85 | run: | 86 | tox -v 87 | env: 88 | DJANGO: ${{ matrix.django-version }} 89 | 90 | - name: Upload coverage 91 | uses: codecov/codecov-action@v5 92 | with: 93 | name: Python ${{ matrix.python-version }} 94 | 95 | ruff: 96 | runs-on: ubuntu-latest 97 | steps: 98 | - uses: actions/checkout@v4 99 | - run: pip install --user ruff==0.12.5 100 | - run: ruff check . --extend-select=C4,C9,I,PLC,PLE,PLR,U --ignore=C414,I001,PLR0913,UP007,UP032 --target-version=py39 101 | -------------------------------------------------------------------------------- /docs/templates.rst: -------------------------------------------------------------------------------- 1 | .. _ref-templates: 2 | 3 | ==================== 4 | Javascript Templates 5 | ==================== 6 | 7 | Pipeline allows you to use javascript templates along with your javascript views. 8 | To use your javascript templates, just add them to your ``JAVASCRIPT`` group :: 9 | 10 | PIPELINE['JAVASCRIPT'] = { 11 | 'application': { 12 | 'source_filenames': ( 13 | 'js/application.js', 14 | 'js/templates/**/*.jst', 15 | ), 16 | 'output_filename': 'js/application.js' 17 | } 18 | } 19 | 20 | For example, if you have the following template ``js/templates/photo/detail.jst`` :: 21 | 22 |
23 | 24 |
25 | <%= caption %> 26 |
27 |
28 | 29 | It will be available from your javascript code via window.JST :: 30 | 31 | JST.photo_detail({ src:"images/baby-panda.jpg", caption:"A baby panda is born" }); 32 | 33 | 34 | Configuration 35 | ------------- 36 | 37 | Template function 38 | ................. 39 | 40 | By default, Pipeline uses a variant of `Micro Templating `_ to compile the templates, but you can choose your preferred JavaScript templating engine by changing ``PIPELINE['TEMPLATE_FUNC']`` :: 41 | 42 | PIPELINE['TEMPLATE_FUNC'] = 'template' 43 | 44 | Template namespace 45 | .................. 46 | 47 | Your templates are made available in a top-level object, by default ``window.JST``, 48 | but you can choose your own via ``PIPELINE['TEMPLATE_NAMESPACE']`` :: 49 | 50 | PIPELINE['TEMPLATE_NAMESPACE'] = 'window.Template' 51 | 52 | 53 | Template extension 54 | .................. 55 | 56 | Templates are detected by their extension, by default ``.jst``, but you can use 57 | your own extension via ``PIPELINE['TEMPLATE_EXT']`` :: 58 | 59 | PIPELINE['TEMPLATE_EXT'] = '.mustache' 60 | 61 | Template separator 62 | .................. 63 | 64 | Templates identifier are built using a replacement for directory separator, 65 | by default ``_``, but you specify your own separator via ``PIPELINE['TEMPLATE_SEPARATOR']`` :: 66 | 67 | PIPELINE['TEMPLATE_SEPARATOR'] = '/' 68 | 69 | 70 | Using it with your favorite template library 71 | -------------------------------------------- 72 | 73 | Mustache 74 | ........ 75 | 76 | To use it with `Mustache `_ you will need 77 | some extra javascript :: 78 | 79 | Mustache.template = function(templateString) { 80 | return function() { 81 | if (arguments.length < 1) { 82 | return templateString; 83 | } else { 84 | return Mustache.to_html(templateString, arguments[0], arguments[1]); 85 | } 86 | }; 87 | }; 88 | 89 | And use these settings :: 90 | 91 | PIPELINE['TEMPLATE_EXT'] = '.mustache' 92 | PIPELINE['TEMPLATE_FUNC'] = 'Mustache.template' 93 | 94 | Handlebars 95 | .......... 96 | 97 | To use it with `Handlebars `_, use the following settings :: 98 | 99 | PIPELINE['TEMPLATE_EXT'] = '.handlebars' 100 | PIPELINE['TEMPLATE_FUNC'] = 'Handlebars.compile' 101 | PIPELINE['TEMPLATE_NAMESPACE'] = 'Handlebars.templates' 102 | 103 | Ember.js + Handlebars 104 | ..................... 105 | 106 | To use it with `Ember.js `_, use the following settings :: 107 | 108 | PIPELINE['TEMPLATE_EXT'] = '.handlebars' 109 | PIPELINE['TEMPLATE_FUNC'] = 'Ember.Handlebars.compile' 110 | PIPELINE['TEMPLATE_NAMESPACE'] = 'window.Ember.TEMPLATES' 111 | PIPELINE['TEMPLATE_SEPARATOR'] = '/' 112 | 113 | Prototype 114 | ......... 115 | 116 | To use it with `Prototype `_, just setup your 117 | ``PIPELINE['TEMPLATE_FUNC']`` :: 118 | 119 | PIPELINE['TEMPLATE_FUNC'] = 'new Template' 120 | 121 | -------------------------------------------------------------------------------- /pipeline/collector.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import django 4 | from django.contrib.staticfiles import finders 5 | from django.contrib.staticfiles.storage import staticfiles_storage 6 | 7 | from pipeline.finders import PipelineFinder 8 | 9 | 10 | class Collector: 11 | request = None 12 | 13 | def __init__(self, storage=None): 14 | if storage is None: 15 | storage = staticfiles_storage 16 | self.storage = storage 17 | 18 | def _get_modified_time(self, storage, prefixed_path): 19 | if django.VERSION[:2] >= (1, 10): 20 | return storage.get_modified_time(prefixed_path) 21 | return storage.modified_time(prefixed_path) 22 | 23 | def clear(self, path=""): 24 | dirs, files = self.storage.listdir(path) 25 | for f in files: 26 | fpath = os.path.join(path, f) 27 | self.storage.delete(fpath) 28 | for d in dirs: 29 | self.clear(os.path.join(path, d)) 30 | 31 | def collect(self, request=None, files=[]): 32 | if self.request and self.request is request: 33 | return 34 | self.request = request 35 | found_files = {} 36 | for finder in finders.get_finders(): 37 | # Ignore our finder to avoid looping 38 | if isinstance(finder, PipelineFinder): 39 | continue 40 | for path, storage in finder.list(["CVS", ".*", "*~"]): 41 | # Prefix the relative path if the source storage contains it 42 | if getattr(storage, "prefix", None): 43 | prefixed_path = os.path.join(storage.prefix, path) 44 | else: 45 | prefixed_path = path 46 | 47 | if prefixed_path not in found_files and ( 48 | not files or prefixed_path in files 49 | ): 50 | found_files[prefixed_path] = (storage, path) 51 | self.copy_file(path, prefixed_path, storage) 52 | 53 | if files and len(files) == len(found_files): 54 | break 55 | 56 | return found_files.keys() 57 | 58 | def copy_file(self, path, prefixed_path, source_storage): 59 | # Delete the target file if needed or break 60 | if not self.delete_file(path, prefixed_path, source_storage): 61 | return 62 | # Finally start copying 63 | with source_storage.open(path) as source_file: 64 | self.storage.save(prefixed_path, source_file) 65 | 66 | def delete_file(self, path, prefixed_path, source_storage): 67 | if self.storage.exists(prefixed_path): 68 | try: 69 | # When was the target file modified last time? 70 | target_last_modified = self._get_modified_time( 71 | self.storage, 72 | prefixed_path, 73 | ) 74 | except (OSError, NotImplementedError, AttributeError): 75 | # The storage doesn't support ``modified_time`` or failed 76 | pass 77 | else: 78 | try: 79 | # When was the source file modified last time? 80 | source_last_modified = self._get_modified_time(source_storage, path) 81 | except (OSError, NotImplementedError, AttributeError): 82 | pass 83 | else: 84 | # Skip the file if the source file is younger 85 | # Avoid sub-second precision 86 | if target_last_modified.replace( 87 | microsecond=0 88 | ) >= source_last_modified.replace(microsecond=0): 89 | return False 90 | # Then delete the existing file if really needed 91 | self.storage.delete(prefixed_path) 92 | return True 93 | 94 | 95 | default_collector = Collector() 96 | -------------------------------------------------------------------------------- /pipeline/conf.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shlex 3 | from collections.abc import MutableMapping 4 | 5 | from django.conf import settings as _settings 6 | from django.core.signals import setting_changed 7 | from django.dispatch import receiver 8 | 9 | DEFAULTS = { 10 | "PIPELINE_ENABLED": not _settings.DEBUG, 11 | "PIPELINE_COLLECTOR_ENABLED": True, 12 | "PIPELINE_ROOT": _settings.STATIC_ROOT, 13 | "PIPELINE_URL": _settings.STATIC_URL, 14 | "SHOW_ERRORS_INLINE": _settings.DEBUG, 15 | "CSS_COMPRESSOR": "pipeline.compressors.yuglify.YuglifyCompressor", 16 | "JS_COMPRESSOR": "pipeline.compressors.yuglify.YuglifyCompressor", 17 | "COMPILERS": [], 18 | "STYLESHEETS": {}, 19 | "JAVASCRIPT": {}, 20 | "TEMPLATE_NAMESPACE": "window.JST", 21 | "TEMPLATE_EXT": ".jst", 22 | "TEMPLATE_FUNC": "template", 23 | "TEMPLATE_SEPARATOR": "_", 24 | "DISABLE_WRAPPER": False, 25 | "JS_WRAPPER": "(function() {\n%s\n}).call(this);", 26 | "CSSTIDY_BINARY": "/usr/bin/env csstidy", 27 | "CSSTIDY_ARGUMENTS": "--template=highest", 28 | "YUGLIFY_BINARY": "/usr/bin/env yuglify", 29 | "YUGLIFY_CSS_ARGUMENTS": "--terminal", 30 | "YUGLIFY_JS_ARGUMENTS": "--terminal", 31 | "YUI_BINARY": "/usr/bin/env yuicompressor", 32 | "YUI_CSS_ARGUMENTS": "", 33 | "YUI_JS_ARGUMENTS": "", 34 | "CLOSURE_BINARY": "/usr/bin/env closure", 35 | "CLOSURE_ARGUMENTS": "", 36 | "UGLIFYJS_BINARY": "/usr/bin/env uglifyjs", 37 | "UGLIFYJS_ARGUMENTS": "", 38 | "TERSER_BINARY": "/usr/bin/env terser", 39 | "TERSER_ARGUMENTS": "--compress", 40 | "CSSMIN_BINARY": "/usr/bin/env cssmin", 41 | "CSSMIN_ARGUMENTS": "", 42 | "COFFEE_SCRIPT_BINARY": "/usr/bin/env coffee", 43 | "COFFEE_SCRIPT_ARGUMENTS": "", 44 | "BABEL_BINARY": "/usr/bin/env babel", 45 | "BABEL_ARGUMENTS": "", 46 | "LIVE_SCRIPT_BINARY": "/usr/bin/env lsc", 47 | "LIVE_SCRIPT_ARGUMENTS": "", 48 | "TYPE_SCRIPT_BINARY": "/usr/bin/env tsc", 49 | "TYPE_SCRIPT_ARGUMENTS": "", 50 | "SASS_BINARY": "/usr/bin/env sass", 51 | "SASS_ARGUMENTS": "", 52 | "STYLUS_BINARY": "/usr/bin/env stylus", 53 | "STYLUS_ARGUMENTS": "", 54 | "LESS_BINARY": "/usr/bin/env lessc", 55 | "LESS_ARGUMENTS": "", 56 | "MIMETYPES": ( 57 | (("text/coffeescript"), (".coffee")), 58 | (("text/less"), (".less")), 59 | (("text/javascript"), (".js")), 60 | (("text/typescript"), (".ts")), 61 | (("text/x-sass"), (".sass")), 62 | (("text/x-scss"), (".scss")), 63 | ), 64 | "EMBED_MAX_IMAGE_SIZE": 32700, 65 | "EMBED_PATH": r"[/]?embed/", 66 | } 67 | 68 | 69 | class PipelineSettings(MutableMapping): 70 | """ 71 | Container object for pipeline settings 72 | """ 73 | 74 | def __init__(self, wrapped_settings): 75 | self.settings = DEFAULTS.copy() 76 | self.settings.update(wrapped_settings) 77 | 78 | def __getitem__(self, key): 79 | value = self.settings[key] 80 | if key.endswith(("_BINARY", "_ARGUMENTS")): 81 | if isinstance(value, (str,)): 82 | return tuple(shlex.split(value, posix=(os.name == "posix"))) 83 | return tuple(value) 84 | return value 85 | 86 | def __setitem__(self, key, value): 87 | self.settings[key] = value 88 | 89 | def __delitem__(self, key): 90 | del self.store[key] 91 | 92 | def __iter__(self): 93 | return iter(self.settings) 94 | 95 | def __len__(self): 96 | return len(self.settings) 97 | 98 | def __getattr__(self, name): 99 | return self.__getitem__(name) 100 | 101 | 102 | settings = PipelineSettings(_settings.PIPELINE) 103 | 104 | 105 | @receiver(setting_changed) 106 | def reload_settings(**kwargs): 107 | if kwargs["setting"] == "PIPELINE": 108 | settings.update(kwargs["value"]) 109 | -------------------------------------------------------------------------------- /pipeline/finders.py: -------------------------------------------------------------------------------- 1 | from itertools import chain 2 | from os.path import normpath 3 | 4 | from django.contrib.staticfiles.finders import ( 5 | AppDirectoriesFinder as DjangoAppDirectoriesFinder, 6 | ) 7 | from django.contrib.staticfiles.finders import BaseFinder, BaseStorageFinder 8 | from django.contrib.staticfiles.finders import ( 9 | FileSystemFinder as DjangoFileSystemFinder, 10 | ) 11 | from django.contrib.staticfiles.finders import find 12 | from django.contrib.staticfiles.storage import staticfiles_storage 13 | from django.utils._os import safe_join 14 | 15 | from pipeline.conf import settings 16 | 17 | 18 | class PipelineFinder(BaseStorageFinder): 19 | storage = staticfiles_storage 20 | 21 | def find(self, path, **kwargs): 22 | if not settings.PIPELINE_ENABLED: 23 | return super().find(path, **kwargs) 24 | else: 25 | return [] 26 | 27 | def list(self, ignore_patterns): 28 | return [] 29 | 30 | 31 | class ManifestFinder(BaseFinder): 32 | def find(self, path, **kwargs): 33 | """ 34 | Looks for files in PIPELINE.STYLESHEETS and PIPELINE.JAVASCRIPT 35 | """ 36 | matches = [] 37 | for elem in chain(settings.STYLESHEETS.values(), settings.JAVASCRIPT.values()): 38 | if normpath(elem["output_filename"]) == normpath(path): 39 | match = safe_join(settings.PIPELINE_ROOT, path) 40 | if not kwargs.get("find_all", kwargs.get("all", False)): 41 | return match 42 | matches.append(match) 43 | return matches 44 | 45 | def list(self, *args): 46 | return [] 47 | 48 | 49 | class CachedFileFinder(BaseFinder): 50 | def find(self, path, **kwargs): 51 | """ 52 | Work out the uncached name of the file and look that up instead 53 | """ 54 | try: 55 | start, _, extn = path.rsplit(".", 2) 56 | except ValueError: 57 | return [] 58 | path = ".".join((start, extn)) 59 | return find(path, **kwargs) or [] 60 | 61 | def list(self, *args): 62 | return [] 63 | 64 | 65 | class PatternFilterMixin: 66 | ignore_patterns = [] 67 | 68 | def get_ignored_patterns(self): 69 | return list(set(self.ignore_patterns)) 70 | 71 | def list(self, ignore_patterns): 72 | if ignore_patterns: 73 | ignore_patterns = ignore_patterns + self.get_ignored_patterns() 74 | return super().list(ignore_patterns) 75 | 76 | 77 | class AppDirectoriesFinder(PatternFilterMixin, DjangoAppDirectoriesFinder): 78 | """ 79 | Like AppDirectoriesFinder, but doesn't return any additional ignored 80 | patterns. 81 | 82 | This allows us to concentrate/compress our components without dragging 83 | the raw versions in via collectstatic. 84 | """ 85 | 86 | ignore_patterns = [ 87 | "*.js", 88 | "*.css", 89 | "*.less", 90 | "*.scss", 91 | "*.styl", 92 | ] 93 | 94 | 95 | class FileSystemFinder(PatternFilterMixin, DjangoFileSystemFinder): 96 | """ 97 | Like FileSystemFinder, but doesn't return any additional ignored patterns 98 | 99 | This allows us to concentrate/compress our components without dragging 100 | the raw versions in too. 101 | """ 102 | 103 | ignore_patterns = [ 104 | "*.js", 105 | "*.css", 106 | "*.less", 107 | "*.scss", 108 | "*.styl", 109 | "*.sh", 110 | "*.html", 111 | "*.md", 112 | "*.markdown", 113 | "*.php", 114 | "*.txt", 115 | "README*", 116 | "LICENSE*", 117 | "*examples*", 118 | "*test*", 119 | "*bin*", 120 | "*samples*", 121 | "*docs*", 122 | "*build*", 123 | "*demo*", 124 | "Makefile*", 125 | "Gemfile*", 126 | "node_modules", 127 | ] 128 | -------------------------------------------------------------------------------- /tests/tests/test_glob.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | 4 | from django.core.files.base import ContentFile 5 | from django.core.files.storage import FileSystemStorage 6 | from django.test import TestCase 7 | 8 | from pipeline import glob 9 | 10 | 11 | def local_path(path): 12 | return os.path.join(os.path.dirname(__file__), path) 13 | 14 | 15 | class GlobTest(TestCase): 16 | def normpath(self, *parts): 17 | return os.path.normpath(os.path.join(*parts)) 18 | 19 | def mktemp(self, *parts): 20 | filename = self.normpath(*parts) 21 | base, file = os.path.split(filename) 22 | base = os.path.join(self.storage.location, base) 23 | if not os.path.exists(base): 24 | os.makedirs(base) 25 | self.storage.save(filename, ContentFile("")) 26 | 27 | def assertSequenceEqual(self, l1, l2): 28 | self.assertEqual(set(l1), set(l2)) 29 | 30 | def setUp(self): 31 | self.storage = FileSystemStorage(local_path("glob_dir")) 32 | self.old_storage = glob.staticfiles_storage 33 | glob.staticfiles_storage = self.storage 34 | self.mktemp("a", "D") 35 | self.mktemp("aab", "F") 36 | self.mktemp("aaa", "zzzF") 37 | self.mktemp("ZZZ") 38 | self.mktemp("a", "bcd", "EF") 39 | self.mktemp("a", "bcd", "efg", "ha") 40 | 41 | def glob(self, *parts): 42 | if len(parts) == 1: 43 | pattern = parts[0] 44 | else: 45 | pattern = os.path.join(*parts) 46 | return glob.glob(pattern) 47 | 48 | def tearDown(self): 49 | shutil.rmtree(self.storage.location) 50 | glob.staticfiles_storage = self.old_storage 51 | 52 | def test_glob_literal(self): 53 | self.assertSequenceEqual(self.glob("a"), [self.normpath("a")]) 54 | self.assertSequenceEqual(self.glob("a", "D"), [self.normpath("a", "D")]) 55 | self.assertSequenceEqual(self.glob("aab"), [self.normpath("aab")]) 56 | 57 | def test_glob_one_directory(self): 58 | self.assertSequenceEqual( 59 | self.glob("a*"), map(self.normpath, ["a", "aab", "aaa"]) 60 | ) 61 | self.assertSequenceEqual(self.glob("*a"), map(self.normpath, ["a", "aaa"])) 62 | self.assertSequenceEqual(self.glob("aa?"), map(self.normpath, ["aaa", "aab"])) 63 | self.assertSequenceEqual( 64 | self.glob("aa[ab]"), map(self.normpath, ["aaa", "aab"]) 65 | ) 66 | self.assertSequenceEqual(self.glob("*q"), []) 67 | 68 | def test_glob_nested_directory(self): 69 | if os.path.normcase("abCD") == "abCD": 70 | # case-sensitive filesystem 71 | self.assertSequenceEqual( 72 | self.glob("a", "bcd", "E*"), [self.normpath("a", "bcd", "EF")] 73 | ) 74 | else: 75 | # case insensitive filesystem 76 | self.assertSequenceEqual( 77 | self.glob("a", "bcd", "E*"), 78 | [self.normpath("a", "bcd", "EF"), self.normpath("a", "bcd", "efg")], 79 | ) 80 | self.assertSequenceEqual( 81 | self.glob("a", "bcd", "*g"), [self.normpath("a", "bcd", "efg")] 82 | ) 83 | 84 | def test_glob_directory_names(self): 85 | self.assertSequenceEqual(self.glob("*", "D"), [self.normpath("a", "D")]) 86 | self.assertSequenceEqual(self.glob("*", "*a"), []) 87 | self.assertSequenceEqual( 88 | self.glob("a", "*", "*", "*a"), [self.normpath("a", "bcd", "efg", "ha")] 89 | ) 90 | self.assertSequenceEqual( 91 | self.glob("?a?", "*F"), 92 | map(self.normpath, [os.path.join("aaa", "zzzF"), os.path.join("aab", "F")]), 93 | ) 94 | 95 | def test_glob_directory_with_trailing_slash(self): 96 | # We are verifying that when there is wildcard pattern which 97 | # ends with os.sep doesn't blow up. 98 | paths = glob.glob("*" + os.sep) 99 | self.assertEqual(len(paths), 4) 100 | self.assertTrue(all(os.sep in path for path in paths)) 101 | -------------------------------------------------------------------------------- /pipeline/storage.py: -------------------------------------------------------------------------------- 1 | import gzip 2 | from io import BytesIO 3 | 4 | from django import get_version as django_version 5 | from django.contrib.staticfiles.storage import ( 6 | ManifestStaticFilesStorage, 7 | StaticFilesStorage, 8 | ) 9 | from django.contrib.staticfiles.utils import matches_patterns 10 | from django.core.files.base import File 11 | 12 | _CACHED_STATIC_FILES_STORAGE_AVAILABLE = django_version() < "3.1" 13 | 14 | if _CACHED_STATIC_FILES_STORAGE_AVAILABLE: 15 | from django.contrib.staticfiles.storage import CachedStaticFilesStorage 16 | 17 | 18 | class PipelineMixin: 19 | packing = True 20 | 21 | def post_process(self, paths, dry_run=False, **options): 22 | if dry_run: 23 | return 24 | 25 | from pipeline.packager import Packager # noqa: PLC0415 26 | 27 | packager = Packager(storage=self) 28 | for package_name in packager.packages["css"]: 29 | package = packager.package_for("css", package_name) 30 | output_file = package.output_filename 31 | if self.packing: 32 | packager.pack_stylesheets(package) 33 | paths[output_file] = (self, output_file) 34 | yield output_file, output_file, True 35 | for package_name in packager.packages["js"]: 36 | package = packager.package_for("js", package_name) 37 | output_file = package.output_filename 38 | if self.packing: 39 | packager.pack_javascripts(package) 40 | paths[output_file] = (self, output_file) 41 | yield output_file, output_file, True 42 | 43 | super_class = super() 44 | if hasattr(super_class, "post_process"): 45 | yield from super_class.post_process(paths.copy(), dry_run, **options) 46 | 47 | def get_available_name(self, name, max_length=None): 48 | if self.exists(name): 49 | self.delete(name) 50 | return name 51 | 52 | 53 | class GZIPMixin: 54 | gzip_patterns = ("*.css", "*.js") 55 | 56 | def _compress(self, original_file): 57 | content = BytesIO() 58 | gzip_file = gzip.GzipFile(mode="wb", fileobj=content) 59 | gzip_file.write(original_file.read()) 60 | gzip_file.close() 61 | content.seek(0) 62 | return File(content) 63 | 64 | def post_process(self, paths, dry_run=False, **options): 65 | super_class = super() 66 | if hasattr(super_class, "post_process"): 67 | for name, hashed_name, processed in super_class.post_process( 68 | paths.copy(), dry_run, **options 69 | ): 70 | if hashed_name != name: 71 | paths[hashed_name] = (self, hashed_name) 72 | yield name, hashed_name, processed 73 | 74 | if dry_run: 75 | return 76 | 77 | for path in paths: 78 | if path: 79 | if not matches_patterns(path, self.gzip_patterns): 80 | continue 81 | original_file = self.open(path) 82 | gzipped_path = f"{path}.gz" 83 | if self.exists(gzipped_path): 84 | self.delete(gzipped_path) 85 | gzipped_file = self._compress(original_file) 86 | gzipped_path = self.save(gzipped_path, gzipped_file) 87 | yield gzipped_path, gzipped_path, True 88 | 89 | 90 | class NonPackagingMixin: 91 | packing = False 92 | 93 | 94 | class PipelineStorage(PipelineMixin, StaticFilesStorage): 95 | pass 96 | 97 | 98 | class NonPackagingPipelineStorage(NonPackagingMixin, PipelineStorage): 99 | pass 100 | 101 | 102 | if _CACHED_STATIC_FILES_STORAGE_AVAILABLE: 103 | 104 | class PipelineCachedStorage(PipelineMixin, CachedStaticFilesStorage): 105 | # Deprecated since Django 2.2 106 | # Removed in Django 3.1 107 | pass 108 | 109 | class NonPackagingPipelineCachedStorage(NonPackagingMixin, PipelineCachedStorage): 110 | # Deprecated since Django 2.2 111 | # Removed in Django 3.1 112 | pass 113 | 114 | 115 | class PipelineManifestStorage(PipelineMixin, ManifestStaticFilesStorage): 116 | pass 117 | 118 | 119 | class NonPackagingPipelineManifestStorage( 120 | NonPackagingMixin, ManifestStaticFilesStorage 121 | ): 122 | pass 123 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Pipeline 2 | ======== 3 | 4 | .. image:: https://jazzband.co/static/img/badge.svg 5 | :alt: Jazzband 6 | :target: https://jazzband.co/ 7 | 8 | .. image:: https://github.com/jazzband/django-pipeline/workflows/Test/badge.svg 9 | :target: https://github.com/jazzband/django-pipeline/actions 10 | :alt: GitHub Actions 11 | 12 | .. image:: https://codecov.io/gh/jazzband/django-pipeline/branch/master/graph/badge.svg 13 | :target: https://codecov.io/gh/jazzband/django-pipeline 14 | :alt: Coverage 15 | 16 | .. image:: https://readthedocs.org/projects/django-pipeline/badge/?version=latest 17 | :alt: Documentation Status 18 | :target: https://django-pipeline.readthedocs.io/en/latest/?badge=latest 19 | 20 | 21 | Pipeline is an asset packaging library for Django, providing both CSS and 22 | JavaScript concatenation and compression, built-in JavaScript template support, 23 | and optional data-URI image and font embedding. 24 | 25 | .. image:: https://github.com/jazzband/django-pipeline/raw/master/img/django-pipeline.svg 26 | :alt: Django Pipeline Overview 27 | 28 | 29 | Installation 30 | ------------ 31 | 32 | To install it, simply: 33 | 34 | .. code-block:: bash 35 | 36 | pip install django-pipeline 37 | 38 | 39 | Quickstart 40 | ---------- 41 | 42 | Pipeline compiles and compress your assets files from 43 | ``STATICFILES_DIRS`` to your ``STATIC_ROOT`` when you run Django's 44 | ``collectstatic`` command. 45 | 46 | These simple steps add Pipeline to your project to compile multiple ``.js`` and 47 | ``.css`` file into one and compress them. 48 | 49 | Add Pipeline to your installed apps: 50 | 51 | .. code-block:: python 52 | 53 | # settings.py 54 | INSTALLED_APPS = [ 55 | ... 56 | 'pipeline', 57 | ] 58 | 59 | 60 | Use Pipeline specified classes for ``STATICFILES_FINDERS`` and ``STATICFILES_STORAGE``: 61 | 62 | .. code-block:: python 63 | 64 | STATICFILES_STORAGE = 'pipeline.storage.PipelineManifestStorage' 65 | 66 | STATICFILES_FINDERS = ( 67 | 'django.contrib.staticfiles.finders.FileSystemFinder', 68 | 'django.contrib.staticfiles.finders.AppDirectoriesFinder', 69 | 'pipeline.finders.PipelineFinder', 70 | ) 71 | 72 | 73 | Configure Pipeline: 74 | 75 | .. code-block:: python 76 | 77 | # The folowing config merges CSS files(main.css, normalize.css) 78 | # and JavaScript files(app.js, script.js) and compress them using 79 | # `yuglify` into `css/styles.css` and `js/main.js` 80 | # NOTE: Pipeline only works when DEBUG is False 81 | PIPELINE = { 82 | 'STYLESHEETS': { 83 | 'css_files': { 84 | 'source_filenames': ( 85 | 'css/main.css', 86 | 'css/normalize.css', 87 | ), 88 | 'output_filename': 'css/styles.css', 89 | 'extra_context': { 90 | 'media': 'screen,projection', 91 | }, 92 | }, 93 | }, 94 | 'JAVASCRIPT': { 95 | 'js_files': { 96 | 'source_filenames': ( 97 | 'js/app.js', 98 | 'js/script.js', 99 | ), 100 | 'output_filename': 'js/main.js', 101 | } 102 | } 103 | } 104 | 105 | 106 | Then, you have to install compilers and compressors binary manually. 107 | 108 | For example, you can install them using `NPM `_ 109 | and address them from ``node_modules`` directory in your project path: 110 | 111 | .. code-block:: python 112 | 113 | PIPELINE.update({ 114 | 'YUGLIFY_BINARY': path.join(BASE_DIR, 'node_modules/.bin/yuglify'), 115 | }) 116 | # For a list of all supported compilers and compressors see documentation 117 | 118 | 119 | Load static files in your template: 120 | 121 | .. code-block:: 122 | 123 | {% load pipeline %} 124 | {% stylesheet 'css_files' %} 125 | {% javascript 'js_files' %} 126 | 127 | 128 | Documentation 129 | ------------- 130 | 131 | For documentation, usage, and examples, see: 132 | https://django-pipeline.readthedocs.io 133 | 134 | 135 | Issues 136 | ------ 137 | You can report bugs and discuss features on the `issues page `_. 138 | 139 | 140 | Changelog 141 | --------- 142 | 143 | See `HISTORY.rst `_. 144 | -------------------------------------------------------------------------------- /docs/storages.rst: -------------------------------------------------------------------------------- 1 | .. _ref-storages: 2 | 3 | ======== 4 | Storages 5 | ======== 6 | 7 | 8 | Using with staticfiles 9 | ====================== 10 | 11 | Pipeline is providing a storage for `staticfiles app `_, 12 | to use it configure ``STATICFILES_STORAGE`` like so :: 13 | 14 | STATICFILES_STORAGE = 'pipeline.storage.PipelineStorage' 15 | 16 | And if you want versioning use :: 17 | 18 | STATICFILES_STORAGE = 'pipeline.storage.PipelineManifestStorage' 19 | 20 | There is also non-packing storage available, that allows you to run ``collectstatic`` command 21 | without packaging your assets. Useful for production when you don't want to run compressor or compilers :: 22 | 23 | STATICFILES_STORAGE = 'pipeline.storage.NonPackagingPipelineStorage' 24 | 25 | Also available if you want versioning :: 26 | 27 | STATICFILES_STORAGE = 'pipeline.storage.NonPackagingPipelineManifestStorage' 28 | 29 | If you use staticfiles with ``DEBUG = False`` (i.e. for integration tests 30 | with `Selenium `_) you should install the finder 31 | that allows staticfiles to locate your outputted assets : :: 32 | 33 | STATICFILES_FINDERS = ( 34 | 'django.contrib.staticfiles.finders.FileSystemFinder', 35 | 'django.contrib.staticfiles.finders.AppDirectoriesFinder', 36 | 'pipeline.finders.PipelineFinder', 37 | ) 38 | 39 | If you use ``PipelineCachedStorage`` you may also like the ``CachedFileFinder``, 40 | which allows you to use integration tests with cached file URLs. 41 | 42 | Keep in mind that ``PipelineCachedStorage`` is only available for Django versions 43 | before 3.1. 44 | 45 | If you want to exclude Pipelinable content from your collected static files, 46 | you can also use Pipeline's ``FileSystemFinder`` and ``AppDirectoriesFinder``. 47 | These finders will also exclude `unwanted` content like READMEs, tests and 48 | examples, which are particularly useful if you're collecting content from a 49 | tool like Bower. :: 50 | 51 | STATICFILES_FINDERS = ( 52 | 'pipeline.finders.FileSystemFinder', 53 | 'pipeline.finders.AppDirectoriesFinder', 54 | 'pipeline.finders.CachedFileFinder', 55 | 'pipeline.finders.PipelineFinder', 56 | ) 57 | 58 | GZIP compression 59 | ================ 60 | 61 | Pipeline can also creates a gzipped version of your collected static files, 62 | so that you can avoid compressing them on the fly. :: 63 | 64 | STATICFILES_STORAGE = 'your.app.GZIPCachedStorage' 65 | 66 | The storage need to inherit from ``GZIPMixin``: :: 67 | 68 | from django.contrib.staticfiles.storage import CachedStaticFilesStorage 69 | 70 | from pipeline.storage import GZIPMixin 71 | 72 | 73 | class GZIPCachedStorage(GZIPMixin, CachedStaticFilesStorage): 74 | pass 75 | 76 | 77 | Using with other storages 78 | ========================= 79 | 80 | You can also use your own custom storage, for example, if you want to use S3 for your assets : :: 81 | 82 | STATICFILES_STORAGE = 'your.app.S3PipelineManifestStorage' 83 | 84 | Your storage only needs to inherit from ``PipelineMixin`` and ``ManifestFilesMixin`` or ``CachedFilesMixin``. 85 | 86 | In Django 1.7+ you should use `ManifestFilesMixin `_ 87 | unless you don't have access to the local filesystem in which case you should use ``CachedFilesMixin``. :: 88 | 89 | from django.contrib.staticfiles.storage import CachedFilesMixin, ManifestFilesMixin 90 | 91 | from pipeline.storage import PipelineMixin 92 | 93 | from storages.backends.s3boto import S3BotoStorage 94 | 95 | class S3PipelineManifestStorage(PipelineMixin, ManifestFilesMixin, S3BotoStorage): 96 | pass 97 | 98 | class S3PipelineCachedStorage(PipelineMixin, CachedFilesMixin, S3BotoStorage): 99 | pass 100 | 101 | 102 | Using Pipeline with Bower 103 | ========================= 104 | 105 | `Bower `_ is a `package manager for the web` that allows 106 | you to easily include frontend components with named versions. Integrating 107 | Bower with Pipeline is straightforward. 108 | 109 | Add your Bower directory to your ``STATICFILES_DIRS`` : :: 110 | 111 | STATICFILES_DIRS = ( 112 | os.path.join(os.path.dirname(__file__), '..', 'bower_components'), 113 | ) 114 | 115 | Then process the relevant content through Pipeline : :: 116 | 117 | PIPELINE['JAVASCRIPT'] = { 118 | 'components': { 119 | 'source_filenames': ( 120 | 'jquery/jquery.js', 121 | # you can choose to be specific to reduce your payload 122 | 'jquery-ui/ui/*.js', 123 | ), 124 | 'output_filename': 'js/components.js', 125 | }, 126 | } 127 | 128 | ``pipeline.finders.FileSystemFinder`` will help you by excluding much of the 129 | extra content that Bower includes with its components, such as READMEs, tests 130 | and examples, while still including images, fonts, CSS fragments etc. 131 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # Internal variables. 11 | PAPEROPT_a4 = -D latex_paper_size=a4 12 | PAPEROPT_letter = -D latex_paper_size=letter 13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 14 | 15 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest 16 | 17 | help: 18 | @echo "Please use \`make ' where is one of" 19 | @echo " html to make standalone HTML files" 20 | @echo " dirhtml to make HTML files named index.html in directories" 21 | @echo " singlehtml to make a single large HTML file" 22 | @echo " pickle to make pickle files" 23 | @echo " json to make JSON files" 24 | @echo " htmlhelp to make HTML files and a HTML help project" 25 | @echo " qthelp to make HTML files and a qthelp project" 26 | @echo " devhelp to make HTML files and a Devhelp project" 27 | @echo " epub to make an epub" 28 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 29 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 30 | @echo " text to make text files" 31 | @echo " man to make manual pages" 32 | @echo " changes to make an overview of all changed/added/deprecated items" 33 | @echo " linkcheck to check all external links for integrity" 34 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 35 | 36 | clean: 37 | -rm -rf $(BUILDDIR)/* 38 | 39 | html: 40 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 41 | @echo 42 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 43 | 44 | dirhtml: 45 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 46 | @echo 47 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 48 | 49 | singlehtml: 50 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 51 | @echo 52 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 53 | 54 | pickle: 55 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 56 | @echo 57 | @echo "Build finished; now you can process the pickle files." 58 | 59 | json: 60 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 61 | @echo 62 | @echo "Build finished; now you can process the JSON files." 63 | 64 | htmlhelp: 65 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 66 | @echo 67 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 68 | ".hhp project file in $(BUILDDIR)/htmlhelp." 69 | 70 | qthelp: 71 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 72 | @echo 73 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 74 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 75 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/django-pipeline.qhcp" 76 | @echo "To view the help file:" 77 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/django-pipeline.qhc" 78 | 79 | devhelp: 80 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 81 | @echo 82 | @echo "Build finished." 83 | @echo "To view the help file:" 84 | @echo "# mkdir -p $$HOME/.local/share/devhelp/django-pipeline" 85 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/django-pipeline" 86 | @echo "# devhelp" 87 | 88 | epub: 89 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 90 | @echo 91 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 92 | 93 | latex: 94 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 95 | @echo 96 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 97 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 98 | "(use \`make latexpdf' here to do that automatically)." 99 | 100 | latexpdf: 101 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 102 | @echo "Running LaTeX files through pdflatex..." 103 | make -C $(BUILDDIR)/latex all-pdf 104 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 105 | 106 | text: 107 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 108 | @echo 109 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 110 | 111 | man: 112 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 113 | @echo 114 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 115 | 116 | changes: 117 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 118 | @echo 119 | @echo "The overview file is in $(BUILDDIR)/changes." 120 | 121 | linkcheck: 122 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 123 | @echo 124 | @echo "Link check complete; look for any errors in the above output " \ 125 | "or in $(BUILDDIR)/linkcheck/output.txt." 126 | 127 | doctest: 128 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 129 | @echo "Testing of doctests in the sources finished, look at the " \ 130 | "results in $(BUILDDIR)/doctest/output.txt." 131 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | REM Command file for Sphinx documentation 4 | 5 | if "%SPHINXBUILD%" == "" ( 6 | set SPHINXBUILD=sphinx-build 7 | ) 8 | set BUILDDIR=_build 9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . 10 | if NOT "%PAPER%" == "" ( 11 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% 12 | ) 13 | 14 | if "%1" == "" goto help 15 | 16 | if "%1" == "help" ( 17 | :help 18 | echo.Please use `make ^` where ^ is one of 19 | echo. html to make standalone HTML files 20 | echo. dirhtml to make HTML files named index.html in directories 21 | echo. singlehtml to make a single large HTML file 22 | echo. pickle to make pickle files 23 | echo. json to make JSON files 24 | echo. htmlhelp to make HTML files and a HTML help project 25 | echo. qthelp to make HTML files and a qthelp project 26 | echo. devhelp to make HTML files and a Devhelp project 27 | echo. epub to make an epub 28 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter 29 | echo. text to make text files 30 | echo. man to make manual pages 31 | echo. changes to make an overview over all changed/added/deprecated items 32 | echo. linkcheck to check all external links for integrity 33 | echo. doctest to run all doctests embedded in the documentation if enabled 34 | goto end 35 | ) 36 | 37 | if "%1" == "clean" ( 38 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 39 | del /q /s %BUILDDIR%\* 40 | goto end 41 | ) 42 | 43 | if "%1" == "html" ( 44 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 45 | if errorlevel 1 exit /b 1 46 | echo. 47 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 48 | goto end 49 | ) 50 | 51 | if "%1" == "dirhtml" ( 52 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 53 | if errorlevel 1 exit /b 1 54 | echo. 55 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 56 | goto end 57 | ) 58 | 59 | if "%1" == "singlehtml" ( 60 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml 61 | if errorlevel 1 exit /b 1 62 | echo. 63 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. 64 | goto end 65 | ) 66 | 67 | if "%1" == "pickle" ( 68 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 69 | if errorlevel 1 exit /b 1 70 | echo. 71 | echo.Build finished; now you can process the pickle files. 72 | goto end 73 | ) 74 | 75 | if "%1" == "json" ( 76 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 77 | if errorlevel 1 exit /b 1 78 | echo. 79 | echo.Build finished; now you can process the JSON files. 80 | goto end 81 | ) 82 | 83 | if "%1" == "htmlhelp" ( 84 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 85 | if errorlevel 1 exit /b 1 86 | echo. 87 | echo.Build finished; now you can run HTML Help Workshop with the ^ 88 | .hhp project file in %BUILDDIR%/htmlhelp. 89 | goto end 90 | ) 91 | 92 | if "%1" == "qthelp" ( 93 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 94 | if errorlevel 1 exit /b 1 95 | echo. 96 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 97 | .qhcp project file in %BUILDDIR%/qthelp, like this: 98 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\django-pipeline.qhcp 99 | echo.To view the help file: 100 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\django-pipeline.ghc 101 | goto end 102 | ) 103 | 104 | if "%1" == "devhelp" ( 105 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp 106 | if errorlevel 1 exit /b 1 107 | echo. 108 | echo.Build finished. 109 | goto end 110 | ) 111 | 112 | if "%1" == "epub" ( 113 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub 114 | if errorlevel 1 exit /b 1 115 | echo. 116 | echo.Build finished. The epub file is in %BUILDDIR%/epub. 117 | goto end 118 | ) 119 | 120 | if "%1" == "latex" ( 121 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 122 | if errorlevel 1 exit /b 1 123 | echo. 124 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 125 | goto end 126 | ) 127 | 128 | if "%1" == "text" ( 129 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text 130 | if errorlevel 1 exit /b 1 131 | echo. 132 | echo.Build finished. The text files are in %BUILDDIR%/text. 133 | goto end 134 | ) 135 | 136 | if "%1" == "man" ( 137 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man 138 | if errorlevel 1 exit /b 1 139 | echo. 140 | echo.Build finished. The manual pages are in %BUILDDIR%/man. 141 | goto end 142 | ) 143 | 144 | if "%1" == "changes" ( 145 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 146 | if errorlevel 1 exit /b 1 147 | echo. 148 | echo.The overview file is in %BUILDDIR%/changes. 149 | goto end 150 | ) 151 | 152 | if "%1" == "linkcheck" ( 153 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 154 | if errorlevel 1 exit /b 1 155 | echo. 156 | echo.Link check complete; look for any errors in the above output ^ 157 | or in %BUILDDIR%/linkcheck/output.txt. 158 | goto end 159 | ) 160 | 161 | if "%1" == "doctest" ( 162 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 163 | if errorlevel 1 exit /b 1 164 | echo. 165 | echo.Testing of doctests in the sources finished, look at the ^ 166 | results in %BUILDDIR%/doctest/output.txt. 167 | goto end 168 | ) 169 | 170 | :end 171 | -------------------------------------------------------------------------------- /docs/usage.rst: -------------------------------------------------------------------------------- 1 | .. _ref-usage: 2 | 3 | ===== 4 | Usage 5 | ===== 6 | 7 | Describes how to use Pipeline when it is installed and configured. 8 | 9 | Templatetags 10 | ============ 11 | 12 | Pipeline includes two template tags: ``stylesheet`` and ``javascript``, 13 | in a template library called ``pipeline``. 14 | 15 | They are used to output the ```` and ``', # noqa 43 | template.render(), 44 | ) 45 | 46 | def test_package_js_async(self): 47 | template = self.env.from_string("""{% javascript "scripts_async" %}""") 48 | self.assertEqual( 49 | '', # noqa 50 | template.render(), 51 | ) 52 | 53 | def test_package_js_defer(self): 54 | template = self.env.from_string("""{% javascript "scripts_defer" %}""") 55 | self.assertEqual( 56 | '', # noqa 57 | template.render(), 58 | ) 59 | 60 | def test_package_js_async_defer(self): 61 | template = self.env.from_string("""{% javascript "scripts_async_defer" %}""") 62 | self.assertEqual( 63 | '', # noqa 64 | template.render(), 65 | ) 66 | 67 | 68 | class DjangoTest(TestCase): 69 | def render_template(self, template): 70 | return Template(template).render(Context()) 71 | 72 | def test_compressed_empty(self): 73 | rendered = self.render_template( 74 | """{% load pipeline %}{% stylesheet "unknow" %}""", 75 | ) 76 | self.assertEqual("", rendered) 77 | 78 | def test_compressed_css(self): 79 | rendered = self.render_template( 80 | """{% load pipeline %}{% stylesheet "screen" %}""", 81 | ) 82 | self.assertEqual( 83 | '', # noqa 84 | rendered, 85 | ) 86 | 87 | def test_compressed_css_media(self): 88 | rendered = self.render_template( 89 | """{% load pipeline %}{% stylesheet "screen_media" %}""", 90 | ) 91 | self.assertEqual( 92 | '', # noqa 93 | rendered, 94 | ) 95 | 96 | def test_compressed_css_title(self): 97 | rendered = self.render_template( 98 | """{% load pipeline %}{% stylesheet "screen_title" %}""", 99 | ) 100 | self.assertEqual( 101 | '', # noqa 102 | rendered, 103 | ) 104 | 105 | def test_compressed_js(self): 106 | rendered = self.render_template( 107 | """{% load pipeline %}{% javascript "scripts" %}""", 108 | ) 109 | self.assertEqual( 110 | '', # noqa 111 | rendered, 112 | ) 113 | 114 | def test_compressed_js_async(self): 115 | rendered = self.render_template( 116 | """{% load pipeline %}{% javascript "scripts_async" %}""", 117 | ) 118 | self.assertEqual( 119 | '', # noqa 120 | rendered, 121 | ) 122 | 123 | def test_compressed_js_defer(self): 124 | rendered = self.render_template( 125 | """{% load pipeline %}{% javascript "scripts_defer" %}""", 126 | ) 127 | self.assertEqual( 128 | '', # noqa 129 | rendered, 130 | ) 131 | 132 | def test_compressed_js_async_defer(self): 133 | rendered = self.render_template( 134 | """{% load pipeline %}{% javascript "scripts_async_defer" %}""", 135 | ) 136 | self.assertEqual( 137 | '', # noqa 138 | rendered, 139 | ) 140 | -------------------------------------------------------------------------------- /pipeline/packager.py: -------------------------------------------------------------------------------- 1 | from django.contrib.staticfiles.finders import find, get_finders 2 | from django.contrib.staticfiles.storage import staticfiles_storage 3 | from django.core.files.base import ContentFile 4 | from django.utils.encoding import smart_bytes 5 | 6 | from pipeline.compilers import Compiler 7 | from pipeline.compressors import Compressor 8 | from pipeline.conf import settings 9 | from pipeline.exceptions import PackageNotFound 10 | from pipeline.glob import glob 11 | from pipeline.signals import css_compressed, js_compressed 12 | 13 | 14 | class Package: 15 | def __init__(self, config): 16 | self.config = config 17 | self._sources = [] 18 | 19 | @property 20 | def sources(self): 21 | if not self._sources: 22 | paths = [] 23 | for pattern in self.config.get("source_filenames", []): 24 | for path in glob(pattern): 25 | if path not in paths and find(path): 26 | paths.append(str(path)) 27 | self._sources = paths 28 | return self._sources 29 | 30 | @property 31 | def paths(self): 32 | return [ 33 | path for path in self.sources if not path.endswith(settings.TEMPLATE_EXT) 34 | ] 35 | 36 | @property 37 | def templates(self): 38 | return [path for path in self.sources if path.endswith(settings.TEMPLATE_EXT)] 39 | 40 | @property 41 | def output_filename(self): 42 | return self.config.get("output_filename") 43 | 44 | @property 45 | def extra_context(self): 46 | return self.config.get("extra_context", {}) 47 | 48 | @property 49 | def template_name(self): 50 | return self.config.get("template_name") 51 | 52 | @property 53 | def variant(self): 54 | return self.config.get("variant") 55 | 56 | @property 57 | def manifest(self): 58 | return self.config.get("manifest", True) 59 | 60 | @property 61 | def compiler_options(self): 62 | return self.config.get("compiler_options", {}) 63 | 64 | 65 | class Packager: 66 | def __init__( 67 | self, 68 | storage=None, 69 | verbose=False, 70 | css_packages=None, 71 | js_packages=None, 72 | ): 73 | if storage is None: 74 | storage = staticfiles_storage 75 | self.storage = storage 76 | self.verbose = verbose 77 | self.compressor = Compressor(storage=storage, verbose=verbose) 78 | self.compiler = Compiler(storage=storage, verbose=verbose) 79 | if css_packages is None: 80 | css_packages = settings.STYLESHEETS 81 | if js_packages is None: 82 | js_packages = settings.JAVASCRIPT 83 | self.packages = { 84 | "css": self.create_packages(css_packages), 85 | "js": self.create_packages(js_packages), 86 | } 87 | 88 | def package_for(self, kind, package_name): 89 | try: 90 | return self.packages[kind][package_name] 91 | except KeyError: 92 | raise PackageNotFound( 93 | "No corresponding package for {} package name : {}".format( 94 | kind, package_name 95 | ) 96 | ) 97 | 98 | def individual_url(self, filename): 99 | return self.storage.url(filename) 100 | 101 | def pack_stylesheets(self, package, **kwargs): 102 | return self.pack( 103 | package, 104 | self.compressor.compress_css, 105 | css_compressed, 106 | output_filename=package.output_filename, 107 | variant=package.variant, 108 | **kwargs, 109 | ) 110 | 111 | def compile(self, paths, compiler_options={}, force=False): 112 | paths = self.compiler.compile( 113 | paths, 114 | compiler_options=compiler_options, 115 | force=force, 116 | ) 117 | for path in paths: 118 | if not self.storage.exists(path): 119 | if self.verbose: 120 | e = ( 121 | "Compiled file '%s' cannot be " 122 | "found with packager's storage. Locating it." 123 | ) 124 | print(e % path) 125 | 126 | source_storage = self.find_source_storage(path) 127 | if source_storage is not None: 128 | with source_storage.open(path) as source_file: 129 | if self.verbose: 130 | print(f"Saving: {path}") 131 | self.storage.save(path, source_file) 132 | else: 133 | raise OSError(f"File does not exist: {path}") 134 | return paths 135 | 136 | def pack(self, package, compress, signal, **kwargs): 137 | output_filename = package.output_filename 138 | if self.verbose: 139 | print(f"Saving: {output_filename}") 140 | paths = self.compile( 141 | package.paths, 142 | compiler_options=package.compiler_options, 143 | force=True, 144 | ) 145 | content = compress(paths, **kwargs) 146 | self.save_file(output_filename, content) 147 | signal.send(sender=self, package=package, **kwargs) 148 | return output_filename 149 | 150 | def pack_javascripts(self, package, **kwargs): 151 | return self.pack( 152 | package, 153 | self.compressor.compress_js, 154 | js_compressed, 155 | output_filename=package.output_filename, 156 | templates=package.templates, 157 | **kwargs, 158 | ) 159 | 160 | def pack_templates(self, package): 161 | return self.compressor.compile_templates(package.templates) 162 | 163 | def save_file(self, path, content): 164 | return self.storage.save(path, ContentFile(smart_bytes(content))) 165 | 166 | def find_source_storage(self, path): 167 | for finder in get_finders(): 168 | for short_path, storage in finder.list(""): 169 | if short_path == path: 170 | if self.verbose: 171 | print(f"Found storage: {str(self.storage)}") 172 | return storage 173 | return None 174 | 175 | def create_packages(self, config): 176 | packages = {} 177 | if not config: 178 | return packages 179 | for name in config: 180 | packages[name] = Package(config[name]) 181 | return packages 182 | -------------------------------------------------------------------------------- /pipeline/compilers/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | import subprocess 4 | from tempfile import NamedTemporaryFile 5 | 6 | from django.contrib.staticfiles import finders 7 | from django.contrib.staticfiles.storage import staticfiles_storage 8 | from django.core.files.base import ContentFile 9 | from django.utils.encoding import force_str 10 | 11 | from pipeline.conf import settings 12 | from pipeline.exceptions import CompilerError 13 | from pipeline.utils import set_std_streams_blocking, to_class 14 | 15 | 16 | class Compiler: 17 | def __init__(self, storage=None, verbose=False): 18 | if storage is None: 19 | storage = staticfiles_storage 20 | self.storage = storage 21 | self.verbose = verbose 22 | 23 | @property 24 | def compilers(self): 25 | return [to_class(compiler) for compiler in settings.COMPILERS] 26 | 27 | def compile(self, paths, compiler_options={}, force=False): 28 | def _compile(input_path): 29 | for compiler in self.compilers: 30 | compiler = compiler(verbose=self.verbose, storage=self.storage) 31 | if compiler.match_file(input_path): 32 | try: 33 | infile = self.storage.path(input_path) 34 | except NotImplementedError: 35 | infile = finders.find(input_path) 36 | project_infile = finders.find(input_path) 37 | outfile = compiler.output_path(infile, compiler.output_extension) 38 | outdated = compiler.is_outdated(project_infile, outfile) 39 | compiler.compile_file( 40 | project_infile, 41 | outfile, 42 | outdated=outdated, 43 | force=force, 44 | **compiler_options, 45 | ) 46 | 47 | return compiler.output_path(input_path, compiler.output_extension) 48 | else: 49 | return input_path 50 | 51 | try: 52 | import multiprocessing # noqa: PLC0415 53 | from concurrent import futures # noqa: PLC0415 54 | except ImportError: 55 | return list(map(_compile, paths)) 56 | else: 57 | with futures.ThreadPoolExecutor( 58 | max_workers=multiprocessing.cpu_count() 59 | ) as executor: 60 | return list(executor.map(_compile, paths)) 61 | 62 | 63 | class CompilerBase: 64 | def __init__(self, verbose, storage): 65 | self.verbose = verbose 66 | self.storage = storage 67 | 68 | def match_file(self, filename): 69 | raise NotImplementedError 70 | 71 | def compile_file(self, infile, outfile, outdated=False, force=False): 72 | raise NotImplementedError 73 | 74 | def save_file(self, path, content): 75 | return self.storage.save(path, ContentFile(content)) 76 | 77 | def read_file(self, path): 78 | file = self.storage.open(path, "rb") 79 | content = file.read() 80 | file.close() 81 | return content 82 | 83 | def output_path(self, path, extension): 84 | path = os.path.splitext(path) 85 | return ".".join((path[0], extension)) 86 | 87 | def is_outdated(self, infile, outfile): 88 | if not os.path.exists(outfile): 89 | return True 90 | 91 | try: 92 | return os.path.getmtime(infile) > os.path.getmtime(outfile) 93 | except OSError: 94 | return True 95 | 96 | 97 | class SubProcessCompiler(CompilerBase): 98 | def execute_command(self, command, cwd=None, stdout_captured=None): 99 | """Execute a command at cwd, saving its normal output at 100 | stdout_captured. Errors, defined as nonzero return code or a failure 101 | to start execution, will raise a CompilerError exception with a 102 | description of the cause. They do not write output. 103 | 104 | This is file-system safe (any valid file names are allowed, even with 105 | spaces or crazy characters) and OS agnostic (existing and future OSes 106 | that Python supports should already work). 107 | 108 | The only thing weird here is that any incoming command arg item may 109 | itself be a tuple. This allows compiler implementations to look clean 110 | while supporting historical string config settings and maintaining 111 | backwards compatibility. Thus, we flatten one layer deep. 112 | ((env, foocomp), infile, (-arg,)) -> (env, foocomp, infile, -arg) 113 | """ 114 | argument_list = [] 115 | for flattening_arg in command: 116 | if isinstance(flattening_arg, (str,)): 117 | argument_list.append(flattening_arg) 118 | else: 119 | argument_list.extend(flattening_arg) 120 | 121 | # The first element in argument_list is the program that will be 122 | # executed; if it is '', then a PermissionError will be raised. 123 | # Thus empty arguments are filtered out from argument_list 124 | argument_list = list(filter(None, argument_list)) 125 | stdout = None 126 | try: 127 | # We always catch stdout in a file, but we may not have a use for it. 128 | temp_file_container = ( 129 | cwd or os.path.dirname(stdout_captured or "") or os.getcwd() 130 | ) 131 | with NamedTemporaryFile( 132 | "wb", delete=False, dir=temp_file_container 133 | ) as stdout: 134 | compiling = subprocess.Popen( 135 | argument_list, cwd=cwd, stdout=stdout, stderr=subprocess.PIPE 136 | ) 137 | _, stderr = compiling.communicate() 138 | set_std_streams_blocking() 139 | 140 | if compiling.returncode != 0: 141 | stdout_captured = None # Don't save erroneous result. 142 | raise CompilerError( 143 | f"{argument_list!r} exit code {compiling.returncode}\n{stderr}", 144 | command=argument_list, 145 | error_output=force_str(stderr), 146 | ) 147 | 148 | # User wants to see everything that happened. 149 | if self.verbose: 150 | with open(stdout.name, "rb") as out: 151 | print(out.read()) 152 | print(stderr) 153 | except OSError as e: 154 | stdout_captured = None # Don't save erroneous result. 155 | raise CompilerError(e, command=argument_list, error_output=str(e)) 156 | finally: 157 | # Decide what to do with captured stdout. 158 | if stdout: 159 | if stdout_captured: 160 | shutil.move( 161 | stdout.name, os.path.join(cwd or os.curdir, stdout_captured) 162 | ) 163 | else: 164 | os.remove(stdout.name) 165 | -------------------------------------------------------------------------------- /tests/tests/test_forms.py: -------------------------------------------------------------------------------- 1 | from django import get_version as django_version 2 | from django.forms import Media 3 | from django.test import TestCase 4 | 5 | from pipeline.forms import PipelineFormMedia 6 | 7 | from ..utils import pipeline_settings 8 | 9 | 10 | @pipeline_settings( 11 | PIPELINE_COLLECTOR_ENABLED=False, 12 | STYLESHEETS={ 13 | "styles1": { 14 | "source_filenames": ( 15 | "pipeline/css/first.css", 16 | "pipeline/css/second.css", 17 | ), 18 | "output_filename": "styles1.min.css", 19 | }, 20 | "styles2": { 21 | "source_filenames": ("pipeline/css/unicode.css",), 22 | "output_filename": "styles2.min.css", 23 | }, 24 | "print": { 25 | "source_filenames": ("pipeline/css/urls.css",), 26 | "output_filename": "print.min.css", 27 | }, 28 | }, 29 | JAVASCRIPT={ 30 | "scripts1": { 31 | "source_filenames": ( 32 | "pipeline/js/first.js", 33 | "pipeline/js/second.js", 34 | ), 35 | "output_filename": "scripts1.min.js", 36 | }, 37 | "scripts2": { 38 | "source_filenames": ("pipeline/js/application.js",), 39 | "output_filename": "scripts2.min.js", 40 | }, 41 | }, 42 | ) 43 | class PipelineFormMediaTests(TestCase): 44 | """Unit tests for pipeline.forms.PipelineFormMedia.""" 45 | 46 | @pipeline_settings(PIPELINE_ENABLED=True) 47 | def test_css_packages_with_pipeline_enabled(self): 48 | """Testing PipelineFormMedia.css_packages with PIPELINE_ENABLED=True""" 49 | 50 | class MyMedia(PipelineFormMedia): 51 | css_packages = { 52 | "all": ("styles1", "styles2"), 53 | "print": ("print",), 54 | } 55 | 56 | css = {"all": ("extra1.css", "extra2.css")} 57 | 58 | media = Media(MyMedia) 59 | 60 | self.assertEqual( 61 | MyMedia.css, 62 | { 63 | "all": [ 64 | "extra1.css", 65 | "extra2.css", 66 | "/static/styles1.min.css", 67 | "/static/styles2.min.css", 68 | ], 69 | "print": ["/static/print.min.css"], 70 | }, 71 | ) 72 | self.assertEqual(MyMedia.css, media._css) 73 | expected_regex = [ 74 | r''.format(path) 76 | for path in ( 77 | "/static/extra1.css", 78 | "/static/extra2.css", 79 | "/static/styles1.min.css", 80 | "/static/styles2.min.css", 81 | ) 82 | ] + [ 83 | r'' 85 | ] 86 | for rendered_node, expected_node in zip(media.render_css(), expected_regex): 87 | self.assertRegex(rendered_node, expected_node) 88 | 89 | @pipeline_settings(PIPELINE_ENABLED=False) 90 | def test_css_packages_with_pipeline_disabled(self): 91 | """Testing PipelineFormMedia.css_packages with PIPELINE_ENABLED=False""" 92 | 93 | class MyMedia(PipelineFormMedia): 94 | css_packages = { 95 | "all": ("styles1", "styles2"), 96 | "print": ("print",), 97 | } 98 | 99 | css = {"all": ("extra1.css", "extra2.css")} 100 | 101 | media = Media(MyMedia) 102 | 103 | self.assertEqual( 104 | MyMedia.css, 105 | { 106 | "all": [ 107 | "extra1.css", 108 | "extra2.css", 109 | "pipeline/css/first.css", 110 | "pipeline/css/second.css", 111 | "pipeline/css/unicode.css", 112 | ], 113 | "print": ["pipeline/css/urls.css"], 114 | }, 115 | ) 116 | self.assertEqual(MyMedia.css, media._css) 117 | 118 | expected_regex = [ 119 | ''.format(path) 121 | for path in ( 122 | "/static/extra1.css", 123 | "/static/extra2.css", 124 | "/static/pipeline/css/first.css", 125 | "/static/pipeline/css/second.css", 126 | "/static/pipeline/css/unicode.css", 127 | ) 128 | ] + [ 129 | '' 131 | ] 132 | for rendered_node, expected_node in zip(media.render_css(), expected_regex): 133 | self.assertRegex(rendered_node, expected_node) 134 | 135 | @pipeline_settings(PIPELINE_ENABLED=True) 136 | def test_js_packages_with_pipeline_enabled(self): 137 | """Testing PipelineFormMedia.js_packages with PIPELINE_ENABLED=True""" 138 | 139 | class MyMedia(PipelineFormMedia): 140 | js_packages = ("scripts1", "scripts2") 141 | js = ("extra1.js", "extra2.js") 142 | 143 | media = Media(MyMedia) 144 | 145 | if django_version() < "3.1": 146 | script_tag = '' 147 | else: 148 | script_tag = '' 149 | 150 | self.assertEqual( 151 | MyMedia.js, 152 | [ 153 | "extra1.js", 154 | "extra2.js", 155 | "/static/scripts1.min.js", 156 | "/static/scripts2.min.js", 157 | ], 158 | ) 159 | self.assertEqual(MyMedia.js, media._js) 160 | self.assertEqual( 161 | media.render_js(), 162 | [ 163 | script_tag % path 164 | for path in ( 165 | "/static/extra1.js", 166 | "/static/extra2.js", 167 | "/static/scripts1.min.js", 168 | "/static/scripts2.min.js", 169 | ) 170 | ], 171 | ) 172 | 173 | @pipeline_settings(PIPELINE_ENABLED=False) 174 | def test_js_packages_with_pipeline_disabled(self): 175 | """Testing PipelineFormMedia.js_packages with PIPELINE_ENABLED=False""" 176 | 177 | class MyMedia(PipelineFormMedia): 178 | js_packages = ("scripts1", "scripts2") 179 | js = ("extra1.js", "extra2.js") 180 | 181 | media = Media(MyMedia) 182 | 183 | if django_version() < "3.1": 184 | script_tag = '' 185 | else: 186 | script_tag = '' 187 | 188 | self.assertEqual( 189 | MyMedia.js, 190 | [ 191 | "extra1.js", 192 | "extra2.js", 193 | "pipeline/js/first.js", 194 | "pipeline/js/second.js", 195 | "pipeline/js/application.js", 196 | ], 197 | ) 198 | self.assertEqual(MyMedia.js, media._js) 199 | self.assertEqual( 200 | media.render_js(), 201 | [ 202 | script_tag % path 203 | for path in ( 204 | "/static/extra1.js", 205 | "/static/extra2.js", 206 | "/static/pipeline/js/first.js", 207 | "/static/pipeline/js/second.js", 208 | "/static/pipeline/js/application.js", 209 | ) 210 | ], 211 | ) 212 | -------------------------------------------------------------------------------- /docs/compilers.rst: -------------------------------------------------------------------------------- 1 | .. _ref-compilers: 2 | 3 | ========= 4 | Compilers 5 | ========= 6 | 7 | TypeScript compiler 8 | ====================== 9 | 10 | The TypeScript compiler uses `TypeScript `_ 11 | to compile your TypeScript code to JavaScript. 12 | 13 | To use it add this to your ``PIPELINE['COMPILERS']`` :: 14 | 15 | PIPELINE['COMPILERS'] = ( 16 | 'pipeline.compilers.typescript.TypeScriptCompiler', 17 | ) 18 | 19 | ``TYPE_SCRIPT_BINARY`` 20 | --------------------------------- 21 | 22 | Command line to execute for TypeScript program. 23 | You will most likely change this to the location of ``tsc`` on your system. 24 | 25 | Defaults to ``'/usr/bin/env tsc'``. 26 | 27 | ``TYPE_SCRIPT_ARGUMENTS`` 28 | ------------------------------------ 29 | 30 | Additional arguments to use when ``tsc`` is called. 31 | 32 | Defaults to ``''``. 33 | 34 | Coffee Script compiler 35 | ====================== 36 | 37 | The Coffee Script compiler uses `Coffee Script `_ 38 | to compile your javascript. 39 | 40 | To use it add this to your ``PIPELINE['COMPILERS']`` :: 41 | 42 | PIPELINE['COMPILERS'] = ( 43 | 'pipeline.compilers.coffee.CoffeeScriptCompiler', 44 | ) 45 | 46 | ``COFFEE_SCRIPT_BINARY`` 47 | --------------------------------- 48 | 49 | Command line to execute for coffee program. 50 | You will most likely change this to the location of coffee on your system. 51 | 52 | Defaults to ``'/usr/bin/env coffee'``. 53 | 54 | ``COFFEE_SCRIPT_ARGUMENTS`` 55 | ------------------------------------ 56 | 57 | Additional arguments to use when coffee is called. 58 | 59 | Defaults to ``''``. 60 | 61 | Live Script compiler 62 | ====================== 63 | 64 | The LiveScript compiler uses `LiveScript `_ 65 | to compile your javascript. 66 | 67 | To use it add this to your ``PIPELINE['COMPILERS']`` :: 68 | 69 | PIPELINE['COMPILERS'] = ( 70 | 'pipeline.compilers.livescript.LiveScriptCompiler', 71 | ) 72 | 73 | ``LIVE_SCRIPT_BINARY`` 74 | --------------------------------- 75 | 76 | Command line to execute for LiveScript program. 77 | You will most likely change this to the location of lsc on your system. 78 | 79 | Defaults to ``'/usr/bin/env lsc'``. 80 | 81 | ``LIVE_SCRIPT_ARGUMENTS`` 82 | ------------------------------------ 83 | 84 | Additional arguments to use when lsc is called. 85 | 86 | Defaults to ``''``. 87 | 88 | LESS compiler 89 | ============= 90 | 91 | The LESS compiler uses `LESS `_ 92 | to compile your stylesheets. 93 | 94 | To use it add this to your ``PIPELINE['COMPILERS']`` :: 95 | 96 | PIPELINE['COMPILERS'] = ( 97 | 'pipeline.compilers.less.LessCompiler', 98 | ) 99 | 100 | ``LESS_BINARY`` 101 | ------------------------ 102 | 103 | Command line to execute for lessc program. 104 | You will most likely change this to the location of lessc on your system. 105 | 106 | Defaults to ``'/usr/bin/env lessc'``. 107 | 108 | ``LESS_ARGUMENTS`` 109 | --------------------------- 110 | 111 | Additional arguments to use when lessc is called. 112 | 113 | Defaults to ``''``. 114 | 115 | SASS compiler 116 | ============= 117 | 118 | The SASS compiler uses `SASS `_ 119 | to compile your stylesheets. 120 | 121 | To use it add this to your ``PIPELINE['COMPILERS']`` :: 122 | 123 | PIPELINE['COMPILERS'] = ( 124 | 'pipeline.compilers.sass.SASSCompiler', 125 | ) 126 | 127 | 128 | ``SASS_BINARY`` 129 | ------------------------ 130 | 131 | Command line to execute for sass program. 132 | You will most likely change this to the location of sass on your system. 133 | 134 | Defaults to ``'/usr/bin/env sass'``. 135 | 136 | ``SASS_ARGUMENTS`` 137 | --------------------------- 138 | 139 | Additional arguments to use when sass is called. 140 | 141 | Defaults to ``''``. 142 | 143 | 144 | Stylus compiler 145 | =============== 146 | 147 | The Stylus compiler uses `Stylus `_ 148 | to compile your stylesheets. 149 | 150 | To use it add this to your ``PIPELINE['COMPILERS']`` :: 151 | 152 | PIPELINE['COMPILERS'] = ( 153 | 'pipeline.compilers.stylus.StylusCompiler', 154 | ) 155 | 156 | 157 | ``STYLUS_BINARY`` 158 | -------------------------- 159 | 160 | Command line to execute for stylus program. 161 | You will most likely change this to the location of stylus on your system. 162 | 163 | Defaults to ``'/usr/bin/env stylus'``. 164 | 165 | ``STYLUS_ARGUMENTS`` 166 | ----------------------------- 167 | 168 | Additional arguments to use when stylus is called. 169 | 170 | Defaults to ``''``. 171 | 172 | 173 | ES6 compiler 174 | ============ 175 | 176 | The ES6 compiler uses `Babel `_ 177 | to convert ES6+ code into vanilla ES5. 178 | 179 | Note that for files to be transpiled properly they must have the file extension **.es6** 180 | 181 | To use it add this to your ``PIPELINE['COMPILERS']`` :: 182 | 183 | PIPELINE['COMPILERS'] = ( 184 | 'pipeline.compilers.es6.ES6Compiler', 185 | ) 186 | 187 | 188 | ``BABEL_BINARY`` 189 | -------------------------- 190 | 191 | Command line to execute for babel program. 192 | You will most likely change this to the location of babel on your system. 193 | 194 | Defaults to ``'/usr/bin/env babel'``. 195 | 196 | ``BABEL_ARGUMENTS`` 197 | ----------------------------- 198 | 199 | Additional arguments to use when babel is called. 200 | 201 | Defaults to ``''``. 202 | 203 | 204 | Write your own compiler class 205 | ============================= 206 | 207 | You can write your own compiler class, for example if you want to implement other types 208 | of compilers. 209 | 210 | To do so, you just have to create a class that inherits from ``pipeline.compilers.CompilerBase`` 211 | and implements ``match_file`` and ``compile_file`` when needed. 212 | 213 | Finally, specify it in the tuple of compilers ``PIPELINE['COMPILERS']`` in the settings. 214 | 215 | Example 216 | ------- 217 | 218 | A custom compiler for an imaginary compiler called jam :: 219 | 220 | from pipeline.compilers import CompilerBase 221 | 222 | class JamCompiler(CompilerBase): 223 | output_extension = 'js' 224 | 225 | def match_file(self, filename): 226 | return filename.endswith('.jam') 227 | 228 | def compile_file(self, infile, outfile, outdated=False, force=False): 229 | if not outdated and not force: 230 | return # No need to recompiled file 231 | return jam.compile(infile, outfile) 232 | 233 | 234 | 3rd Party Compilers 235 | =================== 236 | 237 | Here is an (in)complete list of 3rd party compilers that integrate with django-pipeline 238 | 239 | Compass (requires RubyGem) 240 | -------------------------- 241 | 242 | :Creator: 243 | `Mila Labs `_ 244 | :Description: 245 | Compass compiler for django-pipeline using the original Ruby gem. 246 | :Link: 247 | `https://github.com/mila-labs/django-pipeline-compass-rubygem` 248 | 249 | Compass (standalone) 250 | -------------------- 251 | 252 | :Creator: 253 | `Vitaly Babiy `_ 254 | :Description: 255 | django-pipeline-compass is a compiler for `django-pipeline `_. Making it really easy to use scss and compass with out requiring the compass gem. 256 | :Link: 257 | `https://github.com/vbabiy/django-pipeline-compass` 258 | 259 | Libsass (standalone) 260 | -------------------- 261 | 262 | :Creator: 263 | `Johanderson Mogollon `_ 264 | :Description: 265 | libsasscompiler is a compiler for `django-pipeline `_. Making it really easy to use scss/sass with the super fast libsass library. 266 | :Link: 267 | `https://github.com/sonic182/libsasscompiler` 268 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | import glob 2 | import os 3 | import shutil 4 | 5 | 6 | def local_path(path): 7 | return os.path.join(os.path.dirname(__file__), path) 8 | 9 | 10 | DATABASES = { 11 | "default": {"ENGINE": "django.db.backends.sqlite3", "TEST_NAME": ":memory:"} 12 | } 13 | 14 | DEBUG = False 15 | 16 | SITE_ID = 1 17 | 18 | INSTALLED_APPS = [ 19 | "django.contrib.contenttypes", 20 | "django.contrib.messages", 21 | "django.contrib.sites", 22 | "django.contrib.sessions", 23 | "django.contrib.staticfiles", 24 | "django.contrib.auth", 25 | "django.contrib.admin", 26 | "pipeline", 27 | "tests.tests", 28 | ] 29 | 30 | 31 | ROOT_URLCONF = "tests.urls" 32 | 33 | MIDDLEWARE = [ 34 | "django.contrib.sessions.middleware.SessionMiddleware", 35 | "django.contrib.auth.middleware.AuthenticationMiddleware", 36 | "django.contrib.messages.middleware.MessageMiddleware", 37 | "django.middleware.common.CommonMiddleware", 38 | "django.middleware.csrf.CsrfViewMiddleware", 39 | ] 40 | 41 | MEDIA_URL = "/media/" 42 | 43 | MEDIA_ROOT = local_path("media") 44 | 45 | STATICFILES_STORAGE = "pipeline.storage.PipelineStorage" 46 | STATIC_ROOT = local_path("static/") 47 | STATIC_URL = "/static/" 48 | STATICFILES_DIRS = (("pipeline", local_path("assets/")),) 49 | STATICFILES_FINDERS = ( 50 | "django.contrib.staticfiles.finders.FileSystemFinder", 51 | "django.contrib.staticfiles.finders.AppDirectoriesFinder", 52 | "pipeline.finders.PipelineFinder", 53 | ) 54 | 55 | SECRET_KEY = "django-pipeline" 56 | 57 | PIPELINE = { 58 | "PIPELINE_ENABLED": True, 59 | "JS_COMPRESSOR": None, 60 | "CSS_COMPRESSOR": None, 61 | "STYLESHEETS": { 62 | "screen": { 63 | "source_filenames": ( 64 | "pipeline/css/first.css", 65 | "pipeline/css/second.css", 66 | "pipeline/css/urls.css", 67 | ), 68 | "output_filename": "screen.css", 69 | }, 70 | "screen_media": { 71 | "source_filenames": ( 72 | "pipeline/css/first.css", 73 | "pipeline/css/second.css", 74 | "pipeline/css/urls.css", 75 | ), 76 | "output_filename": "screen_media.css", 77 | "extra_context": { 78 | "media": "screen and (min-width:500px)", 79 | }, 80 | }, 81 | "screen_title": { 82 | "source_filenames": ( 83 | "pipeline/css/first.css", 84 | "pipeline/css/second.css", 85 | "pipeline/css/urls.css", 86 | ), 87 | "output_filename": "screen_title.css", 88 | "extra_context": { 89 | "title": "Default Style", 90 | }, 91 | }, 92 | }, 93 | "JAVASCRIPT": { 94 | "scripts": { 95 | "source_filenames": ( 96 | "pipeline/js/first.js", 97 | "pipeline/js/second.js", 98 | "pipeline/js/application.js", 99 | "pipeline/templates/**/*.jst", 100 | ), 101 | "output_filename": "scripts.js", 102 | }, 103 | "scripts_async": { 104 | "source_filenames": ( 105 | "pipeline/js/first.js", 106 | "pipeline/js/second.js", 107 | "pipeline/js/application.js", 108 | "pipeline/templates/**/*.jst", 109 | ), 110 | "output_filename": "scripts_async.js", 111 | "extra_context": { 112 | "async": True, 113 | }, 114 | }, 115 | "scripts_defer": { 116 | "source_filenames": ( 117 | "pipeline/js/first.js", 118 | "pipeline/js/second.js", 119 | "pipeline/js/application.js", 120 | "pipeline/templates/**/*.jst", 121 | ), 122 | "output_filename": "scripts_defer.js", 123 | "extra_context": { 124 | "defer": True, 125 | }, 126 | }, 127 | "scripts_async_defer": { 128 | "source_filenames": ( 129 | "pipeline/js/first.js", 130 | "pipeline/js/second.js", 131 | "pipeline/js/application.js", 132 | "pipeline/templates/**/*.jst", 133 | ), 134 | "output_filename": "scripts_async_defer.js", 135 | "extra_context": { 136 | "async": True, 137 | "defer": True, 138 | }, 139 | }, 140 | }, 141 | } 142 | 143 | NODE_MODULES_PATH = local_path("../node_modules") 144 | NODE_BIN_PATH = os.path.join(NODE_MODULES_PATH, ".bin") 145 | NODE_EXE_PATH = shutil.which("node") 146 | JAVA_EXE_PATH = shutil.which("java") 147 | CSSTIDY_EXE_PATH = shutil.which("csstidy") 148 | HAS_NODE = bool(NODE_EXE_PATH) 149 | HAS_JAVA = bool(JAVA_EXE_PATH) 150 | HAS_CSSTIDY = bool(CSSTIDY_EXE_PATH) 151 | 152 | if HAS_NODE: 153 | 154 | def node_exe_path(command): 155 | exe_ext = ".cmd" if os.name == "nt" else "" 156 | return os.path.join(NODE_BIN_PATH, "{}{}".format(command, exe_ext)) 157 | 158 | PIPELINE.update( 159 | { 160 | "SASS_BINARY": node_exe_path("sass"), 161 | "COFFEE_SCRIPT_BINARY": node_exe_path("coffee"), 162 | "COFFEE_SCRIPT_ARGUMENTS": ["--no-header"], 163 | "LESS_BINARY": node_exe_path("lessc"), 164 | "BABEL_BINARY": node_exe_path("babel"), 165 | "BABEL_ARGUMENTS": ["--presets", "es2015"], 166 | "STYLUS_BINARY": node_exe_path("stylus"), 167 | "LIVE_SCRIPT_BINARY": node_exe_path("lsc"), 168 | "LIVE_SCRIPT_ARGUMENTS": ["--no-header"], 169 | "YUGLIFY_BINARY": node_exe_path("yuglify"), 170 | "UGLIFYJS_BINARY": node_exe_path("uglifyjs"), 171 | "TERSER_BINARY": node_exe_path("terser"), 172 | "CSSMIN_BINARY": node_exe_path("cssmin"), 173 | "TYPE_SCRIPT_BINARY": node_exe_path("tsc"), 174 | } 175 | ) 176 | 177 | if HAS_NODE and HAS_JAVA: 178 | PIPELINE.update( 179 | { 180 | "CLOSURE_BINARY": [ 181 | JAVA_EXE_PATH, 182 | "-jar", 183 | os.path.join( 184 | NODE_MODULES_PATH, 185 | "google-closure-compiler-java", 186 | "compiler.jar", 187 | ), 188 | ], 189 | "YUI_BINARY": [ 190 | JAVA_EXE_PATH, 191 | "-jar", 192 | glob.glob( 193 | os.path.join(NODE_MODULES_PATH, "yuicompressor", "build", "*.jar") 194 | )[0], 195 | ], 196 | } 197 | ) 198 | 199 | if HAS_CSSTIDY: 200 | PIPELINE.update({"CSSTIDY_BINARY": CSSTIDY_EXE_PATH}) 201 | 202 | TEMPLATES = [ 203 | { 204 | "BACKEND": "django.template.backends.django.DjangoTemplates", 205 | "APP_DIRS": True, 206 | "DIRS": [local_path("templates")], 207 | "OPTIONS": { 208 | "context_processors": [ 209 | "django.template.context_processors.request", 210 | "django.contrib.auth.context_processors.auth", 211 | "django.contrib.messages.context_processors.messages", 212 | ] 213 | }, 214 | }, 215 | { 216 | "BACKEND": "django.template.backends.jinja2.Jinja2", 217 | "APP_DIRS": True, 218 | "DIRS": [local_path("templates")], 219 | "OPTIONS": {"extensions": ["pipeline.jinja2.PipelineExtension"]}, 220 | }, 221 | ] 222 | 223 | LOGGING = { 224 | "version": 1, 225 | "disable_existing_loggers": False, 226 | "handlers": { 227 | "console": { 228 | "class": "logging.StreamHandler", 229 | }, 230 | }, 231 | "loggers": { 232 | "pipeline.templatetags.pipeline": { 233 | "handlers": ["console"], 234 | "level": "ERROR", 235 | }, 236 | }, 237 | } 238 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Pipeline documentation build configuration file, created by 2 | # sphinx-quickstart on Sat Apr 30 17:47:55 2011. 3 | # 4 | # This file is execfile()d with the current directory set to its containing dir. 5 | # 6 | # Note that not all possible configuration values are present in this 7 | # autogenerated file. 8 | # 9 | # All configuration values have a default; values that are commented out 10 | # serve to show the default. 11 | 12 | # If extensions (or modules to document with autodoc) are in another directory, 13 | # add these directories to sys.path here. If the directory is relative to the 14 | # documentation root, use os.path.abspath to make it absolute, like shown here. 15 | # sys.path.insert(0, os.path.abspath('.')) 16 | from datetime import datetime 17 | 18 | from pipeline import __version__ as pipeline_version 19 | 20 | # -- General configuration ----------------------------------------------------- 21 | 22 | # If your documentation needs a minimal Sphinx version, state it here. 23 | # needs_sphinx = '1.0' 24 | 25 | # Add any Sphinx extension module names here, as strings. They can be extensions 26 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 27 | extensions = [] 28 | 29 | # Add any paths that contain templates here, relative to this directory. 30 | templates_path = ["_templates"] 31 | 32 | # The suffix of source filenames. 33 | source_suffix = ".rst" 34 | 35 | # The encoding of source files. 36 | # source_encoding = 'utf-8-sig' 37 | 38 | # The master toctree document. 39 | master_doc = "index" 40 | 41 | # General information about the project. 42 | project = "django-pipeline" 43 | current_year = datetime.now().year 44 | copyright = "2011-{}, Timothée Peignier".format(current_year) 45 | 46 | # The version info for the project you're documenting, acts as replacement for 47 | # |version| and |release|, also used in various other places throughout the 48 | # built documents. 49 | # 50 | # The full version, including alpha/beta/rc tags. 51 | release = pipeline_version 52 | # The short X.Y version. 53 | version = ".".join(release.split(".")[:2]) 54 | 55 | # The language for content autogenerated by Sphinx. Refer to documentation 56 | # for a list of supported languages. 57 | # language = None 58 | 59 | # There are two options for replacing |today|: either, you set today to some 60 | # non-false value, then it is used: 61 | # today = '' 62 | # Else, today_fmt is used as the format for a strftime call. 63 | # today_fmt = '%B %d, %Y' 64 | 65 | # List of patterns, relative to source directory, that match files and 66 | # directories to ignore when looking for source files. 67 | exclude_patterns = ["_build"] 68 | 69 | # The reST default role (used for this markup: `text`) to use for all documents. 70 | # default_role = None 71 | 72 | # If true, '()' will be appended to :func: etc. cross-reference text. 73 | # add_function_parentheses = True 74 | 75 | # If true, the current module name will be prepended to all description 76 | # unit titles (such as .. function::). 77 | # add_module_names = True 78 | 79 | # If true, sectionauthor and moduleauthor directives will be shown in the 80 | # output. They are ignored by default. 81 | # show_authors = False 82 | 83 | # The name of the Pygments (syntax highlighting) style to use. 84 | pygments_style = "sphinx" 85 | 86 | # A list of ignored prefixes for module index sorting. 87 | # modindex_common_prefix = [] 88 | 89 | 90 | # -- Options for HTML output --------------------------------------------------- 91 | 92 | # The theme to use for HTML and HTML Help pages. See the documentation for 93 | # a list of builtin themes. 94 | html_theme = "default" 95 | 96 | # Theme options are theme-specific and customize the look and feel of a theme 97 | # further. For a list of options available for each theme, see the 98 | # documentation. 99 | # html_theme_options = {} 100 | 101 | # Add any paths that contain custom themes here, relative to this directory. 102 | # html_theme_path = [] 103 | 104 | # The name for this set of Sphinx documents. If None, it defaults to 105 | # " v documentation". 106 | # html_title = None 107 | 108 | # A shorter title for the navigation bar. Default is the same as html_title. 109 | # html_short_title = None 110 | 111 | # The name of an image file (relative to this directory) to place at the top 112 | # of the sidebar. 113 | # html_logo = None 114 | 115 | # The name of an image file (within the static path) to use as favicon of the 116 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 117 | # pixels large. 118 | # html_favicon = None 119 | 120 | # Add any paths that contain custom static files (such as style sheets) here, 121 | # relative to this directory. They are copied after the builtin static files, 122 | # so a file named "default.css" will overwrite the builtin "default.css". 123 | # html_static_path = ['_static'] 124 | 125 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 126 | # using the given strftime format. 127 | # html_last_updated_fmt = '%b %d, %Y' 128 | 129 | # If true, SmartyPants will be used to convert quotes and dashes to 130 | # typographically correct entities. 131 | # html_use_smartypants = True 132 | 133 | # Custom sidebar templates, maps document names to template names. 134 | # html_sidebars = {} 135 | 136 | # Additional templates that should be rendered to pages, maps page names to 137 | # template names. 138 | # html_additional_pages = {} 139 | 140 | # If false, no module index is generated. 141 | # html_domain_indices = True 142 | 143 | # If false, no index is generated. 144 | # html_use_index = True 145 | 146 | # If true, the index is split into individual pages for each letter. 147 | # html_split_index = False 148 | 149 | # If true, links to the reST sources are added to the pages. 150 | # html_show_sourcelink = True 151 | 152 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 153 | # html_show_sphinx = True 154 | 155 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 156 | # html_show_copyright = True 157 | 158 | # If true, an OpenSearch description file will be output, and all pages will 159 | # contain a tag referring to it. The value of this option must be the 160 | # base URL from which the finished HTML is served. 161 | # html_use_opensearch = '' 162 | 163 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 164 | # html_file_suffix = None 165 | 166 | # Output file base name for HTML help builder. 167 | htmlhelp_basename = "django-pipelinedoc" 168 | 169 | 170 | # -- Options for LaTeX output -------------------------------------------------- 171 | 172 | # The paper size ('letter' or 'a4'). 173 | # latex_paper_size = 'letter' 174 | 175 | # The font size ('10pt', '11pt' or '12pt'). 176 | # latex_font_size = '10pt' 177 | 178 | # Grouping the document tree into LaTeX files. List of tuples 179 | # (source start file, target name, title, author, documentclass [howto/manual]). 180 | latex_documents = [ 181 | ( 182 | "index", 183 | "django-pipeline.tex", 184 | "Pipeline Documentation", 185 | "Timothée Peignier", 186 | "manual", 187 | ), 188 | ] 189 | 190 | # The name of an image file (relative to this directory) to place at the top of 191 | # the title page. 192 | # latex_logo = None 193 | 194 | # For "manual" documents, if this is true, then toplevel headings are parts, 195 | # not chapters. 196 | # latex_use_parts = False 197 | 198 | # If true, show page references after internal links. 199 | # latex_show_pagerefs = False 200 | 201 | # If true, show URL addresses after external links. 202 | # latex_show_urls = False 203 | 204 | # Additional stuff for the LaTeX preamble. 205 | # latex_preamble = '' 206 | 207 | # Documents to append as an appendix to all manuals. 208 | # latex_appendices = [] 209 | 210 | # If false, no module index is generated. 211 | # latex_domain_indices = True 212 | 213 | 214 | # -- Options for manual page output -------------------------------------------- 215 | 216 | # One entry per manual page. List of tuples 217 | # (source start file, name, description, authors, manual section). 218 | man_pages = [ 219 | ("index", "django-pipeline", "Pipeline Documentation", ["Timothée Peignier"], 1) 220 | ] 221 | -------------------------------------------------------------------------------- /pipeline/templatetags/pipeline.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import re 3 | import subprocess 4 | 5 | from django import template 6 | from django.contrib.staticfiles.storage import staticfiles_storage 7 | from django.template.base import VariableDoesNotExist 8 | from django.template.loader import render_to_string 9 | from django.utils.safestring import mark_safe 10 | 11 | from ..collector import default_collector 12 | from ..conf import settings 13 | from ..exceptions import CompilerError 14 | from ..packager import PackageNotFound, Packager 15 | from ..utils import guess_type 16 | 17 | logger = logging.getLogger(__name__) 18 | 19 | register = template.Library() 20 | 21 | 22 | class PipelineMixin: 23 | request = None 24 | _request_var = None 25 | 26 | @property 27 | def request_var(self): 28 | if not self._request_var: 29 | self._request_var = template.Variable("request") 30 | return self._request_var 31 | 32 | def package_for(self, package_name, package_type): 33 | package = { 34 | "js": getattr(settings, "JAVASCRIPT", {}).get(package_name, {}), 35 | "css": getattr(settings, "STYLESHEETS", {}).get(package_name, {}), 36 | }[package_type] 37 | 38 | if package: 39 | package = {package_name: package} 40 | 41 | packager = { 42 | "js": Packager(css_packages={}, js_packages=package), 43 | "css": Packager(css_packages=package, js_packages={}), 44 | }[package_type] 45 | 46 | return packager.package_for(package_type, package_name) 47 | 48 | def render(self, context): 49 | try: 50 | self.request = self.request_var.resolve(context) 51 | except VariableDoesNotExist: 52 | pass 53 | 54 | def render_compressed(self, package, package_name, package_type): 55 | """Render HTML for the package. 56 | 57 | If ``PIPELINE_ENABLED`` is ``True``, this will render the package's 58 | output file (using :py:meth:`render_compressed_output`). Otherwise, 59 | this will render the package's source files (using 60 | :py:meth:`render_compressed_sources`). 61 | 62 | Subclasses can override this method to provide custom behavior for 63 | determining what to render. 64 | """ 65 | if settings.PIPELINE_ENABLED: 66 | return self.render_compressed_output(package, package_name, package_type) 67 | else: 68 | return self.render_compressed_sources(package, package_name, package_type) 69 | 70 | def render_compressed_output(self, package, package_name, package_type): 71 | """Render HTML for using the package's output file. 72 | 73 | Subclasses can override this method to provide custom behavior for 74 | rendering the output file. 75 | """ 76 | method = getattr(self, f"render_{package_type}") 77 | 78 | return method(package, package.output_filename) 79 | 80 | def render_compressed_sources(self, package, package_name, package_type): 81 | """Render HTML for using the package's list of source files. 82 | 83 | Each source file will first be collected, if 84 | ``PIPELINE_COLLECTOR_ENABLED`` is ``True``. 85 | 86 | If there are any errors compiling any of the source files, an 87 | ``SHOW_ERRORS_INLINE`` is ``True``, those errors will be shown at 88 | the top of the page. 89 | 90 | Subclasses can override this method to provide custom behavior for 91 | rendering the source files. 92 | """ 93 | if settings.PIPELINE_COLLECTOR_ENABLED: 94 | default_collector.collect(self.request) 95 | 96 | packager = Packager() 97 | method = getattr(self, f"render_individual_{package_type}") 98 | 99 | try: 100 | paths = packager.compile(package.paths) 101 | except CompilerError as e: 102 | if settings.SHOW_ERRORS_INLINE: 103 | method = getattr(self, f"render_error_{package_type}") 104 | return method(package_name, e) 105 | else: 106 | raise 107 | 108 | templates = packager.pack_templates(package) 109 | 110 | return method(package, paths, templates=templates) 111 | 112 | def render_error(self, package_type, package_name, e): 113 | # Remove any ANSI escape sequences in the output. 114 | error_output = re.sub( 115 | re.compile(r"(?:\x1B[@-_]|[\x80-\x9F])[0-?]*[ -/]*[@-~]"), 116 | "", 117 | e.error_output, 118 | ) 119 | 120 | return render_to_string( 121 | "pipeline/compile_error.html", 122 | { 123 | "package_type": package_type, 124 | "package_name": package_name, 125 | "command": subprocess.list2cmdline(e.command), 126 | "errors": error_output, 127 | }, 128 | ) 129 | 130 | 131 | class StylesheetNode(PipelineMixin, template.Node): 132 | def __init__(self, name): 133 | self.name = name 134 | 135 | def render(self, context): 136 | super().render(context) 137 | package_name = template.Variable(self.name).resolve(context) 138 | 139 | try: 140 | package = self.package_for(package_name, "css") 141 | except PackageNotFound: 142 | w = "Package %r is unknown. Check PIPELINE['STYLESHEETS'] in your settings." 143 | logger.warning(w, package_name) 144 | # fail silently, do not return anything if an invalid group is specified 145 | return "" 146 | return self.render_compressed(package, package_name, "css") 147 | 148 | def render_css(self, package, path): 149 | template_name = package.template_name or "pipeline/css.html" 150 | context = package.extra_context 151 | context.update( 152 | { 153 | "type": guess_type(path, "text/css"), 154 | "url": mark_safe(staticfiles_storage.url(path)), 155 | } 156 | ) 157 | return render_to_string(template_name, context) 158 | 159 | def render_individual_css(self, package, paths, **kwargs): 160 | tags = [self.render_css(package, path) for path in paths] 161 | return "\n".join(tags) 162 | 163 | def render_error_css(self, package_name, e): 164 | return super().render_error("CSS", package_name, e) 165 | 166 | 167 | class JavascriptNode(PipelineMixin, template.Node): 168 | def __init__(self, name): 169 | self.name = name 170 | 171 | def render(self, context): 172 | super().render(context) 173 | package_name = template.Variable(self.name).resolve(context) 174 | 175 | try: 176 | package = self.package_for(package_name, "js") 177 | except PackageNotFound: 178 | w = "Package %r is unknown. Check PIPELINE['JAVASCRIPT'] in your settings." 179 | logger.warning(w, package_name) 180 | # fail silently, do not return anything if an invalid group is specified 181 | return "" 182 | return self.render_compressed(package, package_name, "js") 183 | 184 | def render_js(self, package, path): 185 | template_name = package.template_name or "pipeline/js.html" 186 | context = package.extra_context 187 | context.update( 188 | { 189 | "type": guess_type(path, "text/javascript"), 190 | "url": mark_safe(staticfiles_storage.url(path)), 191 | } 192 | ) 193 | return render_to_string(template_name, context) 194 | 195 | def render_inline(self, package, js): 196 | context = package.extra_context 197 | context.update({"source": js}) 198 | return render_to_string("pipeline/inline_js.html", context) 199 | 200 | def render_individual_js(self, package, paths, templates=None): 201 | tags = [self.render_js(package, js) for js in paths] 202 | if templates: 203 | tags.append(self.render_inline(package, templates)) 204 | return "\n".join(tags) 205 | 206 | def render_error_js(self, package_name, e): 207 | return super().render_error("JavaScript", package_name, e) 208 | 209 | 210 | @register.tag 211 | def stylesheet(parser, token): 212 | try: 213 | tag_name, name = token.split_contents() 214 | except ValueError: 215 | e = ( 216 | "%r requires exactly one argument: the name " 217 | "of a group in the PIPELINE.STYLESHEETS setting" 218 | ) 219 | raise template.TemplateSyntaxError(e % token.split_contents()[0]) 220 | return StylesheetNode(name) 221 | 222 | 223 | @register.tag 224 | def javascript(parser, token): 225 | try: 226 | tag_name, name = token.split_contents() 227 | except ValueError: 228 | e = ( 229 | "%r requires exactly one argument: the name " 230 | "of a group in the PIPELINE.JAVASVRIPT setting" 231 | ) 232 | raise template.TemplateSyntaxError(e % token.split_contents()[0]) 233 | return JavascriptNode(name) 234 | -------------------------------------------------------------------------------- /HISTORY.rst: -------------------------------------------------------------------------------- 1 | .. :changelog: 2 | 3 | History 4 | ======= 5 | 6 | 4.1.0 7 | ===== 8 | * Add support for Python 3.13 9 | * Add support for Django 5.2 10 | 11 | 4.0.0 12 | ===== 13 | * Drop support for Python 3.8 14 | * Confirm support for Django 5.1 and drop support for Django 3.2 15 | * Use pyproject.toml 16 | 17 | 3.1.0 18 | ===== 19 | 20 | * Replace deprecated .warn method with .warning 21 | * Update sourcemap paths when concatenating source files 22 | * Ensure correct compiler error styling and strip ANSI escape sequences 23 | 24 | 3.0.0 25 | ===== 26 | 27 | * Use Pypy 3.10 28 | * Drop support for Python 3.7 29 | * Drop support for Django 2 30 | * Add Python 3.12 support 31 | * Add Django 4.2 support 32 | * Add Django 5.0 support 33 | 34 | 2.1.0 35 | ===== 36 | 37 | * Update README.rst and add Pipeline overview image. 38 | * Add TypeScript compiler support. 39 | * Drop support for ``manifesto`` package. 40 | * Add support for Python 3.11 and Django 4.1 41 | 42 | 43 | 2.0.9 44 | ===== 45 | 46 | * Fixed some typos in the docs. 47 | * Fixed string type of errors reported from compilers and compressors. 48 | * Updated github actions matrix for host and django support. 49 | * Updated github actions configuration to use modern versions of third-party 50 | actions. 51 | * Improved the packager to copy files to (S3) storage if it does not exist 52 | (#502). 53 | 54 | 55 | 2.0.8 56 | ===== 57 | 58 | * Added **Django 4.0** compatibility. Thanks to @kevinmarsh (#760) 59 | * Add tests for **Django 4.0**, **Python 3.9** and **Python 3.10**. 60 | Thank to @kevinmarsh (#739) 61 | * Introduce CODE_OF_CONDUCT.md for the project. Thank to @hugovk (#758) 62 | * Add precision in the documentation for PipelineCachedStorage. 63 | Thank to @gatsinski (#739) 64 | * Drop support for slimit compressor (#765) due to package not released 65 | an official version for Python 3 and not any new package release from 2013. 66 | * Edit github actions matrix: django 3.2.9 support python 3.10, remove 67 | python 4.0 (doesn't exist) and exclude pypy-3.8 for django-main. 68 | * Add .pre-commit-config.yaml. Thanks to @hugovk (#762) 69 | * Update package.json due to CoffeeScript on NPM has moved to "coffeescript" 70 | * Update setup.py with Django 4.0 and Python 3.10 71 | 72 | 2.0.7 73 | ===== 74 | 75 | * Added **Django 3.2** compatibility (Thanks to @jramnai in #751) 76 | 77 | 2.0.6 78 | ====== 79 | 80 | * Added terser (JS compressor for ES5 and ES6) (Thanks to @felix-last in #696) 81 | * Moved tests to GitHub Actions: https://github.com/jazzband/django-pipeline/actions (#738) 82 | * Fixed deprecation warnings from Django (Thanks to @edelvalle in #731) 83 | 84 | 2.0.5 85 | ====== 86 | 87 | * Adding **Django 3.1** compatibility. 88 | * CachedStaticFilesStorage is removed from Django. Add a check 89 | of the current version to prevent error while importing. Thank to @vmsp 90 | * Context in django.template.base is removed from Django and 91 | not used anymore in django-pipeline. 92 | * Fixing widgets tests of django-pipeline due to Media.render_js change in 93 | Django. More information in Django ticket #31892 94 | 95 | 2.0.4 96 | ====== 97 | 98 | * Adding **css-html-js-minify** support to compress JS and CSS. 99 | * Update compressors documentation with css-html-js-minify. 100 | * Create tests for css-html-js-minify compressor. 101 | * Optimization by grouping the tests yuglify compressor. 102 | 103 | 2.0.3 104 | ====== 105 | 106 | * Remove futures from pipeline **setup.py** requirements. 107 | 108 | 2.0.2 109 | ===== 110 | 111 | * Fix Middleware to properly decode HTML. Thank to @gatsinski 112 | * Keep mimetypes as str. Thank to @benspaulding 113 | * Based on #642 add 'NonPackagingPipelineManifestStorage' and update 114 | the documentation: **storages.rst**. Thank to @kronion 115 | 116 | 2.0.1 117 | ===== 118 | 119 | * Add subclass of ManifestStaticFilesStorage. Thank to @jhpinson 120 | * Change the documentation to use PipelineManifestStorage in configuration 121 | instead of PipelineCachedStorage now deprecated. 122 | * Change import MutableMapping from collections.abc. Thank to @colons 123 | 124 | 2.0.0 125 | ===== 126 | 127 | * **Definitely drop the support of Python 2**. 128 | * Drop support for Python 3.5 (not compatible with PEP 498). 129 | * Remove 'decorator.py' how was used for backward compatibility 130 | between python 2 and 3 for metaclass inheritance on PipelineFormMedia. 131 | * Replace 'format' by 'fstring' (PEP 498: Literal String Interpolation). 132 | * Remove of old imports form 'django.utils.six' and these fixes (1.7.0). 133 | * Remove tests of uncovered versions of Python and Django. 134 | * Replace tests for Pypy by Pypy3. 135 | * Explicitly specify when files are read / write in binary mode. 136 | * Set opening files for tests to deal with universal newlines. 137 | * Upgrade documentation version to 2.0 to follow the project version. 138 | 139 | 1.7.0 140 | ===== 141 | 142 | * Release the last major version of django-pipeline working on Python 2. 143 | * Thank you for all the modifications made since version 1.6.14, which we cannot quote. 144 | * Apply an optimization to save time during development. Thank to @blankser 145 | * Edit setup.py to follow the recommendation of the documentation. Thank to @shaneikennedy 146 | * Add tests for Django 3.0 and Python 3.8 147 | * Add alternatives imports for django.utils.six, who has been removed in Django 3.0 148 | 149 | 1.6.14 150 | ====== 151 | 152 | * Fix packaging issues. 153 | 154 | 1.6.13 155 | ====== 156 | 157 | * Fix forward-slashed paths on Windows. Thanks to @etiago 158 | * Fix CSS URL detector to match quotes correctly. Thanks to @vskh 159 | * Add a compiler_options dict to compile, to allow passing options to custom 160 | compilers. Thanks to @sassanh 161 | * Verify support for Django 1.11. Thanks to @jwhitlock 162 | 163 | 1.6.12 164 | ====== 165 | 166 | * Supports Django 1.11 167 | * Fix a bug with os.rename on windows. Thanks to @wismill 168 | * Fix to view compile error if happens. Thanks to @brawaga 169 | * Add support for Pipeline CSS/JS packages in forms and widgets. Thanks to @chipx86 170 | 171 | 1.6.11 172 | ====== 173 | 174 | * Fix performance regression. Thanks to Christian Hammond. 175 | 176 | 1.6.10 177 | ====== 178 | 179 | * Added Django 1.10 compatiblity issues. Thanks to Austin Pua and Silvan Spross. 180 | * Documentation improvements. Thanks to Chris Streeter. 181 | 182 | 1.6.9 183 | ===== 184 | 185 | * Various build improvements. 186 | * Improved setup.py classifiers. Thanks to Sobolev Nikita. 187 | * Documentation improvements. Thanks to Adam Chainz. 188 | 189 | 1.6.8 190 | ===== 191 | 192 | * Made templatetags easier to subclass for special rendering behavior. Thanks 193 | to Christian Hammond. 194 | * Updated the link to readthedocs. Thanks to Corey Farwell. 195 | * Fixed some log messages to correctly refer to the new PIPELINE settings 196 | tructure. Thanks to Alvin Mites. 197 | * Changed file outdated checks to use os.path methods directly, avoiding 198 | potential SuspiciousFileOperation errors which could appear with some django 199 | storage configurations. 200 | 201 | 1.6.7 202 | ===== 203 | 204 | * Add a view for collecting static files before serving them. This behaves like 205 | django's built-in ``static`` view and allows running the collector for 206 | images, fonts, and other static files that do not need to be compiled. Thanks 207 | to Christian Hammond. 208 | * Update documentation for the ES6Compiler to clarify filename requirements. 209 | Thanks to Nathan Cox. 210 | * Add error output for compiler errors within the browser. This provides for a 211 | much better experience when compiling files from the devserver. Thanks to 212 | Christian Hammond. 213 | * Make unit tests run against Django 1.6 and 1.7. Thanks to Sławek Ehlert. 214 | 215 | 1.6.6 216 | ===== 217 | 218 | * Fix filtering-out of files which require a finder to locate. 219 | * Allow compilers to override the output path. 220 | * Fix error reporting when a compiler fails to execute. 221 | * Fix IOErrors when running collectstatic with some nodejs-based compilers and 222 | compressors. Thanks to Frankie Dintino. 223 | * Fix compatibility of unit tests when running on Windows. Thanks to Frankie 224 | Dintino. 225 | * Add unit tests for compilers and compressors. Thanks to Frankie Dintino. 226 | 227 | 1.6.5 228 | ===== 229 | 230 | * Fix Django < 1.8 compatibility. Thanks to David Trowbridge. 231 | * Allow to disable collector during development. Thanks to Leonardo Orozco. 232 | 233 | 1.6.4 234 | ===== 235 | 236 | * Fix compressor subprocess calls. 237 | 238 | 1.6.3 239 | ===== 240 | 241 | * Fix compressor command flattening. 242 | 243 | 1.6.2 244 | ===== 245 | 246 | * Remove subprocess32 usage since it breaks universal support. 247 | 248 | 1.6.1 249 | ===== 250 | 251 | * Fix path quoting issues. Thanks to Chad Miller. 252 | * Use subprocess32 package when possible. 253 | * Documentation fixes. Thanks to Sławek Ehlert and Jannis Leidel. 254 | 255 | 1.6.0 256 | ===== 257 | 258 | * Add full support for Django 1.9. 259 | * Drop support for Django 1.7. 260 | * Drop support for Python 2.6. 261 | * **BACKWARD INCOMPATIBLE** : Change configuration settings. 262 | -------------------------------------------------------------------------------- /docs/configuration.rst: -------------------------------------------------------------------------------- 1 | .. _ref-configuration: 2 | 3 | ============= 4 | Configuration 5 | ============= 6 | 7 | 8 | Configuration and list of available settings for Pipeline. Pipeline settings are namespaced in a PIPELINE dictionary in your project settings, e.g.: :: 9 | 10 | PIPELINE = { 11 | 'PIPELINE_ENABLED': True, 12 | 'JAVASCRIPT': { 13 | 'stats': { 14 | 'source_filenames': ( 15 | 'js/jquery.js', 16 | 'js/d3.js', 17 | 'js/collections/*.js', 18 | 'js/application.js', 19 | ), 20 | 'output_filename': 'js/stats.js', 21 | } 22 | } 23 | } 24 | 25 | 26 | Specifying files 27 | ================ 28 | 29 | You specify groups of files to be compressed in your settings. You can use glob 30 | syntax to select multiples files. 31 | 32 | The basic syntax for specifying CSS/JavaScript groups files is :: 33 | 34 | PIPELINE = { 35 | 'STYLESHEETS': { 36 | 'colors': { 37 | 'source_filenames': ( 38 | 'css/core.css', 39 | 'css/colors/*.css', 40 | 'css/layers.css' 41 | ), 42 | 'output_filename': 'css/colors.css', 43 | 'extra_context': { 44 | 'media': 'screen,projection', 45 | }, 46 | }, 47 | }, 48 | 'JAVASCRIPT': { 49 | 'stats': { 50 | 'source_filenames': ( 51 | 'js/jquery.js', 52 | 'js/d3.js', 53 | 'js/collections/*.js', 54 | 'js/application.js', 55 | ), 56 | 'output_filename': 'js/stats.js', 57 | } 58 | } 59 | } 60 | 61 | Group options 62 | ------------- 63 | 64 | ``source_filenames`` 65 | .................... 66 | 67 | **Required** 68 | 69 | Is a tuple with the source files to be compressed. 70 | The files are concatenated in the order specified in the tuple. 71 | 72 | 73 | ``output_filename`` 74 | ................... 75 | 76 | **Required** 77 | 78 | Is the filename of the (to be) compressed file. 79 | 80 | ``variant`` 81 | ........... 82 | 83 | **Optional** 84 | 85 | Is the variant you want to apply to your CSS. This allow you to embed images 86 | and fonts in CSS with data-URI. 87 | Allowed values are : ``None`` and ``datauri``. 88 | 89 | Defaults to ``None``. 90 | 91 | ``template_name`` 92 | ................. 93 | 94 | **Optional** 95 | 96 | Name of the template used to render ``