Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: allow root path to run the app on /path #14972

Merged
merged 24 commits into from Oct 7, 2022
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/lightning_app/core/api.py
Expand Up @@ -346,6 +346,7 @@ def start_server(
has_started_queue: Optional[Queue] = None,
host="127.0.0.1",
port=8000,
root_path: str = "",
uvicorn_run: bool = True,
spec: Optional[List] = None,
apis: Optional[List[HttpMethod]] = None,
Expand Down Expand Up @@ -382,6 +383,6 @@ def start_server(

register_global_routes()

uvicorn.run(app=fastapi_service, host=host, port=port, log_level="error")
uvicorn.run(app=fastapi_service, host=host, port=port, log_level="error", root_path=root_path)

return refresher
7 changes: 6 additions & 1 deletion src/lightning_app/core/app.py
Expand Up @@ -49,6 +49,7 @@ def __init__(
root: "lightning_app.LightningFlow",
debug: bool = False,
info: frontend.AppInfo = None,
root_path: str = "",
kaushikb11 marked this conversation as resolved.
Show resolved Hide resolved
):
"""The Lightning App, or App in short runs a tree of one or more components that interact to create end-to-end
applications. There are two kinds of components: :class:`~lightning_app.core.flow.LightningFlow` and
Expand All @@ -67,6 +68,9 @@ def __init__(
This can be helpful when reporting bugs on Lightning repo.
info: Provide additional info about the app which will be used to update html title,
description and image meta tags and specify any additional tags as list of html strings.
root_path: Set this to `/path` if you want to run your app behind a proxy at `/path` leave empty for "/".
kaushikb11 marked this conversation as resolved.
Show resolved Hide resolved
In most cases users should avoid adding this, or the app won't start.


.. doctest::

Expand All @@ -82,6 +86,7 @@ def __init__(
Hello World!
"""

self.root_path = root_path # when running behind a proxy
_validate_root_flow(root)
self._root = root

Expand Down Expand Up @@ -140,7 +145,7 @@ def __init__(

# update index.html,
# this should happen once for all apps before the ui server starts running.
frontend.update_index_file_with_info(FRONTEND_DIR, info=info)
frontend.update_index_file_with_info_and_root_path(FRONTEND_DIR, info=info, root_path=root_path)

def get_component_by_name(self, component_name: str):
"""Returns the instance corresponding to the given component name."""
Expand Down
6 changes: 3 additions & 3 deletions src/lightning_app/frontend/frontend.py
Expand Up @@ -15,21 +15,21 @@ def __init__(self) -> None:
self.flow: Optional["LightningFlow"] = None

@abstractmethod
def start_server(self, host: str, port: int) -> None:
def start_server(self, host: str, port: int, root_path: str = "") -> None:
"""Start the process that serves the UI at the given hostname and port number.

Arguments:
host: The hostname where the UI will be served. This gets determined by the dispatcher (e.g., cloud),
but defaults to localhost when running locally.
port: The port number where the UI will be served. This gets determined by the dispatcher, which by default
chooses any free port when running locally.

root_path: root_path for the server if app in exposed via a proxy at `/<root_path>`
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
root_path: root_path for the server if app in exposed via a proxy at `/<root_path>`
root_path: root_path for the server if app in exposed via a proxy at `/<root_path>`

You need to leave an empty line

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we include an example like https://domain.com/<root_path>/my_page to visualize what it means.

Example:
An custom implementation could look like this:

.. code-block:: python

def start_server(self, host, port):
def start_server(self, host, port, root_path=""):
self._process = subprocess.Popen(["flask", "run" "--host", host, "--port", str(port)])
"""

Expand Down
2 changes: 1 addition & 1 deletion src/lightning_app/frontend/panel/panel_frontend.py
Expand Up @@ -95,7 +95,7 @@ def __init__(self, entry_point: Callable | str):
self._log_files: dict[str, TextIO] = {}
_logger.debug("PanelFrontend Frontend with %s is initialized.", entry_point)

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

# 1: Prepare environment variables and arguments.
Expand Down
15 changes: 10 additions & 5 deletions src/lightning_app/frontend/web.py
Expand Up @@ -20,6 +20,7 @@ class StaticWebFrontend(Frontend):

Arguments:
serve_dir: A local directory to serve files from. This directory should at least contain a file `index.html`.
root_path: A path prefix when routing traffic from behind a proxy at `<root_path>`
kaushikb11 marked this conversation as resolved.
Show resolved Hide resolved

Example:

Expand All @@ -36,7 +37,7 @@ def __init__(self, serve_dir: str) -> None:
self.serve_dir = serve_dir
self._process: Optional[mp.Process] = None

def start_server(self, host: str, port: int) -> None:
def start_server(self, host: str, port: int, root_path: str = "") -> None:
log_file = str(get_frontend_logfile())
self._process = mp.Process(
target=start_server,
Expand All @@ -46,6 +47,7 @@ def start_server(self, host: str, port: int) -> None:
serve_dir=self.serve_dir,
path=f"/{self.flow.name}",
log_file=log_file,
root_path=root_path,
),
)
self._process.start()
Expand All @@ -61,7 +63,9 @@ def healthz():
return {"status": "ok"}


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

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

uvicorn.run(app=fastapi_service, host=host, port=port, log_config=log_config)
uvicorn.run(app=fastapi_service, host=host, port=port, log_config=log_config, root_path=root_path)


def _get_log_config(log_file: str) -> dict:
Expand Down Expand Up @@ -115,7 +119,8 @@ def _get_log_config(log_file: str) -> dict:
if __name__ == "__main__": # pragma: no-cover
parser = ArgumentParser()
parser.add_argument("serve_dir", type=str)
parser.add_argument("root_path", type=str, default="")
parser.add_argument("--host", type=str, default="localhost")
parser.add_argument("--port", type=int, default=-1)
args = parser.parse_args()
start_server(serve_dir=args.serve_dir, host=args.host, port=args.port)
start_server(serve_dir=args.serve_dir, host=args.host, port=args.port, root_path=args.root_path)
1 change: 1 addition & 0 deletions src/lightning_app/runners/multiprocess.py
Expand Up @@ -83,6 +83,7 @@ def dispatch(self, *args: Any, on_before_run: Optional[Callable] = None, **kwarg
api_delta_queue=self.app.api_delta_queue,
has_started_queue=has_started_queue,
spec=extract_metadata_from_app(self.app),
root_path=self.app.root_path,
)
server_proc = multiprocessing.Process(target=start_server, kwargs=kwargs)
self.processes["server"] = server_proc
Expand Down
1 change: 1 addition & 0 deletions src/lightning_app/runners/singleprocess.py
Expand Up @@ -34,6 +34,7 @@ def dispatch(self, *args, on_before_run: Optional[Callable] = None, **kwargs: An
api_delta_queue=self.app.api_delta_queue,
has_started_queue=has_started_queue,
spec=extract_metadata_from_app(self.app),
root_path=self.app.root_path,
)
server_proc = mp.Process(target=start_server, kwargs=kwargs)
self.processes["server"] = server_proc
Expand Down
40 changes: 30 additions & 10 deletions src/lightning_app/utilities/frontend.py
Expand Up @@ -14,10 +14,15 @@ class AppInfo:
meta_tags: Optional[List[str]] = None


def update_index_file_with_info(ui_root: str, info: AppInfo = None) -> None:
def update_index_file_with_info_and_root_path(
kaushikb11 marked this conversation as resolved.
Show resolved Hide resolved
ui_root: str, info: Optional[AppInfo] = None, root_path: str = ""
) -> None:
import shutil
from pathlib import Path

def rewrite_static_with_root_path(content: str) -> str:
return content.replace("/static", f"{root_path}/static")

entry_file = Path(ui_root) / "index.html"
original_file = Path(ui_root) / "index.original.html"

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

if not info:
return
if info:
with original_file.open() as f:
original = f.read()

original = ""
with entry_file.open("w") as f:
f.write(
rewrite_static_with_root_path(_get_updated_content(original=original, root_path=root_path, info=info))
kaushikb11 marked this conversation as resolved.
Show resolved Hide resolved
)

with original_file.open() as f:
original = f.read()
if root_path:
root_path_without_slash = root_path.replace("/", "", 1) if root_path.startswith("/") else root_path
src_dir = Path(ui_root)
dst_dir = src_dir / root_path_without_slash

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


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

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

if info.meta_tags:
soup.find("head").append(*[BeautifulSoup(meta, "html.parser") for meta in info.meta_tags])
for meta in info.meta_tags:
soup.find("head").append(BeautifulSoup(meta, "html.parser"))

if root_path:
# this will be used by lightning app ui to add root_path to add requests
soup.find("head").append(BeautifulSoup(f'<script>window.app_prefix="{root_path}"</script>', "html.parser"))

return str(soup)
4 changes: 3 additions & 1 deletion tests/tests_app/core/test_lightning_api.py
Expand Up @@ -359,6 +359,7 @@ def test_start_server_started():
has_started_queue=has_started_queue,
api_response_queue=api_response_queue,
port=1111,
root_path="",
)

server_proc = mp.Process(target=start_server, kwargs=kwargs)
Expand All @@ -385,6 +386,7 @@ def test_start_server_info_message(ui_refresher, uvicorn_run, caplog, monkeypatc
api_delta_queue=api_delta_queue,
has_started_queue=has_started_queue,
api_response_queue=api_response_queue,
root_path="test",
)

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

ui_refresher.assert_called_once()
uvicorn_run.assert_called_once_with(host="0.0.0.1", port=1111, log_level="error", app=mock.ANY)
uvicorn_run.assert_called_once_with(host="0.0.0.1", port=1111, log_level="error", app=mock.ANY, root_path="test")


class InputRequestModel(BaseModel):
Expand Down
15 changes: 10 additions & 5 deletions tests/tests_app/frontend/test_web.py
Expand Up @@ -39,6 +39,7 @@ def test_start_stop_server_through_frontend(process_mock):
"serve_dir": ".",
"path": "/root.my.flow",
"log_file": os.path.join(log_file_root, "frontend", "logs.log"),
"root_path": "",
},
)
process_mock().start.assert_called_once()
Expand All @@ -54,17 +55,21 @@ def test_start_server_through_function(uvicorn_mock, tmpdir, monkeypatch):
FastAPIMock.get.return_value = FastAPIGetDecoratorMock
monkeypatch.setattr(lightning_app.frontend.web, "FastAPI", MagicMock(return_value=FastAPIMock))

