PageRenderTime 56ms CodeModel.GetById 25ms RepoModel.GetById 1ms app.codeStats 0ms

/minifier/minifier.py

https://github.com/monfresh/cfawp2012
Python | 830 lines | 700 code | 25 blank | 105 comment | 69 complexity | 6d29f3becbdfa47e5b197cf126acff16 MD5 | raw file
Possible License(s): AGPL-1.0, GPL-2.0
  1. #!/usr/bin/python
  2. # By Jeff Adams
  3. # Copyright (c) 2011 Azavea, Inc.
  4. #
  5. # Permission is hereby granted, free of charge, to any person
  6. # obtaining a copy of this software and associated documentation
  7. # files (the "Software"), to deal in the Software without
  8. # restriction, including without limitation the rights to use,
  9. # copy, modify, merge, publish, distribute, sublicense, and/or sell
  10. # copies of the Software, and to permit persons to whom the
  11. # Software is furnished to do so, subject to the following
  12. # conditions:
  13. #
  14. # The above copyright notice and this permission notice shall be
  15. # included in all copies or substantial portions of the Software.
  16. #
  17. # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
  18. # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
  19. # OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
  20. # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
  21. # HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
  22. # WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
  23. # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
  24. # OTHER DEALINGS IN THE SOFTWARE.
  25. import optparse
  26. import os
  27. import platform
  28. import re
  29. import shutil
  30. import subprocess
  31. import sys
  32. import traceback
  33. class Minifier(object):
  34. def __init__(self):
  35. self._init_parser()
  36. self._compressorfile = None
  37. self._rhinofile = None
  38. self._configmtime = None
  39. self._numerrors = 0
  40. self._numwarnings = 0
  41. self._configsections = []
  42. self._jslintfile = None
  43. self._use_wscript = (platform.system() == 'Windows')
  44. def _init_parser(self):
  45. u = '%prog [options]'
  46. d = 'The Azavea Minifier merges your files, runs them through JSLint,' \
  47. ' and then minifies them using the YUI Compressor.'
  48. e = """
  49. The config file should look something like this:
  50. # This is a comment.
  51. [js/output1.file] --optional_options
  52. js/input1.file
  53. js/input2.file
  54. [styles/output2.file]
  55. styles/input3.file
  56. styles/input4.file
  57. All file names/paths are assumed to be subdirectories of the input and
  58. output directories."""
  59. self.epilog = e
  60. self.parser = p = optparse.OptionParser(usage=u, description=d)
  61. p.add_option('-i', '--input-dir', dest='inputdir', default='.',
  62. help='input file paths start in DIR [default: %default]',
  63. metavar='DIR')
  64. p.add_option('-o', '--output-dir', dest='outputdir', default='.',
  65. help='output file paths start in DIR [default: %default]',
  66. metavar='DIR')
  67. p.add_option('-c', '--config', dest='configfile', default='minifier.conf',
  68. help='use PATH as the config file [default %default)',
  69. metavar='PATH')
  70. p.add_option('--yui', dest='yuijar', default=None,
  71. help='use YUI jar at PATH (normally looks for one adjacent '
  72. 'to this script)',
  73. metavar='PATH')
  74. p.add_option('-v', '--verbose', default=False, dest='verbose',
  75. action='store_true', help='produce extra output')
  76. g = optparse.OptionGroup(p, "Config Options",
  77. 'These may be used on the command line, to '
  78. 'apply to everything, or in config file '
  79. 'header rows ([outfile.js] --no-lint) to '
  80. 'apply only to that section.')
  81. g.add_option('--no-comments', dest='nocomments', default=False,
  82. action='store_true',
  83. help='do not insert header/footer comment in concatenated file')
  84. g.add_option('--no-min', dest='nominify', default=False, action='store_true',
  85. help='do not minify concatenated file')
  86. g.add_option('--no-css-images', dest='nocssimages', default=False,
  87. action='store_true',
  88. help='do not look for images in css to copy with the css')
  89. g.add_option('--no-lint', dest='nolint', default=False, action='store_true',
  90. help='do not run jslint')
  91. g.add_option('--lint-opts', dest='lintopts', default='',
  92. help='include custom OPTS for jslint, as a json object [ex: '
  93. '--lint-opts {"evil"="false"}]',
  94. metavar='OPTS')
  95. g.add_option('--force', dest='force', default=False, action='store_true',
  96. help='force minify even if files are up to date')
  97. p.add_option_group(g)
  98. # This is a small hack to get optparse to output our epilog the way we
  99. # want. We could include a custom formatter but that would end up being
  100. # a lot more complicated.
  101. _orig_help = p.print_help
  102. def myhelp():
  103. _orig_help()
  104. print self.epilog
  105. p.print_help = myhelp
  106. # This allows us to print the full help output on user errors.
  107. def myerror(msg):
  108. prog = os.path.basename(sys.argv[0])
  109. print '%s: %s\n' % (prog, msg)
  110. p.print_help()
  111. sys.exit(1)
  112. p.error = myerror
  113. def err(self, msg, **kwargs):
  114. self.displayerror(message=msg, **kwargs)
  115. self.parser.print_help()
  116. sys.exit(1)
  117. def run(self, args):
  118. self._init_parser()
  119. self.opts, _ = self.parser.parse_args()
  120. print "Welcome to the Azavea Minifier! Use -v for verbose mode."
  121. self.findfiles()
  122. self.parseconfig()
  123. self.mergeandminify()
  124. print ""
  125. if self._numwarnings > 0:
  126. print "*** Warnings encountered: %d" % self._numwarnings
  127. if self._numerrors > 0:
  128. print "*** Errors encountered: %d" % self._numerrors
  129. sys.exit(self._numerrors)
  130. def filter_by_re(self, path, regex):
  131. for name in os.listdir(path):
  132. if regex.match(name):
  133. # use the first match we get
  134. return os.path.join(path, name)
  135. return None
  136. yuijar_re = re.compile(r'^yuicompressor.*\.jar$')
  137. jslint_re = re.compile(r'^jslint.*\.js$')
  138. rhino_re = re.compile(r'^js\.jar$')
  139. def findfiles(self):
  140. # Find the directory this program lives in, used to find other files.
  141. mypath = os.path.dirname(os.path.realpath(__file__))
  142. # If the YUI jar file was specified, use it. Otherwise, find it.
  143. if self.opts.yuijar:
  144. self._compressorfile = self.opts.yuijar
  145. else:
  146. self._compressorfile = self.filter_by_re(mypath, self.yuijar_re)
  147. if not self._compressorfile:
  148. self.err("Can't find YUI in %r (use --yui if needed)" % mypath)
  149. if not self.opts.nolint:
  150. self._jslintfile = self.filter_by_re(mypath, self.jslint_re)
  151. if not self._jslintfile:
  152. self.err("Can't find jslint in %r" % mypath)
  153. if not self._use_wscript:
  154. self._rhinofile = self.filter_by_re(mypath, self.rhino_re)
  155. if not self._rhinofile:
  156. self.err("Can't find js.jar (rhino) in %r" % mypath)
  157. token_re = re.compile(r'\S+|"(?:[^"\\]|\\.)*"')
  158. def splitsubargs(self, s):
  159. '''
  160. Helper function used by parseconfig() to seperate embedded args from
  161. section headers (e.g. [foo] -a -b -c "one two")
  162. '''
  163. return self.token_re.findall(s)
  164. begin_re = re.compile(r'^\s*\[(.+?)\]\s*(.*?)\s*$')
  165. end_re = re.compile(r'^\s*$')
  166. comment_re = re.compile(r'#.*$')
  167. body_re = re.compile(r'^\s*(.+?)\s*$')
  168. def parseconfig(self):
  169. """
  170. Reads all the contents of the config file, and verifies that the input
  171. files all exist.
  172. """
  173. f = None
  174. try:
  175. self._configmtime = os.path.getmtime(self.opts.configfile)
  176. f = open(self.opts.configfile)
  177. except Exception as e:
  178. self.err("Can't open config file %r" % self.opts.configfile)
  179. # Read the file, ignoring comments.
  180. curr = {}
  181. try:
  182. for line in f:
  183. line = self.comment_re.sub('', line).strip()
  184. if self.end_re.match(line):
  185. if 'outpath' in curr:
  186. self._configsections.append(curr)
  187. curr = {}
  188. continue
  189. m = self.begin_re.match(line)
  190. if m:
  191. path = self.fixslashes(os.path.join(self.opts.outputdir,
  192. m.group(1)))
  193. subargs = self.splitsubargs(m.group(2))
  194. curr['outpath'] = path
  195. curr['args'], _ = self.parser.parse_args(subargs)
  196. curr['inpaths'] = []
  197. continue
  198. m = self.body_re.match(line)
  199. if m:
  200. path = self.fixslashes(os.path.join(self.opts.inputdir,
  201. m.group(1)))
  202. if not os.path.exists(path):
  203. self.displayerror(filename=path,
  204. message="Not merging file " + path +
  205. " because it doesn't exist.")
  206. self._numerrors += 1
  207. else:
  208. curr['inpaths'].append(path)
  209. continue
  210. raise Exception(
  211. 'Entered impossible state while parsing config file: %r' % line)
  212. except Exception as e:
  213. self.err("Error while parsing config file:\n" + str(e) + "\n" +
  214. traceback.format_exc(e))
  215. if 'outpath' in curr:
  216. self._configsections.append(curr)
  217. def fixslashes(self, path):
  218. """Converts all slashes in the path to be correct for this os."""
  219. path = path.replace("\\", os.sep);
  220. path = path.replace("/", os.sep);
  221. return path;
  222. def mergeandminify(self):
  223. """
  224. Merges all the groups of input files into the output files, then
  225. minifies the output files.
  226. """
  227. for section in self._configsections:
  228. outpath = section["outpath"]
  229. filetype = None
  230. if outpath.upper().endswith(".CSS"):
  231. filetype = "CSS"
  232. elif outpath.upper().endswith(".JS"):
  233. filetype = "JS"
  234. if filetype == "JS" or filetype == "CSS":
  235. self.mergefiles(section["inpaths"], outpath, filetype,
  236. section["args"])
  237. if ((not self.opts.nominify) and
  238. ((not section["args"]) or
  239. (not section["args"].nominify))):
  240. # Now produce a minified version
  241. self.minify(outpath, section["args"])
  242. else:
  243. # Else assume it is referring to a directory.
  244. self.mergedir(section["inpaths"], outpath)
  245. def mergedir(self, inpaths, outdir):
  246. """
  247. Puts all the files referred to in the config into this directory.
  248. inpaths -- List of (appropriately path-qualified) input files to
  249. copy into the directory.
  250. outdir -- Directory to put all the files into.
  251. """
  252. if not outdir.endswith(os.sep):
  253. outdir += os.sep
  254. self.displayinfo("\nCopying to " + outdir)
  255. self.ensuredirexists(outdir + "junk.file")
  256. for inpath in inpaths:
  257. self.displayinfo(" < " + inpath)
  258. try:
  259. # We already checked that the input file/dir exists
  260. # when we parsed the config.
  261. # It's a directory, copy all its contents.
  262. if (os.path.isdir(inpath)):
  263. numcopied = self.copydir(inpath, outdir)
  264. self.displayinfo(" " + numcopied + " files/folders copied.")
  265. elif (os.path.isfile(inpath)):
  266. shutil.copyfile(inpath,
  267. os.path.join(outdir,
  268. os.path.basename(inpath)))
  269. else:
  270. self.displayerror(filename=inpath, message=
  271. "Unable to access " +
  272. inpath + " as a file or a directory.")
  273. except Exception as e:
  274. self.displayerror(filename=inpath,
  275. message="Unable to copy " + inpath + " to " + outdir,
  276. e=e)
  277. def mergefiles(self, infiles, outfile, filetype, arguments):
  278. """
  279. Merges either CSS or Javascript files.
  280. infiles -- List of (appropriately path-qualified) input files to
  281. copy into the directory.
  282. outfile -- Full output file path.
  283. filetype -- File type (JS or CSS)
  284. arguments -- Any command line or sectional arguments.
  285. """
  286. # Optimization: First check if the input files are all older than the
  287. # output file, nothing has changed and no need to do anything.
  288. if self.opts.force or arguments.force:
  289. domerge = True
  290. else:
  291. domerge = not (os.path.exists(outfile) and os.path.isfile(outfile))
  292. if not domerge:
  293. outfilemtime = os.path.getmtime(outfile)
  294. if self._configmtime > outfilemtime:
  295. # Config was modified more recently than our output, contents
  296. # may have changed.
  297. domerge = True
  298. else:
  299. # When parsing the config file we already verified the
  300. # input files exist, and made sure the names are fully
  301. # qualified.
  302. for infile in infiles:
  303. infilemtime = os.path.getmtime(infile)
  304. if infilemtime > outfilemtime:
  305. # Input is newer, need to do something.
  306. domerge = True
  307. break
  308. if not domerge:
  309. self.displayinfo("Skipping " + outfile +
  310. " because it appears up-to-date. Use --force to override.")
  311. return
  312. images_by_infile = {}
  313. self.displayinfo("\nWriting " + outfile)
  314. self.ensuredirexists(outfile)
  315. # Open a new output stream, overwriting any existing file.
  316. writer = None
  317. try:
  318. writer = open(outfile, "w")
  319. except Exception as e:
  320. self.displayerror(filename=outfile,
  321. message="Unable to open " + outfile + " for writing.", e=e)
  322. self.displaydirections()
  323. sys.exit(-16)
  324. try:
  325. # infiles exist and are fully qualified.
  326. for infile in infiles:
  327. self.displayinfo(" < " + infile)
  328. if (not self.opts.nocomments) and (not arguments.nocomments):
  329. writer.write("\n")
  330. writer.write("/******************** Begin " +
  331. infile.ljust(30) + " ********************/\n")
  332. infileobj = None
  333. try:
  334. infileobj = open(infile, "r")
  335. except Exception as e:
  336. self.displayerror(filename=infile, message=
  337. "Unable to open " + infile + " for reading.", e=e)
  338. self.displaydirections()
  339. sys.exit(-17)
  340. # Assuming we were able to open everything, copy from in
  341. # to out.
  342. try:
  343. contents = infileobj.read()
  344. # Since CSS file paths are relative, if we're copying
  345. # images, we need to figure out all the images this
  346. # CSS file uses.
  347. if ((not self.opts.nocssimages) and
  348. ((not arguments) or
  349. (not arguments.nocssimages)) and
  350. (filetype == "CSS")):
  351. images_by_infile[infile] = self.extractpaths(
  352. infile, contents)
  353. # Write a newline so we don't accidentally concatenate
  354. # the first line of file 2 on the end of the last line
  355. # of file 1, which may not be valid.
  356. writer.write(contents + "\n")
  357. if ((not self.opts.nocomments) and
  358. ((not arguments) or
  359. (not arguments.nocomments))):
  360. writer.write("/******************** End " +
  361. infile.ljust(30) + " ********************/\n")
  362. writer.write("\n")
  363. finally:
  364. infileobj.close()
  365. finally:
  366. writer.flush()
  367. writer.close()
  368. if ((not self.opts.nolint) and
  369. ((not arguments) or
  370. (not arguments.nolint)) and
  371. (filetype == "JS")):
  372. # To make this faster in the "normal" (no error) case, we
  373. # first run lint on the merged output.
  374. lintopts = None
  375. if arguments and arguments.lintopts:
  376. lintopts = arguments.lintopts.strip()
  377. allcomplaints = self.jslint(outfile, lintopts)
  378. if len(allcomplaints):
  379. # If there were complaints, run lint on each file until
  380. # we find them all (since it's much more handy to have the
  381. # errors from the specific file with the correct line
  382. # numbers.
  383. numcomplaints = 0
  384. for infile in infiles:
  385. complaints = self.jslint(infile, lintopts)
  386. for complaint in complaints:
  387. self._numerrors += 1
  388. numcomplaints += 1
  389. self.displayerror(filename=infile,
  390. line=complaint.linenum,
  391. column=complaint.colnum, code="JsLint",
  392. message=complaint.complaint)
  393. # If we've found all the complaints, we don't need to
  394. # bother to run lint on the remaining files.
  395. if numcomplaints == len(allcomplaints):
  396. break
  397. if ((not self.opts.nocssimages) and
  398. ((not arguments) or
  399. (not arguments.nocssimages)) and
  400. (filetype == "CSS")):
  401. # Note: the output file name could have a subdirectory
  402. # specified in it.
  403. self.copyallimages(os.path.dirname(outfile), images_by_infile)
  404. def jslint(self, filename, lintopts=None):
  405. """
  406. This runs JsLint on the specified file, and returns a list of complaints.
  407. lintopts -- Optional (pun intended) JSON string of JSLint options.
  408. Full list of options here:
  409. http://www.jslint.com/lint.html#options
  410. filename -- File to lint.
  411. returns -- A list of complaints. The JSLint output will be parsed into
  412. individual complaints.
  413. """
  414. self.displayinfo("Running JsLint against " + filename)
  415. filehandle = open(filename, "r")
  416. javascript = filehandle.read()
  417. filehandle.close()
  418. if len(self.opts.lintopts.strip()):
  419. lintopts = self.opts.lintopts
  420. if lintopts:
  421. javascript += " --options " + lintopts
  422. procargs = None
  423. if self._use_wscript:
  424. # Windows Script Host has two versions, wscript.exe which pops up
  425. # a window and cscript.exe which does not.
  426. procargs = ["cscript.exe", "//I", "//Nologo", self._jslintfile]
  427. else:
  428. procargs = ["java", "-jar", self._rhinofile, self._jslintfile]
  429. try:
  430. proc = subprocess.Popen(procargs, -1, None, subprocess.PIPE,
  431. subprocess.PIPE, subprocess.PIPE)
  432. proc_output = proc.communicate(javascript)
  433. except Exception as e:
  434. self.displayerror(message=
  435. "Error while running JsLint via Rhino. Is java in your path?",
  436. e=e)
  437. sys.exit(-40)
  438. # 0 = no problems, 1 = lint errors, anything else is presumably a
  439. # problem running it.
  440. if (proc.returncode != 0) and (proc.returncode != 1):
  441. self.displayerror(
  442. message="JsLint (via Rhino) had non-zero exit code: " +
  443. str(proc.returncode) + "\n " + str(procargs) +
  444. "\n output: \n\n" + str(proc_output), code=proc.returncode)
  445. sys.exit(-19)
  446. retval = []
  447. for complaint in proc_output[0].split("Lint at "):
  448. if len(complaint.strip()):
  449. retval.append(JsLintComplaint(complaint))
  450. return retval
  451. def extractpaths(self, cssfile, contents):
  452. """
  453. Searches the contents of a CSS file for any image files, and
  454. returns a list of the paths (relative to the CSS file's location).
  455. cssfile -- Name of the CSS file we're looking at, used for logging.
  456. contents -- Contents of the CSS file.
  457. returns -- A dictionary of everything that appears to be an image file.
  458. Key: path relative to the CSS file, Value: Absolute path.
  459. """
  460. img_start = "URL("
  461. img_end = ")"
  462. img_start_len = len(img_start)
  463. img_end_len = len(img_end)
  464. # Since images in CSS are relative to the file, we need
  465. # the directory the file was in.
  466. cssdir = os.path.dirname(cssfile)
  467. # Store the names in a dictionary to prevent duplicates.
  468. # This is the real path keyed by the CSS relative path.
  469. retval = {}
  470. # We do a really simple check. Find everything between open and
  471. # close parends, and see if it exists. If it does, include it.
  472. lintnum = 0
  473. eol_idx = 0
  474. # This toupper is because the openstr contains upper case chars. The
  475. # indexes however will still be comparable to the original string (in
  476. # English anyway)
  477. contents_upper = contents.upper()
  478. startidx = contents_upper.find(img_start)
  479. endidx = (contents.find(img_end, startidx+img_start_len) if
  480. (startidx >= 0) else -1)
  481. while (startidx >= 0) and (endidx > startidx):
  482. possiblepath = contents[startidx + img_start_len: endidx]
  483. # Remove any whitespace or quotes.
  484. possiblepath = possiblepath.strip()
  485. possiblepath = possiblepath.strip("'\"")
  486. possiblepath = possiblepath.strip()
  487. wasfile = True
  488. # No point in checking for the file if we already have it.
  489. if not retval.has_key(possiblepath):
  490. realpath = os.path.join(cssdir, possiblepath)
  491. if (os.path.exists(realpath) and
  492. os.path.isfile(realpath)):
  493. retval[possiblepath] = realpath
  494. else:
  495. while ((eol_idx <= startidx) and (eol_idx != -1)):
  496. lintnum += 1
  497. eol_idx = contents.find('\n', eol_idx + 1)
  498. self.displaywarning(
  499. "Minifier can't find the file for this url " +
  500. "referenced in the CSS: \n " + possiblepath +
  501. "\nThat should be: \n " + realpath,
  502. cssfile, lintnum)
  503. self._numwarnings += 1
  504. wasfile = False
  505. # Search for the next pair of open/close, plus at least 1 for the
  506. # file name.
  507. minchars = (img_start_len + img_end_len + 1)
  508. if startidx < (len(contents) - minchars):
  509. # If it was a file, we can skip the whole thing.
  510. startidx = contents_upper.find(img_start,
  511. (endidx if wasfile else startidx) + 1)
  512. else:
  513. startidx = -1
  514. if ((startidx >= 0) and
  515. (startidx < (len(contents) - minchars))):
  516. endidx = contents.find(img_end, startidx +
  517. img_start_len)
  518. else:
  519. endidx = -1
  520. return retval
  521. def copyallimages(self, outdir, images_by_infile):
  522. """
  523. Copies all the images used by the input CSS files
  524. outdir -- Path to the directory with the output CSS file, since
  525. image paths in CSS are relative to the file.
  526. images_by_infile -- Images paths.
  527. Key: input file name from the config (used for error
  528. messages)
  529. Value: dictionary of
  530. Key: file path relative to the CSS.
  531. Value: Real file path where we can get the
  532. file from.
  533. """
  534. source_by_dest = {}
  535. for infile in images_by_infile:
  536. images = images_by_infile[infile]
  537. self.displayinfo(" Copying files for " + infile + ":")
  538. for relativepath in images:
  539. sourcefile = images[relativepath]
  540. destfile = os.path.join(outdir, relativepath)
  541. # This is to make sure "../file" and "/blah/blah/blah/file"
  542. # don't happen to be referring to the same file.
  543. if os.path.abspath(sourcefile) == os.path.abspath(destfile):
  544. self.displayinfo(
  545. " Not copying file (src and dest are the same): " +
  546. destfile)
  547. else:
  548. # Make sure the subdirectories exist.
  549. self.ensuredirexists(destfile)
  550. # If we're copying over the same file from a different
  551. # source, raise a warning. If it's the same source,
  552. # skip it.
  553. docopy = True
  554. if source_by_dest.has_key(destfile):
  555. if source_by_dest[destfile] != sourcefile:
  556. self.displaywarning(
  557. "Warning: Overwriting file '" + destfile +
  558. "' originally written for " +
  559. source_by_dest[destfile],
  560. destfile)
  561. self._numwarnings += 1
  562. else:
  563. docopy = False
  564. if docopy:
  565. self.displayinfo(" Copying: " + destfile)
  566. shutil.copyfile(sourcefile, destfile)
  567. source_by_dest[destfile] = sourcefile
  568. else:
  569. self.displayinfo(" Not copying file (already copied): " +
  570. destfile)
  571. def displaydirections(self):
  572. """ Dumps the directions on how to run this program to the console."""
  573. self.parser.print_help()
  574. def displayinfo(self, message):
  575. "Displays an info message only if verbose mode is set."
  576. if self.opts.verbose:
  577. print message
  578. def displaywarning(self, msg, filename=None, line=None):
  579. "Dumps a nicely formatted warning message."
  580. self.displayvsmessage(filename, line, -1, False, None, msg)
  581. def displayerror(self, message, filename=None, line=None, column=None,
  582. code=None, e=None):
  583. "Dumps a nicely formatted error message."
  584. s = message
  585. if e: s += "\n%s\n%s" % (e.__str__(), traceback.format_exc())
  586. self.displayvsmessage(filename, line, column, True, code, s)
  587. def displayvsmessage(self, filename, line, column, error, code, message):
  588. """
  589. Dumps an error or warning message formatted so Visual Studio will
  590. pick it up.
  591. """
  592. output = ""
  593. if filename:
  594. output += filename
  595. if line >= 0:
  596. output += "(" + str(line)
  597. if column >= 0:
  598. output += "," + str(column)
  599. output += ")"
  600. else:
  601. output += "Minifier"
  602. output += ": "
  603. output += "error" if error else "warning"
  604. if code:
  605. output += " " + str(code)
  606. output += " : " + message
  607. print
  608. print output
  609. print
  610. def minify(self, filename, arguments=None):
  611. """
  612. Takes a filename ("filename.extension") to minify and produces a
  613. minified version ("filename-min.extension").
  614. filename -- The file to minify. Currently only JS and CSS files are
  615. supported (we use the YUI compressor).
  616. arguments -- Any command line or sectional arguments.
  617. """
  618. ext_idx = filename.rfind('.')
  619. if ext_idx < 0:
  620. self.displayerror(filename=filename, message="Output file name " +
  621. filename + " does not have a valid extension.")
  622. self.displaydirections()
  623. sys.exit(-18)
  624. minfilename = filename[0: ext_idx]+ "-min" + filename[ext_idx:]
  625. # Optimization: First check if the input file is older than the
  626. # minified file, nothing has changed and no need to do anything.
  627. dominify = ((self.opts.force or
  628. (arguments and arguments.force)) or
  629. not (os.path.exists(minfilename) and
  630. os.path.isfile(minfilename)))
  631. if not dominify:
  632. outfilemtime = os.path.getmtime(minfilename)
  633. if self._configmtime > outfilemtime:
  634. # Config was modified more recently than our output,
  635. # contents may have changed.
  636. dominify = True
  637. else:
  638. if not (os.path.exists(filename) and os.path.isfile(filename)):
  639. self.displayerror(filename=filename, message="File " +
  640. filename + " does not exist.")
  641. self.displaydirections()
  642. sys.exit(-31)
  643. else:
  644. infilemtime = os.path.getmtime(filename)
  645. if infilemtime > outfilemtime:
  646. # Input is newer, need to do something.
  647. dominify = True
  648. if not dominify:
  649. self.displayinfo("Skipping " + minfilename +
  650. " because it appears up-to-date. Use --force to override.")
  651. else:
  652. self.displayinfo("Minifying " + filename + "...")
  653. procargs = ["java", "-jar", self._compressorfile, filename]
  654. output = None
  655. try:
  656. proc = subprocess.Popen(procargs, stdout=subprocess.PIPE,
  657. stderr=subprocess.PIPE)
  658. output = proc.communicate()
  659. if proc.returncode != 0:
  660. self.displayerror(message=
  661. "Compressor had non-zero exit code: " +
  662. str(proc.returncode) + "\n " + str(procargs) +
  663. "\n " + output[1], code=proc.returncode)
  664. sys.exit(-19)
  665. except Exception as e:
  666. self.displayerror(message=
  667. "Error while running compressor. Is java in your path?",
  668. e=e)
  669. sys.exit(-41)
  670. outfileobj = open(minfilename, "w")
  671. try:
  672. outfileobj.write(output[0])
  673. except Exception as e:
  674. self.displayerror(message=
  675. "Error while writing compressed output.", e=e)
  676. sys.exit(-42)
  677. finally:
  678. outfileobj.flush()
  679. outfileobj.close()
  680. self.displayinfo(" > " + minfilename)
  681. def ensuredirexists(self, filename):
  682. """ For a given file name, ensures the directory for that file exists."""
  683. dirname = os.path.dirname(filename)
  684. if not (os.path.exists(dirname) and os.path.isdir(dirname)):
  685. try:
  686. os.makedirs(dirname)
  687. except Exception as e:
  688. self.displayerror(filename=dirname, message="Output dir " +
  689. dirname + " does not exist and could not be created.", e=e)
  690. self.displaydirections()
  691. sys.exit(-20)
  692. def copydir(self, fromdir, todir):
  693. """
  694. Recursively copies all the contents of one directory to another directory.
  695. fromdir -- The source.
  696. todir -- The destination.
  697. returns -- The number of files/subdirectories copied.
  698. """
  699. try:
  700. retval = 0
  701. # Since this recursively does subdirs, need to make sure
  702. # they exist as we go.
  703. self.ensuredirexists(todir + os.sep + "junk.file")
  704. for f in os.listdir(fromdir):
  705. if os.path.isfile(os.path.join(thedir, f)):
  706. shutil.copyfile(f, todir + f[len(fromdir):])
  707. retval += 1
  708. for d in os.listdir(fromdir):
  709. if os.path.isdir(os.path.join(thedir, d)):
  710. # Ignore any svn or git dirs.
  711. if re.match("([\._][Ss][Vv][Nn]|\.git)",
  712. os.path.basename(d)):
  713. # Add the number of files copied.
  714. retval += self.copydir(d, todir + d[len(fromdir):])
  715. # Add one more for the directory itself.
  716. retval += 1
  717. return retval
  718. except Exception as e:
  719. raise Exception("Unable to copy from dir " + fromdir + " to dir " +
  720. todir + ".", e)
  721. class JsLintComplaint(object):
  722. def __init__(self, complaint):
  723. lines = complaint.split('\n')
  724. m = re.search("line ([0-9]*) character ([0-9]*):(.*)", lines[0])
  725. if m:
  726. self.complaint = m.group(2).strip()
  727. for line in lines:
  728. self.complaint += "\n" + line
  729. if len(self.complaint.strip()):
  730. try:
  731. self.linenum = int(m.group(0))
  732. except:
  733. self.linenum = -1
  734. try:
  735. self.colnum = int(m.group(1))
  736. except:
  737. self.colnum = -1
  738. else:
  739. self.linenum = -1
  740. self.colnum = -1
  741. self.complaint = complaint
  742. else:
  743. self.linenum = -1
  744. self.colnum = -1
  745. self.complaint = complaint
  746. # Trim off the first arg, which is the name of the python file.
  747. Minifier().run(sys.argv[1:])