better-rtplot 0.4.6__tar.gz → 0.4.8__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. {better_rtplot-0.4.6 → better_rtplot-0.4.8}/PKG-INFO +1 -1
  2. {better_rtplot-0.4.6 → better_rtplot-0.4.8}/pyproject.toml +1 -1
  3. {better_rtplot-0.4.6 → better_rtplot-0.4.8}/rtplot/client.py +6 -0
  4. {better_rtplot-0.4.6 → better_rtplot-0.4.8}/rtplot/server_browser.py +123 -44
  5. {better_rtplot-0.4.6 → better_rtplot-0.4.8}/rtplot/static/index.html +48 -0
  6. {better_rtplot-0.4.6 → better_rtplot-0.4.8}/LICENSE +0 -0
  7. {better_rtplot-0.4.6 → better_rtplot-0.4.8}/README.md +0 -0
  8. {better_rtplot-0.4.6 → better_rtplot-0.4.8}/rtplot/interactive_test.py +0 -0
  9. {better_rtplot-0.4.6 → better_rtplot-0.4.8}/rtplot/saved_plots/.gitignore +0 -0
  10. {better_rtplot-0.4.6 → better_rtplot-0.4.8}/rtplot/server.py +0 -0
  11. {better_rtplot-0.4.6 → better_rtplot-0.4.8}/rtplot/server_browser_gui.py +0 -0
  12. {better_rtplot-0.4.6 → better_rtplot-0.4.8}/rtplot/static/katex/auto-render.min.js +0 -0
  13. {better_rtplot-0.4.6 → better_rtplot-0.4.8}/rtplot/static/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  14. {better_rtplot-0.4.6 → better_rtplot-0.4.8}/rtplot/static/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  15. {better_rtplot-0.4.6 → better_rtplot-0.4.8}/rtplot/static/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  16. {better_rtplot-0.4.6 → better_rtplot-0.4.8}/rtplot/static/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  17. {better_rtplot-0.4.6 → better_rtplot-0.4.8}/rtplot/static/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  18. {better_rtplot-0.4.6 → better_rtplot-0.4.8}/rtplot/static/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
  19. {better_rtplot-0.4.6 → better_rtplot-0.4.8}/rtplot/static/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  20. {better_rtplot-0.4.6 → better_rtplot-0.4.8}/rtplot/static/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
  21. {better_rtplot-0.4.6 → better_rtplot-0.4.8}/rtplot/static/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
  22. {better_rtplot-0.4.6 → better_rtplot-0.4.8}/rtplot/static/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  23. {better_rtplot-0.4.6 → better_rtplot-0.4.8}/rtplot/static/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
  24. {better_rtplot-0.4.6 → better_rtplot-0.4.8}/rtplot/static/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  25. {better_rtplot-0.4.6 → better_rtplot-0.4.8}/rtplot/static/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  26. {better_rtplot-0.4.6 → better_rtplot-0.4.8}/rtplot/static/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  27. {better_rtplot-0.4.6 → better_rtplot-0.4.8}/rtplot/static/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
  28. {better_rtplot-0.4.6 → better_rtplot-0.4.8}/rtplot/static/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  29. {better_rtplot-0.4.6 → better_rtplot-0.4.8}/rtplot/static/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  30. {better_rtplot-0.4.6 → better_rtplot-0.4.8}/rtplot/static/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  31. {better_rtplot-0.4.6 → better_rtplot-0.4.8}/rtplot/static/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  32. {better_rtplot-0.4.6 → better_rtplot-0.4.8}/rtplot/static/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  33. {better_rtplot-0.4.6 → better_rtplot-0.4.8}/rtplot/static/katex/katex.min.css +0 -0
  34. {better_rtplot-0.4.6 → better_rtplot-0.4.8}/rtplot/static/katex/katex.min.js +0 -0
  35. {better_rtplot-0.4.6 → better_rtplot-0.4.8}/rtplot/static/uPlot.iife.min.js +0 -0
  36. {better_rtplot-0.4.6 → better_rtplot-0.4.8}/rtplot/static/uPlot.min.css +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: better-rtplot
3
- Version: 0.4.6
3
+ Version: 0.4.8
4
4
  Summary:
5
5
  License: GPL V3.0
6
6
  Author: jmontp
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "better-rtplot"
3
- version = "0.4.6"
3
+ version = "0.4.8"
4
4
  description = ""
5
5
  authors = ["jmontp <jmontp@umich.edu>"]
6
6
  license = "GPL V3.0"
