adapter.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432
  1. """Adapters for Jupyter msg spec versions."""
  2. # Copyright (c) Jupyter Development Team.
  3. # Distributed under the terms of the Modified BSD License.
  4. import json
  5. import re
  6. from typing import Any
  7. from ._version import protocol_version_info
  8. def code_to_line(code: str, cursor_pos: int) -> tuple[str, int]:
  9. """Turn a multiline code block and cursor position into a single line
  10. and new cursor position.
  11. For adapting ``complete_`` and ``object_info_request``.
  12. """
  13. if not code:
  14. return "", 0
  15. for line in code.splitlines(True):
  16. n = len(line)
  17. if cursor_pos > n:
  18. cursor_pos -= n
  19. else:
  20. break
  21. return line, cursor_pos
  22. _match_bracket = re.compile(r"\([^\(\)]+\)", re.UNICODE)
  23. _end_bracket = re.compile(r"\([^\(]*$", re.UNICODE)
  24. _identifier = re.compile(r"[a-z_][0-9a-z._]*", re.I | re.UNICODE)
  25. def extract_oname_v4(code: str, cursor_pos: int) -> str:
  26. """Reimplement token-finding logic from IPython 2.x javascript
  27. for adapting object_info_request from v5 to v4
  28. """
  29. line, _ = code_to_line(code, cursor_pos)
  30. oldline = line
  31. line = _match_bracket.sub("", line)
  32. while oldline != line:
  33. oldline = line
  34. line = _match_bracket.sub("", line)
  35. # remove everything after last open bracket
  36. line = _end_bracket.sub("", line)
  37. matches = _identifier.findall(line)
  38. if matches:
  39. return matches[-1]
  40. else:
  41. return ""
  42. class Adapter:
  43. """Base class for adapting messages
  44. Override message_type(msg) methods to create adapters.
  45. """
  46. msg_type_map: dict[str, str] = {}
  47. def update_header(self, msg: dict[str, Any]) -> dict[str, Any]:
  48. """Update the header."""
  49. return msg
  50. def update_metadata(self, msg: dict[str, Any]) -> dict[str, Any]:
  51. """Update the metadata."""
  52. return msg
  53. def update_msg_type(self, msg: dict[str, Any]) -> dict[str, Any]:
  54. """Update the message type."""
  55. header = msg["header"]
  56. msg_type = header["msg_type"]
  57. if msg_type in self.msg_type_map:
  58. msg["msg_type"] = header["msg_type"] = self.msg_type_map[msg_type]
  59. return msg
  60. def handle_reply_status_error(self, msg: dict[str, Any]) -> dict[str, Any]:
  61. """This will be called *instead of* the regular handler
  62. on any reply with status != ok
  63. """
  64. return msg
  65. def __call__(self, msg: dict[str, Any]) -> dict[str, Any]:
  66. msg = self.update_header(msg)
  67. msg = self.update_metadata(msg)
  68. msg = self.update_msg_type(msg)
  69. header = msg["header"]
  70. handler = getattr(self, header["msg_type"], None)
  71. if handler is None:
  72. return msg
  73. # handle status=error replies separately (no change, at present)
  74. if msg["content"].get("status", None) in {"error", "aborted"}:
  75. return self.handle_reply_status_error(msg)
  76. return handler(msg)
  77. def _version_str_to_list(version: str) -> list[int]:
  78. """convert a version string to a list of ints
  79. non-int segments are excluded
  80. """
  81. v = []
  82. for part in version.split("."):
  83. try:
  84. v.append(int(part))
  85. except ValueError:
  86. pass
  87. return v
  88. class V5toV4(Adapter):
  89. """Adapt msg protocol v5 to v4"""
  90. version = "4.1"
  91. msg_type_map = {
  92. "execute_result": "pyout",
  93. "execute_input": "pyin",
  94. "error": "pyerr",
  95. "inspect_request": "object_info_request",
  96. "inspect_reply": "object_info_reply",
  97. }
  98. def update_header(self, msg: dict[str, Any]) -> dict[str, Any]:
  99. """Update the header."""
  100. msg["header"].pop("version", None)
  101. msg["parent_header"].pop("version", None)
  102. return msg
  103. # shell channel
  104. def kernel_info_reply(self, msg: dict[str, Any]) -> dict[str, Any]:
  105. """Handle a kernel info reply."""
  106. v4c = {}
  107. content = msg["content"]
  108. for key in ("language_version", "protocol_version"):
  109. if key in content:
  110. v4c[key] = _version_str_to_list(content[key])
  111. if content.get("implementation", "") == "ipython" and "implementation_version" in content:
  112. v4c["ipython_version"] = _version_str_to_list(content["implementation_version"])
  113. language_info = content.get("language_info", {})
  114. language = language_info.get("name", "")
  115. v4c.setdefault("language", language)
  116. if "version" in language_info:
  117. v4c.setdefault("language_version", _version_str_to_list(language_info["version"]))
  118. msg["content"] = v4c
  119. return msg
  120. def execute_request(self, msg: dict[str, Any]) -> dict[str, Any]:
  121. """Handle an execute request."""
  122. content = msg["content"]
  123. content.setdefault("user_variables", [])
  124. return msg
  125. def execute_reply(self, msg: dict[str, Any]) -> dict[str, Any]:
  126. """Handle an execute reply."""
  127. content = msg["content"]
  128. content.setdefault("user_variables", {})
  129. # TODO: handle payloads
  130. return msg
  131. def complete_request(self, msg: dict[str, Any]) -> dict[str, Any]:
  132. """Handle a complete request."""
  133. content = msg["content"]
  134. code = content["code"]
  135. cursor_pos = content["cursor_pos"]
  136. line, cursor_pos = code_to_line(code, cursor_pos)
  137. new_content = msg["content"] = {}
  138. new_content["text"] = ""
  139. new_content["line"] = line
  140. new_content["block"] = None
  141. new_content["cursor_pos"] = cursor_pos
  142. return msg
  143. def complete_reply(self, msg: dict[str, Any]) -> dict[str, Any]:
  144. """Handle a complete reply."""
  145. content = msg["content"]
  146. cursor_start = content.pop("cursor_start")
  147. cursor_end = content.pop("cursor_end")
  148. match_len = cursor_end - cursor_start
  149. content["matched_text"] = content["matches"][0][:match_len]
  150. content.pop("metadata", None)
  151. return msg
  152. def object_info_request(self, msg: dict[str, Any]) -> dict[str, Any]:
  153. """Handle an object info request."""
  154. content = msg["content"]
  155. code = content["code"]
  156. cursor_pos = content["cursor_pos"]
  157. _line, _ = code_to_line(code, cursor_pos)
  158. new_content = msg["content"] = {}
  159. new_content["oname"] = extract_oname_v4(code, cursor_pos)
  160. new_content["detail_level"] = content["detail_level"]
  161. return msg
  162. def object_info_reply(self, msg: dict[str, Any]) -> dict[str, Any]:
  163. """inspect_reply can't be easily backward compatible"""
  164. msg["content"] = {"found": False, "oname": "unknown"}
  165. return msg
  166. # iopub channel
  167. def stream(self, msg: dict[str, Any]) -> dict[str, Any]:
  168. """Handle a stream message."""
  169. content = msg["content"]
  170. content["data"] = content.pop("text")
  171. return msg
  172. def display_data(self, msg: dict[str, Any]) -> dict[str, Any]:
  173. """Handle a display data message."""
  174. content = msg["content"]
  175. content.setdefault("source", "display")
  176. data = content["data"]
  177. if "application/json" in data:
  178. try:
  179. data["application/json"] = json.dumps(data["application/json"])
  180. except Exception:
  181. # warn?
  182. pass
  183. return msg
  184. # stdin channel
  185. def input_request(self, msg: dict[str, Any]) -> dict[str, Any]:
  186. """Handle an input request."""
  187. msg["content"].pop("password", None)
  188. return msg
  189. class V4toV5(Adapter):
  190. """Convert msg spec V4 to V5"""
  191. version = "5.0"
  192. # invert message renames above
  193. msg_type_map = {v: k for k, v in V5toV4.msg_type_map.items()}
  194. def update_header(self, msg: dict[str, Any]) -> dict[str, Any]:
  195. """Update the header."""
  196. msg["header"]["version"] = self.version
  197. if msg["parent_header"]:
  198. msg["parent_header"]["version"] = self.version
  199. return msg
  200. # shell channel
  201. def kernel_info_reply(self, msg: dict[str, Any]) -> dict[str, Any]:
  202. """Handle a kernel info reply."""
  203. content = msg["content"]
  204. for key in ("protocol_version", "ipython_version"):
  205. if key in content:
  206. content[key] = ".".join(map(str, content[key]))
  207. content.setdefault("protocol_version", "4.1")
  208. if content["language"].startswith("python") and "ipython_version" in content:
  209. content["implementation"] = "ipython"
  210. content["implementation_version"] = content.pop("ipython_version")
  211. language = content.pop("language")
  212. language_info = content.setdefault("language_info", {})
  213. language_info.setdefault("name", language)
  214. if "language_version" in content:
  215. language_version = ".".join(map(str, content.pop("language_version")))
  216. language_info.setdefault("version", language_version)
  217. content["banner"] = ""
  218. return msg
  219. def execute_request(self, msg: dict[str, Any]) -> dict[str, Any]:
  220. """Handle an execute request."""
  221. content = msg["content"]
  222. user_variables = content.pop("user_variables", [])
  223. user_expressions = content.setdefault("user_expressions", {})
  224. for v in user_variables:
  225. user_expressions[v] = v
  226. return msg
  227. def execute_reply(self, msg: dict[str, Any]) -> dict[str, Any]:
  228. """Handle an execute reply."""
  229. content = msg["content"]
  230. user_expressions = content.setdefault("user_expressions", {})
  231. user_variables = content.pop("user_variables", {})
  232. if user_variables:
  233. user_expressions.update(user_variables)
  234. # Pager payloads became a mime bundle
  235. for payload in content.get("payload", []):
  236. if payload.get("source", None) == "page" and ("text" in payload):
  237. if "data" not in payload:
  238. payload["data"] = {}
  239. payload["data"]["text/plain"] = payload.pop("text")
  240. return msg
  241. def complete_request(self, msg: dict[str, Any]) -> dict[str, Any]:
  242. """Handle a complete request."""
  243. old_content = msg["content"]
  244. new_content = msg["content"] = {}
  245. new_content["code"] = old_content["line"]
  246. new_content["cursor_pos"] = old_content["cursor_pos"]
  247. return msg
  248. def complete_reply(self, msg: dict[str, Any]) -> dict[str, Any]:
  249. """Handle a complete reply."""
  250. # complete_reply needs more context than we have to get cursor_start and end.
  251. # use special end=null to indicate current cursor position and negative offset
  252. # for start relative to the cursor.
  253. # start=None indicates that start == end (accounts for no -0).
  254. content = msg["content"]
  255. new_content = msg["content"] = {"status": "ok"}
  256. new_content["matches"] = content["matches"]
  257. if content["matched_text"]:
  258. new_content["cursor_start"] = -len(content["matched_text"])
  259. else:
  260. # no -0, use None to indicate that start == end
  261. new_content["cursor_start"] = None
  262. new_content["cursor_end"] = None
  263. new_content["metadata"] = {}
  264. return msg
  265. def inspect_request(self, msg: dict[str, Any]) -> dict[str, Any]:
  266. """Handle an inspect request."""
  267. content = msg["content"]
  268. name = content["oname"]
  269. new_content = msg["content"] = {}
  270. new_content["code"] = name
  271. new_content["cursor_pos"] = len(name)
  272. new_content["detail_level"] = content["detail_level"]
  273. return msg
  274. def inspect_reply(self, msg: dict[str, Any]) -> dict[str, Any]:
  275. """inspect_reply can't be easily backward compatible"""
  276. content = msg["content"]
  277. new_content = msg["content"] = {"status": "ok"}
  278. found = new_content["found"] = content["found"]
  279. new_content["data"] = data = {}
  280. new_content["metadata"] = {}
  281. if found:
  282. lines = []
  283. for key in ("call_def", "init_definition", "definition"):
  284. if content.get(key, False):
  285. lines.append(content[key])
  286. break
  287. for key in ("call_docstring", "init_docstring", "docstring"):
  288. if content.get(key, False):
  289. lines.append(content[key])
  290. break
  291. if not lines:
  292. lines.append("<empty docstring>")
  293. data["text/plain"] = "\n".join(lines)
  294. return msg
  295. # iopub channel
  296. def stream(self, msg: dict[str, Any]) -> dict[str, Any]:
  297. """Handle a stream message."""
  298. content = msg["content"]
  299. content["text"] = content.pop("data")
  300. return msg
  301. def display_data(self, msg: dict[str, Any]) -> dict[str, Any]:
  302. """Handle display data."""
  303. content = msg["content"]
  304. content.pop("source", None)
  305. data = content["data"]
  306. if "application/json" in data:
  307. try:
  308. data["application/json"] = json.loads(data["application/json"])
  309. except Exception:
  310. # warn?
  311. pass
  312. return msg
  313. # stdin channel
  314. def input_request(self, msg: dict[str, Any]) -> dict[str, Any]:
  315. """Handle an input request."""
  316. msg["content"].setdefault("password", False)
  317. return msg
  318. def adapt(msg: dict[str, Any], to_version: int = protocol_version_info[0]) -> dict[str, Any]:
  319. """Adapt a single message to a target version
  320. Parameters
  321. ----------
  322. msg : dict
  323. A Jupyter message.
  324. to_version : int, optional
  325. The target major version.
  326. If unspecified, adapt to the current version.
  327. Returns
  328. -------
  329. msg : dict
  330. A Jupyter message appropriate in the new version.
  331. """
  332. from .session import utcnow
  333. header = msg["header"]
  334. if "date" not in header:
  335. header["date"] = utcnow()
  336. if "version" in header:
  337. from_version = int(header["version"].split(".")[0])
  338. else:
  339. # assume last version before adding the key to the header
  340. from_version = 4
  341. adapter = adapters.get((from_version, to_version), None)
  342. if adapter is None:
  343. return msg
  344. return adapter(msg)
  345. # one adapter per major version from,to
  346. adapters = {
  347. (5, 4): V5toV4(),
  348. (4, 5): V4toV5(),
  349. }