From 1203253cd969400dceb44930a4a40324b9e30a72 Mon Sep 17 00:00:00 2001 From: Simon Li Date: Thu, 24 Oct 2024 23:04:21 +0100 Subject: [PATCH 1/9] Make config.ServerProcess into a Configurable This will allow us to reuse it --- jupyter_server_proxy/config.py | 257 +++++++++++++++++++++++++++------ 1 file changed, 209 insertions(+), 48 deletions(-) diff --git a/jupyter_server_proxy/config.py b/jupyter_server_proxy/config.py index 649e9a04..11ce175a 100644 --- a/jupyter_server_proxy/config.py +++ b/jupyter_server_proxy/config.py @@ -12,7 +12,21 @@ 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, + TraitError, + Tuple, + Unicode, + Union, + default, + observe, + validate, +) from traitlets.config import Configurable from .handlers import AddSlashHandler, NamedLocalProxyHandler, SuperviseAndProxyHandler @@ -21,25 +35,199 @@ 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 ServerProcess(Configurable): + name = Unicode(help="Name").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, + 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) + + # Can't use Instance(LauncherEntry) because LauncherEntry is not a class + launcher_entry = Union( + [Instance(object), 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): + le = proposal["value"] + invalid_keys = set(le.keys()).difference( + {"enabled", "icon_path", "title", "path_info", "category"} + ) + if invalid_keys: + raise TraitError( + f"launcher_entry {le} contains invalid keys: {invalid_keys}" + ) + return ( + LauncherEntry( + enabled=le.get("enabled", True), + icon_path=le.get("icon_path"), + title=le.get("title", self.name), + path_info=le.get("path_info", self.name + "/"), + category=le.get("category", "Notebook"), + ), + ) + + 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: + + 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()``. + """, + ).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 +313,7 @@ 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) class ServerProxy(Configurable): From ca52e8396af3bca349b143ef6b425c9c2cbf6760 Mon Sep 17 00:00:00 2001 From: Simon Li Date: Thu, 24 Oct 2024 23:31:14 +0100 Subject: [PATCH 2/9] Make config.LauncherEntry a Configurable --- jupyter_server_proxy/config.py | 75 +++++++++++++++++++++++----------- 1 file changed, 51 insertions(+), 24 deletions(-) diff --git a/jupyter_server_proxy/config.py b/jupyter_server_proxy/config.py index 11ce175a..2f04bd1a 100644 --- a/jupyter_server_proxy/config.py +++ b/jupyter_server_proxy/config.py @@ -3,7 +3,6 @@ """ import sys -from collections import namedtuple from warnings import warn if sys.version_info < (3, 10): # pragma: no cover @@ -19,7 +18,6 @@ Instance, Int, List, - TraitError, Tuple, Unicode, Union, @@ -32,9 +30,49 @@ from .handlers import AddSlashHandler, NamedLocalProxyHandler, SuperviseAndProxyHandler from .rawsocket import RawSocketHandler, SuperviseAndRawSocketHandler -LauncherEntry = namedtuple( - "LauncherEntry", ["enabled", "icon_path", "title", "path_info", "category"] -) + +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): @@ -107,9 +145,8 @@ class ServerProcess(Configurable): """, ).tag(config=True) - # Can't use Instance(LauncherEntry) because LauncherEntry is not a class launcher_entry = Union( - [Instance(object), Dict()], + [Instance(LauncherEntry), Dict()], allow_none=False, help=""" A dictionary of various options for entries in classic notebook / jupyterlab launchers. @@ -139,23 +176,13 @@ class ServerProcess(Configurable): @validate("launcher_entry") def _validate_launcher_entry(self, proposal): - le = proposal["value"] - invalid_keys = set(le.keys()).difference( - {"enabled", "icon_path", "title", "path_info", "category"} - ) - if invalid_keys: - raise TraitError( - f"launcher_entry {le} contains invalid keys: {invalid_keys}" - ) - return ( - LauncherEntry( - enabled=le.get("enabled", True), - icon_path=le.get("icon_path"), - title=le.get("title", self.name), - path_info=le.get("path_info", self.name + "/"), - category=le.get("category", "Notebook"), - ), - ) + 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, From e610745221482e5cc436aacee8f718df75ac1b73 Mon Sep 17 00:00:00 2001 From: Simon Li Date: Thu, 24 Oct 2024 23:59:03 +0100 Subject: [PATCH 3/9] unix_socket: allow_none (needed for traitlets 5.1.0) --- jupyter_server_proxy/config.py | 1 + 1 file changed, 1 insertion(+) diff --git a/jupyter_server_proxy/config.py b/jupyter_server_proxy/config.py index 2f04bd1a..eb44ea21 100644 --- a/jupyter_server_proxy/config.py +++ b/jupyter_server_proxy/config.py @@ -126,6 +126,7 @@ class ServerProcess(Configurable): 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 From f8d431a2beaca49be4174423949ccf7900f3477b Mon Sep 17 00:00:00 2001 From: Simon Li Date: Fri, 25 Oct 2024 00:32:23 +0100 Subject: [PATCH 4/9] Autogenerate ServerProxy.servers help instead of duplicating docs --- jupyter_server_proxy/config.py | 129 ++++----------------------------- 1 file changed, 13 insertions(+), 116 deletions(-) diff --git a/jupyter_server_proxy/config.py b/jupyter_server_proxy/config.py index eb44ea21..356ec7d2 100644 --- a/jupyter_server_proxy/config.py +++ b/jupyter_server_proxy/config.py @@ -3,6 +3,7 @@ """ import sys +from textwrap import dedent, indent from warnings import warn if sys.version_info < (3, 10): # pragma: no cover @@ -344,6 +345,16 @@ def make_server_process(name, server_process_config, serverproxy_config): 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): servers = Dict( {}, @@ -354,123 +365,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, ) From fef3695e682c0b7604e00c87d290ea2a30ceaa53 Mon Sep 17 00:00:00 2001 From: Simon Li Date: Sun, 27 Oct 2024 17:24:30 +0000 Subject: [PATCH 5/9] Clean-up ServerProcess doc --- jupyter_server_proxy/config.py | 47 +++++++++++++++++++--------------- 1 file changed, 26 insertions(+), 21 deletions(-) diff --git a/jupyter_server_proxy/config.py b/jupyter_server_proxy/config.py index 356ec7d2..4b21cf70 100644 --- a/jupyter_server_proxy/config.py +++ b/jupyter_server_proxy/config.py @@ -77,12 +77,13 @@ def _default_path_info(self): class ServerProcess(Configurable): - name = Unicode(help="Name").tag(config=True) + name = Unicode(help="Name of the server").tag(config=True) + command = List( Unicode(), - help="""\ + 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}} + 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. @@ -97,9 +98,9 @@ class ServerProcess(Configurable): environment = Union( [Dict(Unicode()), Callable()], default_value={}, - help="""\ + help=""" A dictionary of environment variable mappings. As with the command - traitlet, {{port}}, {{unix_socket}} and {{base_url}} will be substituted. + traitlet, ``{port}``, ``{unix_socket}`` and ``{base_url}`` will be substituted. Could also be a callable. It should return a dictionary. """, @@ -112,7 +113,7 @@ class ServerProcess(Configurable): absolute_url = Bool( False, help=""" - Proxy requests default to being rewritten to '/'. If this is True, + Proxy requests default to being rewritten to ``/``. If this is True, the absolute URL will be sent to the backend instead. """, ).tag(config=True) @@ -155,24 +156,24 @@ class ServerProcess(Configurable): Keys recognized are: - enabled - Set to True (default) to make an entry in the launchers. Set to False to have no - explicit entry. + ``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 + ``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. + ``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. + ``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". + ``category`` + The category for the launcher item. Currently only used by the JupyterLab launcher. + By default it is "Notebook". """, ).tag(config=True) @@ -199,7 +200,7 @@ def _default_launcher_entry(self): 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. + the command traitlet, ``{port}``, ``{unix_socket}`` and ``{base_url}`` will be substituted. """, ).tag(config=True) @@ -213,6 +214,8 @@ def _default_launcher_entry(self): ``.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') @@ -229,6 +232,8 @@ def dog_to_cat(response): 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 From 7c4002b0d16ac2cd8c5e94c13dd2344f045e2e4c Mon Sep 17 00:00:00 2001 From: Simon Li Date: Fri, 25 Oct 2024 17:39:53 +0100 Subject: [PATCH 6/9] Autogenerate ServerProcess documentation instead of duplicating it Avoid duplication in documentation --- docs/extensions/serverprocess_documenter.py | 60 ++++++ docs/source/conf.py | 8 + docs/source/server-process.md | 225 +------------------- 3 files changed, 70 insertions(+), 223 deletions(-) create mode 100644 docs/extensions/serverprocess_documenter.py diff --git a/docs/extensions/serverprocess_documenter.py b/docs/extensions/serverprocess_documenter.py new file mode 100644 index 00000000..7f15cf7e --- /dev/null +++ b/docs/extensions/serverprocess_documenter.py @@ -0,0 +1,60 @@ +from autodoc_traits import ConfigurableDocumenter, TraitDocumenter +from sphinx.application import Sphinx +from sphinx.ext.autodoc import SUPPRESS, ClassDocumenter, ObjectMember +from sphinx.util.typing import ExtensionMetadata +from traitlets import Undefined + + +class ServerProcessConfigurableDocumenter(ConfigurableDocumenter): + objtype = "serverprocessconfigurable" + directivetype = "class" + priority = 100 + + def get_object_members(self, want_all): + """ + Only document members in this class + """ + config_trait_members = self.object.class_traits(config=True).items() + members = [ObjectMember(name, trait) for (name, trait) in config_trait_members] + return False, members + + def should_suppress_directive_header(): + return True + + # Skip over autodoc_traits otherwise it'll prepend c.ServerProcess + # to the annotation + def add_directive_header(self, sig): + print(f"{sig=}") + self.options.annotation = SUPPRESS + super(ClassDocumenter, self).add_directive_header(sig) + + +class ServerProcessTraitDocumenter(TraitDocumenter): + objtype = "serverprocesstrait" + directivetype = "attribute" + priority = 100 # AttributeDocumenter has 10 + member_order = 0 # AttributeDocumenter has 60 + + def add_directive_header(self, sig): + default_value = self.object.default_value + if default_value is Undefined: + default_value = "" + else: + default_value = repr(default_value) + + traitlets_type = self.object.__class__.__name__ + self.options.annotation = f"{traitlets_type}({default_value})" + # Skip over autodoc_traits otherwise it'll prepend c.ServerProcess + # to the annotation + super(TraitDocumenter, self).add_directive_header(sig) + + +def setup(app: Sphinx) -> ExtensionMetadata: + app.setup_extension("autodoc_traits") + app.add_autodocumenter(ServerProcessConfigurableDocumenter) + app.add_autodocumenter(ServerProcessTraitDocumenter) + return { + "version": "0.1", + "parallel_read_safe": True, + "parallel_write_safe": True, + } diff --git a/docs/source/conf.py b/docs/source/conf.py index 97320dee..db8a89aa 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -3,6 +3,8 @@ # Configuration reference: https://www.sphinx-doc.org/en/master/usage/configuration.html # import datetime +import sys +from pathlib import Path # -- Project information ----------------------------------------------------- # ref: https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information @@ -18,11 +20,17 @@ # Add any Sphinx extension module names here, as strings. They can be extensions # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. # + +# Custom extensions included this repo +extensions_dir = Path(__file__).absolute().parent.parent / "extensions" +sys.path.append(str(extensions_dir)) + extensions = [ "myst_parser", "sphinx_copybutton", "sphinxext.opengraph", "sphinxext.rediraffe", + "serverprocess_documenter", ] root_doc = "index" source_suffix = [".md"] diff --git a/docs/source/server-process.md b/docs/source/server-process.md index 6ac2208d..9e9cf316 100644 --- a/docs/source/server-process.md +++ b/docs/source/server-process.md @@ -15,232 +15,11 @@ as separate packages. Server Processes are configured with a dictionary of key value pairs. -(server-process:cmd)= +```{eval-rst} -### `command` - -One of: - -- A list of strings that is the command used to start the - process. The following template strings will be replaced: - - - `{port}` the port that the process should listen on. This will be 0 if it - should use a Unix socket instead. - - `{unix_socket}` the path at which the process should listen on a Unix - socket. This will be an empty string if it should use a TCP port. - - `{base_url}` the base URL of the notebook. For example, if the application - needs to know its full path it can be constructed from - `{base_url}/proxy/{port}` - -- A callable that takes any {ref}`callable arguments `, - and returns a list of strings that are used & treated same as above. - -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. - -### `timeout` - -Timeout in seconds for the process to become ready, default `5`. - -A process is considered 'ready' when it can return a valid HTTP response on the -port it is supposed to start at. - -### `environment` - -One of: - -- A dictionary of strings that are passed in as the environment to - the started process, in addition to the environment of the notebook - process itself. The strings `{port}`, `{unix_socket}` and - `{base_url}` will be replaced as for **command**. -- A callable that takes any {ref}`callable arguments `, - and returns a dictionary of strings that are used & treated same as above. - -### `absolute_url` - -_True_ if the URL as seen by the proxied application should be the full URL -sent by the user. _False_ if the URL as seen by the proxied application should -see the URL after the parts specific to jupyter-server-proxy have been stripped. - -For example, with the following config: - -```python -c.ServerProxy.servers = { - "test-server": { - "command": ["python3", "-m", "http.server", "{port}"], - "absolute_url": False - } -} +.. autoserverprocessconfigurable:: jupyter_server_proxy.config.ServerProcess ``` -When a user requests `/test-server/some-url`, the proxied server will see it -as a request for `/some-url` - the `/test-server` part is stripped out. - -If `absolute_url` is set to `True` instead, the proxied server will see it -as a request for `/test-server/some-url` instead - without any stripping. - -This is very useful with applications that require a `base_url` to be set. - -Defaults to _False_. - -### `port` - -Set the port that the service will listen on. The default is to -automatically select an unused port. - -(server-process:unix-socket)= - -### `unix_socket` - -This option uses a Unix socket on a filesystem path, instead of a TCP -port. It can be passed as a string specifying the socket path, or _True_ for -Jupyter Server Proxy to create a temporary directory to hold the socket, -ensuring that only the user running Jupyter can connect to it. - -If this is used, the `{unix_socket}` argument in the command template -(see {ref}`server-process:cmd`) will be a filesystem path. The server should -create a Unix socket bound to this path and listen for HTTP requests on it. -The `port` configuration key will be ignored. - -```{note} -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 with options on if / how an entry in the classic Jupyter Notebook -'New' dropdown or the JupyterLab launcher should be added. It can contain -the following keys: - -1. **enabled** - Set to True (default) to make an entry in the launchers. Set to False to have no - explicit entry. -2. **icon_path** - Full path to an svg icon that could be used with a launcher. Currently only used by the - JupyterLab launcher, when category is "Notebook" (default) or "Console". -3. **title** - Title to be used for the launcher entry. Defaults to the name of the server if missing. -4. **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. -5. **category** - The category for the launcher item. Currently only used by the JupyterLab launcher. - By default it is "Notebook". - -### `new_browser_tab` - -_JupyterLab only_ - _True_ (default) if the proxied server URL should be opened in a new browser tab. -_False_ if the proxied server URL should be opened in a new JupyterLab tab. - -If _False_, the proxied server needs to allow its pages to be rendered in an iframe. This -is generally done by configuring the web server `X-Frame-Options` to `SAMEORIGIN`. -For more information, refer to -[MDN Web docs on X-Frame-Options](https://developer.mozilla.org/docs/Web/HTTP/Headers/X-Frame-Options). - -Note that applications might use a different terminology to refer to frame options. -For example, RStudio uses the term _frame origin_ and require the flag -`--www-frame-origin=same` to allow rendering of its pages in an iframe. - -### `request_headers_override` - -One of: - -- A dictionary of strings that are passed in as HTTP headers to the proxy - request. The strings `{port}`, `{unix_socket}` and `{base_url}` will be - replaced as for **command**. -- A callable that takes any {ref}`callable arguments `, - and returns a dictionary of strings that are used & treated same as above. - -### `update_last_activity` - -Whether to report activity from the proxy to Jupyter Server. If _True_, Jupyter Server -will be notified of new activity. This is primarily used by JupyterHub for idle detection and culling. - -Useful if you want to have a seperate way of determining activity through a -proxied application. - -Defaults to _True_. - -(server-process:callable-arguments)= - -### `raw_socket_proxy` - -_True_ to proxy only websocket connections into raw stream connections. -_False_ (default) if the proxied server speaks full HTTP. - -If _True_, the proxied server is treated a raw TCP (or unix socket) server that -does not use HTTP. -In this mode, only websockets are handled, and messages are sent to the backend -server as raw stream data. This is similar to running a -[websockify](https://github.com/novnc/websockify) wrapper. -All other HTTP requests return 405. - -### Callable arguments - -Certain config options accept callables, as documented above. This should return -the same type of object that the option normally expects. -When you use a callable this way, it can ask for any arguments it needs -by simply declaring it - only arguments the callable asks for will be passed to it. - -For example, with the following config: - -```python -def _cmd_callback(): - return ["some-command"] - -server_config = { - "command": _cmd_callback -} -``` - -No arguments will be passed to `_cmd_callback`, since it doesn't ask for any. However, -with: - -```python -def _cmd_callback(port): - return ["some-command", "--port=" + str(port)] - -server_config = { - "command": _cmd_callback -} -``` - -The `port` argument will be passed to the callable. This is a simple form of dependency -injection that helps us add more parameters in the future without breaking backwards -compatibility. - -#### Available arguments - -Unless otherwise documented for specific options, the arguments available for -callables are: - -1. **port** - The TCP port on which the server should listen, or is listening. - This is 0 if a Unix socket is used instead of TCP. -2. **unix_socket** - The path of a Unix socket on which the server should listen, or is listening. - This is an empty string if a TCP socket is used. -3. **base_url** - The base URL of the notebook - -If any of the returned strings, lists or dictionaries contain strings -of form `{}`, they will be replaced with the value -of the argument. For example, if your function is: - -```python -def _openrefine_cmd(): - return ["openrefine", "-p", "{port}"] -``` - -The `{port}` will be replaced with the appropriate port before -the command is started - ## Specifying config via traitlets [Traitlets](https://traitlets.readthedocs.io/) are the configuration From 90c5fd004485101220cfc467f53c5d7923362391 Mon Sep 17 00:00:00 2001 From: Simon Li Date: Sat, 26 Oct 2024 14:51:44 +0100 Subject: [PATCH 7/9] Don't subclass autodoc_traits, since we need to override too much --- docs/extensions/serverprocess_documenter.py | 54 ++++++++++++++++----- 1 file changed, 41 insertions(+), 13 deletions(-) diff --git a/docs/extensions/serverprocess_documenter.py b/docs/extensions/serverprocess_documenter.py index 7f15cf7e..1339aa7a 100644 --- a/docs/extensions/serverprocess_documenter.py +++ b/docs/extensions/serverprocess_documenter.py @@ -1,14 +1,37 @@ -from autodoc_traits import ConfigurableDocumenter, TraitDocumenter +""" +A modified version of https://github.com/jupyterhub/autodoc-traits/tree/1.2.2 +for documenting trait fields that are used to configure another object, but +where the traitlet cannot be set directly. + +This is used to generate the Server Process options documentation: +https://github.com/jupyterhub/jupyter-server-proxy/blob/main/docs/source/server-process.md +""" + from sphinx.application import Sphinx -from sphinx.ext.autodoc import SUPPRESS, ClassDocumenter, ObjectMember +from sphinx.ext.autodoc import ( + SUPPRESS, + AttributeDocumenter, + ClassDocumenter, + ObjectMember, +) from sphinx.util.typing import ExtensionMetadata -from traitlets import Undefined +from traitlets import MetaHasTraits, TraitType, Undefined + +class ServerProcessConfigurableDocumenter(ClassDocumenter): + """ + A modified version of autodoc_traits.ConfigurableDocumenter that only documents + the traits in this class, not the inherited traits. + https://github.com/jupyterhub/autodoc-traits/blob/1.2.2/autodoc_traits.py#L20-L122 + """ -class ServerProcessConfigurableDocumenter(ConfigurableDocumenter): objtype = "serverprocessconfigurable" directivetype = "class" - priority = 100 + priority = 100 # higher priority than ClassDocumenter's 10 + + @classmethod + def can_document_member(cls, member, membername, isattr, parent): + return isinstance(member, MetaHasTraits) def get_object_members(self, want_all): """ @@ -21,20 +44,27 @@ def get_object_members(self, want_all): def should_suppress_directive_header(): return True - # Skip over autodoc_traits otherwise it'll prepend c.ServerProcess - # to the annotation def add_directive_header(self, sig): print(f"{sig=}") self.options.annotation = SUPPRESS - super(ClassDocumenter, self).add_directive_header(sig) + super().add_directive_header(sig) + +class ServerProcessTraitDocumenter(AttributeDocumenter): + """ + A modified version of autodoc_traits.TraitDocumenter that omits the c.ClassName prefix + https://github.com/jupyterhub/autodoc-traits/blob/1.2.2/autodoc_traits.py#L125-L203 + """ -class ServerProcessTraitDocumenter(TraitDocumenter): objtype = "serverprocesstrait" directivetype = "attribute" priority = 100 # AttributeDocumenter has 10 member_order = 0 # AttributeDocumenter has 60 + @classmethod + def can_document_member(cls, member, membername, isattr, parent): + return isinstance(member, TraitType) + def add_directive_header(self, sig): default_value = self.object.default_value if default_value is Undefined: @@ -44,13 +74,11 @@ def add_directive_header(self, sig): traitlets_type = self.object.__class__.__name__ self.options.annotation = f"{traitlets_type}({default_value})" - # Skip over autodoc_traits otherwise it'll prepend c.ServerProcess - # to the annotation - super(TraitDocumenter, self).add_directive_header(sig) + super().add_directive_header(sig) def setup(app: Sphinx) -> ExtensionMetadata: - app.setup_extension("autodoc_traits") + app.setup_extension("sphinx.ext.autodoc") app.add_autodocumenter(ServerProcessConfigurableDocumenter) app.add_autodocumenter(ServerProcessTraitDocumenter) return { From 640f16800c71ce85c42a514de5c7c22be66b8c98 Mon Sep 17 00:00:00 2001 From: Simon Li Date: Sat, 26 Oct 2024 17:28:27 +0100 Subject: [PATCH 8/9] Replace custom autodoc parser with a dedicated directive --- .github/workflows/linkcheck.yaml | 2 +- .readthedocs.yaml | 1 + docs/extensions/serverprocess_documenter.py | 102 +++++++------------- docs/requirements.txt | 1 + docs/source/server-process.md | 2 +- 5 files changed, 40 insertions(+), 68 deletions(-) diff --git a/.github/workflows/linkcheck.yaml b/.github/workflows/linkcheck.yaml index 96cf9040..d38d748d 100644 --- a/.github/workflows/linkcheck.yaml +++ b/.github/workflows/linkcheck.yaml @@ -27,7 +27,7 @@ jobs: python-version: "3.12" - name: Install deps - run: pip install -r docs/requirements.txt + run: pip install . -r docs/requirements.txt - name: make linkcheck run: | diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 6bf99912..98501d58 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -14,4 +14,5 @@ build: python: install: + - path: . - requirements: docs/requirements.txt diff --git a/docs/extensions/serverprocess_documenter.py b/docs/extensions/serverprocess_documenter.py index 1339aa7a..567ab857 100644 --- a/docs/extensions/serverprocess_documenter.py +++ b/docs/extensions/serverprocess_documenter.py @@ -1,86 +1,56 @@ """ -A modified version of https://github.com/jupyterhub/autodoc-traits/tree/1.2.2 -for documenting trait fields that are used to configure another object, but -where the traitlet cannot be set directly. - -This is used to generate the Server Process options documentation: +A custom Sphinx directive to generate the Server Process options documentation: https://github.com/jupyterhub/jupyter-server-proxy/blob/main/docs/source/server-process.md """ +import importlib +from textwrap import dedent + +from docutils import nodes from sphinx.application import Sphinx -from sphinx.ext.autodoc import ( - SUPPRESS, - AttributeDocumenter, - ClassDocumenter, - ObjectMember, -) +from sphinx.util.docutils import SphinxDirective from sphinx.util.typing import ExtensionMetadata -from traitlets import MetaHasTraits, TraitType, Undefined - - -class ServerProcessConfigurableDocumenter(ClassDocumenter): - """ - A modified version of autodoc_traits.ConfigurableDocumenter that only documents - the traits in this class, not the inherited traits. - https://github.com/jupyterhub/autodoc-traits/blob/1.2.2/autodoc_traits.py#L20-L122 - """ - - objtype = "serverprocessconfigurable" - directivetype = "class" - priority = 100 # higher priority than ClassDocumenter's 10 - - @classmethod - def can_document_member(cls, member, membername, isattr, parent): - return isinstance(member, MetaHasTraits) - - def get_object_members(self, want_all): - """ - Only document members in this class - """ - config_trait_members = self.object.class_traits(config=True).items() - members = [ObjectMember(name, trait) for (name, trait) in config_trait_members] - return False, members +from traitlets import Undefined - def should_suppress_directive_header(): - return True - def add_directive_header(self, sig): - print(f"{sig=}") - self.options.annotation = SUPPRESS - super().add_directive_header(sig) +class ServerProcessDirective(SphinxDirective): + """A directive to say hello!""" + required_arguments = 2 -class ServerProcessTraitDocumenter(AttributeDocumenter): - """ - A modified version of autodoc_traits.TraitDocumenter that omits the c.ClassName prefix - https://github.com/jupyterhub/autodoc-traits/blob/1.2.2/autodoc_traits.py#L125-L203 - """ + def run(self) -> list[nodes.Node]: + module = importlib.import_module(self.arguments[0], ".") + cls = getattr(module, self.arguments[1]) + config_trait_members = cls.class_traits(config=True).items() - objtype = "serverprocesstrait" - directivetype = "attribute" - priority = 100 # AttributeDocumenter has 10 - member_order = 0 # AttributeDocumenter has 60 + doc = [] - @classmethod - def can_document_member(cls, member, membername, isattr, parent): - return isinstance(member, TraitType) + for name, trait in config_trait_members: + default_value = trait.default_value + if default_value is Undefined: + default_value = "" + else: + default_value = repr(default_value) + traitlets_type = trait.__class__.__name__ - def add_directive_header(self, sig): - default_value = self.object.default_value - if default_value is Undefined: - default_value = "" - else: - default_value = repr(default_value) + help = self.parse_text_to_nodes(dedent(trait.metadata.get("help", ""))) - traitlets_type = self.object.__class__.__name__ - self.options.annotation = f"{traitlets_type}({default_value})" - super().add_directive_header(sig) + definition = nodes.definition_list_item( + "", + nodes.term( + "", + "", + nodes.strong(text=f"{name}"), + nodes.emphasis(text=f" {traitlets_type}({default_value})"), + ), + nodes.definition("", *help), + ) + doc.append(nodes.definition_list("", definition)) + return doc def setup(app: Sphinx) -> ExtensionMetadata: - app.setup_extension("sphinx.ext.autodoc") - app.add_autodocumenter(ServerProcessConfigurableDocumenter) - app.add_autodocumenter(ServerProcessTraitDocumenter) + app.add_directive("serverprocess", ServerProcessDirective) return { "version": "0.1", "parallel_read_safe": True, diff --git a/docs/requirements.txt b/docs/requirements.txt index 63f4be10..cc731455 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,4 +1,5 @@ myst-parser +sphinx>=7.4 sphinx-autobuild sphinx-book-theme sphinx-copybutton diff --git a/docs/source/server-process.md b/docs/source/server-process.md index 9e9cf316..6cc95922 100644 --- a/docs/source/server-process.md +++ b/docs/source/server-process.md @@ -17,7 +17,7 @@ pairs. ```{eval-rst} -.. autoserverprocessconfigurable:: jupyter_server_proxy.config.ServerProcess +.. serverprocess:: jupyter_server_proxy.config ServerProcess ``` ## Specifying config via traitlets From c01de6108feba7a12d5226887328f36f7f1322eb Mon Sep 17 00:00:00 2001 From: Simon Li Date: Sun, 27 Oct 2024 18:01:14 +0000 Subject: [PATCH 9/9] readthedocs: nodejs 20 --- .readthedocs.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 98501d58..b05dbbea 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -10,6 +10,7 @@ sphinx: build: os: ubuntu-22.04 tools: + nodejs: "20" python: "3.11" python: