Nevar pievienot vairāk kā 25 tēmas Tēmai ir jāsākas ar burtu vai ciparu, tā var saturēt domu zīmes ('-') un var būt līdz 35 simboliem gara.
 
 
 
 
 
 

414 rindas
11 KiB

  1. # Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
  2. # License: MIT. See LICENSE
  3. import os
  4. import shutil
  5. import re
  6. import subprocess
  7. from distutils.spawn import find_executable
  8. from subprocess import getoutput
  9. from tempfile import mkdtemp, mktemp
  10. from urllib.parse import urlparse
  11. import click
  12. import psutil
  13. from requests import head
  14. from requests.exceptions import HTTPError
  15. from semantic_version import Version
  16. import frappe
  17. timestamps = {}
  18. app_paths = None
  19. sites_path = os.path.abspath(os.getcwd())
  20. class AssetsNotDownloadedError(Exception):
  21. pass
  22. class AssetsDontExistError(HTTPError):
  23. pass
  24. def download_file(url, prefix):
  25. from requests import get
  26. filename = urlparse(url).path.split("/")[-1]
  27. local_filename = os.path.join(prefix, filename)
  28. with get(url, stream=True, allow_redirects=True) as r:
  29. r.raise_for_status()
  30. with open(local_filename, "wb") as f:
  31. for chunk in r.iter_content(chunk_size=8192):
  32. f.write(chunk)
  33. return local_filename
  34. def build_missing_files():
  35. '''Check which files dont exist yet from the assets.json and run build for those files'''
  36. missing_assets = []
  37. current_asset_files = []
  38. for type in ["css", "js"]:
  39. folder = os.path.join(sites_path, "assets", "frappe", "dist", type)
  40. current_asset_files.extend(os.listdir(folder))
  41. development = frappe.local.conf.developer_mode or frappe.local.dev_server
  42. build_mode = "development" if development else "production"
  43. assets_json = frappe.read_file("assets/assets.json")
  44. if assets_json:
  45. assets_json = frappe.parse_json(assets_json)
  46. for bundle_file, output_file in assets_json.items():
  47. if not output_file.startswith('/assets/frappe'):
  48. continue
  49. if os.path.basename(output_file) not in current_asset_files:
  50. missing_assets.append(bundle_file)
  51. if missing_assets:
  52. click.secho("\nBuilding missing assets...\n", fg="yellow")
  53. files_to_build = ["frappe/" + name for name in missing_assets]
  54. bundle(build_mode, files=files_to_build)
  55. else:
  56. # no assets.json, run full build
  57. bundle(build_mode, apps="frappe")
  58. def get_assets_link(frappe_head) -> str:
  59. tag = getoutput(
  60. r"cd ../apps/frappe && git show-ref --tags -d | grep %s | sed -e 's,.*"
  61. r" refs/tags/,,' -e 's/\^{}//'"
  62. % frappe_head
  63. )
  64. if tag:
  65. # if tag exists, download assets from github release
  66. url = f"https://github.com/frappe/frappe/releases/download/{tag}/assets.tar.gz"
  67. else:
  68. url = f"http://assets.frappeframework.com/{frappe_head}.tar.gz"
  69. if not head(url):
  70. reference = f"Release {tag}" if tag else f"Commit {frappe_head}"
  71. raise AssetsDontExistError(f"Assets for {reference} don't exist")
  72. return url
  73. def fetch_assets(url, frappe_head):
  74. click.secho("Retrieving assets...", fg="yellow")
  75. prefix = mkdtemp(prefix="frappe-assets-", suffix=frappe_head)
  76. assets_archive = download_file(url, prefix)
  77. if not assets_archive:
  78. raise AssetsNotDownloadedError(f"Assets could not be retrived from {url}")
  79. click.echo(click.style("✔", fg="green") + f" Downloaded Frappe assets from {url}")
  80. return assets_archive
  81. def setup_assets(assets_archive):
  82. import tarfile
  83. directories_created = set()
  84. click.secho("\nExtracting assets...\n", fg="yellow")
  85. with tarfile.open(assets_archive) as tar:
  86. for file in tar:
  87. if not file.isdir():
  88. dest = "." + file.name.replace("./frappe-bench/sites", "")
  89. asset_directory = os.path.dirname(dest)
  90. show = dest.replace("./assets/", "")
  91. if asset_directory not in directories_created:
  92. if not os.path.exists(asset_directory):
  93. os.makedirs(asset_directory, exist_ok=True)
  94. directories_created.add(asset_directory)
  95. tar.makefile(file, dest)
  96. click.echo(click.style("✔", fg="green") + f" Restored {show}")
  97. return directories_created
  98. def download_frappe_assets(verbose=True):
  99. """Downloads and sets up Frappe assets if they exist based on the current
  100. commit HEAD.
  101. Returns True if correctly setup else returns False.
  102. """
  103. frappe_head = getoutput("cd ../apps/frappe && git rev-parse HEAD")
  104. if not frappe_head:
  105. return False
  106. try:
  107. url = get_assets_link(frappe_head)
  108. assets_archive = fetch_assets(url, frappe_head)
  109. setup_assets(assets_archive)
  110. build_missing_files()
  111. return True
  112. except AssetsDontExistError as e:
  113. click.secho(str(e), fg="yellow")
  114. except Exception as e:
  115. # TODO: log traceback in bench.log
  116. click.secho(str(e), fg="red")
  117. finally:
  118. try:
  119. shutil.rmtree(os.path.dirname(assets_archive))
  120. except Exception:
  121. pass
  122. return False
  123. def symlink(target, link_name, overwrite=False):
  124. """
  125. Create a symbolic link named link_name pointing to target.
  126. If link_name exists then FileExistsError is raised, unless overwrite=True.
  127. When trying to overwrite a directory, IsADirectoryError is raised.
  128. Source: https://stackoverflow.com/a/55742015/10309266
  129. """
  130. if not overwrite:
  131. return os.symlink(target, link_name)
  132. # os.replace() may fail if files are on different filesystems
  133. link_dir = os.path.dirname(link_name)
  134. # Create link to target with temporary filename
  135. while True:
  136. temp_link_name = mktemp(dir=link_dir)
  137. # os.* functions mimic as closely as possible system functions
  138. # The POSIX symlink() returns EEXIST if link_name already exists
  139. # https://pubs.opengroup.org/onlinepubs/9699919799/functions/symlink.html
  140. try:
  141. os.symlink(target, temp_link_name)
  142. break
  143. except FileExistsError:
  144. pass
  145. # Replace link_name with temp_link_name
  146. try:
  147. # Pre-empt os.replace on a directory with a nicer message
  148. if os.path.isdir(link_name):
  149. raise IsADirectoryError("Cannot symlink over existing directory: '{}'".format(link_name))
  150. try:
  151. os.replace(temp_link_name, link_name)
  152. except AttributeError:
  153. os.renames(temp_link_name, link_name)
  154. except:
  155. if os.path.islink(temp_link_name):
  156. os.remove(temp_link_name)
  157. raise
  158. def setup():
  159. global app_paths, assets_path
  160. pymodules = []
  161. for app in frappe.get_all_apps(True):
  162. try:
  163. pymodules.append(frappe.get_module(app))
  164. except ImportError:
  165. pass
  166. app_paths = [os.path.dirname(pymodule.__file__) for pymodule in pymodules]
  167. assets_path = os.path.join(frappe.local.sites_path, "assets")
  168. def bundle(mode, apps=None, hard_link=False, make_copy=False, restore=False, verbose=False, skip_frappe=False, files=None):
  169. """concat / minify js files"""
  170. setup()
  171. make_asset_dirs(hard_link=hard_link)
  172. mode = "production" if mode == "production" else "build"
  173. command = "yarn run {mode}".format(mode=mode)
  174. if apps:
  175. command += " --apps {apps}".format(apps=apps)
  176. if skip_frappe:
  177. command += " --skip_frappe"
  178. if files:
  179. command += " --files {files}".format(files=','.join(files))
  180. command += " --run-build-command"
  181. check_node_executable()
  182. frappe_app_path = frappe.get_app_path("frappe", "..")
  183. frappe.commands.popen(command, cwd=frappe_app_path, env=get_node_env(), raise_err=True)
  184. def watch(apps=None):
  185. """watch and rebuild if necessary"""
  186. setup()
  187. command = "yarn run watch"
  188. if apps:
  189. command += " --apps {apps}".format(apps=apps)
  190. live_reload = frappe.utils.cint(
  191. os.environ.get("LIVE_RELOAD", frappe.conf.live_reload)
  192. )
  193. if live_reload:
  194. command += " --live-reload"
  195. check_node_executable()
  196. frappe_app_path = frappe.get_app_path("frappe", "..")
  197. frappe.commands.popen(command, cwd=frappe_app_path, env=get_node_env())
  198. def check_node_executable():
  199. node_version = Version(subprocess.getoutput('node -v')[1:])
  200. warn = '⚠️ '
  201. if node_version.major < 14:
  202. click.echo(f"{warn} Please update your node version to 14")
  203. if not find_executable("yarn"):
  204. click.echo(f"{warn} Please install yarn using below command and try again.\nnpm install -g yarn")
  205. click.echo()
  206. def get_node_env():
  207. node_env = {
  208. "NODE_OPTIONS": f"--max_old_space_size={get_safe_max_old_space_size()}"
  209. }
  210. return node_env
  211. def get_safe_max_old_space_size():
  212. safe_max_old_space_size = 0
  213. try:
  214. total_memory = psutil.virtual_memory().total / (1024 * 1024)
  215. # reference for the safe limit assumption
  216. # https://nodejs.org/api/cli.html#cli_max_old_space_size_size_in_megabytes
  217. # set minimum value 1GB
  218. safe_max_old_space_size = max(1024, int(total_memory * 0.75))
  219. except Exception:
  220. pass
  221. return safe_max_old_space_size
  222. def generate_assets_map():
  223. symlinks = {}
  224. for app_name in frappe.get_all_apps():
  225. app_doc_path = None
  226. pymodule = frappe.get_module(app_name)
  227. app_base_path = os.path.abspath(os.path.dirname(pymodule.__file__))
  228. app_public_path = os.path.join(app_base_path, "public")
  229. app_node_modules_path = os.path.join(app_base_path, "..", "node_modules")
  230. app_docs_path = os.path.join(app_base_path, "docs")
  231. app_www_docs_path = os.path.join(app_base_path, "www", "docs")
  232. app_assets = os.path.abspath(app_public_path)
  233. app_node_modules = os.path.abspath(app_node_modules_path)
  234. # {app}/public > assets/{app}
  235. if os.path.isdir(app_assets):
  236. symlinks[app_assets] = os.path.join(assets_path, app_name)
  237. # {app}/node_modules > assets/{app}/node_modules
  238. if os.path.isdir(app_node_modules):
  239. symlinks[app_node_modules] = os.path.join(assets_path, app_name, "node_modules")
  240. # {app}/docs > assets/{app}_docs
  241. if os.path.isdir(app_docs_path):
  242. app_doc_path = os.path.join(app_base_path, "docs")
  243. elif os.path.isdir(app_www_docs_path):
  244. app_doc_path = os.path.join(app_base_path, "www", "docs")
  245. if app_doc_path:
  246. app_docs = os.path.abspath(app_doc_path)
  247. symlinks[app_docs] = os.path.join(assets_path, app_name + "_docs")
  248. return symlinks
  249. def setup_assets_dirs():
  250. for dir_path in (os.path.join(assets_path, x) for x in ("js", "css")):
  251. os.makedirs(dir_path, exist_ok=True)
  252. def clear_broken_symlinks():
  253. for path in os.listdir(assets_path):
  254. path = os.path.join(assets_path, path)
  255. if os.path.islink(path) and not os.path.exists(path):
  256. os.remove(path)
  257. def unstrip(message: str) -> str:
  258. """Pads input string on the right side until the last available column in the terminal
  259. """
  260. _len = len(message)
  261. try:
  262. max_str = os.get_terminal_size().columns
  263. except Exception:
  264. max_str = 80
  265. if _len < max_str:
  266. _rem = max_str - _len
  267. else:
  268. _rem = max_str % _len
  269. return f"{message}{' ' * _rem}"
  270. def make_asset_dirs(hard_link=False):
  271. setup_assets_dirs()
  272. clear_broken_symlinks()
  273. symlinks = generate_assets_map()
  274. for source, target in symlinks.items():
  275. start_message = unstrip(f"{'Copying assets from' if hard_link else 'Linking'} {source} to {target}")
  276. fail_message = unstrip(f"Cannot {'copy' if hard_link else 'link'} {source} to {target}")
  277. # Used '\r' instead of '\x1b[1K\r' to print entire lines in smaller terminal sizes
  278. try:
  279. print(start_message, end="\r")
  280. link_assets_dir(source, target, hard_link=hard_link)
  281. except Exception:
  282. print(fail_message, end="\r")
  283. click.echo(unstrip(click.style("✔", fg="green") + " Application Assets Linked") + "\n")
  284. def link_assets_dir(source, target, hard_link=False):
  285. if not os.path.exists(source):
  286. return
  287. if os.path.exists(target):
  288. if os.path.islink(target):
  289. os.unlink(target)
  290. else:
  291. shutil.rmtree(target)
  292. if hard_link:
  293. shutil.copytree(source, target, dirs_exist_ok=True)
  294. else:
  295. symlink(source, target, overwrite=True)
  296. def scrub_html_template(content):
  297. """Returns HTML content with removed whitespace and comments"""
  298. # remove whitespace to a single space
  299. content = re.sub(r"\s+", " ", content)
  300. # strip comments
  301. content = re.sub(r"(<!--.*?-->)", "", content)
  302. return content.replace("'", "\'")
  303. def html_to_js_template(path, content):
  304. """returns HTML template content as Javascript code, adding it to `frappe.templates`"""
  305. return """frappe.templates["{key}"] = '{content}';\n""".format(
  306. key=path.rsplit("/", 1)[-1][:-5], content=scrub_html_template(content))