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"
 |