Skip to content

Commit 401675f

Browse files
authored
All outputs for cell (#91)
* Removed unused code * Added a method to get all outputs for a cell. * Updated handler to accommodate all outputs * Updated to sort outputs by last modified time * Updated handler * lint * Updated to return jsonl response * Removed duplicate import * Sorting outputs by output index, simplified length check * Moved placeholder to a separate function, ruff format. * Updated method name * Cleaned up for better naming * Fixed a regression
1 parent 01e4e23 commit 401675f

File tree

5 files changed

+143
-92
lines changed

5 files changed

+143
-92
lines changed

jupyter_server_documents/handlers.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
import tornado
55

66
from jupyter_server.auth.decorator import authorized
7-
from jupyter_server.base.handlers import APIHandler
87
from tornado import web
98
from tornado.escape import json_encode
109

jupyter_server_documents/outputs/handlers.py

Lines changed: 9 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,20 @@ def outputs(self):
2020
@authorized
2121
async def get(self, file_id=None, cell_id=None, output_index=None):
2222
try:
23-
output = self.outputs.get_output(file_id, cell_id, output_index)
23+
if output_index:
24+
output = self.outputs.get_output(file_id, cell_id, output_index)
25+
content_type = "application/json"
26+
else:
27+
outputs = self.outputs.get_outputs(file_id, cell_id)
28+
output = "\n".join(outputs)
29+
content_type = "application/x-ndjson"
2430
except (FileNotFoundError, KeyError):
2531
self.set_status(404)
2632
self.finish({"error": "Output not found."})
2733
else:
2834
self.set_status(200)
29-
self.set_header("Content-Type", "application/json")
3035
self.write(output)
36+
self.finish(set_content_type=content_type)
3137

3238

3339
class StreamAPIHandler(APIHandler):
@@ -71,24 +77,7 @@ async def get(self, file_id=None, cell_id=None):
7177
_output_index_regex = r"(?P<output_index>0|[1-9]\d*)"
7278

7379
outputs_handlers = [
74-
(rf"/api/outputs/{_file_id_regex}/{_cell_id_regex}/{_output_index_regex}.output", OutputsAPIHandler),
80+
(rf"/api/outputs/{_file_id_regex}/{_cell_id_regex}(?:/{_output_index_regex}.output)?", OutputsAPIHandler),
7581
(rf"/api/outputs/{_file_id_regex}/{_cell_id_regex}/stream", StreamAPIHandler),
7682
]
7783

78-
# def setup_handlers(web_app):
79-
# """Setup the handlers for the outputs service."""
80-
81-
# handlers = [
82-
# (rf"/api/outputs/{_file_id_regex}/{_cell_id_regex}/{_output_index_regex}.output", OutputsAPIHandler),
83-
# (rf"/api/outputs/{_file_id_regex}/{_cell_id_regex}/stream", StreamAPIHandler),
84-
# ]
85-
86-
# base_url = web_app.settings["base_url"]
87-
# new_handlers = []
88-
# for handler in handlers:
89-
# pattern = url_path_join(base_url, handler[0])
90-
# new_handler = (pattern, *handler[1:])
91-
# new_handlers.append(new_handler)
92-
93-
# # Add the handler for all hosts
94-
# web_app.add_handlers(".*$", new_handlers)

jupyter_server_documents/outputs/manager.py

Lines changed: 99 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,12 @@
66
from pycrdt import Map
77

88
from traitlets.config import LoggingConfigurable
9-
from traitlets import (
10-
Dict,
11-
Instance,
12-
Int,
13-
default
14-
)
9+
from traitlets import Dict, Instance, Int, default
1510

1611
from jupyter_core.paths import jupyter_runtime_dir
1712

18-
class OutputsManager(LoggingConfigurable):
1913

14+
class OutputsManager(LoggingConfigurable):
2015
_last_output_index = Dict(default_value={})
2116
_stream_count = Dict(default_value={})
2217

@@ -26,7 +21,7 @@ class OutputsManager(LoggingConfigurable):
2621
@default("outputs_path")
2722
def _default_outputs_path(self):
2823
return Path(jupyter_runtime_dir()) / "outputs"
29-
24+
3025
def _ensure_path(self, file_id, cell_id):
3126
nested_dir = self.outputs_path / file_id / cell_id
3227
nested_dir.mkdir(parents=True, exist_ok=True)
@@ -38,16 +33,42 @@ def _build_path(self, file_id, cell_id=None, output_index=None):
3833
if output_index is not None:
3934
path = path / f"{output_index}.output"
4035
return path
41-
36+
4237
def get_output(self, file_id, cell_id, output_index):
43-
"""Get an outputs by file_id, cell_id, and output_index."""
38+
"""Get an output by file_id, cell_id, and output_index."""
4439
path = self._build_path(file_id, cell_id, output_index)
4540
if not os.path.isfile(path):
4641
raise FileNotFoundError(f"The output file doesn't exist: {path}")
4742
with open(path, "r", encoding="utf-8") as f:
4843
output = json.loads(f.read())
4944
return output
5045

46+
def get_outputs(self, file_id, cell_id):
47+
"""Get all outputs by file_id, cell_id."""
48+
path = self._build_path(file_id, cell_id)
49+
if not os.path.isdir(path):
50+
raise FileNotFoundError(f"The output dir doesn't exist: {path}")
51+
52+
outputs = []
53+
54+
output_files = [(f, int(f.stem)) for f in path.glob("*.output")]
55+
output_files.sort(key=lambda x: x[1])
56+
output_files = output_files[: self.stream_limit]
57+
has_more_files = len(output_files) >= self.stream_limit
58+
59+
outputs = []
60+
for file_path, _ in output_files:
61+
with open(file_path, "r", encoding="utf-8") as f:
62+
output = f.read()
63+
outputs.append(output)
64+
65+
if has_more_files:
66+
url = create_output_url(file_id, cell_id)
67+
placeholder = create_placeholder_dict("display_data", url, full=True)
68+
outputs.append(json.dumps(placeholder))
69+
70+
return outputs
71+
5172
def get_stream(self, file_id, cell_id):
5273
"Get the stream output for a cell by file_id and cell_id."
5374
path = self._build_path(file_id, cell_id) / "stream"
@@ -59,7 +80,7 @@ def get_stream(self, file_id, cell_id):
5980

6081
def write(self, file_id, cell_id, output):
6182
"""Write a new output for file_id and cell_id.
62-
83+
6384
Returns a placeholder output (pycrdt.Map) or None if no placeholder
6485
output should be written to the ydoc.
6586
"""
@@ -77,10 +98,10 @@ def write_output(self, file_id, cell_id, output):
7798
data = json.dumps(output, ensure_ascii=False)
7899
with open(path, "w", encoding="utf-8") as f:
79100
f.write(data)
80-
url = f"/api/outputs/{file_id}/{cell_id}/{index}.output"
101+
url = create_output_url(file_id, cell_id, index)
81102
self.log.info(f"Wrote output: {url}")
82103
return create_placeholder_output(output["output_type"], url)
83-
104+
84105
def write_stream(self, file_id, cell_id, output, placeholder) -> Map:
85106
# How many stream outputs have been written for this cell previously
86107
count = self._stream_count.get(cell_id, 0)
@@ -89,12 +110,10 @@ def write_stream(self, file_id, cell_id, output, placeholder) -> Map:
89110
self._ensure_path(file_id, cell_id)
90111
path = self._build_path(file_id, cell_id) / "stream"
91112
text = output["text"]
92-
mode = 'a' if os.path.isfile(path) else 'w'
93113
with open(path, "a", encoding="utf-8") as f:
94114
f.write(text)
95-
url = f"/api/outputs/{file_id}/{cell_id}/stream"
115+
url = create_output_url(file_id, cell_id)
96116
self.log.info(f"Wrote stream: {url}")
97-
98117
# Increment the count
99118
count = count + 1
100119
self._stream_count[cell_id] = count
@@ -105,12 +124,7 @@ def write_stream(self, file_id, cell_id, output, placeholder) -> Map:
105124
placeholder = placeholder
106125
elif count == self.stream_limit:
107126
# Return a link to the full stream output
108-
placeholder = Map({
109-
"output_type": "display_data",
110-
"data": {
111-
'text/html': f'<a href="{url}">Click this link to see the full stream output</a>'
112-
}
113-
})
127+
placeholder = create_placeholder_output("display_data", url, full=True)
114128
elif count > self.stream_limit:
115129
# Return None to indicate that no placeholder should be written to the ydoc
116130
placeholder = None
@@ -133,27 +147,71 @@ def clear(self, file_id, cell_id=None):
133147
pass
134148

135149

136-
def create_placeholder_output(output_type: str, url: str):
150+
def create_output_url(file_id: str, cell_id: str, output_index: int = None) -> str:
151+
"""
152+
Create the URL for an output or stream.
153+
154+
Parameters:
155+
- file_id (str): The ID of the file.
156+
- cell_id (str): The ID of the cell.
157+
- output_index (int, optional): The index of the output. If None, returns the stream URL.
158+
159+
Returns:
160+
- str: The URL string for the output or stream.
161+
"""
162+
if output_index is None:
163+
return f"/api/outputs/{file_id}/{cell_id}/stream"
164+
else:
165+
return f"/api/outputs/{file_id}/{cell_id}/{output_index}.output"
166+
167+
def create_placeholder_dict(output_type: str, url: str, full: bool = False):
168+
"""
169+
Build a placeholder output dict for the given output_type and url.
170+
If full is True and output_type is "display_data", returns a display_data output
171+
with an HTML link to the full stream output.
172+
173+
Parameters:
174+
- output_type (str): The type of the output.
175+
- url (str): The URL associated with the output.
176+
- full (bool): Whether to create a full output placeholder with a link.
177+
178+
Returns:
179+
- dict: The placeholder output dictionary.
180+
181+
Raises:
182+
- ValueError: If the output_type is unknown.
183+
"""
137184
metadata = dict(url=url)
185+
if full and output_type == "display_data":
186+
return {
187+
"output_type": "display_data",
188+
"data": {
189+
"text/html": f'<a href="{url}">Click this link to see the full stream output</a>'
190+
},
191+
}
138192
if output_type == "stream":
139-
output = Map({
140-
"output_type": "stream",
141-
"text": "",
142-
"metadata": metadata
143-
})
193+
return {"output_type": "stream", "text": "", "metadata": metadata}
144194
elif output_type == "display_data":
145-
output = Map({
146-
"output_type": "display_data",
147-
"metadata": metadata
148-
})
195+
return {"output_type": "display_data", "metadata": metadata}
149196
elif output_type == "execute_result":
150-
output = Map({
151-
"output_type": "execute_result",
152-
"metadata": metadata
153-
})
197+
return {"output_type": "execute_result", "metadata": metadata}
154198
elif output_type == "error":
155-
output = Map({
156-
"output_type": "error",
157-
"metadata": metadata
158-
})
159-
return output
199+
return {"output_type": "error", "metadata": metadata}
200+
else:
201+
raise ValueError(f"Unknown output_type: {output_type}")
202+
203+
def create_placeholder_output(output_type: str, url: str, full: bool = False):
204+
"""
205+
Creates a placeholder output Map for the given output_type and url.
206+
If full is True and output_type is "display_data", creates a display_data output with an HTML link.
207+
208+
Parameters:
209+
- output_type (str): The type of the output.
210+
- url (str): The URL associated with the output.
211+
- full (bool): Whether to create a full output placeholder with a link.
212+
213+
Returns:
214+
- Map: The placeholder output `ycrdt.Map`.
215+
"""
216+
output_dict = create_placeholder_dict(output_type, url, full=full)
217+
return Map(output_dict)

src/handler.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,20 @@ export async function requestAPI<T>(
2828
throw new ServerConnection.NetworkError(error as any);
2929
}
3030

31-
let data: any = await response.text();
31+
const contentType = response.headers.get('Content-Type') || '';
32+
let data: any;
3233

33-
if (data.length > 0) {
34+
// Read response text
35+
const responseText = await response.text();
36+
37+
if (contentType.includes('application/x-ndjson')) {
38+
data = responseText
39+
.trim()
40+
.split('\n')
41+
.map(line => JSON.parse(line));
42+
} else if (responseText.length > 0) {
3443
try {
35-
data = JSON.parse(data);
44+
data = JSON.parse(responseText);
3645
} catch (error) {
3746
console.log('Not a JSON response body.', response);
3847
}

src/notebook-factory/notebook-factory.ts

Lines changed: 23 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -110,38 +110,34 @@ const DIRTY_CLASS = 'jp-mod-dirty';
110110
class RtcOutputAreaModel extends OutputAreaModel implements IOutputAreaModel {
111111
constructor(options: IOutputAreaModel.IOptions = {}) {
112112
super({ ...options, values: [] }); // Don't pass values to OutputAreaModel
113-
if (options.values) {
114-
// Create an array to store promises for each value
115-
const valuePromises = options.values.map(value => {
116-
console.debug('output #${index}, value: ${value}');
117-
if ((value as any).metadata?.url) {
118-
return requestAPI((value as any).metadata.url)
119-
.then(data => {
120-
return data;
121-
})
122-
.catch(error => {
123-
console.error('Error fetching output:', error);
124-
return null;
113+
if (options.values?.length) {
114+
const firstValue = options.values[0];
115+
if ((firstValue as any).metadata?.url) {
116+
let outputsUrl = (firstValue as any).metadata.url;
117+
// Skip the last section with *.output
118+
outputsUrl = outputsUrl.substring(0, outputsUrl.lastIndexOf('/'));
119+
requestAPI(outputsUrl)
120+
.then(outputs => {
121+
(outputs as any).forEach((output: any) => {
122+
if (!(this as any).isDisposed) {
123+
const index = (this as any)._add(output) - 1;
124+
const item = (this as any).list.get(index);
125+
item.changed.connect((this as any)._onGenericChange, this);
126+
}
125127
});
126-
} else {
127-
// For values without url, return immediately with original value
128-
return Promise.resolve(value);
129-
}
130-
});
131-
132-
// Wait for all promises to resolve and add values in original order
133-
Promise.all(valuePromises).then(results => {
134-
console.log('After fetching from outputs service:');
135-
// Add each value in order
136-
results.forEach((data, index) => {
137-
console.debug('output #${index}, data: ${data}');
138-
if (data && !(this as any).isDisposed) {
139-
const index = (this as any)._add(data) - 1;
128+
})
129+
.catch(error => {
130+
console.error('Error fetching output:', error);
131+
});
132+
} else {
133+
options.values.forEach((output: any) => {
134+
if (!(this as any).isDisposed) {
135+
const index = (this as any)._add(output) - 1;
140136
const item = (this as any).list.get(index);
141137
item.changed.connect((this as any)._onGenericChange, this);
142138
}
143139
});
144-
});
140+
}
145141
}
146142
}
147143
}

0 commit comments

Comments
 (0)