streamlit-remote 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,386 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import importlib.util
5
+ import shlex
6
+ import sys
7
+ import threading
8
+ import time
9
+ import webbrowser
10
+ from pathlib import Path
11
+ from typing import Sequence
12
+
13
+ from streamlit_remote.https import (
14
+ HttpsError,
15
+ HttpsMaterial,
16
+ planned_self_signed_material,
17
+ prepare_https_material,
18
+ )
19
+ from streamlit_remote.process import (
20
+ ManagedProcess,
21
+ start_logged_process,
22
+ terminate_processes,
23
+ wait_for_process_exit,
24
+ )
25
+ from streamlit_remote.providers import get_provider
26
+ from streamlit_remote.server import LocalServerConfig, is_port_available, wait_until_listening
27
+
28
+
29
+ def build_parser() -> argparse.ArgumentParser:
30
+ parser = argparse.ArgumentParser(
31
+ prog="st-remote",
32
+ description="Run a Streamlit app with optional remote HTTPS access.",
33
+ )
34
+ parser.add_argument("app", type=Path, metavar="APP", help="Streamlit app file path.")
35
+ parser.add_argument("--port", type=int, default=8501, help="Local Streamlit port.")
36
+ parser.add_argument("--host", default="localhost", help="Local Streamlit host.")
37
+ parser.add_argument(
38
+ "--provider",
39
+ default="cloudflare",
40
+ choices=["cloudflare", "ngrok"],
41
+ help="Remote tunnel provider.",
42
+ )
43
+ parser.add_argument(
44
+ "--tunnel-log-level",
45
+ default="info",
46
+ choices=["info", "warn", "error", "off"],
47
+ help="Tunnel provider log verbosity.",
48
+ )
49
+ parser.add_argument(
50
+ "--https",
51
+ dest="https_mode",
52
+ default="off",
53
+ choices=["off", "self-signed", "cert-files"],
54
+ help="Local Streamlit HTTPS mode.",
55
+ )
56
+ parser.add_argument(
57
+ "--ssl-cert-file",
58
+ type=Path,
59
+ help="Existing certificate file for `--https cert-files`.",
60
+ )
61
+ parser.add_argument(
62
+ "--ssl-key-file",
63
+ type=Path,
64
+ help="Existing private key file for `--https cert-files`.",
65
+ )
66
+ parser.add_argument(
67
+ "--cert-valid-days",
68
+ type=int,
69
+ default=30,
70
+ help="Validity period for managed self-signed certificates.",
71
+ )
72
+ parser.add_argument(
73
+ "--streamlit-arg",
74
+ action="append",
75
+ default=[],
76
+ help="Extra argument passed to Streamlit. Can be repeated.",
77
+ )
78
+ parser.add_argument(
79
+ "--no-remote",
80
+ action="store_true",
81
+ help="Run Streamlit only without starting a remote tunnel.",
82
+ )
83
+ parser.add_argument(
84
+ "--no-browser",
85
+ action="store_true",
86
+ help="Do not open the local or remote URL in a browser.",
87
+ )
88
+ parser.add_argument(
89
+ "--dry-run",
90
+ action="store_true",
91
+ help="Print subprocess commands without running them.",
92
+ )
93
+ parser.add_argument(
94
+ "--verbose",
95
+ action="store_true",
96
+ help="Print additional diagnostic information.",
97
+ )
98
+ return parser
99
+
100
+
101
+ def parse_args(argv: Sequence[str]) -> argparse.Namespace:
102
+ cli_args, passthrough_args = split_streamlit_args(argv)
103
+ namespace = build_parser().parse_args(cli_args)
104
+ namespace.streamlit_args = [*namespace.streamlit_arg, *passthrough_args]
105
+ validate_cli_options(namespace)
106
+ return namespace
107
+
108
+
109
+ def split_streamlit_args(argv: Sequence[str]) -> tuple[list[str], list[str]]:
110
+ args = list(argv)
111
+ if "--" not in args:
112
+ return args, []
113
+
114
+ separator_index = args.index("--")
115
+ return args[:separator_index], args[separator_index + 1 :]
116
+
117
+
118
+ def build_streamlit_command(
119
+ app_path: Path,
120
+ host: str,
121
+ port: int,
122
+ streamlit_args: Sequence[str] = (),
123
+ https_material: HttpsMaterial | None = None,
124
+ ) -> list[str]:
125
+ command = [
126
+ sys.executable,
127
+ "-m",
128
+ "streamlit",
129
+ "run",
130
+ str(app_path),
131
+ "--server.address",
132
+ host,
133
+ "--server.port",
134
+ str(port),
135
+ "--server.headless",
136
+ "true",
137
+ ]
138
+
139
+ if https_material is not None:
140
+ command.extend(
141
+ [
142
+ "--server.sslCertFile",
143
+ str(https_material.cert_file),
144
+ "--server.sslKeyFile",
145
+ str(https_material.key_file),
146
+ ]
147
+ )
148
+
149
+ command.extend(streamlit_args)
150
+ return command
151
+
152
+
153
+ def validate_app_path(app_path: Path) -> None:
154
+ if not app_path.exists():
155
+ raise CliError(f"Streamlit app not found: {app_path}")
156
+
157
+ if not app_path.is_file():
158
+ raise CliError(f"Streamlit app path is not a file: {app_path}")
159
+
160
+
161
+ def validate_cli_options(namespace: argparse.Namespace) -> None:
162
+ cert_files = [namespace.ssl_cert_file, namespace.ssl_key_file]
163
+ if namespace.https_mode != "cert-files" and any(path is not None for path in cert_files):
164
+ raise CliError(
165
+ "`--ssl-cert-file` and `--ssl-key-file` can only be used with "
166
+ "`--https cert-files`."
167
+ )
168
+
169
+
170
+ def require_streamlit() -> None:
171
+ if importlib.util.find_spec("streamlit") is None:
172
+ raise CliError(
173
+ "Streamlit is not installed. Install it with `pip install streamlit` "
174
+ "or reinstall this package with its dependencies."
175
+ )
176
+
177
+
178
+ def run_cli(argv: Sequence[str] | None = None) -> int:
179
+ try:
180
+ namespace = parse_args(sys.argv[1:] if argv is None else argv)
181
+ return run(namespace)
182
+ except (CliError, HttpsError) as exc:
183
+ print(f"error: {exc}", file=sys.stderr)
184
+ return 2
185
+
186
+
187
+ def run(namespace: argparse.Namespace) -> int:
188
+ validate_app_path(namespace.app)
189
+
190
+ scheme = "https" if namespace.https_mode != "off" else "http"
191
+ local_server = LocalServerConfig(
192
+ host=namespace.host,
193
+ port=namespace.port,
194
+ scheme=scheme,
195
+ )
196
+
197
+ provider = None
198
+ tunnel_command: list[str] | None = None
199
+ if not namespace.no_remote:
200
+ provider = get_provider(namespace.provider)
201
+ tunnel_command = provider.build_command(
202
+ local_server.url,
203
+ origin_tls_verify=namespace.https_mode != "self-signed",
204
+ tunnel_log_level=namespace.tunnel_log_level,
205
+ )
206
+
207
+ if namespace.dry_run:
208
+ https_material = prepare_cli_https_material(namespace)
209
+ streamlit_command = build_streamlit_command(
210
+ namespace.app,
211
+ local_server.host,
212
+ local_server.port,
213
+ namespace.streamlit_args,
214
+ https_material=https_material,
215
+ )
216
+ print(f"Streamlit command:\n {shlex.join(streamlit_command)}")
217
+ if tunnel_command is None:
218
+ print("Remote access: disabled")
219
+ else:
220
+ print(f"Tunnel command:\n {shlex.join(tunnel_command)}")
221
+ if namespace.https_mode == "self-signed":
222
+ print("HTTPS: managed self-signed certificate will be prepared at runtime")
223
+ return 0
224
+
225
+ require_streamlit()
226
+ if not is_port_available(local_server.host, local_server.port):
227
+ raise CliError(f"Port {local_server.port} is not available on {local_server.host}.")
228
+
229
+ if provider is not None and not provider.is_available():
230
+ raise CliError(provider.install_hint)
231
+
232
+ https_material = prepare_cli_https_material(namespace)
233
+ streamlit_command = build_streamlit_command(
234
+ namespace.app,
235
+ local_server.host,
236
+ local_server.port,
237
+ namespace.streamlit_args,
238
+ https_material=https_material,
239
+ )
240
+
241
+ print("Streamlit local URL:")
242
+ print(f" {local_server.url}")
243
+ if namespace.no_remote:
244
+ print("\nRemote access: disabled")
245
+ if not namespace.no_browser:
246
+ open_browser(local_server.url)
247
+ else:
248
+ if namespace.https_mode == "self-signed" and namespace.provider == "ngrok":
249
+ print(
250
+ "\nNote: ngrok already provides HTTPS for the public URL. "
251
+ "Local self-signed HTTPS will be used only between ngrok and Streamlit."
252
+ )
253
+ print("\nStarting remote tunnel...")
254
+
255
+ remote_url_printed = threading.Event()
256
+ remote_url_lock = threading.Lock()
257
+
258
+ def report_remote_url(public_url: str) -> None:
259
+ with remote_url_lock:
260
+ if remote_url_printed.is_set():
261
+ return
262
+
263
+ remote_url_printed.set()
264
+ print("\nStreamlit local URL:")
265
+ print(f" {local_server.url}")
266
+ print("\nRemote HTTPS URL:")
267
+ print(f" {public_url}\n")
268
+ if not namespace.no_browser:
269
+ open_browser(public_url)
270
+
271
+ def on_tunnel_line(line: str) -> None:
272
+ if provider is None:
273
+ return
274
+
275
+ public_url = provider.parse_public_url(line)
276
+ if public_url is not None:
277
+ report_remote_url(public_url)
278
+
279
+ def poll_provider_public_url() -> None:
280
+ if provider is None:
281
+ return
282
+
283
+ while not remote_url_printed.is_set():
284
+ public_url = provider.get_public_url()
285
+ if public_url is not None:
286
+ report_remote_url(public_url)
287
+ return
288
+ time.sleep(0.5)
289
+
290
+ handles: list[ManagedProcess] = []
291
+ public_url_poll_thread: threading.Thread | None = None
292
+ try:
293
+ handles.append(start_logged_process(streamlit_command, "streamlit"))
294
+ if tunnel_command is not None and not wait_until_listening(
295
+ local_server.host,
296
+ local_server.port,
297
+ ):
298
+ raise CliError(
299
+ f"Streamlit did not start listening on {local_server.url} within the timeout."
300
+ )
301
+
302
+ if tunnel_command is not None:
303
+ handles.append(
304
+ start_logged_process(
305
+ tunnel_command,
306
+ provider.log_prefix,
307
+ on_line=on_tunnel_line,
308
+ should_print_line=lambda line: should_print_tunnel_line(
309
+ line,
310
+ namespace.tunnel_log_level,
311
+ ),
312
+ )
313
+ )
314
+ public_url_poll_thread = threading.Thread(
315
+ target=poll_provider_public_url,
316
+ name="streamlit-remote-public-url-poll",
317
+ daemon=True,
318
+ )
319
+ public_url_poll_thread.start()
320
+
321
+ exited = wait_for_process_exit(handles)
322
+ return exited.process.returncode if exited.process.returncode is not None else 1
323
+ except KeyboardInterrupt:
324
+ print("\nInterrupted. Shutting down child processes...", file=sys.stderr)
325
+ return 130
326
+ finally:
327
+ remote_url_printed.set()
328
+ terminate_processes(handles)
329
+ if public_url_poll_thread is not None:
330
+ public_url_poll_thread.join(timeout=1.0)
331
+
332
+
333
+ def main() -> None:
334
+ raise SystemExit(run_cli())
335
+
336
+
337
+ class CliError(Exception):
338
+ pass
339
+
340
+
341
+ def prepare_cli_https_material(namespace: argparse.Namespace) -> HttpsMaterial | None:
342
+ if namespace.dry_run and namespace.https_mode == "self-signed":
343
+ return planned_self_signed_material(namespace.host)
344
+
345
+ return prepare_https_material(
346
+ mode=namespace.https_mode,
347
+ host=namespace.host,
348
+ cert_file=namespace.ssl_cert_file,
349
+ key_file=namespace.ssl_key_file,
350
+ valid_days=namespace.cert_valid_days,
351
+ )
352
+
353
+
354
+ def should_print_tunnel_line(line: str, tunnel_log_level: str) -> bool:
355
+ if tunnel_log_level == "off":
356
+ return False
357
+
358
+ if tunnel_log_level == "info":
359
+ return True
360
+
361
+ severity = classify_tunnel_line(line)
362
+ if tunnel_log_level == "warn":
363
+ return severity in {"warn", "error"}
364
+
365
+ if tunnel_log_level == "error":
366
+ return severity == "error"
367
+
368
+ return True
369
+
370
+
371
+ def classify_tunnel_line(line: str) -> str:
372
+ lowered = line.lower()
373
+ if any(marker in lowered for marker in ("lvl=error", "lvl=crit", " err ", " error", "fatal")):
374
+ return "error"
375
+
376
+ if any(marker in lowered for marker in ("lvl=warn", " wrn ", " warn", "warning")):
377
+ return "warn"
378
+
379
+ return "info"
380
+
381
+
382
+ def open_browser(url: str) -> None:
383
+ try:
384
+ webbrowser.open(url, new=2)
385
+ except Exception:
386
+ pass
@@ -0,0 +1,210 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import ipaddress
5
+ import json
6
+ import os
7
+ from dataclasses import dataclass
8
+ from datetime import datetime, timedelta, timezone
9
+ from pathlib import Path
10
+
11
+ from cryptography import x509
12
+ from cryptography.hazmat.primitives import hashes, serialization
13
+ from cryptography.hazmat.primitives.asymmetric import rsa
14
+ from cryptography.x509.oid import NameOID
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class HttpsMaterial:
19
+ cert_file: Path
20
+ key_file: Path
21
+
22
+
23
+ class HttpsError(Exception):
24
+ pass
25
+
26
+
27
+ def prepare_https_material(
28
+ mode: str,
29
+ host: str,
30
+ cert_file: Path | None = None,
31
+ key_file: Path | None = None,
32
+ valid_days: int = 30,
33
+ cache_dir: Path | None = None,
34
+ ) -> HttpsMaterial | None:
35
+ if mode == "off":
36
+ return None
37
+
38
+ if mode == "cert-files":
39
+ return validate_cert_files(cert_file, key_file)
40
+
41
+ if mode == "self-signed":
42
+ return prepare_self_signed_material(host, valid_days, cache_dir)
43
+
44
+ raise HttpsError(f"Unsupported HTTPS mode: {mode}")
45
+
46
+
47
+ def validate_cert_files(
48
+ cert_file: Path | None,
49
+ key_file: Path | None,
50
+ ) -> HttpsMaterial:
51
+ if cert_file is None or key_file is None:
52
+ raise HttpsError(
53
+ "`--https cert-files` requires both `--ssl-cert-file` and `--ssl-key-file`."
54
+ )
55
+
56
+ if not cert_file.is_file():
57
+ raise HttpsError(f"SSL certificate file not found: {cert_file}")
58
+
59
+ if not key_file.is_file():
60
+ raise HttpsError(f"SSL key file not found: {key_file}")
61
+
62
+ return HttpsMaterial(cert_file=cert_file, key_file=key_file)
63
+
64
+
65
+ def prepare_self_signed_material(
66
+ host: str,
67
+ valid_days: int = 30,
68
+ cache_dir: Path | None = None,
69
+ ) -> HttpsMaterial:
70
+ if valid_days < 1:
71
+ raise HttpsError("`--cert-valid-days` must be at least 1.")
72
+
73
+ sans = subject_alt_names_for_host(host)
74
+ cert_file, key_file, metadata_file = self_signed_paths(host, cache_dir)
75
+ cert_dir = cert_file.parent
76
+ cert_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
77
+
78
+ if is_reusable_certificate(cert_file, key_file, sans):
79
+ return HttpsMaterial(cert_file=cert_file, key_file=key_file)
80
+
81
+ key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
82
+ now = datetime.now(timezone.utc)
83
+ cert = (
84
+ x509.CertificateBuilder()
85
+ .subject_name(certificate_name())
86
+ .issuer_name(certificate_name())
87
+ .public_key(key.public_key())
88
+ .serial_number(x509.random_serial_number())
89
+ .not_valid_before(now - timedelta(minutes=1))
90
+ .not_valid_after(now + timedelta(days=valid_days))
91
+ .add_extension(x509.SubjectAlternativeName(sans), critical=False)
92
+ .sign(key, hashes.SHA256())
93
+ )
94
+
95
+ key_file.write_bytes(
96
+ key.private_bytes(
97
+ encoding=serialization.Encoding.PEM,
98
+ format=serialization.PrivateFormat.TraditionalOpenSSL,
99
+ encryption_algorithm=serialization.NoEncryption(),
100
+ )
101
+ )
102
+ os.chmod(key_file, 0o600)
103
+
104
+ cert_file.write_bytes(cert.public_bytes(serialization.Encoding.PEM))
105
+ metadata_file.write_text(
106
+ json.dumps(
107
+ {
108
+ "created_at": now.isoformat(),
109
+ "expires_at": (now + timedelta(days=valid_days)).isoformat(),
110
+ "sans": [str(san) for san in sans],
111
+ },
112
+ indent=2,
113
+ )
114
+ + "\n",
115
+ encoding="utf-8",
116
+ )
117
+
118
+ return HttpsMaterial(cert_file=cert_file, key_file=key_file)
119
+
120
+
121
+ def planned_self_signed_material(
122
+ host: str,
123
+ cache_dir: Path | None = None,
124
+ ) -> HttpsMaterial:
125
+ cert_file, key_file, _ = self_signed_paths(host, cache_dir)
126
+ return HttpsMaterial(cert_file=cert_file, key_file=key_file)
127
+
128
+
129
+ def self_signed_paths(
130
+ host: str,
131
+ cache_dir: Path | None = None,
132
+ ) -> tuple[Path, Path, Path]:
133
+ sans = subject_alt_names_for_host(host)
134
+ cert_dir = cache_dir if cache_dir is not None else default_cert_cache_dir()
135
+ fingerprint = hashlib.sha256(
136
+ json.dumps(
137
+ {
138
+ "schema": 1,
139
+ "sans": [str(san) for san in sans],
140
+ },
141
+ sort_keys=True,
142
+ ).encode("utf-8")
143
+ ).hexdigest()[:16]
144
+
145
+ return (
146
+ cert_dir / f"self-signed-{fingerprint}.crt",
147
+ cert_dir / f"self-signed-{fingerprint}.key",
148
+ cert_dir / f"self-signed-{fingerprint}.json",
149
+ )
150
+
151
+
152
+ def default_cert_cache_dir() -> Path:
153
+ return Path.home() / ".streamlit-remote" / "certs"
154
+
155
+
156
+ def subject_alt_names_for_host(host: str) -> list[x509.GeneralName]:
157
+ names: list[x509.GeneralName] = [
158
+ x509.DNSName("localhost"),
159
+ x509.IPAddress(ipaddress.ip_address("127.0.0.1")),
160
+ x509.IPAddress(ipaddress.ip_address("::1")),
161
+ ]
162
+
163
+ try:
164
+ host_ip = ipaddress.ip_address(host)
165
+ except ValueError:
166
+ if host and host != "localhost":
167
+ names.append(x509.DNSName(host))
168
+ else:
169
+ if host_ip not in {ipaddress.ip_address("127.0.0.1"), ipaddress.ip_address("::1")}:
170
+ names.append(x509.IPAddress(host_ip))
171
+
172
+ return names
173
+
174
+
175
+ def is_reusable_certificate(
176
+ cert_file: Path,
177
+ key_file: Path,
178
+ expected_sans: list[x509.GeneralName],
179
+ ) -> bool:
180
+ if not cert_file.is_file() or not key_file.is_file():
181
+ return False
182
+
183
+ try:
184
+ cert = x509.load_pem_x509_certificate(cert_file.read_bytes())
185
+ sans = cert.extensions.get_extension_for_class(
186
+ x509.SubjectAlternativeName
187
+ ).value
188
+ except (OSError, ValueError, x509.ExtensionNotFound):
189
+ return False
190
+
191
+ if sorted(str(san) for san in sans) != sorted(str(san) for san in expected_sans):
192
+ return False
193
+
194
+ expires_at = certificate_not_valid_after(cert)
195
+ return expires_at > datetime.now(timezone.utc) + timedelta(days=1)
196
+
197
+
198
+ def certificate_not_valid_after(cert: x509.Certificate) -> datetime:
199
+ expires_at = getattr(cert, "not_valid_after_utc", None)
200
+ if expires_at is not None:
201
+ return expires_at
202
+ return cert.not_valid_after.replace(tzinfo=timezone.utc)
203
+
204
+
205
+ def certificate_name() -> x509.Name:
206
+ return x509.Name(
207
+ [
208
+ x509.NameAttribute(NameOID.COMMON_NAME, "streamlit-remote local development"),
209
+ ]
210
+ )
@@ -0,0 +1,93 @@
1
+ from __future__ import annotations
2
+
3
+ import subprocess
4
+ import threading
5
+ import time
6
+ from dataclasses import dataclass
7
+ from typing import Callable, Sequence
8
+
9
+
10
+ LineHandler = Callable[[str], None]
11
+ LinePredicate = Callable[[str], bool]
12
+
13
+
14
+ @dataclass
15
+ class ManagedProcess:
16
+ process: subprocess.Popen[str]
17
+ prefix: str
18
+ output_thread: threading.Thread
19
+
20
+
21
+ def start_logged_process(
22
+ command: Sequence[str],
23
+ prefix: str,
24
+ on_line: LineHandler | None = None,
25
+ should_print_line: LinePredicate | None = None,
26
+ ) -> ManagedProcess:
27
+ process = subprocess.Popen(
28
+ list(command),
29
+ stdout=subprocess.PIPE,
30
+ stderr=subprocess.STDOUT,
31
+ text=True,
32
+ encoding="utf-8",
33
+ errors="replace",
34
+ bufsize=1,
35
+ )
36
+
37
+ def pump_output() -> None:
38
+ if process.stdout is None:
39
+ return
40
+
41
+ for raw_line in process.stdout:
42
+ line = raw_line.rstrip("\r\n")
43
+ if should_print_line is None or should_print_line(line):
44
+ print(f"[{prefix}] {line}", flush=True)
45
+ if on_line is not None:
46
+ on_line(line)
47
+
48
+ output_thread = threading.Thread(
49
+ target=pump_output,
50
+ name=f"streamlit-remote-{prefix}",
51
+ daemon=True,
52
+ )
53
+ output_thread.start()
54
+ return ManagedProcess(process=process, prefix=prefix, output_thread=output_thread)
55
+
56
+
57
+ def wait_for_process_exit(
58
+ handles: Sequence[ManagedProcess],
59
+ poll_interval: float = 0.2,
60
+ ) -> ManagedProcess:
61
+ while True:
62
+ for handle in handles:
63
+ if handle.process.poll() is not None:
64
+ return handle
65
+ time.sleep(poll_interval)
66
+
67
+
68
+ def terminate_processes(
69
+ handles: Sequence[ManagedProcess],
70
+ terminate_timeout: float = 5.0,
71
+ kill_timeout: float = 2.0,
72
+ ) -> None:
73
+ for handle in handles:
74
+ if handle.process.poll() is None:
75
+ handle.process.terminate()
76
+
77
+ deadline = time.monotonic() + terminate_timeout
78
+ for handle in handles:
79
+ remaining = max(0.0, deadline - time.monotonic())
80
+ try:
81
+ handle.process.wait(timeout=remaining)
82
+ except subprocess.TimeoutExpired:
83
+ if handle.process.poll() is None:
84
+ handle.process.kill()
85
+
86
+ for handle in handles:
87
+ try:
88
+ handle.process.wait(timeout=kill_timeout)
89
+ except subprocess.TimeoutExpired:
90
+ pass
91
+
92
+ for handle in handles:
93
+ handle.output_thread.join(timeout=kill_timeout)
@@ -0,0 +1,39 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Literal, Protocol
4
+
5
+ from streamlit_remote.providers.cloudflare import CloudflareQuickTunnelProvider
6
+ from streamlit_remote.providers.ngrok import NgrokProvider
7
+
8
+
9
+ TunnelLogLevel = Literal["info", "warn", "error", "off"]
10
+
11
+
12
+ class TunnelProvider(Protocol):
13
+ name: str
14
+ log_prefix: str
15
+ install_hint: str
16
+
17
+ def build_command(
18
+ self,
19
+ local_url: str,
20
+ *,
21
+ origin_tls_verify: bool = True,
22
+ tunnel_log_level: TunnelLogLevel = "info",
23
+ ) -> list[str]: ...
24
+
25
+ def parse_public_url(self, line: str) -> str | None: ...
26
+
27
+ def get_public_url(self) -> str | None: ...
28
+
29
+ def is_available(self) -> bool: ...
30
+
31
+
32
+ def get_provider(name: str) -> TunnelProvider:
33
+ if name == "cloudflare":
34
+ return CloudflareQuickTunnelProvider()
35
+
36
+ if name == "ngrok":
37
+ return NgrokProvider()
38
+
39
+ raise ValueError(f"Unsupported provider: {name}")
@@ -0,0 +1,43 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import shutil
5
+ from dataclasses import dataclass
6
+
7
+
8
+ TRYCLOUDFLARE_URL_RE = re.compile(r"https://[A-Za-z0-9-]+\.trycloudflare\.com\b")
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class CloudflareQuickTunnelProvider:
13
+ name: str = "cloudflare"
14
+ log_prefix: str = "cloudflared"
15
+ install_hint: str = (
16
+ "cloudflared was not found on PATH. Install Cloudflare Tunnel from "
17
+ "https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/ "
18
+ "and try again."
19
+ )
20
+
21
+ def build_command(
22
+ self,
23
+ local_url: str,
24
+ *,
25
+ origin_tls_verify: bool = True,
26
+ tunnel_log_level: str = "info",
27
+ ) -> list[str]:
28
+ command = ["cloudflared", "tunnel", "--url", local_url]
29
+ if not origin_tls_verify:
30
+ command.append("--no-tls-verify")
31
+ return command
32
+
33
+ def parse_public_url(self, line: str) -> str | None:
34
+ match = TRYCLOUDFLARE_URL_RE.search(line)
35
+ if match is None:
36
+ return None
37
+ return match.group(0)
38
+
39
+ def get_public_url(self) -> str | None:
40
+ return None
41
+
42
+ def is_available(self) -> bool:
43
+ return shutil.which("cloudflared") is not None
@@ -0,0 +1,88 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ import shutil
6
+ from dataclasses import dataclass
7
+ from typing import Any
8
+ from urllib.parse import urlparse
9
+ from urllib.request import urlopen
10
+
11
+
12
+ HTTPS_URL_RE = re.compile(r"https://[^\s]+")
13
+ AGENT_API_TUNNELS_URL = "http://127.0.0.1:4040/api/tunnels"
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class NgrokProvider:
18
+ name: str = "ngrok"
19
+ log_prefix: str = "ngrok"
20
+ install_hint: str = (
21
+ "ngrok was not found on PATH. Install ngrok from "
22
+ "https://ngrok.com/download and configure your authtoken with "
23
+ "`ngrok config add-authtoken TOKEN`."
24
+ )
25
+
26
+ def build_command(
27
+ self,
28
+ local_url: str,
29
+ *,
30
+ origin_tls_verify: bool = True,
31
+ tunnel_log_level: str = "info",
32
+ ) -> list[str]:
33
+ if tunnel_log_level == "off":
34
+ return ["ngrok", "http", local_url, "--log", "false"]
35
+
36
+ return [
37
+ "ngrok",
38
+ "http",
39
+ local_url,
40
+ "--log",
41
+ "stdout",
42
+ "--log-format",
43
+ "logfmt",
44
+ "--log-level",
45
+ tunnel_log_level,
46
+ ]
47
+
48
+ def parse_public_url(self, line: str) -> str | None:
49
+ if "Forwarding" not in line:
50
+ return None
51
+
52
+ for candidate in HTTPS_URL_RE.findall(line):
53
+ parsed = urlparse(candidate)
54
+ if parsed.scheme == "https" and parsed.netloc:
55
+ return candidate.rstrip(",")
56
+ return None
57
+
58
+ def get_public_url(self) -> str | None:
59
+ try:
60
+ with urlopen(AGENT_API_TUNNELS_URL, timeout=0.5) as response:
61
+ payload = json.loads(response.read().decode("utf-8"))
62
+ except (OSError, json.JSONDecodeError):
63
+ return None
64
+
65
+ return parse_agent_api_public_url(payload)
66
+
67
+ def is_available(self) -> bool:
68
+ return shutil.which("ngrok") is not None
69
+
70
+
71
+ def parse_agent_api_public_url(payload: dict[str, Any]) -> str | None:
72
+ tunnels = payload.get("tunnels")
73
+ if not isinstance(tunnels, list):
74
+ return None
75
+
76
+ for tunnel in tunnels:
77
+ if not isinstance(tunnel, dict):
78
+ continue
79
+
80
+ public_url = tunnel.get("public_url")
81
+ if not isinstance(public_url, str):
82
+ continue
83
+
84
+ parsed = urlparse(public_url)
85
+ if parsed.scheme == "https" and parsed.netloc:
86
+ return public_url
87
+
88
+ return None
@@ -0,0 +1,40 @@
1
+ from __future__ import annotations
2
+
3
+ import socket
4
+ import time
5
+ from dataclasses import dataclass
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class LocalServerConfig:
10
+ host: str
11
+ port: int
12
+ scheme: str = "http"
13
+
14
+ @property
15
+ def url(self) -> str:
16
+ return f"{self.scheme}://{self.host}:{self.port}"
17
+
18
+
19
+ def is_port_available(host: str, port: int) -> bool:
20
+ try:
21
+ with socket.create_server((host, port), reuse_port=False):
22
+ return True
23
+ except OSError:
24
+ return False
25
+
26
+
27
+ def wait_until_listening(
28
+ host: str,
29
+ port: int,
30
+ timeout: float = 20.0,
31
+ interval: float = 0.2,
32
+ ) -> bool:
33
+ deadline = time.monotonic() + timeout
34
+ while time.monotonic() < deadline:
35
+ try:
36
+ with socket.create_connection((host, port), timeout=interval):
37
+ return True
38
+ except OSError:
39
+ time.sleep(interval)
40
+ return False
@@ -0,0 +1,225 @@
1
+ Metadata-Version: 2.4
2
+ Name: streamlit-remote
3
+ Version: 0.1.0
4
+ Summary: Run Streamlit apps with optional remote HTTPS access.
5
+ Author: Yuichiro Tachibana (Tsuchiya)
6
+ Author-email: Yuichiro Tachibana (Tsuchiya) <t.yic.yt@gmail.com>
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Environment :: Console
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Topic :: Internet :: WWW/HTTP
18
+ Requires-Dist: cryptography>=42
19
+ Requires-Dist: streamlit>=1.28
20
+ Requires-Python: >=3.10
21
+ Project-URL: Homepage, https://github.com/whitphx/streamlit-remote
22
+ Project-URL: Repository, https://github.com/whitphx/streamlit-remote
23
+ Project-URL: Issues, https://github.com/whitphx/streamlit-remote/issues
24
+ Description-Content-Type: text/markdown
25
+
26
+ # streamlit-remote
27
+
28
+ `streamlit-remote` runs a Streamlit app locally, can serve it over local HTTPS, and can expose it through a temporary remote HTTPS URL.
29
+
30
+ It supports Cloudflare Quick Tunnel, ngrok, and managed self-signed certificates for local HTTPS. It is meant for development, demos, and temporary sharing, similar in spirit to Slidev's remote access workflow.
31
+
32
+ ## Installation
33
+
34
+ ```bash
35
+ pip install streamlit-remote
36
+ ```
37
+
38
+ This package requires Python 3.10 or newer.
39
+
40
+ ## Basic Usage
41
+
42
+ ```bash
43
+ st-remote app.py
44
+ ```
45
+
46
+ This starts Streamlit on `http://localhost:8501`, starts a Cloudflare Quick Tunnel to that local URL, prefixes logs from both child processes, prints the public `trycloudflare.com` URL once Cloudflare reports it, and opens that remote URL in your browser.
47
+
48
+ You can also use the alias:
49
+
50
+ ```bash
51
+ streamlit-remote app.py
52
+ ```
53
+
54
+ ## Options
55
+
56
+ ```bash
57
+ st-remote APP [--port 8501] [--host localhost] [--https off] [--provider cloudflare]
58
+ ```
59
+
60
+ Useful options:
61
+
62
+ ```bash
63
+ st-remote app.py --port 9000
64
+ st-remote app.py --host 0.0.0.0
65
+ st-remote app.py --no-remote
66
+ st-remote app.py --no-browser
67
+ st-remote app.py --dry-run
68
+ st-remote app.py --https self-signed --no-remote
69
+ st-remote app.py --provider ngrok
70
+ st-remote app.py --provider ngrok --tunnel-log-level warn
71
+ st-remote app.py -- --server.headless true
72
+ ```
73
+
74
+ Extra arguments after `--` are passed to `python -m streamlit run`.
75
+
76
+ `st-remote` starts Streamlit in headless mode so Streamlit does not open the local URL automatically. When remote access is enabled, `st-remote` opens the detected remote HTTPS URL instead. Use `--no-browser` to suppress browser opening.
77
+
78
+ ## Local HTTPS
79
+
80
+ By default, Streamlit runs locally over HTTP:
81
+
82
+ ```bash
83
+ st-remote app.py --https off
84
+ ```
85
+
86
+ For local HTTPS, use managed self-signed mode:
87
+
88
+ ```bash
89
+ st-remote app.py --https self-signed --no-remote
90
+ ```
91
+
92
+ `streamlit-remote` creates and reuses a local development certificate in its own cache directory, then passes Streamlit's `server.sslCertFile` and `server.sslKeyFile` options automatically. You do not need to choose filenames or manage generated certificate files.
93
+
94
+ Browsers generally do not trust self-signed certificates by default. You may see a certificate warning unless you manually trust the generated certificate. This mode is intended for local development and testing, not production.
95
+
96
+ Advanced users can pass existing certificate files:
97
+
98
+ ```bash
99
+ st-remote app.py --https cert-files \
100
+ --ssl-cert-file cert.pem \
101
+ --ssl-key-file key.pem
102
+ ```
103
+
104
+ ## Remote Providers
105
+
106
+ ### Cloudflare Tunnel
107
+
108
+ Cloudflare Quick Tunnel requires the `cloudflared` command to be installed and available on `PATH`.
109
+
110
+ Install instructions are available from Cloudflare:
111
+
112
+ https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/
113
+
114
+ `streamlit-remote` checks for `cloudflared` before starting the tunnel and prints an actionable error if it is missing. It does not install `cloudflared` automatically.
115
+
116
+ ### ngrok
117
+
118
+ ngrok requires the `ngrok` command to be installed and available on `PATH`.
119
+
120
+ Install instructions are available from ngrok:
121
+
122
+ https://ngrok.com/download
123
+
124
+ Configure your ngrok account token before use:
125
+
126
+ ```bash
127
+ ngrok config add-authtoken TOKEN
128
+ ```
129
+
130
+ Then run:
131
+
132
+ ```bash
133
+ st-remote app.py --provider ngrok
134
+ ```
135
+
136
+ ngrok provides HTTPS for the public URL while forwarding to your local Streamlit app. If you combine ngrok with local self-signed HTTPS:
137
+
138
+ ```bash
139
+ st-remote app.py --provider ngrok --https self-signed
140
+ ```
141
+
142
+ ngrok still provides HTTPS for the public URL. The self-signed certificate is used only between the local ngrok agent and Streamlit.
143
+
144
+ ## Tunnel Logs
145
+
146
+ Tunnel provider logs are shown by default:
147
+
148
+ ```bash
149
+ st-remote app.py --tunnel-log-level info
150
+ ```
151
+
152
+ Use a quieter level to reduce provider noise while keeping Streamlit logs visible:
153
+
154
+ ```bash
155
+ st-remote app.py --provider ngrok --tunnel-log-level warn
156
+ st-remote app.py --provider ngrok --tunnel-log-level error
157
+ st-remote app.py --provider ngrok --tunnel-log-level off
158
+ ```
159
+
160
+ `off` suppresses printed tunnel logs but still captures provider output internally when needed to detect the remote URL. ngrok also uses its local agent API as a fallback for URL detection.
161
+
162
+ ## HTTPS Serving vs Remote Access
163
+
164
+ The design treats HTTPS serving and remote access as separate concepts.
165
+
166
+ HTTPS serving means the user-facing URL uses HTTPS. Remote access means the app is reachable from outside your local machine.
167
+
168
+ Cloudflare Quick Tunnel and ngrok usually provide both at once: Streamlit can run locally over plain HTTP, while the provider gives you a public HTTPS URL that forwards to the local app.
169
+
170
+ Local self-signed HTTPS is different: Streamlit itself runs with HTTPS locally. You can use this without remote access, or combine it with a remote provider when you specifically want HTTPS between the tunnel agent and Streamlit.
171
+
172
+ Common combinations:
173
+
174
+ ```text
175
+ --https off + --provider cloudflare
176
+ Public HTTPS via Cloudflare, local HTTP Streamlit.
177
+
178
+ --https off + --provider ngrok
179
+ Public HTTPS via ngrok, local HTTP Streamlit.
180
+
181
+ --https self-signed + --no-remote
182
+ Local HTTPS Streamlit only.
183
+
184
+ --https self-signed + --provider ngrok
185
+ Public HTTPS via ngrok, local HTTPS between ngrok and Streamlit.
186
+ ```
187
+
188
+ For managed self-signed HTTPS with Cloudflare Tunnel, `streamlit-remote` also passes Cloudflare's origin TLS verification flag automatically so `cloudflared` can connect to the local self-signed Streamlit server.
189
+
190
+ ## Security
191
+
192
+ This exposes a local Streamlit app to the internet.
193
+
194
+ Do not use it for sensitive data unless you have proper authentication and access control in place. Cloudflare Quick Tunnel and ngrok are best suited for development, demos, and temporary sharing.
195
+
196
+ Streamlit's built-in SSL configuration is useful for local testing, but it is not a replacement for a production HTTPS reverse proxy.
197
+
198
+ ## Limitations
199
+
200
+ The current package does not include mkcert integration, production Cloudflare named tunnels, authentication, password protection, reverse proxy management, or PyPI publishing automation.
201
+
202
+ ## Roadmap
203
+
204
+ - local HTTPS with mkcert
205
+ - optional auth and access-control integration
206
+
207
+ ## Development
208
+
209
+ ```bash
210
+ uv run pytest
211
+ ```
212
+
213
+ ## Release Management
214
+
215
+ This project uses `scriv-release` for changelog-fragment based releases.
216
+
217
+ For user-visible changes, add a fragment:
218
+
219
+ ```bash
220
+ uv run scriv create --edit
221
+ ```
222
+
223
+ When fragments are merged to `main`, the release workflow opens or updates a changelog preview PR. Merging that preview PR tags the release. Tag pushes matching `v*` run the PyPI publish workflow through Trusted Publishing.
224
+
225
+ The release workflow expects a GitHub App configured through `RELEASE_APP_CLIENT_ID` and `RELEASE_APP_KEY` so release tags can trigger the downstream publish workflow.
@@ -0,0 +1,13 @@
1
+ streamlit_remote/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
2
+ streamlit_remote/cli.py,sha256=uf06kpaEezoc5oWd0u0UR-uJaIwrrvyP8x-_E7lOkeQ,11873
3
+ streamlit_remote/https.py,sha256=rHzSqN8EBHnoFb3sKHr_89sbhgLvv3wWU4l-yyE7Rsc,6159
4
+ streamlit_remote/process.py,sha256=OzlrTNWpoDYFO3X-1SI4drz5_jF0CKad17pY4W16Fww,2467
5
+ streamlit_remote/providers/__init__.py,sha256=u7PY69vMMHFB4M_joExxqnSmDjECQ7YhQJoWequ03dk,938
6
+ streamlit_remote/providers/cloudflare.py,sha256=zZlfSSHSOx1ZmZOQrg0vSoGWdF79GtXoz3ahdi_jjag,1225
7
+ streamlit_remote/providers/ngrok.py,sha256=zqCsAmv406W9u6GpFH77F_rRHJELVnhfHmd9LcIe5DU,2395
8
+ streamlit_remote/server.py,sha256=NotodxzLQQkscDJrPD_JsmrzyFDvNXaShDUjPHFqtGk,889
9
+ streamlit_remote-0.1.0.dist-info/licenses/LICENSE,sha256=xsfsjT1anhkYpYTUBK5AzuBgCSVwGxIqG0_5sVSy1Xk,1086
10
+ streamlit_remote-0.1.0.dist-info/WHEEL,sha256=wXwAVsgVaOZ_pwDFqQm5Rd6PID-Fc74nkLc8X8gHiDo,81
11
+ streamlit_remote-0.1.0.dist-info/entry_points.txt,sha256=_tb74_eGe-Mloy8jT3rZrswZHR1VkHMgAX4HWpz7vqk,102
12
+ streamlit_remote-0.1.0.dist-info/METADATA,sha256=6h4LcCs6G3YPerx2_yRbPC9tHicBRXX10zgymowyffU,7754
13
+ streamlit_remote-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.11.19
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,4 @@
1
+ [console_scripts]
2
+ st-remote = streamlit_remote.cli:main
3
+ streamlit-remote = streamlit_remote.cli:main
4
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Yuichiro Tachibana (Tsuchiya)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.