@@ -538,6 +538,12 @@ def poll_controls():
538
538
  if plot_desc_dict is not None:
539
539
  socket.send_string(SENDING_PLOT_UPDATE)
540
540
  socket.send_json(plot_desc_dict)
541
+ # Surface a one-line confirmation so the user can see in
542
+ # their script log that the recovery handshake worked.
543
+ # If they never see this after clicking Reconnect, the
544
+ # PUSH/PULL channel back from the server isn't wired —
545
+ # check that poll_controls() is actually being called.
546
+ print("rtplot.client: resent cached initialize_plots() config")
541
547
 
542
548
  buttons = _control_button_events
543
549
  _control_button_events = []
@@ -215,7 +215,13 @@ RECEIVED_DISPLAY = 4
215
215
  BIND_ME_ID = "bind_me"
216
216
  BIND_ME_NAME = "Shared - Bind to me"
217
217
 
218
- TABS_FILE = os.path.join(os.path.expanduser("~"), ".rtplot", "tabs.json")
218
+ # Path to the persisted tabs JSON. Honors RTPLOT_TABS_FILE so tests
219
+ # (and operators with multi-user setups) can override without symlinking
220
+ # their HOME directory or losing access to ~/.local site-packages.
221
+ TABS_FILE = os.environ.get(
222
+ "RTPLOT_TABS_FILE",
223
+ os.path.join(os.path.expanduser("~"), ".rtplot", "tabs.json"),
224
+ )
219
225
 
220
226
  zmq_ctx = zmq.asyncio.Context()
221
227
 
@@ -306,6 +312,12 @@ class Tab:
306
312
  data_rate_hz: float = 0.0
307
313
  _last_rx_ts: float = 0.0
308
314
 
315
+ # When the most recent client-supplied config failed to parse we
316
+ # stash the reason + a unix timestamp so the browser can surface
317
+ # "your last config was rejected at HH:MM:SS" instead of just
318
+ # silently rendering nothing. Cleared on the next successful parse.
319
+ last_config_error: Optional[dict] = None
320
+
309
321
  def ensure_buffer(self):
310
322
  if self.buffer is None:
311
323
  self.buffer = np.zeros((INITIAL_NUM_TRACES, MAX_LOCAL_STORAGE))
@@ -344,6 +356,7 @@ def tab_public(t: Tab) -> dict:
344
356
  "endpoint": t.endpoint,
345
357
  "status": t.status,
346
358
  "error": t.error,
359
+ "last_config_error": t.last_config_error,
347
360
  }
348
361
 
349
362
 
@@ -595,43 +608,67 @@ def _remove_ws(ws):
595
608
  def _open_tab_sockets(tab: Tab):
596
609
  """Open ``tab``'s data + control sockets according to its mode.
597
610
 
598
- On failure, sets tab.status="error" and tab.error=<reason>; the tab
599
- stays in the list but won't stream until re-opened.
611
+ For bind tabs, the just-closed sockets may leave the OS-level port
612
+ in a transient state for a few hundred ms (especially on Windows /
613
+ WSL). We retry the bind with a small backoff so the user-visible
614
+ Reconnect button doesn't fail spuriously the very first time. On
615
+ sustained failure we set tab.status="error" + tab.error=<reason>;
616
+ the tab stays in the list but won't stream until re-opened.
600
617
  """
601
618
  # Close any prior sockets first (e.g. on retry).
602
619
  _close_tab_sockets(tab)
603
620
 
