Compare commits

..

34 Commits

Author SHA1 Message Date
Min RK
b186bdbce3 Bump to 2.0.0rc5 2021-11-26 09:07:15 +01:00
Min RK
36fe6c6f66 Merge pull request #3692 from minrk/clrc5
changelog for 2.0.0rc5
2021-11-26 09:06:21 +01:00
Min RK
8bf559db52 changelog for 2.0.0rc5 2021-11-26 09:05:21 +01:00
Simon Li
750085f627 Merge pull request #3690 from minrk/gha-singleuser
build jupyterhub/singleuser along with other images
2021-11-25 20:17:12 +00:00
Min RK
2dc2c99b4a Merge pull request #3640 from minrk/doc-api-only
add api-only doc
2021-11-25 20:26:25 +01:00
pre-commit-ci[bot]
e703555888 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2021-11-25 19:16:41 +00:00
Min RK
7e102f0511 Apply suggestions from code review
Co-authored-by: Carol Willing <carolcode@willingconsulting.com>
2021-11-25 20:16:10 +01:00
Min RK
facde96425 build jupyterhub/singleuser along with other images
got lost in the migration to GHA docker builds
2021-11-24 21:15:59 +01:00
Erik Sundell
608c746a59 Merge pull request #3689 from jupyterhub/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2021-11-22 22:26:25 +01:00
pre-commit-ci[bot]
a8c834410f [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/asottile/pyupgrade: v2.29.0 → v2.29.1](https://github.com/asottile/pyupgrade/compare/v2.29.0...v2.29.1)
- [github.com/psf/black: 21.10b0 → 21.11b1](https://github.com/psf/black/compare/21.10b0...21.11b1)
2021-11-22 20:51:45 +00:00
Min RK
bda14b487a Bump to 2.0.0rc4 2021-11-18 15:33:12 +01:00
Min RK
fd5cf8c360 Merge pull request #3687 from minrk/rc4-changelog
update 2.0 changelog
2021-11-18 15:32:27 +01:00
Min RK
03758e5b46 update 2.0 changelog
prep for rc4
2021-11-18 14:50:10 +01:00
Erik Sundell
e540d143bb Merge pull request #3685 from minrk/session-id-model
Add Session id to token/identify models
2021-11-18 13:39:34 +01:00
Erik Sundell
b2c5ad40c5 Merge pull request #3686 from minrk/login_with_token
Hub: only accept tokens in API requests
2021-11-18 13:27:41 +01:00
Min RK
edfdf672d8 Hub: only accept tokens in API requests
do not allow token-based access to pages

Tokens are only accepted via Authorization header, which doesn't make sense to pass to pages,
so disallow it explicitly to avoid surprises
2021-11-18 09:36:49 +01:00
Min RK
39f19aef49 add session_id to token model 2021-11-17 09:46:26 +01:00
Min RK
8813bb63d4 update to openapi 3.0
easier to implement oneOf schemas

document scopes, session_id in /api/user model
2021-11-17 09:44:38 +01:00
Yuvi Panda
7c18d6fe14 Merge pull request #3681 from minrk/log-app-versions
Log single-user app versions at startup
2021-11-16 00:11:32 +05:30
Erik Sundell
d1fe17d3cb Merge pull request #3682 from minrk/relpath
always use relative paths in data_files
2021-11-08 14:06:20 +01:00
Min RK
b8965c2017 always use relative paths in data_files
instead of absolute paths for sources

seems to have started failing on Windows
2021-11-08 13:29:26 +01:00
Min RK
733d7bc158 Log single-user app versions at startup
Reports jupyterlab, jupyter_server versions during startup
2021-11-08 09:21:32 +01:00
Min RK
88f31c29bb add api-only doc
Describe how to use JupyterHub as a "behind the scenes" implementation detail,
rather than a user-facing application.
2021-11-04 17:16:59 +01:00
Min RK
3caf3cfda8 Bump to 2.0.0rc3 2021-11-04 15:52:37 +01:00
Erik Sundell
d076c55cca Merge pull request #3679 from minrk/forward-1.5
Forward-port fixes from 1.5.0 security release
2021-11-04 15:30:04 +01:00
Min RK
3e185022c8 changelog for 1.5.0 2021-11-04 15:04:40 +01:00
Min RK
857ee2885f jupyterlab: don't use $JUPYTERHUB_API_TOKEN in PageConfig.token 2021-11-04 15:03:12 +01:00
Min RK
cd8dd56213 Revert "store tokens passed via url or header, not only url."
This reverts commit 53c3201c17.

Only tokens in URLs should be persisted in cookies.
Tokens in headers should not have any effect on cookies.
2021-11-04 15:03:12 +01:00
Erik Sundell
f06902aa8f Merge pull request #3675 from jupyterhub/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2021-11-02 01:56:07 +01:00
pre-commit-ci[bot]
bb109c6f75 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/psf/black: 21.9b0 → 21.10b0](https://github.com/psf/black/compare/21.9b0...21.10b0)
2021-11-01 20:25:25 +00:00
Erik Sundell
e525ec7b5b Merge pull request #3674 from minrk/verify-login-role
verify that successful login assigns default role
2021-10-30 17:50:01 +02:00
Min RK
356b98e19f verify that successful login assigns default role
and that repeated login after revoked role doesn't reassign role
2021-10-30 14:30:33 +02:00
Erik Sundell
8c803e7a53 Merge pull request #3673 from minrk/main
more calculators
2021-10-30 14:21:17 +02:00
Min RK
2e21a6f4e0 more calculators 2021-10-30 14:07:04 +02:00
28 changed files with 1416 additions and 901 deletions

View File

@@ -150,7 +150,7 @@ jobs:
- name: Get list of jupyterhub-onbuild tags
id: onbuildtags
uses: jupyterhub/action-major-minor-tag-calculator@v1
uses: jupyterhub/action-major-minor-tag-calculator@v2
with:
githubToken: ${{ secrets.GITHUB_TOKEN }}
prefix: "${{ env.REGISTRY }}jupyterhub/jupyterhub-onbuild:"
@@ -171,7 +171,7 @@ jobs:
- name: Get list of jupyterhub-demo tags
id: demotags
uses: jupyterhub/action-major-minor-tag-calculator@v1
uses: jupyterhub/action-major-minor-tag-calculator@v2
with:
githubToken: ${{ secrets.GITHUB_TOKEN }}
prefix: "${{ env.REGISTRY }}jupyterhub/jupyterhub-demo:"
@@ -190,3 +190,23 @@ jobs:
platforms: linux/amd64
push: true
tags: ${{ join(fromJson(steps.demotags.outputs.tags)) }}
# jupyterhub/singleuser
- name: Get list of jupyterhub/singleuser tags
id: singleusertags
uses: jupyterhub/action-major-minor-tag-calculator@v2
with:
githubToken: ${{ secrets.GITHUB_TOKEN }}
prefix: "${{ env.REGISTRY }}jupyterhub/singleuser:"
defaultTag: "${{ env.REGISTRY }}jupyterhub/singleuser:noref"
branchRegex: ^\w[\w-.]*$
- name: Build and push jupyterhub/singleuser
uses: docker/build-push-action@e1b7f96249f2e4c8e4ac1519b9608c0d48944a1f # associated tag: v2.4.0
with:
build-args: |
JUPYTERHUB_VERSION=${{ github.ref_type == 'tag' && github.ref_name || format('git:{0}', github.sha) }}
context: singleuser
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ join(fromJson(steps.singleusertags.outputs.tags)) }}

