app.py 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128
  1. """A terminals extension app."""
  2. from __future__ import annotations
  3. import os
  4. import shlex
  5. import sys
  6. import typing as t
  7. from shutil import which
  8. from jupyter_core.utils import ensure_async
  9. from jupyter_server.extension.application import ExtensionApp
  10. from jupyter_server.transutils import trans
  11. from traitlets import Type
  12. from . import api_handlers, handlers
  13. from .terminalmanager import TerminalManager
  14. class TerminalsExtensionApp(ExtensionApp):
  15. """A terminals extension app."""
  16. name = "jupyter_server_terminals"
  17. terminal_manager_class: type[TerminalManager] = Type( # type:ignore[assignment]
  18. default_value=TerminalManager, help="The terminal manager class to use."
  19. ).tag(config=True)
  20. # Since use of terminals is also a function of whether the terminado package is
  21. # available, this variable holds the "final indication" of whether terminal functionality
  22. # should be considered (particularly during shutdown/cleanup). It is enabled only
  23. # once both the terminals "service" can be initialized and terminals_enabled is True.
  24. # Note: this variable is slightly different from 'terminals_available' in the web settings
  25. # in that this variable *could* remain false if terminado is available, yet the terminal
  26. # service's initialization still fails. As a result, this variable holds the truth.
  27. terminals_available = False
  28. def initialize_settings(self) -> None:
  29. """Initialize settings."""
  30. if not self.serverapp or not self.serverapp.terminals_enabled:
  31. self.settings.update({"terminals_available": False})
  32. return
  33. self.initialize_configurables()
  34. self.settings.update(
  35. {"terminals_available": True, "terminal_manager": self.terminal_manager}
  36. )
  37. def initialize_configurables(self) -> None:
  38. """Initialize configurables."""
  39. default_shell = "powershell.exe" if os.name == "nt" else which("sh")
  40. assert self.serverapp is not None
  41. shell_override = self.serverapp.terminado_settings.get("shell_command")
  42. if isinstance(shell_override, str):
  43. shell_override = shlex.split(shell_override)
  44. shell = (
  45. [os.environ.get("SHELL") or default_shell] if shell_override is None else shell_override
  46. )
  47. # When the notebook server is not running in a terminal (e.g. when
  48. # it's launched by a JupyterHub spawner), it's likely that the user
  49. # environment hasn't been fully set up. In that case, run a login
  50. # shell to automatically source /etc/profile and the like, unless
  51. # the user has specifically set a preferred shell command.
  52. if os.name != "nt" and shell_override is None and not sys.stdout.isatty():
  53. shell.append("-l")
  54. self.terminal_manager = self.terminal_manager_class(
  55. shell_command=shell,
  56. extra_env={
  57. "JUPYTER_SERVER_ROOT": self.serverapp.root_dir,
  58. "JUPYTER_SERVER_URL": self.serverapp.connection_url,
  59. },
  60. parent=self.serverapp,
  61. )
  62. self.terminal_manager.log = self.serverapp.log
  63. def initialize_handlers(self) -> None:
  64. """Initialize handlers."""
  65. if not self.serverapp:
  66. # Already set `terminals_available` as `False` in `initialize_settings`
  67. return
  68. if not self.serverapp.terminals_enabled:
  69. # webapp settings for backwards compat (used by nbclassic), #12
  70. self.serverapp.web_app.settings["terminals_available"] = self.settings[
  71. "terminals_available"
  72. ]
  73. return
  74. self.handlers.append(
  75. (
  76. r"/terminals/websocket/(\w+)",
  77. handlers.TermSocket,
  78. {"term_manager": self.terminal_manager},
  79. )
  80. )
  81. self.handlers.extend(api_handlers.default_handlers)
  82. assert self.serverapp is not None
  83. self.serverapp.web_app.settings["terminal_manager"] = self.terminal_manager
  84. self.serverapp.web_app.settings["terminals_available"] = self.settings[
  85. "terminals_available"
  86. ]
  87. def current_activity(self) -> dict[str, t.Any] | None:
  88. """Get current activity info."""
  89. if self.terminals_available:
  90. terminals = self.terminal_manager.terminals
  91. if terminals:
  92. return terminals
  93. return None
  94. async def cleanup_terminals(self) -> None:
  95. """Shutdown all terminals.
  96. The terminals will shutdown themselves when this process no longer exists,
  97. but explicit shutdown allows the TerminalManager to cleanup.
  98. """
  99. if not self.terminals_available:
  100. return
  101. terminal_manager = self.terminal_manager
  102. n_terminals = len(terminal_manager.list())
  103. terminal_msg = trans.ngettext(
  104. "Shutting down %d terminal", "Shutting down %d terminals", n_terminals
  105. )
  106. self.log.info("%s %% %s", terminal_msg, n_terminals)
  107. await ensure_async(terminal_manager.terminate_all()) # type:ignore[arg-type]
  108. async def stop_extension(self) -> None:
  109. """Stop the extension."""
  110. await self.cleanup_terminals()