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/.github/workflows/test.yaml b/.github/workflows/test.yaml index 57c187a9..fb3ade2f 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -167,6 +167,10 @@ jobs: jupyter lab extension list jupyter lab extension list 2>&1 | grep -iE 'jupyter_server_proxy.*OK.*' + - name: Install a dummy entrypoint so we can test its loaded correctly + run: | + pip install ./tests/resources/dummyentrypoint/ + # we have installed a pre-built wheel and configured code coverage to # inspect "jupyter_server_proxy", by re-locating to another directory, # there is no confusion about "jupyter_server_proxy" referring to our diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 6bf99912..b05dbbea 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -10,8 +10,10 @@ sphinx: build: os: ubuntu-22.04 tools: + nodejs: "20" python: "3.11" python: install: + - path: . - requirements: docs/requirements.txt diff --git a/docs/extensions/serverprocess_documenter.py b/docs/extensions/serverprocess_documenter.py new file mode 100644 index 00000000..567ab857 --- /dev/null +++ b/docs/extensions/serverprocess_documenter.py @@ -0,0 +1,58 @@ +""" +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.util.docutils import SphinxDirective +from sphinx.util.typing import ExtensionMetadata +from traitlets import Undefined + + +class ServerProcessDirective(SphinxDirective): + """A directive to say hello!""" + + required_arguments = 2 + + 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() + + doc = [] + + 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__ + + help = self.parse_text_to_nodes(dedent(trait.metadata.get("help", ""))) + + 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.add_directive("serverprocess", ServerProcessDirective) + return { + "version": "0.1", + "parallel_read_safe": True, + "parallel_write_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/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..6cc95922 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 - } -} +.. serverprocess:: 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 diff --git a/jupyter_server_proxy/__init__.py b/jupyter_server_proxy/__init__.py index cbce0d5f..addae684 100644 --- a/jupyter_server_proxy/__init__.py +++ b/jupyter_server_proxy/__init__.py @@ -2,6 +2,7 @@ from ._version import __version__ # noqa from .api import IconHandler, ServersInfoHandler +from .config import ServerProcess, ServerProcessEntryPoint from .config import ServerProxy as ServerProxyConfig from .config import get_entrypoint_server_processes, make_handlers, make_server_process from .handlers import setup_handlers @@ -45,7 +46,9 @@ def _load_jupyter_server_extension(nbapp): make_server_process(name, server_process_config, serverproxy_config) for name, server_process_config in serverproxy_config.servers.items() ] - server_processes += get_entrypoint_server_processes(serverproxy_config) + server_processes += get_entrypoint_server_processes( + serverproxy_config, parent=nbapp + ) server_handlers = make_handlers(base_url, server_processes) nbapp.web_app.add_handlers(".*", server_handlers) @@ -81,3 +84,5 @@ def _load_jupyter_server_extension(nbapp): # For backward compatibility load_jupyter_server_extension = _load_jupyter_server_extension _jupyter_server_extension_paths = _jupyter_server_extension_points + +__all__ = ["ServerProcess", "ServerProcessEntryPoint"] diff --git a/jupyter_server_proxy/config.py b/jupyter_server_proxy/config.py index 649e9a04..d157fbc0 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,272 @@ 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) + + +class ServerProcess(_ServerProcess): + """ + A configurable server process for single standalone servers. + This is separate from ServerProcessEntryPoint so that we can configure it + independently of ServerProcessEntryPoint + """ + + +class ServerProcessEntryPoint(_ServerProcess): + """ + A ServeProcess entrypoint that is a Configurable. + This is separate from ServerProcess so that we can configure it + independently of ServerProcess + """ def _make_proxy_handler(sp: ServerProcess): @@ -97,16 +335,23 @@ def get_timeout(self): return _Proxy -def get_entrypoint_server_processes(serverproxy_config): +def get_entrypoint_server_processes(serverproxy_config, parent): sps = [] for entry_point in entry_points(group="jupyter_serverproxy_servers"): name = entry_point.name try: - server_process_config = entry_point.load()() + server_process_callable = entry_point.load() + if issubclass(server_process_callable, ServerProcessEntryPoint): + server_process = server_process_callable(name=name, parent=parent) + sps.append(server_process) + else: + server_process_config = server_process_callable() + sps.append( + make_server_process(name, server_process_config, serverproxy_config) + ) except Exception as e: warn(f"entry_point {name} was unable to be loaded: {str(e)}") continue - sps.append(make_server_process(name, server_process_config, serverproxy_config)) return sps @@ -125,34 +370,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 +393,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, ) diff --git a/tests/resources/dummyentrypoint/pyproject.toml b/tests/resources/dummyentrypoint/pyproject.toml new file mode 100644 index 00000000..2d9e43c2 --- /dev/null +++ b/tests/resources/dummyentrypoint/pyproject.toml @@ -0,0 +1,7 @@ +[project] +name = "test-jsp-dummyentrypoint" +version = "0.0.0" + + +[project.entry-points.jupyter_serverproxy_servers] +test-serverprocessentrypoint = "test_jsp_dummyentrypoint:CustomServerProcessEntryPoint" diff --git a/tests/resources/dummyentrypoint/test_jsp_dummyentrypoint/__init__.py b/tests/resources/dummyentrypoint/test_jsp_dummyentrypoint/__init__.py new file mode 100644 index 00000000..a920c553 --- /dev/null +++ b/tests/resources/dummyentrypoint/test_jsp_dummyentrypoint/__init__.py @@ -0,0 +1,17 @@ +""" +Test whether ServerProcessEntryPoint can be configured using traitlets +""" + +import sys +from pathlib import Path + +from traitlets.config import default + +from jupyter_server_proxy import ServerProcessEntryPoint + + +class CustomServerProcessEntryPoint(ServerProcessEntryPoint): + @default("command") + def _default_command(self): + parent = Path(__file__).parent.resolve() + return [sys.executable, str(parent / "httpinfo.py"), "--port={port}"] diff --git a/tests/resources/dummyentrypoint/test_jsp_dummyentrypoint/httpinfo.py b/tests/resources/dummyentrypoint/test_jsp_dummyentrypoint/httpinfo.py new file mode 100644 index 00000000..07d01ddb --- /dev/null +++ b/tests/resources/dummyentrypoint/test_jsp_dummyentrypoint/httpinfo.py @@ -0,0 +1,24 @@ +""" +Simple webserver to respond with an echo of the sent request. +""" + +import argparse +from http.server import BaseHTTPRequestHandler, HTTPServer + + +class EchoRequestInfo(BaseHTTPRequestHandler): + def do_GET(self): + self.send_response(200) + self.send_header("Content-type", "text/plain") + self.end_headers() + self.wfile.write(f"{self.requestline}\n".encode()) + self.wfile.write(f"{self.headers}\n".encode()) + + +if __name__ == "__main__": + ap = argparse.ArgumentParser() + ap.add_argument("--port", type=int) + args = ap.parse_args() + + httpd = HTTPServer(("127.0.0.1", args.port), EchoRequestInfo) + httpd.serve_forever() diff --git a/tests/resources/jupyter_server_config.py b/tests/resources/jupyter_server_config.py index ac1e0dfe..06ed03c9 100644 --- a/tests/resources/jupyter_server_config.py +++ b/tests/resources/jupyter_server_config.py @@ -51,6 +51,12 @@ def my_env(): return {"MYVAR": "String with escaped {{var}}"} +# Traitlets configuration for test-serverprocessentrypoint in the dummyentrypoint package +c.CustomServerProcessEntryPoint.request_headers_override = { + "X-Custom-Header": "custom-configurable" +} + + c.ServerProxy.servers = { "python-http": { "command": [sys.executable, _get_path("httpinfo.py"), "--port={port}"], diff --git a/tests/test_proxies.py b/tests/test_proxies.py index 4573517c..3afdb3fd 100644 --- a/tests/test_proxies.py +++ b/tests/test_proxies.py @@ -528,3 +528,11 @@ async def test_server_proxy_rawsocket( await conn.write_message(msg) res = await conn.read_message() assert res == msg.swapcase() + + +def test_server_configurable_class(a_server_port_and_token: Tuple[int, str]) -> None: + PORT, TOKEN = a_server_port_and_token + r = request_get(PORT, "/test-serverprocessentrypoint/", TOKEN, host="127.0.0.1") + assert r.code == 200 + s = r.read().decode("ascii") + assert "X-Custom-Header: custom-configurable\n" in s