consoleapp.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378
  1. """A minimal application base mixin for all ZMQ based IPython frontends.
  2. This is not a complete console app, as subprocess will not be able to receive
  3. input, there is no real readline support, among other limitations. This is a
  4. refactoring of what used to be the IPython/qt/console/qtconsoleapp.py
  5. """
  6. # Copyright (c) Jupyter Development Team.
  7. # Distributed under the terms of the Modified BSD License.
  8. import atexit
  9. import os
  10. import signal
  11. import sys
  12. import typing as t
  13. import uuid
  14. import warnings
  15. from jupyter_core.application import base_aliases, base_flags
  16. from traitlets import CBool, CUnicode, Dict, List, Type, Unicode
  17. from traitlets.config.application import boolean_flag
  18. from . import KernelManager, connect, find_connection_file, tunnel_to_kernel
  19. from .blocking import BlockingKernelClient
  20. from .connect import KernelConnectionInfo
  21. from .kernelspec import NoSuchKernel
  22. from .localinterfaces import localhost
  23. from .restarter import KernelRestarter
  24. from .session import Session
  25. from .utils import _filefind
  26. ConnectionFileMixin = connect.ConnectionFileMixin
  27. # -----------------------------------------------------------------------------
  28. # Aliases and Flags
  29. # -----------------------------------------------------------------------------
  30. flags: dict = {}
  31. flags.update(base_flags)
  32. # the flags that are specific to the frontend
  33. # these must be scrubbed before being passed to the kernel,
  34. # or it will raise an error on unrecognized flags
  35. app_flags: dict = {
  36. "existing": (
  37. {"JupyterConsoleApp": {"existing": "kernel*.json"}},
  38. "Connect to an existing kernel. If no argument specified, guess most recent",
  39. ),
  40. }
  41. app_flags.update(
  42. boolean_flag(
  43. "confirm-exit",
  44. "JupyterConsoleApp.confirm_exit",
  45. """Set to display confirmation dialog on exit. You can always use 'exit' or
  46. 'quit', to force a direct exit without any confirmation. This can also
  47. be set in the config file by setting
  48. `c.JupyterConsoleApp.confirm_exit`.
  49. """,
  50. """Don't prompt the user when exiting. This will terminate the kernel
  51. if it is owned by the frontend, and leave it alive if it is external.
  52. This can also be set in the config file by setting
  53. `c.JupyterConsoleApp.confirm_exit`.
  54. """,
  55. )
  56. )
  57. flags.update(app_flags)
  58. aliases: dict = {}
  59. aliases.update(base_aliases)
  60. # also scrub aliases from the frontend
  61. app_aliases: dict = {
  62. "ip": "JupyterConsoleApp.ip",
  63. "transport": "JupyterConsoleApp.transport",
  64. "hb": "JupyterConsoleApp.hb_port",
  65. "shell": "JupyterConsoleApp.shell_port",
  66. "iopub": "JupyterConsoleApp.iopub_port",
  67. "stdin": "JupyterConsoleApp.stdin_port",
  68. "control": "JupyterConsoleApp.control_port",
  69. "existing": "JupyterConsoleApp.existing",
  70. "f": "JupyterConsoleApp.connection_file",
  71. "kernel": "JupyterConsoleApp.kernel_name",
  72. "ssh": "JupyterConsoleApp.sshserver",
  73. "sshkey": "JupyterConsoleApp.sshkey",
  74. }
  75. aliases.update(app_aliases)
  76. # -----------------------------------------------------------------------------
  77. # Classes
  78. # -----------------------------------------------------------------------------
  79. classes: t.List[t.Type[t.Any]] = [KernelManager, KernelRestarter, Session]
  80. class JupyterConsoleApp(ConnectionFileMixin):
  81. """The base Jupyter console application."""
  82. name: t.Union[str, Unicode] = "jupyter-console-mixin"
  83. description: t.Union[str, Unicode] = """
  84. The Jupyter Console Mixin.
  85. This class contains the common portions of console client (QtConsole,
  86. ZMQ-based terminal console, etc). It is not a full console, in that
  87. launched terminal subprocesses will not be able to accept input.
  88. The Console using this mixing supports various extra features beyond
  89. the single-process Terminal IPython shell, such as connecting to
  90. existing kernel, via:
  91. jupyter console <appname> --existing
  92. as well as tunnel via SSH
  93. """
  94. classes = classes
  95. flags = Dict(flags)
  96. aliases = Dict(aliases)
  97. kernel_manager_class = Type(
  98. default_value=KernelManager,
  99. config=True,
  100. help="The kernel manager class to use.",
  101. )
  102. kernel_client_class = BlockingKernelClient
  103. kernel_argv = List(Unicode())
  104. # connection info:
  105. sshserver = Unicode("", config=True, help="""The SSH server to use to connect to the kernel.""")
  106. sshkey = Unicode(
  107. "",
  108. config=True,
  109. help="""Path to the ssh key to use for logging in to the ssh server.""",
  110. )
  111. def _connection_file_default(self) -> str:
  112. return "kernel-%i.json" % os.getpid()
  113. existing = CUnicode("", config=True, help="""Connect to an already running kernel""")
  114. kernel_name = Unicode(
  115. "python", config=True, help="""The name of the default kernel to start."""
  116. )
  117. confirm_exit = CBool(
  118. True,
  119. config=True,
  120. help="""
  121. Set to display confirmation dialog on exit. You can always use 'exit' or 'quit',
  122. to force a direct exit without any confirmation.""",
  123. )
  124. def build_kernel_argv(self, argv: object = None) -> None:
  125. """build argv to be passed to kernel subprocess
  126. Override in subclasses if any args should be passed to the kernel
  127. """
  128. self.kernel_argv = self.extra_args # type:ignore[attr-defined]
  129. def init_connection_file(self) -> None:
  130. """find the connection file, and load the info if found.
  131. The current working directory and the current profile's security
  132. directory will be searched for the file if it is not given by
  133. absolute path.
  134. When attempting to connect to an existing kernel and the `--existing`
  135. argument does not match an existing file, it will be interpreted as a
  136. fileglob, and the matching file in the current profile's security dir
  137. with the latest access time will be used.
  138. After this method is called, self.connection_file contains the *full path*
  139. to the connection file, never just its name.
  140. """
  141. runtime_dir = self.runtime_dir # type:ignore[attr-defined]
  142. if self.existing:
  143. try:
  144. cf = find_connection_file(self.existing, [".", runtime_dir])
  145. except Exception:
  146. self.log.critical(
  147. "Could not find existing kernel connection file %s", self.existing
  148. )
  149. self.exit(1) # type:ignore[attr-defined]
  150. self.log.debug("Connecting to existing kernel: %s", cf)
  151. self.connection_file = cf
  152. else:
  153. # not existing, check if we are going to write the file
  154. # and ensure that self.connection_file is a full path, not just the shortname
  155. try:
  156. cf = find_connection_file(self.connection_file, [runtime_dir])
  157. except Exception:
  158. # file might not exist
  159. if self.connection_file == os.path.basename(self.connection_file):
  160. # just shortname, put it in security dir
  161. cf = os.path.join(runtime_dir, self.connection_file)
  162. else:
  163. cf = self.connection_file
  164. self.connection_file = cf
  165. try:
  166. self.connection_file = _filefind(self.connection_file, [".", runtime_dir])
  167. except OSError:
  168. self.log.debug("Connection File not found: %s", self.connection_file)
  169. return
  170. # should load_connection_file only be used for existing?
  171. # as it is now, this allows reusing ports if an existing
  172. # file is requested
  173. try:
  174. self.load_connection_file()
  175. except Exception:
  176. self.log.error(
  177. "Failed to load connection file: %r",
  178. self.connection_file,
  179. exc_info=True,
  180. )
  181. self.exit(1) # type:ignore[attr-defined]
  182. def init_ssh(self) -> None:
  183. """set up ssh tunnels, if needed."""
  184. if not self.existing or (not self.sshserver and not self.sshkey):
  185. return
  186. self.load_connection_file()
  187. transport = self.transport
  188. ip = self.ip
  189. if transport != "tcp":
  190. self.log.error("Can only use ssh tunnels with TCP sockets, not %s", transport)
  191. sys.exit(-1)
  192. if self.sshkey and not self.sshserver:
  193. # specifying just the key implies that we are connecting directly
  194. self.sshserver = ip
  195. ip = localhost()
  196. # build connection dict for tunnels:
  197. info: KernelConnectionInfo = {
  198. "ip": ip,
  199. "shell_port": self.shell_port,
  200. "iopub_port": self.iopub_port,
  201. "stdin_port": self.stdin_port,
  202. "hb_port": self.hb_port,
  203. "control_port": self.control_port,
  204. }
  205. self.log.info("Forwarding connections to %s via %s", ip, self.sshserver)
  206. # tunnels return a new set of ports, which will be on localhost:
  207. self.ip = localhost()
  208. try:
  209. newports = tunnel_to_kernel(info, self.sshserver, self.sshkey)
  210. except: # noqa
  211. # even catch KeyboardInterrupt
  212. self.log.error("Could not setup tunnels", exc_info=True)
  213. self.exit(1) # type:ignore[attr-defined]
  214. (
  215. self.shell_port,
  216. self.iopub_port,
  217. self.stdin_port,
  218. self.hb_port,
  219. self.control_port,
  220. ) = newports
  221. cf = self.connection_file
  222. root, ext = os.path.splitext(cf)
  223. self.connection_file = root + "-ssh" + ext
  224. self.write_connection_file() # write the new connection file
  225. self.log.info("To connect another client via this tunnel, use:")
  226. self.log.info("--existing %s", os.path.basename(self.connection_file))
  227. def _new_connection_file(self) -> str:
  228. cf = ""
  229. while not cf:
  230. # we don't need a 128b id to distinguish kernels, use more readable
  231. # 48b node segment (12 hex chars). Users running more than 32k simultaneous
  232. # kernels can subclass.
  233. ident = str(uuid.uuid4()).split("-")[-1]
  234. runtime_dir = self.runtime_dir # type:ignore[attr-defined]
  235. cf = os.path.join(runtime_dir, "kernel-%s.json" % ident)
  236. # only keep if it's actually new. Protect against unlikely collision
  237. # in 48b random search space
  238. cf = cf if not os.path.exists(cf) else ""
  239. return cf
  240. def init_kernel_manager(self) -> None:
  241. """Initialize the kernel manager."""
  242. # Don't let Qt or ZMQ swallow KeyboardInterupts.
  243. if self.existing:
  244. self.kernel_manager = None
  245. return
  246. signal.signal(signal.SIGINT, signal.SIG_DFL)
  247. # Create a KernelManager and start a kernel.
  248. try:
  249. self.kernel_manager = self.kernel_manager_class(
  250. ip=self.ip,
  251. session=self.session,
  252. transport=self.transport,
  253. shell_port=self.shell_port,
  254. iopub_port=self.iopub_port,
  255. stdin_port=self.stdin_port,
  256. hb_port=self.hb_port,
  257. control_port=self.control_port,
  258. connection_file=self.connection_file,
  259. kernel_name=self.kernel_name,
  260. parent=self,
  261. data_dir=self.data_dir,
  262. )
  263. # access kernel_spec to ensure the NoSuchKernel error is raised
  264. # if it's going to be
  265. kernel_spec = self.kernel_manager.kernel_spec # noqa: F841
  266. except NoSuchKernel:
  267. self.log.critical("Could not find kernel %r", self.kernel_name)
  268. self.exit(1) # type:ignore[attr-defined]
  269. self.kernel_manager = t.cast(KernelManager, self.kernel_manager)
  270. self.kernel_manager.client_factory = self.kernel_client_class
  271. kwargs = {}
  272. kwargs["extra_arguments"] = self.kernel_argv
  273. self.kernel_manager.start_kernel(**kwargs)
  274. atexit.register(self.kernel_manager.cleanup_ipc_files)
  275. if self.sshserver:
  276. # ssh, write new connection file
  277. self.kernel_manager.write_connection_file()
  278. # in case KM defaults / ssh writing changes things:
  279. km = self.kernel_manager
  280. self.shell_port = km.shell_port
  281. self.iopub_port = km.iopub_port
  282. self.stdin_port = km.stdin_port
  283. self.hb_port = km.hb_port
  284. self.control_port = km.control_port
  285. self.connection_file = km.connection_file
  286. atexit.register(self.kernel_manager.cleanup_connection_file)
  287. def init_kernel_client(self) -> None:
  288. """Initialize the kernel client."""
  289. if self.kernel_manager is not None:
  290. self.kernel_client = self.kernel_manager.client()
  291. else:
  292. self.kernel_client = self.kernel_client_class(
  293. session=self.session,
  294. ip=self.ip,
  295. transport=self.transport,
  296. shell_port=self.shell_port,
  297. iopub_port=self.iopub_port,
  298. stdin_port=self.stdin_port,
  299. hb_port=self.hb_port,
  300. control_port=self.control_port,
  301. connection_file=self.connection_file,
  302. parent=self,
  303. )
  304. self.kernel_client.start_channels()
  305. def initialize(self, argv: object = None) -> None:
  306. """
  307. Classes which mix this class in should call:
  308. JupyterConsoleApp.initialize(self,argv)
  309. """
  310. if getattr(self, "_dispatching", False):
  311. return
  312. self.init_connection_file()
  313. self.init_ssh()
  314. self.init_kernel_manager()
  315. self.init_kernel_client()
  316. class IPythonConsoleApp(JupyterConsoleApp):
  317. """An app to manage an ipython console."""
  318. def __init__(self, *args: t.Any, **kwargs: t.Any) -> None:
  319. """Initialize the app."""
  320. warnings.warn("IPythonConsoleApp is deprecated. Use JupyterConsoleApp", stacklevel=2)
  321. super().__init__(*args, **kwargs)