Compare commits

...

36 Commits
3.1.0 ... 2.3.1

Author SHA1 Message Date
Erik Sundell
20b3229249 Bump to 2.3.1 2022-06-06 16:26:12 +02:00
Yuvi Panda
f0862f1d10 Merge pull request #3930 from consideRatio/pr/add-changelog-to-2x-branch
Add changelog for 2.3.1
2022-06-06 19:53:45 +05:30
Erik Sundell
3c5f9b255e Add changelog for 2.3.1 2022-06-06 16:15:36 +02:00
Erik Sundell
b6d9d5c120 Merge pull request #3926 from meeseeksmachine/auto-backport-of-pr-3910-on-2.x
Backport PR #3910 on branch 2.x (use equality to filter token prefixes)
2022-06-06 15:36:36 +02:00
Yuvi Panda
bccd0e2ff1 Merge pull request #3928 from yuvipanda/auto-backport-of-pr-3919-on-2.x
Auto backport of pr 3919 on 2.x
2022-06-06 18:40:16 +05:30
Yuvi Panda
a2d39c693d Merge pull request #3927 from meeseeksmachine/auto-backport-of-pr-3918-on-2.x
Backport PR #3918 on branch 2.x (set default_url via config)
2022-06-06 18:40:06 +05:30
Yuvi Panda
76e65da9ff Merge pull request #3925 from meeseeksmachine/auto-backport-of-pr-3906-on-2.x
Backport PR #3906 on branch 2.x (Force add existing certificates)
2022-06-06 18:39:24 +05:30
Yuvi Panda
eb9bb71655 Merge pull request #3924 from meeseeksmachine/auto-backport-of-pr-3889-on-2.x
Backport PR #3889 on branch 2.x (admin: make user-info table selectable)
2022-06-06 18:39:12 +05:30
Yuvi Panda
a39ef8f163 Merge pull request #3923 from meeseeksmachine/auto-backport-of-pr-3837-on-2.x
Backport PR #3837 on branch 2.x (ensure _import_error is set when JUPYTERHUB_SINGLEUSER_APP is unavailable)
2022-06-06 18:38:57 +05:30
Yuvi Panda
f4727cba47 Backport PR #3919: ensure custom template is loaded with jupyter-server notebook extension 2022-06-03 21:15:44 +05:30
Yuvi Panda
14dfa65c75 Backport PR #3918: set default_url via config 2022-06-03 15:17:09 +00:00
Yuvi Panda
9f23bc2959 Backport PR #3910: use equality to filter token prefixes 2022-06-03 15:17:01 +00:00
Min RK
24e8362401 Backport PR #3906: Force add existing certificates 2022-06-03 15:16:46 +00:00
Min RK
c4c662843c Backport PR #3889: admin: make user-info table selectable 2022-06-03 15:16:29 +00:00
Erik Sundell
6d5b13962c Backport PR #3837: ensure _import_error is set when JUPYTERHUB_SINGLEUSER_APP is unavailable 2022-06-03 15:16:20 +00:00
Min RK
fe64595d75 Bump to 2.3.1.dev 2022-05-06 16:06:06 +02:00
Min RK
a3c93088a8 Bump to 2.3.0 2022-05-06 16:05:34 +02:00
Min RK
834229622d Merge pull request #3887 from minrk/2.3-backports
2.3 backports
2022-05-06 16:05:10 +02:00
Min RK
44a1ea42de One more in the changelog 2022-05-06 15:56:13 +02:00
Simon Li
3879a96b67 Backport PR #3886: Cleanup everything on API shutdown
`app.stop` triggers full cleanup and stopping of the event loop

closes  3881

Signed-off-by: Min RK <benjaminrk@gmail.com>
2022-05-06 15:55:00 +02:00
Min RK
d40627d397 changelog for 2.3 2022-05-05 13:24:00 +02:00
Min RK
057cdbc9e9 pre-commit autoupdate 2022-05-05 13:23:52 +02:00
Min RK
75390d2e46 Backport PR #3882: Use log.exception when logging exceptions
This provides the stack trace in the log file, incredibly
useful when debugging

Signed-off-by: Min RK <benjaminrk@gmail.com>
2022-05-05 13:15:28 +02:00
Min RK
f5e4846cfa Backport PR #3874: Missing f prefix on f-strings fix
Fixes  3873

Signed-off-by: Min RK <benjaminrk@gmail.com>
2022-05-05 13:15:27 +02:00
Georgiana Elena
3dc115a829 Backport PR #3876: don't confuse :// in next_url query params for a redirect hostname
closes  3014

