streamlit-remote 0.7.0__tar.gz → 0.7.2__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: streamlit-remote
3
- Version: 0.7.0
3
+ Version: 0.7.2
4
4
  Summary: Run Streamlit apps with optional remote HTTPS access.
5
5
  Author: Yuichiro Tachibana (Tsuchiya)
6
6
  Author-email: Yuichiro Tachibana (Tsuchiya) <t.yic.yt@gmail.com>
@@ -94,6 +94,8 @@ Press `r` to restart only the Streamlit server while keeping the remote tunnel p
94
94
 
95
95
  Press `t` to toggle between the live terminal display and plain prefixed log output. When switching to plain output, `st-remote` replays recent logs into the normal terminal buffer so you can copy older logs from terminal or tmux history. You can also press `p` to force plain output and `f` to force the live terminal display. In terminals where single-key input is unavailable, the Enter-based commands still work.
96
96
 
97
+ If Streamlit or the remote tunnel exits with an error while the live terminal display is active, `st-remote` returns to the normal terminal buffer and replays recent logs before exiting.
98
+
97
99
  Use Ctrl+C to stop both Streamlit and the remote tunnel.
98
100
 
99
101
  ## Local HTTPS
@@ -68,6 +68,8 @@ Press `r` to restart only the Streamlit server while keeping the remote tunnel p
68
68
 
69
69
  Press `t` to toggle between the live terminal display and plain prefixed log output. When switching to plain output, `st-remote` replays recent logs into the normal terminal buffer so you can copy older logs from terminal or tmux history. You can also press `p` to force plain output and `f` to force the live terminal display. In terminals where single-key input is unavailable, the Enter-based commands still work.
70
70
 
71
+ If Streamlit or the remote tunnel exits with an error while the live terminal display is active, `st-remote` returns to the normal terminal buffer and replays recent logs before exiting.
72
+
71
73
  Use Ctrl+C to stop both Streamlit and the remote tunnel.
72
74
 
73
75
  ## Local HTTPS
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "streamlit-remote"
3
- version = "0.7.0"
3
+ version = "0.7.2"
4
4
  description = "Run Streamlit apps with optional remote HTTPS access."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -31,7 +31,7 @@ from streamlit_remote.providers.ngrok import (
31
31
  planned_managed_oauth_policy,
32
32
  prepare_managed_oauth_policy,
33
33
  )
34
- from streamlit_remote.runtime_display import make_runtime_display
34
+ from streamlit_remote.runtime_display import STREAMLIT_SOURCE, make_runtime_display
35
35
  from streamlit_remote.server import LocalServerConfig, is_port_available, wait_until_listening
36
36
 
37
37
 
@@ -421,7 +421,16 @@ def run(namespace: argparse.Namespace) -> int:
421
421
 
422
422
  def start_streamlit_process() -> ManagedProcess:
423
423
  display.set_status("Streamlit", "starting")
424
- return start_logged_process(streamlit_command, "streamlit", write_log=display.log)
424
+ streamlit_env: dict[str, str] = {}
425
+ streamlit_columns = display.streamlit_subprocess_columns()
426
+ if streamlit_columns is not None:
427
+ streamlit_env["COLUMNS"] = str(streamlit_columns)
428
+ return start_logged_process(
429
+ streamlit_command,
430
+ STREAMLIT_SOURCE,
431
+ write_log=display.log,
432
+ env=streamlit_env or None,
433
+ )
425
434
 
426
435
  def restart_streamlit_process(current_handle: ManagedProcess) -> ManagedProcess:
427
436
  display.set_status("Streamlit", "restarting")
@@ -483,11 +492,15 @@ def run(namespace: argparse.Namespace) -> int:
483
492
  streamlit_handle = restart_streamlit_process(current_handle)
484
493
  return streamlit_handle
485
494
 
