minify.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267
  1. # -*- coding: utf-8 -*-
  2. """CSS-JS-Minify.
  3. Minifier for the Web.
  4. """
  5. import os
  6. import sys
  7. import gzip
  8. # import logging as log
  9. from argparse import ArgumentParser
  10. from datetime import datetime
  11. from functools import partial
  12. from hashlib import sha1
  13. from multiprocessing import Pool, cpu_count
  14. from subprocess import getoutput
  15. from time import sleep
  16. from .css_minifer import css_minify
  17. from .js_minifer import js_minify
  18. __all__ = ('process_multiple_files', 'prefixer_extensioner',
  19. 'process_single_css_file', 'process_single_js_file',
  20. 'make_arguments_parser', 'main')
  21. color = {
  22. 'cyan': '\033[1;36m',
  23. 'end': '\033[0m',
  24. 'green': '\033[1;32m'
  25. }
  26. def process_multiple_files(file_path, watch=False, wrap=False, timestamp=False,
  27. comments=False, sort=False, overwrite=False,
  28. zipy=False, prefix='', add_hash=False):
  29. """Process multiple CSS, JS files with multiprocessing."""
  30. print("Process %s is Compressing %s" % (os.getpid(), file_path))
  31. if watch:
  32. previous = int(os.stat(file_path).st_mtime)
  33. print("Process %s is Watching %s" % (os.getpid(), file_path))
  34. while True:
  35. actual = int(os.stat(file_path).st_mtime)
  36. if previous == actual:
  37. sleep(60)
  38. else:
  39. previous = actual
  40. print("Modification detected on %s" % file_path)
  41. if file_path.endswith(".css"):
  42. process_single_css_file(
  43. file_path, wrap=wrap, timestamp=timestamp,
  44. comments=comments, sort=sort, overwrite=overwrite,
  45. zipy=zipy, prefix=prefix, add_hash=add_hash)
  46. elif file_path.endswith(".js"):
  47. process_single_js_file(
  48. file_path, timestamp=timestamp,
  49. overwrite=overwrite, zipy=zipy)
  50. else:
  51. if file_path.endswith(".css"):
  52. process_single_css_file(
  53. file_path, wrap=wrap, timestamp=timestamp,
  54. comments=comments, sort=sort, overwrite=overwrite, zipy=zipy,
  55. prefix=prefix, add_hash=add_hash)
  56. elif file_path.endswith(".js"):
  57. process_single_js_file(
  58. file_path, timestamp=timestamp,
  59. overwrite=overwrite, zipy=zipy)
  60. def prefixer_extensioner(file_path, old, new,
  61. file_content=None, prefix='', add_hash=False):
  62. """Take a file path and safely preppend a prefix and change extension.
  63. This is needed because filepath.replace('.foo', '.bar') sometimes may
  64. replace '/folder.foo/file.foo' into '/folder.bar/file.bar' wrong!.
  65. >>> prefixer_extensioner('/tmp/test.js', '.js', '.min.js')
  66. '/tmp/test.min.js'
  67. """
  68. print("Prepending '%s' Prefix to %s" % (new.upper(), file_path))
  69. extension = os.path.splitext(file_path)[1].lower().replace(old, new)
  70. filenames = os.path.splitext(os.path.basename(file_path))[0]
  71. filenames = prefix + filenames if prefix else filenames
  72. if add_hash and file_content: # http://stackoverflow.com/a/25568916
  73. filenames += "-" + sha1(file_content.encode("utf-8")).hexdigest()[:11]
  74. print("Appending SHA1 HEX-Digest Hash to '%s'" % file_path)
  75. dir_names = os.path.dirname(file_path)
  76. file_path = os.path.join(dir_names, filenames + extension)
  77. return file_path
  78. def process_single_css_file(css_file_path, wrap=False, timestamp=False,
  79. comments=False, sort=False, overwrite=False,
  80. zipy=False, prefix='', add_hash=False,
  81. output_path=None):
  82. """Process a single CSS file."""
  83. print("Processing %sCSS%s file: %s" % (color['cyan'],
  84. color['end'],
  85. css_file_path))
  86. with open(css_file_path, encoding="utf-8") as css_file:
  87. original_css = css_file.read()
  88. print("INPUT: Reading CSS file %s" % css_file_path)
  89. minified_css = css_minify(original_css, wrap=wrap,
  90. comments=comments, sort=sort)
  91. if timestamp:
  92. taim = "/* {0} */ ".format(datetime.now().isoformat()[:-7].lower())
  93. minified_css = taim + minified_css
  94. if output_path is None:
  95. min_css_file_path = prefixer_extensioner(
  96. css_file_path, ".css", ".css" if overwrite else ".min.css",
  97. original_css, prefix=prefix, add_hash=add_hash)
  98. if zipy:
  99. gz_file_path = prefixer_extensioner(
  100. css_file_path, ".css",
  101. ".css.gz" if overwrite else ".min.css.gz", original_css,
  102. prefix=prefix, add_hash=add_hash)
  103. print("OUTPUT: Writing ZIP CSS %s" % gz_file_path)
  104. else:
  105. min_css_file_path = gz_file_path = output_path
  106. if not zipy or output_path is None:
  107. # if specific output path is requested,write write only one output file
  108. with open(min_css_file_path, "w", encoding="utf-8") as output_file:
  109. output_file.write(minified_css)
  110. if zipy:
  111. with gzip.open(gz_file_path, "wt", encoding="utf-8") as output_gz:
  112. output_gz.write(minified_css)
  113. print("OUTPUT: Writing CSS Minified %s" % min_css_file_path)
  114. return min_css_file_path
  115. def process_single_js_file(js_file_path, timestamp=False, overwrite=False,
  116. zipy=False, output_path=None):
  117. """Process a single JS file."""
  118. print("Processing %sJS%s file: %s" % (color['green'],
  119. color['end'],
  120. js_file_path))
  121. with open(js_file_path, encoding="utf-8") as js_file:
  122. original_js = js_file.read()
  123. print("INPUT: Reading JS file %s" % js_file_path)
  124. minified_js = js_minify(original_js)
  125. if timestamp:
  126. taim = "/* {} */ ".format(datetime.now().isoformat()[:-7].lower())
  127. minified_js = taim + minified_js
  128. if output_path is None:
  129. min_js_file_path = prefixer_extensioner(
  130. js_file_path, ".js", ".js" if overwrite else ".min.js",
  131. original_js)
  132. if zipy:
  133. gz_file_path = prefixer_extensioner(
  134. js_file_path, ".js", ".js.gz" if overwrite else ".min.js.gz",
  135. original_js)
  136. print("OUTPUT: Writing ZIP JS %s" % gz_file_path)
  137. else:
  138. min_js_file_path = gz_file_path = output_path
  139. if not zipy or output_path is None:
  140. # if specific output path is requested,write write only one output file
  141. with open(min_js_file_path, "w", encoding="utf-8") as output_file:
  142. output_file.write(minified_js)
  143. if zipy:
  144. with gzip.open(gz_file_path, "wt", encoding="utf-8") as output_gz:
  145. output_gz.write(minified_js)
  146. print("OUTPUT: Writing JS Minified %s" % min_js_file_path)
  147. return min_js_file_path
  148. def make_arguments_parser():
  149. """Build and return a command line agument parser."""
  150. parser = ArgumentParser(description=__doc__, epilog="""CSS-JS-Minify:
  151. Takes a file or folder full path string and process all CSS/JS found.
  152. If argument is not file/folder will fail. Check Updates works on Python3.
  153. Std-In to Std-Out is deprecated since it may fail with unicode characters.
  154. SHA1 HEX-Digest 11 Chars Hash on Filenames is used for Server Cache.
  155. CSS Properties are Alpha-Sorted, to help spot cloned ones, Selectors not.
  156. Watch works for whole folders, with minimum of ~60 Secs between runs.""")
  157. # parser.add_argument('--version', action='version',
  158. # version=css_js_minify.__version__)
  159. parser.add_argument('fullpath', metavar='fullpath', type=str,
  160. help='Full path to local file or folder.')
  161. parser.add_argument('--wrap', action='store_true',
  162. help="Wrap output to ~80 chars per line, CSS only.")
  163. parser.add_argument('--prefix', type=str,
  164. help="Prefix string to prepend on output filenames.")
  165. parser.add_argument('--timestamp', action='store_true',
  166. help="Add a Time Stamp on all CSS/JS output files.")
  167. parser.add_argument('--quiet', action='store_true', help="Quiet, Silent.")
  168. parser.add_argument('--hash', action='store_true',
  169. help="Add SHA1 HEX-Digest 11chars Hash to Filenames.")
  170. parser.add_argument('--zipy', action='store_true',
  171. help="GZIP Minified files as '*.gz', CSS/JS only.")
  172. parser.add_argument('--sort', action='store_true',
  173. help="Alphabetically Sort CSS Properties, CSS only.")
  174. parser.add_argument('--comments', action='store_true',
  175. help="Keep comments, CSS only (Not Recommended)")
  176. parser.add_argument('--overwrite', action='store_true',
  177. help="Force overwrite all in-place (Not Recommended)")
  178. parser.add_argument('--after', type=str,
  179. help="Command to execute after run (Experimental).")
  180. parser.add_argument('--before', type=str,
  181. help="Command to execute before run (Experimental).")
  182. parser.add_argument('--watch', action='store_true', help="Watch changes.")
  183. parser.add_argument('--multiple', action='store_true',
  184. help="Allow Multiple instances (Not Recommended).")
  185. return parser.parse_args()
  186. def walk2list(folder: str, target: tuple, omit: tuple = (),
  187. showhidden: bool = False, topdown: bool = True,
  188. onerror: object = None, followlinks: bool = False) -> tuple:
  189. """Perform full walk, gather full path of all files."""
  190. oswalk = os.walk(folder, topdown=topdown,
  191. onerror=onerror, followlinks=followlinks)
  192. return [os.path.abspath(os.path.join(r, f))
  193. for r, d, fs in oswalk
  194. for f in fs if not f.startswith(() if showhidden else ".") and
  195. not f.endswith(omit) and f.endswith(target)]
  196. def main():
  197. """Main Loop."""
  198. args = make_arguments_parser()
  199. if os.path.isfile(args.fullpath) and args.fullpath.endswith(".css"):
  200. print("Target is a CSS File.") # Work based on if argument is
  201. list_of_files = str(args.fullpath) # file or folder, folder is slower.
  202. process_single_css_file(
  203. args.fullpath, wrap=args.wrap, timestamp=args.timestamp,
  204. comments=args.comments, sort=args.sort, overwrite=args.overwrite,
  205. zipy=args.zipy, prefix=args.prefix, add_hash=args.hash)
  206. elif os.path.isfile(args.fullpath) and args.fullpath.endswith(".js"):
  207. print("Target is a JS File.")
  208. list_of_files = str(args.fullpath)
  209. process_single_js_file(
  210. args.fullpath, timestamp=args.timestamp,
  211. overwrite=args.overwrite, zipy=args.zipy)
  212. elif os.path.isdir(args.fullpath):
  213. print("Target is a Folder with CSS, JS files !.")
  214. print("Processing a whole Folder may take some time...")
  215. list_of_files = walk2list(
  216. args.fullpath,
  217. (".css", ".js" if args.overwrite else None),
  218. (".min.css", ".min.js" if args.overwrite else None))
  219. print("Total Maximum CPUs used: ~%s Cores." % cpu_count())
  220. pool = Pool(cpu_count()) # Multiprocessing Async
  221. pool.map_async(partial(
  222. process_multiple_files, watch=args.watch,
  223. wrap=args.wrap, timestamp=args.timestamp,
  224. comments=args.comments, sort=args.sort,
  225. overwrite=args.overwrite, zipy=args.zipy,
  226. prefix=args.prefix, add_hash=args.hash),
  227. list_of_files)
  228. pool.close()
  229. pool.join()
  230. else:
  231. print("File or folder not found,or cant be read,or I/O Error.")
  232. sys.exit(1)
  233. if args.after and getoutput:
  234. print(getoutput(str(args.after)))
  235. print("\n %s \n Files Processed: %s" % ("-" * 80, list_of_files))
  236. print("Number of Files Processed: %s" %
  237. (len(list_of_files) if isinstance(list_of_files, tuple) else 1))