diff --git a/src/prefect/cli/__init__.py b/src/prefect/cli/__init__.py index 8b94c6324b82..d328400854a2 100644 --- a/src/prefect/cli/__init__.py +++ b/src/prefect/cli/__init__.py @@ -12,6 +12,7 @@ import prefect.cli.shell import prefect.cli.concurrency_limit import prefect.cli.config +import prefect.cli.dashboard import prefect.cli.deploy import prefect.cli.deployment import prefect.cli.dev diff --git a/src/prefect/cli/dashboard.py b/src/prefect/cli/dashboard.py new file mode 100644 index 000000000000..9ead1988490c --- /dev/null +++ b/src/prefect/cli/dashboard.py @@ -0,0 +1,40 @@ +import webbrowser + +from prefect.cli._types import PrefectTyper +from prefect.cli._utilities import exit_with_success +from prefect.cli.cloud import get_current_workspace +from prefect.cli.root import app +from prefect.client.cloud import get_cloud_client +from prefect.settings import PREFECT_UI_URL +from prefect.utilities.asyncutils import run_sync_in_worker_thread + +dashboard_app = PrefectTyper( + name="dashboard", + help="Commands for interacting with the Prefect UI.", +) +app.add_typer(dashboard_app) + + +@dashboard_app.command() +async def open(): + """ + Open the Prefect UI in the browser. + """ + + if not (ui_url := PREFECT_UI_URL.value()): + raise RuntimeError( + "`PREFECT_UI_URL` must be set to the URL of a running Prefect server or Prefect Cloud workspace." + ) + + await run_sync_in_worker_thread(webbrowser.open_new_tab, ui_url) + + async with get_cloud_client() as client: + current_workspace = get_current_workspace(await client.read_workspaces()) + + destination = ( + f"{current_workspace.account_handle}/{current_workspace.workspace_handle}" + if current_workspace + else ui_url + ) + + exit_with_success(f"Opened {destination!r} in browser.") diff --git a/tests/cli/test_cloud.py b/tests/cli/test_cloud.py index 9d748e4ba635..6574e795de59 100644 --- a/tests/cli/test_cloud.py +++ b/tests/cli/test_cloud.py @@ -1599,69 +1599,3 @@ def test_webhook_methods_with_invalid_uuid(): ["cloud", "webhook", cmd, bad_webhook_id], expected_code=2, ) - - -def test_open_current_workspace_in_browser_success(mock_webbrowser, respx_mock): - foo_workspace = gen_test_workspace(account_handle="test", workspace_handle="foo") - - save_profiles( - ProfilesCollection( - [ - Profile( - name="logged-in-profile", - settings={ - PREFECT_API_URL: foo_workspace.api_url(), - PREFECT_API_KEY: "foo", - }, - ) - ], - active="logged-in-profile", - ) - ) - - respx_mock.get(PREFECT_CLOUD_API_URL.value() + "/me/workspaces").mock( - return_value=httpx.Response( - status.HTTP_200_OK, - json=[foo_workspace.model_dump(mode="json")], - ) - ) - - with use_profile("logged-in-profile"): - invoke_and_assert( - ["cloud", "open"], - expected_code=0, - expected_output_contains=f"Opened {foo_workspace.handle!r} in browser.", - ) - - mock_webbrowser.open_new_tab.assert_called_with(foo_workspace.ui_url()) - - -def test_open_current_workspace_in_browser_failure_no_workspace_set(respx_mock): - save_profiles( - ProfilesCollection( - [ - Profile( - name="logged-in-profile", - settings={ - PREFECT_API_URL: "https://api.prefect.io", - PREFECT_API_KEY: "foo", - }, - ) - ], - active="logged-in-profile", - ) - ) - - respx_mock.get(PREFECT_CLOUD_API_URL.value() + "/me/workspaces").mock( - return_value=httpx.Response( - status.HTTP_200_OK, - json=[], - ) - ) - - with use_profile("logged-in-profile"): - invoke_and_assert( - ["cloud", "open"], - expected_code=1, - expected_output_contains="There is no current workspace set - set one with", - ) diff --git a/tests/cli/test_dashboard.py b/tests/cli/test_dashboard.py new file mode 100644 index 000000000000..77f3020e4f41 --- /dev/null +++ b/tests/cli/test_dashboard.py @@ -0,0 +1,104 @@ +import uuid +from unittest.mock import MagicMock + +import httpx +import pytest +from starlette import status + +from prefect.client.schemas import Workspace +from prefect.context import use_profile +from prefect.settings import ( + PREFECT_API_KEY, + PREFECT_API_URL, + PREFECT_CLOUD_API_URL, + Profile, + ProfilesCollection, + save_profiles, +) +from prefect.testing.cli import invoke_and_assert + + +def gen_test_workspace(**kwargs) -> Workspace: + defaults = { + "account_id": uuid.uuid4(), + "account_name": "account name", + "account_handle": "account-handle", + "workspace_id": uuid.uuid4(), + "workspace_name": "workspace name", + "workspace_handle": "workspace-handle", + "workspace_description": "workspace description", + } + defaults.update(kwargs) + return Workspace(**defaults) + + +@pytest.fixture +def mock_webbrowser(monkeypatch): + mock = MagicMock() + monkeypatch.setattr("prefect.cli.dashboard.webbrowser", mock) + yield mock + + +def test_open_current_workspace_in_browser_success(mock_webbrowser, respx_mock): + foo_workspace = gen_test_workspace(account_handle="test", workspace_handle="foo") + + save_profiles( + ProfilesCollection( + [ + Profile( + name="logged-in-profile", + settings={ + PREFECT_API_URL: foo_workspace.api_url(), + PREFECT_API_KEY: "foo", + }, + ) + ], + active="logged-in-profile", + ) + ) + + respx_mock.get(PREFECT_CLOUD_API_URL.value() + "/me/workspaces").mock( + return_value=httpx.Response( + status.HTTP_200_OK, + json=[foo_workspace.model_dump(mode="json")], + ) + ) + with use_profile("logged-in-profile"): + invoke_and_assert( + ["dashboard", "open"], + expected_code=0, + expected_output_contains=f"Opened {foo_workspace.handle!r} in browser.", + ) + + mock_webbrowser.open_new_tab.assert_called_with(foo_workspace.ui_url()) + + +@pytest.mark.usefixtures("mock_webbrowser") +@pytest.mark.parametrize("api_url", ["http://localhost:4200", "https://api.prefect.io"]) +def test_open_current_workspace_in_browser_failure_no_workspace_set( + respx_mock, api_url +): + save_profiles( + ProfilesCollection( + [ + Profile( + name="logged-in-profile", + settings={ + PREFECT_API_URL: api_url, + PREFECT_API_KEY: "foo", + }, + ) + ], + active="logged-in-profile", + ) + ) + + respx_mock.get(PREFECT_CLOUD_API_URL.value() + "/me/workspaces").mock( + return_value=httpx.Response( + status.HTTP_200_OK, + json=[], + ) + ) + + with use_profile("logged-in-profile"): + invoke_and_assert(["dashboard", "open"], expected_code=0)