604
- try:
605
- data = zmq_ctx.socket(zmq.SUB)
606
- data.setsockopt_string(zmq.SUBSCRIBE, "")
607
- ctrl = zmq_ctx.socket(zmq.PUSH)
608
- ctrl.setsockopt(zmq.SNDHWM, 1000)
609
- ctrl.setsockopt(zmq.LINGER, 0)
610
-
611
- if tab.mode == "bind":
612
- data.bind(f"tcp://*:{ZMQ_DEFAULT_PORT}")
613
- ctrl.bind(f"tcp://*:{ZMQ_CONTROL_PORT}")
614
- tab.endpoint = f"*:{ZMQ_DEFAULT_PORT}"
615
- print(f"[{tab.id}] ZMQ: bound on tcp://*:{ZMQ_DEFAULT_PORT}")
616
- else:
617
- data_ep, label = _normalize_connect_target(tab.endpoint)
618
- data.connect(data_ep)
619
- ctrl_ep, _ = _normalize_connect_target(
620
- _control_target_from_data(tab.endpoint), port=ZMQ_CONTROL_PORT
621
- )
622
- ctrl.connect(ctrl_ep)
623
- tab.endpoint = label
624
- print(f"[{tab.id}] ZMQ: connected to {data_ep} (ctrl {ctrl_ep})")
621
+ last_exc: Exception | None = None
622
+ attempts = 4 if tab.mode == "bind" else 1
623
+ for attempt in range(attempts):
624
+ try:
625
+ data = zmq_ctx.socket(zmq.SUB)
626
+ data.setsockopt_string(zmq.SUBSCRIBE, "")
627
+ data.setsockopt(zmq.LINGER, 0)
628
+ ctrl = zmq_ctx.socket(zmq.PUSH)
629
+ ctrl.setsockopt(zmq.SNDHWM, 1000)
630
+ ctrl.setsockopt(zmq.LINGER, 0)
631
+
632
+ if tab.mode == "bind":
633
+ data.bind(f"tcp://*:{ZMQ_DEFAULT_PORT}")
634
+ ctrl.bind(f"tcp://*:{ZMQ_CONTROL_PORT}")
635
+ tab.endpoint = f"*:{ZMQ_DEFAULT_PORT}"
636
+ print(f"[{tab.id}] ZMQ: bound on tcp://*:{ZMQ_DEFAULT_PORT}")
637
+ else:
638
+ data_ep, label = _normalize_connect_target(tab.endpoint)
639
+ data.connect(data_ep)
640
+ ctrl_ep, _ = _normalize_connect_target(
641
+ _control_target_from_data(tab.endpoint), port=ZMQ_CONTROL_PORT
642
+ )
643
+ ctrl.connect(ctrl_ep)
644
+ tab.endpoint = label
645
+ print(f"[{tab.id}] ZMQ: connected to {data_ep} (ctrl {ctrl_ep})")
625
646
 
626
- tab.data_sock = data
627
- tab.ctrl_sock = ctrl
628
- tab.status = "idle"
629
- tab.error = None
630
- except Exception as exc: # noqa: BLE001
631
- tab.status = "error"
632
- tab.error = str(exc)
633
- print(f"[{tab.id}] ZMQ open failed: {exc}")
634
- _close_tab_sockets(tab)
647
+ tab.data_sock = data
648
+ tab.ctrl_sock = ctrl
649
+ tab.status = "idle"
650
+ tab.error = None
651
+ return
652
+ except Exception as exc: # noqa: BLE001
653
+ last_exc = exc
654
+ # Drop the half-built sockets before retrying so we don't
655
+ # leak descriptors and so the next attempt re-takes the port.
656
+ try: data.close(0)
657
+ except Exception: pass
658
+ try: ctrl.close(0)
659
+ except Exception: pass
660
+ if attempt < attempts - 1:
661
+ wait = 0.15 * (attempt + 1)
662
+ print(
663
+ f"[{tab.id}] ZMQ open attempt {attempt + 1}/{attempts}"
664
+ f" failed ({exc}); retrying in {wait:.2f}s"
665
+ )
666
+ time.sleep(wait)
667
+
668
+ tab.status = "error"
669
+ tab.error = str(last_exc) if last_exc is not None else "unknown error"
670
+ print(f"[{tab.id}] ZMQ open failed after {attempts} attempts: {last_exc}")
671
+ _close_tab_sockets(tab)
635
672
 
636
673
 
637
674
  def _close_tab_sockets(tab: Tab):
@@ -711,8 +748,30 @@ async def zmq_receiver(tab: Tab):
711
748
  tab._last_rx_ts = now
712
749
 
713
750
  if category == RECEIVED_PLOT_UPDATE:
714
- cfg = await sock.recv_json(object_pairs_hook=OrderedDict)
715
- parse_config(tab, cfg)
751
+ # Decode + parse are wrapped so a malformed config doesn't kill
752
+ # the receiver task. We stash the failure on the tab so the
753
+ # browser can show "your last config was rejected at HH:MM:SS"
754
+ # instead of looking like nothing happened. Existing valid
755
+ # state (if any) is preserved on parse failure.
756
+ try:
757
+ cfg = await sock.recv_json(object_pairs_hook=OrderedDict)
758
+ except Exception as exc: # noqa: BLE001
759
+ msg = f"Could not decode config JSON: {type(exc).__name__}: {exc}"
760
+ print(f"[{tab.id}] {msg}")
761
+ tab.last_config_error = {"message": msg, "timestamp": time.time()}
762
+ await broadcast_tab(tab.id)
763
+ continue
764
+
765
+ try:
766
+ parse_config(tab, cfg)
767
+ except Exception as exc: # noqa: BLE001
768
+ msg = f"Configuration rejected: {type(exc).__name__}: {exc}"
769
+ print(f"[{tab.id}] {msg}")
770
+ tab.last_config_error = {"message": msg, "timestamp": time.time()}
771
+ await broadcast_tab(tab.id)
772
+ continue
773
+
774
+ tab.last_config_error = None # clear: this one was good
716
775
  tab.config_dict = cfg