View File

@@ -1,6 +1,6 @@
repos:
- repo: https://github.com/asottile/pyupgrade
rev: v2.29.0
rev: v2.29.1
hooks:
- id: pyupgrade
args:
@@ -10,7 +10,7 @@ repos:
hooks:
- id: reorder-python-imports
- repo: https://github.com/psf/black
rev: 21.9b0
rev: 21.11b1
hooks:
- id: black
- repo: https://github.com/pre-commit/mirrors-prettier

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

View File

@@ -114,7 +114,9 @@ class ScopeTableGenerator:
if doc_description:
description = doc_description
scope_dict[scope] = description
content['securityDefinitions']['oauth2']['scopes'] = scope_dict
content['components']['securitySchemes']['oauth2']['flows'][
'authorizationCode'
]['scopes'] = scope_dict
with open(filename, 'w') as f:
yaml.dump(content, f)

View File

@@ -0,0 +1,128 @@
(api-only)=
# Deploying JupyterHub in "API only mode"
As a service for deploying and managing Jupyter servers for users, JupyterHub
exposes this functionality _primarily_ via a [REST API](rest).
For convenience, JupyterHub also ships with a _basic_ web UI built using that REST API.
The basic web UI enables users to click a button to quickly start and stop their servers,
and it lets admins perform some basic user and server management tasks.
The REST API has always provided additional functionality beyond what is available in the basic web UI.
Similarly, we avoid implementing UI functionality that is also not available via the API.
With JupyterHub 2.0, the basic web UI will **always** be composed using the REST API.
In other words, no UI pages should rely on information not available via the REST API.
Previously, some admin UI functionality could only be achieved via admin pages,
such as paginated requests.
## Limited UI customization via templates
The JupyterHub UI is customizable via extensible HTML [templates](templates),
but this has some limited scope to what can be customized.
Adding some content and messages to existing pages is well supported,
but changing the page flow and what pages are available are beyond the scope of what is customizable.
## Rich UI customization with REST API based apps
Increasingly, JupyterHub is used purely as an API for managing Jupyter servers
for other Jupyter-based applications that might want to present a different user experience.
If you want a fully customized user experience,
you can now disable the Hub UI and use your own pages together with the JupyterHub REST API
to build your own web application to serve your users,
relying on the Hub only as an API for managing users and servers.
One example of such an application is [BinderHub][], which powers https://mybinder.org,
and motivates many of these changes.
BinderHub is distinct from a traditional JupyterHub deployment
because it uses temporary users created for each launch.
Instead of presenting a login page,
users are presented with a form to specify what environment they would like to launch:
![Binder launch form](../images/binderhub-form.png)
When a launch is requested:
1. an image is built, if necessary
2. a temporary user is created,
3. a server is launched for that user, and
4. when running, users are redirected to an already running server with an auth token in the URL
5. after the session is over, the user is deleted
This means that a lot of JupyterHub's UI flow doesn't make sense:
- there is no way for users to login
- the human user doesn't map onto a JupyterHub `User` in a meaningful way
- when a server isn't running, there isn't a 'restart your server' action available because the user has been deleted
- users do not have any access to any Hub functionality, so presenting pages for those features would be confusing
BinderHub is one of the motivating use cases for JupyterHub supporting being used _only_ via its API.
We'll use BinderHub here as an example of various configuration options.
[binderhub]: https://binderhub.readthedocs.io
## Disabling Hub UI
`c.JupyterHub.hub_routespec` is a configuration option to specify which URL prefix should be routed to the Hub.
The default is `/` which means that the Hub will receive all requests not already specified to be routed somewhere else.
There are three values that are most logical for `hub_routespec`:
- `/` - this is the default, and used in most deployments.
It is also the only option prior to JupyterHub 1.4.
- `/hub/` - this serves only Hub pages, both UI and API
- `/hub/api` - this serves _only the Hub API_, so all Hub UI is disabled,
aside from the OAuth confirmation page, if used.
If you choose a hub routespec other than `/`,
the main JupyterHub feature you will lose is the automatic handling of requests for `/user/:username`
when the requested server is not running.
JupyterHub's handling of this request shows this page,
telling you that the server is not running,
with a button to launch it again:
![screenshot of hub page for server not running](../images/server-not-running.png)
If you set `hub_routespec` to something other than `/`,
it is likely that you also want to register another destination for `/` to handle requests to not-running servers.
If you don't, you will see a default 404 page from the proxy:
![screenshot of CHP default 404](../images/chp-404.png)
For mybinder.org, the default "start my server" page doesn't make sense,
because when a server is gone, there is no restart action.
Instead, we provide hints about how to get back to a link to start a _new_ server:
![screenshot of mybinder.org 404](../images/binder-404.png)
To achieve this, mybinder.org registers a route for `/` that goes to a custom endpoint
that runs nginx and only serves this static HTML error page.
This is set with
```python
c.Proxy.extra_routes = {
"/": "http://custom-404-entpoint/",
}
```
You may want to use an alternate behavior, such as redirecting to a landing page,
or taking some other action based on the requested page.
If you use `c.JupyterHub.hub_routespec = "/hub/"`,
then all the Hub pages will be available,
and only this default-page-404 issue will come up.
If you use `c.JupyterHub.hub_routespec = "/hub/api/"`,
then only the Hub _API_ will be available,
and all UI will be up to you.
mybinder.org takes this last option,
because none of the Hub UI pages really make sense.
Binder users don't have any reason to know or care that JupyterHub happens
to be an implementation detail of how their environment is managed.
Seeing Hub error pages and messages in that situation is more likely to be confusing than helpful.
:::{versionadded} 1.4
`c.JupyterHub.hub_routespec` and `c.Proxy.extra_routes` are new in JupyterHub 1.4.
:::

