Compare commits

..

32 Commits

Author SHA1 Message Date
Min RK
bbc3870803 Bump to 5.0.0b2 2024-05-09 09:03:55 +02:00
Min RK
212d618978 Merge pull request #4811 from minrk/5b2
Update changelog for 5.0b2
2024-05-09 09:03:39 +02:00
Min RK
75673fc268 beta 2 2024-05-09 08:04:09 +02:00
Min RK
332a393083 Update changelog for 5.0b2 2024-05-08 20:15:57 +02:00
Simon Li
fa538cfc65 Merge pull request #4807 from minrk/jupyter-events
switch from jupyter-telemetry to jupyter-events
2024-05-08 11:31:11 +02:00
Min RK
29ae082399 Merge pull request #4808 from jupyterhub/pre-commit-ci-update-config
Update string formatting - from %s to f-strings
2024-05-07 15:05:59 +02:00
Min RK
463960edaf schemas are not published...YET 2024-05-07 11:40:45 +02:00
Min RK
d9c6e43508 remove unused version number from test events 2024-05-07 11:39:56 +02:00
Min RK
961d2fe878 allows_schemas config is not in jupyter-events 2024-05-07 11:38:24 +02:00
Min RK
5636472ebf apply ruff fixes for UP031 2024-05-07 11:33:59 +02:00
Erik Sundell
fc02f9e2e6 Merge pull request #4809 from consideRatio/pr/fix-internal-ref
docs: fix internal reference typo
2024-05-07 09:16:59 +02:00
Erik Sundell
fd21b2fe94 docs: fix internal reference typo 2024-05-07 09:10:28 +02:00
pre-commit-ci[bot]
6051dc9fa7 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.3.5 → v0.4.3](https://github.com/astral-sh/ruff-pre-commit/compare/v0.3.5...v0.4.3)
- [github.com/pre-commit/pre-commit-hooks: v4.5.0 → v4.6.0](https://github.com/pre-commit/pre-commit-hooks/compare/v4.5.0...v4.6.0)
2024-05-06 22:03:18 +00:00
Simon Li
4ee5ee4e02 Merge pull request #4806 from minrk/pam-grouplist
use os.getgrouplist to check group membership in allowed_groups
2024-05-04 17:25:19 +02:00
Min RK
745cad5058 ignore unpublished schema URLs 2024-05-03 12:32:40 +02:00
Min RK
335803d19f switch from jupyter-telemetry to jupyter-events
- id must be a URL
- change `record_event` to `emit`
2024-05-03 12:00:42 +02:00
Min RK
3924295650 use getgrouplist to check group membership in allowed_groups
gr_mem check is less reliable
2024-05-03 09:21:10 +02:00
Min RK
c135e109ab Merge pull request #4805 from minrk/user-redirect-domain
include domain in PrefixRedirectHandler
2024-05-03 09:02:28 +02:00
Min RK
7e098fa09f include domain in PrefixRedirectHandler
redirects user.domain/user/foo -> hub.domain/hub/user/foo when server is not running

ensures right cookies, etc. are available
2024-05-01 15:43:46 +02:00
Erik Sundell
49f88450d5 Merge pull request #4804 from minrk/doc-redirect_uri
document conditions for oauth_redirect_url more clearly
2024-04-30 17:57:23 +02:00
Min RK
de20379933 document conditions for oauth_redirect_url more clearly 2024-04-30 15:22:18 +02:00
Min RK
8d406c398b Merge pull request #4799 from lahwaacz/async_generator
Relax dependency on async_generator
2024-04-26 11:04:04 +02:00
Jakub Klinkovský
dbd3813a1c async_generator is needed only for python<3.10
- the asynccontextmanager object is available in the standard contextlib
  module since Pyhton 3.7
- the aclosing object is available in the standard contextlib module
  since Pyhton 3.10
- JupyterHub currently requires Python 3.8 or newer
2024-04-24 23:11:10 +02:00
Simon Li
df04596172 Merge pull request #4798 from minrk/use_public_url
add full_url, full_progress_url to server models
2024-04-24 19:24:13 +02:00
Min RK
12f96df4eb fix condition for adding public_url to full_url
check directly if it is just a path, instead of trying to check other config that means it ought to be
2024-04-24 16:18:37 +02:00
Min RK
aecb95cd26 add full_url, full_progress_url to server models
if public_url is defined
2024-04-24 14:38:00 +02:00
Min RK
5fecb71265 Merge pull request #4797 from minrk/raise-not-redirect-loop
403 instead of redirect for token-only HubAuth
2024-04-24 11:08:00 +02:00
Min RK
e0157ff5eb don't try to login redirect with token-only HubAuth class
login via redirect is an artifact of the old services cookie,
removed in 2.0
2024-04-24 09:43:36 +02:00
Min RK
5ae250506b service-whoami: don't advertise link that won't work
whoami-api is api-only, it shouldn't be in the services dropdown
2024-04-23 10:09:05 +02:00
Min RK
8d298922e5 Merge pull request #4796 from manics/fix-redoc
Fix rest API djlint auto-formatting
2024-04-23 09:38:57 +02:00
Simon Li
18707e24b3 Forcibly disable djlint-reformat-jinja for redoc.html
Possible bug, djlint doesn't respect `djlint off` comments
2024-04-22 20:10:48 +01:00
Simon Li
3580904e8a redoc.html: revert djlint changes (breaks handlebar template) 2024-04-22 20:08:46 +01:00
54 changed files with 395 additions and 240 deletions

View File

@@ -16,7 +16,7 @@ ci:
repos:
# autoformat and lint Python code
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.3.5
rev: v0.4.3
hooks:
- id: ruff
types_or:
@@ -42,13 +42,14 @@ repos:
- id: djlint-reformat-jinja
files: ".*templates/.*.html"
types_or: ["html"]
exclude: redoc.html
- id: djlint-jinja
files: ".*templates/.*.html"
types_or: ["html"]
# Autoformat and linting, misc. details
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.5.0
rev: v4.6.0
hooks:
- id: end-of-file-fixer
exclude: share/jupyterhub/static/js/admin-react.js

View File

@@ -7,7 +7,7 @@ info:
license:
name: BSD-3-Clause
identifier: BSD-3-Clause
version: 5.0.0b1
version: 5.0.0b2
servers:
- url: /hub/api
security:
@@ -1714,12 +1714,31 @@ components:
url:
type: string
description: |
The URL where the server can be accessed
The URL path where the server can be accessed
(typically /user/:name/:server.name/).
Will be a full URL if subdomains are configured.
progress_url:
type: string
description: |
The URL for an event-stream to retrieve events during a spawn.
The URL path for an event-stream to retrieve events during a spawn.
full_url:
type:
- string
- "null"
description: |
The full URL of the server (`https://hub.example.org/user/:name/:servername`).
`null` unless JupyterHub.public_url or subdomains are configured.
Added in 5.0.
full_progress_url:
type:
- string
- "null"
description: |
The full URL for the progress events (`https://hub.example.org/hub/api/users/:name/servers/:servername/progress`).
`null` unless JupyterHub.public_url is configured.
Added in 5.0.
started:
type: string
description: UTC timestamp when the server was last started.

View File

@@ -1,3 +1,4 @@
{# djlint: off #}
{%- extends "!layout.html" %}
{# not sure why, but theme CSS prevents scrolling within redoc content
# If this were fixed, we could keep the navbar and footer
@@ -8,9 +9,7 @@
{% endblock docs_navbar %}
{% block footer %}
{% endblock footer %}
{# djlint: off #}
{%- block body_tag -%}<body>{%- endblock body_tag %}
{# djlint: on #}
{%- block extrahead %}
{{ super() }}
<link href="{{ pathto('_static/redoc-fonts.css', 1) }}" rel="stylesheet" />
@@ -23,14 +22,11 @@
document.body.innerText = "Rendered API specification doesn't work with file: protocol. Use sphinx-autobuild to do local builds of the docs, served over HTTP."
} else {
Redoc.init(
"{{ pathto('_static/rest-api.yml', 1) }}", {
{
meta.redoc_options |
default ({})
}
},
"{{ pathto('_static/rest-api.yml', 1) }}",
{{ meta.redoc_options | default ({}) }},
document.getElementById("redoc-spec"),
);
}
</script>
{%- endblock content %}
{# djlint: on #}

View File

@@ -289,6 +289,7 @@ linkcheck_ignore = [
r"https://github.com/[^/]*$", # too many github usernames / searches in changelog
"https://github.com/jupyterhub/jupyterhub/pull/", # too many PRs in changelog
"https://github.com/jupyterhub/jupyterhub/compare/", # too many comparisons in changelog
"https://schema.jupyter.org/jupyterhub/.*", # schemas are not published yet
r"https?://(localhost|127.0.0.1).*", # ignore localhost references in auto-links
r"https://linux.die.net/.*", # linux.die.net seems to block requests from CI with 403 sometimes
# don't check links to unpublished advisories

File diff suppressed because one or more lines are too long

View File

@@ -1,28 +1,23 @@
# Event logging and telemetry
JupyterHub can be configured to record structured events from a running server using Jupyter's [Telemetry System]. The types of events that JupyterHub emits are defined by [JSON schemas] listed at the bottom of this page.
JupyterHub can be configured to record structured events from a running server using Jupyter's [Events System]. The types of events that JupyterHub emits are defined by [JSON schemas] listed at the bottom of this page.
## How to emit events
Event logging is handled by its `Eventlog` object. This leverages Python's standing [logging] library to emit, filter, and collect event data.
Event logging is handled by its `EventLogger` object. This leverages Python's standing [logging] library to emit, filter, and collect event data.
To begin recording events, you'll need to set two configurations:
To begin recording events, you'll need to set at least one configuration option:
> 1. `handlers`: tells the EventLog _where_ to route your events. This trait is a list of Python logging handlers that route events to the event log file.
> 2. `allows_schemas`: tells the EventLog _which_ events should be recorded. No events are emitted by default; all recorded events must be listed here.
> `EventLogger.handlers`: tells the EventLogger _where_ to route your events. This trait is a list of Python logging handlers that route events to e.g. an event log file.
Here's a basic example:
```
```python
import logging
c.EventLog.handlers = [
c.EventLogger.handlers = [
logging.FileHandler('event.log'),
]
c.EventLog.allowed_schemas = [
'hub.jupyter.org/server-action'
]
```
The output is a file, `"event.log"`, with events recorded as JSON data.
@@ -37,6 +32,15 @@ The output is a file, `"event.log"`, with events recorded as JSON data.
server-actions
```
:::{versionchanged} 5.0
JupyterHub 5.0 changes from the deprecated jupyter-telemetry to jupyter-events.
The main changes are:
- `EventLog` configuration is now called `EventLogger`
- The `hub.jupyter.org/server-action` schema is now called `https://schema.jupyter.org/jupyterhub/events/server-action`
:::
[json schemas]: https://json-schema.org/
[logging]: https://docs.python.org/3/library/logging.html
[telemetry system]: https://github.com/jupyter/telemetry
[events system]: https://jupyter-events.readthedocs.io

View File

@@ -514,7 +514,7 @@ For example, using flask:
:language: python
```
We recommend looking at the [`HubOAuth`][huboauth] class implementation for reference,
We recommend looking at the {class}`.HubOAuth` class implementation for reference,
and taking note of the following process:
1. retrieve the token from the request.

View File

@@ -7,5 +7,5 @@ import httpx
def get_client():
base_url = os.environ["JUPYTERHUB_API_URL"]
token = os.environ["JUPYTERHUB_API_TOKEN"]
headers = {"Authorization": "Bearer %s" % token}
headers = {"Authorization": f"Bearer {token}"}
return httpx.AsyncClient(base_url=base_url, headers=headers)

View File

@@ -38,7 +38,7 @@ def authenticated(f):
else:
# redirect to login url on failed auth
state = auth.generate_state(next_url=request.path)
response = make_response(redirect(auth.login_url + '&state=%s' % state))
response = make_response(redirect(auth.login_url + f'&state={state}'))
response.set_cookie(auth.state_cookie_name, state)
return response

View File

@@ -7,6 +7,7 @@ c.JupyterHub.services = [
'name': 'whoami-api',
'url': 'http://127.0.0.1:10101',
'command': [sys.executable, './whoami.py'],
'display': False,
},
{
'name': 'whoami-oauth',
@@ -36,3 +37,5 @@ c.JupyterHub.load_roles = [
c.JupyterHub.authenticator_class = 'dummy'
c.JupyterHub.spawner_class = 'simple'
c.JupyterHub.ip = '127.0.0.1' # let's just run on localhost while dummy auth is enabled
# default to home page, since we don't want to start servers for this demo
c.JupyterHub.default_url = "/hub/home"

View File

@@ -11,7 +11,7 @@ c = get_config() # noqa
class DemoFormSpawner(LocalProcessSpawner):
def _options_form_default(self):
default_env = "YOURNAME=%s\n" % self.user.name
default_env = f"YOURNAME={self.user.name}\n"
return f"""
<div class="form-group">
<label for="args">Extra notebook CLI arguments</label>

View File

@@ -3,7 +3,7 @@
# Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License.
# version_info updated by running `tbump`
version_info = (5, 0, 0, "b1", "")
version_info = (5, 0, 0, "b2", "")
# pep 440 version: no dot before beta/rc, but before .dev
# 0.1.0rc1
@@ -70,6 +70,4 @@ def _check_version(hub_version, singleuser_version, log):
singleuser_version,
)
else:
log.debug(
"jupyterhub and jupyterhub-singleuser both on version %s" % hub_version
)
log.debug(f"jupyterhub and jupyterhub-singleuser both on version {hub_version}")

View File

@@ -46,7 +46,7 @@ class TokenAPIHandler(APIHandler):
elif orm_token.service:
model = self.service_model(orm_token.service)
else:
self.log.warning("%s has no user or service. Deleting..." % orm_token)
self.log.warning(f"{orm_token} has no user or service. Deleting...")
self.db.delete(orm_token)
self.db.commit()
raise web.HTTPError(404)

View File

@@ -202,7 +202,22 @@ class APIHandler(BaseHandler):
'url': url_path_join(user.url, url_escape_path(spawner.name), '/'),
'user_options': spawner.user_options,
'progress_url': user.progress_url(spawner.name),
'full_url': None,
'full_progress_url': None,
}
# fill out full_url fields
public_url = self.settings.get("public_url")
if urlparse(model["url"]).netloc:
# if using subdomains, this is already a full URL
model["full_url"] = model["url"]
if public_url:
model["full_progress_url"] = urlunparse(
public_url._replace(path=model["progress_url"])
)
if not model["full_url"]:
# set if not defined already by subdomain
model["full_url"] = urlunparse(public_url._replace(path=model["url"]))
scope_filter = self.get_scope_filter('admin:server_state')
if scope_filter(spawner, kind='server'):
model['state'] = state
@@ -446,9 +461,9 @@ class APIHandler(BaseHandler):
name (str): name of the model, used in error messages
"""
if not isinstance(model, dict):
raise web.HTTPError(400, "Invalid JSON data: %r" % model)
raise web.HTTPError(400, f"Invalid JSON data: {model!r}")
if not set(model).issubset(set(model_types)):
raise web.HTTPError(400, "Invalid JSON keys: %r" % model)
raise web.HTTPError(400, f"Invalid JSON keys: {model!r}")
for key, value in model.items():
if not isinstance(value, model_types[key]):
raise web.HTTPError(

View File

@@ -19,7 +19,7 @@ class _GroupAPIHandler(APIHandler):
username = self.authenticator.normalize_username(username)
user = self.find_user(username)
if user is None:
raise web.HTTPError(400, "No such user: %s" % username)
raise web.HTTPError(400, f"No such user: {username}")
users.append(user.orm_user)
return users
@@ -87,7 +87,7 @@ class GroupListAPIHandler(_GroupAPIHandler):
for name in groupnames:
existing = orm.Group.find(self.db, name=name)
if existing is not None:
raise web.HTTPError(409, "Group %s already exists" % name)
raise web.HTTPError(409, f"Group {name} already exists")
usernames = model.get('users', [])
# check that users exist
@@ -124,7 +124,7 @@ class GroupAPIHandler(_GroupAPIHandler):
existing = orm.Group.find(self.db, name=group_name)
if existing is not None:
raise web.HTTPError(409, "Group %s already exists" % group_name)
raise web.HTTPError(409, f"Group {group_name} already exists")
usernames = model.get('users', [])
# check that users exist

View File

@@ -32,14 +32,14 @@ class ShutdownAPIHandler(APIHandler):
proxy = data['proxy']
if proxy not in {True, False}:
raise web.HTTPError(
400, "proxy must be true or false, got %r" % proxy
400, f"proxy must be true or false, got {proxy!r}"
)
app.cleanup_proxy = proxy
if 'servers' in data:
servers = data['servers']
if servers not in {True, False}:
raise web.HTTPError(
400, "servers must be true or false, got %r" % servers
400, f"servers must be true or false, got {servers!r}"
)
app.cleanup_servers = servers

View File

@@ -5,9 +5,14 @@
import asyncio
import inspect
import json
import sys
from datetime import timedelta, timezone
from async_generator import aclosing
if sys.version_info >= (3, 10):
from contextlib import aclosing
else:
from async_generator import aclosing
from dateutil.parser import parse as parse_date
from sqlalchemy import func, or_
from sqlalchemy.orm import joinedload, raiseload, selectinload # noqa
@@ -156,7 +161,7 @@ class UserListAPIHandler(APIHandler):
.having(func.count(orm.Server.id) == 0)
)
elif state_filter:
raise web.HTTPError(400, "Unrecognized state filter: %r" % state_filter)
raise web.HTTPError(400, f"Unrecognized state filter: {state_filter!r}")
# apply eager load options
query = query.options(
@@ -241,15 +246,15 @@ class UserListAPIHandler(APIHandler):
continue
user = self.find_user(name)
if user is not None:
self.log.warning("User %s already exists" % name)
self.log.warning(f"User {name} already exists")
else:
to_create.append(name)
if invalid_names:
if len(invalid_names) == 1:
msg = "Invalid username: %s" % invalid_names[0]
msg = f"Invalid username: {invalid_names[0]}"
else:
msg = "Invalid usernames: %s" % ', '.join(invalid_names)
msg = "Invalid usernames: {}".format(', '.join(invalid_names))
raise web.HTTPError(400, msg)
if not to_create:
@@ -265,7 +270,7 @@ class UserListAPIHandler(APIHandler):
try:
await maybe_future(self.authenticator.add_user(user))
except Exception as e:
self.log.error("Failed to create user: %s" % name, exc_info=True)
self.log.error(f"Failed to create user: {name}", exc_info=True)
self.users.delete(user)
raise web.HTTPError(400, f"Failed to create user {name}: {e}")
else:
@@ -302,7 +307,7 @@ class UserAPIHandler(APIHandler):
data = self.get_json_body()
user = self.find_user(user_name)
if user is not None:
raise web.HTTPError(409, "User %s already exists" % user_name)
raise web.HTTPError(409, f"User {user_name} already exists")
user = self.user_from_username(user_name)
if data:
@@ -315,10 +320,10 @@ class UserAPIHandler(APIHandler):
try:
await maybe_future(self.authenticator.add_user(user))
except Exception:
self.log.error("Failed to create user: %s" % user_name, exc_info=True)
self.log.error(f"Failed to create user: {user_name}", exc_info=True)
# remove from registry
self.users.delete(user)
raise web.HTTPError(400, "Failed to create user: %s" % user_name)
raise web.HTTPError(400, f"Failed to create user: {user_name}")
self.write(json.dumps(self.user_model(user)))
self.set_status(201)
@@ -333,15 +338,14 @@ class UserAPIHandler(APIHandler):
if user.spawner._stop_pending:
raise web.HTTPError(
400,
"%s's server is in the process of stopping, please wait." % user_name,
f"{user_name}'s server is in the process of stopping, please wait.",
)
if user.running:
await self.stop_single_user(user)
if user.spawner._stop_pending:
raise web.HTTPError(
400,
"%s's server is in the process of stopping, please wait."
% user_name,
f"{user_name}'s server is in the process of stopping, please wait.",
)
await maybe_future(self.authenticator.delete_user(user))
@@ -365,7 +369,9 @@ class UserAPIHandler(APIHandler):
if self.find_user(data['name']):
raise web.HTTPError(
400,
"User %s already exists, username must be unique" % data['name'],
"User {} already exists, username must be unique".format(
data['name']
),
)
for key, value in data.items():
if key == 'auth_state':
@@ -397,7 +403,7 @@ class UserTokenListAPIHandler(APIHandler):
"""Get tokens for a given user"""
user = self.find_user(user_name)
if not user:
raise web.HTTPError(404, "No such user: %s" % user_name)
raise web.HTTPError(404, f"No such user: {user_name}")
now = utcnow(with_tz=False)
api_tokens = []
@@ -619,7 +625,7 @@ class UserServerAPIHandler(APIHandler):
finally:
spawner._spawn_pending = False
if state is None:
raise web.HTTPError(400, "%s is already running" % spawner._log_name)
raise web.HTTPError(400, f"{spawner._log_name} is already running")
options = self.get_json_body()
await self.spawn_single_user(user, server_name, options=options)

View File

@@ -21,6 +21,7 @@ from datetime import datetime, timedelta, timezone
from functools import partial
from getpass import getuser
from operator import itemgetter
from pathlib import Path
from textwrap import dedent
from typing import Optional
from urllib.parse import unquote, urlparse, urlunparse
@@ -29,7 +30,7 @@ import tornado.httpserver
import tornado.options
from dateutil.parser import parse as parse_date
from jinja2 import ChoiceLoader, Environment, FileSystemLoader, PrefixLoader
from jupyter_telemetry.eventlog import EventLog
from jupyter_events.logger import EventLogger
from sqlalchemy.exc import OperationalError, SQLAlchemyError
from sqlalchemy.orm import joinedload
from tornado import gen, web
@@ -213,7 +214,7 @@ class NewToken(Application):
ThreadPoolExecutor(1).submit(init_roles_and_users).result()
user = orm.User.find(hub.db, self.name)
if user is None:
print("No such user: %s" % self.name, file=sys.stderr)
print(f"No such user: {self.name}", file=sys.stderr)
self.exit(1)
token = user.new_api_token(note="command-line generated")
print(token)
@@ -1475,7 +1476,7 @@ class JupyterHub(Application):
new = change['new']
if '://' not in new:
# assume sqlite, if given as a plain filename
self.db_url = 'sqlite:///%s' % new
self.db_url = f'sqlite:///{new}'
db_kwargs = Dict(
help="""Include any kwargs to pass to the database connection.
@@ -1778,10 +1779,10 @@ class JupyterHub(Application):
[
# add trailing / to ``/user|services/:name`
(
r"%s(user|services)/([^/]+)" % self.base_url,
rf"{self.base_url}(user|services)/([^/]+)",
handlers.AddSlashHandler,
),
(r"(?!%s).*" % self.hub_prefix, handlers.PrefixRedirectHandler),
(rf"(?!{self.hub_prefix}).*", handlers.PrefixRedirectHandler),
(r'(.*)', handlers.Template404),
]
)
@@ -1910,7 +1911,7 @@ class JupyterHub(Application):
default_alt_names = ["IP:127.0.0.1", "DNS:localhost"]
if self.subdomain_host:
default_alt_names.append(
"DNS:%s" % urlparse(self.subdomain_host).hostname
f"DNS:{urlparse(self.subdomain_host).hostname}"
)
# The signed certs used by hub-internal components
try:
@@ -2095,7 +2096,7 @@ class JupyterHub(Application):
ck.check_available()
except Exception as e:
self.exit(
"auth_state is enabled, but encryption is not available: %s" % e
f"auth_state is enabled, but encryption is not available: {e}"
)
# give the authenticator a chance to check its own config
@@ -2114,7 +2115,7 @@ class JupyterHub(Application):
self.authenticator.admin_users = set(admin_users) # force normalization
for username in admin_users:
if not self.authenticator.validate_username(username):
raise ValueError("username %r is not valid" % username)
raise ValueError(f"username {username!r} is not valid")
new_users = []
@@ -2138,7 +2139,7 @@ class JupyterHub(Application):
self.authenticator.allowed_users = set(allowed_users) # force normalization
for username in allowed_users:
if not self.authenticator.validate_username(username):
raise ValueError("username %r is not valid" % username)
raise ValueError(f"username {username!r} is not valid")
if self.authenticator.allowed_users and self.authenticator.admin_users:
# make sure admin users are in the allowed_users set, if defined,
@@ -2206,7 +2207,7 @@ class JupyterHub(Application):
user = orm.User.find(self.db, name=username)
if user is None:
if not self.authenticator.validate_username(username):
raise ValueError("Username %r is not valid" % username)
raise ValueError(f"Username {username!r} is not valid")
self.log.info(f"Creating user {username} found in {hint}")
user = orm.User(name=username)
self.db.add(user)
@@ -2317,9 +2318,7 @@ class JupyterHub(Application):
old_role = orm.Role.find(self.db, name=role_name)
if old_role:
if not set(role_spec.get('scopes', [])).issubset(old_role.scopes):
self.log.warning(
"Role %s has obtained extra permissions" % role_name
)
self.log.warning(f"Role {role_name} has obtained extra permissions")
roles_with_new_permissions.append(role_name)
# make sure we load any default roles not overridden
@@ -2583,14 +2582,14 @@ class JupyterHub(Application):
elif kind == 'service':
Class = orm.Service
else:
raise ValueError("kind must be user or service, not %r" % kind)
raise ValueError(f"kind must be user or service, not {kind!r}")
db = self.db
for token, name in token_dict.items():
if kind == 'user':
name = self.authenticator.normalize_username(name)
if not self.authenticator.validate_username(name):
raise ValueError("Token user name %r is not valid" % name)
raise ValueError(f"Token user name {name!r} is not valid")
if kind == 'service':
if not any(service_name == name for service_name in self._service_map):
self.log.warning(
@@ -2790,7 +2789,7 @@ class JupyterHub(Application):
for key, value in spec.items():
trait = traits.get(key)
if trait is None:
raise AttributeError("No such service field: %s" % key)
raise AttributeError(f"No such service field: {key}")
setattr(service, key, value)
# also set the value on the orm object
# unless it's marked as not in the db
@@ -2863,7 +2862,7 @@ class JupyterHub(Application):
client_id=service.oauth_client_id,
client_secret=service.api_token,
redirect_uri=service.oauth_redirect_uri,
description="JupyterHub service %s" % service.name,
description=f"JupyterHub service {service.name}",
)
service.orm.oauth_client = oauth_client
# add access-scopes, derived from OAuthClient itself
@@ -3251,13 +3250,10 @@ class JupyterHub(Application):
def init_eventlog(self):
"""Set up the event logging system."""
self.eventlog = EventLog(parent=self)
self.eventlog = EventLogger(parent=self)
for dirname, _, files in os.walk(os.path.join(here, 'event-schemas')):
for file in files:
if not file.endswith('.yaml'):
continue
self.eventlog.register_schema_file(os.path.join(dirname, file))
for schema in (Path(here) / "event-schemas").glob("**/*.yaml"):
self.eventlog.register_event_schema(schema)
def write_pid_file(self):
pid = os.getpid()
@@ -3456,7 +3452,7 @@ class JupyterHub(Application):
answer = ''
def ask():
prompt = "Overwrite %s with default config? [y/N]" % self.config_file
prompt = f"Overwrite {self.config_file} with default config? [y/N]"
try:
return input(prompt).lower() or 'n'
except KeyboardInterrupt:
@@ -3473,7 +3469,7 @@ class JupyterHub(Application):
config_text = self.generate_config_file()
if isinstance(config_text, bytes):
config_text = config_text.decode('utf8')
print("Writing default config to: %s" % self.config_file)
print(f"Writing default config to: {self.config_file}")
with open(self.config_file, mode='w') as f:
f.write(config_text)

View File

@@ -332,7 +332,7 @@ class Authenticator(LoggingConfigurable):
if short_names:
sorted_names = sorted(short_names)
single = ''.join(sorted_names)
string_set_typo = "set('%s')" % single
string_set_typo = f"set('{single}')"
self.log.warning(
"Allowed set contains single-character names: %s; did you mean set([%r]) instead of %s?",
sorted_names[:8],
@@ -663,7 +663,7 @@ class Authenticator(LoggingConfigurable):
return
if isinstance(authenticated, dict):
if 'name' not in authenticated:
raise ValueError("user missing a name: %r" % authenticated)
raise ValueError(f"user missing a name: {authenticated!r}")
else:
authenticated = {'name': authenticated}
authenticated.setdefault('auth_state', None)
@@ -850,7 +850,7 @@ class Authenticator(LoggingConfigurable):
user (User): The User wrapper object
"""
if not self.validate_username(user.name):
raise ValueError("Invalid username: %s" % user.name)
raise ValueError(f"Invalid username: {user.name}")
if self.allow_existing_users and not self.allow_all:
self.allowed_users.add(user.name)
@@ -1115,13 +1115,16 @@ class LocalAuthenticator(Authenticator):
"""
if not self.allowed_groups:
return False
user_group_gids = set(
self._getgrouplist(username, self._getpwnam(username).pw_gid)
)
for grnam in self.allowed_groups:
try:
group = self._getgrnam(grnam)
except KeyError:
self.log.error('No such group: [%s]' % grnam)
self.log.error(f'No such group: [{grnam}]')
continue
if username in group.gr_mem:
if group.gr_gid in user_group_gids:
return True
return False
@@ -1190,7 +1193,7 @@ class LocalAuthenticator(Authenticator):
uid = self.uids[name]
cmd += ['--uid', '%d' % uid]
except KeyError:
self.log.debug("No UID for user %s" % name)
self.log.debug(f"No UID for user {name}")
cmd += [name]
self.log.info("Creating user: %s", ' '.join(map(shlex.quote, cmd)))
p = Popen(cmd, stdout=PIPE, stderr=STDOUT)

View File

@@ -33,7 +33,7 @@ class CryptographyUnavailable(EncryptionUnavailable):
class NoEncryptionKeys(EncryptionUnavailable):
def __str__(self):
return "Encryption keys must be specified in %s env" % KEY_ENV
return f"Encryption keys must be specified in {KEY_ENV} env"
def _validate_key(key):

View File

@@ -95,7 +95,7 @@ def backup_db_file(db_file, log=None):
backup_db_file = f'{db_file}.{timestamp}.{i}'
#
if os.path.exists(backup_db_file):
raise OSError("backup db file already exists: %s" % backup_db_file)
raise OSError(f"backup db file already exists: {backup_db_file}")
if log:
log.info("Backing up %s => %s", db_file, backup_db_file)
shutil.copy(db_file, backup_db_file)
@@ -167,7 +167,7 @@ def main(args=None):
# to subcommands
choices = ['shell', 'alembic']
if not args or args[0] not in choices:
print("Select a command from: %s" % ', '.join(choices))
print("Select a command from: {}".format(', '.join(choices)))
return 1
cmd, args = args[0], args[1:]

View File

@@ -1,4 +1,4 @@
"$id": hub.jupyter.org/server-action
"$id": https://schema.jupyter.org/jupyterhub/events/server-action
version: 1
title: JupyterHub server events
description: |

View File

@@ -1128,10 +1128,13 @@ class BaseHandler(RequestHandler):
SERVER_SPAWN_DURATION_SECONDS.labels(
status=ServerSpawnStatus.success
).observe(time.perf_counter() - spawn_start_time)
self.eventlog.record_event(
'hub.jupyter.org/server-action',
1,
{'action': 'start', 'username': user.name, 'servername': server_name},
self.eventlog.emit(
schema_id='https://schema.jupyter.org/jupyterhub/events/server-action',
data={
'action': 'start',
'username': user.name,
'servername': server_name,
},
)
proxy_add_start_time = time.perf_counter()
spawner._proxy_pending = True
@@ -1334,10 +1337,9 @@ class BaseHandler(RequestHandler):
SERVER_STOP_DURATION_SECONDS.labels(
status=ServerStopStatus.success
).observe(toc - tic)
self.eventlog.record_event(
'hub.jupyter.org/server-action',
1,
{
self.eventlog.emit(
schema_id='https://schema.jupyter.org/jupyterhub/events/server-action',
data={
'action': 'stop',
'username': user.name,
'servername': server_name,
@@ -1512,7 +1514,7 @@ class BaseHandler(RequestHandler):
# so we run it sync here, instead of making a sync version of render_template
try:
html = self.render_template('%s.html' % status_code, sync=True, **ns)
html = self.render_template(f'{status_code}.html', sync=True, **ns)
except TemplateNotFound:
self.log.debug("Using default error template for %d", status_code)
try:
@@ -1537,6 +1539,16 @@ class PrefixRedirectHandler(BaseHandler):
"""Redirect anything outside a prefix inside.
Redirects /foo to /prefix/foo, etc.
Redirect specifies hub domain when public_url or subdomains are enabled.
Mainly handles requests for non-running servers, e.g. to
/user/tree/ -> /hub/user/tree/
UserUrlHandler will handle the request after redirect.
Don't do anything but redirect here because cookies, etc. won't be available to this request,
due to not being on the hub's path or possibly domain.
"""
def get(self):
@@ -1554,7 +1566,19 @@ class PrefixRedirectHandler(BaseHandler):
# default / -> /hub/ redirect
# avoiding extra hop through /hub
path = '/'
self.redirect(url_path_join(self.hub.base_url, path), permanent=False)
redirect_url = redirect_path = url_path_join(self.hub.base_url, path)
# when using subdomains,
# make sure we redirect `user.domain/user/foo` -> `hub.domain/hub/user/foo/...`
# so that the Hub handles it properly with cookies and all
public_url = self.settings.get("public_url")
subdomain_host = self.settings.get("subdomain_host")
if public_url:
redirect_url = urlunparse(public_url._replace(path=redirect_path))
elif subdomain_host:
redirect_url = url_path_join(subdomain_host, redirect_path)
self.redirect(redirect_url, permanent=False)
class UserUrlHandler(BaseHandler):

View File

@@ -236,12 +236,12 @@ class SpawnHandler(BaseHandler):
if for_user != user.name:
user = self.find_user(for_user)
if user is None:
raise web.HTTPError(404, "No such user: %s" % for_user)
raise web.HTTPError(404, f"No such user: {for_user}")
spawner = user.get_spawner(server_name, replace_failed=True)
if spawner.ready:
raise web.HTTPError(400, "%s is already running" % (spawner._log_name))
raise web.HTTPError(400, f"{spawner._log_name} is already running")
elif spawner.pending:
raise web.HTTPError(
400, f"{spawner._log_name} is pending {spawner.pending}"
@@ -251,7 +251,7 @@ class SpawnHandler(BaseHandler):
for key, byte_list in self.request.body_arguments.items():
form_options[key] = [bs.decode('utf8') for bs in byte_list]
for key, byte_list in self.request.files.items():
form_options["%s_file" % key] = byte_list
form_options[f"{key}_file"] = byte_list
try:
self.log.debug(
"Triggering spawn with supplied form options for %s", spawner._log_name
@@ -345,7 +345,7 @@ class SpawnPendingHandler(BaseHandler):
if for_user != current_user.name:
user = self.find_user(for_user)
if user is None:
raise web.HTTPError(404, "No such user: %s" % for_user)
raise web.HTTPError(404, f"No such user: {for_user}")
if server_name and server_name not in user.spawners:
raise web.HTTPError(404, f"{user.name} has no such server {server_name}")
@@ -642,7 +642,7 @@ class ProxyErrorHandler(BaseHandler):
message_html = ' '.join(
[
"Your server appears to be down.",
"Try restarting it <a href='%s'>from the hub</a>" % hub_home,
f"Try restarting it <a href='{hub_home}'>from the hub</a>",
]
)
ns = dict(
@@ -655,7 +655,7 @@ class ProxyErrorHandler(BaseHandler):
self.set_header('Content-Type', 'text/html')
# render the template
try:
html = await self.render_template('%s.html' % status_code, **ns)
html = await self.render_template(f'{status_code}.html', **ns)
except TemplateNotFound:
self.log.debug("Using default error template for %d", status_code)
html = await self.render_template('error.html', **ns)

View File

@@ -156,7 +156,7 @@ class JupyterHubRequestValidator(RequestValidator):
self.db.query(orm.OAuthClient).filter_by(identifier=client_id).first()
)
if orm_client is None:
raise ValueError("No such client: %s" % client_id)
raise ValueError(f"No such client: {client_id}")
scopes = set(orm_client.allowed_scopes)
if 'inherit' not in scopes:
# add identify-user scope
@@ -255,7 +255,7 @@ class JupyterHubRequestValidator(RequestValidator):
self.db.query(orm.OAuthClient).filter_by(identifier=client_id).first()
)
if orm_client is None:
raise ValueError("No such client: %s" % client_id)
raise ValueError(f"No such client: {client_id}")
orm_code = orm.OAuthCode(
code=code['code'],
@@ -345,7 +345,7 @@ class JupyterHubRequestValidator(RequestValidator):
app_log.debug("Saving bearer token %s", log_token)
if request.user is None:
raise ValueError("No user for access token: %s" % request.user)
raise ValueError(f"No user for access token: {request.user}")
client = (
self.db.query(orm.OAuthClient)
.filter_by(identifier=request.client.client_id)

View File

@@ -1113,7 +1113,7 @@ class APIToken(Hashed, Base):
elif kind == 'service':
prefix_match = prefix_match.filter(cls.service_id != None)
elif kind is not None:
raise ValueError("kind must be 'user', 'service', or None, not %r" % kind)
raise ValueError(f"kind must be 'user', 'service', or None, not {kind!r}")
for orm_token in prefix_match:
if orm_token.match(token):
if not orm_token.client_id:

View File

@@ -221,11 +221,11 @@ class Proxy(LoggingConfigurable):
host_route = not routespec.startswith('/')
if host_route and not self.host_routing:
raise ValueError(
"Cannot add host-based route %r, not using host-routing" % routespec
f"Cannot add host-based route {routespec!r}, not using host-routing"
)
if self.host_routing and not host_route:
raise ValueError(
"Cannot add route without host %r, using host-routing" % routespec
f"Cannot add route without host {routespec!r}, using host-routing"
)
# add trailing slash
if not routespec.endswith('/'):
@@ -613,8 +613,8 @@ class ConfigurableHTTPProxy(Proxy):
# check for required token if proxy is external
if not self.auth_token and not self.should_start:
raise ValueError(
"%s.auth_token or CONFIGPROXY_AUTH_TOKEN env is required"
" if Proxy.should_start is False" % self.__class__.__name__
f"{self.__class__.__name__}.auth_token or CONFIGPROXY_AUTH_TOKEN env is required"
" if Proxy.should_start is False"
)
def _check_previous_process(self):
@@ -758,11 +758,11 @@ class ConfigurableHTTPProxy(Proxy):
)
except FileNotFoundError as e:
self.log.error(
"Failed to find proxy %r\n"
f"Failed to find proxy {self.command!r}\n"
"The proxy can be installed with `npm install -g configurable-http-proxy`."
"To install `npm`, install nodejs which includes `npm`."
"If you see an `EACCES` error or permissions error, refer to the `npm` "
"documentation on How To Prevent Permissions Errors." % self.command
"documentation on How To Prevent Permissions Errors."
)
raise

View File

@@ -1257,7 +1257,7 @@ def define_custom_scopes(scopes):
The keys are the scopes,
while the values are dictionaries with at least a `description` field,
and optional `subscopes` field.
%s
CUSTOM_SCOPE_DESCRIPTION
Examples::
define_custom_scopes(
@@ -1274,7 +1274,7 @@ def define_custom_scopes(scopes):
},
}
)
""" % indent(_custom_scope_description, " " * 8)
""".replace("CUSTOM_SCOPE_DESCRIPTION", indent(_custom_scope_description, " " * 8))
for scope, scope_definition in scopes.items():
if scope in scope_definitions and scope_definitions[scope] != scope_definition:
raise ValueError(

View File

@@ -250,7 +250,6 @@ class HubAuth(SingletonConfigurable):
fetched from JUPYTERHUB_API_URL by default.
- cookie_cache_max_age: the number of seconds responses
from the Hub should be cached.
- login_url (the *public* ``/hub/login`` URL of the Hub).
"""
hub_host = Unicode(
@@ -331,17 +330,18 @@ class HubAuth(SingletonConfigurable):
return url_path_join(os.getenv('JUPYTERHUB_BASE_URL') or '/', 'hub') + '/'
login_url = Unicode(
'/hub/login',
help="""The login URL to use
Typically /hub/login
'',
help="""The login URL to use, if any.
The base HubAuth class doesn't support login via URL,
and will raise 403 on `@web.authenticated` requests without a valid token.
An empty string here raises 403 errors instead of redirecting.
HubOAuth will redirect to /hub/api/oauth2/authorize.
""",
).tag(config=True)
@default('login_url')
def _default_login_url(self):
return self.hub_host + url_path_join(self.hub_prefix, 'login')
keyfile = Unicode(
os.getenv('JUPYTERHUB_SSL_KEYFILE', ''),
help="""The ssl key to use for requests
@@ -613,11 +613,8 @@ class HubAuth(SingletonConfigurable):
r = await AsyncHTTPClient().fetch(req, raise_error=False)
except Exception as e:
app_log.error("Error connecting to %s: %s", self.api_url, e)
msg = "Failed to connect to Hub API at %r." % self.api_url
msg += (
" Is the Hub accessible at this URL (from host: %s)?"
% socket.gethostname()
)
msg = f"Failed to connect to Hub API at {self.api_url!r}."
msg += f" Is the Hub accessible at this URL (from host: {socket.gethostname()})?"
if '127.0.0.1' in self.api_url:
msg += (
" Make sure to set c.JupyterHub.hub_ip to an IP accessible to"
@@ -1045,7 +1042,7 @@ class HubOAuth(HubAuth):
@validate('oauth_client_id', 'api_token')
def _ensure_not_empty(self, proposal):
if not proposal.value:
raise ValueError("%s cannot be empty." % proposal.trait.name)
raise ValueError(f"{proposal.trait.name} cannot be empty.")
return proposal.value
oauth_redirect_uri = Unicode(
@@ -1385,6 +1382,12 @@ class HubAuthenticated:
if self._hub_login_url is not None:
# cached value, don't call this more than once per handler
return self._hub_login_url
if not self.hub_auth.login_url:
# HubOAuth is required for login via redirect,
# base class can only raise to avoid redirect loops
raise HTTPError(403)
# temporary override at setting level,
# to allow any subclass overrides of get_login_url to preserve their effect
# for example, APIHandler raises 403 to prevent redirects
@@ -1555,7 +1558,7 @@ class HubOAuthCallbackHandler(HubOAuthenticated, RequestHandler):
error = self.get_argument("error", False)
if error:
msg = self.get_argument("error_description", error)
raise HTTPError(400, "Error in oauth: %s" % msg)
raise HTTPError(400, f"Error in oauth: {msg}")
code = self.get_argument("code", False)
if not code:

View File

@@ -167,6 +167,12 @@ class Service(LoggingConfigurable):
- url: str (None)
The URL where the service is/should be.
If specified, the service will be added to the proxy at /services/:name
- oauth_redirect_url: str ('/services/:name/oauth_redirect')
The URI for the oauth redirect.
Not usually needed, but must be set for external services that are not accessed through the proxy,
or any service which have a redirect URI different from the default of `/services/:name/oauth_redirect`.
- oauth_no_confirm: bool(False)
Whether this service should be allowed to complete oauth
with logged-in users without prompting for confirmation.
@@ -321,7 +327,7 @@ class Service(LoggingConfigurable):
@default('oauth_client_id')
def _default_client_id(self):
return 'service-%s' % self.name
return f'service-{self.name}'
@validate("oauth_client_id")
def _validate_client_id(self, proposal):
@@ -335,7 +341,9 @@ class Service(LoggingConfigurable):
oauth_redirect_uri = Unicode(
help="""OAuth redirect URI for this service.
You shouldn't generally need to change this.
Must be set for external services that are not accessed through the proxy,
or any service which have a redirect URI different from the default.
Default: `/services/:name/oauth_callback`
"""
).tag(input=True, in_db=False)
@@ -411,7 +419,7 @@ class Service(LoggingConfigurable):
async def start(self):
"""Start a managed service"""
if not self.managed:
raise RuntimeError("Cannot start unmanaged service %s" % self)
raise RuntimeError(f"Cannot start unmanaged service {self}")
self.log.info("Starting service %r: %r", self.name, self.command)
env = {}
env.update(self.environment)
@@ -465,7 +473,7 @@ class Service(LoggingConfigurable):
"""Stop a managed service"""
self.log.debug("Stopping service %s", self.name)
if not self.managed:
raise RuntimeError("Cannot stop unmanaged service %s" % self)
raise RuntimeError(f"Cannot stop unmanaged service {self}")
if self.spawner:
if self.orm.server:
self.db.delete(self.orm.server)

View File

@@ -341,7 +341,7 @@ class SingleUserNotebookAppMixin(Configurable):
# If we receive a non-absolute path, make it absolute.
value = os.path.abspath(value)
if not os.path.isdir(value):
raise TraitError("No such notebook dir: %r" % value)
raise TraitError(f"No such notebook dir: {value!r}")
return value
@default('log_level')

View File

@@ -18,7 +18,11 @@ from tempfile import mkdtemp
from textwrap import dedent
from urllib.parse import urlparse
from async_generator import aclosing
if sys.version_info >= (3, 10):
from contextlib import aclosing
else:
from async_generator import aclosing
from sqlalchemy import inspect
from tornado.ioloop import PeriodicCallback
from traitlets import (
@@ -990,7 +994,7 @@ class Spawner(LoggingConfigurable):
env = {}
if self.env:
warnings.warn(
"Spawner.env is deprecated, found %s" % self.env, DeprecationWarning
f"Spawner.env is deprecated, found {self.env}", DeprecationWarning
)
env.update(self.env)
@@ -1490,7 +1494,7 @@ def _try_setcwd(path):
path, _ = os.path.split(path)
else:
return
print("Couldn't set CWD at all (%s), using temp dir" % exc, file=sys.stderr)
print(f"Couldn't set CWD at all ({exc}), using temp dir", file=sys.stderr)
td = mkdtemp()
os.chdir(td)
@@ -1520,7 +1524,7 @@ def set_user_setuid(username, chdir=True):
try:
os.setgroups(gids)
except Exception as e:
print('Failed to set groups %s' % e, file=sys.stderr)
print(f'Failed to set groups {e}', file=sys.stderr)
os.setuid(uid)
# start in the user's home dir

View File

@@ -32,6 +32,20 @@ async def login(browser, username, password=None):
await browser.get_by_role("button", name="Sign in").click()
async def login_home(browser, app, username):
"""Visit login page, login, go home
A good way to start a session
"""
login_url = url_concat(
url_path_join(public_url(app), "hub/login"),
{"next": ujoin(app.hub.base_url, "home")},
)
await browser.goto(login_url)
async with browser.expect_navigation(url=re.compile(".*/hub/home")):
await login(browser, username)
async def test_open_login_page(app, browser):
login_url = url_path_join(public_host(app), app.hub.base_url, "login")
await browser.goto(login_url)
@@ -1367,6 +1381,17 @@ async def test_login_xsrf_initial_cookies(app, browser, case, username):
await login(browser, username, username)
async def test_prefix_redirect_not_running(browser, app, user):
# tests PrefixRedirectHandler for stopped servers
await login_home(browser, app, user.name)
# visit user url (includes subdomain, if enabled)
url = public_url(app, user, "/tree/")
await browser.goto(url)
# make sure we end up on the Hub (domain included)
expected_url = url_path_join(public_url(app), f"hub/user/{user.name}/tree/")
await expect(browser).to_have_url(expected_url)
def _cookie_dict(cookie_list):
"""Convert list of cookies to dict of the form

View File

@@ -83,7 +83,7 @@ async def app(request, io_loop, ssl_tmpdir):
try:
mocked_app.stop()
except Exception as e:
print("Error stopping Hub: %s" % e, file=sys.stderr)
print(f"Error stopping Hub: {e}", file=sys.stderr)
request.addfinalizer(fin)
await mocked_app.initialize([])

View File

@@ -54,7 +54,7 @@ class APIHandler(web.RequestHandler):
api_token = os.environ['JUPYTERHUB_API_TOKEN']
api_url = os.environ['JUPYTERHUB_API_URL']
r = requests.get(
api_url + path, headers={'Authorization': 'token %s' % api_token}
api_url + path, headers={'Authorization': f'token {api_token}'}
)
r.raise_for_status()
self.set_header('Content-Type', 'application/json')

View File

@@ -63,7 +63,7 @@ async def test_auth_api(app):
app,
'authorizations/token',
api_token,
headers={'Authorization': 'token: %s' % user.cookie_id},
headers={'Authorization': f'token: {user.cookie_id}'},
)
assert r.status_code == 403
@@ -965,7 +965,7 @@ async def test_spawn(app):
status = await app_user.spawner.poll()
assert status is None
assert spawner.server.base_url == ujoin(app.base_url, 'user/%s' % name) + '/'
assert spawner.server.base_url == ujoin(app.base_url, f'user/{name}') + '/'
url = public_url(app, user)
kwargs = {}
if app.internal_ssl:
@@ -1412,7 +1412,7 @@ async def test_progress_bad_slow(request, app, no_patience, slow_bad_spawn):
async def progress_forever():
"""progress function that yields messages forever"""
for i in range(1, 10):
yield {'progress': i, 'message': 'Stage %s' % i}
yield {'progress': i, 'message': f'Stage {i}'}
# wait a long time before the next event
await asyncio.sleep(10)
@@ -1741,7 +1741,7 @@ async def test_token_for_user(app, as_user, for_user, status):
if for_user != 'missing':
for_user_obj = add_user(app.db, app, name=for_user)
data = {'username': for_user}
headers = {'Authorization': 'token %s' % u.new_api_token()}
headers = {'Authorization': f'token {u.new_api_token()}'}
r = await api_request(
app,
'users',
@@ -1765,7 +1765,7 @@ async def test_token_for_user(app, as_user, for_user, status):
if for_user == as_user:
note = 'Requested via api'
else:
note = 'Requested via api by user %s' % as_user
note = f'Requested via api by user {as_user}'
assert reply['note'] == note
# delete the token
@@ -1836,7 +1836,7 @@ async def test_token_list(app, as_user, for_user, status):
u = add_user(app.db, app, name=as_user)
if for_user != 'missing':
for_user_obj = add_user(app.db, app, name=for_user)
headers = {'Authorization': 'token %s' % u.new_api_token()}
headers = {'Authorization': f'token {u.new_api_token()}'}
r = await api_request(app, 'users', for_user, 'tokens', headers=headers)
assert r.status_code == status
if status != 200:
@@ -2214,7 +2214,7 @@ async def test_get_service(app, mockservice_url):
r = await api_request(
app,
f"services/{mockservice.name}",
headers={'Authorization': 'token %s' % mockservice.api_token},
headers={'Authorization': f'token {mockservice.api_token}'},
)
r.raise_for_status()

View File

@@ -165,21 +165,35 @@ async def test_pam_auth_allowed():
async def test_pam_auth_allowed_groups():
def getgrnam(name):
return MockStructGroup('grp', ['kaylee'])
class TestAuthenticator(MockPAMAuthenticator):
@staticmethod
def _getpwnam(name):
return MockStructPasswd(name=name)
authenticator = MockPAMAuthenticator(allowed_groups={'group'}, allow_all=False)
@staticmethod
def _getgrnam(name):
if name == "group":
return MockStructGroup('grp', ['kaylee'], gid=1234)
else:
return None
with mock.patch.object(authenticator, '_getgrnam', getgrnam):
authorized = await authenticator.get_authenticated_user(
None, {'username': 'kaylee', 'password': 'kaylee'}
)
@staticmethod
def _getgrouplist(username, gid):
gids = [gid]
if username == "kaylee":
gids.append(1234)
return gids
authenticator = TestAuthenticator(allowed_groups={'group'}, allow_all=False)
authorized = await authenticator.get_authenticated_user(
None, {'username': 'kaylee', 'password': 'kaylee'}
)
assert authorized['name'] == 'kaylee'
with mock.patch.object(authenticator, '_getgrnam', getgrnam):
authorized = await authenticator.get_authenticated_user(
None, {'username': 'mal', 'password': 'mal'}
)
authorized = await authenticator.get_authenticated_user(
None, {'username': 'mal', 'password': 'mal'}
)
assert authorized is None
@@ -270,6 +284,7 @@ async def test_pam_auth_no_such_group():
authenticator = MockPAMAuthenticator(
allowed_groups={'nosuchcrazygroup'},
)
authenticator._getpwnam = MockStructPasswd
authorized = await authenticator.get_authenticated_user(
None, {'username': 'kaylee', 'password': 'kaylee'}
)

View File

@@ -57,7 +57,7 @@ async def test_upgrade(tmpdir, hub_version):
# use persistent temp env directory
# to reuse across multiple runs
env_dir = os.path.join(tempfile.gettempdir(), 'test-hub-upgrade-%s' % hub_version)
env_dir = os.path.join(tempfile.gettempdir(), f'test-hub-upgrade-{hub_version}')
generate_old_db(env_dir, hub_version, db_url)

View File

@@ -19,20 +19,22 @@ from traitlets.config import Config
# and `invalid_events` dictionary below.
# To test valid events, add event item with the form:
# { ( '<schema id>', <version> ) : { <event_data> } }
# ( '<schema id>', { <event_data> } )
valid_events = [
(
'hub.jupyter.org/server-action',
1,
'https://schema.jupyter.org/jupyterhub/events/server-action',
dict(action='start', username='test-username', servername='test-servername'),
)
]
# To test invalid events, add event item with the form:
# { ( '<schema id>', <version> ) : { <event_data> } }
# ( '<schema id>', { <event_data> } )
invalid_events = [
# Missing required keys
('hub.jupyter.org/server-action', 1, dict(action='start'))
(
'https://schema.jupyter.org/jupyterhub/events/server-action',
dict(action='start'),
)
]
@@ -41,11 +43,11 @@ def eventlog_sink(app):
"""Return eventlog and sink objects"""
sink = io.StringIO()
handler = logging.StreamHandler(sink)
# Update the EventLog config with handler
# Update the EventLogger config with handler
cfg = Config()
cfg.EventLog.handlers = [handler]
cfg.EventLogger.handlers = [handler]
with mock.patch.object(app.config, 'EventLog', cfg.EventLog):
with mock.patch.object(app.config, 'EventLogger', cfg.EventLogger):
# recreate the eventlog object with our config
app.init_eventlog()
# return the sink from the fixture
@@ -54,12 +56,12 @@ def eventlog_sink(app):
app.init_eventlog()
@pytest.mark.parametrize('schema, version, event', valid_events)
def test_valid_events(eventlog_sink, schema, version, event):
@pytest.mark.parametrize('schema, event', valid_events)
def test_valid_events(eventlog_sink, schema, event):
eventlog, sink = eventlog_sink
eventlog.allowed_schemas = [schema]
# Record event
eventlog.record_event(schema, version, event)
eventlog.emit(schema_id=schema, data=event)
# Inspect consumed event
output = sink.getvalue()
assert output
@@ -68,11 +70,11 @@ def test_valid_events(eventlog_sink, schema, version, event):
assert data is not None
@pytest.mark.parametrize('schema, version, event', invalid_events)
def test_invalid_events(eventlog_sink, schema, version, event):
@pytest.mark.parametrize('schema, event', invalid_events)
def test_invalid_events(eventlog_sink, schema, event):
eventlog, sink = eventlog_sink
eventlog.allowed_schemas = [schema]
# Make sure an error is thrown when bad events are recorded
with pytest.raises(jsonschema.ValidationError):
recorded_event = eventlog.record_event(schema, version, event)
recorded_event = eventlog.emit(schema_id=schema, data=event)

View File

@@ -69,6 +69,12 @@ async def test_default_server(app, named_servers):
r.raise_for_status()
user_model = normalize_user(r.json())
full_progress_url = None
if app.public_url:
full_progress_url = url_path_join(
app.public_url,
f'hub/api/users/{username}/server/progress',
)
assert user_model == fill_user(
{
'name': username,
@@ -88,6 +94,8 @@ async def test_default_server(app, named_servers):
'progress_url': f'PREFIX/hub/api/users/{username}/server/progress',
'state': {'pid': 0},
'user_options': {},
'full_url': user.public_url() or None,
'full_progress_url': full_progress_url,
}
},
}
@@ -157,6 +165,14 @@ async def test_create_named_server(
assert db_server_names == {"", servername}
user_model = normalize_user(r.json())
full_progress_url = None
if app.public_url:
full_progress_url = url_path_join(
app.public_url,
f'hub/api/users/{username}/servers/{escapedname}/progress',
)
assert user_model == fill_user(
{
'name': username,
@@ -175,6 +191,8 @@ async def test_create_named_server(
'progress_url': f'PREFIX/hub/api/users/{username}/servers/{escapedname}/progress',
'state': {'pid': 0},
'user_options': {},
'full_url': user.public_url(name) or None,
'full_progress_url': full_progress_url,
}
for name in [servername]
},

View File

@@ -120,7 +120,7 @@ async def test_admin_version(app):
@pytest.mark.parametrize('sort', ['running', 'last_activity', 'admin', 'name'])
async def test_admin_sort(app, sort):
cookies = await app.login_user('admin')
r = await get_page('admin?sort=%s' % sort, app, cookies=cookies)
r = await get_page(f'admin?sort={sort}', app, cookies=cookies)
r.raise_for_status()
assert r.status_code == 200
@@ -170,7 +170,7 @@ async def test_spawn_redirect(app, last_failed):
r.raise_for_status()
print(urlparse(r.url))
path = urlparse(r.url).path
assert path == ujoin(app.base_url, '/user/%s/' % name)
assert path == ujoin(app.base_url, f'/user/{name}/')
# stop server to ensure /user/name is handled by the Hub
r = await api_request(
@@ -181,7 +181,7 @@ async def test_spawn_redirect(app, last_failed):
# test handing of trailing slash on `/user/name`
r = await get_page('user/' + name, app, hub=False, cookies=cookies)
path = urlparse(r.url).path
assert path == ujoin(app.base_url, 'hub/user/%s/' % name)
assert path == ujoin(app.base_url, f'hub/user/{name}/')
assert r.status_code == 424
@@ -586,7 +586,7 @@ async def test_user_redirect(app, username):
await asyncio.sleep(0.1)
r = await async_requests.get(r.url, cookies=cookies)
path = urlparse(r.url).path
assert path == ujoin(app.base_url, '/user/%s/notebooks/test.ipynb' % name)
assert path == ujoin(app.base_url, f'/user/{name}/notebooks/test.ipynb')
async def test_user_redirect_hook(app, username):
@@ -1240,7 +1240,7 @@ async def test_token_page(app):
r.raise_for_status()
body = extract_body(r)
assert "API Tokens" in body, body
assert "Server at %s" % user.base_url in body, body
assert f"Server at {user.base_url}" in body, body
assert "Authorized Applications" in body, body
@@ -1299,7 +1299,7 @@ async def test_pre_spawn_start_exc_options_form(app):
r.raise_for_status()
assert FormSpawner.options_form in r.text
# spawning the user server should throw the pre_spawn_start error
with pytest.raises(Exception, match="%s" % exc):
with pytest.raises(Exception, match=str(exc)):
await user.spawn()

View File

@@ -171,7 +171,7 @@ async def test_external_proxy(request):
async def test_check_routes(app, username, disable_check_routes):
proxy = app.proxy
test_user = add_user(app.db, app, name=username)
r = await api_request(app, 'users/%s/server' % username, method='post')
r = await api_request(app, f'users/{username}/server', method='post')
r.raise_for_status()
# check a valid route exists for user

View File

@@ -956,7 +956,7 @@ async def test_user_group_roles(app, create_temp_role):
# jack's API token
token = user.new_api_token()
headers = {'Authorization': 'token %s' % token}
headers = {'Authorization': f'token {token}'}
r = await api_request(app, f'users/{user.name}', method='get', headers=headers)
assert r.status_code == 200
r.raise_for_status()
@@ -968,7 +968,7 @@ async def test_user_group_roles(app, create_temp_role):
assert len(reply['roles']) == 1
assert group_role.name not in reply['roles']
headers = {'Authorization': 'token %s' % token}
headers = {'Authorization': f'token {token}'}
r = await api_request(app, 'groups', method='get', headers=headers)
assert r.status_code == 200
r.raise_for_status()
@@ -978,7 +978,7 @@ async def test_user_group_roles(app, create_temp_role):
assert len(reply) == 1
assert reply[0]['name'] == 'A'
headers = {'Authorization': 'token %s' % token}
headers = {'Authorization': f'token {token}'}
r = await api_request(app, f'users/{user.name}', method='get', headers=headers)
assert r.status_code == 200
r.raise_for_status()

View File

@@ -289,7 +289,7 @@ async def test_exceeding_user_permissions(
orm_api_token = orm.APIToken.find(app.db, token=api_token)
# store scopes user does not have
orm_api_token.scopes = list(orm_api_token.scopes) + ['list:users', 'read:users']
headers = {'Authorization': 'token %s' % api_token}
headers = {'Authorization': f'token {api_token}'}
r = await api_request(app, 'users', headers=headers)
assert r.status_code == 200
keys = {key for user in r.json() for key in user.keys()}
@@ -307,7 +307,7 @@ async def test_user_service_separation(app, mockservice_url, create_temp_role):
roles.update_roles(app.db, mockservice_url.orm, roles=['reader_role'])
user.roles.remove(orm.Role.find(app.db, name='user'))
api_token = user.new_api_token()
headers = {'Authorization': 'token %s' % api_token}
headers = {'Authorization': f'token {api_token}'}
r = await api_request(app, 'users', headers=headers)
assert r.status_code == 200
keys = {key for user in r.json() for key in user.keys()}
@@ -551,7 +551,7 @@ async def test_server_state_access(
)
service = create_service_with_scopes("read:users:name!user=bianca", *scopes)
api_token = service.new_api_token()
headers = {'Authorization': 'token %s' % api_token}
headers = {'Authorization': f'token {api_token}'}
# can I get the user model?
r = await api_request(app, 'users', user.name, headers=headers)

View File

@@ -3,10 +3,9 @@
import os
import sys
from binascii import hexlify
from contextlib import asynccontextmanager
from subprocess import Popen
from async_generator import asynccontextmanager
from ..utils import (
exponential_backoff,
maybe_future,

View File

@@ -88,14 +88,10 @@ async def test_hubauth_token(app, mockservice_url, create_user_with_scopes):
# token in ?token parameter is not allowed by default
r = await async_requests.get(
public_url(app, mockservice_url) + '/whoami/?token=%s' % token,
public_url(app, mockservice_url) + f'/whoami/?token={token}',
allow_redirects=False,
)
assert r.status_code == 302
assert 'Location' in r.headers
location = r.headers['Location']
path = urlparse(location).path
assert path.endswith('/hub/login')
assert r.status_code == 403
@pytest.mark.parametrize(
@@ -154,7 +150,7 @@ async def test_hubauth_service_token(request, app, mockservice_url, scopes, allo
# token in Authorization header
r = await async_requests.get(
public_url(app, mockservice_url) + 'whoami/',
headers={'Authorization': 'token %s' % token},
headers={'Authorization': f'token {token}'},
allow_redirects=False,
)
service_model = {
@@ -174,14 +170,10 @@ async def test_hubauth_service_token(request, app, mockservice_url, scopes, allo
# token in ?token parameter is not allowed by default
r = await async_requests.get(
public_url(app, mockservice_url) + 'whoami/?token=%s' % token,
public_url(app, mockservice_url) + f'whoami/?token={token}',
allow_redirects=False,
)
assert r.status_code == 302
assert 'Location' in r.headers
location = r.headers['Location']
path = urlparse(location).path
assert path.endswith('/hub/login')
assert r.status_code == 403
@pytest.mark.parametrize(
@@ -311,7 +303,7 @@ async def test_oauth_service_roles(
# we should be looking at the oauth confirmation page
assert urlparse(r.url).path == app.base_url + 'hub/api/oauth2/authorize'
# verify oauth state cookie was set at some point
assert set(r.history[0].cookies.keys()) == {'service-%s-oauth-state' % service.name}
assert set(r.history[0].cookies.keys()) == {f'service-{service.name}-oauth-state'}
page = BeautifulSoup(r.text, "html.parser")
scope_inputs = page.find_all("input", {"name": "scopes"})
@@ -326,9 +318,9 @@ async def test_oauth_service_roles(
r.raise_for_status()
assert r.url == url
# verify oauth cookie is set
assert 'service-%s' % service.name in set(s.cookies.keys())
assert f'service-{service.name}' in set(s.cookies.keys())
# verify oauth state cookie has been consumed
assert 'service-%s-oauth-state' % service.name not in set(s.cookies.keys())
assert f'service-{service.name}-oauth-state' not in set(s.cookies.keys())
# second request should be authenticated, which means no redirects
r = await s.get(url, allow_redirects=False)
@@ -410,16 +402,16 @@ async def test_oauth_access_scopes(
# we should be looking at the oauth confirmation page
assert urlparse(r.url).path == app.base_url + 'hub/api/oauth2/authorize'
# verify oauth state cookie was set at some point
assert set(r.history[0].cookies.keys()) == {'service-%s-oauth-state' % service.name}
assert set(r.history[0].cookies.keys()) == {f'service-{service.name}-oauth-state'}
# submit the oauth form to complete authorization
r = await s.post(r.url, data={"_xsrf": s.cookies["_xsrf"]})
r.raise_for_status()
assert r.url == url
# verify oauth cookie is set
assert 'service-%s' % service.name in set(s.cookies.keys())
assert f'service-{service.name}' in set(s.cookies.keys())
# verify oauth state cookie has been consumed
assert 'service-%s-oauth-state' % service.name not in set(s.cookies.keys())
assert f'service-{service.name}-oauth-state' not in set(s.cookies.keys())
# second request should be authenticated, which means no redirects
r = await s.get(url, allow_redirects=False)
@@ -507,8 +499,8 @@ async def test_oauth_cookie_collision(
name = 'mypha'
create_user_with_scopes("access:services", name=name)
s.cookies = await app.login_user(name)
state_cookie_name = 'service-%s-oauth-state' % service.name
service_cookie_name = 'service-%s' % service.name
state_cookie_name = f'service-{service.name}-oauth-state'
service_cookie_name = f'service-{service.name}'
url_1 = url + "?oauth_test=1"
oauth_1 = await s.get(url_1)
assert state_cookie_name in s.cookies
@@ -590,7 +582,7 @@ async def test_oauth_logout(app, mockservice_url, create_user_with_scopes):
4. cache hit
"""
service = mockservice_url
service_cookie_name = 'service-%s' % service.name
service_cookie_name = f'service-{service.name}'
url = url_path_join(public_url(app, mockservice_url), 'owhoami/?foo=bar')
# first request is only going to set login cookie
s = AsyncSession()

View File

@@ -221,8 +221,8 @@ def test_string_formatting(db):
name = s.user.name
assert s.notebook_dir == 'user/{username}/'
assert s.default_url == '/base/{username}'
assert s.format_string(s.notebook_dir) == 'user/%s/' % name
assert s.format_string(s.default_url) == '/base/%s' % name
assert s.format_string(s.notebook_dir) == f'user/{name}/'
assert s.format_string(s.default_url) == f'/base/{name}'
async def test_popen_kwargs(db):
@@ -496,7 +496,7 @@ async def test_hub_connect_url(db):
assert env["JUPYTERHUB_API_URL"] == "https://example.com/api"
assert (
env["JUPYTERHUB_ACTIVITY_URL"]
== "https://example.com/api/users/%s/activity" % name
== f"https://example.com/api/users/{name}/activity"
)

View File

@@ -1,12 +1,17 @@
"""Tests for utilities"""
import asyncio
import sys
import time
from concurrent.futures import ThreadPoolExecutor
from unittest.mock import Mock
if sys.version_info >= (3, 10):
from contextlib import aclosing
else:
from async_generator import aclosing
import pytest
from async_generator import aclosing
from tornado import gen
from tornado.concurrent import run_on_executor
from tornado.httpserver import HTTPRequest

View File

@@ -149,7 +149,7 @@ def auth_header(db, name):
if user is None:
raise KeyError(f"No such user: {name}")
token = user.new_api_token()
return {'Authorization': 'token %s' % token}
return {'Authorization': f'token {token}'}
@check_db_locks
@@ -198,7 +198,7 @@ async def api_request(
def get_page(path, app, hub=True, **kw):
if "://" in path:
raise ValueError(
"Not a hub page path: %r. Did you mean async_requests.get?" % path
f"Not a hub page path: {path!r}. Did you mean async_requests.get?"
)
if hub:
prefix = app.hub.base_url

View File

@@ -125,7 +125,7 @@ class UserDict(dict):
elif isinstance(key, str):
orm_user = self.db.query(orm.User).filter(orm.User.name == key).first()
if orm_user is None:
raise KeyError("No such user: %s" % key)
raise KeyError(f"No such user: {key}")
else:
key = orm_user.id
if isinstance(key, orm.User):
@@ -142,7 +142,7 @@ class UserDict(dict):
if id not in self:
orm_user = self.db.query(orm.User).filter(orm.User.id == id).first()
if orm_user is None:
raise KeyError("No such user: %s" % id)
raise KeyError(f"No such user: {id}")
user = self.add(orm_user)
else:
user = super().__getitem__(id)
@@ -505,7 +505,7 @@ class User:
# use fully quoted name for client_id because it will be used in cookie-name
# self.escaped_name may contain @ which is legal in URLs but not cookie keys
client_id = 'jupyterhub-user-%s' % quote(self.name)
client_id = f'jupyterhub-user-{quote(self.name)}'
if server_name:
client_id = f'{client_id}-{quote(server_name)}'
@@ -790,7 +790,7 @@ class User:
orm_server = orm.Server(base_url=base_url)
db.add(orm_server)
note = "Server at %s" % base_url
note = f"Server at {base_url}"
db.commit()
spawner = self.get_spawner(server_name, replace_failed=True)
@@ -962,7 +962,7 @@ class User:
)
self.db.delete(found)
self.db.commit()
raise ValueError("Invalid token for %s!" % self.name)
raise ValueError(f"Invalid token for {self.name}!")
else:
# Spawner.api_token has changed, but isn't in the db.
# What happened? Maybe something unclean in a resumed container.
@@ -975,7 +975,7 @@ class User:
self.new_api_token(
spawner.api_token,
generated=False,
note="retrieved from spawner %s" % server_name,
note=f"retrieved from spawner {server_name}",
scopes=resolved_scopes,
)
# update OAuth client secret with updated API token

View File

@@ -27,8 +27,12 @@ from hmac import compare_digest
from operator import itemgetter
from urllib.parse import quote
if sys.version_info >= (3, 10):
from contextlib import aclosing
else:
from async_generator import aclosing
import idna
from async_generator import aclosing
from sqlalchemy.exc import SQLAlchemyError
from tornado import gen, ioloop, web
from tornado.httpclient import AsyncHTTPClient, HTTPError
@@ -500,7 +504,7 @@ def print_ps_info(file=sys.stderr):
if cpu >= 10:
cpu_s = "%i" % cpu
else:
cpu_s = "%.1f" % cpu
cpu_s = f"{cpu:.1f}"
# format memory (only resident set)
rss = p.memory_info().rss
@@ -558,7 +562,7 @@ def print_stacks(file=sys.stderr):
print("Active threads: %i" % threading.active_count(), file=file)
for thread in threading.enumerate():
print("Thread %s:" % thread.name, end='', file=file)
print(f"Thread {thread.name}:", end='', file=file)
frame = sys._current_frames()[thread.ident]
stack = traceback.extract_stack(frame)
if thread is threading.current_thread():

View File

@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
# ref: https://setuptools.pypa.io/en/latest/userguide/pyproject_config.html
[project]
name = "jupyterhub"
version = "5.0.0b1"
version = "5.0.0b2"
dynamic = ["readme", "dependencies"]
description = "JupyterHub: A multi-user server for Jupyter notebooks"
authors = [
@@ -147,7 +147,7 @@ indent_size = 2
github_url = "https://github.com/jupyterhub/jupyterhub"
[tool.tbump.version]
current = "5.0.0b1"
current = "5.0.0b2"
# Example of a semver regexp.
# Make sure this matches current_version before

View File

@@ -1,10 +1,10 @@
alembic>=1.4
async_generator>=1.9
async_generator>=1.9; python_version < '3.10'
certipy>=0.1.2
idna
importlib_metadata>=3.6; python_version < '3.10'
jinja2>=2.11.0
jupyter_telemetry>=0.1.0
jupyter_events
oauthlib>=3.0
packaging
pamela>=1.1.0; sys_platform != 'win32'

View File

@@ -157,7 +157,7 @@ class CSS(BaseCommand):
try:
check_call(args, cwd=here, shell=shell)
except OSError as e:
print("Failed to build css: %s" % e, file=sys.stderr)
print(f"Failed to build css: {e}", file=sys.stderr)
print("You can install js dependencies with `npm install`", file=sys.stderr)
raise
# update data-files in case this created new files