lightning_app.frontend.web.start_server(serve_dir=tmpdir, host="myhost", port=1000, path="/test-flow")
uvicorn_mock.run.assert_called_once_with(app=ANY, host="myhost", port=1000, log_config=ANY)
FastAPIMock.mount.assert_called_once_with("/test-flow", ANY, name="static")
root_path = "/base"
kaushikb11 marked this conversation as resolved.
Show resolved Hide resolved

lightning_app.frontend.web.start_server(
serve_dir=tmpdir, host="myhost", port=1000, path="/test-flow", root_path=root_path
)
uvicorn_mock.run.assert_called_once_with(app=ANY, host="myhost", port=1000, log_config=ANY, root_path=root_path)
FastAPIMock.mount.assert_called_once_with(root_path, ANY, name="static")
FastAPIMock.get.assert_called_once_with("/test-flow/healthz", status_code=200)

FastAPIGetDecoratorMock.assert_called_once_with(healthz)

# path has default value "/"
FastAPIMock.mount = MagicMock()
lightning_app.frontend.web.start_server(serve_dir=tmpdir, host="myhost", port=1000)
FastAPIMock.mount.assert_called_once_with("/", ANY, name="static")
lightning_app.frontend.web.start_server(serve_dir=tmpdir, host="myhost", port=1000, root_path=root_path)
FastAPIMock.mount.assert_called_once_with(root_path, ANY, name="static")


def test_healthz():
Expand Down