You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
298 lines
9.8 KiB
Python
298 lines
9.8 KiB
Python
"""Announcements handler for JupyterLab."""
|
|
|
|
# Copyright (c) Jupyter Development Team.
|
|
# Distributed under the terms of the Modified BSD License.
|
|
|
|
import abc
|
|
import hashlib
|
|
import json
|
|
import xml.etree.ElementTree as ET
|
|
from collections.abc import Awaitable
|
|
from dataclasses import asdict, dataclass, field
|
|
from datetime import datetime, timezone
|
|
from typing import Optional, Union
|
|
|
|
from jupyter_server.base.handlers import APIHandler
|
|
from jupyterlab_server.translation_utils import translator
|
|
from packaging.version import parse
|
|
from tornado import httpclient, web
|
|
|
|
from jupyterlab._version import __version__
|
|
|
|
ISO8601_FORMAT = "%Y-%m-%dT%H:%M:%S%z"
|
|
JUPYTERLAB_LAST_RELEASE_URL = "https://pypi.org/pypi/jupyterlab/json"
|
|
JUPYTERLAB_RELEASE_URL = "https://github.com/jupyterlab/jupyterlab/releases/tag/v"
|
|
|
|
|
|
def format_datetime(dt_str: str):
|
|
return datetime.fromisoformat(dt_str).timestamp() * 1000
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Notification:
|
|
"""Notification
|
|
|
|
Attributes:
|
|
createdAt: Creation date
|
|
message: Notification message
|
|
modifiedAt: Modification date
|
|
type: Notification type — ["default", "error", "info", "success", "warning"]
|
|
link: Notification link button as a tuple (label, URL)
|
|
options: Notification options
|
|
"""
|
|
|
|
createdAt: float # noqa
|
|
message: str
|
|
modifiedAt: float # noqa
|
|
type: str = "default"
|
|
link: tuple[str, str] = field(default_factory=tuple)
|
|
options: dict = field(default_factory=dict)
|
|
|
|
|
|
class CheckForUpdateABC(abc.ABC):
|
|
"""Abstract class to check for update.
|
|
|
|
Args:
|
|
version: Current JupyterLab version
|
|
|
|
Attributes:
|
|
version - str: Current JupyterLab version
|
|
logger - logging.Logger: Server logger
|
|
"""
|
|
|
|
def __init__(self, version: str) -> None:
|
|
self.version = version
|
|
|
|
@abc.abstractmethod
|
|
async def __call__(self) -> Awaitable[Union[None, str, tuple[str, tuple[str, str]]]]:
|
|
"""Get the notification message if a new version is available.
|
|
|
|
Returns:
|
|
None if there is not update.
|
|
or the notification message
|
|
or the notification message and a tuple(label, URL link) for the user to get more information
|
|
"""
|
|
msg = "CheckForUpdateABC.__call__ is not implemented"
|
|
raise NotImplementedError(msg)
|
|
|
|
|
|
class CheckForUpdate(CheckForUpdateABC):
|
|
"""Default class to check for update.
|
|
|
|
Args:
|
|
version: Current JupyterLab version
|
|
|
|
Attributes:
|
|
version - str: Current JupyterLab version
|
|
logger - logging.Logger: Server logger
|
|
"""
|
|
|
|
async def __call__(self) -> Awaitable[tuple[str, tuple[str, str]]]:
|
|
"""Get the notification message if a new version is available.
|
|
|
|
Returns:
|
|
None if there is no update.
|
|
or the notification message
|
|
or the notification message and a tuple(label, URL link) for the user to get more information
|
|
"""
|
|
http_client = httpclient.AsyncHTTPClient()
|
|
try:
|
|
response = await http_client.fetch(
|
|
JUPYTERLAB_LAST_RELEASE_URL,
|
|
headers={"Content-Type": "application/json"},
|
|
)
|
|
data = json.loads(response.body).get("info")
|
|
last_version = data["version"]
|
|
except Exception as e:
|
|
self.logger.debug("Failed to get latest version", exc_info=e)
|
|
return None
|
|
else:
|
|
if parse(self.version) < parse(last_version):
|
|
trans = translator.load("jupyterlab")
|
|
return (
|
|
trans.__(f"A newer version ({last_version}) of JupyterLab is available."),
|
|
(trans.__("Read more…"), f"{JUPYTERLAB_RELEASE_URL}{last_version}"),
|
|
)
|
|
else:
|
|
return None
|
|
|
|
|
|
class NeverCheckForUpdate(CheckForUpdateABC):
|
|
"""Check update version that does nothing.
|
|
|
|
This is provided for administrators that want to
|
|
turn off requesting external resources.
|
|
|
|
Args:
|
|
version: Current JupyterLab version
|
|
|
|
Attributes:
|
|
version - str: Current JupyterLab version
|
|
logger - logging.Logger: Server logger
|
|
"""
|
|
|
|
async def __call__(self) -> Awaitable[None]:
|
|
"""Get the notification message if a new version is available.
|
|
|
|
Returns:
|
|
None if there is no update.
|
|
or the notification message
|
|
or the notification message and a tuple(label, URL link) for the user to get more information
|
|
"""
|
|
return None
|
|
|
|
|
|
class CheckForUpdateHandler(APIHandler):
|
|
"""Check for Updates API handler.
|
|
|
|
Args:
|
|
update_check: The class checking for a new version
|
|
"""
|
|
|
|
def initialize(
|
|
self,
|
|
update_checker: Optional[CheckForUpdate] = None,
|
|
) -> None:
|
|
super().initialize()
|
|
self.update_checker = (
|
|
NeverCheckForUpdate(__version__) if update_checker is None else update_checker
|
|
)
|
|
self.update_checker.logger = self.log
|
|
|
|
@web.authenticated
|
|
async def get(self):
|
|
"""Check for updates.
|
|
Response:
|
|
{
|
|
"notification": Optional[Notification]
|
|
}
|
|
"""
|
|
notification = None
|
|
out = await self.update_checker()
|
|
if out:
|
|
message, link = (out, ()) if isinstance(out, str) else out
|
|
now = datetime.now(tz=timezone.utc).timestamp() * 1000.0
|
|
hash_ = hashlib.sha1(message.encode()).hexdigest() # noqa: S324
|
|
notification = Notification(
|
|
message=message,
|
|
createdAt=now,
|
|
modifiedAt=now,
|
|
type="info",
|
|
link=link,
|
|
options={"data": {"id": hash_, "tags": ["update"]}},
|
|
)
|
|
|
|
self.set_status(200)
|
|
self.finish(
|
|
json.dumps({"notification": None if notification is None else asdict(notification)})
|
|
)
|
|
|
|
|
|
class NewsHandler(APIHandler):
|
|
"""News API handler.
|
|
|
|
Args:
|
|
news_url: The Atom feed to fetch for news
|
|
"""
|
|
|
|
def initialize(
|
|
self,
|
|
news_url: Optional[str] = None,
|
|
) -> None:
|
|
super().initialize()
|
|
self.news_url = news_url
|
|
|
|
@web.authenticated
|
|
async def get(self):
|
|
"""Get the news.
|
|
|
|
Response:
|
|
{
|
|
"news": List[Notification]
|
|
}
|
|
"""
|
|
news = []
|
|
|
|
http_client = httpclient.AsyncHTTPClient()
|
|
|
|
if self.news_url is not None:
|
|
trans = translator.load("jupyterlab")
|
|
|
|
# Those registrations are global, naming them to reduce chance of clashes
|
|
xml_namespaces = {"atom": "http://www.w3.org/2005/Atom"}
|
|
for key, spec in xml_namespaces.items():
|
|
ET.register_namespace(key, spec)
|
|
|
|
try:
|
|
response = await http_client.fetch(
|
|
self.news_url,
|
|
headers={"Content-Type": "application/atom+xml"},
|
|
)
|
|
tree = ET.fromstring(response.body) # noqa S314
|
|
|
|
def build_entry(node):
|
|
def get_xml_text(attr: str, default: Optional[str] = None) -> str:
|
|
node_item = node.find(f"atom:{attr}", xml_namespaces)
|
|
if node_item is not None:
|
|
return node_item.text
|
|
elif default is not None:
|
|
return default
|
|
else:
|
|
error_m = (
|
|
f"atom feed entry does not contain a required attribute: {attr}"
|
|
)
|
|
raise KeyError(error_m)
|
|
|
|
entry_title = get_xml_text("title")
|
|
entry_id = get_xml_text("id")
|
|
entry_updated = get_xml_text("updated")
|
|
entry_published = get_xml_text("published", entry_updated)
|
|
entry_summary = get_xml_text("summary", default="")
|
|
links = node.findall("atom:link", xml_namespaces)
|
|
if len(links) > 1:
|
|
alternate = list(filter(lambda elem: elem.get("rel") == "alternate", links))
|
|
link_node = alternate[0] if alternate else links[0]
|
|
else:
|
|
link_node = links[0] if len(links) == 1 else None
|
|
entry_link = link_node.get("href") if link_node is not None else None
|
|
|
|
message = (
|
|
"\n".join([entry_title, entry_summary]) if entry_summary else entry_title
|
|
)
|
|
modified_at = format_datetime(entry_updated)
|
|
created_at = format_datetime(entry_published)
|
|
notification = Notification(
|
|
message=message,
|
|
createdAt=created_at,
|
|
modifiedAt=modified_at,
|
|
type="info",
|
|
link=None
|
|
if entry_link is None
|
|
else (
|
|
trans.__("Open full post"),
|
|
entry_link,
|
|
),
|
|
options={
|
|
"data": {
|
|
"id": entry_id,
|
|
"tags": ["news"],
|
|
}
|
|
},
|
|
)
|
|
return notification
|
|
|
|
entries = map(build_entry, tree.findall("atom:entry", xml_namespaces))
|
|
news.extend(entries)
|
|
except Exception as e:
|
|
self.log.debug(
|
|
f"Failed to get announcements from Atom feed: {self.news_url}",
|
|
exc_info=e,
|
|
)
|
|
|
|
self.set_status(200)
|
|
self.finish(json.dumps({"news": list(map(asdict, news))}))
|
|
|
|
|
|
news_handler_path = r"/lab/api/news"
|
|
check_update_handler_path = r"/lab/api/update"
|