application.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637
  1. """An extension application."""
  2. from __future__ import annotations
  3. import logging
  4. import re
  5. import sys
  6. import typing as t
  7. from jinja2 import Environment, FileSystemLoader
  8. from jupyter_core.application import JupyterApp, NoStart
  9. from tornado.log import LogFormatter
  10. from tornado.web import RedirectHandler
  11. from traitlets import Any, Bool, Dict, HasTraits, List, Unicode, default
  12. from traitlets.config import Config
  13. from jupyter_server.serverapp import ServerApp
  14. from jupyter_server.transutils import _i18n
  15. from jupyter_server.utils import is_namespace_package, url_path_join
  16. from .handler import ExtensionHandlerMixin
  17. # -----------------------------------------------------------------------------
  18. # Util functions and classes.
  19. # -----------------------------------------------------------------------------
  20. def _preparse_for_subcommand(application_klass, argv):
  21. """Preparse command line to look for subcommands."""
  22. # Read in arguments from command line.
  23. if len(argv) == 0:
  24. return None
  25. # Find any subcommands.
  26. if application_klass.subcommands and len(argv) > 0:
  27. # we have subcommands, and one may have been specified
  28. subc, subargv = argv[0], argv[1:]
  29. if re.match(r"^\w(\-?\w)*$", subc) and subc in application_klass.subcommands:
  30. # it's a subcommand, and *not* a flag or class parameter
  31. app = application_klass()
  32. app.initialize_subcommand(subc, subargv)
  33. return app.subapp
  34. def _preparse_for_stopping_flags(application_klass, argv):
  35. """Looks for 'help', 'version', and 'generate-config; commands
  36. in command line. If found, raises the help and version of
  37. current Application.
  38. This is useful for traitlets applications that have to parse
  39. the command line multiple times, but want to control when
  40. when 'help' and 'version' is raised.
  41. """
  42. # Arguments after a '--' argument are for the script IPython may be
  43. # about to run, not IPython iteslf. For arguments parsed here (help and
  44. # version), we want to only search the arguments up to the first
  45. # occurrence of '--', which we're calling interpreted_argv.
  46. try:
  47. interpreted_argv = argv[: argv.index("--")]
  48. except ValueError:
  49. interpreted_argv = argv
  50. # Catch any help calls.
  51. if any(x in interpreted_argv for x in ("-h", "--help-all", "--help")):
  52. app = application_klass()
  53. app.print_help("--help-all" in interpreted_argv)
  54. app.exit(0)
  55. # Catch version commands
  56. if "--version" in interpreted_argv or "-V" in interpreted_argv:
  57. app = application_klass()
  58. app.print_version()
  59. app.exit(0)
  60. # Catch generate-config commands.
  61. if "--generate-config" in interpreted_argv:
  62. app = application_klass()
  63. app.write_default_config()
  64. app.exit(0)
  65. class ExtensionAppJinjaMixin(HasTraits):
  66. """Use Jinja templates for HTML templates on top of an ExtensionApp."""
  67. jinja2_options = Dict(
  68. help=_i18n(
  69. """Options to pass to the jinja2 environment for this
  70. """
  71. )
  72. ).tag(config=True)
  73. @t.no_type_check
  74. def _prepare_templates(self):
  75. """Get templates defined in a subclass."""
  76. self.initialize_templates()
  77. # Add templates to web app settings if extension has templates.
  78. if len(self.template_paths) > 0:
  79. self.settings.update({f"{self.name}_template_paths": self.template_paths})
  80. # Create a jinja environment for logging html templates.
  81. self.jinja2_env = Environment(
  82. loader=FileSystemLoader(self.template_paths),
  83. extensions=["jinja2.ext.i18n"],
  84. autoescape=True,
  85. **self.jinja2_options,
  86. )
  87. # Add the jinja2 environment for this extension to the tornado settings.
  88. self.settings.update({f"{self.name}_jinja2_env": self.jinja2_env})
  89. # -----------------------------------------------------------------------------
  90. # ExtensionApp
  91. # -----------------------------------------------------------------------------
  92. class JupyterServerExtensionException(Exception):
  93. """Exception class for raising for Server extensions errors."""
  94. # -----------------------------------------------------------------------------
  95. # ExtensionApp
  96. # -----------------------------------------------------------------------------
  97. class ExtensionApp(JupyterApp):
  98. """Base class for configurable Jupyter Server Extension Applications.
  99. ExtensionApp subclasses can be initialized two ways:
  100. - Extension is listed as a jpserver_extension, and ServerApp calls
  101. its load_jupyter_server_extension classmethod. This is the
  102. classic way of loading a server extension.
  103. - Extension is launched directly by calling its `launch_instance`
  104. class method. This method can be set as a entry_point in
  105. the extensions setup.py.
  106. """
  107. # Subclasses should override this trait. Tells the server if
  108. # this extension allows other other extensions to be loaded
  109. # side-by-side when launched directly.
  110. load_other_extensions = True
  111. # A useful class property that subclasses can override to
  112. # configure the underlying Jupyter Server when this extension
  113. # is launched directly (using its `launch_instance` method).
  114. serverapp_config: dict[str, t.Any] = {}
  115. # Some subclasses will likely override this trait to flip
  116. # the default value to False if they don't offer a browser
  117. # based frontend.
  118. open_browser = Bool(
  119. help="""Whether to open in a browser after starting.
  120. The specific browser used is platform dependent and
  121. determined by the python standard library `webbrowser`
  122. module, unless it is overridden using the --browser
  123. (ServerApp.browser) configuration option.
  124. """
  125. ).tag(config=True)
  126. @default("open_browser")
  127. def _default_open_browser(self):
  128. assert self.serverapp is not None
  129. return self.serverapp.config["ServerApp"].get("open_browser", True)
  130. @property
  131. def config_file_paths(self):
  132. """Look on the same path as our parent for config files"""
  133. # rely on parent serverapp, which should control all config loading
  134. assert self.serverapp is not None
  135. return self.serverapp.config_file_paths
  136. # The extension name used to name the jupyter config
  137. # file, jupyter_{name}_config.
  138. # This should also match the jupyter subcommand used to launch
  139. # this extension from the CLI, e.g. `jupyter {name}`.
  140. name: str | Unicode[str, str] = "ExtensionApp" # type:ignore[assignment]
  141. @classmethod
  142. def get_extension_package(cls):
  143. """Get an extension package."""
  144. parts = cls.__module__.split(".")
  145. if is_namespace_package(parts[0]):
  146. # in this case the package name is `<namespace>.<package>`.
  147. return ".".join(parts[0:2])
  148. return parts[0]
  149. @classmethod
  150. def get_extension_point(cls):
  151. """Get an extension point."""
  152. return cls.__module__
  153. # Extension URL sets the default landing page for this extension.
  154. extension_url = "/"
  155. default_url = Unicode().tag(config=True)
  156. @default("default_url")
  157. def _default_url(self):
  158. return self.extension_url
  159. file_url_prefix = Unicode("notebooks")
  160. # Is this linked to a serverapp yet?
  161. _linked = Bool(False)
  162. # Extension can configure the ServerApp from the command-line
  163. classes = [
  164. ServerApp,
  165. ]
  166. # A ServerApp is not defined yet, but will be initialized below.
  167. serverapp: ServerApp | None = Any() # type:ignore[assignment]
  168. @default("serverapp")
  169. def _default_serverapp(self):
  170. # load the current global instance, if any
  171. if ServerApp.initialized():
  172. try:
  173. return ServerApp.instance()
  174. except Exception:
  175. # error retrieving instance, e.g. MultipleInstanceError
  176. pass
  177. # serverapp accessed before it was defined,
  178. # declare an empty one
  179. return ServerApp()
  180. _log_formatter_cls = LogFormatter # type:ignore[assignment]
  181. @default("log_level")
  182. def _default_log_level(self):
  183. return logging.INFO
  184. @default("log_format")
  185. def _default_log_format(self):
  186. """override default log format to include date & time"""
  187. return (
  188. "%(color)s[%(levelname)1.1s %(asctime)s.%(msecs).03d %(name)s]%(end_color)s %(message)s"
  189. )
  190. static_url_prefix = Unicode(
  191. help="""Url where the static assets for the extension are served."""
  192. ).tag(config=True)
  193. @default("static_url_prefix")
  194. def _default_static_url_prefix(self):
  195. static_url = f"static/{self.name}/"
  196. assert self.serverapp is not None
  197. return url_path_join(self.serverapp.base_url, static_url)
  198. static_paths = List(
  199. Unicode(),
  200. help="""paths to search for serving static files.
  201. This allows adding javascript/css to be available from the notebook server machine,
  202. or overriding individual files in the IPython
  203. """,
  204. ).tag(config=True)
  205. template_paths = List(
  206. Unicode(),
  207. help=_i18n(
  208. """Paths to search for serving jinja templates.
  209. Can be used to override templates from notebook.templates."""
  210. ),
  211. ).tag(config=True)
  212. settings = Dict(help=_i18n("""Settings that will passed to the server.""")).tag(config=True)
  213. handlers: List[tuple[t.Any, ...]] = List(
  214. help=_i18n("""Handlers appended to the server.""")
  215. ).tag(config=True)
  216. def _config_file_name_default(self):
  217. """The default config file name."""
  218. if not self.name:
  219. return ""
  220. return "jupyter_{}_config".format(self.name.replace("-", "_"))
  221. def initialize_settings(self):
  222. """Override this method to add handling of settings."""
  223. def initialize_handlers(self):
  224. """Override this method to append handlers to a Jupyter Server."""
  225. def initialize_templates(self):
  226. """Override this method to add handling of template files."""
  227. def _prepare_config(self):
  228. """Builds a Config object from the extension's traits and passes
  229. the object to the webapp's settings as `<name>_config`.
  230. """
  231. traits = self.class_own_traits().keys()
  232. self.extension_config = Config({t: getattr(self, t) for t in traits})
  233. self.settings[f"{self.name}_config"] = self.extension_config
  234. def _prepare_settings(self):
  235. """Prepare the settings."""
  236. # Make webapp settings accessible to initialize_settings method
  237. assert self.serverapp is not None
  238. webapp = self.serverapp.web_app
  239. self.settings.update(**webapp.settings)
  240. # Add static and template paths to settings.
  241. self.settings.update(
  242. {
  243. f"{self.name}_static_paths": self.static_paths,
  244. f"{self.name}": self,
  245. }
  246. )
  247. # Get setting defined by subclass using initialize_settings method.
  248. self.initialize_settings()
  249. # Update server settings with extension settings.
  250. webapp.settings.update(**self.settings)
  251. def _prepare_handlers(self):
  252. """Prepare the handlers."""
  253. assert self.serverapp is not None
  254. webapp = self.serverapp.web_app
  255. # Get handlers defined by extension subclass.
  256. self.initialize_handlers()
  257. # prepend base_url onto the patterns that we match
  258. new_handlers = []
  259. for handler_items in self.handlers:
  260. # Build url pattern including base_url
  261. pattern = url_path_join(webapp.settings["base_url"], handler_items[0])
  262. handler = handler_items[1]
  263. # Get handler kwargs, if given
  264. kwargs: dict[str, t.Any] = {}
  265. if issubclass(handler, ExtensionHandlerMixin):
  266. kwargs["name"] = self.name
  267. try:
  268. kwargs.update(handler_items[2])
  269. except IndexError:
  270. pass
  271. new_handler = (pattern, handler, kwargs)
  272. new_handlers.append(new_handler)
  273. # Add static endpoint for this extension, if static paths are given.
  274. if len(self.static_paths) > 0:
  275. # Append the extension's static directory to server handlers.
  276. static_url = url_path_join(self.static_url_prefix, "(.*)")
  277. # Construct handler.
  278. handler = (
  279. static_url,
  280. webapp.settings["static_handler_class"],
  281. {"path": self.static_paths},
  282. )
  283. new_handlers.append(handler)
  284. webapp.add_handlers(".*$", new_handlers)
  285. def _prepare_templates(self):
  286. """Add templates to web app settings if extension has templates."""
  287. if len(self.template_paths) > 0:
  288. self.settings.update({f"{self.name}_template_paths": self.template_paths})
  289. self.initialize_templates()
  290. def _jupyter_server_config(self):
  291. """The jupyter server config."""
  292. base_config = {
  293. "ServerApp": {
  294. "default_url": self.default_url,
  295. "open_browser": self.open_browser,
  296. "file_url_prefix": self.file_url_prefix,
  297. }
  298. }
  299. base_config["ServerApp"].update(self.serverapp_config)
  300. return base_config
  301. def _link_jupyter_server_extension(self, serverapp: ServerApp) -> None:
  302. """Link the ExtensionApp to an initialized ServerApp.
  303. The ServerApp is stored as an attribute and config
  304. is exchanged between ServerApp and `self` in case
  305. the command line contains traits for the ExtensionApp
  306. or the ExtensionApp's config files have server
  307. settings.
  308. Note, the ServerApp has not initialized the Tornado
  309. Web Application yet, so do not try to affect the
  310. `web_app` attribute.
  311. """
  312. self.serverapp = serverapp
  313. # Load config from an ExtensionApp's config files.
  314. self.load_config_file()
  315. # ServerApp's config might have picked up
  316. # config for the ExtensionApp. We call
  317. # update_config to update ExtensionApp's
  318. # traits with these values found in ServerApp's
  319. # config.
  320. # ServerApp config ---> ExtensionApp traits
  321. self.update_config(self.serverapp.config)
  322. # Use ExtensionApp's CLI parser to find any extra
  323. # args that passed through ServerApp and
  324. # now belong to ExtensionApp.
  325. self.parse_command_line(self.serverapp.extra_args)
  326. # If any config should be passed upstream to the
  327. # ServerApp, do it here.
  328. # i.e. ServerApp traits <--- ExtensionApp config
  329. self.serverapp.update_config(self.config)
  330. # Acknowledge that this extension has been linked.
  331. self._linked = True
  332. def initialize(self):
  333. """Initialize the extension app. The
  334. corresponding server app and webapp should already
  335. be initialized by this step.
  336. - Appends Handlers to the ServerApp,
  337. - Passes config and settings from ExtensionApp
  338. to the Tornado web application
  339. - Points Tornado Webapp to templates and static assets.
  340. """
  341. if not self.serverapp:
  342. msg = (
  343. "This extension has no attribute `serverapp`. "
  344. "Try calling `.link_to_serverapp()` before calling "
  345. "`.initialize()`."
  346. )
  347. raise JupyterServerExtensionException(msg)
  348. self._prepare_config()
  349. self._prepare_templates()
  350. self._prepare_settings()
  351. self._prepare_handlers()
  352. def start(self):
  353. """Start the underlying Jupyter server.
  354. Server should be started after extension is initialized.
  355. """
  356. super().start()
  357. # Start the server.
  358. assert self.serverapp is not None
  359. self.serverapp.start()
  360. def current_activity(self):
  361. """Return a list of activity happening in this extension."""
  362. return
  363. async def stop_extension(self):
  364. """Cleanup any resources managed by this extension."""
  365. def stop(self):
  366. """Stop the underlying Jupyter server."""
  367. assert self.serverapp is not None
  368. self.serverapp.stop()
  369. self.serverapp.clear_instance()
  370. @classmethod
  371. def _load_jupyter_server_extension(cls, serverapp):
  372. """Initialize and configure this extension, then add the extension's
  373. settings and handlers to the server's web application.
  374. """
  375. extension_manager = serverapp.extension_manager
  376. try:
  377. # Get loaded extension from serverapp.
  378. point = extension_manager.extension_points[cls.name]
  379. extension = point.app
  380. except KeyError:
  381. extension = cls()
  382. extension._link_jupyter_server_extension(serverapp)
  383. extension.initialize()
  384. return extension
  385. async def _start_jupyter_server_extension(self, serverapp):
  386. """
  387. An async hook to start e.g. tasks from the extension after
  388. the server's event loop is running.
  389. Override this method (no need to call `super()`) to
  390. start (async) tasks from an extension.
  391. This is useful for starting e.g. background tasks from
  392. an extension.
  393. """
  394. @classmethod
  395. def load_classic_server_extension(cls, serverapp):
  396. """Enables extension to be loaded as classic Notebook (jupyter/notebook) extension."""
  397. extension = cls()
  398. extension.serverapp = serverapp
  399. extension.load_config_file()
  400. extension.update_config(serverapp.config)
  401. extension.parse_command_line(serverapp.extra_args)
  402. # Add redirects to get favicons from old locations in the classic notebook server
  403. extension.handlers.extend(
  404. [
  405. (
  406. r"/static/favicons/favicon.ico",
  407. RedirectHandler,
  408. {"url": url_path_join(serverapp.base_url, "static/base/images/favicon.ico")},
  409. ),
  410. (
  411. r"/static/favicons/favicon-busy-1.ico",
  412. RedirectHandler,
  413. {
  414. "url": url_path_join(
  415. serverapp.base_url, "static/base/images/favicon-busy-1.ico"
  416. )
  417. },
  418. ),
  419. (
  420. r"/static/favicons/favicon-busy-2.ico",
  421. RedirectHandler,
  422. {
  423. "url": url_path_join(
  424. serverapp.base_url, "static/base/images/favicon-busy-2.ico"
  425. )
  426. },
  427. ),
  428. (
  429. r"/static/favicons/favicon-busy-3.ico",
  430. RedirectHandler,
  431. {
  432. "url": url_path_join(
  433. serverapp.base_url, "static/base/images/favicon-busy-3.ico"
  434. )
  435. },
  436. ),
  437. (
  438. r"/static/favicons/favicon-file.ico",
  439. RedirectHandler,
  440. {
  441. "url": url_path_join(
  442. serverapp.base_url, "static/base/images/favicon-file.ico"
  443. )
  444. },
  445. ),
  446. (
  447. r"/static/favicons/favicon-notebook.ico",
  448. RedirectHandler,
  449. {
  450. "url": url_path_join(
  451. serverapp.base_url,
  452. "static/base/images/favicon-notebook.ico",
  453. )
  454. },
  455. ),
  456. (
  457. r"/static/favicons/favicon-terminal.ico",
  458. RedirectHandler,
  459. {
  460. "url": url_path_join(
  461. serverapp.base_url,
  462. "static/base/images/favicon-terminal.ico",
  463. )
  464. },
  465. ),
  466. (
  467. r"/static/logo/logo.png",
  468. RedirectHandler,
  469. {"url": url_path_join(serverapp.base_url, "static/base/images/logo.png")},
  470. ),
  471. ]
  472. )
  473. extension.initialize()
  474. serverapp_class = ServerApp
  475. @classmethod
  476. def make_serverapp(cls, **kwargs: t.Any) -> ServerApp:
  477. """Instantiate the ServerApp
  478. Override to customize the ServerApp before it loads any configuration
  479. """
  480. return cls.serverapp_class.instance(**kwargs)
  481. @classmethod
  482. def initialize_server(cls, argv=None, load_other_extensions=True, **kwargs):
  483. """Creates an instance of ServerApp and explicitly sets
  484. this extension to enabled=True (i.e. superseding disabling
  485. found in other config from files).
  486. The `launch_instance` method uses this method to initialize
  487. and start a server.
  488. """
  489. jpserver_extensions = {cls.get_extension_package(): True}
  490. find_extensions = cls.load_other_extensions
  491. if "jpserver_extensions" in cls.serverapp_config:
  492. jpserver_extensions.update(cls.serverapp_config["jpserver_extensions"])
  493. cls.serverapp_config["jpserver_extensions"] = jpserver_extensions
  494. find_extensions = False
  495. serverapp = cls.make_serverapp(jpserver_extensions=jpserver_extensions, **kwargs)
  496. serverapp.aliases.update(cls.aliases) # type:ignore[has-type]
  497. serverapp.initialize(
  498. argv=argv or [],
  499. starter_extension=cls.name,
  500. find_extensions=find_extensions,
  501. )
  502. return serverapp
  503. @classmethod
  504. def launch_instance(cls, argv=None, **kwargs):
  505. """Launch the extension like an application. Initializes+configs a stock server
  506. and appends the extension to the server. Then starts the server and routes to
  507. extension's landing page.
  508. """
  509. # Handle arguments.
  510. if argv is None: # noqa: SIM108
  511. args = sys.argv[1:] # slice out extension config.
  512. else:
  513. args = argv
  514. # Handle all "stops" that could happen before
  515. # continuing to launch a server+extension.
  516. subapp = _preparse_for_subcommand(cls, args)
  517. if subapp:
  518. subapp.start()
  519. return
  520. # Check for help, version, and generate-config arguments
  521. # before initializing server to make sure these
  522. # arguments trigger actions from the extension not the server.
  523. _preparse_for_stopping_flags(cls, args)
  524. serverapp = cls.initialize_server(argv=args)
  525. # Log if extension is blocking other extensions from loading.
  526. if not cls.load_other_extensions:
  527. serverapp.log.info(f"{cls.name} is running without loading other extensions.")
  528. # Start the server.
  529. try:
  530. serverapp.start()
  531. except NoStart:
  532. pass