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 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/)
[![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)
[![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)

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
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 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
has several extensions that are tailored for using Jupyter Notebooks, as well as extensions
for other parts of the data science stack.

View File

@@ -6,7 +6,7 @@ version_info = (
1,
2,
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
)

View File

@@ -274,9 +274,26 @@ class OAuthAuthorizeHandler(OAuthHandler, BaseHandler):
uri, http_method, body, headers = self.extract_oauth_params()
referer = self.request.headers.get('Referer', 'no referer')
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
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(
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 ._data import DATA_FILES_PATH
from .log import CoroutineLogFormatter, log_request
from .pagination import Pagination
from .proxy import Proxy, ConfigurableHTTPProxy
from .traitlets import URLPrefix, Command, EntryPointType, Callable
from .utils import (
@@ -279,7 +280,7 @@ class JupyterHub(Application):
@default('classes')
def _load_classes(self):
classes = [Spawner, Authenticator, CryptKeeper]
classes = [Spawner, Authenticator, CryptKeeper, Pagination]
for name, trait in self.traits(config=True).items():
# load entry point groups into configurable class list
# so that they show up in config files, etc.

View File

@@ -453,7 +453,7 @@ class AdminHandler(BaseHandler):
@web.authenticated
@admin_only
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'}
default_sort = ['admin', 'name']
@@ -511,7 +511,11 @@ class AdminHandler(BaseHandler):
total = self.db.query(orm.User.id).count()
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()

View File

@@ -1,69 +1,94 @@
"""Basic class to manage pagination utils."""
# Copyright (c) Jupyter Development Team.
# 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'
_per_page_name = 'per_page'
_default_page = 1
_default_per_page = 100
_max_per_page = 250
# configurable options
default_per_page = Integer(
100,
config=True,
help="Default number of entries per page for paginated results.",
)
def __init__(self, *args, **kwargs):
"""Potential parameters.
**url**: URL in request
**page**: current page in use
**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)
max_per_page = Integer(
250,
config=True,
help="Maximum number of entries per page for paginated results.",
)
if self.per_page > self._max_per_page:
self.per_page = self._max_per_page
# state variables
url = Unicode("")
page = Integer(1)
per_page = Integer(1, min=1)
self.total = int(kwargs.get('total', 0))
self.url = kwargs.get('url') or self.get_url()
self.init_values()
@default("per_page")
def _default_per_page(self):
return self.default_per_page
def init_values(self):
self._cached = {}
self.skip = (self.page - 1) * self.per_page
pages = divmod(self.total, self.per_page)
self.total_pages = pages[0] + 1 if pages[1] else pages[0]
@validate("per_page")
def _limit_per_page(self, proposal):
if self.max_per_page and proposal.value > self.max_per_page:
return self.max_per_page
if proposal.value <= 1:
return 1
return proposal.value
self.has_prev = self.page > 1
self.has_next = self.page < self.total_pages
@observe("max_per_page")
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):
"""
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
It returns:
- self.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
- self.per_page * (self.page - 1): The offset to consider when managing pagination via the ORM
Returns:
- page: The page requested for paginating or the default value (1)
- per_page: The number of items to return in this page. No more than max_per_page
- offset: The offset to consider when managing pagination via the ORM
"""
self.page = handler.get_argument(self._page_name, self._default_page)
self.per_page = handler.get_argument(
self._per_page_name, self._default_per_page
)
page = handler.get_argument("page", 1)
per_page = handler.get_argument("per_page", self.default_per_page)
try:
self.per_page = int(self.per_page)
if self.per_page > self._max_per_page:
self.per_page = self._max_per_page
except:
self.per_page = int(per_page)
except Exception:
self.per_page = self._default_per_page
try:
self.page = int(self.page)
self.page = int(page)
if self.page < 1:
self.page = self._default_page
self.page = 1
except:
self.page = self._default_page
self.page = 1
return self.page, self.per_page, self.per_page * (self.page - 1)
@@ -81,48 +106,54 @@ class Pagination:
return {'total': self.total, 'start': start, 'end': end}
def calculate_pages_window(self):
"""Calculates the set of pages to render later in links() method.
It returns the list of pages to render via links for the pagination
"""Calculates the set of pages to render later in links() method.
It returns the list of pages to render via links for the pagination
By default, as we've observed in other applications, we're going to render
only a finite and predefined number of pages, avoiding visual fatigue related
to a long list of pages. By default, we render 7 pages plus some inactive links with the characters '...'
to point out that there are other pages that aren't explicitly rendered.
The primary way of work is to provide current webpage and 5 next pages, the last 2 ones
The primary way of work is to provide current webpage and 5 next pages, the last 2 ones
(in case the current page + 5 does not overflow the total lenght of pages) and the first one for reference.
"""
self.separator_character = '...'
default_pages_to_render = 7
after_page = 5
before_end = 2
before_page = 2
after_page = 2
window_size = before_page + after_page + 1
# Add 1 to self.total_pages since our default page is 1 and not 0
total_pages = self.total_pages + 1
# Add 1 to total_pages since our starting page is 1 and not 0
last_page = self.total_pages
pages = []
if total_pages > default_pages_to_render:
if self.page > 1:
pages.extend([1, '...'])
# will default window + start, end fit without truncation?
if self.total_pages > window_size + 2:
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(list(range(self.page, total_pages)))
pages.extend(range(max(1, self.page - before_page), self.page))
# 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:
if total_pages >= self.page + after_page + before_end:
pages.extend(list(range(self.page, self.page + after_page)))
pages.append('...')
pages.extend(list(range(total_pages - before_end, total_pages)))
else:
pages.extend(list(range(self.page, self.page + after_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)))
# add full after_page entries
pages.extend(range(self.page, self.page + after_page + 1))
# add separator *if* this doesn't get to last page - 1
if self.page + after_page < last_page - 1:
pages.append(self.separator)
pages.append(last_page)
return pages
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
def links(self):
@@ -155,9 +186,11 @@ class Pagination:
page=page
)
)
elif page == self.separator_character:
elif page == self.separator:
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:
links.append(

View File

@@ -43,6 +43,7 @@ from . import utils
from .metrics import CHECK_ROUTES_DURATION_SECONDS
from .metrics import PROXY_POLL_DURATION_SECONDS
from .objects import Server
from .utils import exponential_backoff
from .utils import make_ssl_context
from .utils import url_path_join
from jupyterhub.traitlets import Command
@@ -768,10 +769,36 @@ class ConfigurableHTTPProxy(Proxy):
method=method,
headers={'Authorization': 'token {}'.format(self.auth_token)},
body=body,
connect_timeout=3, # default: 20s
request_timeout=10, # default: 20s
)
async with self.semaphore:
result = await client.fetch(req)
return result
async def _wait_for_api_request():
try:
async with self.semaphore:
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
async def add_route(self, routespec, target, data):
body = data or {}

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