You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

441 lines
12 KiB

  1. # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
  2. # MIT License. See license.txt
  3. from __future__ import print_function, unicode_literals
  4. import os
  5. import re
  6. import json
  7. import shutil
  8. import warnings
  9. import tempfile
  10. from distutils.spawn import find_executable
  11. import frappe
  12. from frappe.utils.minify import JavascriptMinify
  13. import click
  14. from requests import get
  15. from six import iteritems, text_type
  16. from six.moves.urllib.parse import urlparse
  17. timestamps = {}
  18. app_paths = None
  19. sites_path = os.path.abspath(os.getcwd())
  20. def download_file(url, prefix):
  21. filename = urlparse(url).path.split("/")[-1]
  22. local_filename = os.path.join(prefix, filename)
  23. with get(url, stream=True, allow_redirects=True) as r:
  24. r.raise_for_status()
  25. with open(local_filename, "wb") as f:
  26. for chunk in r.iter_content(chunk_size=8192):
  27. f.write(chunk)
  28. return local_filename
  29. def build_missing_files():
  30. # check which files dont exist yet from the build.json and tell build.js to build only those!
  31. missing_assets = []
  32. current_asset_files = []
  33. for type in ["css", "js"]:
  34. current_asset_files.extend(
  35. [
  36. "{0}/{1}".format(type, name)
  37. for name in os.listdir(os.path.join(sites_path, "assets", type))
  38. ]
  39. )
  40. with open(os.path.join(sites_path, "assets", "frappe", "build.json")) as f:
  41. all_asset_files = json.load(f).keys()
  42. for asset in all_asset_files:
  43. if asset.replace("concat:", "") not in current_asset_files:
  44. missing_assets.append(asset)
  45. if missing_assets:
  46. from subprocess import check_call
  47. from shlex import split
  48. click.secho("\nBuilding missing assets...\n", fg="yellow")
  49. command = split(
  50. "node rollup/build.js --files {0} --no-concat".format(",".join(missing_assets))
  51. )
  52. check_call(command, cwd=os.path.join("..", "apps", "frappe"))
  53. def get_assets_link(frappe_head):
  54. from subprocess import getoutput
  55. from requests import head
  56. tag = getoutput(
  57. "cd ../apps/frappe && git show-ref --tags -d | grep %s | sed -e 's,.*"
  58. " refs/tags/,,' -e 's/\^{}//'"
  59. % frappe_head
  60. )
  61. if tag:
  62. # if tag exists, download assets from github release
  63. url = "https://github.com/frappe/frappe/releases/download/{0}/assets.tar.gz".format(tag)
  64. else:
  65. url = "http://assets.frappeframework.com/{0}.tar.gz".format(frappe_head)
  66. if not head(url):
  67. raise ValueError("URL {0} doesn't exist".format(url))
  68. return url
  69. def download_frappe_assets(verbose=True):
  70. """Downloads and sets up Frappe assets if they exist based on the current
  71. commit HEAD.
  72. Returns True if correctly setup else returns False.
  73. """
  74. from simple_chalk import green
  75. from subprocess import getoutput
  76. from tempfile import mkdtemp
  77. assets_setup = False
  78. frappe_head = getoutput("cd ../apps/frappe && git rev-parse HEAD")
  79. if frappe_head:
  80. try:
  81. url = get_assets_link(frappe_head)
  82. click.secho("Retreiving assets...", fg="yellow")
  83. prefix = mkdtemp(prefix="frappe-assets-", suffix=frappe_head)
  84. assets_archive = download_file(url, prefix)
  85. print("\n{0} Downloaded Frappe assets from {1}".format(green('✔'), url))
  86. if assets_archive:
  87. import tarfile
  88. click.secho("\nExtracting assets...\n", fg="yellow")
  89. with tarfile.open(assets_archive) as tar:
  90. for file in tar:
  91. if not file.isdir():
  92. dest = "." + file.name.replace("./frappe-bench/sites", "")
  93. show = dest.replace("./assets/", "")
  94. tar.makefile(file, dest)
  95. print("{0} Restored {1}".format(green('✔'), show))
  96. build_missing_files()
  97. return True
  98. else:
  99. raise
  100. except Exception:
  101. # TODO: log traceback in bench.log
  102. click.secho("An Error occurred while downloading assets...", fg="red")
  103. assets_setup = False
  104. finally:
  105. try:
  106. shutil.rmtree(os.path.dirname(assets_archive))
  107. except Exception:
  108. pass
  109. return assets_setup
  110. def symlink(target, link_name, overwrite=False):
  111. """
  112. Create a symbolic link named link_name pointing to target.
  113. If link_name exists then FileExistsError is raised, unless overwrite=True.
  114. When trying to overwrite a directory, IsADirectoryError is raised.
  115. Source: https://stackoverflow.com/a/55742015/10309266
  116. """
  117. if not overwrite:
  118. return os.symlink(target, link_name)
  119. # os.replace() may fail if files are on different filesystems
  120. link_dir = os.path.dirname(link_name)
  121. # Create link to target with temporary filename
  122. while True:
  123. temp_link_name = tempfile.mktemp(dir=link_dir)
  124. # os.* functions mimic as closely as possible system functions
  125. # The POSIX symlink() returns EEXIST if link_name already exists
  126. # https://pubs.opengroup.org/onlinepubs/9699919799/functions/symlink.html
  127. try:
  128. os.symlink(target, temp_link_name)
  129. break
  130. except FileExistsError:
  131. pass
  132. # Replace link_name with temp_link_name
  133. try:
  134. # Pre-empt os.replace on a directory with a nicer message
  135. if os.path.isdir(link_name):
  136. raise IsADirectoryError("Cannot symlink over existing directory: '{}'".format(link_name))
  137. try:
  138. os.replace(temp_link_name, link_name)
  139. except AttributeError:
  140. os.renames(temp_link_name, link_name)
  141. except:
  142. if os.path.islink(temp_link_name):
  143. os.remove(temp_link_name)
  144. raise
  145. def setup():
  146. global app_paths
  147. pymodules = []
  148. for app in frappe.get_all_apps(True):
  149. try:
  150. pymodules.append(frappe.get_module(app))
  151. except ImportError:
  152. pass
  153. app_paths = [os.path.dirname(pymodule.__file__) for pymodule in pymodules]
  154. def get_node_pacman():
  155. exec_ = find_executable("yarn")
  156. if exec_:
  157. return exec_
  158. raise ValueError("Yarn not found")
  159. def bundle(no_compress, app=None, make_copy=False, restore=False, verbose=False, skip_frappe=False):
  160. """concat / minify js files"""
  161. setup()
  162. make_asset_dirs(make_copy=make_copy, restore=restore)
  163. pacman = get_node_pacman()
  164. mode = "build" if no_compress else "production"
  165. command = "{pacman} run {mode}".format(pacman=pacman, mode=mode)
  166. if app:
  167. command += " --app {app}".format(app=app)
  168. if skip_frappe:
  169. command += " --skip_frappe"
  170. frappe_app_path = os.path.abspath(os.path.join(app_paths[0], ".."))
  171. check_yarn()
  172. frappe.commands.popen(command, cwd=frappe_app_path)
  173. def watch(no_compress):
  174. """watch and rebuild if necessary"""
  175. setup()
  176. pacman = get_node_pacman()
  177. frappe_app_path = os.path.abspath(os.path.join(app_paths[0], ".."))
  178. check_yarn()
  179. frappe_app_path = frappe.get_app_path("frappe", "..")
  180. frappe.commands.popen("{pacman} run watch".format(pacman=pacman), cwd=frappe_app_path)
  181. def check_yarn():
  182. if not find_executable("yarn"):
  183. print("Please install yarn using below command and try again.\nnpm install -g yarn")
  184. def make_asset_dirs(make_copy=False, restore=False):
  185. # don't even think of making assets_path absolute - rm -rf ahead.
  186. assets_path = os.path.join(frappe.local.sites_path, "assets")
  187. for dir_path in [os.path.join(assets_path, "js"), os.path.join(assets_path, "css")]:
  188. if not os.path.exists(dir_path):
  189. os.makedirs(dir_path)
  190. for app_name in frappe.get_all_apps(True):
  191. pymodule = frappe.get_module(app_name)
  192. app_base_path = os.path.abspath(os.path.dirname(pymodule.__file__))
  193. symlinks = []
  194. app_public_path = os.path.join(app_base_path, "public")
  195. # app/public > assets/app
  196. symlinks.append([app_public_path, os.path.join(assets_path, app_name)])
  197. # app/node_modules > assets/app/node_modules
  198. if os.path.exists(os.path.abspath(app_public_path)):
  199. symlinks.append(
  200. [
  201. os.path.join(app_base_path, "..", "node_modules"),
  202. os.path.join(assets_path, app_name, "node_modules"),
  203. ]
  204. )
  205. app_doc_path = None
  206. if os.path.isdir(os.path.join(app_base_path, "docs")):
  207. app_doc_path = os.path.join(app_base_path, "docs")
  208. elif os.path.isdir(os.path.join(app_base_path, "www", "docs")):
  209. app_doc_path = os.path.join(app_base_path, "www", "docs")
  210. if app_doc_path:
  211. symlinks.append([app_doc_path, os.path.join(assets_path, app_name + "_docs")])
  212. for source, target in symlinks:
  213. source = os.path.abspath(source)
  214. if os.path.exists(source):
  215. if restore:
  216. if os.path.exists(target):
  217. if os.path.islink(target):
  218. os.unlink(target)
  219. else:
  220. shutil.rmtree(target)
  221. shutil.copytree(source, target)
  222. elif make_copy:
  223. if os.path.exists(target):
  224. warnings.warn("Target {target} already exists.".format(target=target))
  225. else:
  226. shutil.copytree(source, target)
  227. else:
  228. if os.path.exists(target):
  229. if os.path.islink(target):
  230. os.unlink(target)
  231. else:
  232. shutil.rmtree(target)
  233. try:
  234. symlink(source, target, overwrite=True)
  235. except OSError:
  236. print("Cannot link {} to {}".format(source, target))
  237. else:
  238. # warnings.warn('Source {source} does not exist.'.format(source = source))
  239. pass
  240. def build(no_compress=False, verbose=False):
  241. assets_path = os.path.join(frappe.local.sites_path, "assets")
  242. for target, sources in iteritems(get_build_maps()):
  243. pack(os.path.join(assets_path, target), sources, no_compress, verbose)
  244. def get_build_maps():
  245. """get all build.jsons with absolute paths"""
  246. # framework js and css files
  247. build_maps = {}
  248. for app_path in app_paths:
  249. path = os.path.join(app_path, "public", "build.json")
  250. if os.path.exists(path):
  251. with open(path) as f:
  252. try:
  253. for target, sources in iteritems(json.loads(f.read())):
  254. # update app path
  255. source_paths = []
  256. for source in sources:
  257. if isinstance(source, list):
  258. s = frappe.get_pymodule_path(source[0], *source[1].split("/"))
  259. else:
  260. s = os.path.join(app_path, source)
  261. source_paths.append(s)
  262. build_maps[target] = source_paths
  263. except ValueError as e:
  264. print(path)
  265. print("JSON syntax error {0}".format(str(e)))
  266. return build_maps
  267. def pack(target, sources, no_compress, verbose):
  268. from six import StringIO
  269. outtype, outtxt = target.split(".")[-1], ""
  270. jsm = JavascriptMinify()
  271. for f in sources:
  272. suffix = None
  273. if ":" in f:
  274. f, suffix = f.split(":")
  275. if not os.path.exists(f) or os.path.isdir(f):
  276. print("did not find " + f)
  277. continue
  278. timestamps[f] = os.path.getmtime(f)
  279. try:
  280. with open(f, "r") as sourcefile:
  281. data = text_type(sourcefile.read(), "utf-8", errors="ignore")
  282. extn = f.rsplit(".", 1)[1]
  283. if (
  284. outtype == "js"
  285. and extn == "js"
  286. and (not no_compress)
  287. and suffix != "concat"
  288. and (".min." not in f)
  289. ):
  290. tmpin, tmpout = StringIO(data.encode("utf-8")), StringIO()
  291. jsm.minify(tmpin, tmpout)
  292. minified = tmpout.getvalue()
  293. if minified:
  294. outtxt += text_type(minified or "", "utf-8").strip("\n") + ";"
  295. if verbose:
  296. print("{0}: {1}k".format(f, int(len(minified) / 1024)))
  297. elif outtype == "js" and extn == "html":
  298. # add to frappe.templates
  299. outtxt += html_to_js_template(f, data)
  300. else:
  301. outtxt += "\n/*\n *\t%s\n */" % f
  302. outtxt += "\n" + data + "\n"
  303. except Exception:
  304. print("--Error in:" + f + "--")
  305. print(frappe.get_traceback())
  306. with open(target, "w") as f:
  307. f.write(outtxt.encode("utf-8"))
  308. print("Wrote %s - %sk" % (target, str(int(os.path.getsize(target) / 1024))))
  309. def html_to_js_template(path, content):
  310. """returns HTML template content as Javascript code, adding it to `frappe.templates`"""
  311. return """frappe.templates["{key}"] = '{content}';\n""".format(
  312. key=path.rsplit("/", 1)[-1][:-5], content=scrub_html_template(content))
  313. def scrub_html_template(content):
  314. """Returns HTML content with removed whitespace and comments"""
  315. # remove whitespace to a single space
  316. content = re.sub("\s+", " ", content)
  317. # strip comments
  318. content = re.sub("(<!--.*?-->)", "", content)
  319. return content.replace("'", "\'")
  320. def files_dirty():
  321. for target, sources in iteritems(get_build_maps()):
  322. for f in sources:
  323. if ":" in f:
  324. f, suffix = f.split(":")
  325. if not os.path.exists(f) or os.path.isdir(f):
  326. continue
  327. if os.path.getmtime(f) != timestamps.get(f):
  328. print(f + " dirty")
  329. return True
  330. else:
  331. return False
  332. def compile_less():
  333. if not find_executable("lessc"):
  334. return
  335. for path in app_paths:
  336. less_path = os.path.join(path, "public", "less")
  337. if os.path.exists(less_path):
  338. for fname in os.listdir(less_path):
  339. if fname.endswith(".less") and fname != "variables.less":
  340. fpath = os.path.join(less_path, fname)
  341. mtime = os.path.getmtime(fpath)
  342. if fpath in timestamps and mtime == timestamps[fpath]:
  343. continue
  344. timestamps[fpath] = mtime
  345. print("compiling {0}".format(fpath))
  346. css_path = os.path.join(path, "public", "css", fname.rsplit(".", 1)[0] + ".css")
  347. os.system("lessc {0} > {1}".format(fpath, css_path))