These query params should be url-encoded (https://github.com/jupyterhub/nbgitpuller/issues/118), but we still shouldn't be making the wrong assumptions about when a hostname is specified

Signed-off-by: Min RK <benjaminrk@gmail.com>
2022-05-05 13:15:25 +02:00
Min RK
af4ddbfc58 Backport PR #3867: ci: update black configuration
Signed-off-by: Min RK <benjaminrk@gmail.com>
2022-05-05 13:15:24 +02:00
Min RK
50a4d1e34d Backport PR #3863: [Bug Fix] Search bar disabled on admin dashboard
I originally had `defaultValue` here and I changed it not realizing this would break/disable the input.

Signed-off-by: Min RK <benjaminrk@gmail.com>
2022-05-05 13:15:23 +02:00
Erik Sundell
86a238334c Backport PR #3862: Fix typo in [rest api] link in README.md
Signed-off-by: Min RK <benjaminrk@gmail.com>
2022-05-05 13:15:22 +02:00
Simon Li
dacb9d1668 Backport PR #3859: Do not store Spawner.ip/port on spawner.server during get_env
we shouldn't mutate db state when getting the environment.

IIRC, this was part of an attempt to get the url via `self.server.bind_url` that didn't end up getting used in  3381. So this doesn't really have any positive effects, but it _can_ have negative effects if `get_env` is called in unusual circumstances (jupyterhub/batchspawner 236)

closes jupyterhub/batchspawner 236

Signed-off-by: Min RK <benjaminrk@gmail.com>
2022-05-05 13:15:21 +02:00
Min RK
95cc170383 Backport PR #3853: Fix xsrf_cookie_kwargs ValueError
Fixes

`ValueError: too many values to unpack (expected 2)`

Related to code added as a fix for https://github.com/jupyterhub/jupyterhub/issues/3821

Signed-off-by: Min RK <benjaminrk@gmail.com>
2022-05-05 13:15:20 +02:00
Erik Sundell
437a9d150f Backport PR #3849: The word used is duplicated in upgrade.md
This PR is to update doc for that the word `used` is duplicated in this doc.

Signed-off-by: Min RK <benjaminrk@gmail.com>
2022-05-05 13:15:19 +02:00
Erik Sundell
c9616d6f11 Backport PR #3843: Some typos in docs
- fix some references to old 'all' name which was renamed 'inherit'
- fix a heading level in changlog that sphinx warns about

Signed-off-by: Min RK <benjaminrk@gmail.com>
2022-05-05 13:15:18 +02:00
Min RK
61aed70c4d Backport PR #3841: adopt pytest-asyncio asyncio_mode='auto'
removes need for our own implementation of the same behavior in conftest

Signed-off-by: Min RK <benjaminrk@gmail.com>
2022-05-05 13:15:17 +02:00
Erik Sundell
9abb573d47 Backport PR #3839: Document version mismatch log message
Signed-off-by: Min RK <benjaminrk@gmail.com>
2022-05-05 13:15:16 +02:00
Erik Sundell
b074304834 Backport PR #3835: remove lingering reference to distutils
traitlets, like most Jupyter projects (and Python itself), has a `.version_info` tuple to avoid needing to parse versions

Signed-off-by: Min RK <benjaminrk@gmail.com>
2022-05-05 13:15:15 +02:00
Min RK
201e7ca3d8 Backport PR #3834: Admin Dashboard - Collapsible Details View
I made this PR to see if this feature would be useful for other people. Right now, you can't see all of a user or server's details in the admin page so I added a collapsible view which will let you see the entire server and user objects. I'm open to ideas about how the information is displayed. Will add more tests if this feature is accepted.

![improved-collapse](https://user-images.githubusercontent.com/737367/158468531-1efc28e6-a229-4383-b5f9-b301898d929f.gif)

Signed-off-by: Min RK <benjaminrk@gmail.com>
2022-05-05 13:15:14 +02:00
37 changed files with 530 additions and 213 deletions

View File

@@ -11,7 +11,7 @@
repos: repos:
# Autoformat: Python code, syntax patterns are modernized # Autoformat: Python code, syntax patterns are modernized
- repo: https://github.com/asottile/pyupgrade - repo: https://github.com/asottile/pyupgrade
rev: v2.31.1 rev: v2.32.1
hooks: hooks:
- id: pyupgrade - id: pyupgrade
args: args:
@@ -19,26 +19,25 @@ repos:
# Autoformat: Python code # Autoformat: Python code
- repo: https://github.com/asottile/reorder_python_imports - repo: https://github.com/asottile/reorder_python_imports
rev: v3.0.1 rev: v3.1.0
hooks: hooks:
- id: reorder-python-imports - id: reorder-python-imports
# Autoformat: Python code # Autoformat: Python code
- repo: https://github.com/psf/black - repo: https://github.com/psf/black
rev: 22.1.0 rev: 22.3.0
hooks: hooks:
- id: black - id: black
args: [--target-version=py36]
# Autoformat: markdown, yaml, javascript (see the file .prettierignore) # Autoformat: markdown, yaml, javascript (see the file .prettierignore)
- repo: https://github.com/pre-commit/mirrors-prettier - repo: https://github.com/pre-commit/mirrors-prettier
rev: v2.5.1 rev: v2.6.2
hooks: hooks:
- id: prettier - id: prettier
# Autoformat and linting, misc. details # Autoformat and linting, misc. details
- repo: https://github.com/pre-commit/pre-commit-hooks - repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.1.0 rev: v4.2.0
hooks: hooks:
- id: end-of-file-fixer - id: end-of-file-fixer
exclude: share/jupyterhub/static/js/admin-react.js exclude: share/jupyterhub/static/js/admin-react.js

View File

@@ -59,7 +59,7 @@ JupyterHub also provides a
[REST API][] [REST API][]
for administration of the Hub and its users. for administration of the Hub and its users.
[rest api]: https://juptyerhub.readthedocs.io/en/latest/reference/rest-api.html [rest api]: https://jupyterhub.readthedocs.io/en/latest/reference/rest-api.html
## Installation ## Installation

View File

@@ -11,7 +11,8 @@ jupyterlab >=3
mock mock
pre-commit pre-commit
pytest>=3.3 pytest>=3.3
pytest-asyncio pytest-asyncio; python_version < "3.7"
pytest-asyncio>=0.17; python_version >= "3.7"
pytest-cov pytest-cov
requests-mock requests-mock
tbump tbump

View File

@@ -6,7 +6,7 @@ info:
description: The REST API for JupyterHub description: The REST API for JupyterHub
license: license:
name: BSD-3-Clause name: BSD-3-Clause
version: 2.3.0.dev version: 2.3.1
servers: servers:
- url: /hub/api - url: /hub/api
security: security:

View File

@@ -1,4 +1,4 @@
# Common log messages emitted by JupyterHub # Interpreting common log messages
When debugging errors and outages, looking at the logs emitted by When debugging errors and outages, looking at the logs emitted by
JupyterHub is very helpful. This document tries to document some common JupyterHub is very helpful. This document tries to document some common
@@ -35,3 +35,38 @@ URL, used by [jupyter-resource-usage](https://github.com/jupyter-server/jupyter-
### Actions you can take ### Actions you can take
This log message is benign, and there is usually no action for you to take. This log message is benign, and there is usually no action for you to take.
## JupyterHub Singleuser Version mismatch
### Example
```
jupyterhub version 1.5.0 != jupyterhub-singleuser version 1.3.0. This could cause failure to authenticate and result in redirect loops!
```
### Cause
JupyterHub requires the `jupyterhub` python package installed inside the image or
environment the user server starts in. This message indicates that the version of
the `jupyterhub` package installed inside the user image or environment is not
the same version as the JupyterHub server itself. This is not necessarily always a
problem - some version drift is mostly acceptable, and the only two known cases of
breakage are across the 0.7 and 2.0 version releases. In those cases, issues pop
up immediately after upgrading your version of JupyterHub, so **always check the JupyterHub
changelog before upgrading!**. The primary problems this _could_ cause are:
1. Infinite redirect loops after the user server starts
2. Missing expected environment variables in the user server once it starts
3. Failure for the started user server to authenticate with the JupyterHub server -
note that this is _not_ the same as _user authentication_ failing!
However, for the most part, unless you are seeing these specific issues, the log
message should be counted as a warning to get the `jupyterhub` package versions
aligned, rather than as an indicator of an existing problem.
### Actions you can take
Upgrade the version of the `jupyterhub` package in your user environment or image
so it matches the version of JupyterHub running your JupyterHub server! If you
are using the [zero-to-jupyterhub](https://z2jh.jupyter.org) helm chart, you can find the appropriate
version of the `jupyterhub` package to install in your user image [here](https://jupyterhub.github.io/helm-chart/)

File diff suppressed because one or more lines are too long

View File

@@ -7,7 +7,7 @@ JupyterHub provides four roles that are available by default:
```{admonition} **Default roles** ```{admonition} **Default roles**
- `user` role provides a {ref}`default user scope <default-user-scope-target>` `self` that grants access to the user's own resources. - `user` role provides a {ref}`default user scope <default-user-scope-target>` `self` that grants access to the user's own resources.
- `admin` role contains all available scopes and grants full rights to all actions. This role **cannot be edited**. - `admin` role contains all available scopes and grants full rights to all actions. This role **cannot be edited**.
- `token` role provides a {ref}`default token scope <default-token-scope-target>` `all` that resolves to the same permissions as the owner of the token has. - `token` role provides a {ref}`default token scope <default-token-scope-target>` `inherit` that resolves to the same permissions as the owner of the token has.
- `server` role allows for posting activity of "itself" only. - `server` role allows for posting activity of "itself" only.
**These roles cannot be deleted.** **These roles cannot be deleted.**

View File

@@ -38,7 +38,7 @@ By adding a scope to an existing role, all role bearers will gain the associated
Metascopes do not follow the general scope syntax. Instead, a metascope resolves to a set of scopes, which can refer to different resources, based on their owning entity. In JupyterHub, there are currently two metascopes: Metascopes do not follow the general scope syntax. Instead, a metascope resolves to a set of scopes, which can refer to different resources, based on their owning entity. In JupyterHub, there are currently two metascopes:
1. default user scope `self`, and 1. default user scope `self`, and
2. default token scope `all`. 2. default token scope `inherit`.
(default-user-scope-target)= (default-user-scope-target)=
@@ -57,11 +57,11 @@ The `self` scope is only valid for user entities. In other cases (e.g., for serv
### Default token scope ### Default token scope
The token metascope `all` covers the same scopes as the token owner's scopes during requests. For example, if a token owner has roles containing the scopes `read:groups` and `read:users`, the `all` scope resolves to the set of scopes `{read:groups, read:users}`. The token metascope `inherit` causes the token to have the same permissions as the token's owner. For example, if a token owner has roles containing the scopes `read:groups` and `read:users`, the `inherit` scope resolves to the set of scopes `{read:groups, read:users}`.
If the token owner has default `user` role, the `all` scope resolves to `self`, which will subsequently be expanded to include all the user-specific scopes (or empty set in the case of services). If the token owner has default `user` role, the `inherit` scope resolves to `self`, which will subsequently be expanded to include all the user-specific scopes (or empty set in the case of services).
If the token owner is a member of any group with roles, the group scopes will also be included in resolving the `all` scope. If the token owner is a member of any group with roles, the group scopes will also be included in resolving the `inherit` scope.
(horizontal-filtering-target)= (horizontal-filtering-target)=

View File

@@ -49,6 +49,6 @@ API tokens can also be issued to users via API ([_/hub/token_](../reference/urls
### With RBAC ### With RBAC
The RBAC framework allows for granting tokens different levels of permissions via scopes attached to roles. The 'only identify' purpose of the separate OAuth tokens is no longer required. API tokens can be used used for every action, including the login and authentication, for which an API token with no role (i.e., no scope in {ref}`available-scopes-target`) is used. The RBAC framework allows for granting tokens different levels of permissions via scopes attached to roles. The 'only identify' purpose of the separate OAuth tokens is no longer required. API tokens can be used for every action, including the login and authentication, for which an API token with no role (i.e., no scope in {ref}`available-scopes-target`) is used.
OAuth tokens are therefore dropped from the Hub upgraded with the RBAC framework. OAuth tokens are therefore dropped from the Hub upgraded with the RBAC framework.

View File

@@ -37,6 +37,15 @@ object-assign
* LICENSE file in the root directory of this source tree. * LICENSE file in the root directory of this source tree.
*/ */
/** @license React v17.0.2
* react-jsx-runtime.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/** @license React v17.0.2 /** @license React v17.0.2
* react.production.min.js * react.production.min.js
* *

View File

@@ -43,10 +43,11 @@
"lodash.debounce": "^4.0.8", "lodash.debounce": "^4.0.8",
"prop-types": "^15.7.2", "prop-types": "^15.7.2",
"react": "^17.0.1", "react": "^17.0.1",
"react-bootstrap": "^1.4.0", "react-bootstrap": "^2.1.1",
"react-dom": "^17.0.1", "react-dom": "^17.0.1",
"react-icons": "^4.1.0", "react-icons": "^4.1.0",
"react-multi-select-component": "^3.0.7", "react-multi-select-component": "^3.0.7",
"react-object-table-viewer": "^1.0.7",
"react-redux": "^7.2.2", "react-redux": "^7.2.2",
"react-router": "^5.2.0", "react-router": "^5.2.0",
"react-router-dom": "^5.2.0", "react-router-dom": "^5.2.0",
@@ -60,7 +61,6 @@
}, },
"devDependencies": { "devDependencies": {
"@wojtekmaj/enzyme-adapter-react-17": "^0.6.5", "@wojtekmaj/enzyme-adapter-react-17": "^0.6.5",
"sinon": "^13.0.1",
"babel-jest": "^26.6.3", "babel-jest": "^26.6.3",
"enzyme": "^3.11.0", "enzyme": "^3.11.0",
"eslint": "^7.18.0", "eslint": "^7.18.0",
@@ -68,6 +68,7 @@
"eslint-plugin-react": "^7.22.0", "eslint-plugin-react": "^7.22.0",
"identity-obj-proxy": "^3.0.0", "identity-obj-proxy": "^3.0.0",
"jest": "^26.6.3", "jest": "^26.6.3",
"prettier": "^2.2.1" "prettier": "^2.2.1",
"sinon": "^13.0.1"
} }
} }

View File

@@ -14,7 +14,7 @@ export const reducers = (state = initialState, action) => {
return Object.assign({}, state, { return Object.assign({}, state, {
user_page: action.value.page, user_page: action.value.page,
user_data: action.value.data, user_data: action.value.data,
name_filter: action.value.name_filter, name_filter: action.value.name_filter || "",
}); });
// Updates the client group model data and stores the page // Updates the client group model data and stores the page

View File

@@ -3,7 +3,17 @@ import regeneratorRuntime from "regenerator-runtime";
import { useSelector, useDispatch } from "react-redux"; import { useSelector, useDispatch } from "react-redux";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import { Button, Col, Row, FormControl } from "react-bootstrap"; import {
Button,
Col,
Row,
FormControl,
Card,
CardGroup,
Collapse,
} from "react-bootstrap";
import ReactObjectTableViewer from "react-object-table-viewer";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { FaSort, FaSortUp, FaSortDown } from "react-icons/fa"; import { FaSort, FaSortUp, FaSortDown } from "react-icons/fa";
@@ -40,6 +50,7 @@ const ServerDashboard = (props) => {
var [errorAlert, setErrorAlert] = useState(null); var [errorAlert, setErrorAlert] = useState(null);
var [sortMethod, setSortMethod] = useState(null); var [sortMethod, setSortMethod] = useState(null);
var [disabledButtons, setDisabledButtons] = useState({}); var [disabledButtons, setDisabledButtons] = useState({});
const [collapseStates, setCollapseStates] = useState({});
var user_data = useSelector((state) => state.user_data), var user_data = useSelector((state) => state.user_data),
user_page = useSelector((state) => state.user_page), user_page = useSelector((state) => state.user_page),
@@ -189,6 +200,124 @@ const ServerDashboard = (props) => {
); );
}; };
const ServerRowTable = ({ data }) => {
return (
<ReactObjectTableViewer
className="table-striped table-bordered"
style={{
padding: "3px 6px",
margin: "auto",
}}
keyStyle={{
padding: "4px",
}}
valueStyle={{
padding: "4px",
}}
data={data}
/>
);
};
const serverRow = (user, server) => {
const { servers, ...userNoServers } = user;
const serverNameDash = server.name ? `-${server.name}` : "";
const userServerName = user.name + serverNameDash;
const open = collapseStates[userServerName] || false;
return [
<tr key={`${userServerName}-row`} className="user-row">
<td data-testid="user-row-name">
<span>
<Button
onClick={() =>
setCollapseStates({
...collapseStates,
[userServerName]: !open,
})
}
aria-controls={`${userServerName}-collapse`}
aria-expanded={open}
data-testid={`${userServerName}-collapse-button`}
variant={open ? "secondary" : "primary"}
size="sm"
>
<span className="caret"></span>
</Button>{" "}
</span>
<span data-testid={`user-name-div-${userServerName}`}>
{user.name}
</span>
</td>
<td data-testid="user-row-admin">{user.admin ? "admin" : ""}</td>
<td data-testid="user-row-server">
{server.name ? (
<p className="text-secondary">{server.name}</p>
) : (
<p style={{ color: "lightgrey" }}>[MAIN]</p>
)}
</td>
<td data-testid="user-row-last-activity">
{server.last_activity ? timeSince(server.last_activity) : "Never"}
</td>
<td data-testid="user-row-server-activity">
{server.started ? (
// Stop Single-user server
<>
<StopServerButton serverName={server.name} userName={user.name} />
<AccessServerButton url={server.url} />
</>
) : (
// Start Single-user server
<>
<StartServerButton
serverName={server.name}
userName={user.name}
style={{ marginRight: 20 }}
/>
<a
href={`${base_url}spawn/${user.name}${
server.name && "/" + server.name
}`}
>
<button
className="btn btn-secondary btn-xs"
style={{ marginRight: 20 }}
>
Spawn Page
</button>
</a>
</>
)}
</td>
<EditUserCell user={user} />
</tr>,
<tr>
<td
colSpan={6}
style={{ padding: 0 }}
data-testid={`${userServerName}-td`}
>
<Collapse in={open} data-testid={`${userServerName}-collapse`}>
<CardGroup
id={`${userServerName}-card-group`}
style={{ width: "100%", margin: "0 auto", float: "none" }}
>
<Card style={{ width: "100%", padding: 3, margin: "0 auto" }}>
<Card.Title>User</Card.Title>
<ServerRowTable data={userNoServers} />
</Card>
<Card style={{ width: "100%", padding: 3, margin: "0 auto" }}>
<Card.Title>Server</Card.Title>
<ServerRowTable data={server} />
</Card>
</CardGroup>
</Collapse>
</td>
</tr>,
];
};
let servers = user_data.flatMap((user) => { let servers = user_data.flatMap((user) => {
let userServers = Object.values({ let userServers = Object.values({
"": user.server || {}, "": user.server || {},
@@ -225,7 +354,7 @@ const ServerDashboard = (props) => {
name="user_search" name="user_search"
placeholder="Search users" placeholder="Search users"
aria-label="user-search" aria-label="user-search"
value={name_filter} defaultValue={name_filter}
onChange={handleSearch} onChange={handleSearch}
/> />
</Col> </Col>
@@ -234,7 +363,7 @@ const ServerDashboard = (props) => {
<Link to="/groups">{"> Manage Groups"}</Link> <Link to="/groups">{"> Manage Groups"}</Link>
</Col> </Col>
</Row> </Row>
<table className="table table-striped table-bordered table-hover"> <table className="table table-bordered table-hover">
<thead className="admin-table-head"> <thead className="admin-table-head">
<tr> <tr>
<th id="user-header"> <th id="user-header">
@@ -373,63 +502,7 @@ const ServerDashboard = (props) => {
</Button> </Button>
</td> </td>
</tr> </tr>
{servers.map(([user, server], i) => { {servers.flatMap(([user, server]) => serverRow(user, server))}
server.name = server.name || "";
return (
<tr key={i + "row"} className="user-row">
<td data-testid="user-row-name">{user.name}</td>
<td data-testid="user-row-admin">
{user.admin ? "admin" : ""}
</td>
<td data-testid="user-row-server">
{server.name ? (
<p class="text-secondary">{server.name}</p>
) : (
<p style={{ color: "lightgrey" }}>[MAIN]</p>
)}
</td>
<td data-testid="user-row-last-activity">
{server.last_activity
? timeSince(server.last_activity)
: "Never"}
</td>
<td data-testid="user-row-server-activity">
{server.started ? (
// Stop Single-user server
<>
<StopServerButton
serverName={server.name}
userName={user.name}
/>
<AccessServerButton url={server.url} />
</>
) : (
// Start Single-user server
<>
<StartServerButton
serverName={server.name}
userName={user.name}
/>
<a
href={`${base_url}spawn/${user.name}${
server.name && "/" + server.name
}`}
>
<button
className="btn btn-secondary btn-xs"
style={{ marginRight: 20 }}
>
Spawn Page
</button>
</a>
</>
)}
</td>
<EditUserCell user={user} />
</tr>
);
})}
</tbody> </tbody>
</table> </table>
<PaginationFooter <PaginationFooter

View File

@@ -1,6 +1,7 @@
import React from "react"; import React from "react";
import "@testing-library/jest-dom"; import "@testing-library/jest-dom";
import { act } from "react-dom/test-utils"; import { act } from "react-dom/test-utils";
import userEvent from "@testing-library/user-event";
import { render, screen, fireEvent } from "@testing-library/react"; import { render, screen, fireEvent } from "@testing-library/react";
import { HashRouter, Switch } from "react-router-dom"; import { HashRouter, Switch } from "react-router-dom";
import { Provider, useSelector } from "react-redux"; import { Provider, useSelector } from "react-redux";
@@ -76,8 +77,8 @@ test("Renders users from props.user_data into table", async () => {
render(serverDashboardJsx(callbackSpy)); render(serverDashboardJsx(callbackSpy));
}); });
let foo = screen.getByText("foo"); let foo = screen.getByTestId("user-name-div-foo");
let bar = screen.getByText("bar"); let bar = screen.getByTestId("user-name-div-bar");
expect(foo).toBeVisible(); expect(foo).toBeVisible();
expect(bar).toBeVisible(); expect(bar).toBeVisible();
@@ -156,12 +157,12 @@ test("Sorts according to username", async () => {
fireEvent.click(handler); fireEvent.click(handler);
let first = screen.getAllByTestId("user-row-name")[0]; let first = screen.getAllByTestId("user-row-name")[0];
expect(first.textContent).toBe("bar"); expect(first.textContent).toContain("bar");
fireEvent.click(handler); fireEvent.click(handler);
first = screen.getAllByTestId("user-row-name")[0]; first = screen.getAllByTestId("user-row-name")[0];
expect(first.textContent).toBe("foo"); expect(first.textContent).toContain("foo");
}); });
test("Sorts according to admin", async () => { test("Sorts according to admin", async () => {
@@ -194,12 +195,12 @@ test("Sorts according to last activity", async () => {
fireEvent.click(handler); fireEvent.click(handler);
let first = screen.getAllByTestId("user-row-name")[0]; let first = screen.getAllByTestId("user-row-name")[0];
expect(first.textContent).toBe("foo"); expect(first.textContent).toContain("foo");
fireEvent.click(handler); fireEvent.click(handler);
first = screen.getAllByTestId("user-row-name")[0]; first = screen.getAllByTestId("user-row-name")[0];
expect(first.textContent).toBe("bar"); expect(first.textContent).toContain("bar");
}); });
test("Sorts according to server status (running/not running)", async () => { test("Sorts according to server status (running/not running)", async () => {
@@ -213,12 +214,53 @@ test("Sorts according to server status (running/not running)", async () => {
fireEvent.click(handler); fireEvent.click(handler);
let first = screen.getAllByTestId("user-row-name")[0]; let first = screen.getAllByTestId("user-row-name")[0];
expect(first.textContent).toBe("foo"); expect(first.textContent).toContain("foo");
fireEvent.click(handler); fireEvent.click(handler);
first = screen.getAllByTestId("user-row-name")[0]; first = screen.getAllByTestId("user-row-name")[0];
expect(first.textContent).toBe("bar"); expect(first.textContent).toContain("bar");
});
test("Shows server details with button click", async () => {
let callbackSpy = mockAsync();
await act(async () => {
render(serverDashboardJsx(callbackSpy));
});
let button = screen.getByTestId("foo-collapse-button");
let collapse = screen.getByTestId("foo-collapse");
let collapseBar = screen.getByTestId("bar-collapse");
// expect().toBeVisible does not work here with collapse.
expect(collapse).toHaveClass("collapse");
expect(collapse).not.toHaveClass("show");
expect(collapseBar).not.toHaveClass("show");
await act(async () => {
fireEvent.click(button);
});
clock.tick(400);
expect(collapse).toHaveClass("collapse show");
expect(collapseBar).not.toHaveClass("show");
await act(async () => {
fireEvent.click(button);
});
clock.tick(400);
expect(collapse).toHaveClass("collapse");
expect(collapse).not.toHaveClass("show");
expect(collapseBar).not.toHaveClass("show");
await act(async () => {
fireEvent.click(button);
});
clock.tick(400);
expect(collapse).toHaveClass("collapse show");
expect(collapseBar).not.toHaveClass("show");
}); });
test("Renders nothing if required data is not available", async () => { test("Renders nothing if required data is not available", async () => {
@@ -467,15 +509,15 @@ test("Search for user calls updateUsers with name filter", async () => {
let search = screen.getByLabelText("user-search"); let search = screen.getByLabelText("user-search");
fireEvent.change(search, { target: { value: "a" } }); userEvent.type(search, "a");
clock.tick(400);
expect(mockUpdateUsers.mock.calls).toHaveLength(2);
expect(mockUpdateUsers.mock.calls[1][2]).toEqual("a");
expect(search.value).toEqual("a"); expect(search.value).toEqual("a");
fireEvent.change(search, { target: { value: "ab" } });
clock.tick(400); clock.tick(400);
expect(mockUpdateUsers.mock.calls).toHaveLength(3); expect(mockUpdateUsers.mock.calls[1][2]).toEqual("a");
expect(mockUpdateUsers.mock.calls[2][2]).toEqual("ab"); expect(mockUpdateUsers.mock.calls).toHaveLength(2);
userEvent.type(search, "b");
expect(search.value).toEqual("ab"); expect(search.value).toEqual("ab");
clock.tick(400);
expect(mockUpdateUsers.mock.calls[2][2]).toEqual("ab");
expect(mockUpdateUsers.mock.calls).toHaveLength(3);
}); });

View File

@@ -4,7 +4,7 @@ import { jhapiRequest } from "./jhapiUtil";
const withAPI = withProps(() => ({ const withAPI = withProps(() => ({
updateUsers: (offset, limit, name_filter) => updateUsers: (offset, limit, name_filter) =>
jhapiRequest( jhapiRequest(
`/users?offset=${offset}&limit=${limit}&name_filter=${name_filter}`, `/users?offset=${offset}&limit=${limit}&name_filter=${name_filter || ""}`,
"GET" "GET"
).then((data) => data.json()), ).then((data) => data.json()),
updateGroups: (offset, limit) => updateGroups: (offset, limit) =>

View File

@@ -928,7 +928,7 @@
"@babel/plugin-transform-react-jsx-development" "^7.16.7" "@babel/plugin-transform-react-jsx-development" "^7.16.7"
"@babel/plugin-transform-react-pure-annotations" "^7.16.7" "@babel/plugin-transform-react-pure-annotations" "^7.16.7"
"@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.12.1", "@babel/runtime@^7.12.13", "@babel/runtime@^7.12.5", "@babel/runtime@^7.13.8", "@babel/runtime@^7.14.0", "@babel/runtime@^7.15.4", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.3", "@babel/runtime@^7.7.6", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.2": "@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.12.1", "@babel/runtime@^7.12.13", "@babel/runtime@^7.12.5", "@babel/runtime@^7.13.16", "@babel/runtime@^7.15.4", "@babel/runtime@^7.17.2", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.2", "@babel/runtime@^7.6.3", "@babel/runtime@^7.7.6", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.2":
version "7.17.7" version "7.17.7"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.17.7.tgz#a5f3328dc41ff39d803f311cfe17703418cf9825" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.17.7.tgz#a5f3328dc41ff39d803f311cfe17703418cf9825"
integrity sha512-L6rvG9GDxaLgFjg41K+5Yv9OMrU98sWe+Ykmc6FDJW/+vYZMhdOMKkISgzptMaERHvS2Y2lw9MDRm2gHhlQQoA== integrity sha512-L6rvG9GDxaLgFjg41K+5Yv9OMrU98sWe+Ykmc6FDJW/+vYZMhdOMKkISgzptMaERHvS2Y2lw9MDRm2gHhlQQoA==
@@ -1215,23 +1215,41 @@
"@jridgewell/resolve-uri" "^3.0.3" "@jridgewell/resolve-uri" "^3.0.3"
"@jridgewell/sourcemap-codec" "^1.4.10" "@jridgewell/sourcemap-codec" "^1.4.10"
"@popperjs/core@^2.8.6": "@popperjs/core@^2.10.1":
version "2.11.4" version "2.11.4"
resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.4.tgz#d8c7b8db9226d2d7664553a0741ad7d0397ee503" resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.4.tgz#d8c7b8db9226d2d7664553a0741ad7d0397ee503"
integrity sha512-q/ytXxO5NKvyT37pmisQAItCFqA7FD/vNb8dgaJy3/630Fsc+Mz9/9f2SziBoIZ30TJooXyTwZmhi1zjXmObYg== integrity sha512-q/ytXxO5NKvyT37pmisQAItCFqA7FD/vNb8dgaJy3/630Fsc+Mz9/9f2SziBoIZ30TJooXyTwZmhi1zjXmObYg==
"@restart/context@^2.1.4": "@react-aria/ssr@^3.0.1":
version "2.1.4" version "3.1.2"
resolved "https://registry.yarnpkg.com/@restart/context/-/context-2.1.4.tgz#a99d87c299a34c28bd85bb489cb07bfd23149c02" resolved "https://registry.yarnpkg.com/@react-aria/ssr/-/ssr-3.1.2.tgz#665a6fd56385068c7417922af2d0d71b0618e52d"
integrity sha512-INJYZQJP7g+IoDUh/475NlGiTeMfwTXUEr3tmRneckHIxNolGOW9CTq83S8cxq0CgJwwcMzMJFchxvlwe7Rk8Q== integrity sha512-amXY11ImpokvkTMeKRHjsSsG7v1yzzs6yeqArCyBIk60J3Yhgxwx9Cah+Uu/804ATFwqzN22AXIo7SdtIaMP+g==
dependencies:
"@babel/runtime" "^7.6.2"
"@restart/hooks@^0.3.26": "@restart/hooks@^0.4.0", "@restart/hooks@^0.4.5":
version "0.3.27" version "0.4.5"
resolved "https://registry.yarnpkg.com/@restart/hooks/-/hooks-0.3.27.tgz#91f356d66d4699a8cd8b3d008402708b6a9dc505" resolved "https://registry.yarnpkg.com/@restart/hooks/-/hooks-0.4.5.tgz#e7acbea237bfc9e479970500cf87538b41a1ed02"
integrity sha512-s984xV/EapUIfkjlf8wz9weP2O9TNKR96C68FfMEy2bE69+H4cNv3RD4Mf97lW7Htt7PjZrYTjSC8f3SB9VCXw== integrity sha512-tLGtY0aHeIfT7aPwUkvQuhIy3+q3w4iqmUzFLPlOAf/vNUacLaBt1j/S//jv/dQhenRh8jvswyMojCwmLvJw8A==
dependencies: dependencies:
dequal "^2.0.2" dequal "^2.0.2"
"@restart/ui@^1.0.2":
version "1.0.2"
resolved "https://registry.yarnpkg.com/@restart/ui/-/ui-1.0.2.tgz#009a06ae698d624672c5e6a776efd0e8a6017842"
integrity sha512-vKGe0UBJLnbvNAjr8ljlDvphf2HkpjBjXsblmgKPvKdZBDn/mtAz89wmznaomIaEJ9VNoSEY0vA5T5MDi2jIcQ==
dependencies:
"@babel/runtime" "^7.13.16"
"@popperjs/core" "^2.10.1"
"@react-aria/ssr" "^3.0.1"
"@restart/hooks" "^0.4.0"
"@types/warning" "^3.0.0"
dequal "^2.0.2"
dom-helpers "^5.2.0"
prop-types "^15.7.2"
uncontrollable "^7.2.1"
warning "^4.0.3"
"@sinonjs/commons@^1.6.0", "@sinonjs/commons@^1.7.0", "@sinonjs/commons@^1.8.3": "@sinonjs/commons@^1.6.0", "@sinonjs/commons@^1.7.0", "@sinonjs/commons@^1.8.3":
version "1.8.3" version "1.8.3"
resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.8.3.tgz#3802ddd21a50a949b6721ddd72da36e67e7f1b2d" resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.8.3.tgz#3802ddd21a50a949b6721ddd72da36e67e7f1b2d"
@@ -1399,7 +1417,7 @@
"@types/react" "*" "@types/react" "*"
hoist-non-react-statics "^3.3.0" hoist-non-react-statics "^3.3.0"
"@types/invariant@^2.2.33": "@types/invariant@^2.2.35":
version "2.2.35" version "2.2.35"
resolved "https://registry.yarnpkg.com/@types/invariant/-/invariant-2.2.35.tgz#cd3ebf581a6557452735688d8daba6cf0bd5a3be" resolved "https://registry.yarnpkg.com/@types/invariant/-/invariant-2.2.35.tgz#cd3ebf581a6557452735688d8daba6cf0bd5a3be"
integrity sha512-DxX1V9P8zdJPYQat1gHyY0xj3efl8gnMVjiM9iCY6y27lj+PoQWkgjt8jDqmovPqULkKVpKRg8J36iQiA+EtEg== integrity sha512-DxX1V9P8zdJPYQat1gHyY0xj3efl8gnMVjiM9iCY6y27lj+PoQWkgjt8jDqmovPqULkKVpKRg8J36iQiA+EtEg==
@@ -1456,7 +1474,7 @@
resolved "https://registry.yarnpkg.com/@types/prettier/-/prettier-2.4.4.tgz#5d9b63132df54d8909fce1c3f8ca260fdd693e17" resolved "https://registry.yarnpkg.com/@types/prettier/-/prettier-2.4.4.tgz#5d9b63132df54d8909fce1c3f8ca260fdd693e17"
integrity sha512-ReVR2rLTV1kvtlWFyuot+d1pkpG2Fw/XKE3PDAdj57rbM97ttSp9JZ2UsP+2EHTylra9cUf6JA7tGwW1INzUrA== integrity sha512-ReVR2rLTV1kvtlWFyuot+d1pkpG2Fw/XKE3PDAdj57rbM97ttSp9JZ2UsP+2EHTylra9cUf6JA7tGwW1INzUrA==
"@types/prop-types@*", "@types/prop-types@^15.7.3": "@types/prop-types@*", "@types/prop-types@^15.7.4":
version "15.7.4" version "15.7.4"
resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.4.tgz#fcf7205c25dff795ee79af1e30da2c9790808f11" resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.4.tgz#fcf7205c25dff795ee79af1e30da2c9790808f11"
integrity sha512-rZ5drC/jWjrArrS8BR6SIr4cWpW09RNTYt9AMZo3Jwwif+iacXAqgVjm0B0Bv/S1jhDXKHqRVNCbACkJ89RAnQ== integrity sha512-rZ5drC/jWjrArrS8BR6SIr4cWpW09RNTYt9AMZo3Jwwif+iacXAqgVjm0B0Bv/S1jhDXKHqRVNCbACkJ89RAnQ==
@@ -1478,7 +1496,7 @@
hoist-non-react-statics "^3.3.0" hoist-non-react-statics "^3.3.0"
redux "^4.0.0" redux "^4.0.0"
"@types/react-transition-group@^4.4.1": "@types/react-transition-group@^4.4.4":
version "4.4.4" version "4.4.4"
resolved "https://registry.yarnpkg.com/@types/react-transition-group/-/react-transition-group-4.4.4.tgz#acd4cceaa2be6b757db61ed7b432e103242d163e" resolved "https://registry.yarnpkg.com/@types/react-transition-group/-/react-transition-group-4.4.4.tgz#acd4cceaa2be6b757db61ed7b432e103242d163e"
integrity sha512-7gAPz7anVK5xzbeQW9wFBDg7G++aPLAFY0QaSMOou9rJZpbuI58WAuJrgu+qR92l61grlnCUe7AFX8KGahAgug== integrity sha512-7gAPz7anVK5xzbeQW9wFBDg7G++aPLAFY0QaSMOou9rJZpbuI58WAuJrgu+qR92l61grlnCUe7AFX8KGahAgug==
@@ -6167,30 +6185,29 @@ raw-body@2.4.3:
iconv-lite "0.4.24" iconv-lite "0.4.24"
unpipe "1.0.0" unpipe "1.0.0"
react-bootstrap@^1.4.0: react-bootstrap@^2.1.1:
version "1.6.4" version "2.2.1"
resolved "https://registry.yarnpkg.com/react-bootstrap/-/react-bootstrap-1.6.4.tgz#94d5d2422e26bba277656d3529128e14f838b7ca" resolved "https://registry.yarnpkg.com/react-bootstrap/-/react-bootstrap-2.2.1.tgz#2a6ad0931e9367882ec3fc88a70ed0b8ace90b26"
integrity sha512-z3BhBD4bEZuLP8VrYqAD7OT7axdcSkkyvWBWnS2U/4MhyabUihrUyucPWkan7aMI1XIHbmH4LCpEtzWGfx/yfA== integrity sha512-x8lpVQflsbevphuWbTnTNCatcbKyPJNrP2WyQ1MJYmFEcVjbTbai1yZhdlXr0QUxLQLxA8g5hQWb5TwJtaZoCA==
dependencies: dependencies:
"@babel/runtime" "^7.14.0" "@babel/runtime" "^7.17.2"
"@restart/context" "^2.1.4" "@restart/hooks" "^0.4.5"
"@restart/hooks" "^0.3.26" "@restart/ui" "^1.0.2"
"@types/invariant" "^2.2.33" "@types/invariant" "^2.2.35"
"@types/prop-types" "^15.7.3" "@types/prop-types" "^15.7.4"
"@types/react" ">=16.14.8" "@types/react" ">=16.14.8"
"@types/react-transition-group" "^4.4.1" "@types/react-transition-group" "^4.4.4"
"@types/warning" "^3.0.0" "@types/warning" "^3.0.0"
classnames "^2.3.1" classnames "^2.3.1"
dom-helpers "^5.2.1" dom-helpers "^5.2.1"
invariant "^2.2.4" invariant "^2.2.4"
prop-types "^15.7.2" prop-types "^15.8.1"
prop-types-extra "^1.1.0" prop-types-extra "^1.1.0"
react-overlays "^5.1.1" react-transition-group "^4.4.2"
react-transition-group "^4.4.1"
uncontrollable "^7.2.1" uncontrollable "^7.2.1"
warning "^4.0.3" warning "^4.0.3"
react-dom@^17.0.1: react-dom@17.0.2, react-dom@^17.0.1:
version "17.0.2" version "17.0.2"
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-17.0.2.tgz#ecffb6845e3ad8dbfcdc498f0d0a939736502c23" resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-17.0.2.tgz#ecffb6845e3ad8dbfcdc498f0d0a939736502c23"
integrity sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA== integrity sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==
@@ -6226,19 +6243,13 @@ react-multi-select-component@^3.0.7:
dependencies: dependencies:
goober "^2.0.30" goober "^2.0.30"
react-overlays@^5.1.1: react-object-table-viewer@^1.0.7:
version "5.1.1" version "1.0.7"
resolved "https://registry.yarnpkg.com/react-overlays/-/react-overlays-5.1.1.tgz#2e7cf49744b56537c7828ccb94cfc63dd778ae4f" resolved "https://registry.yarnpkg.com/react-object-table-viewer/-/react-object-table-viewer-1.0.7.tgz#31816021fa4526641c6b66bd9433ec9b78c2e472"
integrity sha512-eCN2s2/+GVZzpnId4XVWtvDPYYBD2EtOGP74hE+8yDskPzFy9+pV1H3ZZihxuRdEbQzzacySaaDkR7xE0ydl4Q== integrity sha512-OezCet8+BmEdJJHO5WGPFPRWXxw4Ls6HsV4Uh1kRPlmRXLOTNqWt/ZHmH8NhTl1BA9HkdhEegKVqc2b61wDMLg==
dependencies: dependencies:
"@babel/runtime" "^7.13.8" react "^17.0.2"
"@popperjs/core" "^2.8.6" react-dom "17.0.2"
"@restart/hooks" "^0.3.26"
"@types/warning" "^3.0.0"
dom-helpers "^5.2.0"
prop-types "^15.7.2"
uncontrollable "^7.2.1"
warning "^4.0.3"
react-redux@^7.2.2: react-redux@^7.2.2:
version "7.2.6" version "7.2.6"
@@ -6299,7 +6310,7 @@ react-test-renderer@^17.0.0:
react-shallow-renderer "^16.13.1" react-shallow-renderer "^16.13.1"
scheduler "^0.20.2" scheduler "^0.20.2"
react-transition-group@^4.4.1: react-transition-group@^4.4.2:
version "4.4.2" version "4.4.2"
resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-4.4.2.tgz#8b59a56f09ced7b55cbd53c36768b922890d5470" resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-4.4.2.tgz#8b59a56f09ced7b55cbd53c36768b922890d5470"
integrity sha512-/RNYfRAMlZwDSr6z4zNKV6xu53/e2BuaBbGhbyYIXTrmgu/bGHzmqOs7mJSJBHy9Ud+ApHx3QjrkKSp1pxvlFg== integrity sha512-/RNYfRAMlZwDSr6z4zNKV6xu53/e2BuaBbGhbyYIXTrmgu/bGHzmqOs7mJSJBHy9Ud+ApHx3QjrkKSp1pxvlFg==
@@ -6309,7 +6320,7 @@ react-transition-group@^4.4.1:
loose-envify "^1.4.0" loose-envify "^1.4.0"
prop-types "^15.6.2" prop-types "^15.6.2"
react@^17.0.1: react@^17.0.1, react@^17.0.2:
version "17.0.2" version "17.0.2"
resolved "https://registry.yarnpkg.com/react/-/react-17.0.2.tgz#d0b5cc516d29eb3eee383f75b62864cfb6800037" resolved "https://registry.yarnpkg.com/react/-/react-17.0.2.tgz#d0b5cc516d29eb3eee383f75b62864cfb6800037"
integrity sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA== integrity sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==

View File

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

View File

@@ -50,7 +50,7 @@ class GroupListAPIHandler(_GroupAPIHandler):
# the only valid filter is group=... # the only valid filter is group=...
# don't expand invalid !server=x to all groups! # don't expand invalid !server=x to all groups!
self.log.warning( self.log.warning(
"Invalid filter on list:group for {self.current_user}: {sub_scope}" f"Invalid filter on list:group for {self.current_user}: {sub_scope}"
) )
raise web.HTTPError(403) raise web.HTTPError(403)
query = query.filter(orm.Group.name.in_(sub_scope['group'])) query = query.filter(orm.Group.name.in_(sub_scope['group']))

View File

@@ -47,9 +47,8 @@ class ShutdownAPIHandler(APIHandler):
self.set_status(202) self.set_status(202)
self.finish(json.dumps({"message": "Shutting down Hub"})) self.finish(json.dumps({"message": "Shutting down Hub"}))
# stop the eventloop, which will trigger cleanup # instruct the app to stop, which will trigger cleanup
loop = IOLoop.current() app.stop()
loop.add_callback(loop.stop)
class RootAPIHandler(APIHandler): class RootAPIHandler(APIHandler):

View File

@@ -131,7 +131,7 @@ class UserListAPIHandler(APIHandler):
if not set(sub_scope).issubset({'group', 'user'}): if not set(sub_scope).issubset({'group', 'user'}):
# don't expand invalid !server=x filter to all users! # don't expand invalid !server=x filter to all users!
self.log.warning( self.log.warning(
"Invalid filter on list:user for {self.current_user}: {sub_scope}" f"Invalid filter on list:user for {self.current_user}: {sub_scope}"
) )
raise web.HTTPError(403) raise web.HTTPError(403)
filters = [] filters = []

View File

@@ -1689,7 +1689,9 @@ class JupyterHub(Application):
for authority, files in self.internal_ssl_authorities.items(): for authority, files in self.internal_ssl_authorities.items():
if files: if files:
self.log.info("Adding CA for %s", authority) self.log.info("Adding CA for %s", authority)
certipy.store.add_record(authority, is_ca=True, files=files) certipy.store.add_record(
authority, is_ca=True, files=files, overwrite=True
)
self.internal_trust_bundles = certipy.trust_from_graph( self.internal_trust_bundles = certipy.trust_from_graph(
self.internal_ssl_components_trust self.internal_ssl_components_trust
@@ -3241,9 +3243,15 @@ class JupyterHub(Application):
loop.make_current() loop.make_current()
loop.run_sync(self.cleanup) loop.run_sync(self.cleanup)
async def shutdown_cancel_tasks(self, sig): async def shutdown_cancel_tasks(self, sig=None):
"""Cancel all other tasks of the event loop and initiate cleanup""" """Cancel all other tasks of the event loop and initiate cleanup"""
self.log.critical("Received signal %s, initiating shutdown...", sig.name) if sig is None:
self.log.critical("Initiating shutdown...")
else:
self.log.critical("Received signal %s, initiating shutdown...", sig.name)
await self.cleanup()
tasks = [t for t in asyncio_all_tasks() if t is not asyncio_current_task()] tasks = [t for t in asyncio_all_tasks() if t is not asyncio_current_task()]
if tasks: if tasks:
@@ -3260,7 +3268,6 @@ class JupyterHub(Application):
tasks = [t for t in asyncio_all_tasks()] tasks = [t for t in asyncio_all_tasks()]
for t in tasks: for t in tasks:
self.log.debug("Task status: %s", t) self.log.debug("Task status: %s", t)
await self.cleanup()
asyncio.get_event_loop().stop() asyncio.get_event_loop().stop()
def stop(self): def stop(self):
@@ -3268,7 +3275,7 @@ class JupyterHub(Application):
return return
if self.http_server: if self.http_server:
self.http_server.stop() self.http_server.stop()
self.io_loop.add_callback(self.io_loop.stop) self.io_loop.add_callback(self.shutdown_cancel_tasks)
async def start_show_config(self): async def start_show_config(self):
"""Async wrapper around base start_show_config method""" """Async wrapper around base start_show_config method"""

View File

@@ -529,7 +529,7 @@ class BaseHandler(RequestHandler):
# clear_cookie only accepts a subset of set_cookie's kwargs # clear_cookie only accepts a subset of set_cookie's kwargs
clear_xsrf_cookie_kwargs = { clear_xsrf_cookie_kwargs = {
key: value key: value
for key, value in self.settings.get('xsrf_cookie_kwargs', {}) for key, value in self.settings.get('xsrf_cookie_kwargs', {}).items()
if key in {"path", "domain"} if key in {"path", "domain"}
} }
@@ -642,29 +642,32 @@ class BaseHandler(RequestHandler):
next_url = next_url.replace('\\', '%5C') next_url = next_url.replace('\\', '%5C')
proto = get_browser_protocol(self.request) proto = get_browser_protocol(self.request)
host = self.request.host host = self.request.host
if next_url.startswith("///"):
# strip more than 2 leading // down to 2
# because urlparse treats that as empty netloc,
# whereas browsers treat more than two leading // the same as //,
# so netloc is the first non-/ bit
next_url = "//" + next_url.lstrip("/")
parsed_next_url = urlparse(next_url)
if (next_url + '/').startswith((f'{proto}://{host}/', f'//{host}/',)) or ( if (next_url + '/').startswith((f'{proto}://{host}/', f'//{host}/',)) or (
self.subdomain_host self.subdomain_host
and urlparse(next_url).netloc and parsed_next_url.netloc
and ("." + urlparse(next_url).netloc).endswith( and ("." + parsed_next_url.netloc).endswith(
"." + urlparse(self.subdomain_host).netloc "." + urlparse(self.subdomain_host).netloc
) )
): ):
# treat absolute URLs for our host as absolute paths: # treat absolute URLs for our host as absolute paths:
# below, redirects that aren't strictly paths # below, redirects that aren't strictly paths are rejected
parsed = urlparse(next_url) next_url = parsed_next_url.path
next_url = parsed.path if parsed_next_url.query:
if parsed.query: next_url = next_url + '?' + parsed_next_url.query
next_url = next_url + '?' + parsed.query if parsed_next_url.fragment:
if parsed.fragment: next_url = next_url + '#' + parsed_next_url.fragment
next_url = next_url + '#' + parsed.fragment parsed_next_url = urlparse(next_url)
# if it still has host info, it didn't match our above check for *this* host # if it still has host info, it didn't match our above check for *this* host
if next_url and ( if next_url and (parsed_next_url.netloc or not next_url.startswith('/')):
'://' in next_url
or next_url.startswith('//')
or not next_url.startswith('/')
):
self.log.warning("Disallowing redirect outside JupyterHub: %r", next_url) self.log.warning("Disallowing redirect outside JupyterHub: %r", next_url)
next_url = '' next_url = ''

View File

@@ -498,7 +498,7 @@ class TokenPageHandler(BaseHandler):
continue continue
if not token.client_id: if not token.client_id:
# token should have been deleted when client was deleted # token should have been deleted when client was deleted
self.log.warning("Deleting stale oauth token {token}") self.log.warning(f"Deleting stale oauth token {token}")
self.db.delete(token) self.db.delete(token)
self.db.commit() self.db.commit()
continue continue

View File

@@ -536,9 +536,7 @@ class Hashed(Expiring):
prefix = token[: cls.prefix_length] prefix = token[: cls.prefix_length]
# since we can't filter on hashed values, filter on prefix # since we can't filter on hashed values, filter on prefix
# so we aren't comparing with all tokens # so we aren't comparing with all tokens
prefix_match = db.query(cls).filter( prefix_match = db.query(cls).filter_by(prefix=prefix)
bindparam('prefix', prefix).startswith(cls.prefix)
)
prefix_match = prefix_match.filter( prefix_match = prefix_match.filter(
or_(cls.expires_at == None, cls.expires_at >= cls.now()) or_(cls.expires_at == None, cls.expires_at >= cls.now())
) )

View File

@@ -29,9 +29,9 @@ else:
try: try:
App = import_item(JUPYTERHUB_SINGLEUSER_APP) App = import_item(JUPYTERHUB_SINGLEUSER_APP)
except ImportError as e: except ImportError as e:
continue
if _import_error is None: if _import_error is None:
_import_error = e _import_error = e
continue
else: else:
break break
if App is None: if App is None:

View File

@@ -182,6 +182,7 @@ page_template = """
<span> <span>
<a href='{{hub_control_panel_url}}' <a href='{{hub_control_panel_url}}'
id='jupyterhub-control-panel-link'
class='btn btn-default btn-sm navbar-btn pull-right' class='btn btn-default btn-sm navbar-btn pull-right'
style='margin-right: 4px; margin-left: 2px;'> style='margin-right: 4px; margin-left: 2px;'>
Control Panel Control Panel
@@ -633,8 +634,15 @@ class SingleUserNotebookAppMixin(Configurable):
# disable trash by default # disable trash by default
# this can be re-enabled by config # this can be re-enabled by config
self.config.FileContentsManager.delete_to_trash = False self.config.FileContentsManager.delete_to_trash = False
# load default-url env at higher priority than `@default`,
# which may have their own _defaults_ which should not override explicit default_url config
# via e.g. c.Spawner.default_url. Seen in jupyterlab's SingleUserLabApp.
default_url = os.environ.get("JUPYTERHUB_DEFAULT_URL")
if default_url:
self.config[self.__class__.__name__].default_url = default_url
self._log_app_versions() self._log_app_versions()
return super().initialize(argv) super().initialize(argv)
self.patch_templates()
def start(self): def start(self):
self.log.info("Starting jupyterhub-singleuser server version %s", __version__) self.log.info("Starting jupyterhub-singleuser server version %s", __version__)
@@ -705,7 +713,6 @@ class SingleUserNotebookAppMixin(Configurable):
# apply X-JupyterHub-Version to *all* request handlers (even redirects) # apply X-JupyterHub-Version to *all* request handlers (even redirects)
self.patch_default_headers() self.patch_default_headers()
self.patch_templates()
def page_config_hook(self, handler, page_config): def page_config_hook(self, handler, page_config):
"""JupyterLab page config hook """JupyterLab page config hook
@@ -738,19 +745,30 @@ class SingleUserNotebookAppMixin(Configurable):
) )
self.jinja_template_vars['hub_host'] = self.hub_host self.jinja_template_vars['hub_host'] = self.hub_host
self.jinja_template_vars['hub_prefix'] = self.hub_prefix self.jinja_template_vars['hub_prefix'] = self.hub_prefix
env = self.web_app.settings['jinja2_env'] self.jinja_template_vars[
'hub_control_panel_url'
] = self.hub_host + url_path_join(self.hub_prefix, 'home')
env.globals['hub_control_panel_url'] = self.hub_host + url_path_join( settings = self.web_app.settings
self.hub_prefix, 'home' # patch classic notebook jinja env
) jinja_envs = []
if 'jinja2_env' in settings:
# default jinja env (should we do this on jupyter-server, or only notebook?)
jinja_envs.append(settings['jinja2_env'])
if 'notebook_jinja2_env' in settings:
# when running with jupyter-server, classic notebook (nbclassic server extension)
# gets its own jinja env, which needs the same patch
jinja_envs.append(settings['notebook_jinja2_env'])
# patch jinja env loading to modify page template # patch jinja env loading to get modified template, only for base page.html
def get_page(name): def get_page(name):
if name == 'page.html': if name == 'page.html':
return page_template return page_template
orig_loader = env.loader for jinja_env in jinja_envs:
env.loader = ChoiceLoader([FunctionLoader(get_page), orig_loader]) jinja_env.loader = ChoiceLoader(
[FunctionLoader(get_page), jinja_env.loader]
)
def load_server_extensions(self): def load_server_extensions(self):
# Loading LabApp sets $JUPYTERHUB_API_TOKEN on load, which is incorrect # Loading LabApp sets $JUPYTERHUB_API_TOKEN on load, which is incorrect

View File

@@ -97,10 +97,15 @@ class Spawner(LoggingConfigurable):
Used in logging for consistency with named servers. Used in logging for consistency with named servers.
""" """
if self.name: if self.user:
return f'{self.user.name}:{self.name}' user_name = self.user.name
else: else:
return self.user.name # no user, only happens in mock tests
user_name = "(no user)"
if self.name:
return f"{user_name}:{self.name}"
else:
return user_name
@property @property
def _failed(self): def _failed(self):
@@ -228,7 +233,7 @@ class Spawner(LoggingConfigurable):
self.orm_spawner.server = server.orm_server self.orm_spawner.server = server.orm_server
elif server is not None: elif server is not None:
self.log.warning( self.log.warning(
"Setting Spawner.server for {self._log_name} with no underlying orm_spawner" f"Setting Spawner.server for {self._log_name} with no underlying orm_spawner"
) )
@property @property
@@ -871,9 +876,6 @@ class Spawner(LoggingConfigurable):
if self.server: if self.server:
base_url = self.server.base_url base_url = self.server.base_url
if self.ip or self.port:
self.server.ip = self.ip
self.server.port = self.port
env['JUPYTERHUB_SERVICE_PREFIX'] = self.server.base_url env['JUPYTERHUB_SERVICE_PREFIX'] = self.server.base_url
else: else:
# this should only occur in mock/testing scenarios # this should only occur in mock/testing scenarios

View File

@@ -57,12 +57,14 @@ from .utils import add_user
_db = None _db = None
def pytest_collection_modifyitems(items): def _pytest_collection_modifyitems(items):
"""This function is automatically run by pytest passing all collected test """This function is automatically run by pytest passing all collected test
functions. functions.
We use it to add asyncio marker to all async tests and assert we don't use We use it to add asyncio marker to all async tests and assert we don't use
test functions that are async generators which wouldn't make sense. test functions that are async generators which wouldn't make sense.
It is no longer required with pytest-asyncio >= 0.17
""" """
for item in items: for item in items:
if inspect.iscoroutinefunction(item.obj): if inspect.iscoroutinefunction(item.obj):
@@ -70,6 +72,13 @@ def pytest_collection_modifyitems(items):
assert not inspect.isasyncgenfunction(item.obj) assert not inspect.isasyncgenfunction(item.obj)
if sys.version_info < (3, 7):
# apply pytest-asyncio's 'auto' mode on Python 3.6.
# 'auto' mode is new in pytest-asyncio 0.17,
# which requires Python 3.7.
pytest_collection_modifyitems = _pytest_collection_modifyitems
@fixture(scope='module') @fixture(scope='module')
def ssl_tmpdir(tmpdir_factory): def ssl_tmpdir(tmpdir_factory):
return tmpdir_factory.mktemp('ssl') return tmpdir_factory.mktemp('ssl')
@@ -182,6 +191,8 @@ def cleanup_after(request, io_loop):
if not MockHub.initialized(): if not MockHub.initialized():
return return
app = MockHub.instance() app = MockHub.instance()
if app.db_file.closed:
return
for uid, user in list(app.users.items()): for uid, user in list(app.users.items()):
for name, spawner in list(user.spawners.items()): for name, spawner in list(user.spawners.items()):
if spawner.active: if spawner.active:

View File

@@ -333,26 +333,28 @@ class MockHub(JupyterHub):
roles.assign_default_roles(self.db, entity=user) roles.assign_default_roles(self.db, entity=user)
self.db.commit() self.db.commit()
def stop(self): _stop_called = False
super().stop()
def stop(self):
if self._stop_called:
return
self._stop_called = True
# run cleanup in a background thread # run cleanup in a background thread
# to avoid multiple eventloops in the same thread errors from asyncio # to avoid multiple eventloops in the same thread errors from asyncio
def cleanup(): def cleanup():
asyncio.set_event_loop(asyncio.new_event_loop()) loop = asyncio.new_event_loop()
loop = IOLoop.current() loop.run_until_complete(self.cleanup())
loop.run_sync(self.cleanup)
loop.close() loop.close()
pool = ThreadPoolExecutor(1) with ThreadPoolExecutor(1) as pool:
f = pool.submit(cleanup) f = pool.submit(cleanup)
# wait for cleanup to finish # wait for cleanup to finish
f.result() f.result()
pool.shutdown()
# ignore the call that will fire in atexit # prevent redundant atexit from running
self.cleanup = lambda: None self._atexit_ran = True
super().stop()
self.db_file.close() self.db_file.close()
async def login_user(self, name): async def login_user(self, name):

View File

@@ -2104,14 +2104,23 @@ def test_shutdown(app):
) )
return r return r
real_stop = loop.stop real_stop = loop.asyncio_loop.stop
def stop(): def stop():
stop.called = True stop.called = True
loop.call_later(1, real_stop) loop.call_later(1, real_stop)
with mock.patch.object(loop, 'stop', stop): real_cleanup = app.cleanup
def cleanup():
cleanup.called = True
return real_cleanup()
app.cleanup = cleanup
with mock.patch.object(loop.asyncio_loop, 'stop', stop):
r = loop.run_sync(shutdown, timeout=5) r = loop.run_sync(shutdown, timeout=5)
r.raise_for_status() r.raise_for_status()
reply = r.json() reply = r.json()
assert cleanup.called
assert stop.called assert stop.called

View File

@@ -15,7 +15,6 @@ from unittest.mock import patch
import pytest import pytest
import traitlets import traitlets
from distutils.version import LooseVersion as V
from traitlets.config import Config from traitlets.config import Config
from .. import orm from .. import orm
@@ -33,7 +32,7 @@ def test_help_all():
assert '--JupyterHub.ip' in out assert '--JupyterHub.ip' in out
@pytest.mark.skipif(V(traitlets.__version__) < V('5'), reason="requires traitlets 5") @pytest.mark.skipif(traitlets.version_info < (5,), reason="requires traitlets 5")
def test_show_config(tmpdir): def test_show_config(tmpdir):
tmpdir.chdir() tmpdir.chdir()
p = Popen( p = Popen(

View File

@@ -771,6 +771,10 @@ async def test_login_strip(app):
(False, '/user/other', '/hub/user/other', None), (False, '/user/other', '/hub/user/other', None),
(False, '/absolute', '/absolute', None), (False, '/absolute', '/absolute', None),
(False, '/has?query#andhash', '/has?query#andhash', None), (False, '/has?query#andhash', '/has?query#andhash', None),
# :// in query string or fragment
(False, '/has?repo=https/host.git', '/has?repo=https/host.git', None),
(False, '/has?repo=https://host.git', '/has?repo=https://host.git', None),
(False, '/has#repo=https://host.git', '/has#repo=https://host.git', None),
# next_url outside is not allowed # next_url outside is not allowed
(False, 'relative/path', '', None), (False, 'relative/path', '', None),
(False, 'https://other.domain', '', None), (False, 'https://other.domain', '', None),
@@ -810,7 +814,9 @@ async def test_login_redirect(app, running, next_url, location, params):
if params: if params:
url = url_concat(url, params) url = url_concat(url, params)
if next_url: if next_url:
if '//' not in next_url and next_url.startswith('/'): if next_url.startswith('/') and not (
next_url.startswith("//") or urlparse(next_url).netloc
):
next_url = ujoin(app.base_url, next_url, '') next_url = ujoin(app.base_url, next_url, '')
url = url_concat(url, dict(next=next_url)) url = url_concat(url, dict(next=next_url))

View File

@@ -5,9 +5,11 @@ from contextlib import contextmanager
from subprocess import CalledProcessError from subprocess import CalledProcessError
from subprocess import check_output from subprocess import check_output
from unittest import mock from unittest import mock
from urllib.parse import urlencode
from urllib.parse import urlparse from urllib.parse import urlparse
import pytest import pytest
from bs4 import BeautifulSoup
import jupyterhub import jupyterhub
from .. import orm from .. import orm
@@ -16,6 +18,7 @@ from .mocking import public_url
from .mocking import StubSingleUserSpawner from .mocking import StubSingleUserSpawner
from .utils import async_requests from .utils import async_requests
from .utils import AsyncSession from .utils import AsyncSession
from .utils import get_page
@contextmanager @contextmanager
@@ -225,3 +228,22 @@ def test_singleuser_app_class(JUPYTERHUB_SINGLEUSER_APP):
else: else:
assert '--ServerApp.' in out assert '--ServerApp.' in out
assert '--NotebookApp.' not in out assert '--NotebookApp.' not in out
async def test_nbclassic_control_panel(app, user):
# use StubSingleUserSpawner to launch a single-user app in a thread
app.spawner_class = StubSingleUserSpawner
app.tornado_settings['spawner_class'] = StubSingleUserSpawner
# login, start the server
await user.spawn()
cookies = await app.login_user(user.name)
next_url = url_path_join(user.url, "tree/")
url = '/?' + urlencode({'next': next_url})
r = await get_page(url, app, cookies=cookies)
r.raise_for_status()
assert urlparse(r.url).path == urlparse(next_url).path
page = BeautifulSoup(r.text, "html.parser")
link = page.find("a", id="jupyterhub-control-panel-link")
assert link, f"Missing jupyterhub-control-panel-link in {page}"
assert link["href"] == url_path_join(app.base_url, "hub/home")

View File

@@ -282,9 +282,9 @@ class User:
new_groups = set(group_names).difference(current_groups) new_groups = set(group_names).difference(current_groups)
removed_groups = current_groups.difference(group_names) removed_groups = current_groups.difference(group_names)
if new_groups: if new_groups:
self.log.info("Adding user {self.name} to group(s): {new_groups}") self.log.info(f"Adding user {self.name} to group(s): {new_groups}")
if removed_groups: if removed_groups:
self.log.info("Removing user {self.name} from group(s): {removed_groups}") self.log.info(f"Removing user {self.name} from group(s): {removed_groups}")
if group_names: if group_names:
groups = ( groups = (
@@ -812,7 +812,7 @@ class User:
e.reason = 'timeout' e.reason = 'timeout'
self.settings['statsd'].incr('spawner.failure.timeout') self.settings['statsd'].incr('spawner.failure.timeout')
else: else:
self.log.error( self.log.exception(
"Unhandled error starting {user}'s server: {error}".format( "Unhandled error starting {user}'s server: {error}".format(
user=self.name, error=e user=self.name, error=e
) )
@@ -822,7 +822,7 @@ class User:
try: try:
await self.stop(spawner.name) await self.stop(spawner.name)
except Exception: except Exception:
self.log.error( self.log.exception(
"Failed to cleanup {user}'s server that failed to start".format( "Failed to cleanup {user}'s server that failed to start".format(
user=self.name user=self.name
), ),
@@ -870,7 +870,7 @@ class User:
self.settings['statsd'].incr('spawner.failure.http_timeout') self.settings['statsd'].incr('spawner.failure.http_timeout')
else: else:
e.reason = 'error' e.reason = 'error'
self.log.error( self.log.exception(
"Unhandled error waiting for {user}'s server to show up at {url}: {error}".format( "Unhandled error waiting for {user}'s server to show up at {url}: {error}".format(
user=self.name, url=server.url, error=e user=self.name, url=server.url, error=e
) )
@@ -879,7 +879,7 @@ class User:
try: try:
await self.stop(spawner.name) await self.stop(spawner.name)
except Exception: except Exception:
self.log.error( self.log.exception(
"Failed to cleanup {user}'s server that failed to start".format( "Failed to cleanup {user}'s server that failed to start".format(
user=self.name user=self.name
), ),

View File

@@ -1,9 +1,13 @@
[tool.black] [tool.black]
skip-string-normalization = true skip-string-normalization = true
# target-version should be all supported versions, see
# https://github.com/psf/black/issues/751#issuecomment-473066811
target_version = [ target_version = [
"py36", "py36",
"py37", "py37",
"py38", "py38",
"py39",
"py310",
] ]
[tool.tbump] [tool.tbump]
@@ -11,7 +15,7 @@ target_version = [
github_url = "https://github.com/jupyterhub/jupyterhub" github_url = "https://github.com/jupyterhub/jupyterhub"
[tool.tbump.version] [tool.tbump.version]
current = "2.3.0.dev" current = "2.3.1"
# Example of a semver regexp. # Example of a semver regexp.
# Make sure this matches current_version before # Make sure this matches current_version before

View File

@@ -3,6 +3,9 @@
# so we have to disable this until pytest 3.11 # so we have to disable this until pytest 3.11
# minversion = 3.3 # minversion = 3.3
# automatically run coroutine tests with asyncio
asyncio_mode = auto
# jupyter_server plugin is incompatible with notebook imports # jupyter_server plugin is incompatible with notebook imports
addopts = -p no:jupyter_server addopts = -p no:jupyter_server

File diff suppressed because one or more lines are too long