diff --git a/jupyter_server_proxy/config.py b/jupyter_server_proxy/config.py index 649e9a04..4b21cf70 100644 --- a/jupyter_server_proxy/config.py +++ b/jupyter_server_proxy/config.py @@ -3,7 +3,7 @@ """ import sys -from collections import namedtuple +from textwrap import dedent, indent from warnings import warn if sys.version_info < (3, 10): # pragma: no cover @@ -12,34 +12,256 @@ from importlib.metadata import entry_points from jupyter_server.utils import url_path_join as ujoin -from traitlets import Callable, Dict, List, Tuple, Union, default, observe +from traitlets import ( + Bool, + Callable, + Dict, + Instance, + Int, + List, + Tuple, + Unicode, + Union, + default, + observe, + validate, +) from traitlets.config import Configurable from .handlers import AddSlashHandler, NamedLocalProxyHandler, SuperviseAndProxyHandler from .rawsocket import RawSocketHandler, SuperviseAndRawSocketHandler -LauncherEntry = namedtuple( - "LauncherEntry", ["enabled", "icon_path", "title", "path_info", "category"] -) -ServerProcess = namedtuple( - "ServerProcess", - [ - "name", - "command", - "environment", - "timeout", - "absolute_url", - "port", - "unix_socket", - "mappath", - "launcher_entry", - "new_browser_tab", - "request_headers_override", - "rewrite_response", - "update_last_activity", - "raw_socket_proxy", - ], -) + +class LauncherEntry(Configurable): + enabled = Bool( + True, + help=""" + Set to True (default) to make an entry in the launchers. Set to False to have no + explicit entry. + """, + ) + + icon_path = Unicode( + "", + help=""" + Full path to an svg icon that could be used with a launcher. Currently only used by the + JupyterLab launcher + """, + ) + + title = Unicode( + allow_none=False, + help=""" + Title to be used for the launcher entry. Defaults to the name of the server if missing. + """, + ) + + path_info = Unicode( + help=""" + The trailing path that is appended to the user's server URL to access the proxied server. + By default it is the name of the server followed by a trailing slash. + """, + ) + + @default("path_info") + def _default_path_info(self): + return self.title + "/" + + category = Unicode( + "Notebook", + help=""" + The category for the launcher item. Currently only used by the JupyterLab launcher. + By default it is "Notebook". + """, + ) + + +class ServerProcess(Configurable): + name = Unicode(help="Name of the server").tag(config=True) + + command = List( + Unicode(), + help=""" + An optional list of strings that should be the full command to be executed. + The optional template arguments ``{port}``, ``{unix_socket}`` and ``{base_url}`` + will be substituted with the port or Unix socket path the process should + listen on and the base-url of the notebook. + + Could also be a callable. It should return a list. + + If the command is not specified or is an empty list, the server + process is assumed to be started ahead of time and already available + to be proxied to. + """, + ).tag(config=True) + + environment = Union( + [Dict(Unicode()), Callable()], + default_value={}, + help=""" + A dictionary of environment variable mappings. As with the command + traitlet, ``{port}``, ``{unix_socket}`` and ``{base_url}`` will be substituted. + + Could also be a callable. It should return a dictionary. + """, + ).tag(config=True) + + timeout = Int( + 5, help="Timeout in seconds for the process to become ready, default 5s." + ).tag(config=True) + + absolute_url = Bool( + False, + help=""" + Proxy requests default to being rewritten to ``/``. If this is True, + the absolute URL will be sent to the backend instead. + """, + ).tag(config=True) + + port = Int( + 0, + help=""" + Set the port that the service will listen on. The default is to automatically select an unused port. + """, + ).tag(config=True) + + unix_socket = Union( + [Bool(False), Unicode()], + default_value=None, + allow_none=True, + help=""" + If set, the service will listen on a Unix socket instead of a TCP port. + Set to True to use a socket in a new temporary folder, or a string + path to a socket. This overrides port. + + Proxying websockets over a Unix socket requires Tornado >= 6.3. + """, + ).tag(config=True) + + mappath = Union( + [Dict(Unicode()), Callable()], + default_value={}, + help=""" + Map request paths to proxied paths. + Either a dictionary of request paths to proxied paths, + or a callable that takes parameter ``path`` and returns the proxied path. + """, + ).tag(config=True) + + launcher_entry = Union( + [Instance(LauncherEntry), Dict()], + allow_none=False, + help=""" + A dictionary of various options for entries in classic notebook / jupyterlab launchers. + + Keys recognized are: + + ``enabled`` + Set to True (default) to make an entry in the launchers. Set to False to have no + explicit entry. + + ``icon_path`` + Full path to an svg icon that could be used with a launcher. Currently only used by the + JupyterLab launcher + + ``title`` + Title to be used for the launcher entry. Defaults to the name of the server if missing. + + ``path_info`` + The trailing path that is appended to the user's server URL to access the proxied server. + By default it is the name of the server followed by a trailing slash. + + ``category`` + The category for the launcher item. Currently only used by the JupyterLab launcher. + By default it is "Notebook". + """, + ).tag(config=True) + + @validate("launcher_entry") + def _validate_launcher_entry(self, proposal): + kwargs = {"title": self.name} + kwargs.update(proposal["value"]) + return LauncherEntry(**kwargs) + + @default("launcher_entry") + def _default_launcher_entry(self): + return LauncherEntry(title=self.name) + + new_browser_tab = Bool( + True, + help=""" + Set to True (default) to make the proxied server interface opened as a new browser tab. Set to False + to have it open a new JupyterLab tab. This has no effect in classic notebook. + """, + ).tag(config=True) + + request_headers_override = Dict( + Unicode(), + default_value={}, + help=""" + A dictionary of additional HTTP headers for the proxy request. As with + the command traitlet, ``{port}``, ``{unix_socket}`` and ``{base_url}`` will be substituted. + """, + ).tag(config=True) + + rewrite_response = Union( + [Callable(), List(Callable())], + default_value=[], + help=""" + An optional function to rewrite the response for the given service. + Input is a RewritableResponse object which is an argument that MUST be named + ``response``. The function should modify one or more of the attributes + ``.body``, ``.headers``, ``.code``, or ``.reason`` of the ``response`` + argument. For example: + + .. code-block:: + + def dog_to_cat(response): + response.headers["I-Like"] = "tacos" + response.body = response.body.replace(b'dog', b'cat') + + c.ServerProxy.servers['my_server']['rewrite_response'] = dog_to_cat + + The ``rewrite_response`` function can also accept several optional + positional arguments. Arguments named ``host``, ``port``, and ``path`` will + receive values corresponding to the URL ``/proxy/:``. In + addition, the original Tornado ``HTTPRequest`` and ``HTTPResponse`` objects + are available as arguments named ``request`` and ``orig_response``. (These + objects should not be modified.) + + A list or tuple of functions can also be specified for chaining multiple + rewrites. For example: + + .. code-block:: + + def cats_only(response, path): + if path.startswith("/cat-club"): + response.code = 403 + response.body = b"dogs not allowed" + + c.ServerProxy.servers['my_server']['rewrite_response'] = [dog_to_cat, cats_only] + + Note that if the order is reversed to ``[cats_only, dog_to_cat]``, then accessing + ``/cat-club`` will produce a "403 Forbidden" response with body "cats not allowed" + instead of "dogs not allowed". + + Defaults to the empty tuple ``tuple()``. + """, + ).tag(config=True) + + update_last_activity = Bool( + True, help="Will cause the proxy to report activity back to jupyter server." + ).tag(config=True) + + raw_socket_proxy = Bool( + False, + help=""" + Proxy websocket requests as a raw TCP (or unix socket) stream. + In this mode, only websockets are handled, and messages are sent to the backend, + similar to running a websockify layer (https://github.com/novnc/websockify). + All other HTTP requests return 405 (and thus this will also bypass rewrite_response). + """, + ).tag(config=True) def _make_proxy_handler(sp: ServerProcess): @@ -125,34 +347,17 @@ def make_handlers(base_url, server_processes): def make_server_process(name, server_process_config, serverproxy_config): - le = server_process_config.get("launcher_entry", {}) - return ServerProcess( - name=name, - command=server_process_config.get("command", list()), - environment=server_process_config.get("environment", {}), - timeout=server_process_config.get("timeout", 5), - absolute_url=server_process_config.get("absolute_url", False), - port=server_process_config.get("port", 0), - unix_socket=server_process_config.get("unix_socket", None), - mappath=server_process_config.get("mappath", {}), - launcher_entry=LauncherEntry( - enabled=le.get("enabled", True), - icon_path=le.get("icon_path"), - title=le.get("title", name), - path_info=le.get("path_info", name + "/"), - category=le.get("category", "Notebook"), - ), - new_browser_tab=server_process_config.get("new_browser_tab", True), - request_headers_override=server_process_config.get( - "request_headers_override", {} - ), - rewrite_response=server_process_config.get( - "rewrite_response", - tuple(), - ), - update_last_activity=server_process_config.get("update_last_activity", True), - raw_socket_proxy=server_process_config.get("raw_socket_proxy", False), - ) + return ServerProcess(name=name, **server_process_config) + + +def _serverproxy_servers_help(): + serverprocess_help = "" + for k, v in ServerProcess.class_traits().items(): + help = v.metadata.get("help", "").lstrip("\n").rstrip() + if help: + help = indent(dedent(help), " ") + serverprocess_help += f"{k}\n{help}\n\n" + return serverprocess_help class ServerProxy(Configurable): @@ -165,123 +370,9 @@ class ServerProxy(Configurable): the URL prefix, and all requests matching this prefix are routed to this process. Value should be a dictionary with the following keys: - command - An optional list of strings that should be the full command to be executed. - The optional template arguments {{port}}, {{unix_socket}} and {{base_url}} - will be substituted with the port or Unix socket path the process should - listen on and the base-url of the notebook. - - Could also be a callable. It should return a list. - - If the command is not specified or is an empty list, the server - process is assumed to be started ahead of time and already available - to be proxied to. - environment - A dictionary of environment variable mappings. As with the command - traitlet, {{port}}, {{unix_socket}} and {{base_url}} will be substituted. - - Could also be a callable. It should return a dictionary. - - timeout - Timeout in seconds for the process to become ready, default 5s. - - absolute_url - Proxy requests default to being rewritten to '/'. If this is True, - the absolute URL will be sent to the backend instead. - - port - Set the port that the service will listen on. The default is to automatically select an unused port. - - unix_socket - If set, the service will listen on a Unix socket instead of a TCP port. - Set to True to use a socket in a new temporary folder, or a string - path to a socket. This overrides port. - - Proxying websockets over a Unix socket requires Tornado >= 6.3. - - mappath - Map request paths to proxied paths. - Either a dictionary of request paths to proxied paths, - or a callable that takes parameter ``path`` and returns the proxied path. - - launcher_entry - A dictionary of various options for entries in classic notebook / jupyterlab launchers. - - Keys recognized are: - - enabled - Set to True (default) to make an entry in the launchers. Set to False to have no - explicit entry. - - icon_path - Full path to an svg icon that could be used with a launcher. Currently only used by the - JupyterLab launcher - - title - Title to be used for the launcher entry. Defaults to the name of the server if missing. - - path_info - The trailing path that is appended to the user's server URL to access the proxied server. - By default it is the name of the server followed by a trailing slash. - - category - The category for the launcher item. Currently only used by the JupyterLab launcher. - By default it is "Notebook". - - new_browser_tab - Set to True (default) to make the proxied server interface opened as a new browser tab. Set to False - to have it open a new JupyterLab tab. This has no effect in classic notebook. - - request_headers_override - A dictionary of additional HTTP headers for the proxy request. As with - the command traitlet, {{port}}, {{unix_socket}} and {{base_url}} will be substituted. - - rewrite_response - An optional function to rewrite the response for the given service. - Input is a RewritableResponse object which is an argument that MUST be named - ``response``. The function should modify one or more of the attributes - ``.body``, ``.headers``, ``.code``, or ``.reason`` of the ``response`` - argument. For example: - - def dog_to_cat(response): - response.headers["I-Like"] = "tacos" - response.body = response.body.replace(b'dog', b'cat') - - c.ServerProxy.servers['my_server']['rewrite_response'] = dog_to_cat - - The ``rewrite_response`` function can also accept several optional - positional arguments. Arguments named ``host``, ``port``, and ``path`` will - receive values corresponding to the URL ``/proxy/:``. In - addition, the original Tornado ``HTTPRequest`` and ``HTTPResponse`` objects - are available as arguments named ``request`` and ``orig_response``. (These - objects should not be modified.) - - A list or tuple of functions can also be specified for chaining multiple - rewrites. For example: - - def cats_only(response, path): - if path.startswith("/cat-club"): - response.code = 403 - response.body = b"dogs not allowed" - - c.ServerProxy.servers['my_server']['rewrite_response'] = [dog_to_cat, cats_only] - - Note that if the order is reversed to ``[cats_only, dog_to_cat]``, then accessing - ``/cat-club`` will produce a "403 Forbidden" response with body "cats not allowed" - instead of "dogs not allowed". - - Defaults to the empty tuple ``tuple()``. - - update_last_activity - Will cause the proxy to report activity back to jupyter server. - - raw_socket_proxy - Proxy websocket requests as a raw TCP (or unix socket) stream. - In this mode, only websockets are handled, and messages are sent to the backend, - similar to running a websockify layer (https://github.com/novnc/websockify). - All other HTTP requests return 405 (and thus this will also bypass rewrite_response). - """, + """ + + indent(_serverproxy_servers_help(), " "), config=True, )