diff --git a/.flake8 b/.flake8 new file mode 100644 index 00000000..6a944f76 --- /dev/null +++ b/.flake8 @@ -0,0 +1,12 @@ +# flake8 is used for linting Python code setup to automatically run with +# pre-commit. +# +# ref: https://flake8.pycqa.org/en/latest/user/configuration.html +# + +[flake8] +# E: style errors +# W: style warnings +# C: complexity +# D: docstring warnings (unused pydocstyle extension) +ignore = E, C, W, D diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..1c84cc2a --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,74 @@ +# pre-commit is a tool to perform a predefined set of tasks manually and/or +# automatically before git commits are made. +# +# Config reference: https://pre-commit.com/#pre-commit-configyaml---top-level +# +# Common tasks +# +# - Run on all files: pre-commit run --all-files +# - Register git hooks: pre-commit install --install-hooks +# +repos: + # Autoformat: Python code, syntax patterns are modernized + - repo: https://github.com/asottile/pyupgrade + rev: v3.3.1 + hooks: + - id: pyupgrade + args: + - --py38-plus + + # Autoformat: Python code + - repo: https://github.com/PyCQA/autoflake + rev: v2.0.2 + hooks: + - id: autoflake + # args ref: https://github.com/PyCQA/autoflake#advanced-usage + args: + - --in-place + + # Autoformat: Python code + - repo: https://github.com/pycqa/isort + rev: 5.12.0 + hooks: + - id: isort + + # Autoformat: Python code + - repo: https://github.com/psf/black + rev: 23.3.0 + hooks: + - id: black + exclude: "contrib\/template\/.*" + + # Autoformat: markdown, yaml + - repo: https://github.com/pre-commit/mirrors-prettier + rev: v3.0.0-alpha.6 + hooks: + - id: prettier + + # Misc... + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.4.0 + # ref: https://github.com/pre-commit/pre-commit-hooks#hooks-available + hooks: + # Autoformat: Makes sure files end in a newline and only a newline. + - id: end-of-file-fixer + + # Autoformat: Sorts entries in requirements.txt. + - id: requirements-txt-fixer + + # Lint: Check for files with names that would conflict on a + # case-insensitive filesystem like MacOS HFS+ or Windows FAT. + - id: check-case-conflict + + # Lint: Checks that non-binary executables have a proper shebang. + - id: check-executables-have-shebangs + + # Lint: Python code + - repo: https://github.com/PyCQA/flake8 + rev: "6.0.0" + hooks: + - id: flake8 + +# pre-commit.ci config reference: https://pre-commit.ci/#configuration +ci: + autoupdate_schedule: monthly diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 69f37a0e..cdbe485d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -90,12 +90,12 @@ jlpm run watch jupyter lab ``` -With the watch command running, every saved change will immediately be built locally -and available in your running JupyterLab. Refresh JupyterLab to load the change in +With the watch command running, every saved change will immediately be built locally +and available in your running JupyterLab. Refresh JupyterLab to load the change in your browser (you may need to wait several seconds for the extension to be rebuilt). -By default, the `jlpm run build` command generates the source maps for this -extension to make it easier to debug using the browser dev tools. To also generate +By default, the `jlpm run build` command generates the source maps for this +extension to make it easier to debug using the browser dev tools. To also generate source maps for the JupyterLab core extensions, you can run the following command: ```bash diff --git a/README.md b/README.md index fb55fbf9..953eb4e9 100644 --- a/README.md +++ b/README.md @@ -48,13 +48,13 @@ Jupyter Server within a container and only allow network access to the Jupyter Server via the container. > For more insights, see [Ryan Lovett's comment about -it](https://github.com/jupyterhub/jupyter-server-proxy/pull/359#issuecomment-1350118197). +> it](https://github.com/jupyterhub/jupyter-server-proxy/pull/359#issuecomment-1350118197). ## Install ### Requirements -* `jupyterlab>=2` or `notebook` +- `jupyterlab>=2` or `notebook` ### Python package diff --git a/contrib/code-server-traitlet/jupyter_notebook_config.py b/contrib/code-server-traitlet/jupyter_notebook_config.py index 4698e117..0a06b827 100644 --- a/contrib/code-server-traitlet/jupyter_notebook_config.py +++ b/contrib/code-server-traitlet/jupyter_notebook_config.py @@ -1,14 +1,16 @@ +# load the config object for traitlets based configuration +c = get_config() # noqa + + c.ServerProxy.servers = { - 'code-server': { - 'command': [ - 'code-server', - '--auth=none', - '--disable-telemetry', - '--bind-addr=localhost:{port}' - ], - 'timeout': 20, - 'launcher_entry': { - 'title': 'VS Code' + "code-server": { + "command": [ + "code-server", + "--auth=none", + "--disable-telemetry", + "--bind-addr=localhost:{port}", + ], + "timeout": 20, + "launcher_entry": {"title": "VS Code"}, } - } } diff --git a/contrib/template/cookiecutter.json b/contrib/template/cookiecutter.json index 2b47d82c..bb77d5fa 100644 --- a/contrib/template/cookiecutter.json +++ b/contrib/template/cookiecutter.json @@ -1,5 +1,5 @@ { - "project_name": "", - "author_name": "Project Jupyter Contributors", - "author_email": "projectjupyter@gmail.com" + "project_name": "", + "author_name": "Project Jupyter Contributors", + "author_email": "projectjupyter@gmail.com" } diff --git a/contrib/template/{{cookiecutter.project_name}}/jupyter_{{cookiecutter.project_name}}_proxy/__init__.py b/contrib/template/{{cookiecutter.project_name}}/jupyter_{{cookiecutter.project_name}}_proxy/__init__.py index 9e428ff6..7c360aa4 100644 --- a/contrib/template/{{cookiecutter.project_name}}/jupyter_{{cookiecutter.project_name}}_proxy/__init__.py +++ b/contrib/template/{{cookiecutter.project_name}}/jupyter_{{cookiecutter.project_name}}_proxy/__init__.py @@ -6,6 +6,7 @@ """ import os + def setup_{{cookiecutter.project_name}}(): return { 'command': [], diff --git a/contrib/theia/jupyter_theia_proxy/__init__.py b/contrib/theia/jupyter_theia_proxy/__init__.py index 23d5f85c..008ff468 100644 --- a/contrib/theia/jupyter_theia_proxy/__init__.py +++ b/contrib/theia/jupyter_theia_proxy/__init__.py @@ -7,21 +7,22 @@ import os import shutil + def setup_theia(): # Make sure theia is in $PATH def _theia_command(port): - full_path = shutil.which('theia') + full_path = shutil.which("theia") if not full_path: - raise FileNotFoundError('Can not find theia executable in $PATH') - return ['theia', 'start', '.', '--hostname=127.0.0.1', '--port=' + str(port)] + raise FileNotFoundError("Can not find theia executable in $PATH") + return ["theia", "start", ".", "--hostname=127.0.0.1", "--port=" + str(port)] return { - 'command': _theia_command, - 'environment': { - 'USE_LOCAL_GIT': 'true' + "command": _theia_command, + "environment": {"USE_LOCAL_GIT": "true"}, + "launcher_entry": { + "title": "Theia IDE", + "icon_path": os.path.join( + os.path.dirname(os.path.abspath(__file__)), "icons", "theia.svg" + ), }, - 'launcher_entry': { - 'title': 'Theia IDE', - 'icon_path': os.path.join(os.path.dirname(os.path.abspath(__file__)), 'icons', 'theia.svg') - } - } \ No newline at end of file + } diff --git a/contrib/theia/setup.py b/contrib/theia/setup.py index 0014f81b..38544125 100644 --- a/contrib/theia/setup.py +++ b/contrib/theia/setup.py @@ -2,22 +2,20 @@ setuptools.setup( name="jupyter-theia-proxy", - version='1.0dev', + version="1.0dev", url="https://github.com/jupyterhub/jupyter-server-proxy/tree/HEAD/contrib/theia", author="Project Jupyter Contributors", description="projectjupyter@gmail.com", packages=setuptools.find_packages(), - keywords=['Jupyter'], - classifiers=['Framework :: Jupyter'], - install_requires=[ - 'jupyter-server-proxy' - ], + keywords=["Jupyter"], + classifiers=["Framework :: Jupyter"], + install_requires=["jupyter-server-proxy"], entry_points={ - 'jupyter_serverproxy_servers': [ - 'theia = jupyter_theia_proxy:setup_theia', + "jupyter_serverproxy_servers": [ + "theia = jupyter_theia_proxy:setup_theia", ] }, package_data={ - 'jupyter_theia_proxy': ['icons/*'], + "jupyter_theia_proxy": ["icons/*"], }, ) diff --git a/docs/source/arbitrary-ports-hosts.rst b/docs/source/arbitrary-ports-hosts.rst index 0155320a..b26e562c 100644 --- a/docs/source/arbitrary-ports-hosts.rst +++ b/docs/source/arbitrary-ports-hosts.rst @@ -90,4 +90,4 @@ accessing it from a classic notebook extension. // Construct URL of our proxied service let service_url = base_url + 'proxy/' + port; - // Do stuff with your service_url \ No newline at end of file + // Do stuff with your service_url diff --git a/docs/source/changelog.md b/docs/source/changelog.md index 396b178d..b25ba763 100644 --- a/docs/source/changelog.md +++ b/docs/source/changelog.md @@ -23,7 +23,6 @@ [@bollwyvl](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyter-server-proxy+involves%3Abollwyvl+updated%3A2021-11-29..2022-01-19&type=Issues) | [@consideRatio](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyter-server-proxy+involves%3AconsideRatio+updated%3A2021-11-29..2022-01-19&type=Issues) | [@yuvipanda](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyter-server-proxy+involves%3Ayuvipanda+updated%3A2021-11-29..2022-01-19&type=Issues) - ### 3.2.0 - 2021-11-29 #### New features added @@ -110,7 +109,7 @@ #### Bugs fixed -* Include jupyterlab-server-proxy in the sdist [#260](https://github.com/jupyterhub/jupyter-server-proxy/pull/260) ([@xhochy](https://github.com/xhochy)) +- Include jupyterlab-server-proxy in the sdist [#260](https://github.com/jupyterhub/jupyter-server-proxy/pull/260) ([@xhochy](https://github.com/xhochy)) #### Contributors to this release @@ -120,7 +119,7 @@ #### Bugs fixed -* Fix PyPI url [#259](https://github.com/jupyterhub/jupyter-server-proxy/pull/259) ([@janjagusch](https://github.com/janjagusch)) +- Fix PyPI url [#259](https://github.com/jupyterhub/jupyter-server-proxy/pull/259) ([@janjagusch](https://github.com/janjagusch)) #### Contributors to this release @@ -138,19 +137,19 @@ version jumps from 2.1.2 to 3.0.0. #### Enhancements made -* Package jupyter lab extension [#245](https://github.com/jupyterhub/jupyter-server-proxy/pull/245) ([@janjagusch](https://github.com/janjagusch)) +- Package jupyter lab extension [#245](https://github.com/jupyterhub/jupyter-server-proxy/pull/245) ([@janjagusch](https://github.com/janjagusch)) #### Maintenance and upkeep improvements -* Breaking: Replace host_whitelist with host_allowlist [#256](https://github.com/jupyterhub/jupyter-server-proxy/pull/256) ([@manics](https://github.com/manics)) -* Switch from notebook to jupyter-server [#254](https://github.com/jupyterhub/jupyter-server-proxy/pull/254) ([@manics](https://github.com/manics)) +- Breaking: Replace host_whitelist with host_allowlist [#256](https://github.com/jupyterhub/jupyter-server-proxy/pull/256) ([@manics](https://github.com/manics)) +- Switch from notebook to jupyter-server [#254](https://github.com/jupyterhub/jupyter-server-proxy/pull/254) ([@manics](https://github.com/manics)) #### Continuous integration improvements -* Move build.yaml into test.yaml [#255](https://github.com/jupyterhub/jupyter-server-proxy/pull/255) ([@manics](https://github.com/manics)) -* Fix build.yaml workflow [#249](https://github.com/jupyterhub/jupyter-server-proxy/pull/249) ([@manics](https://github.com/manics)) -* Add publish PyPI and NPM workflow [#247](https://github.com/jupyterhub/jupyter-server-proxy/pull/247) ([@manics](https://github.com/manics)) -* tests: remove bad test, add new clarifying current behavior [#240](https://github.com/jupyterhub/jupyter-server-proxy/pull/240) ([@consideRatio](https://github.com/consideRatio)) +- Move build.yaml into test.yaml [#255](https://github.com/jupyterhub/jupyter-server-proxy/pull/255) ([@manics](https://github.com/manics)) +- Fix build.yaml workflow [#249](https://github.com/jupyterhub/jupyter-server-proxy/pull/249) ([@manics](https://github.com/manics)) +- Add publish PyPI and NPM workflow [#247](https://github.com/jupyterhub/jupyter-server-proxy/pull/247) ([@manics](https://github.com/manics)) +- tests: remove bad test, add new clarifying current behavior [#240](https://github.com/jupyterhub/jupyter-server-proxy/pull/240) ([@consideRatio](https://github.com/consideRatio)) #### Contributors to this release @@ -170,14 +169,14 @@ extension isn't yet bundled with the python package. #### Enhancements made -* Add Jupyter Server extension data file (JupyterLab 3 support) [#235](https://github.com/jupyterhub/jupyter-server-proxy/pull/235) ([@jtpio](https://github.com/jtpio)) -* Update dependencies to include jupyterlab 3.x.x (JupyterLab 3 support) [#229](https://github.com/jupyterhub/jupyter-server-proxy/pull/229) ([@dipanjank](https://github.com/dipanjank)) +- Add Jupyter Server extension data file (JupyterLab 3 support) [#235](https://github.com/jupyterhub/jupyter-server-proxy/pull/235) ([@jtpio](https://github.com/jtpio)) +- Update dependencies to include jupyterlab 3.x.x (JupyterLab 3 support) [#229](https://github.com/jupyterhub/jupyter-server-proxy/pull/229) ([@dipanjank](https://github.com/dipanjank)) #### Documentation improvements -* Bump to 1.6.0 (setup.py) and add CHANGELOG.md [#238](https://github.com/jupyterhub/jupyter-server-proxy/pull/238) ([@consideRatio](https://github.com/consideRatio)) -* Replace server-process list with linkable headings [#236](https://github.com/jupyterhub/jupyter-server-proxy/pull/236) ([@manics](https://github.com/manics)) -* Rename the mamba-navigator example to gator in the documentation [#234](https://github.com/jupyterhub/jupyter-server-proxy/pull/234) ([@jtpio](https://github.com/jtpio)) +- Bump to 1.6.0 (setup.py) and add CHANGELOG.md [#238](https://github.com/jupyterhub/jupyter-server-proxy/pull/238) ([@consideRatio](https://github.com/consideRatio)) +- Replace server-process list with linkable headings [#236](https://github.com/jupyterhub/jupyter-server-proxy/pull/236) ([@manics](https://github.com/manics)) +- Rename the mamba-navigator example to gator in the documentation [#234](https://github.com/jupyterhub/jupyter-server-proxy/pull/234) ([@jtpio](https://github.com/jtpio)) #### Contributors to this release diff --git a/docs/source/conf.py b/docs/source/conf.py index 744b8fc3..54bd4455 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -4,7 +4,6 @@ # import datetime - # -- Project information ----------------------------------------------------- # ref: https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information # diff --git a/docs/source/convenience/new.rst b/docs/source/convenience/new.rst index 21633155..34293d5c 100644 --- a/docs/source/convenience/new.rst +++ b/docs/source/convenience/new.rst @@ -19,4 +19,4 @@ named after your project with a python package. From there, you should: process, any environment variables, and title of the launcher icon #. (Optionally) Add a square svg icon for your launcher in the ``icons`` - subfolder, with the same name as your project. \ No newline at end of file + subfolder, with the same name as your project. diff --git a/jupyter_server_proxy/__init__.py b/jupyter_server_proxy/__init__.py index a89344a1..ee658938 100644 --- a/jupyter_server_proxy/__init__.py +++ b/jupyter_server_proxy/__init__.py @@ -1,32 +1,43 @@ -from .handlers import setup_handlers -from .config import ServerProxy as ServerProxyConfig, make_handlers, get_entrypoint_server_processes, make_server_process from jupyter_server.utils import url_path_join as ujoin -from .api import ServersInfoHandler, IconHandler + +from .api import IconHandler, ServersInfoHandler +from .config import ServerProxy as ServerProxyConfig +from .config import get_entrypoint_server_processes, make_handlers, make_server_process +from .handlers import setup_handlers + # Jupyter Extension points def _jupyter_server_extension_points(): - return [{ - 'module': 'jupyter_server_proxy', - }] + return [ + { + "module": "jupyter_server_proxy", + } + ] + def _jupyter_nbextension_paths(): - return [{ - "section": "tree", - "dest": "jupyter_server_proxy", - 'src': 'static', - "require": "jupyter_server_proxy/tree" - }] + return [ + { + "section": "tree", + "dest": "jupyter_server_proxy", + "src": "static", + "require": "jupyter_server_proxy/tree", + } + ] + def _jupyter_labextension_paths(): - return [{ - "src": "labextension", - "dest": "@jupyterhub/jupyter-server-proxy", - }] + return [ + { + "src": "labextension", + "dest": "@jupyterhub/jupyter-server-proxy", + } + ] def _load_jupyter_server_extension(nbapp): # Set up handlers picked up via config - base_url = nbapp.web_app.settings['base_url'] + base_url = nbapp.web_app.settings["base_url"] serverproxy_config = ServerProxyConfig(parent=nbapp) server_processes = [ @@ -35,7 +46,7 @@ def _load_jupyter_server_extension(nbapp): ] server_processes += get_entrypoint_server_processes(serverproxy_config) server_handlers = make_handlers(base_url, server_processes) - nbapp.web_app.add_handlers('.*', server_handlers) + nbapp.web_app.add_handlers(".*", server_handlers) # Set up default non-server handler setup_handlers( @@ -48,10 +59,17 @@ def _load_jupyter_server_extension(nbapp): if sp.launcher_entry.enabled and sp.launcher_entry.icon_path: icons[sp.name] = sp.launcher_entry.icon_path - nbapp.web_app.add_handlers('.*', [ - (ujoin(base_url, 'server-proxy/servers-info'), ServersInfoHandler, {'server_processes': server_processes}), - (ujoin(base_url, 'server-proxy/icon/(.*)'), IconHandler, {'icons': icons}), - ]) + nbapp.web_app.add_handlers( + ".*", + [ + ( + ujoin(base_url, "server-proxy/servers-info"), + ServersInfoHandler, + {"server_processes": server_processes}, + ), + (ujoin(base_url, "server-proxy/icon/(.*)"), IconHandler, {"icons": icons}), + ], + ) # For backward compatibility diff --git a/jupyter_server_proxy/api.py b/jupyter_server_proxy/api.py index 1967653d..d183a53f 100644 --- a/jupyter_server_proxy/api.py +++ b/jupyter_server_proxy/api.py @@ -1,8 +1,9 @@ -from tornado import web import mimetypes + from jupyter_server.base.handlers import JupyterHandler from jupyter_server.utils import url_path_join as ujoin -from collections import namedtuple +from tornado import web + class ServersInfoHandler(JupyterHandler): def initialize(self, server_processes): @@ -16,21 +17,21 @@ async def get(self): for sp in self.server_processes: # Manually recurse to convert namedtuples into JSONable structures item = { - 'name': sp.name, - 'launcher_entry': { - 'enabled': sp.launcher_entry.enabled, - 'title': sp.launcher_entry.title, - 'path_info': sp.launcher_entry.path_info + "name": sp.name, + "launcher_entry": { + "enabled": sp.launcher_entry.enabled, + "title": sp.launcher_entry.title, + "path_info": sp.launcher_entry.path_info, }, - 'new_browser_tab' : sp.new_browser_tab + "new_browser_tab": sp.new_browser_tab, } if sp.launcher_entry.icon_path: - icon_url = ujoin(self.base_url, 'server-proxy', 'icon', sp.name) - item['launcher_entry']['icon_url'] = icon_url + icon_url = ujoin(self.base_url, "server-proxy", "icon", sp.name) + item["launcher_entry"]["icon_url"] = icon_url data.append(item) - self.write({'server_processes': data}) + self.write({"server_processes": data}) # FIXME: Should be a StaticFileHandler subclass @@ -38,6 +39,7 @@ class IconHandler(JupyterHandler): """ Serve launcher icons """ + def initialize(self, icons): """ icons is a dict of titles to paths @@ -67,4 +69,4 @@ async def get(self, name): with open(self.icons[name]) as f: self.write(f.read()) - self.set_header('Content-Type', content_type) + self.set_header("Content-Type", content_type) diff --git a/jupyter_server_proxy/config.py b/jupyter_server_proxy/config.py index f04be9ae..0cb37a78 100644 --- a/jupyter_server_proxy/config.py +++ b/jupyter_server_proxy/config.py @@ -1,17 +1,15 @@ """ Traitlets based configuration for jupyter_server_proxy """ +from collections import namedtuple +from warnings import warn + +import pkg_resources from jupyter_server.utils import url_path_join as ujoin from traitlets import Dict, List, Tuple, Union, default, observe from traitlets.config import Configurable -from tornado import httpclient -from warnings import warn -from .handlers import ( - NamedLocalProxyHandler, SuperviseAndProxyHandler, AddSlashHandler, -) -import pkg_resources -from collections import namedtuple -from .utils import call_with_asked_args + +from .handlers import AddSlashHandler, NamedLocalProxyHandler, SuperviseAndProxyHandler try: # Traitlets >= 4.3.3 @@ -20,11 +18,26 @@ from .utils import Callable -LauncherEntry = namedtuple('LauncherEntry', ['enabled', 'icon_path', 'title', 'path_info']) -ServerProcess = namedtuple('ServerProcess', [ - 'name', 'command', 'environment', 'timeout', 'absolute_url', 'port', 'unix_socket', - 'mappath', 'launcher_entry', 'new_browser_tab', 'request_headers_override', 'rewrite_response', -]) +LauncherEntry = namedtuple( + "LauncherEntry", ["enabled", "icon_path", "title", "path_info"] +) +ServerProcess = namedtuple( + "ServerProcess", + [ + "name", + "command", + "environment", + "timeout", + "absolute_url", + "port", + "unix_socket", + "mappath", + "launcher_entry", + "new_browser_tab", + "request_headers_override", + "rewrite_response", + ], +) def _make_namedproxy_handler(sp: ServerProcess): @@ -44,10 +57,12 @@ def get_request_headers_override(self): return _Proxy + def _make_supervisedproxy_handler(sp: ServerProcess): """ Create a SuperviseAndProxyHandler subclass with given parameters """ + # FIXME: Set 'name' properly class _Proxy(SuperviseAndProxyHandler): def __init__(self, *args, **kwargs): @@ -75,14 +90,13 @@ def get_timeout(self): def get_entrypoint_server_processes(serverproxy_config): sps = [] - for entry_point in pkg_resources.iter_entry_points('jupyter_serverproxy_servers'): + for entry_point in pkg_resources.iter_entry_points("jupyter_serverproxy_servers"): name = entry_point.name server_process_config = entry_point.load()() - sps.append( - make_server_process(name, server_process_config, serverproxy_config) - ) + sps.append(make_server_process(name, server_process_config, serverproxy_config)) return sps + def make_handlers(base_url, server_processes): """ Get tornado handlers for registered server_processes @@ -94,46 +108,53 @@ def make_handlers(base_url, server_processes): kwargs = dict(state={}) else: if not (sp.port or isinstance(sp.unix_socket, str)): - warn(f"Server proxy {sp.name} does not have a command, port " - f"number or unix_socket path. At least one of these is " - f"required.") + warn( + f"Server proxy {sp.name} does not have a command, port " + f"number or unix_socket path. At least one of these is " + f"required." + ) continue handler = _make_namedproxy_handler(sp) kwargs = {} - handlers.append(( - ujoin(base_url, sp.name, r'(.*)'), handler, kwargs, - )) - handlers.append(( - ujoin(base_url, sp.name), AddSlashHandler - )) + handlers.append( + ( + ujoin(base_url, sp.name, r"(.*)"), + handler, + kwargs, + ) + ) + handlers.append((ujoin(base_url, sp.name), AddSlashHandler)) return handlers def make_server_process(name, server_process_config, serverproxy_config): - le = server_process_config.get('launcher_entry', {}) + 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', {}), + 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 + "/") + enabled=le.get("enabled", True), + icon_path=le.get("icon_path"), + title=le.get("title", name), + path_info=le.get("path_info", name + "/"), + ), + new_browser_tab=server_process_config.get("new_browser_tab", True), + request_headers_override=server_process_config.get( + "request_headers_override", {} ), - 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', + "rewrite_response", tuple(), ), ) + class ServerProxy(Configurable): servers = Dict( {}, @@ -248,7 +269,7 @@ def cats_only(response, path): Defaults to the empty tuple ``tuple()``. """, - config=True + config=True, ) non_service_rewrite_response = Union( @@ -261,7 +282,7 @@ def cats_only(response, path): See the description for ``rewrite_response`` for more information. Defaults to the empty tuple ``tuple()``. """, - config=True + config=True, ) host_allowlist = Union( @@ -284,7 +305,7 @@ def host_allowlist(handler, host): Defaults to a list of ["localhost", "127.0.0.1"]. """, - config=True + config=True, ) @default("host_allowlist") @@ -294,7 +315,8 @@ def _host_allowlist_default(self): host_whitelist = Union( trait_types=[List(), Callable()], help="Deprecated, use host_allowlist", - config=True) + config=True, + ) @observe("host_whitelist") def _host_whitelist_deprecated(self, change): diff --git a/jupyter_server_proxy/etc/nbconfig/tree.d/jupyter-server-proxy.json b/jupyter_server_proxy/etc/nbconfig/tree.d/jupyter-server-proxy.json index 81a7886c..84a5617b 100644 --- a/jupyter_server_proxy/etc/nbconfig/tree.d/jupyter-server-proxy.json +++ b/jupyter_server_proxy/etc/nbconfig/tree.d/jupyter-server-proxy.json @@ -1,5 +1,5 @@ { "load_extensions": { - "jupyter_server_proxy/tree": true + "jupyter_server_proxy/tree": true } } diff --git a/jupyter_server_proxy/handlers.py b/jupyter_server_proxy/handlers.py index 258db435..a253e1b5 100644 --- a/jupyter_server_proxy/handlers.py +++ b/jupyter_server_proxy/handlers.py @@ -4,33 +4,32 @@ Some original inspiration from https://github.com/senko/tornado-proxy """ -import inspect -import socket import os -from urllib.parse import urlunparse, urlparse, quote -import aiohttp +import socket from asyncio import Lock from copy import copy from tempfile import mkdtemp +from urllib.parse import quote, urlparse, urlunparse -from tornado import gen, web, httpclient, httputil, process, websocket, ioloop, version_info -from tornado.simple_httpclient import SimpleAsyncHTTPClient - -from jupyter_server.utils import ensure_async, url_path_join +import aiohttp from jupyter_server.base.handlers import JupyterHandler, utcnow -from traitlets.traitlets import HasTraits +from jupyter_server.utils import ensure_async, url_path_join +from simpervisor import SupervisedProcess +from tornado import httpclient, httputil, web +from tornado.simple_httpclient import SimpleAsyncHTTPClient from traitlets import Bytes, Dict, Instance, Integer, Unicode, Union, default, observe +from traitlets.traitlets import HasTraits from .unixsock import UnixResolver from .utils import call_with_asked_args from .websocket import WebSocketHandlerMixin, pingable_ws_connect -from simpervisor import SupervisedProcess class RewritableResponse(HasTraits): """ A class to hold the response to be rewritten by rewrite_response """ + # The following should not be modified (or even accessed) by rewrite_response. # It is used to initialize the default values of the traits. orig_response = Instance(klass=httpclient.HTTPResponse) @@ -41,23 +40,23 @@ class RewritableResponse(HasTraits): code = Integer() reason = Unicode(allow_none=True) - @default('headers') + @default("headers") def _default_headers(self): return copy(self.orig_response.headers) - @default('body') + @default("body") def _default_body(self): return self.orig_response.body - @default('code') + @default("code") def _default_code(self): return self.orig_response.code - @default('reason') + @default("reason") def _default_reason(self): return self.orig_response.reason - @observe('code') + @observe("code") def _observe_code(self, change): # HTTP status codes are mapped to short descriptions in the # httputil.responses dictionary, 200 maps to "OK", 403 maps to @@ -67,8 +66,8 @@ def _observe_code(self, change): # description, we update reason to match the new code's short # description. # - if self.reason == httputil.responses.get(change['old'], 'Unknown'): - self.reason = httputil.responses.get(change['new'], 'Unknown') + if self.reason == httputil.responses.get(change["old"], "Unknown"): + self.reason = httputil.responses.get(change["new"], "Unknown") def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -87,12 +86,14 @@ def _apply_to_copy(self, func): class AddSlashHandler(JupyterHandler): """Add trailing slash to URLs that need them.""" + @web.authenticated def get(self, *args): src = urlparse(self.request.uri) - dest = src._replace(path=src.path + '/') + dest = src._replace(path=src.path + "/") self.redirect(urlunparse(dest)) + class ProxyHandler(WebSocketHandlerMixin, JupyterHandler): """ A tornado request handler that proxies HTTP and websockets from @@ -104,14 +105,15 @@ class ProxyHandler(WebSocketHandlerMixin, JupyterHandler): Subclasses should implement open, http_get, post, put, delete, head, patch, and options. """ + unix_socket = None # Used in subclasses def __init__(self, *args, **kwargs): - self.proxy_base = '' - self.absolute_url = kwargs.pop('absolute_url', False) - self.host_allowlist = kwargs.pop('host_allowlist', ['localhost', '127.0.0.1']) + self.proxy_base = "" + self.absolute_url = kwargs.pop("absolute_url", False) + self.host_allowlist = kwargs.pop("host_allowlist", ["localhost", "127.0.0.1"]) self.rewrite_response = kwargs.pop( - 'rewrite_response', + "rewrite_response", tuple(), ) self.subprotocols = None @@ -127,29 +129,35 @@ def check_origin(self, origin=None): # is passed to WebSocketHandlerMixin and then to WebSocketHandler. async def open(self, port, proxied_path): - raise NotImplementedError('Subclasses of ProxyHandler should implement open') + raise NotImplementedError("Subclasses of ProxyHandler should implement open") - async def http_get(self, host, port, proxy_path=''): - '''Our non-websocket GET.''' - raise NotImplementedError('Subclasses of ProxyHandler should implement http_get') + async def http_get(self, host, port, proxy_path=""): + """Our non-websocket GET.""" + raise NotImplementedError( + "Subclasses of ProxyHandler should implement http_get" + ) - def post(self, host, port, proxy_path=''): - raise NotImplementedError('Subclasses of ProxyHandler should implement this post') + def post(self, host, port, proxy_path=""): + raise NotImplementedError( + "Subclasses of ProxyHandler should implement this post" + ) - def put(self, port, proxy_path=''): - raise NotImplementedError('Subclasses of ProxyHandler should implement this put') + def put(self, port, proxy_path=""): + raise NotImplementedError( + "Subclasses of ProxyHandler should implement this put" + ) - def delete(self, host, port, proxy_path=''): - raise NotImplementedError('Subclasses of ProxyHandler should implement delete') + def delete(self, host, port, proxy_path=""): + raise NotImplementedError("Subclasses of ProxyHandler should implement delete") - def head(self, host, port, proxy_path=''): - raise NotImplementedError('Subclasses of ProxyHandler should implement head') + def head(self, host, port, proxy_path=""): + raise NotImplementedError("Subclasses of ProxyHandler should implement head") - def patch(self, host, port, proxy_path=''): - raise NotImplementedError('Subclasses of ProxyHandler should implement patch') + def patch(self, host, port, proxy_path=""): + raise NotImplementedError("Subclasses of ProxyHandler should implement patch") - def options(self, host, port, proxy_path=''): - raise NotImplementedError('Subclasses of ProxyHandler should implement options') + def options(self, host, port, proxy_path=""): + raise NotImplementedError("Subclasses of ProxyHandler should implement options") def on_message(self, message): """ @@ -158,7 +166,7 @@ def on_message(self, message): We proxy it to the backend. """ self._record_activity() - if hasattr(self, 'ws'): + if hasattr(self, "ws"): self.ws.write_message(message, binary=isinstance(message, bytes)) def on_ping(self, data): @@ -167,16 +175,16 @@ def on_ping(self, data): We proxy it to the backend. """ - self.log.debug('jupyter_server_proxy: on_ping: {}'.format(data)) + self.log.debug(f"jupyter_server_proxy: on_ping: {data}") self._record_activity() - if hasattr(self, 'ws'): + if hasattr(self, "ws"): self.ws.protocol.write_ping(data) def on_pong(self, data): """ Called when we receive a ping back. """ - self.log.debug('jupyter_server_proxy: on_pong: {}'.format(data)) + self.log.debug(f"jupyter_server_proxy: on_pong: {data}") def on_close(self): """ @@ -184,7 +192,7 @@ def on_close(self): We close our connection to the backend too. """ - if hasattr(self, 'ws'): + if hasattr(self, "ws"): self.ws.close() def _record_activity(self): @@ -193,7 +201,7 @@ def _record_activity(self): avoids proxied traffic being ignored by the notebook's internal idle-shutdown mechanism """ - self.settings['api_last_activity'] = utcnow() + self.settings["api_last_activity"] = utcnow() def _get_context_path(self, host, port): """ @@ -205,13 +213,13 @@ def _get_context_path(self, host, port): - {base_url}/proxy/absolute/{host}:{port} - {base_url}/{proxy_base} """ - host_and_port = str(port) if host == 'localhost' else host + ":" + str(port) + host_and_port = str(port) if host == "localhost" else host + ":" + str(port) if self.proxy_base: return url_path_join(self.base_url, self.proxy_base) if self.absolute_url: - return url_path_join(self.base_url, 'proxy', 'absolute', host_and_port) + return url_path_join(self.base_url, "proxy", "absolute", host_and_port) else: - return url_path_join(self.base_url, 'proxy', host_and_port) + return url_path_join(self.base_url, "proxy", host_and_port) def get_client_uri(self, protocol, host, port, proxied_path): if self.absolute_url: @@ -233,35 +241,38 @@ def get_client_uri(self, protocol, host, port, proxied_path): # ref: https://tools.ietf.org/html/rfc3986#section-2.2 client_path = quote(client_path, safe=":/?#[]@!$&'()*+,;=-._~") - client_uri = '{protocol}://{host}:{port}{path}'.format( + client_uri = "{protocol}://{host}:{port}{path}".format( protocol=protocol, host=host, port=port, path=client_path, ) if self.request.query: - client_uri += '?' + self.request.query + client_uri += "?" + self.request.query return client_uri def _build_proxy_request(self, host, port, proxied_path, body): - headers = self.proxy_request_headers() - client_uri = self.get_client_uri('http', host, port, proxied_path) + client_uri = self.get_client_uri("http", host, port, proxied_path) # Some applications check X-Forwarded-Context and X-ProxyContextPath # headers to see if and where they are being proxied from. if not self.absolute_url: context_path = self._get_context_path(host, port) - headers['X-Forwarded-Context'] = context_path - headers['X-ProxyContextPath'] = context_path + headers["X-Forwarded-Context"] = context_path + headers["X-ProxyContextPath"] = context_path # to be compatible with flask/werkzeug wsgi applications - headers['X-Forwarded-Prefix'] = context_path + headers["X-Forwarded-Prefix"] = context_path req = httpclient.HTTPRequest( - client_uri, method=self.request.method, body=body, + client_uri, + method=self.request.method, + body=body, decompress_response=False, - headers=headers, **self.proxy_request_options()) + headers=headers, + **self.proxy_request_options(), + ) return req def _check_host_allowlist(self, host): @@ -272,32 +283,36 @@ def _check_host_allowlist(self, host): @web.authenticated async def proxy(self, host, port, proxied_path): - ''' + """ This serverextension handles: {base_url}/proxy/{port([0-9]+)}/{proxied_path} {base_url}/proxy/absolute/{port([0-9]+)}/{proxied_path} {base_url}/{proxy_base}/{proxied_path} - ''' + """ if not self._check_host_allowlist(host): self.set_status(403) - self.write("Host '{host}' is not allowed. " - "See https://jupyter-server-proxy.readthedocs.io/en/latest/arbitrary-ports-hosts.html for info.".format(host=host)) + self.write( + "Host '{host}' is not allowed. " + "See https://jupyter-server-proxy.readthedocs.io/en/latest/arbitrary-ports-hosts.html for info.".format( + host=host + ) + ) return # Remove hop-by-hop headers that don't necessarily apply to the request we are making # to the backend. See https://github.com/jupyterhub/jupyter-server-proxy/pull/328 # for more information hop_by_hop_headers = [ - 'Proxy-Connection', - 'Keep-Alive', - 'Transfer-Encoding', - 'TE', - 'Connection', - 'Trailer', - 'Upgrade', - 'Proxy-Authorization', - 'Proxy-Authenticate' + "Proxy-Connection", + "Keep-Alive", + "Transfer-Encoding", + "TE", + "Connection", + "Trailer", + "Upgrade", + "Proxy-Authorization", + "Proxy-Authenticate", ] for header_to_remove in hop_by_hop_headers: if header_to_remove in self.request.headers: @@ -305,23 +320,25 @@ async def proxy(self, host, port, proxied_path): self._record_activity() - if self.request.headers.get("Upgrade", "").lower() == 'websocket': + if self.request.headers.get("Upgrade", "").lower() == "websocket": # We wanna websocket! # jupyterhub/jupyter-server-proxy@36b3214 - self.log.info("we wanna websocket, but we don't define WebSocketProxyHandler") + self.log.info( + "we wanna websocket, but we don't define WebSocketProxyHandler" + ) self.set_status(500) body = self.request.body if not body: - if self.request.method in {'POST', 'PUT'}: - body = b'' + if self.request.method in {"POST", "PUT"}: + body = b"" else: body = None if self.unix_socket is not None: # Port points to a Unix domain socket self.log.debug("Making client for Unix socket %r", self.unix_socket) - assert host == 'localhost', "Unix sockets only possible on localhost" + assert host == "localhost", "Unix sockets only possible on localhost" client = SimpleAsyncHTTPClient(resolver=UnixResolver(self.unix_socket)) else: client = httpclient.AsyncHTTPClient() @@ -369,11 +386,11 @@ async def proxy(self, host, port, proxied_path): # To be passed on-demand as args to the rewrite_response functions. optional_args_to_rewrite_function = { - 'request': self.request, - 'orig_response': original_response, - 'host': host, - 'port': port, - 'path': proxied_path + "request": self.request, + "orig_response": original_response, + "host": host, + "port": port, + "path": proxied_path, } # Initial value for rewriting @@ -389,10 +406,11 @@ def rewrite_pe(rewritable_response: RewritableResponse): return call_with_asked_args( rewrite, { - 'response': rewritable_response, - **optional_args_to_rewrite_function - } + "response": rewritable_response, + **optional_args_to_rewrite_function, + }, ) + # Now we can cleanly apply the partially evaulated function to a copy of # the rewritten response. rewritten_response = rewritten_response._apply_to_copy(rewrite_pe) @@ -403,15 +421,14 @@ def rewrite_pe(rewritable_response: RewritableResponse): # clear tornado default header self._headers = httputil.HTTPHeaders() for header, v in rewritten_response.headers.get_all(): - if header not in ('Content-Length', 'Transfer-Encoding', - 'Connection'): + if header not in ("Content-Length", "Transfer-Encoding", "Connection"): # some header appear multiple times, eg 'Set-Cookie' self.add_header(header, v) if rewritten_response.body: self.write(rewritten_response.body) - async def proxy_open(self, host, port, proxied_path=''): + async def proxy_open(self, host, port, proxied_path=""): """ Called when a client opens a websocket connection. @@ -421,22 +438,26 @@ async def proxy_open(self, host, port, proxied_path=''): if not self._check_host_allowlist(host): self.set_status(403) - self.log.info("Host '{host}' is not allowed. " - "See https://jupyter-server-proxy.readthedocs.io/en/latest/arbitrary-ports-hosts.html for info.".format(host=host)) + self.log.info( + "Host '{host}' is not allowed. " + "See https://jupyter-server-proxy.readthedocs.io/en/latest/arbitrary-ports-hosts.html for info.".format( + host=host + ) + ) self.close() return - if not proxied_path.startswith('/'): - proxied_path = '/' + proxied_path + if not proxied_path.startswith("/"): + proxied_path = "/" + proxied_path if self.unix_socket is not None: - assert host == 'localhost', "Unix sockets only possible on localhost" + assert host == "localhost", "Unix sockets only possible on localhost" self.log.debug("Opening websocket on Unix socket %r", port) resolver = UnixResolver(self.unix_socket) # Requires tornado >= 6.3 else: resolver = None - client_uri = self.get_client_uri('ws', host, port, proxied_path) + client_uri = self.get_client_uri("ws", host, port, proxied_path) headers = self.proxy_request_headers() def message_cb(message): @@ -463,14 +484,18 @@ def ping_cb(data): self.ping(data) async def start_websocket_connection(): - self.log.info('Trying to establish websocket connection to {}'.format(client_uri)) + self.log.info(f"Trying to establish websocket connection to {client_uri}") self._record_activity() request = httpclient.HTTPRequest(url=client_uri, headers=headers) - self.ws = await pingable_ws_connect(request=request, - on_message_callback=message_cb, on_ping_callback=ping_cb, - subprotocols=self.subprotocols, resolver=resolver) + self.ws = await pingable_ws_connect( + request=request, + on_message_callback=message_cb, + on_ping_callback=ping_cb, + subprotocols=self.subprotocols, + resolver=resolver, + ) self._record_activity() - self.log.info('Websocket connection established to {}'.format(client_uri)) + self.log.info(f"Websocket connection established to {client_uri}") # Wait for the WebSocket to be connected before resolving. # Otherwise, messages sent by the client before the @@ -478,35 +503,36 @@ async def start_websocket_connection(): await start_websocket_connection() def proxy_request_headers(self): - '''A dictionary of headers to be used when constructing - a tornado.httpclient.HTTPRequest instance for the proxy request.''' + """A dictionary of headers to be used when constructing + a tornado.httpclient.HTTPRequest instance for the proxy request.""" headers = self.request.headers.copy() # Merge any manually configured request headers headers.update(self.get_request_headers_override()) return headers def get_request_headers_override(self): - '''Add additional request headers. Typically overridden in subclasses.''' + """Add additional request headers. Typically overridden in subclasses.""" return {} def proxy_request_options(self): - '''A dictionary of options to be used when constructing - a tornado.httpclient.HTTPRequest instance for the proxy request.''' - return dict(follow_redirects=False, connect_timeout=250.0, request_timeout=300.0) + """A dictionary of options to be used when constructing + a tornado.httpclient.HTTPRequest instance for the proxy request.""" + return dict( + follow_redirects=False, connect_timeout=250.0, request_timeout=300.0 + ) def check_xsrf_cookie(self): - ''' + """ http://www.tornadoweb.org/en/stable/guide/security.html Defer to proxied apps. - ''' - pass + """ def select_subprotocol(self, subprotocols): - '''Select a single Sec-WebSocket-Protocol during handshake.''' + """Select a single Sec-WebSocket-Protocol during handshake.""" self.subprotocols = subprotocols if isinstance(subprotocols, list) and subprotocols: - self.log.debug('Client sent subprotocols: {}'.format(subprotocols)) + self.log.debug(f"Client sent subprotocols: {subprotocols}") return subprotocols[0] return super().select_subprotocol(subprotocols) @@ -521,11 +547,12 @@ class LocalProxyHandler(ProxyHandler): the URL as capture groups in the regex specified in the add_handlers method. """ + async def http_get(self, port, proxied_path): return await self.proxy(port, proxied_path) async def open(self, port, proxied_path): - return await self.proxy_open('localhost', port, proxied_path) + return await self.proxy_open("localhost", port, proxied_path) def post(self, port, proxied_path): return self.proxy(port, proxied_path) @@ -546,7 +573,8 @@ def options(self, port, proxied_path): return self.proxy(port, proxied_path) def proxy(self, port, proxied_path): - return super().proxy('localhost', port, proxied_path) + return super().proxy("localhost", port, proxied_path) + class RemoteProxyHandler(ProxyHandler): """ @@ -595,15 +623,16 @@ class NamedLocalProxyHandler(LocalProxyHandler): Config will create a subclass of this for each named proxy. A further subclass below is used for named proxies where we also start the server. """ + port = 0 mappath = {} @property def process_args(self): return { - 'port': self.port, - 'unix_socket': (self.unix_socket or ''), - 'base_url': self.base_url, + "port": self.port, + "unix_socket": (self.unix_socket or ""), + "base_url": self.base_url, } def _render_template(self, value): @@ -618,7 +647,7 @@ def _render_template(self, value): for k, v in value.items() } else: - raise ValueError('Value of unrecognized type {}'.format(type(value))) + raise ValueError(f"Value of unrecognized type {type(value)}") def _realize_rendered_template(self, attribute): """Call any callables, then render any templated values.""" @@ -630,11 +659,11 @@ def _realize_rendered_template(self, attribute): @web.authenticated async def proxy(self, port, path): - if not path.startswith('/'): - path = '/' + path + if not path.startswith("/"): + path = "/" + path if self.mappath: if callable(self.mappath): - path = call_with_asked_args(self.mappath, {'path': path}) + path = call_with_asked_args(self.mappath, {"path": path}) else: path = self.mappath.get(path, path) @@ -684,10 +713,10 @@ def __init__(self, *args, **kwargs): def initialize(self, state): self.state = state - if 'proc_lock' not in state: - state['proc_lock'] = Lock() + if "proc_lock" not in state: + state["proc_lock"] = Lock() - name = 'process' + name = "process" @property def port(self): @@ -698,29 +727,29 @@ def port(self): if self.requested_unix_socket: # unix_socket has priority over port return 0 - if 'port' not in self.state: + if "port" not in self.state: if self.requested_port: - self.state['port'] = self.requested_port + self.state["port"] = self.requested_port else: sock = socket.socket() - sock.bind(('', self.requested_port)) - self.state['port'] = sock.getsockname()[1] + sock.bind(("", self.requested_port)) + self.state["port"] = sock.getsockname()[1] sock.close() - return self.state['port'] + return self.state["port"] @property def unix_socket(self): - if 'unix_socket' not in self.state: + if "unix_socket" not in self.state: if self.requested_unix_socket is True: - sock_dir = mkdtemp(prefix='jupyter-server-proxy-') - sock_path = os.path.join(sock_dir, 'socket') + sock_dir = mkdtemp(prefix="jupyter-server-proxy-") + sock_path = os.path.join(sock_dir, "socket") elif self.requested_unix_socket: sock_path = self.requested_unix_socket else: sock_path = None - self.state['unix_socket'] = sock_path - return self.state['unix_socket'] + self.state["unix_socket"] = sock_path + return self.state["unix_socket"] def get_cmd(self): return self._realize_rendered_template(self.command) @@ -734,8 +763,8 @@ def get_cwd(self): return os.getcwd() def get_env(self): - '''Set up extra environment variables for process. Typically - overridden in subclasses.''' + """Set up extra environment variables for process. Typically + overridden in subclasses.""" return {} def get_timeout(self): @@ -746,20 +775,20 @@ def get_timeout(self): async def _http_ready_func(self, p): if self.unix_socket is not None: - url = 'http://localhost' + url = "http://localhost" connector = aiohttp.UnixConnector(self.unix_socket) else: - url = 'http://localhost:{}'.format(self.port) + url = f"http://localhost:{self.port}" connector = None # Default, TCP connector async with aiohttp.ClientSession(connector=connector) as session: try: async with session.get(url, allow_redirects=False) as resp: # We only care if we get back *any* response, not just 200 # If there's an error response, that can be shown directly to the user - self.log.debug('Got code {} back from {}'.format(resp.status, url)) + self.log.debug(f"Got code {resp.status} back from {url}") return True except aiohttp.ClientConnectionError: - self.log.debug('Connection to {} refused'.format(url)) + self.log.debug(f"Connection to {url} refused") return False async def ensure_process(self): @@ -770,8 +799,8 @@ async def ensure_process(self): # FIXME: Make sure this times out properly? # Invariant here should be: when lock isn't being held, either 'proc' is in state & # running, or not. - async with self.state['proc_lock']: - if 'proc' not in self.state: + async with self.state["proc_lock"]: + if "proc" not in self.state: # FIXME: Prevent races here # FIXME: Handle graceful exits of spawned processes here @@ -780,7 +809,7 @@ async def ensure_process(self): # won't await its readiness or similar either. cmd = self.get_cmd() if not cmd: - self.state['proc'] = "process not managed by jupyter-server-proxy" + self.state["proc"] = "process not managed by jupyter-server-proxy" return # Set up extra environment variables for process @@ -789,8 +818,15 @@ async def ensure_process(self): timeout = self.get_timeout() - proc = SupervisedProcess(self.name, *cmd, env=server_env, ready_func=self._http_ready_func, ready_timeout=timeout, log=self.log) - self.state['proc'] = proc + proc = SupervisedProcess( + self.name, + *cmd, + env=server_env, + ready_func=self._http_ready_func, + ready_timeout=timeout, + log=self.log, + ) + self.state["proc"] = proc try: await proc.start() @@ -799,10 +835,10 @@ async def ensure_process(self): if not is_ready: await proc.kill() - raise web.HTTPError(500, 'could not start {} in time'.format(self.name)) + raise web.HTTPError(500, f"could not start {self.name} in time") except: # Make sure we remove proc from state in any error condition - del self.state['proc'] + del self.state["proc"] raise @web.authenticated @@ -869,6 +905,3 @@ def setup_handlers(web_app, serverproxy_config): ), ], ) - - -# vim: set et ts=4 sw=4: diff --git a/jupyter_server_proxy/static/tree.js b/jupyter_server_proxy/static/tree.js index 14e780a6..41725aba 100644 --- a/jupyter_server_proxy/static/tree.js +++ b/jupyter_server_proxy/static/tree.js @@ -1,55 +1,55 @@ -define(['jquery', 'base/js/namespace', 'base/js/utils'], function($, Jupyter, utils) { - var $ = require('jquery'); - var Jupyter = require('base/js/namespace'); - var utils = require('base/js/utils'); - - var base_url = utils.get_body_data('baseUrl'); - - function load() { - if (!Jupyter.notebook_list) return; - - var servers_info_url = base_url + 'server-proxy/servers-info' ; - $.get(servers_info_url, function(data) { - /* locate the right-side dropdown menu of apps and notebooks */ - var $menu = $('.tree-buttons').find('.dropdown-menu'); - - /* create a divider */ - var $divider = $('
  • ') - .attr('role', 'presentation') - .addClass('divider'); - - - /* add the divider */ - $menu.append($divider); - - $.each(data.server_processes, function(_, server_process) { - if (!server_process.launcher_entry.enabled) { - return; - } - - /* create our list item */ - var $entry_container = $('
  • ') - .attr('role', 'presentation') - .addClass('new-' + server_process.name); - - /* create our list item's link */ - var $entry_link = $('') - .attr('role', 'menuitem') - .attr('tabindex', '-1') - .attr('href', base_url + server_process.launcher_entry.path_info) - .attr('target', '_blank') - .text(server_process.launcher_entry.title); - - /* add the link to the item and - * the item to the menu */ - $entry_container.append($entry_link); - $menu.append($entry_container); - - }); - }); - } - - return { - load_ipython_extension: load - }; +define(["jquery", "base/js/namespace", "base/js/utils"], function ( + $, + Jupyter, + utils, +) { + var $ = require("jquery"); + var Jupyter = require("base/js/namespace"); + var utils = require("base/js/utils"); + + var base_url = utils.get_body_data("baseUrl"); + + function load() { + if (!Jupyter.notebook_list) return; + + var servers_info_url = base_url + "server-proxy/servers-info"; + $.get(servers_info_url, function (data) { + /* locate the right-side dropdown menu of apps and notebooks */ + var $menu = $(".tree-buttons").find(".dropdown-menu"); + + /* create a divider */ + var $divider = $("
  • ").attr("role", "presentation").addClass("divider"); + + /* add the divider */ + $menu.append($divider); + + $.each(data.server_processes, function (_, server_process) { + if (!server_process.launcher_entry.enabled) { + return; + } + + /* create our list item */ + var $entry_container = $("
  • ") + .attr("role", "presentation") + .addClass("new-" + server_process.name); + + /* create our list item's link */ + var $entry_link = $("") + .attr("role", "menuitem") + .attr("tabindex", "-1") + .attr("href", base_url + server_process.launcher_entry.path_info) + .attr("target", "_blank") + .text(server_process.launcher_entry.title); + + /* add the link to the item and + * the item to the menu */ + $entry_container.append($entry_link); + $menu.append($entry_container); + }); + }); + } + + return { + load_ipython_extension: load, + }; }); diff --git a/jupyter_server_proxy/unixsock.py b/jupyter_server_proxy/unixsock.py index fe3bfcdb..1b798c64 100644 --- a/jupyter_server_proxy/unixsock.py +++ b/jupyter_server_proxy/unixsock.py @@ -1,4 +1,5 @@ import socket + from tornado.netutil import Resolver diff --git a/jupyter_server_proxy/utils.py b/jupyter_server_proxy/utils.py index eca7fd07..e3ea7bea 100644 --- a/jupyter_server_proxy/utils.py +++ b/jupyter_server_proxy/utils.py @@ -1,5 +1,6 @@ from traitlets import TraitType + def call_with_asked_args(callback, args): """ Call callback with only the args it wants from args @@ -13,9 +14,9 @@ def call_with_asked_args(callback, args): """ # FIXME: support default args # FIXME: support kwargs - # co_varnames contains both args and local variables, in order. + # co_varnames contains both args and local variables, in order. # We only pick the local variables - asked_arg_names = callback.__code__.co_varnames[:callback.__code__.co_argcount] + asked_arg_names = callback.__code__.co_varnames[: callback.__code__.co_argcount] asked_arg_values = [] missing_args = [] for asked_arg_name in asked_arg_names: @@ -25,13 +26,13 @@ def call_with_asked_args(callback, args): missing_args.append(asked_arg_name) if missing_args: raise TypeError( - '{}() missing required positional argument: {}'.format( - callback.__code__.co_name, - ', '.join(missing_args) + "{}() missing required positional argument: {}".format( + callback.__code__.co_name, ", ".join(missing_args) ) ) return callback(*asked_arg_values) + # copy-pasted from the ipython/traitlets source code, see # https://github.com/ipython/traitlets/blob/a1425327460c4a3ae970aeaef17e0c22da4c53c6/traitlets/traitlets.py#L3232-L3246 class Callable(TraitType): @@ -41,7 +42,7 @@ class Callable(TraitType): Classes are callable, as are instances with a __call__() method.""" - info_text = 'a callable' + info_text = "a callable" def validate(self, obj, value): if callable(value): diff --git a/jupyter_server_proxy/websocket.py b/jupyter_server_proxy/websocket.py index 4fed0c3f..8a9a9bd6 100644 --- a/jupyter_server_proxy/websocket.py +++ b/jupyter_server_proxy/websocket.py @@ -5,24 +5,18 @@ """ import inspect -import socket -import os -from urllib.parse import urlunparse, urlparse - -from tornado import gen, web, httpclient, httputil, process, websocket, ioloop, version_info - -from jupyter_server.utils import url_path_join -from jupyter_server.base.handlers import JupyterHandler, utcnow from jupyter_server.utils import ensure_async +from tornado import httpclient, httputil, ioloop, version_info, websocket class PingableWSClientConnection(websocket.WebSocketClientConnection): """A WebSocketClientConnection with an on_ping callback.""" + def __init__(self, **kwargs): - if 'on_ping_callback' in kwargs: - self._on_ping_callback = kwargs['on_ping_callback'] - del(kwargs['on_ping_callback']) + if "on_ping_callback" in kwargs: + self._on_ping_callback = kwargs["on_ping_callback"] + del kwargs["on_ping_callback"] super().__init__(**kwargs) def on_ping(self, data): @@ -30,8 +24,13 @@ def on_ping(self, data): self._on_ping_callback(data) -def pingable_ws_connect(request=None,on_message_callback=None, - on_ping_callback=None, subprotocols=None, resolver=None): +def pingable_ws_connect( + request=None, + on_message_callback=None, + on_ping_callback=None, + subprotocols=None, + resolver=None, +): """ A variation on websocket_connect that returns a PingableWSClientConnection with on_ping_callback. @@ -39,32 +38,37 @@ def pingable_ws_connect(request=None,on_message_callback=None, # Copy and convert the headers dict/object (see comments in # AsyncHTTPClient.fetch) request.headers = httputil.HTTPHeaders(request.headers) - request = httpclient._RequestProxy( - request, httpclient.HTTPRequest._DEFAULTS) + request = httpclient._RequestProxy(request, httpclient.HTTPRequest._DEFAULTS) # for tornado 4.5.x compatibility if version_info[0] == 4: - conn = PingableWSClientConnection(io_loop=ioloop.IOLoop.current(), + conn = PingableWSClientConnection( + io_loop=ioloop.IOLoop.current(), compression_options={}, request=request, on_message_callback=on_message_callback, - on_ping_callback=on_ping_callback) + on_ping_callback=on_ping_callback, + ) else: # resolver= parameter requires tornado >= 6.3. Only pass it if needed # (for Unix socket support), so older versions of tornado can still # work otherwise. - kwargs = {'resolver': resolver} if resolver else {} - conn = PingableWSClientConnection(request=request, + kwargs = {"resolver": resolver} if resolver else {} + conn = PingableWSClientConnection( + request=request, compression_options={}, on_message_callback=on_message_callback, on_ping_callback=on_ping_callback, - max_message_size=getattr(websocket, '_default_max_message_size', 10 * 1024 * 1024), + max_message_size=getattr( + websocket, "_default_max_message_size", 10 * 1024 * 1024 + ), subprotocols=subprotocols, - **kwargs + **kwargs, ) return conn.connect_future + # from https://stackoverflow.com/questions/38663666/how-can-i-serve-a-http-page-and-a-websocket-on-the-same-url-in-tornado class WebSocketHandlerMixin(websocket.WebSocketHandler): def __init__(self, *args, **kwargs): @@ -77,31 +81,33 @@ def __init__(self, *args, **kwargs): try: nextparent = bases[meindex + 1] except IndexError: - raise Exception("WebSocketHandlerMixin should be followed " - "by another parent to make sense") + raise Exception( + "WebSocketHandlerMixin should be followed " + "by another parent to make sense" + ) # undisallow methods --- t.ws.WebSocketHandler disallows methods, # we need to re-enable these methods def wrapper(method): def undisallow(*args2, **kwargs2): getattr(nextparent, method)(self, *args2, **kwargs2) + return undisallow - for method in ["write", "redirect", "set_header", "set_cookie", - "set_status", "flush", "finish"]: + for method in [ + "write", + "redirect", + "set_header", + "set_cookie", + "set_status", + "flush", + "finish", + ]: setattr(self, method, wrapper(method)) nextparent.__init__(self, *args, **kwargs) async def get(self, *args, **kwargs): - if self.request.headers.get("Upgrade", "").lower() != 'websocket': + if self.request.headers.get("Upgrade", "").lower() != "websocket": return await self.http_get(*args, **kwargs) else: await ensure_async(super().get(*args, **kwargs)) - - -def setup_handlers(web_app): - web_app.add_handlers('.*', [ - (url_path_join(web_app.settings['base_url'], r'/proxy/(\d+)(.*)'), LocalProxyHandler) - ]) - -# vim: set et ts=4 sw=4: diff --git a/labextension/src/index.ts b/labextension/src/index.ts index d13db3dd..d90cce9a 100644 --- a/labextension/src/index.ts +++ b/labextension/src/index.ts @@ -1,19 +1,34 @@ -import { JupyterFrontEnd, JupyterFrontEndPlugin, ILayoutRestorer } from '@jupyterlab/application'; -import { ILauncher } from '@jupyterlab/launcher'; -import { PageConfig } from '@jupyterlab/coreutils'; -import { IFrame, MainAreaWidget, WidgetTracker } from '@jupyterlab/apputils'; +import { + JupyterFrontEnd, + JupyterFrontEndPlugin, + ILayoutRestorer, +} from "@jupyterlab/application"; +import { ILauncher } from "@jupyterlab/launcher"; +import { PageConfig } from "@jupyterlab/coreutils"; +import { IFrame, MainAreaWidget, WidgetTracker } from "@jupyterlab/apputils"; -function newServerProxyWidget(id: string, url: string, text: string): MainAreaWidget