diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2c0e2919..6096b92a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -38,9 +38,9 @@ jobs: # Tests everything when JupyterHub works against a dedicated mysql or # postgresql server. # - # jupyter_server: + # nbclassic: # Tests everything when the user instances are started with - # jupyter_server instead of notebook. + # notebook instead of jupyter_server. # # ssl: # Tests everything using internal SSL connections instead of @@ -48,7 +48,7 @@ jobs: # # main_dependencies: # Tests everything when the we use the latest available dependencies - # from: ipytraitlets. + # from: traitlets. # # NOTE: Since only the value of these parameters are presented in the # GitHub UI when the workflow run, we avoid using true/false as @@ -56,6 +56,7 @@ jobs: include: - python: "3.6" oldest_dependencies: oldest_dependencies + nbclassic: nbclassic - python: "3.6" subdomain: subdomain - python: "3.7" @@ -65,7 +66,7 @@ jobs: - python: "3.8" db: postgres - python: "3.8" - jupyter_server: jupyter_server + nbclassic: nbclassic - python: "3.9" main_dependencies: main_dependencies @@ -130,9 +131,9 @@ jobs: if [ "${{ matrix.main_dependencies }}" != "" ]; then pip install git+https://github.com/ipython/traitlets#egg=traitlets --force fi - if [ "${{ matrix.jupyter_server }}" != "" ]; then - pip uninstall notebook --yes - pip install jupyter_server + if [ "${{ matrix.nbclassic }}" != "" ]; then + pip uninstall jupyter_server --yes + pip install notebook fi if [ "${{ matrix.db }}" == "mysql" ]; then pip install mysql-connector-python diff --git a/dev-requirements.txt b/dev-requirements.txt index e000750a..bf1c33cd 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -7,8 +7,8 @@ codecov coverage cryptography html5lib # needed for beautifulsoup +jupyterlab >=3 mock -notebook pre-commit pytest>=3.3 pytest-asyncio diff --git a/docs/source/reference/config-user-env.md b/docs/source/reference/config-user-env.md index 524cdc41..f1e9ef05 100644 --- a/docs/source/reference/config-user-env.md +++ b/docs/source/reference/config-user-env.md @@ -76,13 +76,26 @@ c.InteractiveShellApp.extensions.append("cython") ### Example: Enable a Jupyter notebook configuration setting for all users +:::{note} +These examples configure the Jupyter ServerApp, +which is used by JupyterLab, the default in JupyterHub 2.0. + +If you are using the classing Jupyter Notebook server, +the same things should work, +with the following substitutions: + +- Where you see `jupyter_server_config`, use `jupyter_notebook_config` +- Where you see `NotebookApp`, use `ServerApp` + +::: + To enable Jupyter notebook's internal idle-shutdown behavior (requires -notebook ≥ 5.4), set the following in the `/etc/jupyter/jupyter_notebook_config.py` +notebook ≥ 5.4), set the following in the `/etc/jupyter/jupyter_server_config.py` file: ```python # shutdown the server after no activity for an hour -c.NotebookApp.shutdown_no_activity_timeout = 60 * 60 +c.ServerApp.shutdown_no_activity_timeout = 60 * 60 # shutdown kernels after no activity for 20 minutes c.MappingKernelManager.cull_idle_timeout = 20 * 60 # check for idle kernels every two minutes @@ -112,8 +125,8 @@ Assuming I have a Python 2 and Python 3 environment that I want to make sure are available, I can install their specs system-wide (in /usr/local) with: ```bash -/path/to/python3 -m IPython kernel install --prefix=/usr/local -/path/to/python2 -m IPython kernel install --prefix=/usr/local +/path/to/python3 -m ipykernel install --prefix=/usr/local +/path/to/python2 -m ipykernel install --prefix=/usr/local ``` ## Multi-user hosts vs. Containers @@ -176,12 +189,38 @@ The number of named servers per user can be limited by setting c.JupyterHub.named_server_limit_per_user = 5 ``` -## Switching to Jupyter Server +## Switching back to classic notebook -[Jupyter Server](https://jupyter-server.readthedocs.io/en/latest/) is a new Tornado Server backend for Jupyter web applications (e.g. JupyterLab 3.0 uses this package as its default backend). +By default the single-user server launches JupyterLab, +which is based on [Jupyter Server][]. +This is the default server when running JupyterHub ≥ 2.0. +You can switch to using the legacy Jupyter Notebook server by setting the `JUPYTERHUB_SINGLEUSER_APP` environment variable +(in the single-user environment) to: -By default, the single-user notebook server uses the (old) `NotebookApp` from the [notebook](https://github.com/jupyter/notebook) package. You can switch to using Jupyter Server's `ServerApp` backend (this will likely become the default in future releases) by setting the `JUPYTERHUB_SINGLEUSER_APP` environment variable to: +```bash +export JUPYTERHUB_SINGLEUSER_APP='notebook.notebookapp.NotebookApp' +``` + +[jupyter server]: https://jupyter-server.readthedocs.io +[jupyter notebook]: https://jupyter-notebook.readthedocs.io + +:::{versionchanged} 2.0 +JupyterLab is now the default singleuser UI, if available, +which is based on the [Jupyter Server][], +no longer the legacy [Jupyter Notebook][] server. +JupyterHub prior to 2.0 launched the legacy notebook server (`jupyter notebook`), +and Jupyter server could be selected by specifying + +```python +# jupyterhub_config.py +c.Spawner.cmd = ["jupyter-labhub"] +``` + +or for an otherwise customized Jupyter Server app, +set the environment variable: ```bash export JUPYTERHUB_SINGLEUSER_APP='jupyter_server.serverapp.ServerApp' ``` + +::: diff --git a/jupyterhub/singleuser/app.py b/jupyterhub/singleuser/app.py index 3afe6fe1..c48ab845 100644 --- a/jupyterhub/singleuser/app.py +++ b/jupyterhub/singleuser/app.py @@ -1,7 +1,12 @@ """Make a single-user app based on the environment: - $JUPYTERHUB_SINGLEUSER_APP, the base Application class, to be wrapped in JupyterHub authentication. - default: notebook.notebookapp.NotebookApp + default: jupyter_server.serverapp.ServerApp + +.. versionchanged:: 2.0 + + Default app changed to launch `jupyter labhub`. + Use JUPYTERHUB_SINGLEUSER_APP=notebook.notebookapp.NotebookApp for the legacy 'classic' notebook server. """ import os @@ -9,12 +14,55 @@ from traitlets import import_item from .mixins import make_singleuser_app -JUPYTERHUB_SINGLEUSER_APP = ( - os.environ.get("JUPYTERHUB_SINGLEUSER_APP") or "notebook.notebookapp.NotebookApp" -) +JUPYTERHUB_SINGLEUSER_APP = os.environ.get("JUPYTERHUB_SINGLEUSER_APP") + + +if JUPYTERHUB_SINGLEUSER_APP: + App = import_item(JUPYTERHUB_SINGLEUSER_APP) +else: + App = None + _import_error = None + for JUPYTERHUB_SINGLEUSER_APP in ( + "jupyter_server.serverapp.ServerApp", + "notebook.notebookapp.NotebookApp", + ): + try: + App = import_item(JUPYTERHUB_SINGLEUSER_APP) + except ImportError as e: + continue + if _import_error is None: + _import_error = e + else: + break + if App is None: + raise _import_error -App = import_item(JUPYTERHUB_SINGLEUSER_APP) SingleUserNotebookApp = make_singleuser_app(App) -main = SingleUserNotebookApp.launch_instance + +def main(): + """Launch a jupyterhub single-user server""" + if not os.environ.get("JUPYTERHUB_SINGLEUSER_APP"): + # app not specified, launch jupyter-labhub by default, + # if jupyterlab is recent enough (3.1). + # This is a minimally extended ServerApp that does: + # 1. ensure lab extension is enabled, and + # 2. set default URL to `/lab` + import re + + _version_pat = re.compile(r"(\d+)\.(\d+)") + try: + import jupyterlab + from jupyterlab.labhubapp import SingleUserLabApp + + m = _version_pat.match(jupyterlab.__version__) + except Exception: + m = None + + if m is not None: + version_tuple = tuple(int(v) for v in m.groups()) + if version_tuple >= (3, 1): + return SingleUserLabApp.launch_instance() + + return SingleUserNotebookApp.launch_instance() diff --git a/jupyterhub/tests/test_singleuser.py b/jupyterhub/tests/test_singleuser.py index 7d6c860c..89dbb8f9 100644 --- a/jupyterhub/tests/test_singleuser.py +++ b/jupyterhub/tests/test_singleuser.py @@ -1,6 +1,10 @@ """Tests for jupyterhub.singleuser""" +import os import sys +from contextlib import contextmanager +from subprocess import CalledProcessError from subprocess import check_output +from unittest import mock from urllib.parse import urlparse import pytest @@ -14,6 +18,12 @@ from .utils import async_requests from .utils import AsyncSession +@contextmanager +def nullcontext(): + """Python 3.7+ contextlib.nullcontext, backport for 3.6""" + yield + + @pytest.mark.parametrize( "access_scopes, server_name, expect_success", [ @@ -171,3 +181,47 @@ def test_version(): [sys.executable, '-m', 'jupyterhub.singleuser', '--version'] ).decode('utf8', 'replace') assert jupyterhub.__version__ in out + + +@pytest.mark.parametrize( + "JUPYTERHUB_SINGLEUSER_APP", + [ + "", + "notebook.notebookapp.NotebookApp", + "jupyter_server.serverapp.ServerApp", + ], +) +def test_singleuser_app_class(JUPYTERHUB_SINGLEUSER_APP): + try: + import jupyter_server # noqa + except ImportError: + have_server = False + expect_error = "jupyter_server" in JUPYTERHUB_SINGLEUSER_APP + else: + have_server = True + expect_error = False + + if expect_error: + ctx = pytest.raises(CalledProcessError) + else: + ctx = nullcontext() + + with mock.patch.dict( + os.environ, + { + "JUPYTERHUB_SINGLEUSER_APP": JUPYTERHUB_SINGLEUSER_APP, + }, + ): + with ctx: + out = check_output( + [sys.executable, '-m', 'jupyterhub.singleuser', '--help-all'] + ).decode('utf8', 'replace') + if expect_error: + return + # use help-all output to check inheritance + if 'NotebookApp' in JUPYTERHUB_SINGLEUSER_APP or not have_server: + assert '--NotebookApp.' in out + assert '--ServerApp.' not in out + else: + assert '--ServerApp.' in out + assert '--NotebookApp.' not in out diff --git a/singleuser/Dockerfile b/singleuser/Dockerfile index 61a22cd1..8b060684 100644 --- a/singleuser/Dockerfile +++ b/singleuser/Dockerfile @@ -7,6 +7,6 @@ MAINTAINER Project Jupyter ADD install_jupyterhub /tmp/install_jupyterhub ARG JUPYTERHUB_VERSION=main -# install pinned jupyterhub and ensure notebook is installed +# install pinned jupyterhub and ensure jupyterlab is installed RUN python3 /tmp/install_jupyterhub && \ - python3 -m pip install notebook + python3 -m pip install jupyterlab