migrate.py 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282
  1. # PYTHON_ARGCOMPLETE_OK
  2. """Migrating IPython < 4.0 to Jupyter
  3. This *copies* configuration and resources to their new locations in Jupyter
  4. Migrations:
  5. - .ipython/
  6. - nbextensions -> JUPYTER_DATA_DIR/nbextensions
  7. - kernels -> JUPYTER_DATA_DIR/kernels
  8. - .ipython/profile_default/
  9. - static/custom -> .jupyter/custom
  10. - nbconfig -> .jupyter/nbconfig
  11. - security/
  12. - notebook_secret, notebook_cookie_secret, nbsignatures.db -> JUPYTER_DATA_DIR
  13. - ipython_{notebook,nbconvert,qtconsole}_config.py -> .jupyter/jupyter_{name}_config.py
  14. """
  15. # Copyright (c) Jupyter Development Team.
  16. # Distributed under the terms of the Modified BSD License.
  17. from __future__ import annotations
  18. import os
  19. import re
  20. import shutil
  21. from datetime import datetime, timezone
  22. from pathlib import Path
  23. from typing import Any
  24. from traitlets.config.loader import JSONFileConfigLoader, PyFileConfigLoader
  25. from traitlets.log import get_logger
  26. from .application import JupyterApp
  27. from .paths import jupyter_config_dir, jupyter_data_dir
  28. from .utils import ensure_dir_exists
  29. # mypy: disable-error-code="no-untyped-call"
  30. migrations = {
  31. str(Path("{ipython_dir}", "nbextensions")): str(Path("{jupyter_data}", "nbextensions")),
  32. str(Path("{ipython_dir}", "kernels")): str(Path("{jupyter_data}", "kernels")),
  33. str(Path("{profile}", "nbconfig")): str(Path("{jupyter_config}", "nbconfig")),
  34. }
  35. custom_src_t = str(Path("{profile}", "static", "custom"))
  36. custom_dst_t = str(Path("{jupyter_config}", "custom"))
  37. for security_file in ("notebook_secret", "notebook_cookie_secret", "nbsignatures.db"):
  38. src = str(Path("{profile}", "security", security_file))
  39. dst = str(Path("{jupyter_data}", security_file))
  40. migrations[src] = dst
  41. config_migrations = ["notebook", "nbconvert", "qtconsole"]
  42. regex = re.compile
  43. config_substitutions = {
  44. regex(r"\bIPythonQtConsoleApp\b"): "JupyterQtConsoleApp",
  45. regex(r"\bIPythonWidget\b"): "JupyterWidget",
  46. regex(r"\bRichIPythonWidget\b"): "RichJupyterWidget",
  47. regex(r"\bIPython\.html\b"): "notebook",
  48. regex(r"\bIPython\.nbconvert\b"): "nbconvert",
  49. }
  50. def get_ipython_dir() -> str:
  51. """Return the IPython directory location.
  52. Not imported from IPython because the IPython implementation
  53. ensures that a writable directory exists,
  54. creating a temporary directory if not.
  55. We don't want to trigger that when checking if migration should happen.
  56. We only need to support the IPython < 4 behavior for migration,
  57. so importing for forward-compatibility and edge cases is not important.
  58. """
  59. return os.environ.get("IPYTHONDIR", str(Path("~/.ipython").expanduser()))
  60. def migrate_dir(src: str, dst: str) -> bool:
  61. """Migrate a directory from src to dst"""
  62. log = get_logger()
  63. src_path = Path(src)
  64. dst_path = Path(dst)
  65. if not any(src_path.iterdir()):
  66. log.debug("No files in %s", src)
  67. return False
  68. if dst_path.exists():
  69. if any(dst_path.iterdir()):
  70. # already exists, non-empty
  71. log.debug("%s already exists", dst)
  72. return False
  73. dst_path.rmdir()
  74. log.info("Copying %s -> %s", src, dst)
  75. ensure_dir_exists(dst_path.parent)
  76. shutil.copytree(src, dst, symlinks=True)
  77. return True
  78. def migrate_file(src: str | Path, dst: str | Path, substitutions: Any = None) -> bool:
  79. """Migrate a single file from src to dst
  80. substitutions is an optional dict of {regex: replacement} for performing replacements on the file.
  81. """
  82. log = get_logger()
  83. dst_path = Path(dst)
  84. if dst_path.exists():
  85. # already exists
  86. log.debug("%s already exists", dst)
  87. return False
  88. log.info("Copying %s -> %s", src, dst)
  89. ensure_dir_exists(dst_path.parent)
  90. shutil.copy(src, dst)
  91. if substitutions:
  92. with dst_path.open() as f:
  93. text = f.read()
  94. for pat, replacement in substitutions.items():
  95. text = pat.sub(replacement, text)
  96. with dst_path.open("w") as f:
  97. f.write(text)
  98. return True
  99. def migrate_one(src: str, dst: str) -> bool:
  100. """Migrate one item
  101. dispatches to migrate_dir/_file
  102. """
  103. log = get_logger()
  104. if Path(src).is_file():
  105. return migrate_file(src, dst)
  106. if Path(src).is_dir():
  107. return migrate_dir(src, dst)
  108. log.debug("Nothing to migrate for %s", src)
  109. return False
  110. def migrate_static_custom(src: str, dst: str) -> bool:
  111. """Migrate non-empty custom.js,css from src to dst
  112. src, dst are 'custom' directories containing custom.{js,css}
  113. """
  114. log = get_logger()
  115. migrated = False
  116. custom_js = Path(src, "custom.js")
  117. custom_css = Path(src, "custom.css")
  118. # check if custom_js is empty:
  119. custom_js_empty = True
  120. if Path(custom_js).is_file():
  121. with Path.open(custom_js, encoding="utf-8") as f:
  122. js = f.read().strip()
  123. for line in js.splitlines():
  124. if not (line.isspace() or line.strip().startswith(("/*", "*", "//"))):
  125. custom_js_empty = False
  126. break
  127. # check if custom_css is empty:
  128. custom_css_empty = True
  129. if Path(custom_css).is_file():
  130. with Path.open(custom_css, encoding="utf-8") as f:
  131. css = f.read().strip()
  132. custom_css_empty = css.startswith("/*") and css.endswith("*/")
  133. if custom_js_empty:
  134. log.debug("Ignoring empty %s", custom_js)
  135. if custom_css_empty:
  136. log.debug("Ignoring empty %s", custom_css)
  137. if custom_js_empty and custom_css_empty:
  138. # nothing to migrate
  139. return False
  140. ensure_dir_exists(dst)
  141. if not custom_js_empty or not custom_css_empty:
  142. ensure_dir_exists(dst)
  143. if not custom_js_empty and migrate_file(custom_js, Path(dst, "custom.js")):
  144. migrated = True
  145. if not custom_css_empty and migrate_file(custom_css, Path(dst, "custom.css")):
  146. migrated = True
  147. return migrated
  148. def migrate_config(name: str, env: Any) -> list[Any]:
  149. """Migrate a config file.
  150. Includes substitutions for updated configurable names.
  151. """
  152. log = get_logger()
  153. src_base = str(Path(f"{env['profile']}", f"ipython_{name}_config"))
  154. dst_base = str(Path(f"{env['jupyter_config']}", f"jupyter_{name}_config"))
  155. loaders = {
  156. ".py": PyFileConfigLoader,
  157. ".json": JSONFileConfigLoader,
  158. }
  159. migrated = []
  160. for ext in (".py", ".json"):
  161. src = src_base + ext
  162. dst = dst_base + ext
  163. if Path(src).exists():
  164. cfg = loaders[ext](src).load_config()
  165. if cfg:
  166. if migrate_file(src, dst, substitutions=config_substitutions):
  167. migrated.append(src)
  168. else:
  169. # don't migrate empty config files
  170. log.debug("Not migrating empty config file: %s", src)
  171. return migrated
  172. def migrate() -> bool:
  173. """Migrate IPython configuration to Jupyter"""
  174. env = {
  175. "jupyter_data": jupyter_data_dir(),
  176. "jupyter_config": jupyter_config_dir(),
  177. "ipython_dir": get_ipython_dir(),
  178. "profile": str(Path(get_ipython_dir(), "profile_default")),
  179. }
  180. migrated = False
  181. for src_t, dst_t in migrations.items():
  182. src = src_t.format(**env)
  183. dst = dst_t.format(**env)
  184. if Path(src).exists() and migrate_one(src, dst):
  185. migrated = True
  186. for name in config_migrations:
  187. if migrate_config(name, env):
  188. migrated = True
  189. custom_src = custom_src_t.format(**env)
  190. custom_dst = custom_dst_t.format(**env)
  191. if Path(custom_src).exists() and migrate_static_custom(custom_src, custom_dst):
  192. migrated = True
  193. # write a marker to avoid re-running migration checks
  194. ensure_dir_exists(env["jupyter_config"])
  195. with Path.open(Path(env["jupyter_config"], "migrated"), "w", encoding="utf-8") as f:
  196. f.write(datetime.now(tz=timezone.utc).isoformat())
  197. return migrated
  198. class JupyterMigrate(JupyterApp):
  199. """A Jupyter Migration App."""
  200. name = "jupyter-migrate"
  201. description = """
  202. Migrate configuration and data from .ipython prior to 4.0 to Jupyter locations.
  203. This migrates:
  204. - config files in the default profile
  205. - kernels in ~/.ipython/kernels
  206. - notebook javascript extensions in ~/.ipython/extensions
  207. - custom.js/css to .jupyter/custom
  208. to their new Jupyter locations.
  209. All files are copied, not moved.
  210. If the destinations already exist, nothing will be done.
  211. """
  212. def start(self) -> None:
  213. """Start the application."""
  214. if not migrate():
  215. self.log.info("Found nothing to migrate.")
  216. main = JupyterMigrate.launch_instance
  217. if __name__ == "__main__":
  218. main()