From c6f4c70e48ce3bb6e17bc8f61f5f91246d9adca2 Mon Sep 17 00:00:00 2001 From: Carlos Date: Tue, 19 May 2026 13:25:41 +0200 Subject: [PATCH 1/2] ENG-9522: Fix CLI: Lists projects as "Unknown" --- .../src/reflex_cli/utils/hosting.py | 18 +- .../src/reflex_cli/v2/cli.py | 95 +++-- tests/units/reflex_cli/utils/test_hosting.py | 38 ++ tests/units/reflex_cli/v2/test_cli.py | 326 ++++++++++++++++++ 4 files changed, 421 insertions(+), 56 deletions(-) diff --git a/packages/reflex-hosting-cli/src/reflex_cli/utils/hosting.py b/packages/reflex-hosting-cli/src/reflex_cli/utils/hosting.py index 30a0cd65d75..98c0553c8d5 100644 --- a/packages/reflex-hosting-cli/src/reflex_cli/utils/hosting.py +++ b/packages/reflex-hosting-cli/src/reflex_cli/utils/hosting.py @@ -1078,6 +1078,20 @@ def select_project(project: str, token: str | None = None) -> str: return f"{project} is now selected." +def normalize_project_id(value: Any) -> str | None: + """Normalize a project ID value, treating empty/whitespace strings and non-strings as None. + + Args: + value: The raw project ID value from config, CLI args, or hosting.json. + + Returns: + The stripped project ID, or None if the value is missing or blank. + """ + if isinstance(value, str) and value.strip(): + return value.strip() + return None + + def get_selected_project() -> str | None: """Retrieve the currently selected project ID. @@ -1088,10 +1102,10 @@ def get_selected_project() -> str | None: try: with constants.Hosting.HOSTING_JSON.open() as config_file: hosting_config = json.load(config_file) - return hosting_config.get("project") + return normalize_project_id(hosting_config.get("project")) except Exception as ex: console.debug( - f"Unable to fetch token from {constants.Hosting.HOSTING_JSON} due to: {ex}" + f"Unable to read selected project from {constants.Hosting.HOSTING_JSON} due to: {ex}" ) return None diff --git a/packages/reflex-hosting-cli/src/reflex_cli/v2/cli.py b/packages/reflex-hosting-cli/src/reflex_cli/v2/cli.py index 659779eda69..89b69cef4ab 100644 --- a/packages/reflex-hosting-cli/src/reflex_cli/v2/cli.py +++ b/packages/reflex-hosting-cli/src/reflex_cli/v2/cli.py @@ -179,19 +179,24 @@ def deploy( if not description: description = config.get("description", None) - # resolve the project id from the project name. + project_id = hosting.normalize_project_id(project_id) + if project_name and not project_id: result = hosting.search_project( project_name, client=authenticated_client, interactive=interactive ) - project_id = result.get("id") if result else None + project_id = hosting.normalize_project_id(result.get("id")) if result else None + + selected_project_id = hosting.get_selected_project() + validated_project: dict[str, Any] | None = None try: - # check if provided project exists. + if not project_id: + project_id = selected_project_id if project_id: - hosting.get_project(project_id, client=authenticated_client) - else: - project_id = hosting.get_selected_project() + validated_project = hosting.get_project( + project_id, client=authenticated_client + ) except httpx.HTTPStatusError as ex: try: console.error(ex.response.json().get("detail")) @@ -210,7 +215,7 @@ def deploy( if app_name and not app_id: search_project_id = project_id if not interactive and not project and not search_project_id: - search_project_id = hosting.get_selected_project() + search_project_id = selected_project_id elif interactive and not project: search_project_id = None @@ -230,16 +235,13 @@ def deploy( raise click.exceptions.Exit(1) from ex if app and interactive and not project and not app_id: - default_project_id = hosting.get_selected_project() + default_project_id = selected_project_id app_project_id = app.get("project_id") if app_project_id and ( not default_project_id or app_project_id != default_project_id ): - app_project = hosting.get_project( - app_project_id, client=authenticated_client - ) - app_project_name = app_project.get("name", "Unknown") + app_project_name = (app.get("project") or {}).get("name") or app_project_id if ( console.ask( f"Deploy to app '{app['name']}' in project '{app_project_name}'?", @@ -262,55 +264,40 @@ def deploy( ) == "y" ): - # Check if we need confirmation for deploying to non-default project if not project: - default_project_id = hosting.get_selected_project() - if not default_project_id: - try: - if project_id: - target_project = hosting.get_project( - project_id, client=authenticated_client - ) - project_name = target_project.get("name", "Unknown") - else: - token = hosting.get_existing_access_token() - default_project_id = hosting.get_default_project( - authenticated_client + needs_confirmation = not selected_project_id or ( + project_id and project_id != selected_project_id + ) + if needs_confirmation: + if project_id: + project_display_name = ( + ( + validated_project.get("name") + if validated_project + else None ) - if default_project_id: - default_project = hosting.get_project( - default_project_id, client=authenticated_client - ) - project_name = default_project.get( - "name", "Default Project" - ) - else: - project_name = "Default Project" - except Exception: - project_name = "Unknown" - - if ( - console.ask( - f"Create and deploy app '{app_name}' in project '{project_name}'?", - choices=["y", "n"], - default="y", + or project_name + or project_id ) - != "y" - ): - console.info("Deployment cancelled.") - raise click.exceptions.Exit(0) - elif project_id and project_id != default_project_id: - try: - target_project = hosting.get_project( - project_id, client=authenticated_client + else: + project_display_name = "your default project" + fallback_project_id = hosting.get_default_project( + authenticated_client ) - project_name = target_project.get("name", "Unknown") - except Exception: - project_name = "Unknown" + if fallback_project_id: + try: + fallback_project = hosting.get_project( + fallback_project_id, client=authenticated_client + ) + project_display_name = ( + fallback_project.get("name") or project_display_name + ) + except Exception: + pass if ( console.ask( - f"Create and deploy app '{app_name}' in project '{project_name}'?", + f"Create and deploy app '{app_name}' in project '{project_display_name}'?", choices=["y", "n"], default="y", ) diff --git a/tests/units/reflex_cli/utils/test_hosting.py b/tests/units/reflex_cli/utils/test_hosting.py index 89d05d96eeb..488391dd1d6 100644 --- a/tests/units/reflex_cli/utils/test_hosting.py +++ b/tests/units/reflex_cli/utils/test_hosting.py @@ -13,6 +13,8 @@ delete_token_from_config, get_authenticated_client, get_existing_access_token, + get_selected_project, + normalize_project_id, save_token_to_config, ) @@ -177,3 +179,39 @@ def test_scale_params_as_json_is_pure_when_type_is_unspecified(): assert scale_params.type is None assert first == second == {"type": ScaleType.REGION.value, "regions": {}} + + +@pytest.mark.parametrize( + "config_content, expected", + [ + ('{"project": "abc-uuid"}', "abc-uuid"), + ('{"project": ""}', None), + ('{"project": " "}', None), + ('{"project": null}', None), + ('{"project": 123}', None), + ('{"project": []}', None), + ("{}", None), + ], +) +def test_get_selected_project_normalizes_empty_to_none( + mocker: MockerFixture, config_content: str, expected: str | None +): + mocker.patch("pathlib.Path.open", mock_open(read_data=config_content)) + assert get_selected_project() == expected + + +@pytest.mark.parametrize( + "value, expected", + [ + ("abc-uuid", "abc-uuid"), + (" abc-uuid ", "abc-uuid"), + ("", None), + (" ", None), + (None, None), + (123, None), + ([], None), + ({}, None), + ], +) +def test_normalize_project_id(value: object, expected: str | None): + assert normalize_project_id(value) == expected diff --git a/tests/units/reflex_cli/v2/test_cli.py b/tests/units/reflex_cli/v2/test_cli.py index fd94b03ea78..31436d33b6e 100644 --- a/tests/units/reflex_cli/v2/test_cli.py +++ b/tests/units/reflex_cli/v2/test_cli.py @@ -718,3 +718,329 @@ def test_deploy_create_deployment_multiple_apps_interactive( token="fake-token", validated_data={"foo": "bar"} ), ) + + +def _common_deploy_mocks( + mocker: MockerFixture, *, selected_project: str | None = None +) -> None: + mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + return_value=hosting.AuthenticatedClient( + token="fake-token", validated_data={"user_id": "user-uuid"} + ), + ) + mocker.patch( + "reflex_cli.utils.hosting.get_selected_project", + return_value=selected_project, + ) + mocker.patch( + "reflex_cli.utils.hosting.get_hostname", + return_value={"hostname": "fake-hostname", "server": "fake-server"}, + ) + mocker.patch( + "reflex_cli.utils.hosting.validate_deployment_args", return_value="success" + ) + mocker.patch( + "reflex_cli.utils.hosting.create_deployment", + return_value={"deployment_id": "fake-deployment-id"}, + ) + mocker.patch( + "reflex_cli.utils.hosting.watch_deployment_status", + return_value={"status": "ready"}, + ) + + +def test_deploy_interactive_existing_app_uses_embedded_project_name( + mocker: MockerFixture, + mock_export_fn: Callable[[str, str, str, bool, bool, bool, bool], None], +): + _common_deploy_mocks(mocker) + mocker.patch( + "reflex_cli.utils.hosting.search_app", + return_value={ + "name": "fake-app", + "id": "fake-id", + "project_id": "real-project-id", + "project": {"id": "real-project-id", "name": "RealProject"}, + }, + ) + get_project = mocker.patch( + "reflex_cli.utils.hosting.get_project", side_effect=AssertionError + ) + console_ask = mocker.patch("reflex_cli.utils.console.ask", return_value="y") + + cli.deploy(app_name="fake-app", export_fn=mock_export_fn, interactive=True) + + get_project.assert_not_called() + prompts = [call.args[0] for call in console_ask.call_args_list] + deploy_prompt = next(p for p in prompts if p.startswith("Deploy to app")) + assert "RealProject" in deploy_prompt + + +def test_deploy_interactive_new_app_resolved_project_reuses_validation( + mocker: MockerFixture, + mock_export_fn: Callable[[str, str, str, bool, bool, bool, bool], None], +): + _common_deploy_mocks(mocker) + mocker.patch("reflex_cli.utils.hosting.search_app", return_value=None) + mocker.patch( + "reflex_cli.utils.hosting.search_project", + return_value={"id": "chosen-project-id", "name": "ChosenProject"}, + ) + get_project = mocker.patch( + "reflex_cli.utils.hosting.get_project", + return_value={"id": "chosen-project-id", "name": "ChosenProject"}, + ) + create_app = mocker.patch( + "reflex_cli.utils.hosting.create_app", + return_value={ + "name": "fake-app", + "id": "fake-id", + "project_id": "chosen-project-id", + }, + ) + console_ask = mocker.patch("reflex_cli.utils.console.ask", return_value="y") + + cli.deploy( + app_name="fake-app", + export_fn=mock_export_fn, + project_name="ChosenProject", + interactive=True, + ) + + get_project.assert_called_once_with( + "chosen-project-id", + client=hosting.AuthenticatedClient( + token="fake-token", validated_data={"user_id": "user-uuid"} + ), + ) + create_app.assert_called_once() + create_prompt = next( + call.args[0] + for call in console_ask.call_args_list + if call.args and call.args[0].startswith("Create and deploy") + ) + assert "ChosenProject" in create_prompt + + +def test_deploy_interactive_new_app_non_default_project_shows_name( + mocker: MockerFixture, + mock_export_fn: Callable[[str, str, str, bool, bool, bool, bool], None], +): + _common_deploy_mocks(mocker, selected_project="default-project-id") + mocker.patch("reflex_cli.utils.hosting.search_app", return_value=None) + mocker.patch( + "reflex_cli.utils.hosting.search_project", + return_value={"id": "other-project-id", "name": "OtherProject"}, + ) + mocker.patch( + "reflex_cli.utils.hosting.get_project", + return_value={"id": "other-project-id", "name": "OtherProject"}, + ) + create_app = mocker.patch( + "reflex_cli.utils.hosting.create_app", + return_value={ + "name": "fake-app", + "id": "fake-id", + "project_id": "other-project-id", + }, + ) + console_ask = mocker.patch("reflex_cli.utils.console.ask", return_value="y") + + cli.deploy( + app_name="fake-app", + export_fn=mock_export_fn, + project_name="OtherProject", + interactive=True, + ) + + create_app.assert_called_once() + create_prompt = next( + call.args[0] + for call in console_ask.call_args_list + if call.args and call.args[0].startswith("Create and deploy") + ) + assert "OtherProject" in create_prompt + + +def test_deploy_interactive_existing_app_without_project_dict_falls_back_to_id( + mocker: MockerFixture, + mock_export_fn: Callable[[str, str, str, bool, bool, bool, bool], None], +): + _common_deploy_mocks(mocker) + mocker.patch( + "reflex_cli.utils.hosting.search_app", + return_value={ + "name": "fake-app", + "id": "fake-id", + "project_id": "lone-project-id", + }, + ) + mocker.patch("reflex_cli.utils.hosting.get_project") + console_ask = mocker.patch("reflex_cli.utils.console.ask", return_value="y") + + cli.deploy(app_name="fake-app", export_fn=mock_export_fn, interactive=True) + + deploy_prompt = next( + call.args[0] + for call in console_ask.call_args_list + if call.args and call.args[0].startswith("Deploy to app") + ) + assert "lone-project-id" in deploy_prompt + + +def test_deploy_interactive_existing_app_user_declines_exits_cleanly( + mocker: MockerFixture, + mock_export_fn: Callable[[str, str, str, bool, bool, bool, bool], None], +): + _common_deploy_mocks(mocker) + mocker.patch( + "reflex_cli.utils.hosting.search_app", + return_value={ + "name": "fake-app", + "id": "fake-id", + "project_id": "real-project-id", + "project": {"id": "real-project-id", "name": "RealProject"}, + }, + ) + mocker.patch("reflex_cli.utils.hosting.get_project") + create_deployment = mocker.patch("reflex_cli.utils.hosting.create_deployment") + mocker.patch("reflex_cli.utils.console.ask", return_value="n") + + with pytest.raises(click.exceptions.Exit) as exc_info: + cli.deploy(app_name="fake-app", export_fn=mock_export_fn, interactive=True) + + assert exc_info.value.exit_code == 0 + create_deployment.assert_not_called() + + +def test_deploy_interactive_new_app_user_declines_create_exits_cleanly( + mocker: MockerFixture, + mock_export_fn: Callable[[str, str, str, bool, bool, bool, bool], None], +): + _common_deploy_mocks(mocker) + mocker.patch("reflex_cli.utils.hosting.search_app", return_value=None) + mocker.patch( + "reflex_cli.utils.hosting.search_project", + return_value={"id": "chosen-project-id", "name": "ChosenProject"}, + ) + mocker.patch( + "reflex_cli.utils.hosting.get_project", + return_value={"id": "chosen-project-id", "name": "ChosenProject"}, + ) + create_app = mocker.patch("reflex_cli.utils.hosting.create_app") + create_deployment = mocker.patch("reflex_cli.utils.hosting.create_deployment") + mocker.patch("reflex_cli.utils.console.ask", side_effect=["y", "n"]) + + with pytest.raises(click.exceptions.Exit) as exc_info: + cli.deploy( + app_name="fake-app", + export_fn=mock_export_fn, + project_name="ChosenProject", + interactive=True, + ) + + assert exc_info.value.exit_code == 0 + create_app.assert_not_called() + create_deployment.assert_not_called() + + +def test_deploy_interactive_get_project_failure_exits_before_prompting( + mocker: MockerFixture, + mock_export_fn: Callable[[str, str, str, bool, bool, bool, bool], None], +): + _common_deploy_mocks(mocker) + mocker.patch( + "reflex_cli.utils.hosting.search_project", + return_value={"id": "broken-project-id", "name": "BrokenProject"}, + ) + mocker.patch( + "reflex_cli.utils.hosting.get_project", + side_effect=httpx.HTTPStatusError( + "boom", + request=mocker.Mock(), + response=mocker.Mock(json=lambda: {"detail": "bad project"}), + ), + ) + search_app = mocker.patch("reflex_cli.utils.hosting.search_app") + create_app = mocker.patch("reflex_cli.utils.hosting.create_app") + console_ask = mocker.patch("reflex_cli.utils.console.ask") + + with pytest.raises(click.exceptions.Exit) as exc_info: + cli.deploy( + app_name="fake-app", + export_fn=mock_export_fn, + project_name="BrokenProject", + interactive=True, + ) + + assert exc_info.value.exit_code == 1 + console_ask.assert_not_called() + search_app.assert_not_called() + create_app.assert_not_called() + + +def test_deploy_interactive_new_app_no_selected_project_shows_default_name( + mocker: MockerFixture, + mock_export_fn: Callable[[str, str, str, bool, bool, bool, bool], None], +): + _common_deploy_mocks(mocker) + mocker.patch("reflex_cli.utils.hosting.search_app", return_value=None) + get_project = mocker.patch( + "reflex_cli.utils.hosting.get_project", + return_value={"id": "user-uuid", "name": "MyPersonalProject"}, + ) + create_app = mocker.patch( + "reflex_cli.utils.hosting.create_app", + return_value={ + "name": "fake-app", + "id": "fake-id", + "project_id": "user-uuid", + }, + ) + console_ask = mocker.patch("reflex_cli.utils.console.ask", return_value="y") + + cli.deploy(app_name="fake-app", export_fn=mock_export_fn, interactive=True) + + get_project.assert_called_once_with( + "user-uuid", + client=hosting.AuthenticatedClient( + token="fake-token", validated_data={"user_id": "user-uuid"} + ), + ) + create_app.assert_called_once() + create_prompt = next( + call.args[0] + for call in console_ask.call_args_list + if call.args and call.args[0].startswith("Create and deploy") + ) + assert "MyPersonalProject" in create_prompt + + +def test_deploy_empty_project_in_config_is_not_forwarded_to_create_app( + mocker: MockerFixture, + mock_export_fn: Callable[[str, str, str, bool, bool, bool, bool], None], +): + from reflex_cli.core.config import Config + + _common_deploy_mocks(mocker) + mocker.patch( + "reflex_cli.utils.hosting.read_config", + return_value=Config(name="fake-app", project=" "), + ) + mocker.patch("reflex_cli.utils.hosting.search_app", return_value=None) + get_project = mocker.patch("reflex_cli.utils.hosting.get_project") + create_app = mocker.patch( + "reflex_cli.utils.hosting.create_app", + return_value={ + "name": "fake-app", + "id": "fake-id", + "project_id": "user-uuid", + }, + ) + + cli.deploy(app_name="fake-app", export_fn=mock_export_fn, interactive=False) + + get_project.assert_not_called() + create_app.assert_called_once() + assert create_app.call_args.kwargs.get("project_id") is None From 054c27461824082a85b4c8e2739e103927eba640 Mon Sep 17 00:00:00 2001 From: Carlos Date: Tue, 19 May 2026 13:32:36 +0200 Subject: [PATCH 2/2] update --- packages/reflex-hosting-cli/src/reflex_cli/v2/cli.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/reflex-hosting-cli/src/reflex_cli/v2/cli.py b/packages/reflex-hosting-cli/src/reflex_cli/v2/cli.py index 89b69cef4ab..fe14265d458 100644 --- a/packages/reflex-hosting-cli/src/reflex_cli/v2/cli.py +++ b/packages/reflex-hosting-cli/src/reflex_cli/v2/cli.py @@ -214,9 +214,7 @@ def deploy( try: if app_name and not app_id: search_project_id = project_id - if not interactive and not project and not search_project_id: - search_project_id = selected_project_id - elif interactive and not project: + if interactive and not project: search_project_id = None app = hosting.search_app(