Skip to content
Merged
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
18 changes: 16 additions & 2 deletions packages/reflex-hosting-cli/src/reflex_cli/utils/hosting.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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

Expand Down
97 changes: 41 additions & 56 deletions packages/reflex-hosting-cli/src/reflex_cli/v2/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand All @@ -209,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 = hosting.get_selected_project()
elif interactive and not project:
if interactive and not project:
search_project_id = None

app = hosting.search_app(
Expand All @@ -230,16 +233,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}'?",
Expand All @@ -262,55 +262,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",
)
Expand Down
38 changes: 38 additions & 0 deletions tests/units/reflex_cli/utils/test_hosting.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)

Expand Down Expand Up @@ -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
Loading
Loading