717
776
  tab.config_message = build_config_message(tab, cfg)
718
777
  tab.initialized = True
@@ -972,24 +1031,44 @@ async def _request_config_resend(tab: "Tab"):
972
1031
  the cached config so the plot picks back up without the user having
973
1032
  to restart their script.
974
1033
 
975
- Retries on a brief schedule because the client's PULL socket may need
976
- a moment to auto-reconnect to the freshly bound PUSH socket — a
977
- one-shot DONTWAIT send to a peer-less PUSH gets silently dropped.
978
- Stops early once we see ``tab.initialized`` flip to True (the client
979
- has answered) so we don't spam.
1034
+ Retries over a ~10 s window because:
1035
+ * the client's PULL socket may need a moment to auto-reconnect to
1036
+ the freshly bound PUSH socket especially over a WAN/SSH link;
1037
+ * a slow user loop may only call poll_controls() once per second,
1038
+ so the message can sit unread in the PULL queue for a while;
1039
+ * the client may not be running at all (no peer), in which case
1040
+ the queued message is just dropped when the next reconnect happens.
1041
+ Stops early once tab.initialized flips True (the client answered).
980
1042
  """
981
- for i, delay in enumerate((0.4, 1.0, 2.0)):
1043
+ schedule = (0.3, 0.7, 1.5, 2.5, 5.0) # cumulative ~10 s
1044
+ started_initialized = tab.initialized
1045
+ for i, delay in enumerate(schedule):
982
1046
  await asyncio.sleep(delay)
983
1047
  if tab.ctrl_sock is None:
984
1048
  return
985
- if i > 0 and tab.initialized:
1049
+ if i > 0 and tab.initialized and not started_initialized:
986
1050
  return
987
1051
  try:
988
1052
  await tab.ctrl_sock.send_json(
989
1053
  {"type": "resend_config"}, flags=zmq.DONTWAIT
990
1054
  )
991
- except (zmq.Again, zmq.ZMQError):
992
- pass
1055
+ if DEBUG_TEXT_ENABLED:
1056
+ print(f"[{tab.id}] resend_config sent (attempt {i + 1}/{len(schedule)})")
1057
+ except (zmq.Again, zmq.ZMQError) as exc:
1058
+ if DEBUG_TEXT_ENABLED:
1059
+ print(
1060
+ f"[{tab.id}] resend_config attempt {i + 1}/{len(schedule)}"
1061
+ f" failed: {exc} (likely no peer connected yet)"
1062
+ )
1063
+ # If we asked the client to re-init but nothing came back, surface
1064
+ # a hint so the user knows where to look. Common causes: the client
1065
+ # is on rtplot < 0.4.6 (no resend_config handler), or its loop never
1066
+ # calls poll_controls(), or there's no client running at all.
1067
+ if not started_initialized and not tab.initialized:
1068
+ print(
1069
+ f"[{tab.id}] no config received after resend_config nudges."
1070
+ " Is the client running rtplot >= 0.4.6 and calling poll_controls()?"
1071
+ )
993
1072
 
994
1073
 
995
1074
  async def reconnect_tab(tab_id: str):
@@ -67,6 +67,13 @@
67
67
  .plot-wrap { background: #fff; border: 1px solid #ddd; border-radius: 4px; padding: 8px; flex: 1 1 auto; min-width: 320px; }
68
68
  .plot-title { font-size: 13px; font-weight: 600; margin: 0 0 4px 4px; color: #333; }
69
69
  #empty-state { padding: 28px 32px; color: #888; font-size: calc(14px * var(--ui-scale)); font-style: italic; }
70
+ /* Persistent banner the server raises when a client-supplied plot
71
+ config fails to parse — the user used to be left guessing why
72
+ nothing rendered; now they get the reason + a local timestamp. */
73
+ .config-error-banner { margin: 12px 12px 0 12px; padding: 10px 14px; background: #fce8e8; color: #8a1a1a; border: 1px solid #f0c2c2; border-left: 4px solid #d24a4a; border-radius: 4px; font-size: calc(13px * var(--ui-scale)); }
74
+ .config-error-banner .ce-title { font-weight: 600; margin-right: 8px; }
75
+ .config-error-banner .ce-when { color: #6b1818; opacity: 0.8; font-family: monospace; font-size: calc(12px * var(--ui-scale)); }
76
+ .config-error-banner .ce-msg { display: block; margin-top: 4px; font-family: monospace; font-size: calc(12px * var(--ui-scale)); white-space: pre-wrap; word-break: break-word; }
70
77
  :root { --ctrl-unit-h: 38px; --ui-scale: 1; }
71
78
  .ctrl-row { display: flex; gap: 12px; align-items: center; padding: 10px 14px; background: #fff; border: 1px solid #ddd; border-radius: 4px; flex-wrap: wrap; }
72
79
  .ctrl-item { display: flex; align-items: center; gap: 6px; }
@@ -187,6 +194,7 @@
187
194
  <div id="res-unavail" class="res-unavail" style="display:none;">psutil not installed &mdash; pip install psutil for live stats</div>
188
195
  </div>
189
196
  </div>
197
+ <div id="config-error-banner" class="config-error-banner" style="display:none"></div>
190
198
  <div id="plots" class="row"></div>
191
199
  <script src="/static/uPlot.iife.min.js"></script>
192
200
  <script src="/static/katex/katex.min.js"></script>
@@ -210,6 +218,43 @@
210
218
  return 'rgb(0,0,0)';
211
219
  }
212
220
 
221
+ const configErrorBanner = document.getElementById('config-error-banner');
222
+
223
+ function fmtLocalTimestamp(unixSeconds) {
224
+ const d = new Date(unixSeconds * 1000);
225
+ const pad = (n) => String(n).padStart(2, '0');
226
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ` +
227
+ `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
228
+ }
229
+
230
+ function applyConfigErrorBanner() {
231
+ const t = activeTab && knownTabs[activeTab];
232
+ const err = t && t.last_config_error;
233
+ if (!err) {
234
+ configErrorBanner.style.display = 'none';
235
+ configErrorBanner.innerHTML = '';
236
+ return;
237
+ }
238
+ // Build with textContent on the leaf nodes so user-controlled
239
+ // strings (the error message) can't inject HTML.
240
+ configErrorBanner.innerHTML = '';
241
+ const head = document.createElement('div');
242
+ const title = document.createElement('span');
243
+ title.className = 'ce-title';
244
+ title.textContent = 'Last configuration was rejected';
245
+ head.appendChild(title);
246
+ const when = document.createElement('span');
247
+ when.className = 'ce-when';
248
+ when.textContent = `at ${fmtLocalTimestamp(err.timestamp)}`;
249
+ head.appendChild(when);
250
+ configErrorBanner.appendChild(head);
251
+ const body = document.createElement('span');
252
+ body.className = 'ce-msg';
253
+ body.textContent = err.message || '';
254
+ configErrorBanner.appendChild(body);
255
+ configErrorBanner.style.display = '';
256
+ }
257
+
213
258
  // ---- LaTeX rendering ($...$ inline, $$...$$ display) ----
214
259
  // KaTeX's auto-render walks an element's text nodes looking for the
215
260
  // configured delimiters, so plain labels without `$` are completely
@@ -1111,6 +1156,7 @@
1111
1156
  showEmptyState('Loading tab \u2026');
1112
1157
  const t = knownTabs[id];
1113
1158
  setZmqMode(t.mode, t.endpoint);
1159
+ applyConfigErrorBanner();
1114
1160
  sendCtrl({ type: 'tab_subscribe', id: id });
1115
1161
  }
1116
1162
 
@@ -1183,6 +1229,7 @@
1183
1229
  if (activeTab) {
1184
1230
  const t = knownTabs[activeTab];
1185
1231
  setZmqMode(t.mode, t.endpoint);
1232
+ applyConfigErrorBanner();
1186
1233
  sendCtrl({ type: 'tab_subscribe', id: activeTab });
1187
1234
  showEmptyState('Loading tab \u2026');
1188
1235
  }
@@ -1193,6 +1240,7 @@
1193
1240
  renderTabs();
1194
1241
  if (msg.tab.id === activeTab) {
1195
1242
  setZmqMode(msg.tab.mode, msg.tab.endpoint);
1243
+ applyConfigErrorBanner();
1196
1244
  }
1197
1245
  }
1198
1246
  } else if (msg.type === 'tab_removed') {
File without changes
File without changes