Skip to content

Commit 132e792

Browse files
committed
✨ Allow to specify application directory
Shortcake-Parent: improve-how-we-handle-invalid-tokens
1 parent f686471 commit 132e792

6 files changed

Lines changed: 220 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.3",
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: 52 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

@@ -115,11 +149,11 @@ class AppResponse(BaseModel):
115149
slug: str
116150

117151

118-
def _create_app(team_id: str, app_name: str) -> AppResponse:
152+
def _create_app(team_id: str, app_name: str, directory: Optional[str]) -> AppResponse:
119153
with APIClient() as client:
120154
response = client.post(
121155
"/apps/",
122-
json={"name": app_name, "team_id": team_id},
156+
json={"name": app_name, "team_id": team_id, "directory": directory},
123157
)
124158

125159
response.raise_for_status()
@@ -332,10 +366,23 @@ def _configure_app(toolkit: RichToolkit, path_to_deploy: Path) -> AppConfig:
332366

333367
toolkit.print_line()
334368

369+
directory_input = toolkit.input(
370+
title="Path to the directory containing your app (e.g. src, backend):",
371+
tag="dir",
372+
placeholder="[italic]Leave empty if it's the current directory[/italic]",
373+
validator=TypeAdapter(AppDirectory),
374+
)
375+
376+
directory: Optional[str] = directory_input if directory_input else None
377+
378+
toolkit.print_line()
379+
335380
toolkit.print("Deployment configuration:", tag="summary")
336381
toolkit.print_line()
337382
toolkit.print(f"Team: [bold]{team.name}[/bold]")
338383
toolkit.print(f"App name: [bold]{app_name}[/bold]")
384+
toolkit.print(f"Directory: [bold]{directory or '.'}[/bold]")
385+
339386
toolkit.print_line()
340387

341388
choice = toolkit.ask(
@@ -357,7 +404,7 @@ def _configure_app(toolkit: RichToolkit, path_to_deploy: Path) -> AppConfig:
357404
else:
358405
with toolkit.progress(title="Creating app...") as progress:
359406
with handle_http_errors(progress):
360-
app = _create_app(team.id, app_name)
407+
app = _create_app(team.id, app_name, directory=directory)
361408

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

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
@@ -332,7 +332,7 @@ def test_asks_for_app_name_after_team(
332332
def test_creates_app_on_backend(
333333
logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
334334
) -> None:
335-
steps = [Keys.ENTER, Keys.ENTER, *"demo", Keys.ENTER, Keys.ENTER]
335+
steps = [Keys.ENTER, Keys.ENTER, *"demo", Keys.ENTER, Keys.ENTER, Keys.ENTER]
336336

337337
team = _get_random_team()
338338

@@ -343,10 +343,50 @@ def test_creates_app_on_backend(
343343
)
344344
)
345345

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

386+
respx_mock.post(
387+
"/apps/", json={"name": "demo", "team_id": team["id"], "directory": "src"}
388+
).mock(return_value=Response(201, json=_get_random_app(team_id=team["id"])))
389+
350390
with (
351391
changing_dir(tmp_path),
352392
patch("rich_toolkit.container.getchar") as mock_getchar,
@@ -358,6 +398,52 @@ def test_creates_app_on_backend(
358398
assert result.exit_code == 1
359399

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

362448

363449
@pytest.mark.respx
@@ -369,6 +455,7 @@ def test_cancels_deployment_when_user_selects_no(
369455
Keys.ENTER,
370456
*"demo",
371457
Keys.ENTER,
458+
Keys.ENTER,
372459
Keys.DOWN_ARROW,
373460
Keys.ENTER,
374461
]
@@ -434,6 +521,7 @@ def test_exits_successfully_when_deployment_is_done(
434521
*"demo",
435522
Keys.ENTER,
436523
Keys.ENTER,
524+
Keys.ENTER,
437525
]
438526

439527
team_data = _get_random_team()
@@ -443,9 +531,9 @@ def test_exits_successfully_when_deployment_is_done(
443531
return_value=Response(200, json={"data": [team_data]})
444532
)
445533

446-
respx_mock.post("/apps/", json={"name": "demo", "team_id": team_data["id"]}).mock(
447-
return_value=Response(201, json=app_data)
448-
)
534+
respx_mock.post(
535+
"/apps/", json={"name": "demo", "team_id": team_data["id"], "directory": None}
536+
).mock(return_value=Response(201, json=app_data))
449537

450538
respx_mock.get(f"/apps/{app_data['id']}").mock(
451539
return_value=Response(200, json=app_data)
@@ -685,6 +773,7 @@ def _deploy_without_waiting(respx_mock: respx.MockRouter, tmp_path: Path) -> Res
685773
*"demo",
686774
Keys.ENTER,
687775
Keys.ENTER,
776+
Keys.ENTER,
688777
]
689778

690779
team_data = _get_random_team()
@@ -698,9 +787,9 @@ def _deploy_without_waiting(respx_mock: respx.MockRouter, tmp_path: Path) -> Res
698787
)
699788
)
700789

701-
respx_mock.post("/apps/", json={"name": "demo", "team_id": team_data["id"]}).mock(
702-
return_value=Response(201, json=app_data)
703-
)
790+
respx_mock.post(
791+
"/apps/", json={"name": "demo", "team_id": team_data["id"], "directory": None}
792+
).mock(return_value=Response(201, json=app_data))
704793

705794
respx_mock.get(f"/apps/{app_data['id']}").mock(
706795
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)