|
12 | 12 | from importlib.metadata import entry_points
|
13 | 13 |
|
14 | 14 | from jupyter_server.utils import url_path_join as ujoin
|
15 |
| -from traitlets import Dict, List, Tuple, Union, default, observe |
| 15 | +from traitlets import ( |
| 16 | + Bool, |
| 17 | + Dict, |
| 18 | + Instance, |
| 19 | + Int, |
| 20 | + List, |
| 21 | + TraitError, |
| 22 | + Tuple, |
| 23 | + Unicode, |
| 24 | + Union, |
| 25 | + default, |
| 26 | + observe, |
| 27 | + validate, |
| 28 | +) |
16 | 29 | from traitlets.config import Configurable
|
17 | 30 |
|
18 | 31 | from .handlers import AddSlashHandler, NamedLocalProxyHandler, SuperviseAndProxyHandler
|
|
28 | 41 | LauncherEntry = namedtuple(
|
29 | 42 | "LauncherEntry", ["enabled", "icon_path", "title", "path_info", "category"]
|
30 | 43 | )
|
31 |
| -ServerProcess = namedtuple( |
32 |
| - "ServerProcess", |
33 |
| - [ |
34 |
| - "name", |
35 |
| - "command", |
36 |
| - "environment", |
37 |
| - "timeout", |
38 |
| - "absolute_url", |
39 |
| - "port", |
40 |
| - "unix_socket", |
41 |
| - "mappath", |
42 |
| - "launcher_entry", |
43 |
| - "new_browser_tab", |
44 |
| - "request_headers_override", |
45 |
| - "rewrite_response", |
46 |
| - "update_last_activity", |
47 |
| - "raw_socket_proxy", |
48 |
| - ], |
49 |
| -) |
| 44 | + |
| 45 | + |
| 46 | +class ServerProcess(Configurable): |
| 47 | + name = Unicode(help="Name").tag(config=True) |
| 48 | + command = List( |
| 49 | + Unicode(), |
| 50 | + help="""\ |
| 51 | + An optional list of strings that should be the full command to be executed. |
| 52 | + The optional template arguments {{port}}, {{unix_socket}} and {{base_url}} |
| 53 | + will be substituted with the port or Unix socket path the process should |
| 54 | + listen on and the base-url of the notebook. |
| 55 | +
|
| 56 | + Could also be a callable. It should return a list. |
| 57 | +
|
| 58 | + If the command is not specified or is an empty list, the server |
| 59 | + process is assumed to be started ahead of time and already available |
| 60 | + to be proxied to. |
| 61 | + """, |
| 62 | + ).tag(config=True) |
| 63 | + |
| 64 | + environment = Union( |
| 65 | + [Dict(Unicode()), Callable()], |
| 66 | + default_value={}, |
| 67 | + help="""\ |
| 68 | + A dictionary of environment variable mappings. As with the command |
| 69 | + traitlet, {{port}}, {{unix_socket}} and {{base_url}} will be substituted. |
| 70 | +
|
| 71 | + Could also be a callable. It should return a dictionary. |
| 72 | + """, |
| 73 | + ).tag(config=True) |
| 74 | + |
| 75 | + timeout = Int( |
| 76 | + 5, help="Timeout in seconds for the process to become ready, default 5s." |
| 77 | + ).tag(config=True) |
| 78 | + |
| 79 | + absolute_url = Bool( |
| 80 | + False, |
| 81 | + help=""" |
| 82 | + Proxy requests default to being rewritten to '/'. If this is True, |
| 83 | + the absolute URL will be sent to the backend instead. |
| 84 | + """, |
| 85 | + ).tag(config=True) |
| 86 | + |
| 87 | + port = Int( |
| 88 | + 0, |
| 89 | + help=""" |
| 90 | + Set the port that the service will listen on. The default is to automatically select an unused port. |
| 91 | + """, |
| 92 | + ).tag(config=True) |
| 93 | + |
| 94 | + unix_socket = Union( |
| 95 | + [Bool(False), Unicode()], |
| 96 | + default_value=None, |
| 97 | + help=""" |
| 98 | + If set, the service will listen on a Unix socket instead of a TCP port. |
| 99 | + Set to True to use a socket in a new temporary folder, or a string |
| 100 | + path to a socket. This overrides port. |
| 101 | +
|
| 102 | + Proxying websockets over a Unix socket requires Tornado >= 6.3. |
| 103 | + """, |
| 104 | + ).tag(config=True) |
| 105 | + |
| 106 | + mappath = Union( |
| 107 | + [Dict(Unicode()), Callable()], |
| 108 | + default_value={}, |
| 109 | + help=""" |
| 110 | + Map request paths to proxied paths. |
| 111 | + Either a dictionary of request paths to proxied paths, |
| 112 | + or a callable that takes parameter ``path`` and returns the proxied path. |
| 113 | + """, |
| 114 | + ).tag(config=True) |
| 115 | + |
| 116 | + # Can't use Instance(LauncherEntry) because LauncherEntry is not a class |
| 117 | + launcher_entry = Union( |
| 118 | + [Instance(object), Dict()], |
| 119 | + allow_none=False, |
| 120 | + help=""" |
| 121 | + A dictionary of various options for entries in classic notebook / jupyterlab launchers. |
| 122 | +
|
| 123 | + Keys recognized are: |
| 124 | +
|
| 125 | + enabled |
| 126 | + Set to True (default) to make an entry in the launchers. Set to False to have no |
| 127 | + explicit entry. |
| 128 | +
|
| 129 | + icon_path |
| 130 | + Full path to an svg icon that could be used with a launcher. Currently only used by the |
| 131 | + JupyterLab launcher |
| 132 | +
|
| 133 | + title |
| 134 | + Title to be used for the launcher entry. Defaults to the name of the server if missing. |
| 135 | +
|
| 136 | + path_info |
| 137 | + The trailing path that is appended to the user's server URL to access the proxied server. |
| 138 | + By default it is the name of the server followed by a trailing slash. |
| 139 | +
|
| 140 | + category |
| 141 | + The category for the launcher item. Currently only used by the JupyterLab launcher. |
| 142 | + By default it is "Notebook". |
| 143 | + """, |
| 144 | + ).tag(config=True) |
| 145 | + |
| 146 | + @validate("launcher_entry") |
| 147 | + def _validate_launcher_entry(self, proposal): |
| 148 | + le = proposal["value"] |
| 149 | + invalid_keys = set(le.keys()).difference( |
| 150 | + {"enabled", "icon_path", "title", "path_info", "category"} |
| 151 | + ) |
| 152 | + if invalid_keys: |
| 153 | + raise TraitError( |
| 154 | + f"launcher_entry {le} contains invalid keys: {invalid_keys}" |
| 155 | + ) |
| 156 | + return ( |
| 157 | + LauncherEntry( |
| 158 | + enabled=le.get("enabled", True), |
| 159 | + icon_path=le.get("icon_path"), |
| 160 | + title=le.get("title", self.name), |
| 161 | + path_info=le.get("path_info", self.name + "/"), |
| 162 | + category=le.get("category", "Notebook"), |
| 163 | + ), |
| 164 | + ) |
| 165 | + |
| 166 | + new_browser_tab = Bool( |
| 167 | + True, |
| 168 | + help=""" |
| 169 | + Set to True (default) to make the proxied server interface opened as a new browser tab. Set to False |
| 170 | + to have it open a new JupyterLab tab. This has no effect in classic notebook. |
| 171 | + """, |
| 172 | + ).tag(config=True) |
| 173 | + |
| 174 | + request_headers_override = Dict( |
| 175 | + Unicode(), |
| 176 | + default_value={}, |
| 177 | + help=""" |
| 178 | + A dictionary of additional HTTP headers for the proxy request. As with |
| 179 | + the command traitlet, {{port}}, {{unix_socket}} and {{base_url}} will be substituted. |
| 180 | + """, |
| 181 | + ).tag(config=True) |
| 182 | + |
| 183 | + rewrite_response = Union( |
| 184 | + [Callable(), List(Callable())], |
| 185 | + default_value=[], |
| 186 | + help=""" |
| 187 | + An optional function to rewrite the response for the given service. |
| 188 | + Input is a RewritableResponse object which is an argument that MUST be named |
| 189 | + ``response``. The function should modify one or more of the attributes |
| 190 | + ``.body``, ``.headers``, ``.code``, or ``.reason`` of the ``response`` |
| 191 | + argument. For example: |
| 192 | +
|
| 193 | + def dog_to_cat(response): |
| 194 | + response.headers["I-Like"] = "tacos" |
| 195 | + response.body = response.body.replace(b'dog', b'cat') |
| 196 | +
|
| 197 | + c.ServerProxy.servers['my_server']['rewrite_response'] = dog_to_cat |
| 198 | +
|
| 199 | + The ``rewrite_response`` function can also accept several optional |
| 200 | + positional arguments. Arguments named ``host``, ``port``, and ``path`` will |
| 201 | + receive values corresponding to the URL ``/proxy/<host>:<port><path>``. In |
| 202 | + addition, the original Tornado ``HTTPRequest`` and ``HTTPResponse`` objects |
| 203 | + are available as arguments named ``request`` and ``orig_response``. (These |
| 204 | + objects should not be modified.) |
| 205 | +
|
| 206 | + A list or tuple of functions can also be specified for chaining multiple |
| 207 | + rewrites. For example: |
| 208 | +
|
| 209 | + def cats_only(response, path): |
| 210 | + if path.startswith("/cat-club"): |
| 211 | + response.code = 403 |
| 212 | + response.body = b"dogs not allowed" |
| 213 | +
|
| 214 | + c.ServerProxy.servers['my_server']['rewrite_response'] = [dog_to_cat, cats_only] |
| 215 | +
|
| 216 | + Note that if the order is reversed to ``[cats_only, dog_to_cat]``, then accessing |
| 217 | + ``/cat-club`` will produce a "403 Forbidden" response with body "cats not allowed" |
| 218 | + instead of "dogs not allowed". |
| 219 | +
|
| 220 | + Defaults to the empty tuple ``tuple()``. |
| 221 | + """, |
| 222 | + ).tag(config=True) |
| 223 | + |
| 224 | + update_last_activity = Bool( |
| 225 | + True, help="Will cause the proxy to report activity back to jupyter server." |
| 226 | + ).tag(config=True) |
| 227 | + |
| 228 | + raw_socket_proxy = Bool( |
| 229 | + False, |
| 230 | + help=""" |
| 231 | + Proxy websocket requests as a raw TCP (or unix socket) stream. |
| 232 | + In this mode, only websockets are handled, and messages are sent to the backend, |
| 233 | + similar to running a websockify layer (https://github.com/novnc/websockify). |
| 234 | + All other HTTP requests return 405 (and thus this will also bypass rewrite_response). |
| 235 | + """, |
| 236 | + ).tag(config=True) |
50 | 237 |
|
51 | 238 |
|
52 | 239 | def _make_proxy_handler(sp: ServerProcess):
|
@@ -132,34 +319,7 @@ def make_handlers(base_url, server_processes):
|
132 | 319 |
|
133 | 320 |
|
134 | 321 | def make_server_process(name, server_process_config, serverproxy_config):
|
135 |
| - le = server_process_config.get("launcher_entry", {}) |
136 |
| - return ServerProcess( |
137 |
| - name=name, |
138 |
| - command=server_process_config.get("command", list()), |
139 |
| - environment=server_process_config.get("environment", {}), |
140 |
| - timeout=server_process_config.get("timeout", 5), |
141 |
| - absolute_url=server_process_config.get("absolute_url", False), |
142 |
| - port=server_process_config.get("port", 0), |
143 |
| - unix_socket=server_process_config.get("unix_socket", None), |
144 |
| - mappath=server_process_config.get("mappath", {}), |
145 |
| - launcher_entry=LauncherEntry( |
146 |
| - enabled=le.get("enabled", True), |
147 |
| - icon_path=le.get("icon_path"), |
148 |
| - title=le.get("title", name), |
149 |
| - path_info=le.get("path_info", name + "/"), |
150 |
| - category=le.get("category", "Notebook"), |
151 |
| - ), |
152 |
| - new_browser_tab=server_process_config.get("new_browser_tab", True), |
153 |
| - request_headers_override=server_process_config.get( |
154 |
| - "request_headers_override", {} |
155 |
| - ), |
156 |
| - rewrite_response=server_process_config.get( |
157 |
| - "rewrite_response", |
158 |
| - tuple(), |
159 |
| - ), |
160 |
| - update_last_activity=server_process_config.get("update_last_activity", True), |
161 |
| - raw_socket_proxy=server_process_config.get("raw_socket_proxy", False), |
162 |
| - ) |
| 322 | + return ServerProcess(name=name, **server_process_config) |
163 | 323 |
|
164 | 324 |
|
165 | 325 | class ServerProxy(Configurable):
|
|
0 commit comments