Skip to content

Commit 05bc9b7

Browse files
committed
✨ Allow to specify application directory
Shortcake-Parent: improve-how-we-handle-invalid-tokens
1 parent 1412b7d commit 05bc9b7

6 files changed

Lines changed: 224 additions & 21 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ dependencies = [
3333
"uvicorn[standard] >= 0.15.0",
3434
"rignore >= 0.5.1",
3535
"httpx >= 0.27.0",
36-
"rich-toolkit >= 0.14.5",
36+
"rich-toolkit >= 0.19.4",
3737
"pydantic[email] >= 2.0",
3838
"sentry-sdk >= 2.20.0",
3939
"fastar >= 0.8.0",

src/fastapi_cloud_cli/commands/deploy.py

Lines changed: 56 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,20 @@
11
import contextlib
22
import logging
3+
import re
34
import subprocess
45
import tempfile
56
import time
67
from enum import Enum
78
from itertools import cycle
8-
from pathlib import Path
9+
from pathlib import Path, PurePosixPath
910
from textwrap import dedent
1011
from typing import Annotated, Any, Optional, Union
1112

1213
import fastar
1314
import rignore
1415
import typer
1516
from httpx import Client
16-
from pydantic import BaseModel, EmailStr, TypeAdapter, ValidationError
17+
from pydantic import AfterValidator, BaseModel, EmailStr, TypeAdapter, ValidationError
1718
from rich.text import Text
1819
from rich_toolkit import RichToolkit
1920
from rich_toolkit.menu import Option
@@ -27,6 +28,39 @@
2728
logger = logging.getLogger(__name__)
2829

2930

31+
def validate_app_directory(v: Optional[str]) -> Optional[str]:
32+
if v is None:
33+
return None
34+
35+
v = v.strip()
36+
37+
if not v:
38+
return None
39+
40+
if v.startswith("~"):
41+
raise ValueError("cannot start with '~'")
42+
43+
path = PurePosixPath(v)
44+
45+
if path.is_absolute():
46+
raise ValueError("must be a relative path, not absolute")
47+
48+
if ".." in path.parts:
49+
raise ValueError("cannot contain '..' path segments")
50+
51+
normalized = path.as_posix()
52+
53+
if not re.fullmatch(r"[A-Za-z0-9._/ -]+", normalized):
54+
raise ValueError(
55+
"contains invalid characters (allowed: letters, numbers, space, / . _ -)"
56+
)
57+
58+
return normalized
59+
60+
61+
AppDirectory = Annotated[Optional[str], AfterValidator(validate_app_directory)]
62+
63+
3064
def _cancel_upload(deployment_id: str) -> None:
3165
logger.debug("Cancelling upload for deployment: %s", deployment_id)
3266

@@ -113,13 +147,14 @@ def _get_teams() -> list[Team]:
113147
class AppResponse(BaseModel):
114148
id: str
115149
slug: str
150+
directory: Optional[str]
116151

117152

118-
def _create_app(team_id: str, app_name: str) -> AppResponse:
153+
def _create_app(team_id: str, app_name: str, directory: Optional[str]) -> AppResponse:
119154
with APIClient() as client:
120155
response = client.post(
121156
"/apps/",
122-
json={"name": app_name, "team_id": team_id},
157+
json={"name": app_name, "team_id": team_id, "directory": directory},
123158
)
124159

125160
response.raise_for_status()
@@ -332,10 +367,26 @@ def _configure_app(toolkit: RichToolkit, path_to_deploy: Path) -> AppConfig:
332367

333368
toolkit.print_line()
334369

370+
initial_directory = selected_app.directory if selected_app else ""
371+
372+
directory_input = toolkit.input(
373+
title="Path to the directory containing your app (e.g. src, backend):",
374+
tag="dir",
375+
value=initial_directory or "",
376+
placeholder="[italic]Leave empty if it's the current directory[/italic]",
377+
validator=TypeAdapter(AppDirectory),
378+
)
379+
380+
directory: Optional[str] = directory_input if directory_input else None
381+
382+
toolkit.print_line()
383+
335384
toolkit.print("Deployment configuration:", tag="summary")
336385
toolkit.print_line()
337386
toolkit.print(f"Team: [bold]{team.name}[/bold]")
338387
toolkit.print(f"App name: [bold]{app_name}[/bold]")
388+
toolkit.print(f"Directory: [bold]{directory or '.'}[/bold]")
389+
339390
toolkit.print_line()
340391

341392
choice = toolkit.ask(
@@ -357,7 +408,7 @@ def _configure_app(toolkit: RichToolkit, path_to_deploy: Path) -> AppConfig:
357408
else:
358409
with toolkit.progress(title="Creating app...") as progress:
359410
with handle_http_errors(progress):
360-
app = _create_app(team.id, app_name)
411+
app = _create_app(team.id, app_name, directory=directory)
361412

362413
progress.log(f"App created successfully! App slug: {app.slug}")
363414

src/fastapi_cloud_cli/utils/cli.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,12 +56,13 @@ def get_rich_toolkit(minimal: bool = False) -> RichToolkit:
5656
theme={
5757
"tag.title": "white on #009485",
5858
"tag": "white on #007166",
59-
"placeholder": "grey85",
59+
"placeholder": "grey62",
6060
"text": "white",
6161
"selected": "#007166",
6262
"result": "grey85",
6363
"progress": "on #007166",
6464
"error": "red",
65+
"cancelled": "indian_red italic",
6566
},
6667
)
6768

tests/test_cli_deploy.py

Lines changed: 98 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -336,7 +336,7 @@ def test_asks_for_app_name_after_team(
336336
def test_creates_app_on_backend(
337337
logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
338338
) -> None:
339-
steps = [Keys.ENTER, Keys.ENTER, *"demo", Keys.ENTER, Keys.ENTER]
339+
steps = [Keys.ENTER, Keys.ENTER, *"demo", Keys.ENTER, Keys.ENTER, Keys.ENTER]
340340

341341
team = _get_random_team()
342342

@@ -347,10 +347,50 @@ def test_creates_app_on_backend(
347347
)
348348
)
349349

350-
respx_mock.post("/apps/", json={"name": "demo", "team_id": team["id"]}).mock(
351-
return_value=Response(201, json=_get_random_app(team_id=team["id"]))
350+
respx_mock.post(
351+
"/apps/", json={"name": "demo", "team_id": team["id"], "directory": None}
352+
).mock(return_value=Response(201, json=_get_random_app(team_id=team["id"])))
353+
354+
with (
355+
changing_dir(tmp_path),
356+
patch("rich_toolkit.container.getchar") as mock_getchar,
357+
):
358+
mock_getchar.side_effect = steps
359+
360+
result = runner.invoke(app, ["deploy"])
361+
362+
assert result.exit_code == 1
363+
364+
assert "App created successfully" in result.output
365+
366+
367+
@pytest.mark.respx
368+
def test_creates_app_with_directory(
369+
logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
370+
) -> None:
371+
steps = [
372+
Keys.ENTER,
373+
Keys.ENTER,
374+
*"demo",
375+
Keys.ENTER,
376+
*"src",
377+
Keys.ENTER,
378+
Keys.ENTER,
379+
]
380+
381+
team = _get_random_team()
382+
383+
respx_mock.get("/teams/").mock(
384+
return_value=Response(
385+
200,
386+
json={"data": [team]},
387+
)
352388
)
353389

390+
respx_mock.post(
391+
"/apps/", json={"name": "demo", "team_id": team["id"], "directory": "src"}
392+
).mock(return_value=Response(201, json=_get_random_app(team_id=team["id"])))
393+
354394
with (
355395
changing_dir(tmp_path),
356396
patch("rich_toolkit.container.getchar") as mock_getchar,
@@ -362,6 +402,52 @@ def test_creates_app_on_backend(
362402
assert result.exit_code == 1
363403

364404
assert "App created successfully" in result.output
405+
assert "Directory: src" in result.output
406+
407+
408+
@pytest.mark.respx
409+
@pytest.mark.parametrize(
410+
"directory,expected_error",
411+
[
412+
("~/src", "cannot start with '~'"),
413+
("/absolute/path", "must be a relative path, not absolute"),
414+
("src/../etc", "cannot contain '..' path segments"),
415+
("src/@app", "contains invalid characters"),
416+
],
417+
)
418+
def test_shows_validation_error_for_invalid_directory(
419+
logged_in_cli: None,
420+
tmp_path: Path,
421+
respx_mock: respx.MockRouter,
422+
directory: str,
423+
expected_error: str,
424+
) -> None:
425+
steps = [
426+
Keys.ENTER, # Select team
427+
Keys.ENTER, # Confirm new app
428+
*"demo",
429+
Keys.ENTER, # App name
430+
*directory,
431+
Keys.ENTER, # Submit invalid directory -> validation error shown
432+
Keys.CTRL_C, # Cancel
433+
]
434+
435+
respx_mock.get("/teams/").mock(
436+
return_value=Response(
437+
200,
438+
json={"data": [_get_random_team()]},
439+
)
440+
)
441+
442+
with (
443+
changing_dir(tmp_path),
444+
patch("rich_toolkit.container.getchar") as mock_getchar,
445+
):
446+
mock_getchar.side_effect = steps
447+
448+
result = runner.invoke(app, ["deploy"])
449+
450+
assert expected_error in result.output
365451

366452

367453
@pytest.mark.respx
@@ -373,6 +459,7 @@ def test_cancels_deployment_when_user_selects_no(
373459
Keys.ENTER,
374460
*"demo",
375461
Keys.ENTER,
462+
Keys.ENTER,
376463
Keys.DOWN_ARROW,
377464
Keys.ENTER,
378465
]
@@ -438,6 +525,7 @@ def test_exits_successfully_when_deployment_is_done(
438525
*"demo",
439526
Keys.ENTER,
440527
Keys.ENTER,
528+
Keys.ENTER,
441529
]
442530

443531
team_data = _get_random_team()
@@ -447,9 +535,9 @@ def test_exits_successfully_when_deployment_is_done(
447535
return_value=Response(200, json={"data": [team_data]})
448536
)
449537

450-
respx_mock.post("/apps/", json={"name": "demo", "team_id": team_data["id"]}).mock(
451-
return_value=Response(201, json=app_data)
452-
)
538+
respx_mock.post(
539+
"/apps/", json={"name": "demo", "team_id": team_data["id"], "directory": None}
540+
).mock(return_value=Response(201, json=app_data))
453541

454542
respx_mock.get(f"/apps/{app_data['id']}").mock(
455543
return_value=Response(200, json=app_data)
@@ -689,6 +777,7 @@ def _deploy_without_waiting(respx_mock: respx.MockRouter, tmp_path: Path) -> Res
689777
*"demo",
690778
Keys.ENTER,
691779
Keys.ENTER,
780+
Keys.ENTER,
692781
]
693782

694783
team_data = _get_random_team()
@@ -702,9 +791,9 @@ def _deploy_without_waiting(respx_mock: respx.MockRouter, tmp_path: Path) -> Res
702791
)
703792
)
704793

705-
respx_mock.post("/apps/", json={"name": "demo", "team_id": team_data["id"]}).mock(
706-
return_value=Response(201, json=app_data)
707-
)
794+
respx_mock.post(
795+
"/apps/", json={"name": "demo", "team_id": team_data["id"], "directory": None}
796+
).mock(return_value=Response(201, json=app_data))
708797

709798
respx_mock.get(f"/apps/{app_data['id']}").mock(
710799
return_value=Response(200, json=app_data)

tests/test_deploy_utils.py

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
from pathlib import Path
2+
from typing import Optional
23

34
import pytest
45

5-
from fastapi_cloud_cli.commands.deploy import DeploymentStatus, _should_exclude_entry
6+
from fastapi_cloud_cli.commands.deploy import (
7+
DeploymentStatus,
8+
_should_exclude_entry,
9+
validate_app_directory,
10+
)
611

712

813
@pytest.mark.parametrize(
@@ -75,3 +80,60 @@ def test_deployment_status_to_human_readable(
7580
) -> None:
7681
"""Should convert deployment status to human readable format."""
7782
assert DeploymentStatus.to_human_readable(status) == expected
83+
84+
85+
@pytest.mark.parametrize(
86+
"value,expected",
87+
[
88+
(None, None),
89+
("", None),
90+
(" ", None),
91+
("src", "src"),
92+
("src/app", "src/app"),
93+
(" src/app ", "src/app"),
94+
("my-app", "my-app"),
95+
("my_app", "my_app"),
96+
("my.app", "my.app"),
97+
("src/my app", "src/my app"),
98+
("a/b/c", "a/b/c"),
99+
],
100+
)
101+
def test_validate_app_directory_valid(
102+
value: Optional[str], expected: Optional[str]
103+
) -> None:
104+
"""Should accept valid directory values and normalize them."""
105+
assert validate_app_directory(value) == expected
106+
107+
108+
@pytest.mark.parametrize(
109+
"value,expected_message",
110+
[
111+
("~/src", "cannot start with '~'"),
112+
("/absolute/path", "must be a relative path, not absolute"),
113+
("src/../etc", "cannot contain '..' path segments"),
114+
("..", "cannot contain '..' path segments"),
115+
("src/../../etc", "cannot contain '..' path segments"),
116+
(
117+
"src/@app",
118+
"contains invalid characters (allowed: letters, numbers, space, / . _ -)",
119+
),
120+
(
121+
"src/$app",
122+
"contains invalid characters (allowed: letters, numbers, space, / . _ -)",
123+
),
124+
(
125+
"src/app!",
126+
"contains invalid characters (allowed: letters, numbers, space, / . _ -)",
127+
),
128+
(
129+
"src/app#1",
130+
"contains invalid characters (allowed: letters, numbers, space, / . _ -)",
131+
),
132+
],
133+
)
134+
def test_validate_app_directory_invalid(value: str, expected_message: str) -> None:
135+
"""Should reject invalid directory values with descriptive errors."""
136+
with pytest.raises(ValueError) as exc_info:
137+
validate_app_directory(value)
138+
139+
assert str(exc_info.value) == expected_message

0 commit comments

Comments
 (0)