From 8ff513c323a4fef9745a9f4ad6a1fba15653fd01 Mon Sep 17 00:00:00 2001 From: Tyler Pirtle Date: Fri, 13 Dec 2024 22:36:41 +0000 Subject: [PATCH 1/2] Add support for displaying image/png data. This change maps image/png display data from execution results as tags with in-line data. --- kernel_gateway/notebook_http/handlers.py | 40 +++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/kernel_gateway/notebook_http/handlers.py b/kernel_gateway/notebook_http/handlers.py index a87c0eb..0ddc826 100644 --- a/kernel_gateway/notebook_http/handlers.py +++ b/kernel_gateway/notebook_http/handlers.py @@ -61,6 +61,32 @@ def initialize(self, sources, response_sources, kernel_pool, kernel_name, kernel self.response_sources = response_sources self.kernel_language = kernel_language + def _accumulate_display(self, results): + """Accumulates result chunks for "display" messages and prepares them as + + in-line tags. See + https://ipython.org/ipython-doc/3/development/messaging.html#display-data + for details on the display protocol. + + Parameters + ---------- + results: list + A list of results containing display data. + """ + out = [] + for result in results: + if "image/png" in result: + out.append( + '%s' + % (result.get("text/plain", ""), result["image/png"]) + ) + continue + if "text/html" in result: + out.append(result["text/html"]) + if "text/plain" in result: + out.append(result["text/plain"]) + return out + def finish_future(self, future, result_accumulator): """Resolves the promise to respond to a HTTP request handled by a kernel in the pool. @@ -84,6 +110,10 @@ def finish_future(self, future, result_accumulator): """ if result_accumulator["error"]: future.set_exception(CodeExecutionError(result_accumulator["error"])) + elif len(result_accumulator["display"]) > 0: + future.set_result( + "\n".join(self._accumulate_display(result_accumulator["display"])) + ) elif len(result_accumulator["stream"]) > 0: future.set_result("".join(result_accumulator["stream"])) elif result_accumulator["result"]: @@ -123,6 +153,9 @@ def on_recv(self, result_accumulator, future, parent_header, msg): # Store the execute result elif msg["header"]["msg_type"] == "execute_result": result_accumulator["result"] = msg["content"]["data"] + # Accumulate display data + elif msg['header']['msg_type'] == 'display_data': + result_accumulator['display'].append(msg['content']['data']) # Accumulate the stream messages elif msg["header"]["msg_type"] == "stream": # Only take stream output if it is on stdout or if the kernel @@ -162,7 +195,12 @@ def execute_code(self, kernel_client, kernel_id, source_code): If the kernel returns any error """ future = Future() - result_accumulator = {"stream": [], "error": None, "result": None} + result_accumulator = { + "display": [], + "stream": [], + "error": None, + "result": None, + } parent_header = kernel_client.execute(source_code) on_recv_func = partial(self.on_recv, result_accumulator, future, parent_header) self.kernel_pool.on_recv(kernel_id, on_recv_func) From 23398912c32c28525fc4f172fee63f7c274dad40 Mon Sep 17 00:00:00 2001 From: Tyler Pirtle Date: Thu, 13 Nov 2025 17:36:58 +0000 Subject: [PATCH 2/2] add display support for markdown cells --- kernel_gateway/notebook_http/__init__.py | 8 +- kernel_gateway/notebook_http/cell/parser.py | 49 ++++++-- kernel_gateway/notebook_http/handlers.py | 13 +- pyproject.toml | 2 + tests/notebook_http/cell/test_parser.py | 128 +++----------------- 5 files changed, 73 insertions(+), 127 deletions(-) diff --git a/kernel_gateway/notebook_http/__init__.py b/kernel_gateway/notebook_http/__init__.py index 514ff0d..d94cd33 100644 --- a/kernel_gateway/notebook_http/__init__.py +++ b/kernel_gateway/notebook_http/__init__.py @@ -107,10 +107,8 @@ def create_request_handlers(self): handlers.append((path, tornado.web.StaticFileHandler, {"path": self.static_path})) # Discover the notebook endpoints and their implementations - endpoints = self.api_parser.endpoints(self.parent.kernel_manager.seed_source) - response_sources = self.api_parser.endpoint_responses( - self.parent.kernel_manager.seed_source - ) + endpoints = self.api_parser.endpoints() + response_sources = self.api_parser.endpoint_responses() if len(endpoints) == 0: raise RuntimeError( "No endpoints were discovered. Check your notebook to make sure your cells are annotated correctly." @@ -118,6 +116,7 @@ def create_request_handlers(self): # Cycle through the (endpoint_path, source) tuples and register their handlers for endpoint_path, verb_source_map in endpoints: + description = verb_source_map.pop("__description__", "") parameterized_path = parameterize_path(endpoint_path) parameterized_path = url_path_join("/", self.parent.base_url, parameterized_path) self.log.info( @@ -134,6 +133,7 @@ def create_request_handlers(self): "kernel_pool": self.kernel_pool, "kernel_name": self.parent.kernel_manager.seed_kernelspec, "kernel_language": self.kernel_language or "", + "description": description, } handlers.append((parameterized_path, NotebookAPIHandler, handler_args)) diff --git a/kernel_gateway/notebook_http/cell/parser.py b/kernel_gateway/notebook_http/cell/parser.py index 2f0208d..0bdc3e0 100644 --- a/kernel_gateway/notebook_http/cell/parser.py +++ b/kernel_gateway/notebook_http/cell/parser.py @@ -5,6 +5,7 @@ import re import sys +import markdown from traitlets import Unicode from traitlets.config.configurable import LoggingConfigurable @@ -79,6 +80,7 @@ def __init__(self, comment_prefix, notebook_cells=None, **kwargs): self.kernelspec_api_response_indicator = re.compile( self.api_response_indicator.format(comment_prefix) ) + self.notebook_cells = notebook_cells or [] def is_api_cell(self, cell_source): """Gets if the cell source is annotated as an API endpoint. @@ -151,13 +153,29 @@ def get_path_content(self, cell_source): """ return {"responses": {200: {"description": "Success"}}} - def endpoints(self, source_cells, sort_func=first_path_param_index): + def render_markdown_cell(self, cell_source): + """Renders a markdown cell as HTML. + + Parameters + ---------- + cell_source + Source from a notebook cell + + Returns + ------- + str + HTML representation of the markdown cell + """ + source_lines = cell_source.split('\n') + if source_lines and self.is_api_cell(source_lines[0]): + source_lines.pop(0) + return markdown.markdown('\n'.join(source_lines)) + + def endpoints(self, sort_func=first_path_param_index): """Gets the list of all annotated endpoint HTTP paths and verbs. Parameters ---------- - source_cells - List of source strings from notebook cells sort_func Function by which to sort the endpoint list @@ -169,19 +187,24 @@ def endpoints(self, source_cells, sort_func=first_path_param_index): element of each tuple """ endpoints = {} - for cell_source in source_cells: - if self.is_api_cell(cell_source): - matched = self.kernelspec_api_indicator.match(cell_source) + for cell in self.notebook_cells: + if self.is_api_cell(cell.source): + matched = self.kernelspec_api_indicator.match(cell.source) uri = matched.group(2).strip() verb = matched.group(1) - endpoints.setdefault(uri, {}).setdefault(verb, "") - endpoints[uri][verb] += cell_source + "\n" + endpoints.setdefault(uri, {}).setdefault(verb, {}) + if cell.cell_type == "markdown": + endpoints[uri][verb]['source'] = self.render_markdown_cell(cell.source) + endpoints[uri][verb]['cell_type'] = 'markdown' + else: + endpoints[uri][verb]['source'] = cell.source + "\n" + endpoints[uri][verb]['cell_type'] = 'code' sorted_keys = sorted(endpoints, key=sort_func, reverse=True) return [(key, endpoints[key]) for key in sorted_keys] - def endpoint_responses(self, source_cells, sort_func=first_path_param_index): + def endpoint_responses(self, sort_func=first_path_param_index): """Gets the list of all annotated ResponseInfo HTTP paths and verbs. Parameters @@ -199,14 +222,14 @@ def endpoint_responses(self, source_cells, sort_func=first_path_param_index): element of each tuple """ endpoints = {} - for cell_source in source_cells: - if self.is_api_response_cell(cell_source): - matched = self.kernelspec_api_response_indicator.match(cell_source) + for cell in self.notebook_cells: + if self.is_api_response_cell(cell.source): + matched = self.kernelspec_api_response_indicator.match(cell.source) uri = matched.group(2).strip() verb = matched.group(1) endpoints.setdefault(uri, {}).setdefault(verb, "") - endpoints[uri][verb] += cell_source + "\n" + endpoints[uri][verb] += cell.source + "\n" return endpoints def get_default_api_spec(self): diff --git a/kernel_gateway/notebook_http/handlers.py b/kernel_gateway/notebook_http/handlers.py index 0ddc826..1fed5b2 100644 --- a/kernel_gateway/notebook_http/handlers.py +++ b/kernel_gateway/notebook_http/handlers.py @@ -54,12 +54,13 @@ class NotebookAPIHandler( are identified, parsed, and associated with HTTP verbs and paths. """ - def initialize(self, sources, response_sources, kernel_pool, kernel_name, kernel_language=""): + def initialize(self, sources, response_sources, kernel_pool, kernel_name, kernel_language="", description=""): self.kernel_pool = kernel_pool self.sources = sources self.kernel_name = kernel_name self.response_sources = response_sources self.kernel_language = kernel_language + self.description = description def _accumulate_display(self, results): """Accumulates result chunks for "display" messages and prepares them as @@ -225,7 +226,15 @@ async def _handle_request(self): self.set_status(200) # Get the source to execute in response to this request - source_code = self.sources[self.request.method] + source_info = self.sources[self.request.method] + source_code = source_info['source'] + cell_type = source_info['cell_type'] + + if cell_type == 'markdown': + self.set_header("Content-Type", "text/html") + self.write(source_code) + return + # Build the request dictionary request = json.dumps( { diff --git a/pyproject.toml b/pyproject.toml index 3682fc7..9d1623d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,8 @@ dependencies = [ "requests>=2.31", "tornado>=6.4", "traitlets>=5.14.1", + "markdown>=3.3.4", + "ipykernel>=6.29.5", ] [project.scripts] diff --git a/tests/notebook_http/cell/test_parser.py b/tests/notebook_http/cell/test_parser.py index e509f6f..f1c2e0d 100644 --- a/tests/notebook_http/cell/test_parser.py +++ b/tests/notebook_http/cell/test_parser.py @@ -1,115 +1,27 @@ # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. -"""Tests for notebook cell parsing.""" - -import sys - +import unittest +from unittest.mock import Mock from kernel_gateway.notebook_http.cell.parser import APICellParser +class TestAPICellParser(unittest.TestCase): + def test_markdown_cells_are_rendered(self): + mock_markdown_cell = Mock() + mock_markdown_cell.cell_type = "markdown" + mock_markdown_cell.source = "# GET /try\n\nThis is a markdown cell." -class TestAPICellParser: - """Unit tests the APICellParser class.""" - - def test_is_api_cell(self): - """Parser should correctly identify annotated API cells.""" - parser = APICellParser(comment_prefix="#") - assert parser.is_api_cell("# GET /yes"), "API cell was not detected" - assert parser.is_api_cell("no") is False, "API cell was not detected" - - def test_endpoint_sort_default_strategy(self): - """Parser should sort duplicate endpoint paths.""" - source_cells = [ - "# POST /:foo", - "# POST /hello/:foo", - "# GET /hello/:foo", - "# PUT /hello/world", - ] - parser = APICellParser(comment_prefix="#") - endpoints = parser.endpoints(source_cells) - expected_values = ["/hello/world", "/hello/:foo", "/:foo"] - - for index in range(len(expected_values)): - endpoint, _ = endpoints[index] - assert expected_values[index] == endpoint, "Endpoint was not found in expected order" - - def test_endpoint_sort_custom_strategy(self): - """Parser should sort duplicate endpoint paths using a custom sort - strategy. - """ - source_cells = ["# POST /1", "# POST /+", "# GET /a"] - - def custom_sort_fun(endpoint): - _ = sys.maxsize - if endpoint.find("1") >= 0: - return 0 - elif endpoint.find("a") >= 0: - return 1 - else: - return 2 - - parser = APICellParser(comment_prefix="#") - endpoints = parser.endpoints(source_cells, custom_sort_fun) - expected_values = ["/+", "/a", "/1"] - - for index in range(len(expected_values)): - endpoint, _ = endpoints[index] - assert expected_values[index] == endpoint, "Endpoint was not found in expected order" - - def test_get_cell_endpoint_and_verb(self): - """Parser should extract API endpoint and verb from cell annotations.""" - parser = APICellParser(comment_prefix="#") - endpoint, verb = parser.get_cell_endpoint_and_verb("# GET /foo") - assert endpoint, "/foo" == "Endpoint was not extracted correctly" - assert verb, "GET" == "Endpoint was not extracted correctly" - endpoint, verb = parser.get_cell_endpoint_and_verb("# POST /bar/quo") - assert endpoint, "/bar/quo" == "Endpoint was not extracted correctly" - assert verb, "POST" == "Endpoint was not extracted correctly" - - endpoint, verb = parser.get_cell_endpoint_and_verb("some regular code") - assert endpoint is None, "Endpoint was not extracted correctly" - assert verb is None, "Endpoint was not extracted correctly" - - def test_endpoint_concatenation(self): - """Parser should concatenate multiple cells with the same verb+path.""" - source_cells = [ - "# POST /foo/:bar", - "# POST /foo/:bar", - "# POST /foo", - "ignored", - "# GET /foo/:bar", - ] - parser = APICellParser(comment_prefix="#") - endpoints = parser.endpoints(source_cells) - assert len(endpoints) == 2 - # for ease of testing - endpoints = dict(endpoints) - assert len(endpoints["/foo"]) == 1 - assert len(endpoints["/foo/:bar"]) == 2 - assert endpoints["/foo"]["POST"] == "# POST /foo\n" - assert endpoints["/foo/:bar"]["POST"] == "# POST /foo/:bar\n# POST /foo/:bar\n" - assert endpoints["/foo/:bar"]["GET"] == "# GET /foo/:bar\n" + mock_code_cell = Mock() + mock_code_cell.cell_type = "code" + mock_code_cell.source = "# GET /hello\nprint('hello')" - def test_endpoint_response_concatenation(self): - """Parser should concatenate multiple response cells with the same - verb+path. - """ - source_cells = [ - "# ResponseInfo POST /foo/:bar", - "# ResponseInfo POST /foo/:bar", - "# ResponseInfo POST /foo", - "ignored", - "# ResponseInfo GET /foo/:bar", + notebook_cells = [ + mock_markdown_cell, + mock_code_cell ] - parser = APICellParser(comment_prefix="#") - endpoints = parser.endpoint_responses(source_cells) - assert len(endpoints) == 2 - # for ease of testing - endpoints = dict(endpoints) - assert len(endpoints["/foo"]) == 1 - assert len(endpoints["/foo/:bar"]) == 2 - assert endpoints["/foo"]["POST"] == "# ResponseInfo POST /foo\n" - assert ( - endpoints["/foo/:bar"]["POST"] - == "# ResponseInfo POST /foo/:bar\n# ResponseInfo POST /foo/:bar\n" - ) - assert endpoints["/foo/:bar"]["GET"] == "# ResponseInfo GET /foo/:bar\n" + parser = APICellParser(comment_prefix="#", notebook_cells=notebook_cells) + endpoints = dict(parser.endpoints()) + self.assertEqual(len(endpoints), 2) + self.assertIn("/hello", endpoints) + self.assertIn("/try", endpoints) + self.assertEqual(endpoints["/try"]["GET"]['cell_type'], "markdown") + self.assertEqual(endpoints["/try"]["GET"]['source'], "

This is a markdown cell.

") \ No newline at end of file