495
+ def report_process_error(handle: ManagedProcess, returncode: int) -> None:
496
+ display.report_process_exit(handle.prefix, returncode)
497
+
486
498
  return supervise_processes(
487
499
  streamlit_handle,
488
500
  tunnel_handle,
489
501
  restart_requested,
490
502
  restart_and_track,
503
+ report_process_error=report_process_error,
491
504
  display_toggle_requested=display_toggle_requested,
492
505
  toggle_display=display.toggle_display,
493
506
  plain_output_requested=plain_output_requested,
@@ -662,6 +675,7 @@ def supervise_processes(
662
675
  restart_requested: threading.Event,
663
676
  restart_streamlit: Callable[[ManagedProcess], ManagedProcess],
664
677
  poll_interval: float = 0.2,
678
+ report_process_error: Callable[[ManagedProcess, int], None] | None = None,
665
679
  display_toggle_requested: threading.Event | None = None,
666
680
  toggle_display: Callable[[], bool] | None = None,
667
681
  plain_output_requested: threading.Event | None = None,
@@ -695,20 +709,25 @@ def supervise_processes(
695
709
  continue
696
710
 
697
711
  if tunnel_handle is not None and tunnel_handle.process.poll() is not None:
698
- if tunnel_handle.process.returncode is not None:
699
- return tunnel_handle.process.returncode
700
- return 1
712
+ return report_process_returncode(tunnel_handle, report_process_error)
701
713
 
702
714
  if current_streamlit_handle.process.poll() is not None:
703
- return (
704
- current_streamlit_handle.process.returncode
705
- if current_streamlit_handle.process.returncode is not None
706
- else 1
707
- )
715
+ return report_process_returncode(current_streamlit_handle, report_process_error)
708
716
 
709
717
  time.sleep(poll_interval)
710
718
 
711
719
 
720
+ def report_process_returncode(
721
+ handle: ManagedProcess,
722
+ report_process_error: Callable[[ManagedProcess, int], None] | None,
723
+ ) -> int:
724
+ returncode = handle.process.returncode if handle.process.returncode is not None else 1
725
+ if returncode != 0 and report_process_error is not None:
726
+ handle.output_thread.join(timeout=1.0)
727
+ report_process_error(handle, returncode)
728
+ return returncode
729
+
730
+
712
731
  def prepare_cli_https_material(namespace: argparse.Namespace) -> HttpsMaterial | None:
713
732
  if namespace.dry_run and namespace.https_mode == "self-signed":
714
733
  return planned_self_signed_material(namespace.host)
@@ -3,9 +3,10 @@ from __future__ import annotations
3
3
  import subprocess
4
4
  import threading
5
5
  import time
6
- from collections.abc import Callable, Sequence
6
+ from collections.abc import Callable, Mapping, Sequence
7
7
  from contextlib import suppress
8
8
  from dataclasses import dataclass
9
+ from os import environ
9
10
 
10
11
  LineHandler = Callable[[str], None]
11
12
  LinePredicate = Callable[[str], bool]
@@ -25,7 +26,13 @@ def start_logged_process(
25
26
  on_line: LineHandler | None = None,
26
27
  should_print_line: LinePredicate | None = None,
27
28
  write_log: LogWriter | None = None,
29
+ env: Mapping[str, str] | None = None,
28
30
  ) -> ManagedProcess:
31
+ process_env = None
32
+ if env is not None:
33
+ process_env = environ.copy()
34
+ process_env.update(env)
35
+
29
36
  process = subprocess.Popen(
30
37
  list(command),
31
38
  stdout=subprocess.PIPE,
@@ -34,6 +41,7 @@ def start_logged_process(
34
41
  encoding="utf-8",
35
42
  errors="replace",
36
43
  bufsize=1,
44
+ env=process_env,
37
45
  )
38
46
 
39
47
  def pump_output() -> None:
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import re
3
4
  import sys
4
5
  import threading
5
6
  from collections import deque
@@ -12,6 +13,9 @@ from rich.panel import Panel
12
13
  from rich.table import Table
13
14
  from rich.text import Text
14
15
 
16
+ _ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]")
17
+ STREAMLIT_SOURCE = "streamlit"
18
+
15
19
 
16
20
  class RuntimeDisplay(Protocol):
17
21
  def start(self) -> None: ...
@@ -24,6 +28,8 @@ class RuntimeDisplay(Protocol):
24
28
 
25
29
  def toggle_display(self) -> bool: ...
26
30
 
31
+ def report_process_exit(self, source: str, returncode: int) -> None: ...
32
+
27
33
  def set_local_url(self, url: str) -> None: ...
28
34
 
29
35
  def set_remote_url(self, url: str) -> None: ...
@@ -32,6 +38,8 @@ class RuntimeDisplay(Protocol):
32
38
 
33
39
  def set_shortcuts_visible(self, visible: bool) -> None: ...
34
40
 
41
+ def streamlit_subprocess_columns(self) -> int | None: ...
42
+
35
43
  def info(self, message: str) -> None: ...
36
44
 
37
45
  def error(self, message: str) -> None: ...
@@ -59,6 +67,9 @@ class PlainRuntimeDisplay(RuntimeDisplay):
59
67
  def toggle_display(self) -> bool:
60
68
  return False
61
69
 
70
+ def report_process_exit(self, source: str, returncode: int) -> None:
71
+ self.error(f"error: {source} exited with code {returncode}.")
72
+
62
73
  def set_local_url(self, url: str) -> None:
63
74
  self.info("Streamlit local URL:")
64
75
  self.info(f" {url}")
@@ -75,6 +86,9 @@ class PlainRuntimeDisplay(RuntimeDisplay):
75
86
  self.info("Runtime shortcuts:")
76
87
  self.info(" r: restart Streamlit while keeping the tunnel running")
77
88
 
89
+ def streamlit_subprocess_columns(self) -> int | None:
90
+ return None
91
+
78
92
  def info(self, message: str) -> None:
79
93
  print(message, file=self._output, flush=True)
80
94
 
@@ -92,6 +106,7 @@ class RichRuntimeDisplay(RuntimeDisplay):
92
106
  _PANEL_HORIZONTAL_OVERHEAD = 4
93
107
  _LOG_SOURCE_WIDTH = 11
94
108
  _LOG_SOURCE_SEPARATOR_WIDTH = 1
109
+ _RICH_TRACEBACK_CHARS = frozenset("╭╮╰╯│─")
95
110
 
96
111
  def __init__(
97
112
  self,
@@ -137,6 +152,9 @@ class RichRuntimeDisplay(RuntimeDisplay):
137
152
  def toggle_display(self) -> bool:
138
153
  return False
139
154
 
155
+ def report_process_exit(self, source: str, returncode: int) -> None:
156
+ pass
157
+
140
158
  def set_local_url(self, url: str) -> None:
141
159
  with self._lock:
142
160
  self._local_url = url
@@ -157,6 +175,11 @@ class RichRuntimeDisplay(RuntimeDisplay):
157
175
  self._shortcuts_visible = visible
158
176
  self._refresh()
159
177
 
178
+ def streamlit_subprocess_columns(self) -> int | None:
179
+ # Streamlit formats Rich tracebacks before we can render them without a
180
+ # source prefix, so give it the prefix-less log panel width.
181
+ return self._log_panel_content_width()
182
+
160
183
  def info(self, message: str) -> None:
161
184
  self.log("st-remote", message)
162
185
 
@@ -242,9 +265,13 @@ class RichRuntimeDisplay(RuntimeDisplay):
242
265
 
243
266
  lines = Text()
244
267
  visible_logs = self._visible_logs(panel_height=panel_height)
268
+ raw_traceback_indexes = self._raw_streamlit_traceback_indexes(visible_logs)
245
269
  for index, (source, line) in enumerate(visible_logs):
246
- lines.append(f"{source:>11} ", style=self._source_style(source))
247
- lines.append(self._format_log_line(line))
270
+ if index in raw_traceback_indexes:
271
+ lines.append(self._format_raw_streamlit_traceback_line(line))
272
+ else:
273
+ lines.append(f"{source:>11} ", style=self._source_style(source))
274
+ lines.append(self._format_log_line(line, width=self._log_line_width()))
248
275
  if index < len(visible_logs) - 1:
249
276
  lines.append("\n")
250
277
  return Panel(lines, title="Logs", height=panel_height)
@@ -252,24 +279,142 @@ class RichRuntimeDisplay(RuntimeDisplay):
252
279
  def _visible_logs(self, *, panel_height: int | None = None) -> list[tuple[str, str]]:
253
280
  log_panel_height = panel_height or self._layout_heights()[1]
254
281
  available_rows = max(1, log_panel_height - self._PANEL_FRAME_ROWS)
255
- return list(self._logs)[-available_rows:]
282
+ logs = self._move_interleaved_logs_after_tracebacks(list(self._logs))
283
+ return self._select_visible_logs(logs, available_rows=available_rows)
256
284
 
257
- def _format_log_line(self, line: str) -> str:
285
+ def _select_visible_logs(
286
+ self,
287
+ logs: list[tuple[str, str]],
288
+ *,
289
+ available_rows: int,
290
+ ) -> list[tuple[str, str]]:
291
+ if len(logs) <= available_rows:
292
+ return logs
293
+
294
+ traceback_spans = self._streamlit_traceback_spans(logs)
295
+ traceback_span = traceback_spans[-1] if traceback_spans else None
296
+ if traceback_span is None:
297
+ return logs[-available_rows:]
298
+
299
+ start, end = traceback_span
300
+ if any(source == STREAMLIT_SOURCE for source, _ in logs[end:]):
301
+ return logs[-available_rows:]
302
+
303
+ traceback_logs = logs[start:end]
304
+ if len(traceback_logs) >= available_rows:
305
+ return traceback_logs[-available_rows:]
306
+
307
+ following_rows = available_rows - len(traceback_logs)
308
+ following_logs = logs[end:][-following_rows:] if following_rows else []
309
+ selected_logs = [*traceback_logs, *following_logs]
310
+
311
+ preceding_rows = available_rows - len(selected_logs)
312
+ if preceding_rows:
313
+ selected_logs = [*logs[:start][-preceding_rows:], *selected_logs]
314
+ return selected_logs
315
+
316
+ def _move_interleaved_logs_after_tracebacks(
317
+ self,
318
+ logs: list[tuple[str, str]],
319
+ ) -> list[tuple[str, str]]:
320
+ arranged_logs: list[tuple[str, str]] = []
321
+ deferred_logs: list[tuple[str, str]] = []
322
+ in_streamlit_traceback = False
323
+
324
+ for source, line in logs:
325
+ if self._is_streamlit_traceback_marker(source, line):
326
+ in_streamlit_traceback = True
327
+ arranged_logs.append((source, line))
328
+ elif in_streamlit_traceback and source != STREAMLIT_SOURCE:
329
+ deferred_logs.append((source, line))
330
+ else:
331
+ arranged_logs.append((source, line))
332
+
333
+ arranged_logs.extend(deferred_logs)
334
+ return arranged_logs
335
+
336
+ def _raw_streamlit_traceback_indexes(self, logs: list[tuple[str, str]]) -> set[int]:
337
+ raw_indexes: set[int] = set()
338
+ for start, end in self._streamlit_traceback_spans(logs):
339
+ raw_indexes.update(range(start, end))
340
+ return raw_indexes
341
+
342
+ def _streamlit_traceback_spans(self, logs: list[tuple[str, str]]) -> list[tuple[int, int]]:
343
+ marker_indexes = [
344
+ index
345
+ for index, (source, line) in enumerate(logs)
346
+ if self._is_streamlit_traceback_marker(source, line)
347
+ ]
348
+ spans: list[tuple[int, int]] = []
349
+ for marker_index in marker_indexes:
350
+ start = marker_index
351
+ while start > 0 and logs[start - 1][0] == STREAMLIT_SOURCE:
352
+ start -= 1
353
+
354
+ end = marker_index + 1
355
+ while end < len(logs) and logs[end][0] == STREAMLIT_SOURCE:
356
+ end += 1
357
+
358
+ if spans and start <= spans[-1][1]:
359
+ spans[-1] = (spans[-1][0], max(spans[-1][1], end))
360
+ else:
361
+ spans.append((start, end))
362
+ return spans
363
+
364
+ def _is_streamlit_traceback_marker(self, source: str, line: str) -> bool:
365
+ if source != STREAMLIT_SOURCE:
366
+ return False
367
+ return any(char in _ANSI_ESCAPE_RE.sub("", line) for char in self._RICH_TRACEBACK_CHARS)
368
+
369
+ def _format_raw_streamlit_traceback_line(self, line: str) -> Text:
370
+ text = Text.from_ansi(line)
371
+ plain = text.plain
372
+ start = 0
373
+ end = len(plain)
374
+
375
+ leading_border_match = re.match(r"^\s*│ ?", plain)
376
+ if leading_border_match is not None:
377
+ start = leading_border_match.end()
378
+
379
+ trailing_border_match = re.search(r"\s+│\s*$", plain)
380
+ if trailing_border_match is not None and trailing_border_match.start() >= start:
381
+ end = trailing_border_match.start()
382
+
383
+ end = min(end, len(plain[:end].rstrip()))
384
+ formatted_text = text[start:end]
385
+ return self._format_raw_streamlit_traceback_text(formatted_text)
386
+
387
+ def _format_raw_streamlit_traceback_text(self, text: Text) -> Text:
388
+ width = self._log_panel_content_width()
389
+ formatted_text = text.copy()
390
+ if len(formatted_text.plain) > width:
391
+ if width <= 3:
392
+ formatted_text = Text("." * width)
393
+ else:
394
+ formatted_text = formatted_text[: width - 3]
395
+ formatted_text.append("...")
396
+ formatted_text.pad_right(width - len(formatted_text.plain))
397
+ return formatted_text
398
+
399
+ def _format_log_line(self, line: str, *, width: int) -> str:
258
400
  line = line.replace("\n", r"\n").replace("\r", r"\r")
259
- line_width = self._log_line_width()
260
- if len(line) <= line_width:
401
+ if len(line) <= width:
261
402
  return line
262
- if line_width <= 3:
263
- return "." * line_width
264
- return f"{line[: line_width - 3]}..."
403
+ if width <= 3:
404
+ return "." * width
405
+ return f"{line[: width - 3]}..."
265
406
 
266
407
  def _log_line_width(self) -> int:
267
- content_width = self._console.size.width - self._PANEL_HORIZONTAL_OVERHEAD
268
408
  return max(
269
409
  1,
270
- content_width - self._LOG_SOURCE_WIDTH - self._LOG_SOURCE_SEPARATOR_WIDTH,
410
+ self._log_panel_content_width()
411
+ - self._LOG_SOURCE_WIDTH
412
+ - self._LOG_SOURCE_SEPARATOR_WIDTH,
271
413
  )
272
414
 
415
+ def _log_panel_content_width(self) -> int:
416
+ return max(1, self._console.size.width - self._PANEL_HORIZONTAL_OVERHEAD)
417
+
273
418
  def _render_shortcuts_panel(self, *, height: int | None = None) -> Panel:
274
419
  if not self._shortcuts_visible:
275
420
  return Panel(
@@ -288,7 +433,7 @@ class RichRuntimeDisplay(RuntimeDisplay):
288
433
  return Panel(shortcuts, title="Shortcuts", border_style="green", height=height)
289
434
 
290
435
  def _source_style(self, source: str) -> str:
291
- if source == "streamlit":
436
+ if source == STREAMLIT_SOURCE:
292
437
  return "blue"
293
438
  if source in {"cloudflared", "ngrok"}:
294
439
  return "magenta"
@@ -381,6 +526,24 @@ class SwitchableRuntimeDisplay(RuntimeDisplay):
381
526
  return self.switch_to_plain()
382
527
  return self.switch_to_rich()
383
528
 
529
+ def report_process_exit(self, source: str, returncode: int) -> None:
530
+ with self._lock:
531
+ if self._active_display is self._plain_display:
532
+ self._plain_display.report_process_exit(source, returncode)
533
+ return
534
+
535
+ was_started = self._started
536
+ if was_started:
537
+ self._rich_display.stop()
538
+ self._active_display = self._plain_display
539
+ if was_started:
540
+ self._plain_display.start()
541
+
542
+ self._plain_display.report_process_exit(source, returncode)
543
+ self._plain_display.info("Recent logs:")
544
+ for log_source, line in self._logs:
545
+ self._plain_display.log(log_source, line)
546
+
384
547
  def set_local_url(self, url: str) -> None:
385
548
  with self._lock:
386
549
  self._local_url = url
@@ -401,6 +564,10 @@ class SwitchableRuntimeDisplay(RuntimeDisplay):
401
564
  self._shortcuts_visible = visible
402
565
  self._active_display.set_shortcuts_visible(visible)
403
566
 
567
+ def streamlit_subprocess_columns(self) -> int | None:
568
+ with self._lock:
569
+ return self._active_display.streamlit_subprocess_columns()
570
+
404
571
  def info(self, message: str) -> None:
405
572
  with self._lock:
406
573
  self._logs.append(("st-remote", message))