View File

@@ -21,6 +21,7 @@ what happens under-the-hood when you deploy and configure your JupyterHub.
monitoring
database
templates
api-only
../events/index
config-user-env
config-examples

View File

@@ -5,7 +5,7 @@ Below is an interactive view of JupyterHub's OpenAPI specification.
<!-- client-rendered openapi UI copied from FastAPI -->
<link type="text/css" rel="stylesheet" href="https://cdn.jsdelivr.net/npm/swagger-ui-dist@3/swagger-ui.css">
<script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@3/swagger-ui-bundle.js"></script>
<script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@4.1/swagger-ui-bundle.js"></script>
<!-- `SwaggerUIBundle` is now available on the page -->
<!-- render the ui here -->

View File

@@ -1,3 +1,5 @@
(rest-api)=
# Using JupyterHub's REST API
This section will give you information on:

View File

@@ -2,7 +2,7 @@
# Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License.
# version_info updated by running `tbump`
version_info = (2, 0, 0, "rc2", "")
version_info = (2, 0, 0, "rc5", "")
# pep 440 version: no dot before beta/rc, but before .dev
# 0.1.0rc1

View File

@@ -31,6 +31,9 @@ class APIHandler(BaseHandler):
- methods for REST API models
"""
# accept token-based authentication for API requests
_accept_token_auth = True
@property
def content_security_policy(self):
return '; '.join([super().content_security_policy, "default-src 'none'"])
@@ -210,6 +213,7 @@ class APIHandler(BaseHandler):
'last_activity': isoformat(token.last_activity),
'expires_at': isoformat(token.expires_at),
'note': token.note,
'session_id': token.session_id,
'oauth_client': token.oauth_client.description
or token.oauth_client.identifier,
}

View File

@@ -58,6 +58,14 @@ class SelfAPIHandler(APIHandler):
model = get_model(user)
# add session_id associated with token
# added in 2.0
token = self.get_token()
if token:
model["session_id"] = token.session_id
else:
model["session_id"] = None
# add scopes to identify model,
# but not the scopes we added to ensure we could read our own model
model["scopes"] = sorted(self.expanded_scopes.difference(_added_scopes))

View File

@@ -71,6 +71,12 @@ SESSION_COOKIE_NAME = 'jupyterhub-session-id'
class BaseHandler(RequestHandler):
"""Base Handler class with access to common methods and properties."""
# by default, only accept cookie-based authentication
# The APIHandler base class enables token auth
# versionadded: 2.0
_accept_cookie_auth = True
_accept_token_auth = False
async def prepare(self):
"""Identify the user during the prepare stage of each request
@@ -340,6 +346,7 @@ class BaseHandler(RequestHandler):
auth_info['auth_state'] = await user.get_auth_state()
return await self.auth_to_user(auth_info, user)
@functools.lru_cache()
def get_token(self):
"""get token from authorization header"""
token = self.get_auth_token()
@@ -410,9 +417,11 @@ class BaseHandler(RequestHandler):
async def get_current_user(self):
"""get current username"""
if not hasattr(self, '_jupyterhub_user'):
user = None
try:
user = self.get_current_user_token()
if user is None:
if self._accept_token_auth:
user = self.get_current_user_token()
if user is None and self._accept_cookie_auth:
user = self.get_current_user_cookie()
if user and isinstance(user, User):
user = await self.refresh_auth(user)

