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.
 
 
 
 
 
 

422 rindas
11 KiB

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