Ви не можете вибрати більше 25 тем Теми мають розпочинатися з літери або цифри, можуть містити дефіси (-) і не повинні перевищувати 35 символів.
 
 
 
 
 
 

507 рядки
14 KiB

  1. # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
  2. # MIT License. See license.txt
  3. import os
  4. import re
  5. import json
  6. import shutil
  7. import subprocess
  8. from tempfile import mkdtemp, mktemp
  9. from distutils.spawn import find_executable
  10. import frappe
  11. from frappe.utils.minify import JavascriptMinify
  12. import click
  13. import psutil
  14. from urllib.parse import urlparse
  15. from simple_chalk import green
  16. from semantic_version import Version
  17. timestamps = {}
  18. app_paths = None
  19. sites_path = os.path.abspath(os.getcwd())
  20. def download_file(url, prefix):
  21. from requests import get
  22. filename = urlparse(url).path.split("/")[-1]
  23. local_filename = os.path.join(prefix, filename)
  24. with get(url, stream=True, allow_redirects=True) as r:
  25. r.raise_for_status()
  26. with open(local_filename, "wb") as f:
  27. for chunk in r.iter_content(chunk_size=8192):
  28. f.write(chunk)
  29. return local_filename
  30. def build_missing_files():
  31. '''Check which files dont exist yet from the assets.json and run build for those files'''
  32. missing_assets = []
  33. current_asset_files = []
  34. for type in ["css", "js"]:
  35. folder = os.path.join(sites_path, "assets", "frappe", "dist", type)
  36. current_asset_files.extend(os.listdir(folder))
  37. development = frappe.local.conf.developer_mode or frappe.local.dev_server
  38. build_mode = "development" if development else "production"
  39. assets_json = frappe.read_file(frappe.get_app_path('frappe', 'public', 'dist', 'assets.json'))
  40. if assets_json:
  41. assets_json = frappe.parse_json(assets_json)
  42. for bundle_file, output_file in assets_json.items():
  43. if not output_file.startswith('/assets/frappe'):
  44. continue
  45. if os.path.basename(output_file) not in current_asset_files:
  46. missing_assets.append(bundle_file)
  47. if missing_assets:
  48. click.secho("\nBuilding missing assets...\n", fg="yellow")
  49. files_to_build = ["frappe/" + name for name in missing_assets]
  50. bundle(build_mode, files=files_to_build)
  51. else:
  52. # no assets.json, run full build
  53. bundle(build_mode, apps="frappe")
  54. def get_assets_link(frappe_head):
  55. from subprocess import getoutput
  56. from requests import head
  57. tag = getoutput(
  58. r"cd ../apps/frappe && git show-ref --tags -d | grep %s | sed -e 's,.*"
  59. r" refs/tags/,,' -e 's/\^{}//'"
  60. % frappe_head
  61. )
  62. if tag:
  63. # if tag exists, download assets from github release
  64. url = "https://github.com/frappe/frappe/releases/download/{0}/assets.tar.gz".format(tag)
  65. else:
  66. url = "http://assets.frappeframework.com/{0}.tar.gz".format(frappe_head)
  67. if not head(url):
  68. raise ValueError("URL {0} doesn't exist".format(url))
  69. return url
  70. def download_frappe_assets(verbose=True):
  71. """Downloads and sets up Frappe assets if they exist based on the current
  72. commit HEAD.
  73. Returns True if correctly setup else returns False.
  74. """
  75. from subprocess import getoutput
  76. assets_setup = False
  77. frappe_head = getoutput("cd ../apps/frappe && git rev-parse HEAD")
  78. if frappe_head:
  79. try:
  80. url = get_assets_link(frappe_head)
  81. click.secho("Retrieving assets...", fg="yellow")
  82. prefix = mkdtemp(prefix="frappe-assets-", suffix=frappe_head)
  83. assets_archive = download_file(url, prefix)
  84. print("\n{0} Downloaded Frappe assets from {1}".format(green('✔'), url))
  85. if assets_archive:
  86. import tarfile
  87. directories_created = set()
  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. asset_directory = os.path.dirname(dest)
  94. show = dest.replace("./assets/", "")
  95. if asset_directory not in directories_created:
  96. if not os.path.exists(asset_directory):
  97. os.makedirs(asset_directory, exist_ok=True)
  98. directories_created.add(asset_directory)
  99. tar.makefile(file, dest)
  100. print("{0} Restored {1}".format(green('✔'), show))
  101. build_missing_files()
  102. return True
  103. else:
  104. raise
  105. except Exception:
  106. # TODO: log traceback in bench.log
  107. click.secho("An Error occurred while downloading assets...", fg="red")
  108. assets_setup = False
  109. finally:
  110. try:
  111. shutil.rmtree(os.path.dirname(assets_archive))
  112. except Exception:
  113. pass
  114. return assets_setup
  115. def symlink(target, link_name, overwrite=False):
  116. """
  117. Create a symbolic link named link_name pointing to target.
  118. If link_name exists then FileExistsError is raised, unless overwrite=True.
  119. When trying to overwrite a directory, IsADirectoryError is raised.
  120. Source: https://stackoverflow.com/a/55742015/10309266
  121. """
  122. if not overwrite:
  123. return os.symlink(target, link_name)
  124. # os.replace() may fail if files are on different filesystems
  125. link_dir = os.path.dirname(link_name)
  126. # Create link to target with temporary filename
  127. while True:
  128. temp_link_name = mktemp(dir=link_dir)
  129. # os.* functions mimic as closely as possible system functions
  130. # The POSIX symlink() returns EEXIST if link_name already exists
  131. # https://pubs.opengroup.org/onlinepubs/9699919799/functions/symlink.html
  132. try:
  133. os.symlink(target, temp_link_name)
  134. break
  135. except FileExistsError:
  136. pass
  137. # Replace link_name with temp_link_name
  138. try:
  139. # Pre-empt os.replace on a directory with a nicer message
  140. if os.path.isdir(link_name):
  141. raise IsADirectoryError("Cannot symlink over existing directory: '{}'".format(link_name))
  142. try:
  143. os.replace(temp_link_name, link_name)
  144. except AttributeError:
  145. os.renames(temp_link_name, link_name)
  146. except:
  147. if os.path.islink(temp_link_name):
  148. os.remove(temp_link_name)
  149. raise
  150. def setup():
  151. global app_paths, assets_path
  152. pymodules = []
  153. for app in frappe.get_all_apps(True):
  154. try:
  155. pymodules.append(frappe.get_module(app))
  156. except ImportError:
  157. pass
  158. app_paths = [os.path.dirname(pymodule.__file__) for pymodule in pymodules]
  159. assets_path = os.path.join(frappe.local.sites_path, "assets")
  160. def bundle(mode, apps=None, hard_link=False, make_copy=False, restore=False, verbose=False, skip_frappe=False, files=None):
  161. """concat / minify js files"""
  162. setup()
  163. make_asset_dirs(hard_link=hard_link)
  164. mode = "production" if mode == "production" else "build"
  165. command = "yarn run {mode}".format(mode=mode)
  166. if apps:
  167. command += " --apps {apps}".format(apps=apps)
  168. if skip_frappe:
  169. command += " --skip_frappe"
  170. if files:
  171. command += " --files {files}".format(files=','.join(files))
  172. command += " --run-build-command"
  173. check_node_executable()
  174. frappe_app_path = frappe.get_app_path("frappe", "..")
  175. frappe.commands.popen(command, cwd=frappe_app_path, env=get_node_env())
  176. def watch(apps=None):
  177. """watch and rebuild if necessary"""
  178. setup()
  179. command = "yarn run watch"
  180. if apps:
  181. command += " --apps {apps}".format(apps=apps)
  182. check_node_executable()
  183. frappe_app_path = frappe.get_app_path("frappe", "..")
  184. frappe.commands.popen(command, cwd=frappe_app_path, env=get_node_env())
  185. def check_node_executable():
  186. node_version = Version(subprocess.getoutput('node -v')[1:])
  187. warn = '⚠️ '
  188. if node_version.major < 14:
  189. click.echo(f"{warn} Please update your node version to 14")
  190. if not find_executable("yarn"):
  191. click.echo(f"{warn} Please install yarn using below command and try again.\nnpm install -g yarn")
  192. click.echo()
  193. def get_node_env():
  194. node_env = {
  195. "NODE_OPTIONS": f"--max_old_space_size={get_safe_max_old_space_size()}"
  196. }
  197. return node_env
  198. def get_safe_max_old_space_size():
  199. safe_max_old_space_size = 0
  200. try:
  201. total_memory = psutil.virtual_memory().total / (1024 * 1024)
  202. # reference for the safe limit assumption
  203. # https://nodejs.org/api/cli.html#cli_max_old_space_size_size_in_megabytes
  204. # set minimum value 1GB
  205. safe_max_old_space_size = max(1024, int(total_memory * 0.75))
  206. except Exception:
  207. pass
  208. return safe_max_old_space_size
  209. def generate_assets_map():
  210. symlinks = {}
  211. for app_name in frappe.get_all_apps():
  212. app_doc_path = None
  213. pymodule = frappe.get_module(app_name)
  214. app_base_path = os.path.abspath(os.path.dirname(pymodule.__file__))
  215. app_public_path = os.path.join(app_base_path, "public")
  216. app_node_modules_path = os.path.join(app_base_path, "..", "node_modules")
  217. app_docs_path = os.path.join(app_base_path, "docs")
  218. app_www_docs_path = os.path.join(app_base_path, "www", "docs")
  219. app_assets = os.path.abspath(app_public_path)
  220. app_node_modules = os.path.abspath(app_node_modules_path)
  221. # {app}/public > assets/{app}
  222. if os.path.isdir(app_assets):
  223. symlinks[app_assets] = os.path.join(assets_path, app_name)
  224. # {app}/node_modules > assets/{app}/node_modules
  225. if os.path.isdir(app_node_modules):
  226. symlinks[app_node_modules] = os.path.join(assets_path, app_name, "node_modules")
  227. # {app}/docs > assets/{app}_docs
  228. if os.path.isdir(app_docs_path):
  229. app_doc_path = os.path.join(app_base_path, "docs")
  230. elif os.path.isdir(app_www_docs_path):
  231. app_doc_path = os.path.join(app_base_path, "www", "docs")
  232. if app_doc_path:
  233. app_docs = os.path.abspath(app_doc_path)
  234. symlinks[app_docs] = os.path.join(assets_path, app_name + "_docs")
  235. return symlinks
  236. def setup_assets_dirs():
  237. for dir_path in (os.path.join(assets_path, x) for x in ("js", "css")):
  238. os.makedirs(dir_path, exist_ok=True)
  239. def clear_broken_symlinks():
  240. for path in os.listdir(assets_path):
  241. path = os.path.join(assets_path, path)
  242. if os.path.islink(path) and not os.path.exists(path):
  243. os.remove(path)
  244. def unstrip(message: str) -> str:
  245. """Pads input string on the right side until the last available column in the terminal
  246. """
  247. _len = len(message)
  248. try:
  249. max_str = os.get_terminal_size().columns
  250. except Exception:
  251. max_str = 80
  252. if _len < max_str:
  253. _rem = max_str - _len
  254. else:
  255. _rem = max_str % _len
  256. return f"{message}{' ' * _rem}"
  257. def make_asset_dirs(hard_link=False):
  258. setup_assets_dirs()
  259. clear_broken_symlinks()
  260. symlinks = generate_assets_map()
  261. for source, target in symlinks.items():
  262. start_message = unstrip(f"{'Copying assets from' if hard_link else 'Linking'} {source} to {target}")
  263. fail_message = unstrip(f"Cannot {'copy' if hard_link else 'link'} {source} to {target}")
  264. # Used '\r' instead of '\x1b[1K\r' to print entire lines in smaller terminal sizes
  265. try:
  266. print(start_message, end="\r")
  267. link_assets_dir(source, target, hard_link=hard_link)
  268. except Exception:
  269. print(fail_message, end="\r")
  270. print(unstrip(f"{green('✔')} Application Assets Linked") + "\n")
  271. def link_assets_dir(source, target, hard_link=False):
  272. if not os.path.exists(source):
  273. return
  274. if os.path.exists(target):
  275. if os.path.islink(target):
  276. os.unlink(target)
  277. else:
  278. shutil.rmtree(target)
  279. if hard_link:
  280. shutil.copytree(source, target, dirs_exist_ok=True)
  281. else:
  282. symlink(source, target, overwrite=True)
  283. def build(no_compress=False, verbose=False):
  284. for target, sources in get_build_maps().items():
  285. pack(os.path.join(assets_path, target), sources, no_compress, verbose)
  286. def get_build_maps():
  287. """get all build.jsons with absolute paths"""
  288. # framework js and css files
  289. build_maps = {}
  290. for app_path in app_paths:
  291. path = os.path.join(app_path, "public", "build.json")
  292. if os.path.exists(path):
  293. with open(path) as f:
  294. try:
  295. for target, sources in (json.loads(f.read() or "{}")).items():
  296. # update app path
  297. source_paths = []
  298. for source in sources:
  299. if isinstance(source, list):
  300. s = frappe.get_pymodule_path(source[0], *source[1].split("/"))
  301. else:
  302. s = os.path.join(app_path, source)
  303. source_paths.append(s)
  304. build_maps[target] = source_paths
  305. except ValueError as e:
  306. print(path)
  307. print("JSON syntax error {0}".format(str(e)))
  308. return build_maps
  309. def pack(target, sources, no_compress, verbose):
  310. from six import StringIO
  311. outtype, outtxt = target.split(".")[-1], ""
  312. jsm = JavascriptMinify()
  313. for f in sources:
  314. suffix = None
  315. if ":" in f:
  316. f, suffix = f.split(":")
  317. if not os.path.exists(f) or os.path.isdir(f):
  318. print("did not find " + f)
  319. continue
  320. timestamps[f] = os.path.getmtime(f)
  321. try:
  322. with open(f, "r") as sourcefile:
  323. data = str(sourcefile.read(), "utf-8", errors="ignore")
  324. extn = f.rsplit(".", 1)[1]
  325. if (
  326. outtype == "js"
  327. and extn == "js"
  328. and (not no_compress)
  329. and suffix != "concat"
  330. and (".min." not in f)
  331. ):
  332. tmpin, tmpout = StringIO(data.encode("utf-8")), StringIO()
  333. jsm.minify(tmpin, tmpout)
  334. minified = tmpout.getvalue()
  335. if minified:
  336. outtxt += str(minified or "", "utf-8").strip("\n") + ";"
  337. if verbose:
  338. print("{0}: {1}k".format(f, int(len(minified) / 1024)))
  339. elif outtype == "js" and extn == "html":
  340. # add to frappe.templates
  341. outtxt += html_to_js_template(f, data)
  342. else:
  343. outtxt += "\n/*\n *\t%s\n */" % f
  344. outtxt += "\n" + data + "\n"
  345. except Exception:
  346. print("--Error in:" + f + "--")
  347. print(frappe.get_traceback())
  348. with open(target, "w") as f:
  349. f.write(outtxt.encode("utf-8"))
  350. print("Wrote %s - %sk" % (target, str(int(os.path.getsize(target) / 1024))))
  351. def html_to_js_template(path, content):
  352. """returns HTML template content as Javascript code, adding it to `frappe.templates`"""
  353. return """frappe.templates["{key}"] = '{content}';\n""".format(
  354. key=path.rsplit("/", 1)[-1][:-5], content=scrub_html_template(content))
  355. def scrub_html_template(content):
  356. """Returns HTML content with removed whitespace and comments"""
  357. # remove whitespace to a single space
  358. content = re.sub(r"\s+", " ", content)
  359. # strip comments
  360. content = re.sub(r"(<!--.*?-->)", "", content)
  361. return content.replace("'", "\'")
  362. def files_dirty():
  363. for target, sources in get_build_maps().items():
  364. for f in sources:
  365. if ":" in f:
  366. f, suffix = f.split(":")
  367. if not os.path.exists(f) or os.path.isdir(f):
  368. continue
  369. if os.path.getmtime(f) != timestamps.get(f):
  370. print(f + " dirty")
  371. return True
  372. else:
  373. return False
  374. def compile_less():
  375. if not find_executable("lessc"):
  376. return
  377. for path in app_paths:
  378. less_path = os.path.join(path, "public", "less")
  379. if os.path.exists(less_path):
  380. for fname in os.listdir(less_path):
  381. if fname.endswith(".less") and fname != "variables.less":
  382. fpath = os.path.join(less_path, fname)
  383. mtime = os.path.getmtime(fpath)
  384. if fpath in timestamps and mtime == timestamps[fpath]:
  385. continue
  386. timestamps[fpath] = mtime
  387. print("compiling {0}".format(fpath))
  388. css_path = os.path.join(path, "public", "css", fname.rsplit(".", 1)[0] + ".css")
  389. os.system("lessc {0} > {1}".format(fpath, css_path))