Skip to content

Commit 9bf5059

Browse files
pritamsoni-hsrkaushikb11Bordaawaelchlipre-commit-ci[bot]
authored andcommitted
feat: allow root path to run the app on /path (#14972)
* feat: add base path * uvicorn fix arg * Add prefix * update with base_path fix * replace base path with root path * Apply suggestions from code review Co-authored-by: Kaushik B <[email protected]> Co-authored-by: Kaushik B <[email protected]> Co-authored-by: Jirka Borovec <[email protected]> Co-authored-by: Adrian Wälchli <[email protected]> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> (cherry picked from commit 8008055)
1 parent a3c6ee6 commit 9bf5059

File tree

10 files changed

+66
-28
lines changed

10 files changed

+66
-28
lines changed

src/lightning_app/core/api.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,7 @@ def start_server(
347347
has_started_queue: Optional[Queue] = None,
348348
host="127.0.0.1",
349349
port=8000,
350+
root_path: str = "",
350351
uvicorn_run: bool = True,
351352
spec: Optional[List] = None,
352353
apis: Optional[List[HttpMethod]] = None,
@@ -383,6 +384,6 @@ def start_server(
383384

384385
register_global_routes()
385386

386-
uvicorn.run(app=fastapi_service, host=host, port=port, log_level="error")
387+
uvicorn.run(app=fastapi_service, host=host, port=port, log_level="error", root_path=root_path)
387388

388389
return refresher

src/lightning_app/core/app.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ def __init__(
4949
root: "lightning_app.LightningFlow",
5050
debug: bool = False,
5151
info: frontend.AppInfo = None,
52+
root_path: str = "",
5253
):
5354
"""The Lightning App, or App in short runs a tree of one or more components that interact to create end-to-end
5455
applications. There are two kinds of components: :class:`~lightning_app.core.flow.LightningFlow` and
@@ -67,6 +68,11 @@ def __init__(
6768
This can be helpful when reporting bugs on Lightning repo.
6869
info: Provide additional info about the app which will be used to update html title,
6970
description and image meta tags and specify any additional tags as list of html strings.
71+
root_path: Set this to `/path` if you want to run your app behind a proxy at `/path` leave empty for "/".
72+
For instance, if you want to run your app at `https://customdomain.com/myapp`,
73+
set `root_path` to `/myapp`.
74+
You can learn more about proxy `here <https://www.fortinet.com/resources/cyberglossary/proxy-server>`_.
75+
7076
7177
.. doctest::
7278
@@ -82,6 +88,7 @@ def __init__(
8288
Hello World!
8389
"""
8490

91+
self.root_path = root_path # when running behind a proxy
8592
_validate_root_flow(root)
8693
self._root = root
8794

@@ -140,7 +147,7 @@ def __init__(
140147

141148
# update index.html,
142149
# this should happen once for all apps before the ui server starts running.
143-
frontend.update_index_file_with_info(FRONTEND_DIR, info=info)
150+
frontend.update_index_file(FRONTEND_DIR, info=info, root_path=root_path)
144151

145152
def get_component_by_name(self, component_name: str):
146153
"""Returns the instance corresponding to the given component name."""

src/lightning_app/frontend/frontend.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,21 +15,24 @@ def __init__(self) -> None:
1515
self.flow: Optional["LightningFlow"] = None
1616

1717
@abstractmethod
18-
def start_server(self, host: str, port: int) -> None:
18+
def start_server(self, host: str, port: int, root_path: str = "") -> None:
1919
"""Start the process that serves the UI at the given hostname and port number.
2020
2121
Arguments:
2222
host: The hostname where the UI will be served. This gets determined by the dispatcher (e.g., cloud),
2323
but defaults to localhost when running locally.
2424
port: The port number where the UI will be served. This gets determined by the dispatcher, which by default
2525
chooses any free port when running locally.
26+
root_path: root_path for the server if app in exposed via a proxy at `/<root_path>`
27+
2628
2729
Example:
30+
2831
An custom implementation could look like this:
2932
3033
.. code-block:: python
3134
32-
def start_server(self, host, port):
35+
def start_server(self, host, port, root_path=""):
3336
self._process = subprocess.Popen(["flask", "run" "--host", host, "--port", str(port)])
3437
"""
3538

src/lightning_app/frontend/panel/panel_frontend.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ def __init__(self, entry_point: Callable | str):
9595
self._log_files: dict[str, TextIO] = {}
9696
_logger.debug("PanelFrontend Frontend with %s is initialized.", entry_point)
9797

98-
def start_server(self, host: str, port: int) -> None:
98+
def start_server(self, host: str, port: int, root_path: str = "") -> None:
9999
_logger.debug("PanelFrontend starting server on %s:%s", host, port)
100100

101101
# 1: Prepare environment variables and arguments.

src/lightning_app/frontend/web.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ class StaticWebFrontend(Frontend):
2020
2121
Arguments:
2222
serve_dir: A local directory to serve files from. This directory should at least contain a file `index.html`.
23+
root_path: A path prefix when routing traffic from behind a proxy at `/<root_path>`
2324
2425
Example:
2526
@@ -36,7 +37,7 @@ def __init__(self, serve_dir: str) -> None:
3637
self.serve_dir = serve_dir
3738
self._process: Optional[mp.Process] = None
3839

39-
def start_server(self, host: str, port: int) -> None:
40+
def start_server(self, host: str, port: int, root_path: str = "") -> None:
4041
log_file = str(get_frontend_logfile())
4142
self._process = mp.Process(
4243
target=start_server,
@@ -46,6 +47,7 @@ def start_server(self, host: str, port: int) -> None:
4647
serve_dir=self.serve_dir,
4748
path=f"/{self.flow.name}",
4849
log_file=log_file,
50+
root_path=root_path,
4951
),
5052
)
5153
self._process.start()
@@ -61,7 +63,9 @@ def healthz():
6163
return {"status": "ok"}
6264

6365

64-
def start_server(serve_dir: str, host: str = "localhost", port: int = -1, path: str = "/", log_file: str = "") -> None:
66+
def start_server(
67+
serve_dir: str, host: str = "localhost", port: int = -1, path: str = "/", log_file: str = "", root_path: str = ""
68+
) -> None:
6569
if port == -1:
6670
port = find_free_network_port()
6771
fastapi_service = FastAPI()
@@ -76,11 +80,11 @@ def start_server(serve_dir: str, host: str = "localhost", port: int = -1, path:
7680
# trailing / is required for urljoin to properly join the path. In case of
7781
# multiple trailing /, urljoin removes them
7882
fastapi_service.get(urljoin(f"{path}/", "healthz"), status_code=200)(healthz)
79-
fastapi_service.mount(path, StaticFiles(directory=serve_dir, html=True), name="static")
83+
fastapi_service.mount(urljoin(path, root_path), StaticFiles(directory=serve_dir, html=True), name="static")
8084

8185
log_config = _get_log_config(log_file) if log_file else uvicorn.config.LOGGING_CONFIG
8286

83-
uvicorn.run(app=fastapi_service, host=host, port=port, log_config=log_config)
87+
uvicorn.run(app=fastapi_service, host=host, port=port, log_config=log_config, root_path=root_path)
8488

8589

8690
def _get_log_config(log_file: str) -> dict:
@@ -115,7 +119,8 @@ def _get_log_config(log_file: str) -> dict:
115119
if __name__ == "__main__": # pragma: no-cover
116120
parser = ArgumentParser()
117121
parser.add_argument("serve_dir", type=str)
122+
parser.add_argument("root_path", type=str, default="")
118123
parser.add_argument("--host", type=str, default="localhost")
119124
parser.add_argument("--port", type=int, default=-1)
120125
args = parser.parse_args()
121-
start_server(serve_dir=args.serve_dir, host=args.host, port=args.port)
126+
start_server(serve_dir=args.serve_dir, host=args.host, port=args.port, root_path=args.root_path)

src/lightning_app/runners/multiprocess.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ def dispatch(self, *args: Any, on_before_run: Optional[Callable] = None, **kwarg
8383
api_delta_queue=self.app.api_delta_queue,
8484
has_started_queue=has_started_queue,
8585
spec=extract_metadata_from_app(self.app),
86+
root_path=self.app.root_path,
8687
)
8788
server_proc = multiprocessing.Process(target=start_server, kwargs=kwargs)
8889
self.processes["server"] = server_proc

src/lightning_app/runners/singleprocess.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ def dispatch(self, *args, on_before_run: Optional[Callable] = None, **kwargs: An
3434
api_delta_queue=self.app.api_delta_queue,
3535
has_started_queue=has_started_queue,
3636
spec=extract_metadata_from_app(self.app),
37+
root_path=self.app.root_path,
3738
)
3839
server_proc = mp.Process(target=start_server, kwargs=kwargs)
3940
self.processes["server"] = server_proc

src/lightning_app/utilities/frontend.py

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ class AppInfo:
1414
meta_tags: Optional[List[str]] = None
1515

1616

17-
def update_index_file_with_info(ui_root: str, info: AppInfo = None) -> None:
17+
def update_index_file(ui_root: str, info: Optional[AppInfo] = None, root_path: str = "") -> None:
1818
import shutil
1919
from pathlib import Path
2020

@@ -27,19 +27,27 @@ def update_index_file_with_info(ui_root: str, info: AppInfo = None) -> None:
2727
# revert index.html in case it was modified after creating original.html
2828
shutil.copyfile(original_file, entry_file)
2929

30-
if not info:
31-
return
30+
if info:
31+
with original_file.open() as f:
32+
original = f.read()
3233

33-
original = ""
34+
with entry_file.open("w") as f:
35+
f.write(_get_updated_content(original=original, root_path=root_path, info=info))
3436

35-
with original_file.open() as f:
36-
original = f.read()
37+
if root_path:
38+
root_path_without_slash = root_path.replace("/", "", 1) if root_path.startswith("/") else root_path
39+
src_dir = Path(ui_root)
40+
dst_dir = src_dir / root_path_without_slash
3741

38-
with entry_file.open("w") as f:
39-
f.write(_get_updated_content(original=original, info=info))
42+
if dst_dir.exists():
43+
shutil.rmtree(dst_dir, ignore_errors=True)
44+
# copy everything except the current root_path, this is to fix a bug if user specifies
45+
# /abc at first and then /abc/def, server don't start
46+
# ideally we should copy everything except custom root_path that user passed.
47+
shutil.copytree(src_dir, dst_dir, ignore=shutil.ignore_patterns(f"{root_path_without_slash}*"))
4048

4149

42-
def _get_updated_content(original: str, info: AppInfo) -> str:
50+
def _get_updated_content(original: str, root_path: str, info: AppInfo) -> str:
4351
soup = BeautifulSoup(original, "html.parser")
4452

4553
# replace favicon
@@ -56,6 +64,11 @@ def _get_updated_content(original: str, info: AppInfo) -> str:
5664
soup.find("meta", {"property": "og:image"}).attrs["content"] = info.image
5765

5866
if info.meta_tags:
59-
soup.find("head").append(*[BeautifulSoup(meta, "html.parser") for meta in info.meta_tags])
67+
for meta in info.meta_tags:
68+
soup.find("head").append(BeautifulSoup(meta, "html.parser"))
6069

61-
return str(soup)
70+
if root_path:
71+
# this will be used by lightning app ui to add root_path to add requests
72+
soup.find("head").append(BeautifulSoup(f'<script>window.app_prefix="{root_path}"</script>', "html.parser"))
73+
74+
return str(soup).replace("/static", f"{root_path}/static")

tests/tests_app/core/test_lightning_api.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,7 @@ def test_start_server_started():
358358
has_started_queue=has_started_queue,
359359
api_response_queue=api_response_queue,
360360
port=1111,
361+
root_path="",
361362
)
362363

363364
server_proc = mp.Process(target=start_server, kwargs=kwargs)
@@ -384,6 +385,7 @@ def test_start_server_info_message(ui_refresher, uvicorn_run, caplog, monkeypatc
384385
api_delta_queue=api_delta_queue,
385386
has_started_queue=has_started_queue,
386387
api_response_queue=api_response_queue,
388+
root_path="test",
387389
)
388390

389391
monkeypatch.setattr(api, "logger", logging.getLogger())
@@ -394,7 +396,7 @@ def test_start_server_info_message(ui_refresher, uvicorn_run, caplog, monkeypatc
394396
assert "Your app has started. View it in your browser: http://0.0.0.1:1111/view" in caplog.text
395397

396398
ui_refresher.assert_called_once()
397-
uvicorn_run.assert_called_once_with(host="0.0.0.1", port=1111, log_level="error", app=mock.ANY)
399+
uvicorn_run.assert_called_once_with(host="0.0.0.1", port=1111, log_level="error", app=mock.ANY, root_path="test")
398400

399401

400402
class InputRequestModel(BaseModel):

tests/tests_app/frontend/test_web.py

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ def test_start_stop_server_through_frontend(process_mock):
3939
"serve_dir": ".",
4040
"path": "/root.my.flow",
4141
"log_file": os.path.join(log_file_root, "frontend", "logs.log"),
42+
"root_path": "",
4243
},
4344
)
4445
process_mock().start.assert_called_once()
@@ -47,24 +48,28 @@ def test_start_stop_server_through_frontend(process_mock):
4748

4849

4950
@mock.patch("lightning_app.frontend.web.uvicorn")
50-
def test_start_server_through_function(uvicorn_mock, tmpdir, monkeypatch):
51+
@pytest.mark.parametrize("root_path", ["", "/base"])
52+
def test_start_server_through_function(uvicorn_mock, tmpdir, monkeypatch, root_path):
5153
FastAPIMock = MagicMock()
5254
FastAPIMock.mount = MagicMock()
5355
FastAPIGetDecoratorMock = MagicMock()
5456
FastAPIMock.get.return_value = FastAPIGetDecoratorMock
5557
monkeypatch.setattr(lightning_app.frontend.web, "FastAPI", MagicMock(return_value=FastAPIMock))
5658

57-
lightning_app.frontend.web.start_server(serve_dir=tmpdir, host="myhost", port=1000, path="/test-flow")
58-
uvicorn_mock.run.assert_called_once_with(app=ANY, host="myhost", port=1000, log_config=ANY)
59-
FastAPIMock.mount.assert_called_once_with("/test-flow", ANY, name="static")
59+
lightning_app.frontend.web.start_server(
60+
serve_dir=tmpdir, host="myhost", port=1000, path="/test-flow", root_path=root_path
61+
)
62+
uvicorn_mock.run.assert_called_once_with(app=ANY, host="myhost", port=1000, log_config=ANY, root_path=root_path)
63+
64+
FastAPIMock.mount.assert_called_once_with(root_path or "/test-flow", ANY, name="static")
6065
FastAPIMock.get.assert_called_once_with("/test-flow/healthz", status_code=200)
6166

6267
FastAPIGetDecoratorMock.assert_called_once_with(healthz)
6368

6469
# path has default value "/"
6570
FastAPIMock.mount = MagicMock()
66-
lightning_app.frontend.web.start_server(serve_dir=tmpdir, host="myhost", port=1000)
67-
FastAPIMock.mount.assert_called_once_with("/", ANY, name="static")
71+
lightning_app.frontend.web.start_server(serve_dir=tmpdir, host="myhost", port=1000, root_path=root_path)
72+
FastAPIMock.mount.assert_called_once_with(root_path or "/", ANY, name="static")
6873

6974

7075
def test_healthz():

0 commit comments

Comments
 (0)