Skip to content

Commit 8008055

Browse files
pritamsoni-hsrkaushikb11Bordaawaelchlipre-commit-ci[bot]
authored
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>
1 parent 8ec7ffb commit 8008055

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
@@ -348,6 +348,7 @@ def start_server(
348348
has_started_queue: Optional[Queue] = None,
349349
host="127.0.0.1",
350350
port=8000,
351+
root_path: str = "",
351352
uvicorn_run: bool = True,
352353
spec: Optional[List] = None,
353354
apis: Optional[List[HttpMethod]] = None,
@@ -384,6 +385,6 @@ def start_server(
384385

385386
register_global_routes()
386387

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

389390
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
@@ -359,6 +359,7 @@ def test_start_server_started():
359359
has_started_queue=has_started_queue,
360360
api_response_queue=api_response_queue,
361361
port=1111,
362+
root_path="",
362363
)
363364

364365
server_proc = mp.Process(target=start_server, kwargs=kwargs)
@@ -385,6 +386,7 @@ def test_start_server_info_message(ui_refresher, uvicorn_run, caplog, monkeypatc
385386
api_delta_queue=api_delta_queue,
386387
has_started_queue=has_started_queue,
387388
api_response_queue=api_response_queue,
389+
root_path="test",
388390
)
389391

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

397399
ui_refresher.assert_called_once()
398-
uvicorn_run.assert_called_once_with(host="0.0.0.1", port=1111, log_level="error", app=mock.ANY)
400+
uvicorn_run.assert_called_once_with(host="0.0.0.1", port=1111, log_level="error", app=mock.ANY, root_path="test")
399401

400402

401403
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)