Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 42 additions & 1 deletion projects/fal/src/fal/cli/deploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import argparse
import json
import urllib.parse

from fal.api.client import SyncServerlessClient

Expand Down Expand Up @@ -151,6 +152,44 @@ def _apps_command_hint(
return hint


_UNTESTABLE_PLAYGROUND_SUFFIXES = {"cancel", "health", "object_info"}


def _deployed_app_playground_url(url: str) -> str | None:
"""Map a server-provided model URL to its owning app's Playground tab."""
parsed = urllib.parse.urlsplit(url)
path_segments = parsed.path.split("/")
if (
parsed.scheme not in {"http", "https"}
or not parsed.netloc
or path_segments[:2] != ["", "models"]
):
return url

endpoint_segments = path_segments[2:]
if endpoint_segments and endpoint_segments[-1] == "":
endpoint_segments = endpoint_segments[:-1]
if len(endpoint_segments) < 2 or any(not segment for segment in endpoint_segments):
return url

decoded_segments = [urllib.parse.unquote(segment) for segment in endpoint_segments]
if (
len(decoded_segments) > 2
and decoded_segments[-1] in _UNTESTABLE_PLAYGROUND_SUFFIXES
):
return None
Comment thread
cursor[bot] marked this conversation as resolved.

owner, app_name = decoded_segments[:2]
formatted_endpoint = "/".join(decoded_segments)
path = (
f"/dashboard/apps/{urllib.parse.quote(owner, safe='')}"
f"/{urllib.parse.quote(app_name, safe='')}"
"/testing/playground"
)
query = f"endpoint={urllib.parse.quote(formatted_endpoint, safe='')}"
return urllib.parse.urlunsplit((parsed.scheme, parsed.netloc, path, query, ""))


def _render_deploy_result(
args, res, *, is_first_deploy: bool = False, team: str | None = None
) -> None:
Expand Down Expand Up @@ -237,7 +276,9 @@ def _render_deploy_result(
lines.append(f"{section_icon} Playground ", style="bold")
lines.append("(open in browser)\n", style="dim")
for url in res.urls.get("playground", {}).values():
lines.append(f" {url}\n", style="cyan")
app_url = _deployed_app_playground_url(url)
if app_url is not None:
lines.append(f" {app_url}\n", style="cyan")

# API Endpoints section
if URL_OUTPUT == "all":
Expand Down
56 changes: 55 additions & 1 deletion projects/fal/tests/unit/cli/test_deploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from fal.api import Options
from fal.api.api import IsolatedFunction
from fal.cli._utils import AppData
from fal.cli.deploy import _deploy
from fal.cli.deploy import _deploy, _render_deploy_result
from fal.cli.deploy_check import (
_build_deployment_check_summary,
_diff_table,
Expand Down Expand Up @@ -648,6 +648,60 @@ def mock_args(
return args


@pytest.mark.parametrize("app_alias", ["image-app", "image-app--staging", "health"])
def test_deploy_output_links_testable_routes_to_the_owning_app_playground(
monkeypatch, app_alias
):
monkeypatch.setattr("fal.flags.URL_OUTPUT", "playground")
origin = "https://shark.fal.dev"
app_model_path = f"{origin}/models/team-owner/{app_alias}"
result = SimpleNamespace(
revision="rev",
app_name="image-app",
auth_mode="private",
urls={
"playground": {
"/": f"{app_model_path}/",
"/v2/generate": f"{app_model_path}/v2/generate",
"/stream": f"{app_model_path}/stream",
"/ws": f"{app_model_path}/ws",
"/realtime": f"{app_model_path}/realtime",
"/sse": f"{app_model_path}/sse",
"/health": f"{app_model_path}/health",
"/v2/generate/cancel": f"{app_model_path}/v2/generate/cancel",
"/stream/cancel": f"{app_model_path}/stream/cancel",
"/object_info": f"{app_model_path}/object_info",
},
"sync": {},
"async": {},
},
log_url=f"{origin}/logs/rev",
)
args = mock_args(app_ref=("app.py", "App"))
args.console = Console(
record=True, width=240, force_terminal=False, color_system=None
)

_render_deploy_result(args, result)

rendered_urls = [
line.strip()
for line in args.console.export_text().splitlines()
if line.strip().startswith("https://")
]
app_playground = (
f"{origin}/dashboard/apps/team-owner/{app_alias}/testing/playground"
)
assert rendered_urls == [
f"{app_playground}?endpoint=team-owner%2F{app_alias}",
f"{app_playground}?endpoint=team-owner%2F{app_alias}%2Fv2%2Fgenerate",
f"{app_playground}?endpoint=team-owner%2F{app_alias}%2Fstream",
f"{app_playground}?endpoint=team-owner%2F{app_alias}%2Fws",
f"{app_playground}?endpoint=team-owner%2F{app_alias}%2Frealtime",
f"{app_playground}?endpoint=team-owner%2F{app_alias}%2Fsse",
]


@patch("fal.cli._utils.find_pyproject_toml", return_value="pyproject.toml")
@patch("fal.cli._utils.parse_pyproject_toml")
@patch("fal.api.deploy.execute_prepared_deployment")
Expand Down
Loading