announcements.py 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297
  1. """Announcements handler for JupyterLab."""
  2. # Copyright (c) Jupyter Development Team.
  3. # Distributed under the terms of the Modified BSD License.
  4. import abc
  5. import hashlib
  6. import json
  7. import xml.etree.ElementTree as ET
  8. from collections.abc import Awaitable
  9. from dataclasses import asdict, dataclass, field
  10. from datetime import datetime, timezone
  11. from typing import Optional, Union
  12. from jupyter_server.base.handlers import APIHandler
  13. from jupyterlab_server.translation_utils import translator
  14. from packaging.version import parse
  15. from tornado import httpclient, web
  16. from jupyterlab._version import __version__
  17. ISO8601_FORMAT = "%Y-%m-%dT%H:%M:%S%z"
  18. JUPYTERLAB_LAST_RELEASE_URL = "https://pypi.org/pypi/jupyterlab/json"
  19. JUPYTERLAB_RELEASE_URL = "https://github.com/jupyterlab/jupyterlab/releases/tag/v"
  20. def format_datetime(dt_str: str):
  21. return datetime.fromisoformat(dt_str).timestamp() * 1000
  22. @dataclass(frozen=True)
  23. class Notification:
  24. """Notification
  25. Attributes:
  26. createdAt: Creation date
  27. message: Notification message
  28. modifiedAt: Modification date
  29. type: Notification type — ["default", "error", "info", "success", "warning"]
  30. link: Notification link button as a tuple (label, URL)
  31. options: Notification options
  32. """
  33. createdAt: float # noqa
  34. message: str
  35. modifiedAt: float # noqa
  36. type: str = "default"
  37. link: tuple[str, str] = field(default_factory=tuple)
  38. options: dict = field(default_factory=dict)
  39. class CheckForUpdateABC(abc.ABC):
  40. """Abstract class to check for update.
  41. Args:
  42. version: Current JupyterLab version
  43. Attributes:
  44. version - str: Current JupyterLab version
  45. logger - logging.Logger: Server logger
  46. """
  47. def __init__(self, version: str) -> None:
  48. self.version = version
  49. @abc.abstractmethod
  50. async def __call__(self) -> Awaitable[Union[None, str, tuple[str, tuple[str, str]]]]:
  51. """Get the notification message if a new version is available.
  52. Returns:
  53. None if there is not update.
  54. or the notification message
  55. or the notification message and a tuple(label, URL link) for the user to get more information
  56. """
  57. msg = "CheckForUpdateABC.__call__ is not implemented"
  58. raise NotImplementedError(msg)
  59. class CheckForUpdate(CheckForUpdateABC):
  60. """Default class to check for update.
  61. Args:
  62. version: Current JupyterLab version
  63. Attributes:
  64. version - str: Current JupyterLab version
  65. logger - logging.Logger: Server logger
  66. """
  67. async def __call__(self) -> Awaitable[tuple[str, tuple[str, str]]]:
  68. """Get the notification message if a new version is available.
  69. Returns:
  70. None if there is no update.
  71. or the notification message
  72. or the notification message and a tuple(label, URL link) for the user to get more information
  73. """
  74. http_client = httpclient.AsyncHTTPClient()
  75. try:
  76. response = await http_client.fetch(
  77. JUPYTERLAB_LAST_RELEASE_URL,
  78. headers={"Content-Type": "application/json"},
  79. )
  80. data = json.loads(response.body).get("info")
  81. last_version = data["version"]
  82. except Exception as e:
  83. self.logger.debug("Failed to get latest version", exc_info=e)
  84. return None
  85. else:
  86. if parse(self.version) < parse(last_version):
  87. trans = translator.load("jupyterlab")
  88. return (
  89. trans.gettext(f"A newer version ({last_version}) of JupyterLab is available."),
  90. (trans.gettext("Read more…"), f"{JUPYTERLAB_RELEASE_URL}{last_version}"),
  91. )
  92. else:
  93. return None
  94. class NeverCheckForUpdate(CheckForUpdateABC):
  95. """Check update version that does nothing.
  96. This is provided for administrators that want to
  97. turn off requesting external resources.
  98. Args:
  99. version: Current JupyterLab version
  100. Attributes:
  101. version - str: Current JupyterLab version
  102. logger - logging.Logger: Server logger
  103. """
  104. async def __call__(self) -> Awaitable[None]:
  105. """Get the notification message if a new version is available.
  106. Returns:
  107. None if there is no update.
  108. or the notification message
  109. or the notification message and a tuple(label, URL link) for the user to get more information
  110. """
  111. return None
  112. class CheckForUpdateHandler(APIHandler):
  113. """Check for Updates API handler.
  114. Args:
  115. update_check: The class checking for a new version
  116. """
  117. def initialize(
  118. self,
  119. update_checker: Optional[CheckForUpdate] = None,
  120. ) -> None:
  121. super().initialize()
  122. self.update_checker = (
  123. NeverCheckForUpdate(__version__) if update_checker is None else update_checker
  124. )
  125. self.update_checker.logger = self.log
  126. @web.authenticated
  127. async def get(self):
  128. """Check for updates.
  129. Response:
  130. {
  131. "notification": Optional[Notification]
  132. }
  133. """
  134. notification = None
  135. out = await self.update_checker()
  136. if out:
  137. message, link = (out, ()) if isinstance(out, str) else out
  138. now = datetime.now(tz=timezone.utc).timestamp() * 1000.0
  139. hash_ = hashlib.sha1(message.encode()).hexdigest() # noqa: S324
  140. notification = Notification(
  141. message=message,
  142. createdAt=now,
  143. modifiedAt=now,
  144. type="info",
  145. link=link,
  146. options={"data": {"id": hash_, "tags": ["update"]}},
  147. )
  148. self.set_status(200)
  149. self.finish(
  150. json.dumps({"notification": None if notification is None else asdict(notification)})
  151. )
  152. class NewsHandler(APIHandler):
  153. """News API handler.
  154. Args:
  155. news_url: The Atom feed to fetch for news
  156. """
  157. def initialize(
  158. self,
  159. news_url: Optional[str] = None,
  160. ) -> None:
  161. super().initialize()
  162. self.news_url = news_url
  163. @web.authenticated
  164. async def get(self):
  165. """Get the news.
  166. Response:
  167. {
  168. "news": List[Notification]
  169. }
  170. """
  171. news = []
  172. http_client = httpclient.AsyncHTTPClient()
  173. if self.news_url is not None:
  174. trans = translator.load("jupyterlab")
  175. # Those registrations are global, naming them to reduce chance of clashes
  176. xml_namespaces = {"atom": "http://www.w3.org/2005/Atom"}
  177. for key, spec in xml_namespaces.items():
  178. ET.register_namespace(key, spec)
  179. try:
  180. response = await http_client.fetch(
  181. self.news_url,
  182. headers={"Content-Type": "application/atom+xml"},
  183. )
  184. tree = ET.fromstring(response.body) # noqa S314
  185. def build_entry(node):
  186. def get_xml_text(attr: str, default: Optional[str] = None) -> str:
  187. node_item = node.find(f"atom:{attr}", xml_namespaces)
  188. if node_item is not None:
  189. return node_item.text
  190. elif default is not None:
  191. return default
  192. else:
  193. error_m = (
  194. f"atom feed entry does not contain a required attribute: {attr}"
  195. )
  196. raise KeyError(error_m)
  197. entry_title = get_xml_text("title")
  198. entry_id = get_xml_text("id")
  199. entry_updated = get_xml_text("updated")
  200. entry_published = get_xml_text("published", entry_updated)
  201. entry_summary = get_xml_text("summary", default="")
  202. links = node.findall("atom:link", xml_namespaces)
  203. if len(links) > 1:
  204. alternate = list(filter(lambda elem: elem.get("rel") == "alternate", links))
  205. link_node = alternate[0] if alternate else links[0]
  206. else:
  207. link_node = links[0] if len(links) == 1 else None
  208. entry_link = link_node.get("href") if link_node is not None else None
  209. message = (
  210. "\n".join([entry_title, entry_summary]) if entry_summary else entry_title
  211. )
  212. modified_at = format_datetime(entry_updated)
  213. created_at = format_datetime(entry_published)
  214. notification = Notification(
  215. message=message,
  216. createdAt=created_at,
  217. modifiedAt=modified_at,
  218. type="info",
  219. link=None
  220. if entry_link is None
  221. else (
  222. trans.__("Open full post"),
  223. entry_link,
  224. ),
  225. options={
  226. "data": {
  227. "id": entry_id,
  228. "tags": ["news"],
  229. }
  230. },
  231. )
  232. return notification
  233. entries = map(build_entry, tree.findall("atom:entry", xml_namespaces))
  234. news.extend(entries)
  235. except Exception as e:
  236. self.log.debug(
  237. f"Failed to get announcements from Atom feed: {self.news_url}",
  238. exc_info=e,
  239. )
  240. self.set_status(200)
  241. self.finish(json.dumps({"news": list(map(asdict, news))}))
  242. news_handler_path = r"/lab/api/news"
  243. check_update_handler_path = r"/lab/api/update"