Compare commits

...

23 Commits

Author SHA1 Message Date
Min RK
8249ef69f0 release jupyterhub 1.2.0 2020-10-29 14:03:34 +01:00
Min RK
c63605425f Merge pull request #3233 from minrk/1.2.0-final
latest changelog since 1.2.0b1
2020-10-29 14:03:01 +01:00
Min RK
5b57900c0b 1.2.0 heading in changelog
Co-authored-by: Erik Sundell <erik.i.sundell@gmail.com>
2020-10-29 14:02:35 +01:00
Erik Sundell
d0afdabd4c order changelog entries systematically 2020-10-29 13:13:02 +01:00
Min RK
618746fa00 latest changelog since 1.2.0b1 2020-10-29 13:02:04 +01:00
Min RK
e7bc6c2ba9 Merge pull request #3229 from minrk/configurable-pagination
make pagination configurable
2020-10-29 10:53:29 +01:00
Min RK
e9f86cd602 make pagination configurable
add some unittests for pagination

reorganize pagination a bit to make it easier to configure
2020-10-29 09:24:34 +01:00
Erik Sundell
6e8517f795 Merge pull request #3232 from consideRatio/pr/travis-badge
Update travis-ci badge in README.md
2020-10-28 23:01:04 +01:00
Erik Sundell
5fa540bea1 Update travis-ci badge in README.md 2020-10-28 22:59:44 +01:00
Min RK
99f597887c Merge pull request #3223 from consideRatio/pr/proxy-api_request-retries
Make api_request to CHP's REST API more reliable
2020-10-28 15:21:23 +01:00
Erik Sundell
352526c36a Merge pull request #3226 from xlotlu/patch-1
Fix typo in documentation
2020-10-28 08:09:11 +01:00
Ionuț Ciocîrlan
cbbed04eed fix typo 2020-10-28 03:00:31 +02:00
Erik Sundell
b2756fb18c Retry on >=500 errors on hub to proxy REST API reqeusts 2020-10-27 16:53:53 +01:00
Erik Sundell
37b88029e4 Revert improved logging attempt 2020-10-27 16:28:56 +01:00
Erik Sundell
4b7413184e Adjust hub to proxy REST API requests' timeouts 2020-10-27 16:23:40 +01:00
Min RK
41ef0da180 Merge pull request #3219 from elgalu/patch-3
Fix #2284 must be sent from authorization page
2020-10-27 15:41:05 +01:00
Erik Sundell
a4a8b3fa2c Fix scope mistake 2020-10-27 13:38:34 +01:00
Erik Sundell
02e5984f34 Let API requests to CHP retry on 429,500,503,504 as well 2020-10-27 12:52:14 +01:00
Erik Sundell
b91c5a489c Rely on HTTPError over pycurl assumed CurlError 2020-10-26 20:39:20 +01:00
Erik Sundell
c47c3b2f9e Make api_request to CHP's REST API more reliable 2020-10-25 02:35:36 +01:00
Min RK
eaa1353dcd typos in use of partition 2020-10-23 14:16:46 +02:00
Leo Gallucci
b9a3b0a66a Fix #2284 must be sent from authorization pageUpdate jupyterhub/apihandlers/auth.py
Co-authored-by: Min RK <benjaminrk@gmail.com>
2020-10-22 11:36:15 +02:00
Leo Gallucci
929b805fae Fix #2284 must be sent from authorization page
Fix #2284 Authorization form must be sent from authorization page
2020-10-21 17:57:14 +02:00
10 changed files with 221 additions and 92 deletions

View File

