diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a1ca71df..4365e793 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -11,34 +11,34 @@ repos: # Autoformat: Python code, syntax patterns are modernized - repo: https://github.com/asottile/pyupgrade - rev: v2.31.1 + rev: v2.32.0 hooks: - id: pyupgrade args: - --py36-plus # Autoformat: Python code - - repo: https://github.com/asottile/reorder_python_imports - rev: v3.0.1 + - repo: https://github.com/pycqa/isort + rev: 5.10.1 hooks: - - id: reorder-python-imports + - id: isort # Autoformat: Python code - repo: https://github.com/psf/black - rev: 22.1.0 + rev: 22.3.0 hooks: - id: black args: [--target-version=py36] # Autoformat: markdown, yaml, javascript (see the file .prettierignore) - repo: https://github.com/pre-commit/mirrors-prettier - rev: v2.6.1 + rev: v2.6.2 hooks: - id: prettier # Autoformat and linting, misc. details - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.1.0 + rev: v4.2.0 hooks: - id: end-of-file-fixer exclude: share/jupyterhub/static/js/admin-react.js diff --git a/README.md b/README.md index a8146365..0a579be5 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ JupyterHub also provides a [REST API][] 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 diff --git a/docs/source/conf.py b/docs/source/conf.py index 2c6dc76e..d69b3cee 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -56,13 +56,15 @@ todo_include_todos = False # Set the default role so we can use `foo` instead of ``foo`` default_role = 'literal' -# -- Config ------------------------------------------------------------- -from jupyterhub.app import JupyterHub -from docutils import nodes -from sphinx.directives.other import SphinxDirective from contextlib import redirect_stdout from io import StringIO +from docutils import nodes +from sphinx.directives.other import SphinxDirective + +# -- Config ------------------------------------------------------------- +from jupyterhub.app import JupyterHub + # create a temp instance of JupyterHub just to get the output of the generate-config # and help --all commands. jupyterhub_app = JupyterHub() diff --git a/examples/azuread-with-group-management/jupyterhub_config.py b/examples/azuread-with-group-management/jupyterhub_config.py index f8da8746..32355013 100644 --- a/examples/azuread-with-group-management/jupyterhub_config.py +++ b/examples/azuread-with-group-management/jupyterhub_config.py @@ -7,9 +7,10 @@ to enable testing without administrative privileges. c = get_config() # noqa c.Application.log_level = 'DEBUG' -from oauthenticator.azuread import AzureAdOAuthenticator import os +from oauthenticator.azuread import AzureAdOAuthenticator + c.JupyterHub.authenticator_class = AzureAdOAuthenticator c.AzureAdOAuthenticator.client_id = os.getenv("AAD_CLIENT_ID") diff --git a/examples/custom-scopes/grades.py b/examples/custom-scopes/grades.py index 4b805264..f1f8be54 100644 --- a/examples/custom-scopes/grades.py +++ b/examples/custom-scopes/grades.py @@ -5,12 +5,9 @@ from urllib.parse import urlparse from tornado.httpserver import HTTPServer from tornado.ioloop import IOLoop -from tornado.web import Application -from tornado.web import authenticated -from tornado.web import RequestHandler +from tornado.web import Application, RequestHandler, authenticated -from jupyterhub.services.auth import HubOAuthCallbackHandler -from jupyterhub.services.auth import HubOAuthenticated +from jupyterhub.services.auth import HubOAuthCallbackHandler, HubOAuthenticated from jupyterhub.utils import url_path_join SCOPE_PREFIX = "custom:grades" diff --git a/examples/external-oauth/whoami-oauth-basic.py b/examples/external-oauth/whoami-oauth-basic.py index 2aca9f55..a89f8db7 100644 --- a/examples/external-oauth/whoami-oauth-basic.py +++ b/examples/external-oauth/whoami-oauth-basic.py @@ -5,13 +5,10 @@ so all URLs and requests necessary for OAuth with JupyterHub should be in one pl """ import json import os -from urllib.parse import urlencode -from urllib.parse import urlparse +from urllib.parse import urlencode, urlparse -from tornado import log -from tornado import web -from tornado.httpclient import AsyncHTTPClient -from tornado.httpclient import HTTPRequest +from tornado import log, web +from tornado.httpclient import AsyncHTTPClient, HTTPRequest from tornado.httputil import url_concat from tornado.ioloop import IOLoop diff --git a/examples/server-api/start-stop-server.py b/examples/server-api/start-stop-server.py index ae680535..d20e1530 100644 --- a/examples/server-api/start-stop-server.py +++ b/examples/server-api/start-stop-server.py @@ -16,7 +16,6 @@ import time import requests - log = logging.getLogger(__name__) diff --git a/examples/service-announcement/announcement.py b/examples/service-announcement/announcement.py index 6a50c33d..5354c70c 100644 --- a/examples/service-announcement/announcement.py +++ b/examples/service-announcement/announcement.py @@ -3,9 +3,7 @@ import datetime import json import os -from tornado import escape -from tornado import ioloop -from tornado import web +from tornado import escape, ioloop, web from jupyterhub.services.auth import HubAuthenticated diff --git a/examples/service-fastapi/app/models.py b/examples/service-fastapi/app/models.py index 33fdab68..fad8e1e8 100644 --- a/examples/service-fastapi/app/models.py +++ b/examples/service-fastapi/app/models.py @@ -1,8 +1,5 @@ from datetime import datetime -from typing import Any -from typing import Dict -from typing import List -from typing import Optional +from typing import Any, Dict, List, Optional from pydantic import BaseModel diff --git a/examples/service-fastapi/app/security.py b/examples/service-fastapi/app/security.py index 62b2ea5e..63fd2a5e 100644 --- a/examples/service-fastapi/app/security.py +++ b/examples/service-fastapi/app/security.py @@ -1,9 +1,7 @@ import json import os -from fastapi import HTTPException -from fastapi import Security -from fastapi import status +from fastapi import HTTPException, Security, status from fastapi.security import OAuth2AuthorizationCodeBearer from fastapi.security.api_key import APIKeyQuery diff --git a/examples/service-fastapi/app/service.py b/examples/service-fastapi/app/service.py index da3f8545..81d7b87d 100644 --- a/examples/service-fastapi/app/service.py +++ b/examples/service-fastapi/app/service.py @@ -1,14 +1,9 @@ import os -from fastapi import APIRouter -from fastapi import Depends -from fastapi import Form -from fastapi import Request +from fastapi import APIRouter, Depends, Form, Request from .client import get_client -from .models import AuthorizationError -from .models import HubApiError -from .models import User +from .models import AuthorizationError, HubApiError, User from .security import get_current_user # APIRouter prefix cannot end in / diff --git a/examples/service-whoami-flask/whoami-flask.py b/examples/service-whoami-flask/whoami-flask.py index 139925c9..2e08391b 100644 --- a/examples/service-whoami-flask/whoami-flask.py +++ b/examples/service-whoami-flask/whoami-flask.py @@ -7,16 +7,10 @@ import os import secrets from functools import wraps -from flask import Flask -from flask import make_response -from flask import redirect -from flask import request -from flask import Response -from flask import session +from flask import Flask, Response, make_response, redirect, request, session from jupyterhub.services.auth import HubOAuth - prefix = os.environ.get('JUPYTERHUB_SERVICE_PREFIX', '/') auth = HubOAuth(api_token=os.environ['JUPYTERHUB_API_TOKEN'], cache_max_age=60) diff --git a/examples/service-whoami/whoami-oauth.py b/examples/service-whoami/whoami-oauth.py index b2158e51..41466231 100644 --- a/examples/service-whoami/whoami-oauth.py +++ b/examples/service-whoami/whoami-oauth.py @@ -10,12 +10,9 @@ from urllib.parse import urlparse from tornado.httpserver import HTTPServer from tornado.ioloop import IOLoop -from tornado.web import Application -from tornado.web import authenticated -from tornado.web import RequestHandler +from tornado.web import Application, RequestHandler, authenticated -from jupyterhub.services.auth import HubOAuthCallbackHandler -from jupyterhub.services.auth import HubOAuthenticated +from jupyterhub.services.auth import HubOAuthCallbackHandler, HubOAuthenticated from jupyterhub.utils import url_path_join diff --git a/examples/service-whoami/whoami.py b/examples/service-whoami/whoami.py index 435ff329..da79d9ad 100644 --- a/examples/service-whoami/whoami.py +++ b/examples/service-whoami/whoami.py @@ -10,9 +10,7 @@ from urllib.parse import urlparse from tornado.httpserver import HTTPServer from tornado.ioloop import IOLoop -from tornado.web import Application -from tornado.web import authenticated -from tornado.web import RequestHandler +from tornado.web import Application, RequestHandler, authenticated from jupyterhub.services.auth import HubAuthenticated diff --git a/jsx/src/components/ServerDashboard/ServerDashboard.jsx b/jsx/src/components/ServerDashboard/ServerDashboard.jsx index 9ba4720f..9057fa6f 100644 --- a/jsx/src/components/ServerDashboard/ServerDashboard.jsx +++ b/jsx/src/components/ServerDashboard/ServerDashboard.jsx @@ -361,7 +361,7 @@ const ServerDashboard = (props) => { name="user_search" placeholder="Search users" aria-label="user-search" - value={name_filter} + defaultValue={name_filter} onChange={handleSearch} /> diff --git a/jsx/src/components/ServerDashboard/ServerDashboard.test.js b/jsx/src/components/ServerDashboard/ServerDashboard.test.js index 180feab6..12976b75 100644 --- a/jsx/src/components/ServerDashboard/ServerDashboard.test.js +++ b/jsx/src/components/ServerDashboard/ServerDashboard.test.js @@ -1,6 +1,7 @@ import React from "react"; import "@testing-library/jest-dom"; import { act } from "react-dom/test-utils"; +import userEvent from "@testing-library/user-event"; import { render, screen, fireEvent } from "@testing-library/react"; import { HashRouter, Switch } from "react-router-dom"; import { Provider, useSelector } from "react-redux"; @@ -508,15 +509,15 @@ test("Search for user calls updateUsers with name filter", async () => { let search = screen.getByLabelText("user-search"); - fireEvent.change(search, { target: { value: "a" } }); - clock.tick(400); - expect(mockUpdateUsers.mock.calls).toHaveLength(2); - expect(mockUpdateUsers.mock.calls[1][2]).toEqual("a"); + userEvent.type(search, "a"); expect(search.value).toEqual("a"); - - fireEvent.change(search, { target: { value: "ab" } }); clock.tick(400); - expect(mockUpdateUsers.mock.calls).toHaveLength(3); - expect(mockUpdateUsers.mock.calls[2][2]).toEqual("ab"); + expect(mockUpdateUsers.mock.calls[1][2]).toEqual("a"); + expect(mockUpdateUsers.mock.calls).toHaveLength(2); + + userEvent.type(search, "b"); expect(search.value).toEqual("ab"); + clock.tick(400); + expect(mockUpdateUsers.mock.calls[2][2]).toEqual("ab"); + expect(mockUpdateUsers.mock.calls).toHaveLength(3); }); diff --git a/jupyterhub/__init__.py b/jupyterhub/__init__.py index 5e882401..948f47a6 100644 --- a/jupyterhub/__init__.py +++ b/jupyterhub/__init__.py @@ -1,2 +1 @@ -from ._version import __version__ -from ._version import version_info +from ._version import __version__, version_info diff --git a/jupyterhub/_data.py b/jupyterhub/_data.py index 04452d18..d665f193 100644 --- a/jupyterhub/_data.py +++ b/jupyterhub/_data.py @@ -4,7 +4,7 @@ def get_data_files(): """Walk up until we find share/jupyterhub""" import sys - from os.path import join, abspath, dirname, exists, split + from os.path import abspath, dirname, exists, join, split path = abspath(dirname(__file__)) starting_points = [path] diff --git a/jupyterhub/_memoize.py b/jupyterhub/_memoize.py new file mode 100644 index 00000000..21907b1d --- /dev/null +++ b/jupyterhub/_memoize.py @@ -0,0 +1,154 @@ +"""Utilities for memoization + +Note: a memoized function should always return an _immutable_ +result to avoid later modifications polluting cached results. +""" +from collections import OrderedDict +from functools import wraps + + +class DoNotCache: + """Wrapper to return a result without caching it. + + In a function decorated with `@lru_cache_key`: + + return DoNotCache(result) + + is equivalent to: + + return result # but don't cache it! + """ + + def __init__(self, result): + self.result = result + + +class LRUCache: + """A simple Least-Recently-Used (LRU) cache with a max size""" + + def __init__(self, maxsize=1024): + self._cache = OrderedDict() + self.maxsize = maxsize + + def __contains__(self, key): + return key in self._cache + + def get(self, key, default=None): + """Get an item from the cache""" + if key in self._cache: + # cache hit, bump to front of the queue for LRU + result = self._cache[key] + self._cache.move_to_end(key) + return result + return default + + def set(self, key, value): + """Store an entry in the cache + + Purges oldest entry if cache is full + """ + self._cache[key] = value + # cache is full, purge oldest entry + if len(self._cache) > self.maxsize: + self._cache.popitem(last=False) + + __getitem__ = get + __setitem__ = set + + +def lru_cache_key(key_func, maxsize=1024): + """Like functools.lru_cache, but takes a custom key function, + as seen in sorted(key=func). + + Useful for non-hashable arguments which have a known hashable equivalent (e.g. sets, lists), + or mutable objects where only immutable fields might be used + (e.g. User, where only username affects output). + + For safety: Cached results should always be immutable, + such as using `frozenset` instead of mutable `set`. + + Example: + + @lru_cache_key(lambda user: user.name) + def func_user(user): + # output only varies by name + + Args: + key (callable): + Should have the same signature as the decorated function. + Returns a hashable key to use in the cache + maxsize (int): + The maximum size of the cache. + """ + + def cache_func(func): + cache = LRUCache(maxsize=maxsize) + # the actual decorated function: + @wraps(func) + def cached(*args, **kwargs): + cache_key = key_func(*args, **kwargs) + if cache_key in cache: + # cache hit + return cache[cache_key] + else: + # cache miss, call function and cache result + result = func(*args, **kwargs) + if isinstance(result, DoNotCache): + # DoNotCache prevents caching + result = result.result + else: + cache[cache_key] = result + return result + + return cached + + return cache_func + + +class FrozenDict(dict): + """A frozen dictionary subclass + + Immutable and hashable, so it can be used as a cache key + + Values will be frozen with `.freeze(value)` + and must be hashable after freezing. + + Not rigorous, but enough for our purposes. + """ + + _hash = None + + def __init__(self, d): + dict_set = dict.__setitem__ + for key, value in d.items(): + dict.__setitem__(self, key, self._freeze(value)) + + def _freeze(self, item): + """Make values of a dict hashable + - list, set -> frozenset + - dict -> recursive _FrozenDict + - anything else: assumed hashable + """ + if isinstance(item, FrozenDict): + return item + elif isinstance(item, list): + return tuple(self._freeze(e) for e in item) + elif isinstance(item, set): + return frozenset(item) + elif isinstance(item, dict): + return FrozenDict(item) + else: + # any other type is assumed hashable + return item + + def __setitem__(self, key): + raise RuntimeError("Cannot modify frozen {type(self).__name__}") + + def update(self, other): + raise RuntimeError("Cannot modify frozen {type(self).__name__}") + + def __hash__(self): + """Cache hash because we are immutable""" + if self._hash is None: + self._hash = hash(tuple((key, value) for key, value in self.items())) + return self._hash diff --git a/jupyterhub/alembic/env.py b/jupyterhub/alembic/env.py index 30dfbd92..8b4cba1b 100644 --- a/jupyterhub/alembic/env.py +++ b/jupyterhub/alembic/env.py @@ -3,8 +3,7 @@ import sys from logging.config import fileConfig from alembic import context -from sqlalchemy import engine_from_config -from sqlalchemy import pool +from sqlalchemy import engine_from_config, pool # this is the Alembic Config object, which provides # access to the values within the .ini file in use. @@ -13,6 +12,7 @@ config = context.config # This line sets up loggers basically. if 'jupyterhub' in sys.modules: from traitlets.config import MultipleInstanceError + from jupyterhub.app import JupyterHub app = None diff --git a/jupyterhub/alembic/versions/19c0846f6344_base_revision_for_0_5.py b/jupyterhub/alembic/versions/19c0846f6344_base_revision_for_0_5.py index 5aa2b46d..9ee5a24a 100644 --- a/jupyterhub/alembic/versions/19c0846f6344_base_revision_for_0_5.py +++ b/jupyterhub/alembic/versions/19c0846f6344_base_revision_for_0_5.py @@ -11,8 +11,8 @@ down_revision = None branch_labels = None depends_on = None -from alembic import op import sqlalchemy as sa +from alembic import op def upgrade(): diff --git a/jupyterhub/alembic/versions/1cebaf56856c_session_id.py b/jupyterhub/alembic/versions/1cebaf56856c_session_id.py index 14c4d68b..38f37b0b 100644 --- a/jupyterhub/alembic/versions/1cebaf56856c_session_id.py +++ b/jupyterhub/alembic/versions/1cebaf56856c_session_id.py @@ -15,8 +15,8 @@ import logging logger = logging.getLogger('alembic') -from alembic import op import sqlalchemy as sa +from alembic import op tables = ('oauth_access_tokens', 'oauth_codes') diff --git a/jupyterhub/alembic/versions/3ec6993fe20c_encrypted_auth_state.py b/jupyterhub/alembic/versions/3ec6993fe20c_encrypted_auth_state.py index 8e234318..73d8bc44 100644 --- a/jupyterhub/alembic/versions/3ec6993fe20c_encrypted_auth_state.py +++ b/jupyterhub/alembic/versions/3ec6993fe20c_encrypted_auth_state.py @@ -22,8 +22,9 @@ import logging logger = logging.getLogger('alembic') -from alembic import op import sqlalchemy as sa +from alembic import op + from jupyterhub.orm import JSONDict diff --git a/jupyterhub/alembic/versions/4dc2d5a8c53c_user_options.py b/jupyterhub/alembic/versions/4dc2d5a8c53c_user_options.py index 87417570..617a2d6d 100644 --- a/jupyterhub/alembic/versions/4dc2d5a8c53c_user_options.py +++ b/jupyterhub/alembic/versions/4dc2d5a8c53c_user_options.py @@ -11,8 +11,9 @@ down_revision = '896818069c98' branch_labels = None depends_on = None -from alembic import op import sqlalchemy as sa +from alembic import op + from jupyterhub.orm import JSONDict diff --git a/jupyterhub/alembic/versions/56cc5a70207e_token_tracking.py b/jupyterhub/alembic/versions/56cc5a70207e_token_tracking.py index 6f2a2efa..0c74c09d 100644 --- a/jupyterhub/alembic/versions/56cc5a70207e_token_tracking.py +++ b/jupyterhub/alembic/versions/56cc5a70207e_token_tracking.py @@ -11,11 +11,11 @@ down_revision = '1cebaf56856c' branch_labels = None depends_on = None -from alembic import op -import sqlalchemy as sa - import logging +import sqlalchemy as sa +from alembic import op + logger = logging.getLogger('alembic') diff --git a/jupyterhub/alembic/versions/651f5419b74d_api_token_scopes.py b/jupyterhub/alembic/versions/651f5419b74d_api_token_scopes.py index bda81a2b..05677d4e 100644 --- a/jupyterhub/alembic/versions/651f5419b74d_api_token_scopes.py +++ b/jupyterhub/alembic/versions/651f5419b74d_api_token_scopes.py @@ -13,17 +13,12 @@ depends_on = None import sqlalchemy as sa from alembic import op -from sqlalchemy import Column -from sqlalchemy import ForeignKey -from sqlalchemy import Integer -from sqlalchemy import Table -from sqlalchemy import Unicode +from sqlalchemy import Column, ForeignKey, Integer, Table, Unicode from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import relationship from sqlalchemy.orm.session import Session -from jupyterhub import orm -from jupyterhub import roles +from jupyterhub import orm, roles def upgrade(): diff --git a/jupyterhub/alembic/versions/833da8570507_rbac.py b/jupyterhub/alembic/versions/833da8570507_rbac.py index 2abecea9..ec3325ec 100644 --- a/jupyterhub/alembic/versions/833da8570507_rbac.py +++ b/jupyterhub/alembic/versions/833da8570507_rbac.py @@ -12,12 +12,11 @@ down_revision = '4dc2d5a8c53c' branch_labels = None depends_on = None -from alembic import op import sqlalchemy as sa +from alembic import op from jupyterhub import orm - naming_convention = orm.meta.naming_convention diff --git a/jupyterhub/alembic/versions/896818069c98_token_expires.py b/jupyterhub/alembic/versions/896818069c98_token_expires.py index b3f62411..e8d1a84d 100644 --- a/jupyterhub/alembic/versions/896818069c98_token_expires.py +++ b/jupyterhub/alembic/versions/896818069c98_token_expires.py @@ -11,8 +11,8 @@ down_revision = 'd68c98b66cd4' branch_labels = None depends_on = None -from alembic import op import sqlalchemy as sa +from alembic import op def upgrade(): diff --git a/jupyterhub/alembic/versions/99a28a4418e1_user_created.py b/jupyterhub/alembic/versions/99a28a4418e1_user_created.py index 738ecbe3..42ca4b0b 100644 --- a/jupyterhub/alembic/versions/99a28a4418e1_user_created.py +++ b/jupyterhub/alembic/versions/99a28a4418e1_user_created.py @@ -12,11 +12,11 @@ branch_labels = None depends_on = None -from alembic import op -import sqlalchemy as sa - from datetime import datetime +import sqlalchemy as sa +from alembic import op + def upgrade(): op.add_column('users', sa.Column('created', sa.DateTime, nullable=True)) diff --git a/jupyterhub/alembic/versions/af4cbdb2d13c_services.py b/jupyterhub/alembic/versions/af4cbdb2d13c_services.py index a0f5e8e9..b33da18e 100644 --- a/jupyterhub/alembic/versions/af4cbdb2d13c_services.py +++ b/jupyterhub/alembic/versions/af4cbdb2d13c_services.py @@ -11,8 +11,8 @@ down_revision = 'eeb276e51423' branch_labels = None depends_on = None -from alembic import op import sqlalchemy as sa +from alembic import op def upgrade(): diff --git a/jupyterhub/alembic/versions/d68c98b66cd4_client_description.py b/jupyterhub/alembic/versions/d68c98b66cd4_client_description.py index 68a9b0ff..4eb0a81e 100644 --- a/jupyterhub/alembic/versions/d68c98b66cd4_client_description.py +++ b/jupyterhub/alembic/versions/d68c98b66cd4_client_description.py @@ -11,8 +11,8 @@ down_revision = '99a28a4418e1' branch_labels = None depends_on = None -from alembic import op import sqlalchemy as sa +from alembic import op def upgrade(): diff --git a/jupyterhub/alembic/versions/eeb276e51423_auth_state.py b/jupyterhub/alembic/versions/eeb276e51423_auth_state.py index 8ae54901..5b4df64b 100644 --- a/jupyterhub/alembic/versions/eeb276e51423_auth_state.py +++ b/jupyterhub/alembic/versions/eeb276e51423_auth_state.py @@ -12,8 +12,9 @@ down_revision = '19c0846f6344' branch_labels = None depends_on = None -from alembic import op import sqlalchemy as sa +from alembic import op + from jupyterhub.orm import JSONDict diff --git a/jupyterhub/apihandlers/__init__.py b/jupyterhub/apihandlers/__init__.py index 7900d8af..39733829 100644 --- a/jupyterhub/apihandlers/__init__.py +++ b/jupyterhub/apihandlers/__init__.py @@ -1,9 +1,4 @@ -from . import auth -from . import groups -from . import hub -from . import proxy -from . import services -from . import users +from . import auth, groups, hub, proxy, services, users from .base import * default_handlers = [] diff --git a/jupyterhub/apihandlers/auth.py b/jupyterhub/apihandlers/auth.py index aec972df..11171752 100644 --- a/jupyterhub/apihandlers/auth.py +++ b/jupyterhub/apihandlers/auth.py @@ -1,25 +1,16 @@ """Authorization handlers""" # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. -import itertools import json from datetime import datetime -from urllib.parse import parse_qsl -from urllib.parse import quote -from urllib.parse import urlencode -from urllib.parse import urlparse -from urllib.parse import urlunparse +from urllib.parse import parse_qsl, quote, urlencode, urlparse, urlunparse from oauthlib import oauth2 from tornado import web -from .. import orm -from .. import roles -from .. import scopes -from ..utils import get_browser_protocol -from ..utils import token_authenticated -from .base import APIHandler -from .base import BaseHandler +from .. import orm, roles, scopes +from ..utils import get_browser_protocol, token_authenticated +from .base import APIHandler, BaseHandler class TokenAPIHandler(APIHandler): @@ -38,7 +29,7 @@ class TokenAPIHandler(APIHandler): if owner: # having a token means we should be able to read the owner's model # (this is the only thing this handler is for) - self.expanded_scopes.update(scopes.identify_scopes(owner)) + self.expanded_scopes |= scopes.identify_scopes(owner) self.parsed_scopes = scopes.parse_scopes(self.expanded_scopes) # record activity whenever we see a token @@ -296,7 +287,7 @@ class OAuthAuthorizeHandler(OAuthHandler, BaseHandler): # rather than the expanded_scope intersection required_scopes = {*scopes.identify_scopes(), *scopes.access_scopes(client)} - user_scopes.update({"inherit", *required_scopes}) + user_scopes |= {"inherit", *required_scopes} allowed_scopes = requested_scopes.intersection(user_scopes) excluded_scopes = requested_scopes.difference(user_scopes) diff --git a/jupyterhub/apihandlers/base.py b/jupyterhub/apihandlers/base.py index 0a3dbd84..dcdd3059 100644 --- a/jupyterhub/apihandlers/base.py +++ b/jupyterhub/apihandlers/base.py @@ -4,10 +4,7 @@ import json from functools import lru_cache from http.client import responses -from urllib.parse import parse_qs -from urllib.parse import urlencode -from urllib.parse import urlparse -from urllib.parse import urlunparse +from urllib.parse import parse_qs, urlencode, urlparse, urlunparse from sqlalchemy.exc import SQLAlchemyError from tornado import web @@ -15,9 +12,7 @@ from tornado import web from .. import orm from ..handlers import BaseHandler from ..scopes import get_scopes_for -from ..utils import get_browser_protocol -from ..utils import isoformat -from ..utils import url_path_join +from ..utils import get_browser_protocol, isoformat, url_path_join PAGINATION_MEDIA_TYPE = "application/jupyterhub-pagination+json" diff --git a/jupyterhub/apihandlers/groups.py b/jupyterhub/apihandlers/groups.py index 574ab1e4..512da084 100644 --- a/jupyterhub/apihandlers/groups.py +++ b/jupyterhub/apihandlers/groups.py @@ -6,8 +6,7 @@ import json from tornado import web from .. import orm -from ..scopes import needs_scope -from ..scopes import Scope +from ..scopes import Scope, needs_scope from .base import APIHandler diff --git a/jupyterhub/apihandlers/services.py b/jupyterhub/apihandlers/services.py index 280729aa..43e69aa7 100644 --- a/jupyterhub/apihandlers/services.py +++ b/jupyterhub/apihandlers/services.py @@ -6,8 +6,7 @@ Currently GET-only, no actions can be taken to modify services. # Distributed under the terms of the Modified BSD License. import json -from ..scopes import needs_scope -from ..scopes import Scope +from ..scopes import Scope, needs_scope from .base import APIHandler diff --git a/jupyterhub/apihandlers/users.py b/jupyterhub/apihandlers/users.py index 9b0bd34a..5e1ea285 100644 --- a/jupyterhub/apihandlers/users.py +++ b/jupyterhub/apihandlers/users.py @@ -3,26 +3,19 @@ # Distributed under the terms of the Modified BSD License. import asyncio import json -from datetime import datetime -from datetime import timedelta -from datetime import timezone +from datetime import datetime, timedelta, timezone from async_generator import aclosing from dateutil.parser import parse as parse_date -from sqlalchemy import func -from sqlalchemy import or_ +from sqlalchemy import func, or_ from tornado import web from tornado.iostream import StreamClosedError -from .. import orm -from .. import scopes +from .. import orm, scopes from ..roles import assign_default_roles from ..scopes import needs_scope from ..user import User -from ..utils import isoformat -from ..utils import iterate_until -from ..utils import maybe_future -from ..utils import url_path_join +from ..utils import isoformat, iterate_until, maybe_future, url_path_join from .base import APIHandler @@ -51,7 +44,7 @@ class SelfAPIHandler(APIHandler): for scope in identify_scopes: if scope not in self.expanded_scopes: _added_scopes.add(scope) - self.expanded_scopes.add(scope) + self.expanded_scopes |= {scope} if _added_scopes: # re-parse with new scopes self.parsed_scopes = scopes.parse_scopes(self.expanded_scopes) diff --git a/jupyterhub/app.py b/jupyterhub/app.py index 8c7b4055..b262c3fe 100644 --- a/jupyterhub/app.py +++ b/jupyterhub/app.py @@ -14,15 +14,11 @@ import socket import sys import time from concurrent.futures import ThreadPoolExecutor -from datetime import datetime -from datetime import timedelta -from datetime import timezone +from datetime import datetime, timedelta, timezone from getpass import getuser from operator import itemgetter from textwrap import dedent -from urllib.parse import unquote -from urllib.parse import urlparse -from urllib.parse import urlunparse +from urllib.parse import unquote, urlparse, urlunparse if sys.version_info[:2] < (3, 3): raise ValueError("Python < 3.3 not supported: %s" % sys.version) @@ -36,78 +32,72 @@ except AttributeError as e: asyncio_all_tasks = asyncio.Task.all_tasks asyncio_current_task = asyncio.Task.current_task -from dateutil.parser import parse as parse_date -from jinja2 import Environment, FileSystemLoader, PrefixLoader, ChoiceLoader -from sqlalchemy.exc import OperationalError, SQLAlchemyError - -from tornado.httpclient import AsyncHTTPClient import tornado.httpserver -from tornado.ioloop import IOLoop, PeriodicCallback -from tornado.log import app_log, access_log, gen_log 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 sqlalchemy.exc import OperationalError, SQLAlchemyError from tornado import gen, web - +from tornado.httpclient import AsyncHTTPClient +from tornado.ioloop import IOLoop, PeriodicCallback +from tornado.log import access_log, app_log, gen_log from traitlets import ( - Unicode, - Integer, - Dict, - List, - Bool, Any, - Tuple, - Set, - Instance, + Bool, Bytes, + Dict, Float, + Instance, + Integer, + List, + Set, + Tuple, + Unicode, Union, - observe, default, + observe, validate, ) from traitlets.config import Application, Configurable, catch_config_error -from jupyter_telemetry.eventlog import EventLog - here = os.path.dirname(__file__) import jupyterhub -from . import handlers, apihandlers -from .handlers.static import CacheControlStaticFilesHandler, LogoHandler -from .services.service import Service -from . import crypto -from . import dbutil -from . import orm -from . import roles -from . import scopes -from .user import UserDict -from .oauth.provider import make_provider +from . import apihandlers, crypto, dbutil, handlers, orm, roles, scopes from ._data import DATA_FILES_PATH -from .log import CoroutineLogFormatter, log_request -from .proxy import Proxy, ConfigurableHTTPProxy -from .traitlets import URLPrefix, Command, EntryPointType, Callable -from .utils import ( - AnyTimeoutError, - catch_db_error, - maybe_future, - url_path_join, - print_stacks, - print_ps_info, - make_ssl_context, -) -from .metrics import HUB_STARTUP_DURATION_SECONDS -from .metrics import INIT_SPAWNERS_DURATION_SECONDS -from .metrics import RUNNING_SERVERS -from .metrics import TOTAL_USERS # classes for config from .auth import Authenticator, PAMAuthenticator from .crypto import CryptKeeper -from .spawner import Spawner, LocalProcessSpawner -from .objects import Hub, Server # For faking stats from .emptyclass import EmptyClass +from .handlers.static import CacheControlStaticFilesHandler, LogoHandler +from .log import CoroutineLogFormatter, log_request +from .metrics import ( + HUB_STARTUP_DURATION_SECONDS, + INIT_SPAWNERS_DURATION_SECONDS, + RUNNING_SERVERS, + TOTAL_USERS, +) +from .oauth.provider import make_provider +from .objects import Hub, Server +from .proxy import ConfigurableHTTPProxy, Proxy +from .services.service import Service +from .spawner import LocalProcessSpawner, Spawner +from .traitlets import Callable, Command, EntryPointType, URLPrefix +from .user import UserDict +from .utils import ( + AnyTimeoutError, + catch_db_error, + make_ssl_context, + maybe_future, + print_ps_info, + print_stacks, + url_path_join, +) common_aliases = { 'log-level': 'Application.log_level', diff --git a/jupyterhub/auth.py b/jupyterhub/auth.py index 585d5242..36411b9a 100644 --- a/jupyterhub/auth.py +++ b/jupyterhub/auth.py @@ -9,9 +9,7 @@ import warnings from concurrent.futures import ThreadPoolExecutor from functools import partial from shutil import which -from subprocess import PIPE -from subprocess import Popen -from subprocess import STDOUT +from subprocess import PIPE, STDOUT, Popen try: import pamela @@ -20,13 +18,12 @@ except Exception as e: _pamela_error = e from tornado.concurrent import run_on_executor - +from traitlets import Any, Bool, Dict, Integer, Set, Unicode, default, observe from traitlets.config import LoggingConfigurable -from traitlets import Bool, Integer, Set, Unicode, Dict, Any, default, observe from .handlers.login import LoginHandler -from .utils import maybe_future, url_path_join from .traitlets import Command +from .utils import maybe_future, url_path_join class Authenticator(LoggingConfigurable): diff --git a/jupyterhub/crypto.py b/jupyterhub/crypto.py index 039201b1..854017f8 100644 --- a/jupyterhub/crypto.py +++ b/jupyterhub/crypto.py @@ -4,18 +4,12 @@ import os from binascii import a2b_hex from concurrent.futures import ThreadPoolExecutor -from traitlets import Any -from traitlets import default -from traitlets import Integer -from traitlets import List -from traitlets import observe -from traitlets import validate -from traitlets.config import Config -from traitlets.config import SingletonConfigurable +from traitlets import Any, Integer, List, default, observe, validate +from traitlets.config import Config, SingletonConfigurable try: import cryptography - from cryptography.fernet import Fernet, MultiFernet, InvalidToken + from cryptography.fernet import Fernet, InvalidToken, MultiFernet except ImportError: cryptography = None diff --git a/jupyterhub/handlers/__init__.py b/jupyterhub/handlers/__init__.py index 6af7659f..e39e5730 100644 --- a/jupyterhub/handlers/__init__.py +++ b/jupyterhub/handlers/__init__.py @@ -1,7 +1,4 @@ -from . import base -from . import login -from . import metrics -from . import pages +from . import base, login, metrics, pages from .base import * from .login import * diff --git a/jupyterhub/handlers/base.py b/jupyterhub/handlers/base.py index d6d1c5d9..5932b4f8 100644 --- a/jupyterhub/handlers/base.py +++ b/jupyterhub/handlers/base.py @@ -9,48 +9,44 @@ import random import re import time import uuid -from datetime import datetime -from datetime import timedelta +from datetime import datetime, timedelta from http.client import responses -from urllib.parse import parse_qs -from urllib.parse import parse_qsl -from urllib.parse import urlencode -from urllib.parse import urlparse -from urllib.parse import urlunparse +from urllib.parse import parse_qs, parse_qsl, urlencode, urlparse, urlunparse from jinja2 import TemplateNotFound from sqlalchemy.exc import SQLAlchemyError -from tornado import gen -from tornado import web -from tornado.httputil import HTTPHeaders -from tornado.httputil import url_concat +from tornado import gen, web +from tornado.httputil import HTTPHeaders, url_concat from tornado.ioloop import IOLoop from tornado.log import app_log -from tornado.web import addslash -from tornado.web import RequestHandler +from tornado.web import RequestHandler, addslash -from .. import __version__ -from .. import orm -from .. import roles -from .. import scopes -from ..metrics import PROXY_ADD_DURATION_SECONDS -from ..metrics import PROXY_DELETE_DURATION_SECONDS -from ..metrics import ProxyDeleteStatus -from ..metrics import RUNNING_SERVERS -from ..metrics import SERVER_POLL_DURATION_SECONDS -from ..metrics import SERVER_SPAWN_DURATION_SECONDS -from ..metrics import SERVER_STOP_DURATION_SECONDS -from ..metrics import ServerPollStatus -from ..metrics import ServerSpawnStatus -from ..metrics import ServerStopStatus -from ..metrics import TOTAL_USERS +from .. import __version__, orm, roles, scopes +from ..metrics import ( + PROXY_ADD_DURATION_SECONDS, + PROXY_DELETE_DURATION_SECONDS, + RUNNING_SERVERS, + SERVER_POLL_DURATION_SECONDS, + SERVER_SPAWN_DURATION_SECONDS, + SERVER_STOP_DURATION_SECONDS, + TOTAL_USERS, + ProxyDeleteStatus, + ServerPollStatus, + ServerSpawnStatus, + ServerStopStatus, +) from ..objects import Server from ..spawner import LocalProcessSpawner from ..user import User -from ..utils import AnyTimeoutError -from ..utils import get_accepted_mimetype -from ..utils import maybe_future -from ..utils import url_path_join + +from ..utils import ( + AnyTimeoutError, + get_accepted_mimetype, + get_browser_protocol, + maybe_future, + url_path_join, +) + # pattern for the authentication token header auth_header_pat = re.compile(r'^(?:token|bearer)\s+([^\s]+)$', flags=re.IGNORECASE) @@ -515,7 +511,13 @@ class BaseHandler(RequestHandler): path=url_path_join(self.base_url, 'services'), **kwargs, ) - # clear tornado cookie + # clear_cookie only accepts a subset of set_cookie's kwargs + clear_xsrf_cookie_kwargs = { + key: value + for key, value in self.settings.get('xsrf_cookie_kwargs', {}).items() + if key in {"path", "domain"} + } + self.clear_cookie( '_xsrf', **self.settings.get('xsrf_cookie_kwargs', {}), diff --git a/jupyterhub/handlers/metrics.py b/jupyterhub/handlers/metrics.py index 844a203f..674aeddd 100644 --- a/jupyterhub/handlers/metrics.py +++ b/jupyterhub/handlers/metrics.py @@ -1,7 +1,5 @@ """Handlers for serving prometheus metrics""" -from prometheus_client import CONTENT_TYPE_LATEST -from prometheus_client import generate_latest -from prometheus_client import REGISTRY +from prometheus_client import CONTENT_TYPE_LATEST, REGISTRY, generate_latest from ..utils import metrics_authentication from .base import BaseHandler diff --git a/jupyterhub/handlers/pages.py b/jupyterhub/handlers/pages.py index cd311375..b13f4e9c 100644 --- a/jupyterhub/handlers/pages.py +++ b/jupyterhub/handlers/pages.py @@ -12,11 +12,9 @@ from tornado import web from tornado.httputil import url_concat from .. import __version__ -from ..metrics import SERVER_POLL_DURATION_SECONDS -from ..metrics import ServerPollStatus +from ..metrics import SERVER_POLL_DURATION_SECONDS, ServerPollStatus from ..scopes import needs_scope -from ..utils import maybe_future -from ..utils import url_path_join +from ..utils import maybe_future, url_path_join from .base import BaseHandler diff --git a/jupyterhub/log.py b/jupyterhub/log.py index 47534ab7..4a9638f0 100644 --- a/jupyterhub/log.py +++ b/jupyterhub/log.py @@ -6,13 +6,10 @@ import logging import traceback from functools import partial from http.cookies import SimpleCookie -from urllib.parse import urlparse -from urllib.parse import urlunparse +from urllib.parse import urlparse, urlunparse -from tornado.log import access_log -from tornado.log import LogFormatter -from tornado.web import HTTPError -from tornado.web import StaticFileHandler +from tornado.log import LogFormatter, access_log +from tornado.web import HTTPError, StaticFileHandler from .handlers.pages import HealthCheckHandler from .metrics import prometheus_log_method diff --git a/jupyterhub/metrics.py b/jupyterhub/metrics.py index ff58748f..051aeaaa 100644 --- a/jupyterhub/metrics.py +++ b/jupyterhub/metrics.py @@ -21,8 +21,7 @@ them manually here. """ from enum import Enum -from prometheus_client import Gauge -from prometheus_client import Histogram +from prometheus_client import Gauge, Histogram REQUEST_DURATION_SECONDS = Histogram( 'jupyterhub_request_duration_seconds', diff --git a/jupyterhub/oauth/provider.py b/jupyterhub/oauth/provider.py index 267f3965..05637142 100644 --- a/jupyterhub/oauth/provider.py +++ b/jupyterhub/oauth/provider.py @@ -3,19 +3,14 @@ implements https://oauthlib.readthedocs.io/en/latest/oauth2/server.html """ from oauthlib import uri_validate -from oauthlib.oauth2 import RequestValidator -from oauthlib.oauth2 import WebApplicationServer -from oauthlib.oauth2.rfc6749.grant_types import authorization_code -from oauthlib.oauth2.rfc6749.grant_types import base +from oauthlib.oauth2 import RequestValidator, WebApplicationServer +from oauthlib.oauth2.rfc6749.grant_types import authorization_code, base from tornado.log import app_log from .. import orm from ..roles import roles_to_scopes -from ..scopes import _check_scopes_exist -from ..scopes import access_scopes -from ..scopes import identify_scopes -from ..utils import compare_token -from ..utils import hash_token +from ..scopes import _check_scopes_exist, access_scopes, identify_scopes +from ..utils import compare_token, hash_token # patch absolute-uri check # because we want to allow relative uri oauth @@ -159,9 +154,8 @@ class JupyterHubRequestValidator(RequestValidator): scopes = roles_to_scopes(orm_client.allowed_roles) if 'inherit' not in scopes: # add identify-user scope - scopes.update(identify_scopes()) - # add access-service scope - scopes.update(access_scopes(orm_client)) + # and access-service scope + scopes |= identify_scopes() | access_scopes(orm_client) return scopes def get_original_scopes(self, refresh_token, request, *args, **kwargs): diff --git a/jupyterhub/objects.py b/jupyterhub/objects.py index f5893a3e..4fd215e8 100644 --- a/jupyterhub/objects.py +++ b/jupyterhub/objects.py @@ -3,25 +3,20 @@ # Distributed under the terms of the Modified BSD License. import socket import warnings -from urllib.parse import urlparse -from urllib.parse import urlunparse +from urllib.parse import urlparse, urlunparse -from traitlets import default -from traitlets import HasTraits -from traitlets import Instance -from traitlets import Integer -from traitlets import observe -from traitlets import Unicode -from traitlets import validate +from traitlets import HasTraits, Instance, Integer, Unicode, default, observe, validate from . import orm from .traitlets import URLPrefix -from .utils import can_connect -from .utils import make_ssl_context -from .utils import random_port -from .utils import url_path_join -from .utils import wait_for_http_server -from .utils import wait_for_server +from .utils import ( + can_connect, + make_ssl_context, + random_port, + url_path_join, + wait_for_http_server, + wait_for_server, +) class Server(HasTraits): diff --git a/jupyterhub/orm.py b/jupyterhub/orm.py index dd9dbf8e..d505f4b8 100644 --- a/jupyterhub/orm.py +++ b/jupyterhub/orm.py @@ -3,47 +3,44 @@ # Distributed under the terms of the Modified BSD License. import enum import json -from base64 import decodebytes -from base64 import encodebytes -from datetime import datetime -from datetime import timedelta +from base64 import decodebytes, encodebytes +from datetime import datetime, timedelta import alembic.command import alembic.config from alembic.script import ScriptDirectory -from sqlalchemy import Boolean -from sqlalchemy import Column -from sqlalchemy import create_engine -from sqlalchemy import DateTime -from sqlalchemy import event -from sqlalchemy import exc -from sqlalchemy import ForeignKey -from sqlalchemy import inspect -from sqlalchemy import Integer -from sqlalchemy import MetaData -from sqlalchemy import or_ -from sqlalchemy import select -from sqlalchemy import Table -from sqlalchemy import Unicode +from sqlalchemy import ( + Boolean, + Column, + DateTime, + ForeignKey, + Integer, + MetaData, + Table, + Unicode, + create_engine, + event, + exc, + inspect, + or_, + select, +) from sqlalchemy.ext.declarative import declarative_base -from sqlalchemy.orm import backref -from sqlalchemy.orm import interfaces -from sqlalchemy.orm import object_session -from sqlalchemy.orm import relationship -from sqlalchemy.orm import Session -from sqlalchemy.orm import sessionmaker +from sqlalchemy.orm import ( + Session, + backref, + interfaces, + object_session, + relationship, + sessionmaker, +) from sqlalchemy.pool import StaticPool from sqlalchemy.sql.expression import bindparam -from sqlalchemy.types import LargeBinary -from sqlalchemy.types import Text -from sqlalchemy.types import TypeDecorator +from sqlalchemy.types import LargeBinary, Text, TypeDecorator from tornado.log import app_log from .roles import roles_to_scopes -from .utils import compare_token -from .utils import hash_token -from .utils import new_token -from .utils import random_port +from .utils import compare_token, hash_token, new_token, random_port # top-level variable for easier mocking in tests utcnow = datetime.utcnow diff --git a/jupyterhub/proxy.py b/jupyterhub/proxy.py index 477a6ccc..f09bda83 100644 --- a/jupyterhub/proxy.py +++ b/jupyterhub/proxy.py @@ -26,29 +26,18 @@ from subprocess import Popen from urllib.parse import quote from weakref import WeakKeyDictionary -from tornado.httpclient import AsyncHTTPClient -from tornado.httpclient import HTTPError -from tornado.httpclient import HTTPRequest +from tornado.httpclient import AsyncHTTPClient, HTTPError, HTTPRequest from tornado.ioloop import PeriodicCallback -from traitlets import Any -from traitlets import Bool -from traitlets import default -from traitlets import Dict -from traitlets import Instance -from traitlets import Integer -from traitlets import observe -from traitlets import Unicode +from traitlets import Any, Bool, Dict, Instance, Integer, Unicode, default, observe from traitlets.config import LoggingConfigurable -from . import utils -from .metrics import CHECK_ROUTES_DURATION_SECONDS -from .metrics import PROXY_POLL_DURATION_SECONDS -from .objects import Server -from .utils import AnyTimeoutError -from .utils import exponential_backoff -from .utils import url_path_join from jupyterhub.traitlets import Command +from . import utils +from .metrics import CHECK_ROUTES_DURATION_SECONDS, PROXY_POLL_DURATION_SECONDS +from .objects import Server +from .utils import AnyTimeoutError, exponential_backoff, url_path_join + def _one_at_a_time(method): """decorator to limit an async method to be called only once diff --git a/jupyterhub/roles.py b/jupyterhub/roles.py index c892e5f2..afa77a25 100644 --- a/jupyterhub/roles.py +++ b/jupyterhub/roles.py @@ -7,8 +7,7 @@ from functools import wraps from sqlalchemy import func from tornado.log import app_log -from . import orm -from . import scopes +from . import orm, scopes def get_default_roles(): diff --git a/jupyterhub/scopes.py b/jupyterhub/scopes.py index 8af2263b..1d83ba60 100644 --- a/jupyterhub/scopes.py +++ b/jupyterhub/scopes.py @@ -1,6 +1,10 @@ """ General scope definitions and utilities +Scope functions generally return _immutable_ collections, +such as `frozenset` to avoid mutating cached values. +If needed, mutable copies can be made, e.g. `set(frozen_scopes)` + Scope variable nomenclature --------------------------- scopes or 'raw' scopes: collection of scopes that may contain abbreviations (e.g., in role definition) @@ -23,8 +27,8 @@ import sqlalchemy as sa from tornado import web from tornado.log import app_log -from . import orm -from . import roles +from . import orm, roles +from ._memoize import DoNotCache, FrozenDict, lru_cache_key """when modifying the scope definitions, make sure that `docs/source/rbac/generate-scope-table.py` is run so that changes are reflected in the documentation and REST API description.""" @@ -145,6 +149,12 @@ class Scope(Enum): ALL = True +def _intersection_cache_key(scopes_a, scopes_b, db=None): + """Cache key function for scope intersections""" + return (frozenset(scopes_a), frozenset(scopes_b)) + + +@lru_cache_key(_intersection_cache_key) def _intersect_expanded_scopes(scopes_a, scopes_b, db=None): """Intersect two sets of scopes by comparing their permissions @@ -160,11 +170,16 @@ def _intersect_expanded_scopes(scopes_a, scopes_b, db=None): (i.e. users!group=x & users!user=y will be empty, even if user y is in group x.) """ empty_set = frozenset() + scopes_a = frozenset(scopes_a) + scopes_b = frozenset(scopes_b) # cached lookups for group membership of users and servers @lru_cache() def groups_for_user(username): """Get set of group names for a given username""" + # if we need a group lookup, the result is not cacheable + nonlocal needs_db + needs_db = True user = db.query(orm.User).filter_by(name=username).first() if user is None: return empty_set @@ -180,6 +195,11 @@ def _intersect_expanded_scopes(scopes_a, scopes_b, db=None): parsed_scopes_a = parse_scopes(scopes_a) parsed_scopes_b = parse_scopes(scopes_b) + # track whether we need a db lookup (for groups) + # because we can't cache the intersection if we do + # if there are no group filters, this is cacheable + needs_db = False + common_bases = parsed_scopes_a.keys() & parsed_scopes_b.keys() common_filters = {} @@ -221,6 +241,7 @@ def _intersect_expanded_scopes(scopes_a, scopes_b, db=None): UserWarning, ) warned = True + needs_db = True common_filters[base] = { entity: filters_a[entity] & filters_b[entity] @@ -246,6 +267,7 @@ def _intersect_expanded_scopes(scopes_a, scopes_b, db=None): # resolve group/server hierarchy if db available servers = servers.difference(common_servers) if db is not None and servers and 'group' in b: + needs_db = True for server in servers: server_groups = groups_for_server(server) if server_groups & b['group']: @@ -273,7 +295,12 @@ def _intersect_expanded_scopes(scopes_a, scopes_b, db=None): if common_users and "user" not in common_filters[base]: common_filters[base]["user"] = common_users - return unparse_scopes(common_filters) + intersection = unparse_scopes(common_filters) + if needs_db: + # return intersection, but don't cache it if it needed db lookups + return DoNotCache(intersection) + + return intersection def get_scopes_for(orm_object): @@ -314,7 +341,7 @@ def get_scopes_for(orm_object): # only thing we miss by short-circuiting here: warning about excluded extra scopes return owner_scopes - token_scopes = expand_scopes(token_scopes, owner=owner) + token_scopes = set(expand_scopes(token_scopes, owner=owner)) if orm_object.client_id != "jupyterhub": # oauth tokens can be used to access the service issuing the token, @@ -359,6 +386,7 @@ def get_scopes_for(orm_object): return expanded_scopes +@lru_cache() def _expand_self_scope(username): """ Users have a metascope 'self' that should be expanded to standard user privileges. @@ -391,9 +419,11 @@ def _expand_self_scope(username): 'read:tokens', 'access:servers', ] - return {f"{scope}!user={username}" for scope in scope_list} + # return immutable frozenset because the result is cached + return frozenset(f"{scope}!user={username}" for scope in scope_list) +@lru_cache(maxsize=65535) def _expand_scope(scope): """Returns a scope and all all subscopes @@ -434,9 +464,30 @@ def _expand_scope(scope): else: expanded_scopes = expanded_scope_names - return expanded_scopes + # return immutable frozenset because the result is cached + return frozenset(expanded_scopes) +def _expand_scopes_key(scopes, owner=None): + """Cache key function for expand_scopes + + scopes is usually a mutable list or set, + which can be hashed as a frozenset + + For the owner, we only care about what kind they are, + and their name. + """ + # freeze scopes for hash + frozen_scopes = frozenset(scopes) + if owner is None: + owner_key = None + else: + # owner key is the type and name + owner_key = (type(owner).__name__, owner.name) + return (frozen_scopes, owner_key) + + +@lru_cache_key(_expand_scopes_key) def expand_scopes(scopes, owner=None): """Returns a set of fully expanded scopes for a collection of raw scopes @@ -480,8 +531,9 @@ def expand_scopes(scopes, owner=None): stacklevel=2, ) - # reduce to minimize - return reduce_scopes(expanded_scopes) + # reduce to discard overlapping scopes + # return immutable frozenset because the result is cached + return frozenset(reduce_scopes(expanded_scopes)) def _needs_scope_expansion(filter_, filter_value, sub_scope): @@ -615,6 +667,7 @@ def _check_token_scopes(scopes, owner): ) +@lru_cache_key(frozenset) def parse_scopes(scope_list): """ Parses scopes and filters in something akin to JSON style @@ -650,9 +703,11 @@ def parse_scopes(scope_list): parsed_scopes[base_scope][key] = {value} else: parsed_scopes[base_scope][key].add(value) - return parsed_scopes + # return immutable FrozenDict because the result is cached + return FrozenDict(parsed_scopes) +@lru_cache_key(FrozenDict) def unparse_scopes(parsed_scopes): """Turn a parsed_scopes dictionary back into a expanded scopes set""" expanded_scopes = set() @@ -663,14 +718,17 @@ def unparse_scopes(parsed_scopes): for entity, names_list in filters.items(): for name in names_list: expanded_scopes.add(f'{base}!{entity}={name}') - return expanded_scopes + # return immutable frozenset because the result is cached + return frozenset(expanded_scopes) +@lru_cache_key(frozenset) def reduce_scopes(expanded_scopes): """Reduce expanded scopes to minimal set - Eliminates redundancy, such as access:services and access:services!service=x + Eliminates overlapping scopes, such as access:services and access:services!service=x """ + # unparse_scopes already returns a frozenset return unparse_scopes(parse_scopes(expanded_scopes)) @@ -724,6 +782,14 @@ def needs_scope(*scopes): return scope_decorator +def _identify_key(obj=None): + if obj is None: + return None + else: + return (type(obj).__name__, obj.name) + + +@lru_cache_key(_identify_key) def identify_scopes(obj=None): """Return 'identify' scopes for an orm object @@ -736,20 +802,25 @@ def identify_scopes(obj=None): identify scopes (set): set of scopes needed for 'identify' endpoints """ if obj is None: - return {f"read:users:{field}!user" for field in {"name", "groups"}} + return frozenset(f"read:users:{field}!user" for field in {"name", "groups"}) elif isinstance(obj, orm.User): - return {f"read:users:{field}!user={obj.name}" for field in {"name", "groups"}} + return frozenset( + f"read:users:{field}!user={obj.name}" for field in {"name", "groups"} + ) elif isinstance(obj, orm.Service): - return {f"read:services:{field}!service={obj.name}" for field in {"name"}} + return frozenset( + f"read:services:{field}!service={obj.name}" for field in {"name"} + ) else: raise TypeError(f"Expected orm.User or orm.Service, got {obj!r}") +@lru_cache_key(lambda oauth_client: oauth_client.identifier) def access_scopes(oauth_client): """Return scope(s) required to access an oauth client""" scopes = set() if oauth_client.identifier == "jupyterhub": - return scopes + return frozenset() spawner = oauth_client.spawner if spawner: scopes.add(f"access:servers!server={spawner.user.name}/{spawner.name}") @@ -761,9 +832,19 @@ def access_scopes(oauth_client): app_log.warning( f"OAuth client {oauth_client} has no associated service or spawner!" ) - return scopes + return frozenset(scopes) +def _check_scope_key(sub_scope, orm_resource, kind): + """Cache key function for check_scope_filter""" + if kind == 'server': + resource_key = (orm_resource.user.name, orm_resource.name) + else: + resource_key = orm_resource.name + return (sub_scope, resource_key, kind) + + +@lru_cache_key(_check_scope_key) def check_scope_filter(sub_scope, orm_resource, kind): """Return whether a sub_scope filter applies to a given resource. @@ -792,8 +873,8 @@ def check_scope_filter(sub_scope, orm_resource, kind): if kind == 'user' and 'group' in sub_scope: group_names = {group.name for group in orm_resource.groups} user_in_group = bool(group_names & set(sub_scope['group'])) - if user_in_group: - return True + # cannot cache if we needed to lookup groups in db + return DoNotCache(user_in_group) return False @@ -832,6 +913,7 @@ def describe_parsed_scopes(parsed_scopes, username=None): return descriptions +@lru_cache_key(lambda raw_scopes, username=None: (frozenset(raw_scopes), username)) def describe_raw_scopes(raw_scopes, username=None): """Return list of descriptions of raw scopes @@ -862,7 +944,8 @@ def describe_raw_scopes(raw_scopes, username=None): "filter": filter_text, } ) - return descriptions + # make sure we return immutable from a cached function + return tuple(descriptions) # regex for custom scope diff --git a/jupyterhub/services/auth.py b/jupyterhub/services/auth.py index 716dfaac..a2d59b87 100644 --- a/jupyterhub/services/auth.py +++ b/jupyterhub/services/auth.py @@ -40,21 +40,12 @@ from urllib.parse import urlencode import requests from tornado.httputil import url_concat from tornado.log import app_log -from tornado.web import HTTPError -from tornado.web import RequestHandler -from traitlets import default -from traitlets import Dict -from traitlets import Instance -from traitlets import Integer -from traitlets import observe -from traitlets import Set -from traitlets import Unicode -from traitlets import validate +from tornado.web import HTTPError, RequestHandler +from traitlets import Dict, Instance, Integer, Set, Unicode, default, observe, validate from traitlets.config import SingletonConfigurable from ..scopes import _intersect_expanded_scopes -from ..utils import get_browser_protocol -from ..utils import url_path_join +from ..utils import get_browser_protocol, url_path_join def check_scopes(required_scopes, scopes): diff --git a/jupyterhub/services/service.py b/jupyterhub/services/service.py index db761c7e..57876222 100644 --- a/jupyterhub/services/service.py +++ b/jupyterhub/services/service.py @@ -45,21 +45,22 @@ import pipes import shutil from subprocess import Popen -from traitlets import Any -from traitlets import Bool -from traitlets import default -from traitlets import Dict -from traitlets import HasTraits -from traitlets import Instance -from traitlets import List -from traitlets import Unicode -from traitlets import validate +from traitlets import ( + Any, + Bool, + Dict, + HasTraits, + Instance, + List, + Unicode, + default, + validate, +) from traitlets.config import LoggingConfigurable from .. import orm from ..objects import Server -from ..spawner import LocalProcessSpawner -from ..spawner import set_user_setuid +from ..spawner import LocalProcessSpawner, set_user_setuid from ..traitlets import Command from ..utils import url_path_join diff --git a/jupyterhub/singleuser/__init__.py b/jupyterhub/singleuser/__init__.py index 0b06e2f6..60159406 100644 --- a/jupyterhub/singleuser/__init__.py +++ b/jupyterhub/singleuser/__init__.py @@ -2,10 +2,8 @@ Contains default notebook-app subclass and mixins """ -from .app import main -from .app import SingleUserNotebookApp -from .mixins import HubAuthenticatedHandler -from .mixins import make_singleuser_app +from .app import SingleUserNotebookApp, main +from .mixins import HubAuthenticatedHandler, make_singleuser_app # backward-compatibility JupyterHubLoginHandler = SingleUserNotebookApp.login_handler_class diff --git a/jupyterhub/singleuser/mixins.py b/jupyterhub/singleuser/mixins.py index 706375ae..ff9abce4 100755 --- a/jupyterhub/singleuser/mixins.py +++ b/jupyterhub/singleuser/mixins.py @@ -21,35 +21,29 @@ from datetime import timezone from textwrap import dedent from urllib.parse import urlparse -from jinja2 import ChoiceLoader -from jinja2 import FunctionLoader +from jinja2 import ChoiceLoader, FunctionLoader from tornado import ioloop -from tornado.httpclient import AsyncHTTPClient -from tornado.httpclient import HTTPRequest +from tornado.httpclient import AsyncHTTPClient, HTTPRequest from tornado.web import RequestHandler -from traitlets import Any -from traitlets import Bool -from traitlets import Bytes -from traitlets import CUnicode -from traitlets import default -from traitlets import import_item -from traitlets import Integer -from traitlets import observe -from traitlets import TraitError -from traitlets import Unicode -from traitlets import validate +from traitlets import ( + Any, + Bool, + Bytes, + CUnicode, + Integer, + TraitError, + Unicode, + default, + import_item, + observe, + validate, +) from traitlets.config import Configurable -from .._version import __version__ -from .._version import _check_version +from .._version import __version__, _check_version from ..log import log_request -from ..services.auth import HubOAuth -from ..services.auth import HubOAuthCallbackHandler -from ..services.auth import HubOAuthenticated -from ..utils import exponential_backoff -from ..utils import isoformat -from ..utils import make_ssl_context -from ..utils import url_path_join +from ..services.auth import HubOAuth, HubOAuthCallbackHandler, HubOAuthenticated +from ..utils import exponential_backoff, isoformat, make_ssl_context, url_path_join def _bool_env(key): diff --git a/jupyterhub/spawner.py b/jupyterhub/spawner.py index 9b755cfb..03ac4119 100644 --- a/jupyterhub/spawner.py +++ b/jupyterhub/spawner.py @@ -19,29 +19,31 @@ from urllib.parse import urlparse from async_generator import aclosing from sqlalchemy import inspect from tornado.ioloop import PeriodicCallback -from traitlets import Any -from traitlets import Bool -from traitlets import default -from traitlets import Dict -from traitlets import Float -from traitlets import Instance -from traitlets import Integer -from traitlets import List -from traitlets import observe -from traitlets import Unicode -from traitlets import Union -from traitlets import validate +from traitlets import ( + Any, + Bool, + Dict, + Float, + Instance, + Integer, + List, + Unicode, + Union, + default, + observe, + validate, +) from traitlets.config import LoggingConfigurable from .objects import Server -from .traitlets import ByteSpecification -from .traitlets import Callable -from .traitlets import Command -from .utils import AnyTimeoutError -from .utils import exponential_backoff -from .utils import maybe_future -from .utils import random_port -from .utils import url_path_join +from .traitlets import ByteSpecification, Callable, Command +from .utils import ( + AnyTimeoutError, + exponential_backoff, + maybe_future, + random_port, + url_path_join, +) if os.name == 'nt': import psutil @@ -871,9 +873,6 @@ class Spawner(LoggingConfigurable): if self.server: 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 else: # this should only occur in mock/testing scenarios diff --git a/jupyterhub/tests/conftest.py b/jupyterhub/tests/conftest.py index 7bb8b48c..60109bb1 100644 --- a/jupyterhub/tests/conftest.py +++ b/jupyterhub/tests/conftest.py @@ -35,22 +35,17 @@ from getpass import getuser from subprocess import TimeoutExpired from unittest import mock -from pytest import fixture -from pytest import raises +from pytest import fixture, raises from tornado import ioloop from tornado.httpclient import HTTPError from tornado.platform.asyncio import AsyncIOMainLoop import jupyterhub.services.service -from . import mocking -from .. import crypto -from .. import orm -from .. import scopes -from ..roles import create_role -from ..roles import get_default_roles -from ..roles import mock_roles -from ..roles import update_roles + +from .. import crypto, orm, scopes +from ..roles import create_role, get_default_roles, mock_roles, update_roles from ..utils import random_port +from . import mocking from .mocking import MockHub from .test_services import mockservice_cmd from .utils import add_user diff --git a/jupyterhub/tests/mocking.py b/jupyterhub/tests/mocking.py index bdb976df..2821d0b0 100644 --- a/jupyterhub/tests/mocking.py +++ b/jupyterhub/tests/mocking.py @@ -37,23 +37,15 @@ from urllib.parse import urlparse from pamela import PAMError from tornado.ioloop import IOLoop -from traitlets import Bool -from traitlets import default -from traitlets import Dict +from traitlets import Bool, Dict, default -from .. import metrics -from .. import orm -from .. import roles +from .. import metrics, orm, roles from ..app import JupyterHub from ..auth import PAMAuthenticator from ..singleuser import SingleUserNotebookApp from ..spawner import SimpleLocalProcessSpawner -from ..utils import random_port -from ..utils import utcnow -from .utils import async_requests -from .utils import public_host -from .utils import public_url -from .utils import ssl_setup +from ..utils import random_port, utcnow +from .utils import async_requests, public_host, public_url, ssl_setup def mock_authenticate(username, password, service, encoding): diff --git a/jupyterhub/tests/mockservice.py b/jupyterhub/tests/mockservice.py index b50a6c9b..0afa50eb 100644 --- a/jupyterhub/tests/mockservice.py +++ b/jupyterhub/tests/mockservice.py @@ -19,15 +19,14 @@ import sys from urllib.parse import urlparse import requests -from tornado import httpserver -from tornado import ioloop -from tornado import log -from tornado import web +from tornado import httpserver, ioloop, log, web from tornado.httputil import url_concat -from jupyterhub.services.auth import HubAuthenticated -from jupyterhub.services.auth import HubOAuthCallbackHandler -from jupyterhub.services.auth import HubOAuthenticated +from jupyterhub.services.auth import ( + HubAuthenticated, + HubOAuthCallbackHandler, + HubOAuthenticated, +) from jupyterhub.utils import make_ssl_context @@ -123,7 +122,7 @@ def main(): if __name__ == '__main__': - from tornado.options import parse_command_line, options + from tornado.options import options, parse_command_line parse_command_line() options.logging = 'debug' diff --git a/jupyterhub/tests/mocksu.py b/jupyterhub/tests/mocksu.py index c9f4b85e..8b0723b9 100644 --- a/jupyterhub/tests/mocksu.py +++ b/jupyterhub/tests/mocksu.py @@ -16,10 +16,7 @@ import os import sys from urllib.parse import urlparse -from tornado import httpserver -from tornado import ioloop -from tornado import log -from tornado import web +from tornado import httpserver, ioloop, log, web from tornado.options import options from ..utils import make_ssl_context diff --git a/jupyterhub/tests/test_api.py b/jupyterhub/tests/test_api.py index d450a664..0c6dd9d0 100644 --- a/jupyterhub/tests/test_api.py +++ b/jupyterhub/tests/test_api.py @@ -4,32 +4,23 @@ import json import re import sys import uuid -from datetime import datetime -from datetime import timedelta +from datetime import datetime, timedelta from unittest import mock -from urllib.parse import quote -from urllib.parse import urlparse -from urllib.parse import urlunparse +from urllib.parse import quote, urlparse, urlunparse -from pytest import fixture -from pytest import mark +from pytest import fixture, mark from tornado.httputil import url_concat import jupyterhub + from .. import orm from ..apihandlers.base import PAGINATION_MEDIA_TYPE from ..objects import Server from ..utils import url_path_join as ujoin from ..utils import utcnow from .conftest import new_username -from .mocking import public_host -from .mocking import public_url -from .utils import add_user -from .utils import api_request -from .utils import async_requests -from .utils import auth_header -from .utils import find_user - +from .mocking import public_host, public_url +from .utils import add_user, api_request, async_requests, auth_header, find_user # -------------------- # Authentication tests diff --git a/jupyterhub/tests/test_app.py b/jupyterhub/tests/test_app.py index 46fa52f5..7f7e0fc1 100644 --- a/jupyterhub/tests/test_app.py +++ b/jupyterhub/tests/test_app.py @@ -6,11 +6,8 @@ import os import re import sys import time -from subprocess import check_output -from subprocess import PIPE -from subprocess import Popen -from tempfile import NamedTemporaryFile -from tempfile import TemporaryDirectory +from subprocess import PIPE, Popen, check_output +from tempfile import NamedTemporaryFile, TemporaryDirectory from unittest.mock import patch import pytest @@ -19,8 +16,7 @@ from distutils.version import LooseVersion as V from traitlets.config import Config from .. import orm -from ..app import COOKIE_SECRET_BYTES -from ..app import JupyterHub +from ..app import COOKIE_SECRET_BYTES, JupyterHub from .mocking import MockHub from .test_api import add_user diff --git a/jupyterhub/tests/test_auth.py b/jupyterhub/tests/test_auth.py index 667a046d..db4fd8bc 100644 --- a/jupyterhub/tests/test_auth.py +++ b/jupyterhub/tests/test_auth.py @@ -10,16 +10,10 @@ from requests import HTTPError from traitlets import Any from traitlets.config import Config -from .mocking import MockPAMAuthenticator -from .mocking import MockStructGroup -from .mocking import MockStructPasswd -from .utils import add_user -from .utils import async_requests -from .utils import get_page -from .utils import public_url -from jupyterhub import auth -from jupyterhub import crypto -from jupyterhub import orm +from jupyterhub import auth, crypto, orm + +from .mocking import MockPAMAuthenticator, MockStructGroup, MockStructPasswd +from .utils import add_user, async_requests, get_page, public_url async def test_pam_auth(): diff --git a/jupyterhub/tests/test_auth_expiry.py b/jupyterhub/tests/test_auth_expiry.py index ea8049f9..5b750e79 100644 --- a/jupyterhub/tests/test_auth_expiry.py +++ b/jupyterhub/tests/test_auth_expiry.py @@ -9,13 +9,11 @@ authentication can expire in a number of ways: """ from contextlib import contextmanager from unittest import mock -from urllib.parse import parse_qs -from urllib.parse import urlparse +from urllib.parse import parse_qs, urlparse import pytest -from .utils import api_request -from .utils import get_page +from .utils import api_request, get_page async def refresh_expired(authenticator, user): diff --git a/jupyterhub/tests/test_crypto.py b/jupyterhub/tests/test_crypto.py index 31e888ad..20d5f62b 100644 --- a/jupyterhub/tests/test_crypto.py +++ b/jupyterhub/tests/test_crypto.py @@ -1,13 +1,11 @@ import os -from binascii import b2a_base64 -from binascii import b2a_hex +from binascii import b2a_base64, b2a_hex from unittest.mock import patch import pytest from .. import crypto -from ..crypto import decrypt -from ..crypto import encrypt +from ..crypto import decrypt, encrypt keys = [('%i' % i).encode('ascii') * 32 for i in range(3)] hex_keys = [b2a_hex(key).decode('ascii') for key in keys] diff --git a/jupyterhub/tests/test_db.py b/jupyterhub/tests/test_db.py index a4a39f12..9284df0d 100644 --- a/jupyterhub/tests/test_db.py +++ b/jupyterhub/tests/test_db.py @@ -9,8 +9,7 @@ from pytest import raises from traitlets.config import Config from .. import orm -from ..app import NewToken -from ..app import UpgradeDB +from ..app import NewToken, UpgradeDB from ..scopes import _check_scopes_exist here = os.path.abspath(os.path.dirname(__file__)) diff --git a/jupyterhub/tests/test_eventlog.py b/jupyterhub/tests/test_eventlog.py index 3cfbaec1..d8dcd285 100644 --- a/jupyterhub/tests/test_eventlog.py +++ b/jupyterhub/tests/test_eventlog.py @@ -16,7 +16,6 @@ from traitlets.config import Config from .mocking import MockHub - # To test new schemas, add them to the `valid_events` # and `invalid_events` dictionary below. diff --git a/jupyterhub/tests/test_internal_ssl_connections.py b/jupyterhub/tests/test_internal_ssl_connections.py index 0a8a306c..b2563578 100644 --- a/jupyterhub/tests/test_internal_ssl_connections.py +++ b/jupyterhub/tests/test_internal_ssl_connections.py @@ -4,8 +4,7 @@ import time from unittest import mock import pytest -from requests.exceptions import ConnectionError -from requests.exceptions import SSLError +from requests.exceptions import ConnectionError, SSLError from ..utils import AnyTimeoutError from .test_api import add_user diff --git a/jupyterhub/tests/test_memoize.py b/jupyterhub/tests/test_memoize.py new file mode 100644 index 00000000..c37942a8 --- /dev/null +++ b/jupyterhub/tests/test_memoize.py @@ -0,0 +1,94 @@ +import pytest + +from jupyterhub._memoize import DoNotCache, FrozenDict, LRUCache, lru_cache_key + + +def test_lru_cache(): + cache = LRUCache(maxsize=2) + cache["a"] = 1 + assert "a" in cache + assert "b" not in cache + cache["b"] = 2 + assert cache["b"] == 2 + + # accessing a makes it more recent than b + assert cache["a"] == 1 + assert "b" in cache + assert "a" in cache + + # storing c pushes oldest ('b') out of cache + cache["c"] = 3 + assert len(cache._cache) == 2 + assert "a" in cache + assert "c" in cache + assert "b" not in cache + + +def test_lru_cache_key(): + + call_count = 0 + + @lru_cache_key(frozenset) + def reverse(arg): + nonlocal call_count + call_count += 1 + return list(reversed(arg)) + + in1 = [1, 2] + before = call_count + out1 = reverse(in1) + assert call_count == before + 1 + assert out1 == [2, 1] + + before = call_count + out2 = reverse(in1) + assert call_count == before + assert out2 is out1 + + +def test_do_not_cache(): + + call_count = 0 + + @lru_cache_key(lambda arg: arg) + def is_even(arg): + nonlocal call_count + call_count += 1 + if arg % 2: + return DoNotCache(False) + return True + + before = call_count + assert is_even(0) == True + assert call_count == before + 1 + + # caches even results + before = call_count + assert is_even(0) == True + assert call_count == before + + before = call_count + assert is_even(1) == False + assert call_count == before + 1 + + # doesn't cache odd results + before = call_count + assert is_even(1) == False + assert call_count == before + 1 + + +@pytest.mark.parametrize( + "d", + [ + {"key": "value"}, + {"key": ["list"]}, + {"key": {"set"}}, + {"key": ("tu", "ple")}, + {"key": {"nested": ["dict"]}}, + ], +) +def test_frozen_dict(d): + frozen_1 = FrozenDict(d) + frozen_2 = FrozenDict(d) + assert hash(frozen_1) == hash(frozen_2) + assert frozen_1 == frozen_2 diff --git a/jupyterhub/tests/test_metrics.py b/jupyterhub/tests/test_metrics.py index 795ca89d..072dba7b 100644 --- a/jupyterhub/tests/test_metrics.py +++ b/jupyterhub/tests/test_metrics.py @@ -3,11 +3,9 @@ from unittest import mock import pytest -from .utils import api_request -from .utils import get_page -from jupyterhub import metrics -from jupyterhub import orm -from jupyterhub import roles +from jupyterhub import metrics, orm, roles + +from .utils import api_request, get_page async def test_total_users(app): diff --git a/jupyterhub/tests/test_named_servers.py b/jupyterhub/tests/test_named_servers.py index ee1b3961..70564fc1 100644 --- a/jupyterhub/tests/test_named_servers.py +++ b/jupyterhub/tests/test_named_servers.py @@ -2,22 +2,15 @@ import asyncio import json from unittest import mock -from urllib.parse import urlencode -from urllib.parse import urlparse +from urllib.parse import urlencode, urlparse import pytest from tornado.httputil import url_concat from ..utils import url_path_join -from .mocking import FormSpawner -from .mocking import public_url -from .test_api import add_user -from .test_api import api_request -from .test_api import fill_user -from .test_api import normalize_user -from .test_api import TIMESTAMP -from .utils import async_requests -from .utils import get_page +from .mocking import FormSpawner, public_url +from .test_api import TIMESTAMP, add_user, api_request, fill_user, normalize_user +from .utils import async_requests, get_page @pytest.fixture diff --git a/jupyterhub/tests/test_orm.py b/jupyterhub/tests/test_orm.py index c9ed8c51..7a7f0310 100644 --- a/jupyterhub/tests/test_orm.py +++ b/jupyterhub/tests/test_orm.py @@ -3,17 +3,13 @@ # Distributed under the terms of the Modified BSD License. import os import socket -from datetime import datetime -from datetime import timedelta +from datetime import datetime, timedelta from unittest import mock import pytest from tornado import gen -from .. import crypto -from .. import objects -from .. import orm -from .. import roles +from .. import crypto, objects, orm, roles from ..emptyclass import EmptyClass from ..user import User from .mocking import MockSpawner diff --git a/jupyterhub/tests/test_pages.py b/jupyterhub/tests/test_pages.py index a4f3e805..5d3911bb 100644 --- a/jupyterhub/tests/test_pages.py +++ b/jupyterhub/tests/test_pages.py @@ -2,31 +2,28 @@ import asyncio import sys from unittest import mock -from urllib.parse import parse_qs -from urllib.parse import urlencode -from urllib.parse import urlparse +from urllib.parse import parse_qs, urlencode, urlparse import pytest from bs4 import BeautifulSoup from tornado.escape import url_escape from tornado.httputil import url_concat -from .. import orm -from .. import roles -from .. import scopes +from .. import orm, roles, scopes from ..auth import Authenticator from ..handlers import BaseHandler from ..utils import url_path_join from ..utils import url_path_join as ujoin -from .mocking import FalsyCallableFormSpawner -from .mocking import FormSpawner +from .mocking import FalsyCallableFormSpawner, FormSpawner from .test_api import next_event -from .utils import api_request -from .utils import async_requests -from .utils import AsyncSession -from .utils import get_page -from .utils import public_host -from .utils import public_url +from .utils import ( + AsyncSession, + api_request, + async_requests, + get_page, + public_host, + public_url, +) async def test_root_no_auth(app): diff --git a/jupyterhub/tests/test_proxy.py b/jupyterhub/tests/test_proxy.py index dafa29cd..a9caa3ea 100644 --- a/jupyterhub/tests/test_proxy.py +++ b/jupyterhub/tests/test_proxy.py @@ -3,8 +3,7 @@ import json import os from contextlib import contextmanager from subprocess import Popen -from urllib.parse import quote -from urllib.parse import urlparse +from urllib.parse import quote, urlparse import pytest from traitlets.config import Config @@ -12,8 +11,7 @@ from traitlets.config import Config from ..utils import url_path_join as ujoin from ..utils import wait_for_http_server from .mocking import MockHub -from .test_api import add_user -from .test_api import api_request +from .test_api import add_user, api_request from .utils import skip_if_ssl diff --git a/jupyterhub/tests/test_roles.py b/jupyterhub/tests/test_roles.py index 2ef1e69b..b62d9a22 100644 --- a/jupyterhub/tests/test_roles.py +++ b/jupyterhub/tests/test_roles.py @@ -8,14 +8,11 @@ import pytest from pytest import mark from tornado.log import app_log -from .. import orm -from .. import roles -from ..scopes import get_scopes_for -from ..scopes import scope_definitions +from .. import orm, roles +from ..scopes import get_scopes_for, scope_definitions from ..utils import utcnow from .mocking import MockHub -from .utils import add_user -from .utils import api_request +from .utils import add_user, api_request @mark.role diff --git a/jupyterhub/tests/test_scopes.py b/jupyterhub/tests/test_scopes.py index 7cb25664..b594b0e7 100644 --- a/jupyterhub/tests/test_scopes.py +++ b/jupyterhub/tests/test_scopes.py @@ -7,22 +7,21 @@ from pytest import mark from tornado import web from tornado.httputil import HTTPServerRequest -from .. import orm -from .. import roles -from .. import scopes +from .. import orm, roles, scopes +from .._memoize import FrozenDict from ..handlers import BaseHandler -from ..scopes import _check_scope_access -from ..scopes import _expand_self_scope -from ..scopes import _intersect_expanded_scopes -from ..scopes import expand_scopes -from ..scopes import get_scopes_for -from ..scopes import identify_scopes -from ..scopes import needs_scope -from ..scopes import parse_scopes -from ..scopes import Scope -from .utils import add_user -from .utils import api_request -from .utils import auth_header +from ..scopes import ( + Scope, + _check_scope_access, + _expand_self_scope, + _intersect_expanded_scopes, + expand_scopes, + get_scopes_for, + identify_scopes, + needs_scope, + parse_scopes, +) +from .utils import add_user, api_request, auth_header def get_handler_with_scopes(scopes): @@ -40,6 +39,7 @@ def test_scope_constructor(): f'read:users!user={user2}', ] parsed_scopes = parse_scopes(scope_list) + assert isinstance(parsed_scopes, FrozenDict) assert 'read:users' in parsed_scopes assert parsed_scopes['users'] @@ -469,6 +469,7 @@ async def test_metascope_self_expansion( orm_obj = create_service_with_scopes('self') # test expansion of user/service scopes scopes = get_scopes_for(orm_obj) + assert isinstance(scopes, frozenset) assert bool(scopes) == has_user_scopes # test expansion of token scopes @@ -490,6 +491,8 @@ async def test_metascope_inherit_expansion(app, create_user_with_scopes): token.scopes.clear() app.db.commit() token_scope_set = get_scopes_for(token) + assert isinstance(token_scope_set, frozenset) + assert token_scope_set.issubset(identify_scopes(user.orm_user)) @@ -1172,4 +1175,5 @@ def test_expand_scopes(user, scopes, expected): expected.update(_expand_self_scope(user.name)) expanded = expand_scopes(scopes, owner=user.orm_user) + assert isinstance(expanded, frozenset) assert sorted(expanded) == sorted(expected) diff --git a/jupyterhub/tests/test_services.py b/jupyterhub/tests/test_services.py index 55f2b6bb..a858553b 100644 --- a/jupyterhub/tests/test_services.py +++ b/jupyterhub/tests/test_services.py @@ -8,14 +8,15 @@ from async_generator import asynccontextmanager from .. import orm from ..roles import update_roles -from ..utils import exponential_backoff -from ..utils import maybe_future -from ..utils import random_port -from ..utils import url_path_join -from ..utils import wait_for_http_server +from ..utils import ( + exponential_backoff, + maybe_future, + random_port, + url_path_join, + wait_for_http_server, +) from .mocking import public_url -from .utils import async_requests -from .utils import skip_if_ssl +from .utils import async_requests, skip_if_ssl mockservice_path = os.path.dirname(os.path.abspath(__file__)) mockservice_py = os.path.join(mockservice_path, 'mockservice.py') diff --git a/jupyterhub/tests/test_services_auth.py b/jupyterhub/tests/test_services_auth.py index 0eb8c783..8cda0dbc 100644 --- a/jupyterhub/tests/test_services_auth.py +++ b/jupyterhub/tests/test_services_auth.py @@ -4,9 +4,7 @@ import os import sys from binascii import hexlify from unittest import mock -from urllib.parse import parse_qs -from urllib.parse import quote -from urllib.parse import urlparse +from urllib.parse import parse_qs, quote, urlparse import pytest from bs4 import BeautifulSoup @@ -14,14 +12,11 @@ from pytest import raises from tornado.httputil import url_concat from tornado.log import app_log -from .. import orm -from .. import roles -from .. import scopes +from .. import orm, roles, scopes from ..services.auth import _ExpiringDict from ..utils import url_path_join from .mocking import public_url -from .utils import async_requests -from .utils import AsyncSession +from .utils import AsyncSession, async_requests # mock for sending monotonic counter way into the future monotonic_future = mock.patch('time.monotonic', lambda: sys.maxsize) @@ -365,7 +360,7 @@ async def test_oauth_service_roles( ) if 'inherit' in expected_scopes: - expected_scopes = scopes.get_scopes_for(user.orm_user) + expected_scopes = set(scopes.get_scopes_for(user.orm_user)) # always expect identify/access scopes # on successful authentication diff --git a/jupyterhub/tests/test_singleuser.py b/jupyterhub/tests/test_singleuser.py index 89dbb8f9..828138e1 100644 --- a/jupyterhub/tests/test_singleuser.py +++ b/jupyterhub/tests/test_singleuser.py @@ -2,20 +2,18 @@ import os import sys from contextlib import contextmanager -from subprocess import CalledProcessError -from subprocess import check_output +from subprocess import CalledProcessError, check_output from unittest import mock from urllib.parse import urlparse import pytest import jupyterhub + from .. import orm from ..utils import url_path_join -from .mocking import public_url -from .mocking import StubSingleUserSpawner -from .utils import async_requests -from .utils import AsyncSession +from .mocking import StubSingleUserSpawner, public_url +from .utils import AsyncSession, async_requests @contextmanager diff --git a/jupyterhub/tests/test_spawner.py b/jupyterhub/tests/test_spawner.py index 4c01944e..1cc0332a 100644 --- a/jupyterhub/tests/test_spawner.py +++ b/jupyterhub/tests/test_spawner.py @@ -16,14 +16,10 @@ import pytest from .. import orm from .. import spawner as spawnermod -from ..objects import Hub -from ..objects import Server -from ..spawner import LocalProcessSpawner -from ..spawner import Spawner +from ..objects import Hub, Server +from ..spawner import LocalProcessSpawner, Spawner from ..user import User -from ..utils import AnyTimeoutError -from ..utils import new_token -from ..utils import url_path_join +from ..utils import AnyTimeoutError, new_token, url_path_join from .mocking import public_url from .test_api import add_user from .utils import async_requests diff --git a/jupyterhub/tests/test_traitlets.py b/jupyterhub/tests/test_traitlets.py index 416b532d..8ee21208 100644 --- a/jupyterhub/tests/test_traitlets.py +++ b/jupyterhub/tests/test_traitlets.py @@ -1,10 +1,7 @@ import pytest -from traitlets import HasTraits -from traitlets import TraitError +from traitlets import HasTraits, TraitError -from jupyterhub.traitlets import ByteSpecification -from jupyterhub.traitlets import Command -from jupyterhub.traitlets import URLPrefix +from jupyterhub.traitlets import ByteSpecification, Command, URLPrefix def test_url_prefix(): diff --git a/jupyterhub/tests/test_version.py b/jupyterhub/tests/test_version.py index db9fdd82..789f420b 100644 --- a/jupyterhub/tests/test_version.py +++ b/jupyterhub/tests/test_version.py @@ -3,8 +3,7 @@ import logging import pytest -from .._version import _check_version -from .._version import reset_globals +from .._version import _check_version, reset_globals def setup_function(function): diff --git a/jupyterhub/tests/utils.py b/jupyterhub/tests/utils.py index d300c84a..2e535d4b 100644 --- a/jupyterhub/tests/utils.py +++ b/jupyterhub/tests/utils.py @@ -7,11 +7,9 @@ import pytest import requests from certipy import Certipy -from jupyterhub import metrics -from jupyterhub import orm +from jupyterhub import metrics, orm from jupyterhub.objects import Server -from jupyterhub.roles import assign_default_roles -from jupyterhub.roles import update_roles +from jupyterhub.roles import assign_default_roles, update_roles from jupyterhub.utils import url_path_join as ujoin diff --git a/jupyterhub/traitlets.py b/jupyterhub/traitlets.py index 0cc616a9..543dfcff 100644 --- a/jupyterhub/traitlets.py +++ b/jupyterhub/traitlets.py @@ -4,13 +4,7 @@ Traitlets that are used in JupyterHub # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. import entrypoints -from traitlets import Integer -from traitlets import List -from traitlets import TraitError -from traitlets import TraitType -from traitlets import Type -from traitlets import Undefined -from traitlets import Unicode +from traitlets import Integer, List, TraitError, TraitType, Type, Undefined, Unicode class URLPrefix(Unicode): diff --git a/jupyterhub/user.py b/jupyterhub/user.py index 71ee6ce5..05a555db 100644 --- a/jupyterhub/user.py +++ b/jupyterhub/user.py @@ -3,34 +3,21 @@ import json import warnings from collections import defaultdict -from datetime import datetime -from datetime import timedelta -from urllib.parse import quote -from urllib.parse import urlparse +from datetime import datetime, timedelta +from urllib.parse import quote, urlparse from sqlalchemy import inspect -from tornado import gen -from tornado import web +from tornado import gen, web from tornado.httputil import urlencode from tornado.log import app_log from . import orm -from ._version import __version__ -from ._version import _check_version -from .crypto import CryptKeeper -from .crypto import decrypt -from .crypto import encrypt -from .crypto import EncryptionUnavailable -from .crypto import InvalidToken -from .metrics import RUNNING_SERVERS -from .metrics import TOTAL_USERS +from ._version import __version__, _check_version +from .crypto import CryptKeeper, EncryptionUnavailable, InvalidToken, decrypt, encrypt +from .metrics import RUNNING_SERVERS, TOTAL_USERS from .objects import Server from .spawner import LocalProcessSpawner -from .utils import AnyTimeoutError -from .utils import make_ssl_context -from .utils import maybe_future -from .utils import url_path_join - +from .utils import AnyTimeoutError, make_ssl_context, maybe_future, url_path_join # detailed messages about the most common failure-to-start errors, # which manifest timeouts during start diff --git a/jupyterhub/utils.py b/jupyterhub/utils.py index aa3fa7f7..44bdaef9 100644 --- a/jupyterhub/utils.py +++ b/jupyterhub/utils.py @@ -16,18 +16,14 @@ import threading import uuid import warnings from binascii import b2a_hex -from datetime import datetime -from datetime import timezone +from datetime import datetime, timezone from hmac import compare_digest from operator import itemgetter from async_generator import aclosing from sqlalchemy.exc import SQLAlchemyError -from tornado import gen -from tornado import ioloop -from tornado import web -from tornado.httpclient import AsyncHTTPClient -from tornado.httpclient import HTTPError +from tornado import gen, ioloop, web +from tornado.httpclient import AsyncHTTPClient, HTTPError from tornado.log import app_log # For compatibility with python versions 3.6 or earlier. @@ -477,6 +473,7 @@ def print_stacks(file=sys.stderr): # no need to add them to startup import asyncio import traceback + from .log import coroutine_frames print("Active threads: %i" % threading.active_count(), file=file) diff --git a/pyproject.toml b/pyproject.toml index e81f0658..41e217a2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,6 @@ +[tool.isort] +profile = "black" + [tool.black] skip-string-normalization = true target_version = [ diff --git a/setup.py b/setup.py index aaae3498..3fcbdffe 100755 --- a/setup.py +++ b/setup.py @@ -9,13 +9,11 @@ import shutil import sys from subprocess import check_call -from setuptools import Command -from setuptools import setup +from setuptools import Command, setup from setuptools.command.bdist_egg import bdist_egg from setuptools.command.build_py import build_py from setuptools.command.sdist import sdist - v = sys.version_info if v[:2] < (3, 6): error = "ERROR: JupyterHub requires Python version 3.6 or above." diff --git a/share/jupyterhub/static/js/admin-react.js b/share/jupyterhub/static/js/admin-react.js index 0cf7ccc8..2790eb1a 100644 --- a/share/jupyterhub/static/js/admin-react.js +++ b/share/jupyterhub/static/js/admin-react.js @@ -1,2 +1,2 @@ /*! For license information please see admin-react.js.LICENSE.txt */ -(()=>{var e,t,n={380:(e,t,n)=>{"use strict";var r=n(294),a=n(935),o=r.createContext(null),l=function(e){e()},i=function(){return l},u={notify:function(){},get:function(){return[]}};function c(e,t){var n,r=u;function a(){l.onStateChange&&l.onStateChange()}function o(){n||(n=t?t.addNestedSub(a):e.subscribe(a),r=function(){var e=i(),t=null,n=null;return{clear:function(){t=null,n=null},notify:function(){e((function(){for(var e=t;e;)e.callback(),e=e.next}))},get:function(){for(var e=[],n=t;n;)e.push(n),n=n.next;return e},subscribe:function(e){var r=!0,a=n={callback:e,next:null,prev:n};return a.prev?a.prev.next=a:t=a,function(){r&&null!==t&&(r=!1,a.next?a.next.prev=a.prev:n=a.prev,a.prev?a.prev.next=a.next:t=a.next)}}}}())}var l={addNestedSub:function(e){return o(),r.subscribe(e)},notifyNestedSubs:function(){r.notify()},handleChangeWrapper:a,isSubscribed:function(){return Boolean(n)},trySubscribe:o,tryUnsubscribe:function(){n&&(n(),n=void 0,r.clear(),r=u)},getListeners:function(){return r}};return l}var s="undefined"!=typeof window&&void 0!==window.document&&void 0!==window.document.createElement?r.useLayoutEffect:r.useEffect;const f=function(e){var t=e.store,n=e.context,a=e.children,l=(0,r.useMemo)((function(){var e=c(t);return e.onStateChange=e.notifyNestedSubs,{store:t,subscription:e}}),[t]),i=(0,r.useMemo)((function(){return t.getState()}),[t]);s((function(){var e=l.subscription;return e.trySubscribe(),i!==t.getState()&&e.notifyNestedSubs(),function(){e.tryUnsubscribe(),e.onStateChange=null}}),[l,i]);var u=n||o;return r.createElement(u.Provider,{value:l},a)};function d(){return d=Object.assign||function(e){for(var t=1;t1&&void 0!==arguments[1]?arguments[1]:"";return R("/users/"+e+"/servers/"+(t||""),"POST")},stopServer:function(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:"";return R("/users/"+e+"/servers/"+(t||""),"DELETE")},startAll:function(e){return e.map((function(e){return R("/users/"+e+"/server","POST")}))},stopAll:function(e){return e.map((function(e){return R("/users/"+e+"/server","DELETE")}))},addToGroup:function(e,t){return R("/groups/"+t+"/users","POST",{users:e})},removeFromGroup:function(e,t){return R("/groups/"+t+"/users","DELETE",{users:e})},createGroup:function(e){return R("/groups/"+e,"POST")},deleteGroup:function(e){return R("/groups/"+e,"DELETE")},addUsers:function(e,t){return R("/users","POST",{usernames:e,admin:t})},editUser:function(e,t,n){return R("/users/"+e,"PATCH",{name:t,admin:n})},deleteUser:function(e){return R("/users/"+e,"DELETE")},findUser:function(e){return R("/users/"+e,"GET")},validateUser:function(e){return R("/users/"+e,"GET").then((function(e){return e.status})).then((function(e){return!(e>200)}))},failRegexEvent:function(){return null},noChangeEvent:function(){return null},refreshGroupsData:function(){return R("/groups","GET").then((function(e){return e.json()}))},refreshUserData:function(){return R("/users","GET").then((function(e){return e.json()}))}}},I=function(e){return d({},e,P())},function(e){var t=(0,r.createFactory)(e);return function(e){return t(I(e))}});var I;function A(e){return"/"===e.charAt(0)}function D(e,t){for(var n=t,r=n+1,a=e.length;r=0;s--){var f=a[s];"."===f?D(a,s):".."===f?(D(a,s),c++):c&&(D(a,s),c--)}if(!i)for(;c--;c)a.unshift("..");!i||""===a[0]||a[0]&&A(a[0])||a.unshift("");var d=a.join("/");return n&&"/"!==d.substr(-1)&&(d+="/"),d};"undefined"==typeof window||!window.document||window.document.createElement;var F=n(697),U=n.n(F),$=1073741823,B="undefined"!=typeof globalThis?globalThis:"undefined"!=typeof window?window:void 0!==n.g?n.g:{};function H(e){var t=[];return{on:function(e){t.push(e)},off:function(e){t=t.filter((function(t){return t!==e}))},get:function(){return e},set:function(n,r){e=n,t.forEach((function(t){return t(e,r)}))}}}var V=r.createContext||function(e,t){var n,a,o,l="__create-react-context-"+((B[o="__global_unique_id__"]=(B[o]||0)+1)+"__"),i=function(e){function n(){var t;return(t=e.apply(this,arguments)||this).emitter=H(t.props.value),t}O(n,e);var r=n.prototype;return r.getChildContext=function(){var e;return(e={})[l]=this.emitter,e},r.componentWillReceiveProps=function(e){if(this.props.value!==e.value){var n,r=this.props.value,a=e.value;((o=r)===(l=a)?0!==o||1/o==1/l:o!=o&&l!=l)?n=0:(n="function"==typeof t?t(r,a):$,0!=(n|=0)&&this.emitter.set(e.value,n))}var o,l},r.render=function(){return this.props.children},n}(r.Component);i.childContextTypes=((n={})[l]=U().object.isRequired,n);var u=function(t){function n(){var e;return(e=t.apply(this,arguments)||this).state={value:e.getValue()},e.onUpdate=function(t,n){0!=((0|e.observedBits)&n)&&e.setState({value:e.getValue()})},e}O(n,t);var r=n.prototype;return r.componentWillReceiveProps=function(e){var t=e.observedBits;this.observedBits=null==t?$:t},r.componentDidMount=function(){this.context[l]&&this.context[l].on(this.onUpdate);var e=this.props.observedBits;this.observedBits=null==e?$:e},r.componentWillUnmount=function(){this.context[l]&&this.context[l].off(this.onUpdate)},r.getValue=function(){return this.context[l]?this.context[l].get():e},r.render=function(){return(e=this.props.children,Array.isArray(e)?e[0]:e)(this.state.value);var e},n}(r.Component);return u.contextTypes=((a={})[l]=U().object,a),{Provider:i,Consumer:u}};const W=V;function G(e,t){if(!e)throw new Error("Invariant failed")}var Q=n(779),q=n.n(Q);function K(e,t){if(null==e)return{};var n,r,a={},o=Object.keys(e);for(r=0;r=0||(a[n]=e[n]);return a}n(663);var Y=function(e){var t=W();return t.displayName=e,t},X=Y("Router-History"),J=Y("Router"),Z=function(e){function t(t){var n;return(n=e.call(this,t)||this).state={location:t.history.location},n._isMounted=!1,n._pendingLocation=null,t.staticContext||(n.unlisten=t.history.listen((function(e){n._isMounted?n.setState({location:e}):n._pendingLocation=e}))),n}O(t,e),t.computeRootMatch=function(e){return{path:"/",url:"/",params:{},isExact:"/"===e}};var n=t.prototype;return n.componentDidMount=function(){this._isMounted=!0,this._pendingLocation&&this.setState({location:this._pendingLocation})},n.componentWillUnmount=function(){this.unlisten&&(this.unlisten(),this._isMounted=!1,this._pendingLocation=null)},n.render=function(){return r.createElement(J.Provider,{value:{history:this.props.history,location:this.state.location,match:t.computeRootMatch(this.state.location.pathname),staticContext:this.props.staticContext}},r.createElement(X.Provider,{children:this.props.children||null,value:this.props.history}))},t}(r.Component);r.Component,r.Component;var ee={},te=0;function ne(e,t){void 0===t&&(t={}),("string"==typeof t||Array.isArray(t))&&(t={path:t});var n=t,r=n.path,a=n.exact,o=void 0!==a&&a,l=n.strict,i=void 0!==l&&l,u=n.sensitive,c=void 0!==u&&u;return[].concat(r).reduce((function(t,n){if(!n&&""!==n)return null;if(t)return t;var r=function(e,t){var n=""+t.end+t.strict+t.sensitive,r=ee[n]||(ee[n]={});if(r[e])return r[e];var a=[],o={regexp:q()(e,a,t),keys:a};return te<1e4&&(r[e]=o,te++),o}(n,{end:o,strict:i,sensitive:c}),a=r.regexp,l=r.keys,u=a.exec(e);if(!u)return null;var s=u[0],f=u.slice(1),d=e===s;return o&&!d?null:{path:n,url:"/"===n&&""===s?"/":s,isExact:d,params:l.reduce((function(e,t,n){return e[t.name]=f[n],e}),{})}}),null)}var re=function(e){function t(){return e.apply(this,arguments)||this}return O(t,e),t.prototype.render=function(){var e=this;return r.createElement(J.Consumer,null,(function(t){t||G(!1);var n=e.props.location||t.location,a=d({},t,{location:n,match:e.props.computedMatch?e.props.computedMatch:e.props.path?ne(n.pathname,e.props):t.match}),o=e.props,l=o.children,i=o.component,u=o.render;return Array.isArray(l)&&function(e){return 0===r.Children.count(e)}(l)&&(l=null),r.createElement(J.Provider,{value:a},a.match?l?"function"==typeof l?l(a):l:i?r.createElement(i,a):u?u(a):null:"function"==typeof l?l(a):null)}))},t}(r.Component);r.Component;var ae=function(e){function t(){return e.apply(this,arguments)||this}return O(t,e),t.prototype.render=function(){var e=this;return r.createElement(J.Consumer,null,(function(t){t||G(!1);var n,a,o=e.props.location||t.location;return r.Children.forEach(e.props.children,(function(e){if(null==a&&r.isValidElement(e)){n=e;var l=e.props.path||e.props.from;a=l?ne(o.pathname,d({},e.props,{path:l})):t.match}})),a?r.cloneElement(n,{location:o,computedMatch:a}):null}))},t}(r.Component);function oe(e){return"/"===e.charAt(0)?e:"/"+e}function le(e){return"/"===e.charAt(0)?e.substr(1):e}function ie(e,t){return function(e,t){return 0===e.toLowerCase().indexOf(t.toLowerCase())&&-1!=="/?#".indexOf(e.charAt(t.length))}(e,t)?e.substr(t.length):e}function ue(e){return"/"===e.charAt(e.length-1)?e.slice(0,-1):e}function ce(e){var t=e.pathname,n=e.search,r=e.hash,a=t||"/";return n&&"?"!==n&&(a+="?"===n.charAt(0)?n:"?"+n),r&&"#"!==r&&(a+="#"===r.charAt(0)?r:"#"+r),a}function se(e,t,n,r){var a;"string"==typeof e?(a=function(e){var t=e||"/",n="",r="",a=t.indexOf("#");-1!==a&&(r=t.substr(a),t=t.substr(0,a));var o=t.indexOf("?");return-1!==o&&(n=t.substr(o),t=t.substr(0,o)),{pathname:t,search:"?"===n?"":n,hash:"#"===r?"":r}}(e),a.state=t):(void 0===(a=d({},e)).pathname&&(a.pathname=""),a.search?"?"!==a.search.charAt(0)&&(a.search="?"+a.search):a.search="",a.hash?"#"!==a.hash.charAt(0)&&(a.hash="#"+a.hash):a.hash="",void 0!==t&&void 0===a.state&&(a.state=t));try{a.pathname=decodeURI(a.pathname)}catch(e){throw e instanceof URIError?new URIError('Pathname "'+a.pathname+'" could not be decoded. This is likely caused by an invalid percent-encoding.'):e}return n&&(a.key=n),r?a.pathname?"/"!==a.pathname.charAt(0)&&(a.pathname=z(a.pathname,r.pathname)):a.pathname=r.pathname:a.pathname||(a.pathname="/"),a}function fe(){var e=null,t=[];return{setPrompt:function(t){return e=t,function(){e===t&&(e=null)}},confirmTransitionTo:function(t,n,r,a){if(null!=e){var o="function"==typeof e?e(t,n):e;"string"==typeof o?"function"==typeof r?r(o,a):a(!0):a(!1!==o)}else a(!0)},appendListener:function(e){var n=!0;function r(){n&&e.apply(void 0,arguments)}return t.push(r),function(){n=!1,t=t.filter((function(e){return e!==r}))}},notifyListeners:function(){for(var e=arguments.length,n=new Array(e),r=0;r{(t||"a"===e&&function(e){return!e||"#"===e.trim()}(n))&&r.preventDefault(),t?r.stopPropagation():null==o||o(r)};return"a"===e&&(n||(n="#"),t&&(n=void 0)),[{role:"button",disabled:void 0,tabIndex:t?void 0:l,href:n,target:"a"===e?r:void 0,"aria-disabled":t||void 0,rel:"a"===e?a:void 0,onClick:c,onKeyDown:e=>{" "===e.key&&(e.preventDefault(),c(e))}},u]}r.forwardRef(((e,t)=>{let{as:n,disabled:r}=e,a=function(e,t){if(null==e)return{};var n,r,a={},o=Object.keys(e);for(r=0;r=0||(a[n]=e[n]);return a}(e,Me);const[o,{tagName:l}]=Ie(Object.assign({tagName:n,disabled:r},a));return(0,Re.jsx)(l,Object.assign({},a,o,{ref:t}))})).displayName="Button";const Ae=r.createContext({prefixes:{},breakpoints:["xxl","xl","lg","md","sm","xs"]}),{Consumer:De,Provider:ze}=Ae;function Fe(e,t){const{prefixes:n}=(0,r.useContext)(Ae);return e||n[t]||t}function Ue(){const{breakpoints:e}=(0,r.useContext)(Ae);return e}const $e=r.forwardRef((({as:e,bsPrefix:t,variant:n,size:r,active:a,className:o,...l},i)=>{const u=Fe(t,"btn"),[c,{tagName:s}]=Ie({tagName:e,...l}),f=s;return(0,Re.jsx)(f,{...c,...l,ref:i,className:je()(o,u,a&&"active",n&&`${u}-${n}`,r&&`${u}-${r}`,l.href&&l.disabled&&"disabled")})}));$e.displayName="Button",$e.defaultProps={variant:"primary",active:!1,disabled:!1};const Be=$e;var He=/([A-Z])/g,Ve=/^ms-/;function We(e){return function(e){return e.replace(He,"-$1").toLowerCase()}(e).replace(Ve,"-ms-")}var Ge=/^((translate|rotate|scale)(X|Y|Z|3d)?|matrix(3d)?|perspective|skew(X|Y)?)$/i;const Qe=function(e,t){var n="",r="";if("string"==typeof t)return e.style.getPropertyValue(We(t))||function(e,t){return function(e){var t=function(e){return e&&e.ownerDocument||document}(e);return t&&t.defaultView||window}(e).getComputedStyle(e,void 0)}(e).getPropertyValue(We(t));Object.keys(t).forEach((function(a){var o=t[a];o||0===o?function(e){return!(!e||!Ge.test(e))}(a)?r+=a+"("+o+") ":n+=We(a)+": "+o+";":e.style.removeProperty(We(a))})),r&&(n+="transform: "+r+";"),e.style.cssText+=";"+n},qe=r.createContext(null);var Ke="unmounted",Ye="exited",Xe="entering",Je="entered",Ze="exiting",et=function(e){function t(t,n){var r;r=e.call(this,t,n)||this;var a,o=n&&!n.isMounting?t.enter:t.appear;return r.appearStatus=null,t.in?o?(a=Ye,r.appearStatus=Xe):a=Je:a=t.unmountOnExit||t.mountOnEnter?Ke:Ye,r.state={status:a},r.nextCallback=null,r}O(t,e),t.getDerivedStateFromProps=function(e,t){return e.in&&t.status===Ke?{status:Ye}:null};var n=t.prototype;return n.componentDidMount=function(){this.updateStatus(!0,this.appearStatus)},n.componentDidUpdate=function(e){var t=null;if(e!==this.props){var n=this.state.status;this.props.in?n!==Xe&&n!==Je&&(t=Xe):n!==Xe&&n!==Je||(t=Ze)}this.updateStatus(!1,t)},n.componentWillUnmount=function(){this.cancelNextCallback()},n.getTimeouts=function(){var e,t,n,r=this.props.timeout;return e=t=n=r,null!=r&&"number"!=typeof r&&(e=r.exit,t=r.enter,n=void 0!==r.appear?r.appear:t),{exit:e,enter:t,appear:n}},n.updateStatus=function(e,t){void 0===e&&(e=!1),null!==t?(this.cancelNextCallback(),t===Xe?this.performEnter(e):this.performExit()):this.props.unmountOnExit&&this.state.status===Ye&&this.setState({status:Ke})},n.performEnter=function(e){var t=this,n=this.props.enter,r=this.context?this.context.isMounting:e,o=this.props.nodeRef?[r]:[a.findDOMNode(this),r],l=o[0],i=o[1],u=this.getTimeouts(),c=r?u.appear:u.enter;e||n?(this.props.onEnter(l,i),this.safeSetState({status:Xe},(function(){t.props.onEntering(l,i),t.onTransitionEnd(c,(function(){t.safeSetState({status:Je},(function(){t.props.onEntered(l,i)}))}))}))):this.safeSetState({status:Je},(function(){t.props.onEntered(l)}))},n.performExit=function(){var e=this,t=this.props.exit,n=this.getTimeouts(),r=this.props.nodeRef?void 0:a.findDOMNode(this);t?(this.props.onExit(r),this.safeSetState({status:Ze},(function(){e.props.onExiting(r),e.onTransitionEnd(n.exit,(function(){e.safeSetState({status:Ye},(function(){e.props.onExited(r)}))}))}))):this.safeSetState({status:Ye},(function(){e.props.onExited(r)}))},n.cancelNextCallback=function(){null!==this.nextCallback&&(this.nextCallback.cancel(),this.nextCallback=null)},n.safeSetState=function(e,t){t=this.setNextCallback(t),this.setState(e,t)},n.setNextCallback=function(e){var t=this,n=!0;return this.nextCallback=function(r){n&&(n=!1,t.nextCallback=null,e(r))},this.nextCallback.cancel=function(){n=!1},this.nextCallback},n.onTransitionEnd=function(e,t){this.setNextCallback(t);var n=this.props.nodeRef?this.props.nodeRef.current:a.findDOMNode(this),r=null==e&&!this.props.addEndListener;if(n&&!r){if(this.props.addEndListener){var o=this.props.nodeRef?[this.nextCallback]:[n,this.nextCallback],l=o[0],i=o[1];this.props.addEndListener(l,i)}null!=e&&setTimeout(this.nextCallback,e)}else setTimeout(this.nextCallback,0)},n.render=function(){var e=this.state.status;if(e===Ke)return null;var t=this.props,n=t.children,a=(t.in,t.mountOnEnter,t.unmountOnExit,t.appear,t.enter,t.exit,t.timeout,t.addEndListener,t.onEnter,t.onEntering,t.onEntered,t.onExit,t.onExiting,t.onExited,t.nodeRef,K(t,["children","in","mountOnEnter","unmountOnExit","appear","enter","exit","timeout","addEndListener","onEnter","onEntering","onEntered","onExit","onExiting","onExited","nodeRef"]));return r.createElement(qe.Provider,{value:null},"function"==typeof n?n(e,a):r.cloneElement(r.Children.only(n),a))},t}(r.Component);function tt(){}et.contextType=qe,et.propTypes={},et.defaultProps={in:!1,mountOnEnter:!1,unmountOnExit:!1,appear:!1,enter:!0,exit:!0,onEnter:tt,onEntering:tt,onEntered:tt,onExit:tt,onExiting:tt,onExited:tt},et.UNMOUNTED=Ke,et.EXITED=Ye,et.ENTERING=Xe,et.ENTERED=Je,et.EXITING=Ze;const nt=et,rt=!("undefined"==typeof window||!window.document||!window.document.createElement);var at=!1,ot=!1;try{var lt={get passive(){return at=!0},get once(){return ot=at=!0}};rt&&(window.addEventListener("test",lt,lt),window.removeEventListener("test",lt,!0))}catch(e){}const it=function(e,t,n,r){return function(e,t,n,r){if(r&&"boolean"!=typeof r&&!ot){var a=r.once,o=r.capture,l=n;!ot&&a&&(l=n.__once||function e(r){this.removeEventListener(t,e,o),n.call(this,r)},n.__once=l),e.addEventListener(t,l,at?r:o)}e.addEventListener(t,n,r)}(e,t,n,r),function(){!function(e,t,n,r){var a=r&&"boolean"!=typeof r?r.capture:r;e.removeEventListener(t,n,a),n.__once&&e.removeEventListener(t,n.__once,a)}(e,t,n,r)}};function ut(e,t,n,r){var a,o;null==n&&(o=-1===(a=Qe(e,"transitionDuration")||"").indexOf("ms")?1e3:1,n=parseFloat(a)*o||0);var l=function(e,t,n){void 0===n&&(n=5);var r=!1,a=setTimeout((function(){r||function(e,t,n,r){if(void 0===n&&(n=!1),void 0===r&&(r=!0),e){var a=document.createEvent("HTMLEvents");a.initEvent("transitionend",n,r),e.dispatchEvent(a)}}(e,0,!0)}),t+n),o=it(e,"transitionend",(function(){r=!0}),{once:!0});return function(){clearTimeout(a),o()}}(e,n,r),i=it(e,"transitionend",t);return function(){l(),i()}}function ct(e,t){const n=Qe(e,t)||"",r=-1===n.indexOf("ms")?1e3:1;return parseFloat(n)*r}function st(e,t){const n=ct(e,"transitionDuration"),r=ct(e,"transitionDelay"),a=ut(e,(n=>{n.target===e&&(a(),t(n))}),n+r)}const ft=function(...e){return e.filter((e=>null!=e)).reduce(((e,t)=>{if("function"!=typeof t)throw new Error("Invalid Argument Type, must only provide functions, undefined, or null.");return null===e?t:function(...n){e.apply(this,n),t.apply(this,n)}}),null)};var dt=function(e){return e&&"function"!=typeof e?function(t){e.current=t}:e};const pt=r.forwardRef((({onEnter:e,onEntering:t,onEntered:n,onExit:o,onExiting:l,onExited:i,addEndListener:u,children:c,childRef:s,...f},d)=>{const p=(0,r.useRef)(null),m=(S=p,_=s,(0,r.useMemo)((function(){return function(e,t){var n=dt(e),r=dt(t);return function(e){n&&n(e),r&&r(e)}}(S,_)}),[S,_])),h=e=>{var t;m((t=e)&&"setState"in t?a.findDOMNode(t):null!=t?t:null)},v=e=>t=>{e&&p.current&&e(p.current,t)},y=(0,r.useCallback)(v(e),[e]),g=(0,r.useCallback)(v(t),[t]),b=(0,r.useCallback)(v(n),[n]),E=(0,r.useCallback)(v(o),[o]),w=(0,r.useCallback)(v(l),[l]),k=(0,r.useCallback)(v(i),[i]),x=(0,r.useCallback)(v(u),[u]);var S,_;return(0,Re.jsx)(nt,{ref:d,...f,onEnter:y,onEntered:b,onEntering:g,onExit:E,onExited:k,onExiting:w,addEndListener:x,nodeRef:p,children:"function"==typeof c?(e,t)=>c(e,{...t,ref:h}):r.cloneElement(c,{ref:h})})})),mt={height:["marginTop","marginBottom"],width:["marginLeft","marginRight"]};function ht(e,t){const n=t[`offset${e[0].toUpperCase()}${e.slice(1)}`],r=mt[e];return n+parseInt(Qe(t,r[0]),10)+parseInt(Qe(t,r[1]),10)}const vt={[Ye]:"collapse",[Ze]:"collapsing",[Xe]:"collapsing",[Je]:"collapse show"},yt={in:!1,timeout:300,mountOnEnter:!1,unmountOnExit:!1,appear:!1,getDimensionValue:ht},gt=r.forwardRef((({onEnter:e,onEntering:t,onEntered:n,onExit:a,onExiting:o,className:l,children:i,dimension:u="height",getDimensionValue:c=ht,...s},f)=>{const d="function"==typeof u?u():u,p=(0,r.useMemo)((()=>ft((e=>{e.style[d]="0"}),e)),[d,e]),m=(0,r.useMemo)((()=>ft((e=>{const t=`scroll${d[0].toUpperCase()}${d.slice(1)}`;e.style[d]=`${e[t]}px`}),t)),[d,t]),h=(0,r.useMemo)((()=>ft((e=>{e.style[d]=null}),n)),[d,n]),v=(0,r.useMemo)((()=>ft((e=>{e.style[d]=`${c(d,e)}px`,e.offsetHeight}),a)),[a,c,d]),y=(0,r.useMemo)((()=>ft((e=>{e.style[d]=null}),o)),[d,o]);return(0,Re.jsx)(pt,{ref:f,addEndListener:st,...s,"aria-expanded":s.role?s.in:null,onEnter:p,onEntering:m,onEntered:h,onExit:v,onExiting:y,childRef:i.ref,children:(e,t)=>r.cloneElement(i,{...t,className:je()(l,i.props.className,vt[e],"width"===d&&"collapse-horizontal")})})}));gt.defaultProps=yt;const bt=gt;var Et=/-(.)/g;const wt=e=>{return e[0].toUpperCase()+(t=e,t.replace(Et,(function(e,t){return t.toUpperCase()}))).slice(1);var t};function kt(e,{displayName:t=wt(e),Component:n,defaultProps:a}={}){const o=r.forwardRef((({className:t,bsPrefix:r,as:a=n||"div",...o},l)=>{const i=Fe(r,e);return(0,Re.jsx)(a,{ref:l,className:je()(t,i),...o})}));return o.defaultProps=a,o.displayName=t,o}const xt=kt("card-group"),St=e=>r.forwardRef(((t,n)=>(0,Re.jsx)("div",{...t,ref:n,className:je()(t.className,e)}))),_t=r.forwardRef((({bsPrefix:e,className:t,variant:n,as:r="img",...a},o)=>{const l=Fe(e,"card-img");return(0,Re.jsx)(r,{ref:o,className:je()(n?`${l}-${n}`:l,t),...a})}));_t.displayName="CardImg";const Ct=_t,Nt=r.createContext(null);Nt.displayName="CardHeaderContext";const Ot=Nt,Pt=r.forwardRef((({bsPrefix:e,className:t,as:n="div",...a},o)=>{const l=Fe(e,"card-header"),i=(0,r.useMemo)((()=>({cardHeaderBsPrefix:l})),[l]);return(0,Re.jsx)(Ot.Provider,{value:i,children:(0,Re.jsx)(n,{ref:o,...a,className:je()(t,l)})})}));Pt.displayName="CardHeader";const Tt=Pt,Lt=St("h5"),jt=St("h6"),Rt=kt("card-body"),Mt=kt("card-title",{Component:Lt}),It=kt("card-subtitle",{Component:jt}),At=kt("card-link",{Component:"a"}),Dt=kt("card-text",{Component:"p"}),zt=kt("card-footer"),Ft=kt("card-img-overlay"),Ut=r.forwardRef((({bsPrefix:e,className:t,bg:n,text:r,border:a,body:o,children:l,as:i="div",...u},c)=>{const s=Fe(e,"card");return(0,Re.jsx)(i,{ref:c,...u,className:je()(t,s,n&&`bg-${n}`,r&&`text-${r}`,a&&`border-${a}`),children:o?(0,Re.jsx)(Rt,{children:l}):l})}));Ut.displayName="Card",Ut.defaultProps={body:!1};const $t=Object.assign(Ut,{Img:Ct,Title:Mt,Subtitle:It,Body:Rt,Link:At,Text:Dt,Header:Tt,Footer:zt,ImgOverlay:Ft}),Bt=r.forwardRef((({bsPrefix:e,className:t,as:n="div",...r},a)=>{const o=Fe(e,"row"),l=Ue(),i=`${o}-cols`,u=[];return l.forEach((e=>{const t=r[e];let n;delete r[e],null!=t&&"object"==typeof t?({cols:n}=t):n=t;const a="xs"!==e?`-${e}`:"";null!=n&&u.push(`${i}${a}-${n}`)})),(0,Re.jsx)(n,{ref:a,...r,className:je()(t,o,...u)})}));Bt.displayName="Row";const Ht=Bt,Vt=r.forwardRef(((e,t)=>{const[{className:n,...r},{as:a="div",bsPrefix:o,spans:l}]=function({as:e,bsPrefix:t,className:n,...r}){t=Fe(t,"col");const a=Ue(),o=[],l=[];return a.forEach((e=>{const n=r[e];let a,i,u;delete r[e],"object"==typeof n&&null!=n?({span:a,offset:i,order:u}=n):a=n;const c="xs"!==e?`-${e}`:"";a&&o.push(!0===a?`${t}${c}`:`${t}${c}-${a}`),null!=u&&l.push(`order${c}-${u}`),null!=i&&l.push(`offset${c}-${i}`)})),[{...r,className:je()(n,...o,...l)},{as:e,bsPrefix:t,spans:o}]}(e);return(0,Re.jsx)(a,{...r,ref:t,className:je()(n,!l.length&&o)})}));Vt.displayName="Col";const Wt=Vt;n(473);const Gt={type:U().string,tooltip:U().bool,as:U().elementType},Qt=r.forwardRef((({as:e="div",className:t,type:n="valid",tooltip:r=!1,...a},o)=>(0,Re.jsx)(e,{...a,ref:o,className:je()(t,`${n}-${r?"tooltip":"feedback"}`)})));Qt.displayName="Feedback",Qt.propTypes=Gt;const qt=Qt,Kt=r.createContext({}),Yt=r.forwardRef((({bsPrefix:e,type:t,size:n,htmlSize:a,id:o,className:l,isValid:i=!1,isInvalid:u=!1,plaintext:c,readOnly:s,as:f="input",...d},p)=>{const{controlId:m}=(0,r.useContext)(Kt);let h;return e=Fe(e,"form-control"),h=c?{[`${e}-plaintext`]:!0}:{[e]:!0,[`${e}-${n}`]:n},(0,Re.jsx)(f,{...d,type:t,size:a,ref:p,readOnly:s,id:o||m,className:je()(l,h,i&&"is-valid",u&&"is-invalid","color"===t&&`${e}-color`)})}));Yt.displayName="FormControl";const Xt=Object.assign(Yt,{Feedback:qt});var Jt=n(445),Zt=n.n(Jt),en={color:void 0,size:void 0,className:void 0,style:void 0,attr:void 0},tn=r.createContext&&r.createContext(en),nn=function(){return nn=Object.assign||function(e){for(var t,n=1,r=arguments.length;n=1?r.createElement("button",{className:"btn btn-sm btn-light spaced"},r.createElement(Ce,{to:"".concat(t,"?page=").concat(n-1)},r.createElement("span",{className:"active-pagination"},"Previous"))):r.createElement("button",{className:"btn btn-sm btn-light spaced"},r.createElement("span",{className:"inactive-pagination"},"Previous")),l>=a?r.createElement("button",{className:"btn btn-sm btn-light spaced"},r.createElement(Ce,{to:"".concat(t,"?page=").concat(n+1)},r.createElement("span",{className:"active-pagination"},"Next"))):r.createElement("button",{className:"btn btn-sm btn-light spaced"},r.createElement("span",{className:"inactive-pagination"},"Next"))))};sn.propTypes={endpoint:U().string,page:U().number,limit:U().number,numOffset:U().number,numElements:U().number};const fn=sn;var dn=["servers"];function pn(e,t){var n=Object.keys(e);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(e);t&&(r=r.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),n.push.apply(n,r)}return n}function mn(e){for(var t=1;te.length)&&(t=e.length);for(var n=0,r=new Array(t);nt.name?1:-1}))},o=function(e){return e.sort((function(e,t){return e.name Manage Groups"))),r.createElement("table",{className:"table table-bordered table-hover"},r.createElement("thead",{className:"admin-table-head"},r.createElement("tr",null,r.createElement("th",{id:"user-header"},"User"," ",r.createElement(wn,{sorts:{asc:o,desc:a},callback:function(e){return f((function(){return e}))},testid:"user-sort"})),r.createElement("th",{id:"admin-header"},"Admin"," ",r.createElement(wn,{sorts:{asc:function(e){return e.sort((function(e){return e.admin?1:-1}))},desc:function(e){return e.sort((function(e){return e.admin?-1:1}))}},callback:function(e){return f((function(){return e}))},testid:"admin-sort"})),r.createElement("th",{id:"server-header"},"Server"," ",r.createElement(wn,{sorts:{asc:o,desc:a},callback:function(e){return f((function(){return e}))},testid:"server-sort"})),r.createElement("th",{id:"last-activity-header"},"Last Activity"," ",r.createElement(wn,{sorts:{asc:function(e){return e.sort((function(e,t){return new Date(e.last_activity)-new Date(t.last_activity)>0?1:-1}))},desc:function(e){return e.sort((function(e,t){return new Date(e.last_activity)-new Date(t.last_activity)>0?-1:1}))}},callback:function(e){return f((function(){return e}))},testid:"last-activity-sort"})),r.createElement("th",{id:"running-status-header"},"Running"," ",r.createElement(wn,{sorts:{asc:function(e){return e.sort((function(e){return null==e.server?-1:1}))},desc:function(e){return e.sort((function(e){return null==e.server?1:-1}))}},callback:function(e){return f((function(){return e}))},testid:"running-status-sort"})),r.createElement("th",{id:"actions-header"},"Actions"))),r.createElement("tbody",null,r.createElement("tr",{className:"noborder"},r.createElement("td",null,r.createElement(Be,{variant:"light",className:"add-users-button"},r.createElement(Ce,{to:"/add-users"},"Add Users"))),r.createElement("td",null),r.createElement("td",null),r.createElement("td",null,r.createElement(Be,{variant:"primary",className:"start-all","data-testid":"start-all",onClick:function(){Promise.all(P(v.map((function(e){return e.name})))).then((function(e){var t=e.filter((function(e){return!e.ok}));return t.length>0&&u("Failed to start ".concat(t.length," ").concat(t.length>1?"servers":"server",". ").concat(t.length>1?"Are they ":"Is it "," already running?")),e})).then((function(e){return _.apply(void 0,x).then((function(e){j(e,k,E)})).catch((function(){return u("Failed to update users list.")})),e})).catch((function(){return u("Failed to start servers.")}))}},"Start All"),r.createElement("span",null," "),r.createElement(Be,{variant:"danger",className:"stop-all","data-testid":"stop-all",onClick:function(){Promise.all(T(v.map((function(e){return e.name})))).then((function(e){var t=e.filter((function(e){return!e.ok}));return t.length>0&&u("Failed to stop ".concat(t.length," ").concat(t.length>1?"servers":"server",". ").concat(t.length>1?"Are they ":"Is it "," already stopped?")),e})).then((function(e){return _.apply(void 0,x).then((function(e){j(e,k,E)})).catch((function(){return u("Failed to update users list.")})),e})).catch((function(){return u("Failed to stop servers.")}))}},"Stop All")),r.createElement("td",null,r.createElement(Be,{variant:"danger",id:"shutdown-button",onClick:C},"Shutdown Hub"))),D.flatMap((function(e){var n=yn(e,2);return function(e,n){e.servers;var a,o,l,i,u,c,s=function(e,t){if(null==e)return{};var n,r,a=function(e,t){if(null==e)return{};var n,r,a={},o=Object.keys(e);for(r=0;r=0||(a[n]=e[n]);return a}(e,t);if(Object.getOwnPropertySymbols){var o=Object.getOwnPropertySymbols(e);for(r=0;r=0||Object.prototype.propertyIsEnumerable.call(e,n)&&(a[n]=e[n])}return a}(e,dn),f=n.name?"-".concat(n.name):"",d=e.name+f,p=m[d]||!1;return[r.createElement("tr",{key:"".concat(d,"-row"),className:"user-row"},r.createElement("td",{"data-testid":"user-row-name"},r.createElement("span",null,r.createElement(Be,{onClick:function(){return h(mn(mn({},m),{},hn({},d,!p)))},"aria-controls":"".concat(d,"-collapse"),"aria-expanded":p,"data-testid":"".concat(d,"-collapse-button"),variant:p?"secondary":"primary",size:"sm"},r.createElement("span",{className:"caret"}))," "),r.createElement("span",{"data-testid":"user-name-div-".concat(d)},e.name)),r.createElement("td",{"data-testid":"user-row-admin"},e.admin?"admin":""),r.createElement("td",{"data-testid":"user-row-server"},n.name?r.createElement("p",{className:"text-secondary"},n.name):r.createElement("p",{style:{color:"lightgrey"}},"[MAIN]")),r.createElement("td",{"data-testid":"user-row-last-activity"},n.last_activity?(a=n.last_activity,6e4,o=36e5,l=864e5,i=2592e6,u=31536e6,c=Date.now()-Date.parse(a),c<6e4?Math.round(c/1e3)+" seconds ago":c0?n.map((function(e,n){return r.createElement("li",{className:"list-group-item",key:"group-item"+n},r.createElement("span",{className:"badge badge-pill badge-success"},e.users.length+" users"),r.createElement(Ce,{to:{pathname:"/group-edit",state:{group_data:e,user_data:t}}},e.name))})):r.createElement("div",null,r.createElement("h4",null,"no groups created..."))),r.createElement(fn,{endpoint:"/groups",page:i,limit:o,numOffset:u[0],numElements:n.length})),r.createElement("div",{className:"panel-footer"},r.createElement("button",{className:"btn btn-light adjacent-span-spacing"},r.createElement(Ce,{to:"/"},"Back")),r.createElement("button",{className:"btn btn-primary adjacent-span-spacing",onClick:function(){s.push("/create-group")}},"New Group"))))))):r.createElement("div",{"data-testid":"no-show"})};xn.propTypes={user_data:U().array,groups_data:U().array,updateUsers:U().func,updateGroups:U().func,history:U().shape({push:U().func}),location:U().shape({search:U().string})};const Sn=xn;function _n(e,t){return function(e){if(Array.isArray(e))return e}(e)||function(e,t){var n=null==e?null:"undefined"!=typeof Symbol&&e[Symbol.iterator]||e["@@iterator"];if(null!=n){var r,a,o=[],l=!0,i=!1;try{for(n=n.call(e);!(l=(r=n.next()).done)&&(o.push(r.value),!t||o.length!==t);l=!0);}catch(e){i=!0,a=e}finally{try{l||null==n.return||n.return()}finally{if(i)throw a}}return o}}(e,t)||function(e,t){if(e){if("string"==typeof e)return Cn(e,t);var n=Object.prototype.toString.call(e).slice(8,-1);return"Object"===n&&e.constructor&&(n=e.constructor.name),"Map"===n||"Set"===n?Array.from(e):"Arguments"===n||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)?Cn(e,t):void 0}}(e,t)||function(){throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function Cn(e,t){(null==t||t>e.length)&&(t=e.length);for(var n=0,r=new Array(t);ne.length)&&(t=e.length);for(var n=0,r=new Array(t);n0&&r.push(m(e,x.name)),t.length>0&&r.push(h(t,x.name)),Promise.all(r).then((function(e){0==e.map((function(e){return e.status})).filter((function(e){return e>=300})).length?g(0,f).then((function(e){return p(e,0)})).then((function(){return E.push("/groups")})):s("Failed to edit group.")})).catch((function(){console.log("outer"),s("Failed to edit group.")}))}else E.push("/groups")}},"Apply"),r.createElement("button",{id:"delete-group","data-testid":"delete-group",className:"btn btn-danger",style:{float:"right"},onClick:function(){var e=x.name;v(e).then((function(e){e.status<300?g(0,f).then((function(e){return p(e,0)})).then((function(){return E.push("/groups")})):s("Failed to delete group.")})).catch((function(){return s("Failed to delete group.")}))}},"Delete Group"),r.createElement("br",null),r.createElement("br",null)))):r.createElement("div",null)};Ln.propTypes={location:U().shape({state:U().shape({group_data:U().object,callback:U().func})}),history:U().shape({push:U().func}),addToGroup:U().func,removeFromGroup:U().func,deleteGroup:U().func,updateGroups:U().func,validateUser:U().func};const jn=Ln;function Rn(e,t){return function(e){if(Array.isArray(e))return e}(e)||function(e,t){var n=null==e?null:"undefined"!=typeof Symbol&&e[Symbol.iterator]||e["@@iterator"];if(null!=n){var r,a,o=[],l=!0,i=!1;try{for(n=n.call(e);!(l=(r=n.next()).done)&&(o.push(r.value),!t||o.length!==t);l=!0);}catch(e){i=!0,a=e}finally{try{l||null==n.return||n.return()}finally{if(i)throw a}}return o}}(e,t)||function(e,t){if(e){if("string"==typeof e)return Mn(e,t);var n=Object.prototype.toString.call(e).slice(8,-1);return"Object"===n&&e.constructor&&(n=e.constructor.name),"Map"===n||"Set"===n?Array.from(e):"Arguments"===n||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)?Mn(e,t):void 0}}(e,t)||function(){throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function Mn(e,t){(null==t||t>e.length)&&(t=e.length);for(var n=0,r=new Array(t);ne.length)&&(t=e.length);for(var n=0,r=new Array(t);n2&&0==/[!@#$%^&*(),.?":{}|<>]/g.test(e)}));e.lengthe.length)&&(t=e.length);for(var n=0,r=new Array(t);n2&&0==/[!@#$%^&*(),.?":{}|<>]/g.test(g)?u(m,""!=g?g:m,k).then((function(e){e.status<300?f(0,t).then((function(e){return i(e,0)})).then((function(){return d.push("/")})).catch((function(){return o("Could not update users list.")})):o("Failed to edit user.")})).catch((function(){o("Failed to edit user.")})):o("Failed to edit user. Make sure the username does not contain special characters."):u(m,m,k).then((function(e){e.status<300?f(0,t).then((function(e){return i(e,0)})).then((function(){return d.push("/")})).catch((function(){return o("Could not update users list.")})):o("Failed to edit user.")})).catch((function(){o("Failed to edit user.")})):s()}},"Apply")))))))};Hn.propTypes={location:U().shape({state:U().shape({username:U().string,has_admin:U().bool})}),history:U().shape({push:U().func}),editUser:U().func,deleteUser:U().func,failRegexEvent:U().func,noChangeEvent:U().func,updateUsers:U().func};const Vn=Hn;n(137);var Wn=function e(t,n,r){var a;if("function"==typeof n&&"function"==typeof r||"function"==typeof r&&"function"==typeof arguments[3])throw new Error(k(0));if("function"==typeof n&&void 0===r&&(r=n,n=void 0),void 0!==r){if("function"!=typeof r)throw new Error(k(1));return r(e)(t,n)}if("function"!=typeof t)throw new Error(k(2));var o=t,l=n,i=[],u=i,c=!1;function s(){u===i&&(u=i.slice())}function f(){if(c)throw new Error(k(3));return l}function d(e){if("function"!=typeof e)throw new Error(k(4));if(c)throw new Error(k(5));var t=!0;return s(),u.push(e),function(){if(t){if(c)throw new Error(k(6));t=!1,s();var n=u.indexOf(e);u.splice(n,1),i=null}}}function p(e){if(!C(e))throw new Error(k(7));if(void 0===e.type)throw new Error(k(8));if(c)throw new Error(k(9));try{c=!0,l=o(l,e)}finally{c=!1}for(var t=i=u,n=0;n0&&void 0!==arguments[0]?arguments[0]:j,t=arguments.length>1?arguments[1]:void 0;switch(t.type){case"USER_PAGE":return Object.assign({},e,{user_page:t.value.page,user_data:t.value.data,name_filter:t.value.name_filter||""});case"GROUPS_PAGE":return Object.assign({},e,{groups_page:t.value.page,groups_data:t.value.data});default:return e}}),j),Gn=function(){return(0,r.useEffect)((function(){var e=j.limit,t=j.groups_page;R("/users?offset=".concat(j.user_page*e,"&limit=").concat(e),"GET").then((function(e){return e.json()})).then((function(e){return Wn.dispatch({type:"USER_PAGE",value:{data:e,page:0}})})).catch((function(e){return console.log(e)})),R("/groups?offset=".concat(t*e,"&limit=").concat(e),"GET").then((function(e){return e.json()})).then((function(e){return Wn.dispatch({type:"GROUPS_PAGE",value:{data:e,page:0}})})).catch((function(e){return console.log(e)}))})),r.createElement("div",{className:"resets"},r.createElement(f,{store:Wn},r.createElement(Ee,null,r.createElement(ae,null,r.createElement(re,{exact:!0,path:"/",component:T(M)(kn)}),r.createElement(re,{exact:!0,path:"/groups",component:T(M)(Sn)}),r.createElement(re,{exact:!0,path:"/group-edit",component:T(M)(jn)}),r.createElement(re,{exact:!0,path:"/create-group",component:T(M)(An)}),r.createElement(re,{exact:!0,path:"/add-users",component:T(M)(Un)}),r.createElement(re,{exact:!0,path:"/edit-user",component:T(M)(Vn)})))))};a.render(r.createElement(Gn,null),document.getElementById("react-admin-hook"))},790:(e,t)=>{"use strict";t.E=function(){var e=[],t=e;function n(){t===e&&(t=e.slice())}return{listen:function(e){if("function"!=typeof e)throw new Error("Expected listener to be a function.");var r=!0;return n(),t.push(e),function(){if(r){r=!1,n();var a=t.indexOf(e);t.splice(a,1)}}},emit:function(){for(var n=e=t,r=0;r{var n;!function(){"use strict";var r={}.hasOwnProperty;function a(){for(var e=[],t=0;t{"use strict";n.r(t),n.d(t,{default:()=>i});var r=n(645),a=n.n(r),o=n(223),l=a()((function(e){return e[1]}));l.i(o.default),l.push([e.id,".users-container {\n width: 100%;\n position: relative;\n padding: 5px;\n overflow-x: scroll;\n}\n\n.users-container div {\n display: inline-block;\n}\n\n.users-container .item {\n padding: 3px;\n padding-left: 6px;\n padding-right: 6px;\n border-radius: 3px;\n font-size: 14px;\n margin-left: 4px;\n margin-right: 4px;\n transition: 30ms ease-in all;\n cursor: pointer;\n user-select: none;\n border: solid 1px #dfdfdf;\n}\n\n.users-container .item.unselected {\n background-color: #f7f7f7;\n color: #777;\n}\n\n.users-container .item.selected {\n background-color: orange;\n color: white;\n}\n\n.users-container .item:hover {\n opacity: 0.7;\n}\n",""]);const i=l},457:(e,t,n)=>{"use strict";n.r(t),n.d(t,{default:()=>i});var r=n(645),a=n.n(r),o=n(223),l=a()((function(e){return e[1]}));l.i(o.default),l.push([e.id,".pagination-footer * button {\n margin-right: 10px;\n}\n\n.pagination-footer * .inactive-pagination {\n color: gray;\n cursor: not-allowed;\n}\n\n.pagination-footer * button.spaced {\n color: var(--blue);\n}\n",""]);const i=l},642:(e,t,n)=>{"use strict";n.r(t),n.d(t,{default:()=>i});var r=n(645),a=n.n(r),o=n(223),l=a()((function(e){return e[1]}));l.i(o.default),l.push([e.id,".server-dashboard-container {\n padding-right: 15px;\n padding-left: 15px;\n margin-right: auto;\n margin-left: auto;\n}\n\n.server-dashboard-container .add-users-button {\n border: 1px solid #ddd;\n}\n\n.server-dashboard-container tbody {\n color: #626262;\n}\n\n.admin-table-head {\n user-select: none;\n}\n\n.sort-icon {\n display: inline-block;\n top: 0.125em;\n position: relative;\n user-select: none;\n cursor: pointer;\n}\n\ntr.noborder > td {\n border: none !important;\n}\n",""]);const i=l},223:(e,t,n)=>{"use strict";n.r(t),n.d(t,{default:()=>o});var r=n(645),a=n.n(r)()((function(e){return e[1]}));a.push([e.id,":root {\n --red: #d7191e;\n --orange: #f1ad4e;\n --blue: #2e7ab6;\n --white: #ffffff;\n --gray: #f7f7f7;\n}\n\n/* Color Classes */\n.red {\n background-color: var(--red);\n}\n.orange {\n background-color: var(--orange);\n}\n.blue {\n background-color: var(--blue);\n}\n.white {\n background-color: var(--white);\n}\n\n/* Resets */\n\n.resets .modal {\n display: block;\n visibility: visible;\n z-index: 2000;\n}\n\n/* Global Util Classes */\n.adjacent-span-spacing {\n margin-right: 5px;\n margin-left: 5px;\n}\n",""]);const o=a},645:e=>{"use strict";e.exports=function(e){var t=[];return t.toString=function(){return this.map((function(t){var n=e(t);return t[2]?"@media ".concat(t[2]," {").concat(n,"}"):n})).join("")},t.i=function(e,n,r){"string"==typeof e&&(e=[[null,e,""]]);var a={};if(r)for(var o=0;o{"use strict";var t=Object.prototype.hasOwnProperty;function n(e,t){return e===t?0!==e||0!==t||1/e==1/t:e!=e&&t!=t}e.exports=function(e,r){if(n(e,r))return!0;if("object"!=typeof e||null===e||"object"!=typeof r||null===r)return!1;var a=Object.keys(e),o=Object.keys(r);if(a.length!==o.length)return!1;for(var l=0;l{"use strict";var r=n(296),a={childContextTypes:!0,contextType:!0,contextTypes:!0,defaultProps:!0,displayName:!0,getDefaultProps:!0,getDerivedStateFromError:!0,getDerivedStateFromProps:!0,mixins:!0,propTypes:!0,type:!0},o={name:!0,length:!0,prototype:!0,caller:!0,callee:!0,arguments:!0,arity:!0},l={$$typeof:!0,compare:!0,defaultProps:!0,displayName:!0,propTypes:!0,type:!0},i={};function u(e){return r.isMemo(e)?l:i[e.$$typeof]||a}i[r.ForwardRef]={$$typeof:!0,render:!0,defaultProps:!0,displayName:!0,propTypes:!0},i[r.Memo]=l;var c=Object.defineProperty,s=Object.getOwnPropertyNames,f=Object.getOwnPropertySymbols,d=Object.getOwnPropertyDescriptor,p=Object.getPrototypeOf,m=Object.prototype;e.exports=function e(t,n,r){if("string"!=typeof n){if(m){var a=p(n);a&&a!==m&&e(t,a,r)}var l=s(n);f&&(l=l.concat(f(n)));for(var i=u(t),h=u(n),v=0;v{"use strict";var n="function"==typeof Symbol&&Symbol.for,r=n?Symbol.for("react.element"):60103,a=n?Symbol.for("react.portal"):60106,o=n?Symbol.for("react.fragment"):60107,l=n?Symbol.for("react.strict_mode"):60108,i=n?Symbol.for("react.profiler"):60114,u=n?Symbol.for("react.provider"):60109,c=n?Symbol.for("react.context"):60110,s=n?Symbol.for("react.async_mode"):60111,f=n?Symbol.for("react.concurrent_mode"):60111,d=n?Symbol.for("react.forward_ref"):60112,p=n?Symbol.for("react.suspense"):60113,m=n?Symbol.for("react.suspense_list"):60120,h=n?Symbol.for("react.memo"):60115,v=n?Symbol.for("react.lazy"):60116,y=n?Symbol.for("react.block"):60121,g=n?Symbol.for("react.fundamental"):60117,b=n?Symbol.for("react.responder"):60118,E=n?Symbol.for("react.scope"):60119;function w(e){if("object"==typeof e&&null!==e){var t=e.$$typeof;switch(t){case r:switch(e=e.type){case s:case f:case o:case i:case l:case p:return e;default:switch(e=e&&e.$$typeof){case c:case d:case v:case h:case u:return e;default:return t}}case a:return t}}}function k(e){return w(e)===f}t.AsyncMode=s,t.ConcurrentMode=f,t.ContextConsumer=c,t.ContextProvider=u,t.Element=r,t.ForwardRef=d,t.Fragment=o,t.Lazy=v,t.Memo=h,t.Portal=a,t.Profiler=i,t.StrictMode=l,t.Suspense=p,t.isAsyncMode=function(e){return k(e)||w(e)===s},t.isConcurrentMode=k,t.isContextConsumer=function(e){return w(e)===c},t.isContextProvider=function(e){return w(e)===u},t.isElement=function(e){return"object"==typeof e&&null!==e&&e.$$typeof===r},t.isForwardRef=function(e){return w(e)===d},t.isFragment=function(e){return w(e)===o},t.isLazy=function(e){return w(e)===v},t.isMemo=function(e){return w(e)===h},t.isPortal=function(e){return w(e)===a},t.isProfiler=function(e){return w(e)===i},t.isStrictMode=function(e){return w(e)===l},t.isSuspense=function(e){return w(e)===p},t.isValidElementType=function(e){return"string"==typeof e||"function"==typeof e||e===o||e===f||e===i||e===l||e===p||e===m||"object"==typeof e&&null!==e&&(e.$$typeof===v||e.$$typeof===h||e.$$typeof===u||e.$$typeof===c||e.$$typeof===d||e.$$typeof===g||e.$$typeof===b||e.$$typeof===E||e.$$typeof===y)},t.typeOf=w},296:(e,t,n)=>{"use strict";e.exports=n(103)},954:(e,t,n)=>{var r=/^\s+|\s+$/g,a=/^[-+]0x[0-9a-f]+$/i,o=/^0b[01]+$/i,l=/^0o[0-7]+$/i,i=parseInt,u="object"==typeof n.g&&n.g&&n.g.Object===Object&&n.g,c="object"==typeof self&&self&&self.Object===Object&&self,s=u||c||Function("return this")(),f=Object.prototype.toString,d=Math.max,p=Math.min,m=function(){return s.Date.now()};function h(e){var t=typeof e;return!!e&&("object"==t||"function"==t)}function v(e){if("number"==typeof e)return e;if(function(e){return"symbol"==typeof e||function(e){return!!e&&"object"==typeof e}(e)&&"[object Symbol]"==f.call(e)}(e))return NaN;if(h(e)){var t="function"==typeof e.valueOf?e.valueOf():e;e=h(t)?t+"":t}if("string"!=typeof e)return 0===e?e:+e;e=e.replace(r,"");var n=o.test(e);return n||l.test(e)?i(e.slice(2),n?2:8):a.test(e)?NaN:+e}e.exports=function(e,t,n){var r,a,o,l,i,u,c=0,s=!1,f=!1,y=!0;if("function"!=typeof e)throw new TypeError("Expected a function");function g(t){var n=r,o=a;return r=a=void 0,c=t,l=e.apply(o,n)}function b(e){return c=e,i=setTimeout(w,t),s?g(e):l}function E(e){var n=e-u;return void 0===u||n>=t||n<0||f&&e-c>=o}function w(){var e=m();if(E(e))return k(e);i=setTimeout(w,function(e){var n=t-(e-u);return f?p(n,o-(e-c)):n}(e))}function k(e){return i=void 0,y&&r?g(e):(r=a=void 0,l)}function x(){var e=m(),n=E(e);if(r=arguments,a=this,u=e,n){if(void 0===i)return b(u);if(f)return i=setTimeout(w,t),g(u)}return void 0===i&&(i=setTimeout(w,t)),l}return t=v(t)||0,h(n)&&(s=!!n.leading,o=(f="maxWait"in n)?d(v(n.maxWait)||0,t):o,y="trailing"in n?!!n.trailing:y),x.cancel=function(){void 0!==i&&clearTimeout(i),c=0,r=u=a=i=void 0},x.flush=function(){return void 0===i?l:k(m())},x}},418:e=>{"use strict";var t=Object.getOwnPropertySymbols,n=Object.prototype.hasOwnProperty,r=Object.prototype.propertyIsEnumerable;function a(e){if(null==e)throw new TypeError("Object.assign cannot be called with null or undefined");return Object(e)}e.exports=function(){try{if(!Object.assign)return!1;var e=new String("abc");if(e[5]="de","5"===Object.getOwnPropertyNames(e)[0])return!1;for(var t={},n=0;n<10;n++)t["_"+String.fromCharCode(n)]=n;if("0123456789"!==Object.getOwnPropertyNames(t).map((function(e){return t[e]})).join(""))return!1;var r={};return"abcdefghijklmnopqrst".split("").forEach((function(e){r[e]=e})),"abcdefghijklmnopqrst"===Object.keys(Object.assign({},r)).join("")}catch(e){return!1}}()?Object.assign:function(e,o){for(var l,i,u=a(e),c=1;c{var r=n(173);e.exports=function e(t,n,a){return r(n)||(a=n||a,n=[]),a=a||{},t instanceof RegExp?function(e,t){var n=e.source.match(/\((?!\?)/g);if(n)for(var r=0;r{e.exports=Array.isArray||function(e){return"[object Array]"==Object.prototype.toString.call(e)}},703:(e,t,n)=>{"use strict";var r=n(414);function a(){}function o(){}o.resetWarningCache=a,e.exports=function(){function e(e,t,n,a,o,l){if(l!==r){var i=new Error("Calling PropTypes validators directly is not supported by the `prop-types` package. Use PropTypes.checkPropTypes() to call them. Read more at http://fb.me/use-check-prop-types");throw i.name="Invariant Violation",i}}function t(){return e}e.isRequired=e;var n={array:e,bigint:e,bool:e,func:e,number:e,object:e,string:e,symbol:e,any:e,arrayOf:t,element:e,elementType:e,instanceOf:t,node:e,objectOf:t,oneOf:t,oneOfType:t,shape:t,exact:t,checkPropTypes:o,resetWarningCache:a};return n.PropTypes=n,n}},697:(e,t,n)=>{e.exports=n(703)()},414:e=>{"use strict";e.exports="SECRET_DO_NOT_PASS_THIS_OR_YOU_WILL_BE_FIRED"},448:(e,t,n)=>{"use strict";var r=n(294),a=n(418),o=n(840);function l(e){for(var t="https://reactjs.org/docs/error-decoder.html?invariant="+e,n=1;n