Skip to content

Commit 1d3ea55

Browse files
authored
Merge branch 'dev' into migrate-pane-tests-playwright
2 parents fa209bb + 943f4cb commit 1d3ea55

15 files changed

Lines changed: 160 additions & 46 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -959,6 +959,7 @@ The following `opts` are generic in the sense that they are the same for all vis
959959
- `opts.marginright` : right margin (in pixels)
960960
- `opts.margintop` : top margin (in pixels)
961961
- `opts.marginbottom`: bottom margin (in pixels)
962+
- `opts.caption` : caption displayed below the plot (`string`; optional)
962963

963964
`opts` are passed as dictionary in python scripts.You can pass `opts` like:
964965

js/panes/EmbeddingsPane.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,9 @@ class EmbeddingsPane extends React.Component {
115115
});
116116
var url = window.URL.createObjectURL(blob);
117117
var link = document.createElement('a');
118-
link.download = 'visdom_tsne_data.txt';
118+
link.download = this.props.contentID
119+
? `${this.props.contentID}_tsne_data.txt`
120+
: 'plot_tsne_data.txt';
119121
link.href = url;
120122
link.click();
121123
};

js/panes/NetworkPane.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,8 @@ function NetworkPane(props) {
4646
}
4747

4848
requestAnimationFrame(() => {
49-
saveSvgAsPng(svg, 'plot.png', {
49+
const filename = props.contentID ? `${props.contentID}.png` : 'plot.png';
50+
saveSvgAsPng(svg, filename, {
5051
scale: 2,
5152
backgroundColor: '#FFFFFF',
5253
});

js/panes/PlotPane.js

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ var PlotPane = (props) => {
5252
const handleDownload = () => {
5353
Plotly.downloadImage(plotlyRef.current, {
5454
format: 'svg',
55-
filename: contentID,
55+
filename: contentID || 'plot',
5656
});
5757
};
5858

@@ -67,7 +67,7 @@ var PlotPane = (props) => {
6767
const url = window.URL.createObjectURL(blob);
6868
const link = document.createElement('a');
6969
link.href = url;
70-
link.download = `${contentID}_metadata.json`;
70+
link.download = `${contentID || 'plot'}_metadata.json`;
7171
document.body.appendChild(link);
7272
link.click();
7373
document.body.removeChild(link);
@@ -242,6 +242,10 @@ var PlotPane = (props) => {
242242
layout.margin.t = 30;
243243
}
244244

245+
if (content.caption) {
246+
layout.margin.b = Math.max(layout.margin.b || 60, 100);
247+
}
248+
245249
// draw / redraw plot with layout-options
246250
Plotly.react(contentID, data.concat(smooth_data), content.layout, {
247251
showLink: false,
@@ -321,11 +325,11 @@ var PlotPane = (props) => {
321325
}
322326

323327
var caption_widget = '';
324-
if (isHistory && content && content.caption) {
328+
if (content && content.caption) {
325329
caption_widget = (
326-
<span className="widget" key="plot_caption">
330+
<div className="widget plot-caption" key="plot_caption">
327331
{content.caption}
328-
</span>
332+
</div>
329333
);
330334
}
331335

py/tests/test_storage_wiring.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
"""
2+
Tests that the server routes persistence through ``Application.storage``
3+
(the DataStore backend) rather than calling ``serialize_env`` directly.
4+
5+
These guard the PR-#2 wiring: end-to-end save/fork/reload behavior is already
6+
covered in ``test_environment_lifecycle``; here we assert the abstraction itself
7+
is in place so a future refactor cannot silently bypass the backend.
8+
"""
9+
10+
import json
11+
import tempfile
12+
import unittest
13+
14+
import tornado.testing
15+
16+
from visdom.data_model.base import DataStore
17+
from visdom.data_model.json_store import JSONStore
18+
from visdom.server.app import Application
19+
20+
21+
class TestStorageWiring(tornado.testing.AsyncHTTPTestCase):
22+
def setUp(self):
23+
self._tmp_dir = tempfile.mkdtemp(prefix="visdom_wire_")
24+
super().setUp()
25+
26+
def get_app(self):
27+
return Application(port=self.get_http_port(), env_path=self._tmp_dir)
28+
29+
def post_json(self, path, body):
30+
return self.fetch(
31+
path,
32+
method="POST",
33+
body=json.dumps(body),
34+
headers={"Content-Type": "application/json"},
35+
)
36+
37+
def test_application_has_json_store(self):
38+
self.assertIsInstance(self._app.storage, DataStore)
39+
self.assertIsInstance(self._app.storage, JSONStore)
40+
self.assertEqual(self._app.storage.env_path, self._tmp_dir)
41+
42+
def test_save_routes_through_storage(self):
43+
calls = []
44+
real_save_envs = self._app.storage.save_envs
45+
46+
def spy(state, eids):
47+
calls.append(list(eids))
48+
return real_save_envs(state, eids)
49+
50+
self._app.storage.save_envs = spy
51+
52+
resp = self.post_json("/save", {"data": ["main"]})
53+
54+
self.assertEqual(len(calls), 1)
55+
self.assertIn("main", calls[0])
56+
self.assertIn("main", json.loads(resp.body))
57+
58+
59+
if __name__ == "__main__":
60+
unittest.main()

py/visdom/server/app.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@
2121
import tornado.escape # noqa E402: gotta install ioloop first
2222

2323
from visdom.utils.shared_utils import warn_once, ensure_dir_exists, get_visdom_path
24-
from visdom.utils.server_utils import serialize_env, LazyEnvData
24+
from visdom.utils.server_utils import LazyEnvData
25+
from visdom.data_model.json_store import JSONStore
2526
from visdom.server.handlers.socket_handlers import (
2627
SocketHandler,
2728
SocketWrap,
@@ -83,6 +84,7 @@ def __init__(
8384
self.max_old_content = DEFAULT_MAX_OLD_CONTENT
8485
self.max_text_lines = DEFAULT_MAX_TEXT_LINES
8586
self.env_path = env_path
87+
self.storage = JSONStore(env_path)
8688
self.state = self.load_state()
8789
self.layouts = self.load_layouts()
8890
self.user_settings = self.load_user_settings()
@@ -223,7 +225,7 @@ def load_state(self):
223225

224226
if "main" not in state and "main.json" not in env_jsons:
225227
state["main"] = {"jsons": {}, "reload": {}}
226-
serialize_env(state, ["main"], env_path=self.env_path)
228+
self.storage.save_env("main", state["main"])
227229

228230
return state
229231

py/visdom/server/handlers/base_handlers.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ def initialize(self, app=None):
5959
self.sources = app.sources
6060
self.port = app.port
6161
self.env_path = app.env_path
62+
self.storage = app.storage
6263
self.login_enabled = app.login_enabled
6364
self.max_text_lines = app.max_text_lines
6465
self.max_old_content = app.max_old_content

py/visdom/server/handlers/socket_handlers.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,6 @@
3030
from visdom.utils.server_utils import (
3131
check_auth,
3232
broadcast_envs,
33-
serialize_env,
34-
serialize_all,
3533
send_to_sources,
3634
broadcast,
3735
escape_eid,
@@ -81,6 +79,7 @@ def initialize(self, app):
8179
self.sources = app.sources
8280
self.port = app.port
8381
self.env_path = app.env_path
82+
self.storage = app.storage
8483
self.login_enabled = app.login_enabled
8584
self.app = app
8685
self.readonly = app.readonly
@@ -156,11 +155,11 @@ def on_message(self, message):
156155
self.state[msg["eid"]] = copy.deepcopy(self.state[prev_eid])
157156
self.state[msg["eid"]]["reload"] = msg["data"]
158157
self.eid = msg["eid"]
159-
serialize_env(self.state, [self.eid], env_path=self.env_path)
158+
self.storage.save_env(self.eid, self.state[self.eid])
160159

161160
elif cmd == "save_all":
162161
tornado.ioloop.IOLoop.current().run_in_executor(
163-
None, serialize_all, self.state, self.env_path
162+
None, self.storage.save_all, self.state
164163
)
165164

166165
elif cmd == "delete_env":

py/visdom/server/handlers/web_handlers.py

Lines changed: 56 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,6 @@
3535
register_window,
3636
gather_envs,
3737
broadcast_envs,
38-
serialize_env,
3938
escape_eid,
4039
compare_envs,
4140
load_env,
@@ -118,6 +117,39 @@ def update_packet(p, args, max_text_lines, max_old_content, max_image_history):
118117
patch = jsonpatch.make_patch(old_p, p)
119118
return p, patch.patch
120119

120+
@staticmethod
121+
def update_embeddings_packet(p, args, max_old_content):
122+
update_type = args["data"]["update_type"]
123+
content_id = get_rand_id()
124+
if update_type == "EntitySelected":
125+
selected = args["data"]["selected"]
126+
p["content"]["selected"] = selected
127+
p["contentID"] = content_id
128+
# `selected` may not exist yet on the first selection, so use "add"
129+
# (which also overwrites when the key is already present).
130+
return [
131+
{"op": "add", "path": "/content/selected", "value": selected},
132+
{"op": "replace", "path": "/contentID", "value": content_id},
133+
]
134+
if update_type == "RegionSelected":
135+
old_data = p["content"]["data"]
136+
new_data = args["data"]["points"]
137+
p["old_content"].append(old_data)
138+
# Cap retained history to prevent unbounded in-memory growth (#1320).
139+
if len(p["old_content"]) > max_old_content:
140+
p["old_content"] = p["old_content"][-max_old_content:]
141+
p["content"]["data"] = new_data
142+
p["content"]["has_previous"] = True
143+
p["content"]["selected"] = None
144+
p["contentID"] = content_id
145+
return [
146+
{"op": "replace", "path": "/content/data", "value": new_data},
147+
{"op": "add", "path": "/content/has_previous", "value": True},
148+
{"op": "add", "path": "/content/selected", "value": None},
149+
{"op": "replace", "path": "/contentID", "value": content_id},
150+
]
151+
return []
152+
121153
@staticmethod
122154
def update(p, args, max_text_lines, max_old_content, max_image_history):
123155
# Update text in window, separated by a line break
@@ -127,20 +159,6 @@ def update(p, args, max_text_lines, max_old_content, max_image_history):
127159
if len(lines) > max_text_lines:
128160
p["content"] = "<br>".join(lines[-max_text_lines:])
129161
return p
130-
if p["type"] == "embeddings":
131-
# TODO embeddings updates should be handled outside of the regular
132-
# update flow, as update packets are easy to create manually and
133-
# expensive to calculate otherwise
134-
if args["data"]["update_type"] == "EntitySelected":
135-
p["content"]["selected"] = args["data"]["selected"]
136-
elif args["data"]["update_type"] == "RegionSelected":
137-
p["content"]["selected"] = None
138-
p["old_content"].append(p["content"]["data"])
139-
if len(p["old_content"]) > max_old_content:
140-
p["old_content"] = p["old_content"][-max_old_content:]
141-
p["content"]["has_previous"] = True
142-
p["content"]["data"] = args["data"]["points"]
143-
return p
144162
if p["type"] == "image_history":
145163
utype = args["data"][0]["type"]
146164
if utype == "image_history":
@@ -325,6 +343,17 @@ def update(p, args, max_text_lines, max_old_content, max_image_history):
325343

326344
return p
327345

346+
@staticmethod
347+
def broadcast_window_update(handler, args, eid, p, diff_packet):
348+
broadcast_packet = {
349+
"command": "window_update",
350+
"win": args["win"],
351+
"eid": eid,
352+
"content": diff_packet,
353+
"version": p.get("version", 1),
354+
}
355+
broadcast(handler, json.dumps(broadcast_packet, cls=NanSafeEncoder), eid)
356+
328357
@staticmethod
329358
def wrap_func(handler, args):
330359
if "win" not in args:
@@ -374,6 +403,14 @@ def wrap_func(handler, args):
374403
)
375404
return
376405

406+
if p["type"] == "embeddings":
407+
diff_packet = UpdateHandler.update_embeddings_packet(
408+
p, args, handler.max_old_content
409+
)
410+
UpdateHandler.broadcast_window_update(handler, args, eid, p, diff_packet)
411+
handler.write(p["id"])
412+
return
413+
377414
p, diff_packet = UpdateHandler.update_packet(
378415
p,
379416
args,
@@ -387,14 +424,7 @@ def wrap_func(handler, args):
387424
broadcast_msg["eid"] = eid
388425
broadcast(handler, json.dumps(broadcast_msg, cls=NanSafeEncoder), eid)
389426
else:
390-
broadcast_packet = {
391-
"command": "window_update",
392-
"win": args["win"],
393-
"eid": eid,
394-
"content": diff_packet,
395-
"version": p.get("version", 1),
396-
}
397-
broadcast(handler, json.dumps(broadcast_packet, cls=NanSafeEncoder), eid)
427+
UpdateHandler.broadcast_window_update(handler, args, eid, p, diff_packet)
398428
handler.write(p["id"])
399429

400430
@check_auth
@@ -502,7 +532,7 @@ def wrap_func(handler, args):
502532
assert prev_eid in handler.state, "env to be forked doesn't exist"
503533

504534
handler.state[eid] = copy.deepcopy(handler.state[prev_eid])
505-
serialize_env(handler.state, [eid], env_path=handler.env_path)
535+
handler.storage.save_env(eid, handler.state[eid])
506536
broadcast_envs(handler)
507537

508538
handler.write(eid)
@@ -608,7 +638,7 @@ def wrap_func(handler, args):
608638
envs = args["data"]
609639
envs = [escape_eid(eid) for eid in envs]
610640
# this drops invalid env ids
611-
ret = serialize_env(handler.state, envs, env_path=handler.env_path)
641+
ret = handler.storage.save_envs(handler.state, envs)
612642
handler.write(json.dumps(ret))
613643

614644
@check_auth
@@ -790,8 +820,7 @@ def post(self):
790820

791821
self.state[new_eid] = {"jsons": data["jsons"], "reload": data["reload"]}
792822

793-
if self.env_path is not None:
794-
serialize_env(self.state, [new_eid], env_path=self.env_path)
823+
self.storage.save_env(new_eid, self.state[new_eid])
795824

796825
broadcast_envs(self)
797826

0 commit comments

Comments
 (0)