@@ -13,7 +13,7 @@
[![Latest PyPI version](https://img.shields.io/pypi/v/jupyterhub?logo=pypi)](https://pypi.python.org/pypi/jupyterhub) [![Latest PyPI version](https://img.shields.io/pypi/v/jupyterhub?logo=pypi)](https://pypi.python.org/pypi/jupyterhub)
[![Latest conda-forge version](https://img.shields.io/conda/vn/conda-forge/jupyterhub?logo=conda-forge)](https://www.npmjs.com/package/jupyterhub) [![Latest conda-forge version](https://img.shields.io/conda/vn/conda-forge/jupyterhub?logo=conda-forge)](https://www.npmjs.com/package/jupyterhub)
[![Documentation build status](https://img.shields.io/readthedocs/jupyterhub?logo=read-the-docs)](https://jupyterhub.readthedocs.org/en/latest/) [![Documentation build status](https://img.shields.io/readthedocs/jupyterhub?logo=read-the-docs)](https://jupyterhub.readthedocs.org/en/latest/)
[![TravisCI build status](https://img.shields.io/travis/jupyterhub/jupyterhub/master?logo=travis)](https://travis-ci.org/jupyterhub/jupyterhub) [![TravisCI build status](https://img.shields.io/travis/com/jupyterhub/jupyterhub?logo=travis)](https://travis-ci.com/jupyterhub/jupyterhub)
[![DockerHub build status](https://img.shields.io/docker/build/jupyterhub/jupyterhub?logo=docker&label=build)](https://hub.docker.com/r/jupyterhub/jupyterhub/tags) [![DockerHub build status](https://img.shields.io/docker/build/jupyterhub/jupyterhub?logo=docker&label=build)](https://hub.docker.com/r/jupyterhub/jupyterhub/tags)
[![CircleCI build status](https://img.shields.io/circleci/build/github/jupyterhub/jupyterhub?logo=circleci)](https://circleci.com/gh/jupyterhub/jupyterhub)<!-- CircleCI Token: b5b65862eb2617b9a8d39e79340b0a6b816da8cc --> [![CircleCI build status](https://img.shields.io/circleci/build/github/jupyterhub/jupyterhub?logo=circleci)](https://circleci.com/gh/jupyterhub/jupyterhub)<!-- CircleCI Token: b5b65862eb2617b9a8d39e79340b0a6b816da8cc -->
[![Test coverage of code](https://codecov.io/gh/jupyterhub/jupyterhub/branch/master/graph/badge.svg)](https://codecov.io/gh/jupyterhub/jupyterhub) [![Test coverage of code](https://codecov.io/gh/jupyterhub/jupyterhub/branch/master/graph/badge.svg)](https://codecov.io/gh/jupyterhub/jupyterhub)

File diff suppressed because one or more lines are too long

View File

@@ -21,7 +21,7 @@ Here is a quick breakdown of these three tools:
* **The Jupyter Notebook** is a document specification (the `.ipynb`) file that interweaves * **The Jupyter Notebook** is a document specification (the `.ipynb`) file that interweaves
narrative text with code cells and their outputs. It is also a graphical interface narrative text with code cells and their outputs. It is also a graphical interface
that allows users to edit these documents. There are also several other graphical interfaces that allows users to edit these documents. There are also several other graphical interfaces
that allow users to edit the `.ipynb` format (nteract, Jupyer Lab, Google Colab, Kaggle, etc). that allow users to edit the `.ipynb` format (nteract, Jupyter Lab, Google Colab, Kaggle, etc).
* **JupyterLab** is a flexible and extendible user interface for interactive computing. It * **JupyterLab** is a flexible and extendible user interface for interactive computing. It
has several extensions that are tailored for using Jupyter Notebooks, as well as extensions has several extensions that are tailored for using Jupyter Notebooks, as well as extensions
for other parts of the data science stack. for other parts of the data science stack.

View File

@@ -6,7 +6,7 @@ version_info = (
1, 1,
2, 2,
0, 0,
"b1", # release (b1, rc1, or "" for final or dev) # "b1", # release (b1, rc1, or "" for final or dev)
# "dev", # dev or nothing for beta/rc/stable releases # "dev", # dev or nothing for beta/rc/stable releases
) )

View File

@@ -274,9 +274,26 @@ class OAuthAuthorizeHandler(OAuthHandler, BaseHandler):
uri, http_method, body, headers = self.extract_oauth_params() uri, http_method, body, headers = self.extract_oauth_params()
referer = self.request.headers.get('Referer', 'no referer') referer = self.request.headers.get('Referer', 'no referer')
full_url = self.request.full_url() full_url = self.request.full_url()
if referer != full_url: # trim protocol, which cannot be trusted with multiple layers of proxies anyway
# Referer is set by browser, but full_url can be modified by proxy layers to appear as http
# when it is actually https
referer_proto, _, stripped_referer = referer.partition("://")
referer_proto = referer_proto.lower()
req_proto, _, stripped_full_url = full_url.partition("://")
req_proto = req_proto.lower()
if referer_proto != req_proto:
self.log.warning("Protocol mismatch: %s != %s", referer, full_url)
if req_proto == "https":
# insecure origin to secure target is not allowed
raise web.HTTPError(
403, "Not allowing authorization form submitted from insecure page"
)
if stripped_referer != stripped_full_url:
# OAuth post must be made to the URL it came from # OAuth post must be made to the URL it came from
self.log.error("OAuth POST from %s != %s", referer, full_url) self.log.error("Original OAuth POST from %s != %s", referer, full_url)
self.log.error(
"Stripped OAuth POST from %s != %s", stripped_referer, stripped_full_url
)
raise web.HTTPError( raise web.HTTPError(
403, "Authorization form must be sent from authorization page" 403, "Authorization form must be sent from authorization page"
) )

View File

@@ -77,6 +77,7 @@ from .user import UserDict
from .oauth.provider import make_provider from .oauth.provider import make_provider
from ._data import DATA_FILES_PATH from ._data import DATA_FILES_PATH
from .log import CoroutineLogFormatter, log_request from .log import CoroutineLogFormatter, log_request
from .pagination import Pagination
from .proxy import Proxy, ConfigurableHTTPProxy from .proxy import Proxy, ConfigurableHTTPProxy
from .traitlets import URLPrefix, Command, EntryPointType, Callable from .traitlets import URLPrefix, Command, EntryPointType, Callable
from .utils import ( from .utils import (
@@ -279,7 +280,7 @@ class JupyterHub(Application):
@default('classes') @default('classes')
def _load_classes(self): def _load_classes(self):
classes = [Spawner, Authenticator, CryptKeeper] classes = [Spawner, Authenticator, CryptKeeper, Pagination]
for name, trait in self.traits(config=True).items(): for name, trait in self.traits(config=True).items():
# load entry point groups into configurable class list # load entry point groups into configurable class list
# so that they show up in config files, etc. # so that they show up in config files, etc.

View File

@@ -453,7 +453,7 @@ class AdminHandler(BaseHandler):
@web.authenticated @web.authenticated
@admin_only @admin_only
async def get(self): async def get(self):
page, per_page, offset = Pagination.get_page_args(self) page, per_page, offset = Pagination(config=self.config).get_page_args(self)
available = {'name', 'admin', 'running', 'last_activity'} available = {'name', 'admin', 'running', 'last_activity'}
default_sort = ['admin', 'name'] default_sort = ['admin', 'name']
@@ -511,7 +511,11 @@ class AdminHandler(BaseHandler):
total = self.db.query(orm.User.id).count() total = self.db.query(orm.User.id).count()
pagination = Pagination( pagination = Pagination(
url=self.request.uri, total=total, page=page, per_page=per_page, url=self.request.uri,
total=total,
page=page,
per_page=per_page,
config=self.config,
) )
auth_state = await self.current_user.get_auth_state() auth_state = await self.current_user.get_auth_state()

View File

@@ -1,69 +1,94 @@
"""Basic class to manage pagination utils.""" """Basic class to manage pagination utils."""
# Copyright (c) Jupyter Development Team. # Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License. # Distributed under the terms of the Modified BSD License.
from traitlets import Bool
from traitlets import default
from traitlets import Integer
from traitlets import observe
from traitlets import Unicode
from traitlets import validate
from traitlets.config import Configurable
class Pagination: class Pagination(Configurable):
_page_name = 'page' # configurable options
_per_page_name = 'per_page' default_per_page = Integer(
_default_page = 1 100,
_default_per_page = 100 config=True,
_max_per_page = 250 help="Default number of entries per page for paginated results.",
)
def __init__(self, *args, **kwargs): max_per_page = Integer(
"""Potential parameters. 250,
**url**: URL in request config=True,
**page**: current page in use help="Maximum number of entries per page for paginated results.",
**per_page**: number of records to display in the page. By default 100 )
**total**: total records considered while paginating
"""
self.page = kwargs.get(self._page_name, 1)
if self.per_page > self._max_per_page: # state variables
self.per_page = self._max_per_page url = Unicode("")
page = Integer(1)
per_page = Integer(1, min=1)
self.total = int(kwargs.get('total', 0)) @default("per_page")
self.url = kwargs.get('url') or self.get_url() def _default_per_page(self):
self.init_values() return self.default_per_page
def init_values(self): @validate("per_page")
self._cached = {} def _limit_per_page(self, proposal):
self.skip = (self.page - 1) * self.per_page if self.max_per_page and proposal.value > self.max_per_page:
pages = divmod(self.total, self.per_page) return self.max_per_page
self.total_pages = pages[0] + 1 if pages[1] else pages[0] if proposal.value <= 1:
return 1
return proposal.value
self.has_prev = self.page > 1 @observe("max_per_page")
self.has_next = self.page < self.total_pages def _apply_max(self, change):
if change.new:
self.per_page = min(change.new, self.per_page)
total = Integer(0)
total_pages = Integer(0)
@default("total_pages")
def _calculate_total_pages(self):
total_pages = self.total // self.per_page
if self.total % self.per_page:
# there's a remainder, add 1
total_pages += 1
return total_pages
@observe("per_page", "total")
def _update_total_pages(self, change):
"""Update total_pages when per_page or total is changed"""
self.total_pages = self._calculate_total_pages()
separator = Unicode("...")
@classmethod
def get_page_args(self, handler): def get_page_args(self, handler):
""" """
This method gets the arguments used in the webpage to configurate the pagination This method gets the arguments used in the webpage to configurate the pagination
In case of no arguments, it uses the default values from this class In case of no arguments, it uses the default values from this class
It returns: Returns:
- self.page: The page requested for paginating or the default value (1) - page: The page requested for paginating or the default value (1)
- self.per_page: The number of items to return in this page. By default 100 and no more than 250 - per_page: The number of items to return in this page. No more than max_per_page
- self.per_page * (self.page - 1): The offset to consider when managing pagination via the ORM - offset: The offset to consider when managing pagination via the ORM
""" """
self.page = handler.get_argument(self._page_name, self._default_page) page = handler.get_argument("page", 1)
self.per_page = handler.get_argument( per_page = handler.get_argument("per_page", self.default_per_page)
self._per_page_name, self._default_per_page
)
try: try:
self.per_page = int(self.per_page) self.per_page = int(per_page)
if self.per_page > self._max_per_page: except Exception:
self.per_page = self._max_per_page
except:
self.per_page = self._default_per_page self.per_page = self._default_per_page
try: try:
self.page = int(self.page) self.page = int(page)
if self.page < 1: if self.page < 1:
self.page = self._default_page self.page = 1
except: except:
self.page = self._default_page self.page = 1
return self.page, self.per_page, self.per_page * (self.page - 1) return self.page, self.per_page, self.per_page * (self.page - 1)
@@ -91,38 +116,44 @@ class Pagination:
(in case the current page + 5 does not overflow the total lenght of pages) and the first one for reference. (in case the current page + 5 does not overflow the total lenght of pages) and the first one for reference.
""" """
self.separator_character = '...' before_page = 2
default_pages_to_render = 7 after_page = 2
after_page = 5 window_size = before_page + after_page + 1
before_end = 2
# Add 1 to self.total_pages since our default page is 1 and not 0 # Add 1 to total_pages since our starting page is 1 and not 0
total_pages = self.total_pages + 1 last_page = self.total_pages
pages = [] pages = []
if total_pages > default_pages_to_render: # will default window + start, end fit without truncation?
if self.page > 1: if self.total_pages > window_size + 2:
pages.extend([1, '...']) if self.page - before_page > 1:
# before_page will not reach page 1
pages.append(1)
if self.page - before_page > 2:
# before_page will not reach page 2, need separator
pages.append(self.separator)
if total_pages < self.page + after_page: pages.extend(range(max(1, self.page - before_page), self.page))
pages.extend(list(range(self.page, total_pages))) # we now have up to but not including self.page
if self.page + after_page + 1 >= last_page:
# after_page gets us to the end
pages.extend(range(self.page, last_page + 1))
else: else:
if total_pages >= self.page + after_page + before_end: # add full after_page entries
pages.extend(list(range(self.page, self.page + after_page))) pages.extend(range(self.page, self.page + after_page + 1))
pages.append('...') # add separator *if* this doesn't get to last page - 1
pages.extend(list(range(total_pages - before_end, total_pages))) if self.page + after_page < last_page - 1:
else: pages.append(self.separator)
pages.extend(list(range(self.page, self.page + after_page))) pages.append(last_page)
if self.page + after_page < total_pages:
# show only last page when the after_page window left space to show it
pages.append('...')
pages.extend(list(range(total_pages - 1, total_pages)))
return pages return pages
else: else:
return list(range(1, total_pages)) # everything will fit, nothing to think about
# always return at least one page
return list(range(1, last_page + 1)) or [1]
@property @property
def links(self): def links(self):
@@ -155,9 +186,11 @@ class Pagination:
page=page page=page
) )
) )
elif page == self.separator_character: elif page == self.separator:
links.append( links.append(
'<li class="disabled"><span> <span aria-hidden="true">...</span></span></li>' '<li class="disabled"><span> <span aria-hidden="true">{separator}</span></span></li>'.format(
separator=self.separator
)
) )
else: else:
links.append( links.append(

View File

@@ -43,6 +43,7 @@ from . import utils
from .metrics import CHECK_ROUTES_DURATION_SECONDS from .metrics import CHECK_ROUTES_DURATION_SECONDS
from .metrics import PROXY_POLL_DURATION_SECONDS from .metrics import PROXY_POLL_DURATION_SECONDS
from .objects import Server from .objects import Server
from .utils import exponential_backoff
from .utils import make_ssl_context from .utils import make_ssl_context
from .utils import url_path_join from .utils import url_path_join
from jupyterhub.traitlets import Command from jupyterhub.traitlets import Command
@@ -768,9 +769,35 @@ class ConfigurableHTTPProxy(Proxy):
method=method, method=method,
headers={'Authorization': 'token {}'.format(self.auth_token)}, headers={'Authorization': 'token {}'.format(self.auth_token)},
body=body, body=body,
connect_timeout=3, # default: 20s
request_timeout=10, # default: 20s
) )
async def _wait_for_api_request():
try:
async with self.semaphore: async with self.semaphore:
result = await client.fetch(req) return await client.fetch(req)
except HTTPError as e:
# Retry on potentially transient errors in CHP, typically
# numbered 500 and up. Note that CHP isn't able to emit 429
# errors.
if e.code >= 500:
self.log.warning(
"api_request to the proxy failed with status code {}, retrying...".format(
e.code
)
)
return False # a falsy return value make exponential_backoff retry
else:
self.log.error("api_request to proxy failed: {0}".format(e))
# An unhandled error here will help the hub invoke cleanup logic
raise
result = await exponential_backoff(
_wait_for_api_request,
'Repeated api_request to proxy path "{}" failed.'.format(path),
timeout=30,
)
return result return result
async def add_route(self, routespec, target, data): async def add_route(self, routespec, target, data):

View File

@@ -0,0 +1,45 @@
"""tests for pagination"""
from pytest import mark
from pytest import raises
from traitlets.config import Config
from jupyterhub.pagination import Pagination
def test_per_page_bounds():
cfg = Config()
cfg.Pagination.max_per_page = 10
p = Pagination(config=cfg, per_page=20, total=100)
assert p.per_page == 10
with raises(Exception):
p.per_page = 0
@mark.parametrize(
"page, per_page, total, expected",
[
(1, 10, 99, [1, 2, 3, "...", 10]),
(2, 10, 99, [1, 2, 3, 4, "...", 10]),
(3, 10, 99, [1, 2, 3, 4, 5, "...", 10]),
(4, 10, 99, [1, 2, 3, 4, 5, 6, "...", 10]),
(5, 10, 99, [1, "...", 3, 4, 5, 6, 7, "...", 10]),
(6, 10, 99, [1, "...", 4, 5, 6, 7, 8, "...", 10]),
(7, 10, 99, [1, "...", 5, 6, 7, 8, 9, 10]),
(8, 10, 99, [1, "...", 6, 7, 8, 9, 10]),
(9, 10, 99, [1, "...", 7, 8, 9, 10]),
(1, 20, 99, [1, 2, 3, 4, 5]),
(1, 10, 0, [1]),
(1, 10, 1, [1]),
(1, 10, 10, [1]),
(1, 10, 11, [1, 2]),
(1, 10, 50, [1, 2, 3, 4, 5]),
(1, 10, 60, [1, 2, 3, 4, 5, 6]),
(1, 10, 70, [1, 2, 3, 4, 5, 6, 7]),
(1, 10, 80, [1, 2, 3, "...", 8]),
],
)
def test_window(page, per_page, total, expected):
cfg = Config()
cfg.Pagination
pagination = Pagination(page=page, per_page=per_page, total=total)
assert pagination.calculate_pages_window() == expected