View File

@@ -295,7 +295,7 @@ def get_scopes_for(orm_object):
)
if isinstance(orm_object, orm.APIToken):
app_log.warning(f"Authenticated with token {orm_object}")
app_log.debug(f"Authenticated with token {orm_object}")
owner = orm_object.user or orm_object.service
token_scopes = roles.expand_roles_to_scopes(orm_object)
if orm_object.client_id != "jupyterhub":

View File

@@ -1023,8 +1023,8 @@ class HubAuthenticated:
self._hub_auth_user_cache = None
raise
# store tokens passed via url or header in a cookie for future requests
url_token = self.hub_auth.get_token(self)
# store ?token=... tokens passed via url in a cookie for future requests
url_token = self.get_argument('token', '')
if (
user_model
and url_token

View File

@@ -18,6 +18,7 @@ import sys
import warnings
from datetime import datetime
from datetime import timezone
from importlib import import_module
from textwrap import dedent
from urllib.parse import urlparse
@@ -606,10 +607,34 @@ class SingleUserNotebookAppMixin(Configurable):
t = self.hub_activity_interval * (1 + 0.2 * (random.random() - 0.5))
await asyncio.sleep(t)
def _log_app_versions(self):
"""Log application versions at startup
Logs versions of jupyterhub and singleuser-server base versions (jupyterlab, jupyter_server, notebook)
"""
self.log.info(f"Starting jupyterhub single-user server version {__version__}")
# don't log these package versions
seen = {"jupyterhub", "traitlets", "jupyter_core", "builtins"}
for cls in self.__class__.mro():
module_name = cls.__module__.partition(".")[0]
if module_name not in seen:
seen.add(module_name)
try:
mod = import_module(module_name)
mod_version = getattr(mod, "__version__")
except Exception:
mod_version = ""
self.log.info(
f"Extending {cls.__module__}.{cls.__name__} from {module_name} {mod_version}"
)
def initialize(self, argv=None):
# disable trash by default
# this can be re-enabled by config
self.config.FileContentsManager.delete_to_trash = False
self._log_app_versions()
return super().initialize(argv)
def start(self):
@@ -715,6 +740,18 @@ class SingleUserNotebookAppMixin(Configurable):
orig_loader = env.loader
env.loader = ChoiceLoader([FunctionLoader(get_page), orig_loader])
def load_server_extensions(self):
# Loading LabApp sets $JUPYTERHUB_API_TOKEN on load, which is incorrect
r = super().load_server_extensions()
# clear the token in PageConfig at this step
# so that cookie auth is used
# FIXME: in the future,
# it would probably make sense to set page_config.token to the token
# from the current request.
if 'page_config_data' in self.web_app.settings:
self.web_app.settings['page_config_data']['token'] = ''
return r
def detect_base_package(App):
"""Detect the base package for an App class

View File

@@ -578,6 +578,41 @@ async def test_login_page(app, url, params, redirected_url, form_action):
assert action.endswith(form_action)
@pytest.mark.parametrize(
"url, token_in",
[
("/home", "url"),
("/home", "header"),
("/login", "url"),
("/login", "header"),
],
)
async def test_page_with_token(app, user, url, token_in):
cookies = await app.login_user(user.name)
token = user.new_api_token()
if token_in == "url":
url = url_concat(url, {"token": token})
headers = None
elif token_in == "header":
headers = {
"Authorization": f"token {token}",
}
# request a page with ?token= in URL shouldn't be allowed
r = await get_page(
url,
app,
headers=headers,
allow_redirects=False,
)
if "/hub/login" in r.url:
assert r.status_code == 200
else:
assert r.status_code == 302
assert r.headers["location"].partition("?")[0].endswith("/hub/login")
assert not r.cookies
async def test_login_fail(app):
name = 'wash'
base_url = public_url(app)

View File

@@ -1332,3 +1332,19 @@ async def test_token_keep_roles_on_restart():
for token in user.api_tokens:
hub.db.delete(token)
hub.db.commit()
async def test_login_default_role(app, username):
cookies = await app.login_user(username)
user = app.users[username]
# assert login new user gets 'user' role
assert [role.name for role in user.roles] == ["user"]
# clear roles, keep user
user.roles = []
app.db.commit()
# login *again*; user exists, shouldn't trigger change in roles
cookies = await app.login_user(username)
user = app.users[username]
assert user.roles == []

View File

@@ -11,7 +11,7 @@ target_version = [
github_url = "https://github.com/jupyterhub/jupyterhub"
[tool.tbump.version]
current = "2.0.0rc2"
current = "2.0.0rc5"
# Example of a semver regexp.
# Make sure this matches current_version before

View File

@@ -46,10 +46,9 @@ def get_data_files():
"""Get data files in share/jupyter"""
data_files = []
ntrim = len(here + os.path.sep)
for (d, dirs, filenames) in os.walk(share_jupyterhub):
data_files.append((d[ntrim:], [pjoin(d, f) for f in filenames]))
rel_d = os.path.relpath(d, here)
data_files.append((rel_d, [os.path.join(rel_d, f) for f in filenames]))
return data_files

View File

@@ -6,7 +6,6 @@ FROM $BASE_IMAGE
MAINTAINER Project Jupyter <jupyter@googlegroups.com>
ADD install_jupyterhub /tmp/install_jupyterhub
ARG JUPYTERHUB_VERSION=main
# install pinned jupyterhub and ensure jupyterlab is installed
RUN python3 /tmp/install_jupyterhub && \
python3 -m pip install jupyterlab
ARG JUPYTERHUB_VERSION=git:HEAD
# install pinned jupyterhub
RUN python3 /tmp/install_jupyterhub

View File

@@ -1,4 +0,0 @@
#!/bin/bash
set -ex
docker build --build-arg JUPYTERHUB_VERSION=$DOCKER_TAG -t $DOCKER_REPO:$DOCKER_TAG .

View File

@@ -1,21 +0,0 @@
#!/bin/bash
set -ex
function get_hub_version() {
rm -f hub_version
V=$1
docker run --rm -v $PWD:/version -u $(id -u) -i $DOCKER_REPO:$DOCKER_TAG sh -c 'jupyterhub --version > /version/hub_version'
hub_xyz=$(cat hub_version)
split=( ${hub_xyz//./ } )
hub_xy="${split[0]}.${split[1]}"
# add .dev on hub_xy so it's 1.0.dev
if [[ ! -z "${split[3]:-}" ]]; then
hub_xy="${hub_xy}.${split[3]}"
fi
}
# tag e.g. 0.9 with main
get_hub_version
docker tag $DOCKER_REPO:$DOCKER_TAG $DOCKER_REPO:$hub_xy
docker push $DOCKER_REPO:$hub_xy
docker tag $DOCKER_REPO:$DOCKER_TAG $DOCKER_REPO:$hub_xyz
docker push $DOCKER_REPO:$hub_xyz

View File

@@ -3,19 +3,22 @@ import os
from subprocess import check_call
import sys
V = os.environ['JUPYTERHUB_VERSION']
version = os.environ['JUPYTERHUB_VERSION']
pip_install = [
sys.executable, '-m', 'pip', 'install', '--no-cache', '--upgrade',
'--upgrade-strategy', 'only-if-needed',
sys.executable,
'-m',
'pip',
'install',
'--no-cache',
'--upgrade',
'--upgrade-strategy',
'only-if-needed',
]
if V in {'main', 'HEAD'}:
req = 'https://github.com/jupyterhub/jupyterhub/archive/HEAD.tar.gz'
if version.startswith("git:"):
ref = version.partition(":")[-1]
req = f"https://github.com/jupyterhub/jupyterhub/archive/{ref}.tar.gz"
else:
version_info = [ int(part) for part in V.split('.') ]
version_info[-1] += 1
upper_bound = '.'.join(map(str, version_info))
vs = '>=%s,<%s' % (V, upper_bound)
req = 'jupyterhub%s' % vs
req = f"jupyterhub=={version}"
check_call(pip_install + [req])