upgrade_extension.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344
  1. # Copyright (c) Jupyter Development Team.
  2. # Distributed under the terms of the Modified BSD License.
  3. import configparser
  4. import json
  5. import re
  6. import shutil
  7. import subprocess
  8. import sys
  9. from typing import Optional
  10. try:
  11. import tomllib
  12. except ImportError:
  13. import tomli as tomllib
  14. from importlib.resources import files
  15. from pathlib import Path
  16. try:
  17. import copier
  18. except ModuleNotFoundError:
  19. msg = "Please install copier; you can use `pip install jupyterlab[upgrade-extension]`"
  20. raise RuntimeError(msg) from None
  21. # List of files recommended to be overridden
  22. RECOMMENDED_TO_OVERRIDE = [
  23. ".github/workflows/binder-on-pr.yml",
  24. ".github/workflows/build.yml",
  25. ".github/workflows/check-release.yml",
  26. ".github/workflows/enforce-label.yml",
  27. ".github/workflows/prep-release.yml",
  28. ".github/workflows/publish-release.yml",
  29. ".github/workflows/update-integration-tests.yml",
  30. "binder/postBuild",
  31. ".eslintignore",
  32. ".eslintrc.js",
  33. ".gitignore",
  34. ".prettierignore",
  35. ".prettierrc",
  36. ".stylelintrc",
  37. "RELEASE.md",
  38. "babel.config.js",
  39. "conftest.py",
  40. "jest.config.js",
  41. "pyproject.toml",
  42. "setup.py",
  43. "tsconfig.json",
  44. "tsconfig.test.json",
  45. "ui-tests/README.md",
  46. "ui-tests/jupyter_server_test_config.py",
  47. "ui-tests/package.json",
  48. "ui-tests/playwright.config.js",
  49. ]
  50. JUPYTER_SERVER_REQUIREMENT = re.compile("^jupyter_server([^\\w]|$)")
  51. def update_extension( # noqa
  52. target: str, vcs_ref: Optional[str] = None, interactive: bool = True
  53. ) -> None:
  54. """Update an extension to the current JupyterLab
  55. target: str
  56. Path to the extension directory containing the extension
  57. vcs_ref: str [default: None]
  58. Template vcs_ref to checkout
  59. interactive: bool [default: true]
  60. Whether to ask before overwriting content
  61. """
  62. # Input is a directory with a package.json or the current directory
  63. # Use the extension template as the source
  64. # Pull in the relevant config
  65. # Pull in the Python parts if possible
  66. # Pull in the scripts if possible
  67. target = Path(target).resolve()
  68. package_file = target / "package.json"
  69. pyproject_file = target / "pyproject.toml"
  70. setup_file = target / "setup.py"
  71. if not package_file.exists():
  72. msg = f"No package.json exists in {target!s}"
  73. raise RuntimeError(msg)
  74. # Infer the options from the current directory
  75. with open(package_file) as fid:
  76. data = json.load(fid)
  77. python_name = None
  78. if pyproject_file.exists():
  79. pyproject = tomllib.loads(pyproject_file.read_text())
  80. python_name = pyproject.get("project", {}).get("name")
  81. if python_name is None:
  82. if setup_file.exists():
  83. python_name = (
  84. subprocess.check_output( # noqa: S603
  85. [sys.executable, "setup.py", "--name"],
  86. cwd=target,
  87. )
  88. .decode("utf8")
  89. .strip()
  90. )
  91. else:
  92. python_name = data["name"]
  93. if "@" in python_name:
  94. python_name = python_name[1:]
  95. # Clean up the name to be valid package module name
  96. python_name = python_name.replace("/", "_").replace("-", "_")
  97. output_dir = target / "_temp_extension"
  98. if output_dir.exists():
  99. shutil.rmtree(output_dir)
  100. # Build up the template answers and run the template engine
  101. author = data.get("author", "<author_name>")
  102. author_email = ""
  103. if isinstance(author, dict):
  104. author_name = author.get("name", "<author_name>")
  105. author_email = author.get("email", author_email)
  106. else:
  107. author_name = author
  108. kind = "frontend"
  109. if (target / "jupyter-config").exists():
  110. kind = "server"
  111. elif data.get("jupyterlab", {}).get("themePath", ""):
  112. kind = "theme"
  113. has_test = (
  114. (target / "conftest.py").exists()
  115. or (target / "jest.config.js").exists()
  116. or (target / "ui-tests").exists()
  117. )
  118. extra_context = {
  119. "kind": kind,
  120. "author_name": author_name,
  121. "author_email": author_email,
  122. "labextension_name": data["name"],
  123. "python_name": python_name,
  124. "project_short_description": data.get("description", "<description>"),
  125. "has_settings": bool(data.get("jupyterlab", {}).get("schemaDir", "")),
  126. "has_binder": bool((target / "binder").exists()),
  127. "test": bool(has_test),
  128. "repository": data.get("repository", {}).get("url", "<repository"),
  129. }
  130. template = "https://github.com/jupyterlab/extension-template"
  131. if tuple(copier.__version__.split(".")) < ("8", "0", "0"):
  132. copier.run_auto(template, output_dir, vcs_ref=vcs_ref, data=extra_context, defaults=True)
  133. else:
  134. copier.run_copy(
  135. template, output_dir, vcs_ref=vcs_ref, data=extra_context, defaults=True, unsafe=True
  136. )
  137. # From the created package.json grab the devDependencies
  138. with (output_dir / "package.json").open() as fid:
  139. temp_data = json.load(fid)
  140. if data.get("devDependencies"):
  141. for key, value in temp_data["devDependencies"].items():
  142. data["devDependencies"][key] = value
  143. else:
  144. data["devDependencies"] = temp_data["devDependencies"].copy()
  145. # Ask the user whether to upgrade the scripts automatically
  146. warnings = []
  147. choice = input("Overwrite scripts in package.json? [n]: ") if interactive else "y"
  148. if choice.upper().startswith("Y"):
  149. warnings.append("Updated scripts in package.json")
  150. data.setdefault("scripts", {})
  151. for key, value in temp_data["scripts"].items():
  152. data["scripts"][key] = value
  153. if "install-ext" in data["scripts"]:
  154. del data["scripts"]["install-ext"]
  155. if "prepare" in data["scripts"]:
  156. del data["scripts"]["prepare"]
  157. else:
  158. warnings.append("package.json scripts must be updated manually")
  159. # Set the output directory
  160. data["jupyterlab"]["outputDir"] = temp_data["jupyterlab"]["outputDir"]
  161. # Set linters
  162. ## Map package.json key to previous config file
  163. linters = {
  164. "eslintConfig": ".eslintrc.js",
  165. "eslintIgnore": ".eslintignore",
  166. "prettier": ".prettierrc",
  167. "stylelint": ".stylelintrc",
  168. }
  169. for key, file in linters.items():
  170. if key in temp_data:
  171. data[key] = temp_data[key]
  172. linter_file = target / file
  173. if linter_file.exists():
  174. linter_file.unlink()
  175. warnings.append(f"DELETED {file}")
  176. # Look for resolutions in JupyterLab metadata and upgrade those as well
  177. root_jlab_package = files("jupyterlab").joinpath("staging/package.json")
  178. with root_jlab_package.open() as fid:
  179. root_jlab_data = json.load(fid)
  180. data.setdefault("dependencies", {})
  181. data.setdefault("devDependencies", {})
  182. for key, value in root_jlab_data["resolutions"].items():
  183. if key in data["dependencies"]:
  184. data["dependencies"][key] = value.replace("~", "^")
  185. if key in data["devDependencies"]:
  186. data["devDependencies"][key] = value.replace("~", "^")
  187. # Sort the entries
  188. for key in ["scripts", "dependencies", "devDependencies"]:
  189. if data[key]:
  190. data[key] = dict(sorted(data[key].items()))
  191. else:
  192. del data[key]
  193. # Update style settings
  194. data.setdefault("styleModule", "style/index.js")
  195. if isinstance(data.get("sideEffects"), list) and "style/index.js" not in data["sideEffects"]:
  196. data["sideEffects"].append("style/index.js")
  197. if "files" in data and "style/index.js" not in data["files"]:
  198. data["files"].append("style/index.js")
  199. # Update the root package.json file
  200. package_file.write_text(json.dumps(data, indent=2))
  201. override_pyproject = False
  202. # For the other files, ask about whether to override (when it exists)
  203. # At the end, list the files that were: added, overridden, skipped
  204. for p in output_dir.rglob("*"):
  205. relpath = p.relative_to(output_dir)
  206. if str(relpath) == "package.json":
  207. continue
  208. if p.is_dir():
  209. continue
  210. file_target = target / relpath
  211. if not file_target.exists():
  212. file_target.parent.mkdir(parents=True, exist_ok=True)
  213. shutil.copy(p, file_target)
  214. if file_target.name == "pyproject.toml":
  215. override_pyproject = True
  216. else:
  217. old_data = p.read_bytes()
  218. new_data = file_target.read_bytes()
  219. if old_data == new_data:
  220. continue
  221. default = "y" if relpath.as_posix() in RECOMMENDED_TO_OVERRIDE else "n"
  222. choice = (
  223. (input(f'overwrite "{relpath!s}"? [{default}]: ') or default)
  224. if interactive
  225. else "n"
  226. )
  227. if choice.upper().startswith("Y"):
  228. shutil.copy(p, file_target)
  229. if file_target.name == "pyproject.toml":
  230. override_pyproject = True
  231. else:
  232. warnings.append(f"skipped _temp_extension/{relpath!s}")
  233. if override_pyproject:
  234. if (target / "setup.cfg").exists():
  235. try:
  236. import tomli_w
  237. except ImportError:
  238. msg = "To update pyproject.toml, you need to install tomli-w"
  239. print(msg)
  240. else:
  241. config = configparser.ConfigParser()
  242. with (target / "setup.cfg").open() as setup_cfg_file:
  243. config.read_file(setup_cfg_file)
  244. pyproject_file = target / "pyproject.toml"
  245. pyproject = tomllib.loads(pyproject_file.read_text())
  246. # Backport requirements
  247. requirements_raw = config.get("options", "install_requires", fallback=None)
  248. if requirements_raw is not None:
  249. requirements = list(
  250. filter(
  251. lambda r: r and JUPYTER_SERVER_REQUIREMENT.match(r) is None,
  252. requirements_raw.splitlines(),
  253. )
  254. )
  255. else:
  256. requirements = []
  257. pyproject["project"]["dependencies"] = (
  258. pyproject["project"].get("dependencies", []) + requirements
  259. )
  260. # Backport extras
  261. if config.has_section("options.extras_require"):
  262. for extra, deps_raw in config.items("options.extras_require"):
  263. deps = list(filter(lambda r: r, deps_raw.splitlines()))
  264. if extra in pyproject["project"].get("optional-dependencies", {}):
  265. if pyproject["project"].get("optional-dependencies") is None:
  266. pyproject["project"]["optional-dependencies"] = {}
  267. deps = pyproject["project"]["optional-dependencies"][extra] + deps
  268. pyproject["project"]["optional-dependencies"][extra] = deps
  269. pyproject_file.write_text(tomli_w.dumps(pyproject))
  270. (target / "setup.cfg").unlink()
  271. warnings.append("DELETED setup.cfg")
  272. manifest_in = target / "MANIFEST.in"
  273. if manifest_in.exists():
  274. manifest_in.unlink()
  275. warnings.append("DELETED MANIFEST.in")
  276. # Print out all warnings
  277. for warning in warnings:
  278. print("**", warning)
  279. print("** Remove _temp_extensions directory when finished")
  280. if __name__ == "__main__":
  281. import argparse
  282. parser = argparse.ArgumentParser(description="Upgrade a JupyterLab extension")
  283. parser.add_argument("--no-input", action="store_true", help="whether to prompt for information")
  284. parser.add_argument("path", action="store", type=str, help="the target path")
  285. parser.add_argument("--vcs-ref", help="the template hash to checkout", default=None)
  286. args = parser.parse_args()
  287. answer_file = Path(args.path) / ".copier-answers.yml"
  288. if answer_file.exists():
  289. msg = "This script won't do anything for copier template, instead execute in your extension directory:\n\n copier update"
  290. if tuple(copier.__version__.split(".")) >= ("8", "0", "0"):
  291. msg += " --trust"
  292. print(msg)
  293. else:
  294. update_extension(args.path, args.vcs_ref, args.no_input is False)