Merge branch 'jupyterhub:main' into group_property_feature

This commit is contained in:
Vlad Vifor
2022-09-22 12:34:30 +02:00
committed by GitHub
16 changed files with 286 additions and 83 deletions

View File

@@ -35,13 +35,14 @@ RUN apt-get update \
python3-dev \ python3-dev \
python3-pip \ python3-pip \
python3-pycurl \ python3-pycurl \
python3-venv \
nodejs \ nodejs \
npm \ npm \
yarn \
&& apt-get clean \ && apt-get clean \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
RUN python3 -m pip install --upgrade setuptools pip wheel RUN python3 -m pip install --upgrade setuptools pip build wheel
RUN npm install --global yarn
# copy everything except whats in .dockerignore, its a # copy everything except whats in .dockerignore, its a
# compromise between needing to rebuild and maintaining # compromise between needing to rebuild and maintaining
@@ -51,7 +52,7 @@ WORKDIR /src/jupyterhub
# Build client component packages (they will be copied into ./share and # Build client component packages (they will be copied into ./share and
# packaged with the built wheel.) # packaged with the built wheel.)
RUN python3 setup.py bdist_wheel RUN python3 -m build --wheel
RUN python3 -m pip wheel --wheel-dir wheelhouse dist/*.whl RUN python3 -m pip wheel --wheel-dir wheelhouse dist/*.whl

View File

@@ -4,6 +4,11 @@ from jupyterhub._data import DATA_FILES_PATH
print(f"DATA_FILES_PATH={DATA_FILES_PATH}") print(f"DATA_FILES_PATH={DATA_FILES_PATH}")
for sub_path in ("templates", "static/components", "static/css/style.min.css"): for sub_path in (
"templates",
"static/components",
"static/css/style.min.css",
"static/js/admin-react.js",
):
path = os.path.join(DATA_FILES_PATH, sub_path) path = os.path.join(DATA_FILES_PATH, sub_path)
assert os.path.exists(path), path assert os.path.exists(path), path

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: 3.0.0b1 version: 3.1.0.dev
servers: servers:
- url: /hub/api - url: /hub/api
security: security:

File diff suppressed because one or more lines are too long

View File

@@ -1,23 +1,48 @@
export const initialState = { export const initialState = {
user_data: undefined, user_data: undefined,
user_page: 0, user_page: { offset: 0, limit: window.api_page_limit || 100 },
name_filter: "", name_filter: "",
groups_data: undefined, groups_data: undefined,
groups_page: 0, groups_page: { offset: 0, limit: window.api_page_limit || 100 },
limit: window.api_page_limit, limit: window.api_page_limit || 100,
}; };
export const reducers = (state = initialState, action) => { export const reducers = (state = initialState, action) => {
switch (action.type) { switch (action.type) {
// Updates the client user model data and stores the page // Updates the client user model data and stores the page
case "USER_OFFSET":
return Object.assign({}, state, {
user_page: Object.assign({}, state.user_page, {
offset: action.value.offset,
}),
});
case "USER_NAME_FILTER":
// set offset to 0 if name filter changed,
// otherwise leave it alone
const newOffset =
action.value.name_filter !== state.name_filter ? 0 : state.name_filter;
return Object.assign({}, state, {
user_page: Object.assign({}, state.user_page, {
offset: newOffset,
}),
name_filter: action.value.name_filter,
});
case "USER_PAGE": case "USER_PAGE":
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 || "",
}); });
// Updates the client group model data and stores the page // Updates the client group user model data and stores the page
case "GROUPS_OFFSET":
return Object.assign({}, state, {
groups_page: Object.assign({}, state.groups_page, {
offset: action.value.offset,
}),
});
case "GROUPS_PAGE": case "GROUPS_PAGE":
return Object.assign({}, state, { return Object.assign({}, state, {
groups_page: action.value.page, groups_page: action.value.page,

View File

@@ -10,7 +10,16 @@ const Groups = (props) => {
groups_page = useSelector((state) => state.groups_page), groups_page = useSelector((state) => state.groups_page),
dispatch = useDispatch(); dispatch = useDispatch();
var [offset, setOffset] = useState(groups_page ? groups_page.offset : 0); var offset = groups_page ? groups_page.offset : 0;
const setOffset = (offset) => {
dispatch({
type: "GROUPS_OFFSET",
value: {
offset: offset,
},
});
};
var limit = groups_page ? groups_page.limit : window.api_page_limit; var limit = groups_page ? groups_page.limit : window.api_page_limit;
var total = groups_page ? groups_page.total : undefined; var total = groups_page ? groups_page.total : undefined;

View File

@@ -8,52 +8,65 @@ import { HashRouter } from "react-router-dom";
// eslint-disable-next-line // eslint-disable-next-line
import regeneratorRuntime from "regenerator-runtime"; import regeneratorRuntime from "regenerator-runtime";
import { initialState, reducers } from "../../Store";
import Groups from "./Groups"; import Groups from "./Groups";
jest.mock("react-redux", () => ({ jest.mock("react-redux", () => ({
...jest.requireActual("react-redux"), ...jest.requireActual("react-redux"),
useSelector: jest.fn(), useSelector: jest.fn(),
useDispatch: jest.fn(),
})); }));
var mockAsync = () => var mockAsync = () =>
jest.fn().mockImplementation(() => Promise.resolve({ key: "value" })); jest.fn().mockImplementation(() => Promise.resolve({ key: "value" }));
var groupsJsx = (callbackSpy) => ( var groupsJsx = (callbackSpy) => (
<Provider store={createStore(() => {}, {})}> <Provider store={createStore(mockReducers, mockAppState())}>
<HashRouter> <HashRouter>
<Groups location={{ search: "0" }} updateGroups={callbackSpy} /> <Groups location={{ search: "0" }} updateGroups={callbackSpy} />
</HashRouter> </HashRouter>
</Provider> </Provider>
); );
var mockAppState = () => ({ var mockReducers = jest.fn((state, action) => {
groups_data: JSON.parse( if (action.type === "GROUPS_PAGE" && !action.value.data) {
'[{"kind":"group","name":"testgroup","users":[]}, {"kind":"group","name":"testgroup2","users":["foo", "bar"]}]' // no-op from mock, don't update state
), return state;
groups_page: { }
offset: 0, state = reducers(state, action);
limit: 2, // mocked useSelector seems to cause a problem
total: 4, // this should get the right state back?
next: { // not sure
offset: 2, // useSelector.mockImplementation((callback) => callback(state);
limit: 2, return state;
url: "http://localhost:8000/hub/api/groups?offset=2&limit=2",
},
},
}); });
var mockAppState = () =>
Object.assign({}, initialState, {
groups_data: [
{ kind: "group", name: "testgroup", users: [] },
{ kind: "group", name: "testgroup2", users: ["foo", "bar"] },
],
groups_page: {
offset: 0,
limit: 2,
total: 4,
next: {
offset: 2,
limit: 2,
url: "http://localhost:8000/hub/api/groups?offset=2&limit=2",
},
},
});
beforeEach(() => { beforeEach(() => {
useSelector.mockImplementation((callback) => { useSelector.mockImplementation((callback) => {
return callback(mockAppState()); return callback(mockAppState());
}); });
useDispatch.mockImplementation(() => {
return () => {};
});
}); });
afterEach(() => { afterEach(() => {
useSelector.mockClear(); useSelector.mockClear();
mockReducers.mockClear();
}); });
test("Renders", async () => { test("Renders", async () => {
@@ -104,8 +117,20 @@ test("Interacting with PaginationFooter causes state update and refresh via useE
expect(callbackSpy).toBeCalledWith(0, 2); expect(callbackSpy).toBeCalledWith(0, 2);
var lastState =
mockReducers.mock.results[mockReducers.mock.results.length - 1].value;
expect(lastState.groups_page.offset).toEqual(0);
expect(lastState.groups_page.limit).toEqual(2);
let next = screen.getByTestId("paginate-next"); let next = screen.getByTestId("paginate-next");
fireEvent.click(next); fireEvent.click(next);
expect(callbackSpy).toHaveBeenCalledWith(2, 2); lastState =
mockReducers.mock.results[mockReducers.mock.results.length - 1].value;
expect(lastState.groups_page.offset).toEqual(2);
expect(lastState.groups_page.limit).toEqual(2);
// FIXME: mocked useSelector, state seem to prevent updateGroups from being called
// making the test environment not representative
// expect(callbackSpy).toHaveBeenCalledWith(2, 2);
}); });

View File

@@ -56,7 +56,7 @@ const ServerDashboard = (props) => {
user_page = useSelector((state) => state.user_page), user_page = useSelector((state) => state.user_page),
name_filter = useSelector((state) => state.name_filter); name_filter = useSelector((state) => state.name_filter);
var [offset, setOffset] = useState(user_page ? user_page.offset : 0); var offset = user_page ? user_page.offset : 0;
var limit = user_page ? user_page.limit : window.api_page_limit; var limit = user_page ? user_page.limit : window.api_page_limit;
var total = user_page ? user_page.total : undefined; var total = user_page ? user_page.total : undefined;
@@ -72,12 +72,29 @@ const ServerDashboard = (props) => {
history, history,
} = props; } = props;
var dispatchPageUpdate = (data, page, name_filter) => { const dispatchPageUpdate = (data, page) => {
dispatch({ dispatch({
type: "USER_PAGE", type: "USER_PAGE",
value: { value: {
data: data, data: data,
page: page, page: page,
},
});
};
const setOffset = (newOffset) => {
dispatch({
type: "USER_OFFSET",
value: {
offset: newOffset,
},
});
};
const setNameFilter = (name_filter) => {
dispatch({
type: "USER_NAME_FILTER",
value: {
name_filter: name_filter, name_filter: name_filter,
}, },
}); });
@@ -85,24 +102,18 @@ const ServerDashboard = (props) => {
useEffect(() => { useEffect(() => {
updateUsers(offset, limit, name_filter) updateUsers(offset, limit, name_filter)
.then((data) => .then((data) => dispatchPageUpdate(data.items, data._pagination))
dispatchPageUpdate(data.items, data._pagination, name_filter)
)
.catch((err) => setErrorAlert("Failed to update user list.")); .catch((err) => setErrorAlert("Failed to update user list."));
}, [offset, limit]); }, [offset, limit, name_filter]);
if (!user_data || !user_page) { if (!user_data || !user_page) {
return <div data-testid="no-show"></div>; return <div data-testid="no-show"></div>;
} }
let page = offset / limit;
var slice = [offset, limit, name_filter]; var slice = [offset, limit, name_filter];
const handleSearch = debounce(async (event) => { const handleSearch = debounce(async (event) => {
// setNameFilter(event.target.value); setNameFilter(event.target.value);
updateUsers(offset, limit, event.target.value).then((data) =>
dispatchPageUpdate(data.items, data._pagination, name_filter)
);
}, 300); }, 300);
if (sortMethod != null) { if (sortMethod != null) {

View File

@@ -10,6 +10,7 @@ import { createStore } from "redux";
import regeneratorRuntime from "regenerator-runtime"; import regeneratorRuntime from "regenerator-runtime";
import ServerDashboard from "./ServerDashboard"; import ServerDashboard from "./ServerDashboard";
import { initialState, reducers } from "../../Store";
import * as sinon from "sinon"; import * as sinon from "sinon";
let clock; let clock;
@@ -20,7 +21,7 @@ jest.mock("react-redux", () => ({
})); }));
var serverDashboardJsx = (spy) => ( var serverDashboardJsx = (spy) => (
<Provider store={createStore(() => {}, {})}> <Provider store={createStore(mockReducers, mockAppState())}>
<HashRouter> <HashRouter>
<Switch> <Switch>
<ServerDashboard <ServerDashboard
@@ -42,20 +43,67 @@ var mockAsync = (data) =>
var mockAsyncRejection = () => var mockAsyncRejection = () =>
jest.fn().mockImplementation(() => Promise.reject()); jest.fn().mockImplementation(() => Promise.reject());
var mockAppState = () => ({ var mockAppState = () =>
user_data: JSON.parse( Object.assign({}, initialState, {
'[{"kind":"user","name":"foo","admin":true,"groups":[],"server":"/user/foo/","pending":null,"created":"2020-12-07T18:46:27.112695Z","last_activity":"2020-12-07T21:00:33.336354Z","servers":{"":{"name":"","last_activity":"2020-12-07T20:58:02.437408Z","started":"2020-12-07T20:58:01.508266Z","pending":null,"ready":true,"state":{"pid":28085},"url":"/user/foo/","user_options":{},"progress_url":"/hub/api/users/foo/server/progress"}}},{"kind":"user","name":"bar","admin":false,"groups":[],"server":null,"pending":null,"created":"2020-12-07T18:46:27.115528Z","last_activity":"2020-12-07T20:43:51.013613Z","servers":{}}]' user_data: [
), {
user_page: { kind: "user",
offset: 0, name: "foo",
limit: 2, admin: true,
total: 4, groups: [],
next: { server: "/user/foo/",
offset: 2, pending: null,
created: "2020-12-07T18:46:27.112695Z",
last_activity: "2020-12-07T21:00:33.336354Z",
servers: {
"": {
name: "",
last_activity: "2020-12-07T20:58:02.437408Z",
started: "2020-12-07T20:58:01.508266Z",
pending: null,
ready: true,
state: { pid: 28085 },
url: "/user/foo/",
user_options: {},
progress_url: "/hub/api/users/foo/server/progress",
},
},
},
{
kind: "user",
name: "bar",
admin: false,
groups: [],
server: null,
pending: null,
created: "2020-12-07T18:46:27.115528Z",
last_activity: "2020-12-07T20:43:51.013613Z",
servers: {},
},
],
user_page: {
offset: 0,
limit: 2, limit: 2,
url: "http://localhost:8000/hub/api/groups?offset=2&limit=2", total: 4,
next: {
offset: 2,
limit: 2,
url: "http://localhost:8000/hub/api/groups?offset=2&limit=2",
},
}, },
}, });
var mockReducers = jest.fn((state, action) => {
if (action.type === "USER_PAGE" && !action.value.data) {
// no-op from mock, don't update state
return state;
}
state = reducers(state, action);
// mocked useSelector seems to cause a problem
// this should get the right state back?
// not sure
// useSelector.mockImplementation((callback) => callback(state);
return state;
}); });
beforeEach(() => { beforeEach(() => {
@@ -67,6 +115,7 @@ beforeEach(() => {
afterEach(() => { afterEach(() => {
useSelector.mockClear(); useSelector.mockClear();
mockReducers.mockClear();
clock.restore(); clock.restore();
}); });
@@ -508,11 +557,22 @@ test("Shows a UI error dialogue when stop user server returns an improper status
test("Search for user calls updateUsers with name filter", async () => { test("Search for user calls updateUsers with name filter", async () => {
let spy = mockAsync(); let spy = mockAsync();
let mockUpdateUsers = jest.fn((offset, limit, name_filter) => { let mockUpdateUsers = jest.fn((offset, limit, name_filter) => {
return Promise.resolve([]); return Promise.resolve({
items: [],
_pagination: {
offset: offset,
limit: limit,
total: offset + limit * 2,
next: {
offset: offset + limit,
limit: limit,
},
},
});
}); });
await act(async () => { await act(async () => {
render( render(
<Provider store={createStore(() => {}, {})}> <Provider store={createStore(mockReducers, mockAppState())}>
<HashRouter> <HashRouter>
<Switch> <Switch>
<ServerDashboard <ServerDashboard
@@ -531,17 +591,25 @@ test("Search for user calls updateUsers with name filter", async () => {
let search = screen.getByLabelText("user-search"); let search = screen.getByLabelText("user-search");
expect(mockUpdateUsers.mock.calls).toHaveLength(1);
userEvent.type(search, "a"); userEvent.type(search, "a");
expect(search.value).toEqual("a"); expect(search.value).toEqual("a");
clock.tick(400); clock.tick(400);
expect(mockUpdateUsers.mock.calls[1][2]).toEqual("a"); expect(mockReducers.mock.calls).toHaveLength(3);
expect(mockUpdateUsers.mock.calls).toHaveLength(2); var lastState =
mockReducers.mock.results[mockReducers.mock.results.length - 1].value;
expect(lastState.name_filter).toEqual("a");
// TODO: this should
expect(mockUpdateUsers.mock.calls).toHaveLength(1);
userEvent.type(search, "b"); userEvent.type(search, "b");
expect(search.value).toEqual("ab"); expect(search.value).toEqual("ab");
clock.tick(400); clock.tick(400);
expect(mockUpdateUsers.mock.calls[2][2]).toEqual("ab"); expect(mockReducers.mock.calls).toHaveLength(4);
expect(mockUpdateUsers.mock.calls).toHaveLength(3); lastState =
mockReducers.mock.results[mockReducers.mock.results.length - 1].value;
expect(lastState.name_filter).toEqual("ab");
expect(lastState.user_page.offset).toEqual(0);
}); });
test("Interacting with PaginationFooter causes state update and refresh via useEffect call", async () => { test("Interacting with PaginationFooter causes state update and refresh via useEffect call", async () => {
@@ -551,10 +619,28 @@ test("Interacting with PaginationFooter causes state update and refresh via useE
render(serverDashboardJsx(callbackSpy)); render(serverDashboardJsx(callbackSpy));
}); });
expect(callbackSpy).toBeCalledWith(0, 2, undefined); expect(callbackSpy).toBeCalledWith(0, 2, "");
expect(mockReducers.mock.results).toHaveLength(2);
lastState =
mockReducers.mock.results[mockReducers.mock.results.length - 1].value;
console.log(lastState);
expect(lastState.user_page.offset).toEqual(0);
expect(lastState.user_page.limit).toEqual(2);
let next = screen.getByTestId("paginate-next"); let next = screen.getByTestId("paginate-next");
fireEvent.click(next); fireEvent.click(next);
clock.tick(400);
expect(callbackSpy).toHaveBeenCalledWith(2, 2, undefined); expect(mockReducers.mock.results).toHaveLength(3);
var lastState =
mockReducers.mock.results[mockReducers.mock.results.length - 1].value;
expect(lastState.user_page.offset).toEqual(2);
expect(lastState.user_page.limit).toEqual(2);
// FIXME: should call updateUsers, does in reality.
// tests don't reflect reality due to mocked state/useSelector
// unclear how to fix this.
// expect(callbackSpy.mock.calls).toHaveLength(2);
// expect(callbackSpy).toHaveBeenCalledWith(2, 2, "");
}); });

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 = (3, 0, 0, "b1", "") version_info = (3, 1, 0, "", "dev")
# 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

@@ -16,6 +16,7 @@ import sys
import time import time
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from functools import partial
from getpass import getuser from getpass import getuser
from operator import itemgetter from operator import itemgetter
from textwrap import dedent from textwrap import dedent
@@ -3315,7 +3316,7 @@ class JupyterHub(Application):
loop = IOLoop(make_current=False) loop = IOLoop(make_current=False)
try: try:
loop.run_sync(self.launch_instance_async, argv) loop.run_sync(partial(self.launch_instance_async, argv))
except Exception: except Exception:
loop.close() loop.close()
raise raise

View File

@@ -1,4 +1,5 @@
"""Test the JupyterHub entry point""" """Test the JupyterHub entry point"""
import asyncio
import binascii import binascii
import json import json
import logging import logging
@@ -399,3 +400,33 @@ def test_hub_routespec(
assert "may not receive" in caplog.text assert "may not receive" in caplog.text
else: else:
assert "may not receive" not in caplog.text assert "may not receive" not in caplog.text
@pytest.mark.parametrize(
"argv, sys_argv",
[
(None, ["jupyterhub", "--debug", "--port=1234"]),
(["--log-level=INFO"], ["jupyterhub"]),
],
)
def test_launch_instance(request, argv, sys_argv):
class DummyHub(JupyterHub):
def launch_instance_async(self, argv):
# short-circuit initialize
# by indicating we are going to generate config in start
self.generate_config = True
return super().launch_instance_async(argv)
async def start(self):
asyncio.get_running_loop().stop()
DummyHub.clear_instance()
request.addfinalizer(DummyHub.clear_instance)
with patch.object(sys, "argv", sys_argv):
DummyHub.launch_instance(argv)
hub = DummyHub.instance()
if argv is None:
assert hub.argv == sys_argv[1:]
else:
assert hub.argv == argv

View File

@@ -29,12 +29,12 @@ async def test_userdict_get(db, attr):
["isin1", "isin2"], ["isin1", "isin2"],
["isin1"], ["isin1"],
["notin", "isin1"], ["notin", "isin1"],
["new-group", "isin1"], ["new-group", "new-group", "isin1"],
[], [],
], ],
) )
def test_sync_groups(app, user, group_names): def test_sync_groups(app, user, group_names):
expected = sorted(group_names) expected = sorted(set(group_names))
db = app.db db = app.db
db.add(orm.Group(name="notin")) db.add(orm.Group(name="notin"))
in_groups = [orm.Group(name="isin1"), orm.Group(name="isin2")] in_groups = [orm.Group(name="isin1"), orm.Group(name="isin2")]

View File

@@ -310,19 +310,19 @@ class User:
return return
# log group changes # log group changes
new_groups = set(group_names).difference(current_groups) added_groups = new_groups.difference(current_groups)
removed_groups = current_groups.difference(group_names) removed_groups = current_groups.difference(group_names)
if new_groups: if added_groups:
self.log.info(f"Adding user {self.name} to group(s): {new_groups}") self.log.info(f"Adding user {self.name} to group(s): {added_groups}")
if removed_groups: if removed_groups:
self.log.info(f"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 = (
self.db.query(orm.Group).filter(orm.Group.name.in_(group_names)).all() self.db.query(orm.Group).filter(orm.Group.name.in_(new_groups)).all()
) )
existing_groups = {g.name for g in groups} existing_groups = {g.name for g in groups}
for group_name in group_names: for group_name in added_groups:
if group_name not in existing_groups: if group_name not in existing_groups:
# create groups that don't exist yet # create groups that don't exist yet
self.log.info( self.log.info(
@@ -331,9 +331,9 @@ class User:
group = orm.Group(name=group_name) group = orm.Group(name=group_name)
self.db.add(group) self.db.add(group)
groups.append(group) groups.append(group)
self.groups = groups self.orm_user.groups = groups
else: else:
self.groups = [] self.orm_user.groups = []
self.db.commit() self.db.commit()
async def save_auth_state(self, auth_state): async def save_auth_state(self, auth_state):

View File

@@ -17,7 +17,7 @@ target_version = [
github_url = "https://github.com/jupyterhub/jupyterhub" github_url = "https://github.com/jupyterhub/jupyterhub"
[tool.tbump.version] [tool.tbump.version]
current = "3.0.0b1" current = "3.1.0.dev"
# 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

@@ -24,9 +24,10 @@
{% block expiration_options %} {% block expiration_options %}
<select id="token-expiration-seconds" <select id="token-expiration-seconds"
class="form-control"> class="form-control">
<option value="3600">1 Day</option> <!-- unit used for each value is `seconds` -->
<option value="86400">1 Week</option> <option value="3600">1 Hour</option>
<option value="604800">1 Month</option> <option value="86400">1 Day</option>
<option value="604800">1 Week</option>
<option value="" selected="selected">Never</option> <option value="" selected="selected">Never</option>
</select> </select>
{% endblock expiration_options %} {% endblock expiration_options %}