Skip to content

Commit bb55794

Browse files
authored
Merge pull request #329 from rstudio/launch-browser-2
Add --launch-browser option to shiny run, and random port
2 parents 0e4ceca + 3dea5cb commit bb55794

File tree

5 files changed

+191
-37
lines changed

5 files changed

+191
-37
lines changed

shiny/_autoreload.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import secrets
77
from typing import Optional, Tuple
88
import threading
9+
import webbrowser
910

1011
from asgiref.typing import (
1112
ASGI3Application,
@@ -149,7 +150,7 @@ async def rewrite_send(event: ASGISendEvent) -> None:
149150
# PARENT PROCESS ------------------------------------------------------------
150151

151152

152-
def start_server(port: int, app_port: int):
153+
def start_server(port: int, app_port: int, launch_browser: bool):
153154
"""Starts a websocket server that listens on its own port (separate from the main
154155
Shiny listener).
155156
@@ -173,18 +174,25 @@ def start_server(port: int, app_port: int):
173174
# Run on a background thread so our event loop doesn't interfere with uvicorn.
174175
# Set daemon=True because we don't want to keep the process alive with this thread.
175176
threading.Thread(
176-
None, _thread_main, args=[port, app_url, secret], daemon=True
177+
None, _thread_main, args=[port, app_url, secret, launch_browser], daemon=True
177178
).start()
178179

179180

180-
def _thread_main(port: int, app_url: str, secret: str):
181-
asyncio.run(_coro_main(port, app_url, secret))
181+
def _thread_main(port: int, app_url: str, secret: str, launch_browser: bool):
182+
asyncio.run(_coro_main(port, app_url, secret, launch_browser))
182183

183184

184-
async def _coro_main(port: int, app_url: str, secret: str) -> None:
185+
async def _coro_main(
186+
port: int, app_url: str, secret: str, launch_browser: bool
187+
) -> None:
185188
reload_now: asyncio.Event = asyncio.Event()
186189

187190
def nudge():
191+
nonlocal launch_browser
192+
if launch_browser:
193+
# Only launch the browser once, not every time autoreload occurs
194+
launch_browser = False
195+
webbrowser.open(app_url, 1)
188196
reload_now.set()
189197
reload_now.clear()
190198

shiny/_launchbrowser.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import logging
2+
import os
3+
import webbrowser
4+
5+
from ._hostenv import get_proxy_url
6+
7+
8+
class LaunchBrowserHandler(logging.Handler):
9+
"""Uvicorn log reader, detects successful app startup so we can launch a browser.
10+
11+
This class is ONLY used when reload mode is turned off; in the case of reload, the
12+
launching of the browser must be tightly coupled to the HotReloadHandler as that is
13+
the only way to ensure the browser is only launched at startup, not on every reload.
14+
"""
15+
16+
def __init__(self):
17+
logging.Handler.__init__(self)
18+
self._launched = False
19+
20+
def emit(self, record: logging.LogRecord) -> None:
21+
if self._launched:
22+
# Ensure that we never launch a browser window twice. In non-reload
23+
# scenarios it probably would never happen anyway, as we're unlikely to get
24+
# "Application startup complete." in the logs more than once, but just in
25+
# case someone does choose to log that string...
26+
return
27+
28+
if "Application startup complete." in record.getMessage():
29+
self._launched = True
30+
port = os.environ["SHINY_PORT"]
31+
if not port.isnumeric():
32+
print(
33+
"SHINY_PORT environment variable not set or unusable; "
34+
"--launch-browser will be ignored"
35+
)
36+
# For some reason the shiny port isn't set correctly!?
37+
return
38+
host = os.environ["SHINY_HOST"]
39+
url = get_proxy_url(f"http://{host}:{port}/")
40+
webbrowser.open(url, 1)

shiny/_main.py

Lines changed: 60 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
import importlib
33
import importlib.util
44
import os
5-
import re
65
import shutil
76
import sys
87
import types
@@ -15,7 +14,7 @@
1514

1615
import shiny
1716

18-
from . import _autoreload, _hostenv, _static
17+
from . import _autoreload, _hostenv, _static, _utils
1918

2019

2120
@click.group() # pyright: ignore[reportUnknownMemberType]
@@ -54,14 +53,14 @@ def main() -> None:
5453
"--port",
5554
type=int,
5655
default=8000,
57-
help="Bind socket to this port.",
56+
help="Bind socket to this port. If 0, a random port will be used.",
5857
show_default=True,
5958
)
6059
@click.option(
6160
"--autoreload-port",
62-
type=str,
63-
default="+123",
64-
help="Bind autoreload socket to this port number. If the value begins with + or -, it will be added to the value of --port. Ignored if --reload is not used.",
61+
type=int,
62+
default=0,
63+
help="Bind autoreload socket to this port. If 0, a random port will be used. Ignored if --reload is not used.",
6564
show_default=True,
6665
)
6766
@click.option(
@@ -97,17 +96,25 @@ def main() -> None:
9796
help="Treat APP as an application factory, i.e. a () -> <ASGI app> callable.",
9897
show_default=True,
9998
)
99+
@click.option(
100+
"--launch-browser",
101+
is_flag=True,
102+
default=False,
103+
help="Launch app browser after app starts, using the Python webbrowser module.",
104+
show_default=True,
105+
)
100106
def run(
101107
app: Union[str, shiny.App],
102108
host: str,
103109
port: int,
104-
autoreload_port: str,
110+
autoreload_port: int,
105111
debug: bool,
106112
reload: bool,
107113
ws_max_size: int,
108114
log_level: str,
109115
app_dir: str,
110116
factory: bool,
117+
launch_browser: bool,
111118
) -> None:
112119
return run_app(
113120
app,
@@ -120,20 +127,22 @@ def run(
120127
log_level=log_level,
121128
app_dir=app_dir,
122129
factory=factory,
130+
launch_browser=launch_browser,
123131
)
124132

125133

126134
def run_app(
127135
app: Union[str, shiny.App] = "app:app",
128136
host: str = "127.0.0.1",
129137
port: int = 8000,
130-
autoreload_port: str = "",
138+
autoreload_port: int = 0,
131139
debug: bool = False,
132140
reload: bool = False,
133141
ws_max_size: int = 16777216,
134142
log_level: Optional[str] = None,
135143
app_dir: Optional[str] = ".",
136144
factory: bool = False,
145+
launch_browser: bool = False,
137146
) -> None:
138147
"""
139148
Starts a Shiny app. Press ``Ctrl+C`` (or ``Ctrl+Break`` on Windows) to stop.
@@ -152,7 +161,10 @@ def run_app(
152161
host
153162
The address that the app should listen on.
154163
port
155-
The port that the app should listen on.
164+
The port that the app should listen on. Set to 0 to use a random port.
165+
autoreload_port
166+
The port that should be used for an additional websocket that is used to support
167+
hot-reload. Set to 0 to use a random port.
156168
debug
157169
Enable debug mode.
158170
reload
@@ -163,6 +175,10 @@ def run_app(
163175
Log level.
164176
app_dir
165177
Look for ``app`` under this directory (by adding this to the ``PYTHONPATH``).
178+
factory
179+
Treat ``app`` as an application factory, i.e. a () -> <ASGI app> callable.
180+
launch_browser
181+
Launch app browser after app starts, using the Python webbrowser module.
166182
167183
Tip
168184
---
@@ -189,6 +205,13 @@ def run_app(
189205
run_app("myapp:my_app", app_dir="..")
190206
"""
191207

208+
# If port is 0, randomize
209+
if port == 0:
210+
port = _utils.random_port(host=host)
211+
212+
os.environ["SHINY_HOST"] = host
213+
os.environ["SHINY_PORT"] = str(port)
214+
192215
if isinstance(app, str):
193216
app, app_dir = resolve_app(app, app_dir)
194217

@@ -202,25 +225,20 @@ def run_app(
202225
else:
203226
reload_dirs = []
204227

205-
if reload and autoreload_port != "":
206-
m = re.search("^([+-]?)(\\d+)$", autoreload_port)
207-
if not m:
208-
sys.stderr.write(
209-
"Error: Couldn't understand the provided value for --autoreload-port\n"
210-
)
211-
exit(1)
212-
autoreload_port_num = int(m.group(2))
213-
if m.group(1) == "+":
214-
autoreload_port_num += port
215-
elif m.group(1) == "-":
216-
autoreload_port_num = port - autoreload_port_num
217-
218-
if autoreload_port_num == port:
228+
if reload:
229+
if autoreload_port == 0:
230+
autoreload_port = _utils.random_port(host=host)
231+
232+
if autoreload_port == port:
219233
sys.stderr.write(
220234
"Autoreload port is already being used by the app; disabling autoreload\n"
221235
)
236+
reload = False
222237
else:
223-
setup_hot_reload(log_config, autoreload_port_num, port)
238+
setup_hot_reload(log_config, autoreload_port, port, launch_browser)
239+
240+
if launch_browser and not reload:
241+
setup_launch_browser(log_config)
224242

225243
maybe_setup_rsw_proxying(log_config)
226244

@@ -240,17 +258,32 @@ def run_app(
240258

241259

242260
def setup_hot_reload(
243-
log_config: Dict[str, Any], autoreload_port: int, app_port: int
261+
log_config: Dict[str, Any],
262+
autoreload_port: int,
263+
app_port: int,
264+
launch_browser: bool,
244265
) -> None:
245266
# The only way I've found to get notified when uvicorn decides to reload, is by
246267
# inserting a custom log handler.
247268
log_config["handlers"]["shiny_hot_reload"] = {
248269
"class": "shiny._autoreload.HotReloadHandler",
249270
"level": "INFO",
250271
}
251-
log_config["loggers"]["uvicorn.error"]["handlers"] = ["shiny_hot_reload"]
272+
if "handlers" not in log_config["loggers"]["uvicorn.error"]:
273+
log_config["loggers"]["uvicorn.error"]["handlers"] = []
274+
log_config["loggers"]["uvicorn.error"]["handlers"].append("shiny_hot_reload")
252275

253-
_autoreload.start_server(autoreload_port, app_port)
276+
_autoreload.start_server(autoreload_port, app_port, launch_browser)
277+
278+
279+
def setup_launch_browser(log_config: Dict[str, Any]):
280+
log_config["handlers"]["shiny_launch_browser"] = {
281+
"class": "shiny._launchbrowser.LaunchBrowserHandler",
282+
"level": "INFO",
283+
}
284+
if "handlers" not in log_config["loggers"]["uvicorn.error"]:
285+
log_config["loggers"]["uvicorn.error"]["handlers"] = []
286+
log_config["loggers"]["uvicorn.error"]["handlers"].append("shiny_launch_browser")
254287

255288

256289
def maybe_setup_rsw_proxying(log_config: Dict[str, Any]) -> None:

shiny/_utils.py

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import os
88
import random
99
import secrets
10+
import socketserver
1011
import sys
1112
import tempfile
1213
from typing import (
@@ -81,6 +82,54 @@ def guess_mime_type(
8182
return mimetypes.guess_type(url, strict)[0] or default
8283

8384

85+
def random_port(
86+
min: int = 1024, max: int = 49151, host: str = "127.0.0.1", n: int = 20
87+
) -> int:
88+
"""Find an open TCP port
89+
90+
Finds a random available TCP port for listening on, within a specified range
91+
of ports. The default range of ports to check is 1024 to 49151, which is the
92+
set of TCP User Ports. This function automatically excludes some ports which
93+
are considered unsafe by web browsers.
94+
95+
Parameters
96+
----------
97+
min
98+
Minimum port number.
99+
max
100+
Maximum port number, inclusive.
101+
host
102+
Before returning a port number, ensure that we can successfully bind it on this
103+
host.
104+
n
105+
Number of times to attempt before giving up.
106+
"""
107+
108+
# fmt: off
109+
# From https://github.com/rstudio/httpuv/blob/main/R/random_port.R
110+
unsafe_ports = [1, 7, 9, 11, 13, 15, 17, 19, 20, 21, 22, 23, 25, 37, 42, 43, 53, 77, 79, 87, 95, 101, 102, 103, 104, 109, 110, 111, 113, 115, 117, 119, 123, 135, 139, 143, 179, 389, 427, 465, 512, 513, 514, 515, 526, 530, 531, 532, 540, 548, 556, 563, 587, 601, 636, 993, 995, 2049, 3659, 4045, 6000, 6665, 6666, 6667, 6668, 6669, 6697]
111+
# fmt: on
112+
unusable = set([x for x in unsafe_ports if x >= min and x <= max])
113+
while n > 0:
114+
if (max - min + 1) <= len(unusable):
115+
break
116+
port = round(random.random() * (max - min) + min)
117+
if port in unusable:
118+
continue
119+
try:
120+
# See if we can successfully bind
121+
with socketserver.TCPServer(
122+
(host, port), socketserver.BaseRequestHandler, bind_and_activate=False
123+
) as s:
124+
s.server_bind()
125+
return port
126+
except Exception:
127+
n -= 1
128+
continue
129+
130+
raise RuntimeError("Failed to find a usable random port")
131+
132+
84133
# ==============================================================================
85134
# Private random stream
86135
# ==============================================================================
@@ -163,8 +212,7 @@ def run_coro_sync(coro: Awaitable[T]) -> T:
163212
value. If it does not complete, then it will throw a `RuntimeError`.
164213
165214
What it means to be "in fact synchronous": the coroutine must not yield
166-
control to the event loop. A coroutine may have an `await` expression in it,
167-
and that may call another function that has an `await`, but the chain will
215+
control to the event loop. A coroutine may have an `await` expression in it, and that may call another function that has an `await`, but the chain will
168216
only yield control if a `yield` statement bubbles through `await`s all the
169217
way up. For example, `await asyncio.sleep(0)` will have a `yield` which
170218
bubbles up to the next level. Note that a `yield` in a generator used the

tests/test_utils.py

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1-
import pytest
21
import random
3-
from shiny._utils import AsyncCallbacks, Callbacks, private_seed
4-
from typing import List
2+
import socketserver
3+
from typing import List, Set
4+
5+
import pytest
6+
from shiny._utils import AsyncCallbacks, Callbacks, private_seed, random_port
57

68

79
def test_randomness():
@@ -132,3 +134,26 @@ async def mutate_registrations():
132134
assert cb2.exec_count == 1 # Unregistered by previous invoke, not called again
133135
assert cb3.exec_count == 1 # once=True, so not called again
134136
assert cb4.exec_count == 1 # Registered during previous invoke(), was called
137+
138+
139+
def test_random_port():
140+
assert random_port(9000, 9000) == 9000
141+
142+
seen: Set[int] = set()
143+
# Ensure that 10 unique random ports are eventually generated. If not (e.g. if the
144+
# max port number is treated as exclusive instead of inclusive, say) then the while
145+
# loop will not exit and the test will timeout.
146+
while len(seen) < 10:
147+
seen.add(random_port(9001, 9010))
148+
149+
150+
def test_random_port_unusable():
151+
# 6000 is an unsafe port, make sure that it fails
152+
with pytest.raises(RuntimeError, match="Failed to find a usable random port"):
153+
random_port(6000, 6000)
154+
155+
156+
def test_random_port_starvation():
157+
with socketserver.TCPServer(("127.0.0.1", 9000), socketserver.BaseRequestHandler):
158+
with pytest.raises(RuntimeError, match="Failed to find a usable random port"):
159+
random_port(9000, 9000)

0 commit comments

Comments
 (0)