-
Notifications
You must be signed in to change notification settings - Fork 0
/
bundler.py
281 lines (221 loc) · 9.82 KB
/
bundler.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
# media_bundler/bundle.py
from __future__ import with_statement
import math
import os
import shutil
import subprocess
import re
from StringIO import StringIO
from conf import bundler_settings
from bin_packing import Box, pack_boxes
from jsmin import jsmin
from cssmin import minify_css
import versioning
class InvalidBundleType(Exception):
def __init__(self, type_):
msg = "Invalid bundle type: %r" % type_
super(InvalidBundleType, self).__init__(msg)
def concatenate_files(paths):
"""Generate the contents of several files in 8K blocks."""
for path in paths:
with open(path) as input:
buffer = input.read(8192)
while buffer:
yield buffer
buffer = input.read(8192)
class Bundle(object):
"""Base class for a bundle of media files.
A bundle is a collection of related static files that can be concatenated
together and served as a single file to improve performance.
"""
def __init__(self, name, path, url, files, type):
self.name = name
self.path = path
self.url = url
if not url.endswith("/"):
raise ValueError("Bundle URLs must end with a '/'.")
self.files = files
self.type = type
@classmethod
def check_attr(cls, attrs, attr):
errmsg = "Invalid bundle: %r attribute %r required." % (attrs, attr)
assert attr in attrs, errmsg
@classmethod
def from_dict(cls, attrs):
for attr in ("type", "name", "path", "url", "files"):
cls.check_attr(attrs, attr)
if attrs["type"] == "javascript":
return JavascriptBundle(attrs["name"], attrs["path"], attrs["url"],
attrs["files"], attrs["type"],
attrs.get("minify", False))
elif attrs["type"] == "css":
return CssBundle(attrs["name"], attrs["path"], attrs["url"],
attrs["files"], attrs["type"],
attrs.get("minify", False))
elif attrs["type"] == "png-sprite":
cls.check_attr(attrs, "css_file")
return PngSpriteBundle(attrs["name"], attrs["path"], attrs["url"],
attrs["files"], attrs["type"],
attrs["css_file"])
else:
raise InvalidBundleType(attrs["type"])
def get_paths(self):
return [os.path.join(self.path, f) for f in self.files]
def get_extension(self):
raise NotImplementedError
def get_bundle_filename(self):
return self.name + self.get_extension()
def get_bundle_path(self):
filename = self.get_bundle_filename()
return os.path.join(self.path, filename)
def get_bundle_url(self):
unversioned = self.get_bundle_filename()
filename = versioning.get_bundle_versions().get(self.name, unversioned)
return self.url + filename
def make_bundle(self, versioner):
self._make_bundle()
if versioner:
versioner.update_bundle_version(self)
def do_text_bundle(self, minifier=None):
with open(self.get_bundle_path(), "w") as output:
generator = concatenate_files(self.get_paths())
if minifier:
# Eventually we should use generators to concatenate and minify
# things one bit at a time, but for now we use strings.
output.write(minifier("".join(generator)))
else:
output.write("".join(generator))
class JavascriptBundle(Bundle):
"""Bundle for JavaScript."""
def __init__(self, name, path, url, files, type, minify):
super(JavascriptBundle, self).__init__(name, path, url, files, type)
self.minify = minify
def get_extension(self):
return ".js"
def _make_bundle(self):
minifier = jsmin if self.minify else None
self.do_text_bundle(minifier)
class CssBundle(Bundle):
"""Bundle for CSS."""
def __init__(self, name, path, url, files, type, minify):
super(CssBundle, self).__init__(name, path, url, files, type)
self.minify = minify
def get_extension(self):
return ".css"
def _make_bundle(self):
minifier = minify_css if self.minify else None
self.do_text_bundle(minifier)
class PngSpriteBundle(Bundle):
"""Bundle for PNG sprites.
In addition to generating a PNG sprite, it also generates CSS rules so that
the user can easily place their sprites. We build sprite bundles before CSS
bundles so that the user can bundle the generated CSS with the rest of their
CSS.
"""
def __init__(self, name, path, url, files, type, css_file):
super(PngSpriteBundle, self).__init__(name, path, url, files, type)
self.css_file = css_file
def get_extension(self):
return ".png"
def make_bundle(self, versioner):
import Image # If this fails, you need the Python Imaging Library.
# get the image paths excluding the sprite itself
paths = [path for path in self.get_paths() if not os.path.basename(path) == self.name + self.get_extension()]
boxes = [ImageBox(Image.open(path), path) for path in paths]
# Pick a max_width so that the sprite is squarish and a multiple of 16,
# and so no image is too wide to fit.
total_area = sum(box.width * box.height for box in boxes)
width = max(max(box.width for box in boxes),
(int(math.sqrt(total_area)) // 16 + 1) * 16)
(_, height, packing) = pack_boxes(boxes, width)
sprite = Image.new("RGBA", (width, height))
for (left, top, box) in packing:
# This is a bit of magic to make the transparencies work. To
# preserve transparency, we pass the image so it can take its
# alpha channel mask or something. However, if the image has no
# alpha channels, then it fails, we we have to check if the
# image is RGBA here.
img = box.image
mask = img if img.mode == "RGBA" else None
sprite.paste(img, (left, top), mask)
sprite.save(self.get_bundle_path(), "PNG")
self._optimize_output()
# It's *REALLY* important that this happen here instead of after the
# generate_css() call, because if we waited, the CSS woudl have the URL
# of the last version of this bundle.
if versioner:
versioner.update_bundle_version(self)
self.generate_css(packing)
def _optimize_output(self):
"""Optimize the PNG with pngcrush."""
sprite_path = self.get_bundle_path()
tmp_path = sprite_path + '.tmp'
args = ['pngcrush', '-rem', 'alla', sprite_path, tmp_path]
proc = subprocess.Popen(args, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT)
proc.wait()
if proc.returncode != 0:
raise Exception('pngcrush returned error code: %r\nOutput was:\n\n'
'%s' % (proc.returncode, proc.stdout.read()))
shutil.move(tmp_path, sprite_path)
def generate_css(self, packing):
"""Generate the background offset CSS rules."""
with open(self.css_file, "w") as css:
css.write("/* Auto Generated classes for media-bundler sprites. "
"Don't edit! */\n")
for (left, top, box) in packing:
props = {
"background-image": "url('%s')" % self.get_bundle_url(),
"background-position": "%dpx %dpx" % (-left, -top),
"width": "%dpx" % box.width,
"height": "%dpx" % box.height,
}
css.write(self.make_css(os.path.basename(box.filename), props))
CSS_REGEXP = re.compile(r"[^a-zA-Z\-_]")
def css_class_name(self, rule_name):
# Here we determine if the user has any mappings set from the image file
# name to a predefined CSS classname
name = self.name
css_name = False
bundle_name_mappings = False
try:
css_name = bundler_settings.PNG_BUNDLER_NAMES[name][rule_name]
except KeyError:
if rule_name:
name += "-" + rule_name
name = name.replace(" ", "-").replace(".", "-")
css_name = self.CSS_REGEXP.sub("", name)
if not css_name:
raise Exception (" Rule name could neither be generated from PNG bundle name" +
" nor auto generated with the name: %s" % name)
else:
return css_name
def make_css(self, name, props):
# We try to format it nicely here in case the user actually looks at it.
# If he wants it small, he'll bundle it up in his CssBundle.
css_class = self.css_class_name(name)
css_propstr = "".join(" %s: %s;\n" % p for p in props.iteritems())
return "\n.%s {\n%s}\n" % (css_class, css_propstr)
class ImageBox(Box):
"""A Box representing an image.
We hand these off to the bin packing algorithm. After the boxes have been
arranged, we can place the associated image in the sprite.
"""
def __init__(self, image, filename):
(width, height) = image.size
super(ImageBox, self).__init__(width, height)
self.image = image
self.filename = filename
def __repr__(self):
return "<ImageBox: filename=%r image=%r>" % (self.filename, self.image)
_bundles = None
def get_bundles():
"""Return a dict of bundle names and bundles as described in settings.py.
The result of this function is cached, because settings should never change
throughout the execution of the program.
"""
global _bundles
if not _bundles:
_bundles = dict((bundle["name"], Bundle.from_dict(bundle))
for bundle in bundler_settings.MEDIA_BUNDLES)
return _bundles