Skip to content

Commit 1ca104f

Browse files
YuriiMotovgithub-actions[bot]patrick91
authored
🚸 Show Top-3 files larger than threshold on deploy command (#190)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: Patrick Arminio <patrick.arminio@gmail.com>
1 parent 32a17b8 commit 1ca104f

3 files changed

Lines changed: 262 additions & 4 deletions

File tree

src/fastapi_cloud_cli/commands/deploy.py

Lines changed: 52 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -108,15 +108,19 @@ def _should_exclude_entry(path: Path) -> bool:
108108
return False
109109

110110

111-
def archive(path: Path, tar_path: Path) -> Path:
112-
logger.debug("Starting archive creation for path: %s", path)
113-
files = rignore.walk(
111+
def _rignore_walk(path: Path) -> rignore.Walker:
112+
return rignore.walk(
114113
path,
115114
should_exclude_entry=_should_exclude_entry,
116115
additional_ignore_paths=[".fastapicloudignore"],
117116
ignore_hidden=False,
118117
)
119118

119+
120+
def archive(path: Path, tar_path: Path) -> Path:
121+
logger.debug("Starting archive creation for path: %s", path)
122+
files = _rignore_walk(path)
123+
120124
logger.debug("Archive will be created at: %s", tar_path)
121125

122126
file_count = 0
@@ -134,6 +138,20 @@ def archive(path: Path, tar_path: Path) -> Path:
134138
return tar_path
135139

136140

141+
def _get_large_files(path: Path, threshold_mb: int) -> list[tuple[Path, int]]:
142+
threshold_bytes = threshold_mb * 1024 * 1024
143+
large_files = []
144+
files = _rignore_walk(path)
145+
for filename in files:
146+
if filename.is_dir():
147+
continue
148+
file_size = filename.stat().st_size
149+
if file_size > threshold_bytes:
150+
large_files.append((filename.relative_to(path), file_size))
151+
152+
return sorted(large_files, key=lambda x: x[1], reverse=True)
153+
154+
137155
class Team(BaseModel):
138156
id: str
139157
slug: str
@@ -686,6 +704,14 @@ def deploy(
686704
envvar="FASTAPI_CLOUD_APP_ID",
687705
),
688706
] = None,
707+
large_file_threshold: Annotated[
708+
int,
709+
typer.Option(
710+
help="File size threshold in MB for warning about large files",
711+
min=1,
712+
envvar="FASTAPI_CLOUD_LARGE_FILE_THRESHOLD",
713+
),
714+
] = 10, # 10 MB
689715
) -> Any:
690716
"""
691717
Deploy a [bold]FastAPI[/bold] app to FastAPI Cloud. 🚀
@@ -811,10 +837,32 @@ def deploy(
811837
)
812838
raise typer.Exit(1)
813839

840+
large_files = _get_large_files(
841+
path_to_deploy, threshold_mb=large_file_threshold
842+
)
843+
if large_files:
844+
toolkit.print(
845+
f"⚠️ Some uploaded files are larger than {large_file_threshold} MB ⚖️ :",
846+
tag="warning",
847+
)
848+
for fname, fsize in large_files[:3]:
849+
fsize_mb = fsize // (1024 * 1024)
850+
toolkit.print(f" • {fname} [yellow]({fsize_mb} MB)[/yellow]")
851+
is_more = len(large_files) > 3
852+
if is_more:
853+
toolkit.print(f" [dim]...and {len(large_files) - 3} more[/dim]")
854+
855+
large_files_docs_url = "https://fastapicloud.com/docs/fastapi-cloud-cli/deploy/#large-files-warning"
856+
toolkit.print(
857+
f"Read more: [link={large_files_docs_url}]{large_files_docs_url}[/link]",
858+
tag="tip",
859+
)
860+
toolkit.print_line()
861+
814862
with tempfile.TemporaryDirectory() as temp_dir:
815863
logger.debug("Creating archive for deployment")
816864
archive_path = Path(temp_dir) / "archive.tar"
817-
archive(path or Path.cwd(), archive_path)
865+
archive(path_to_deploy, archive_path)
818866

819867
with (
820868
toolkit.progress(

tests/test_cli_deploy.py

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2195,3 +2195,161 @@ def test_ctrl_c_during_build_streaming_shows_cancelled(
21952195

21962196
assert "🟡" in result.output
21972197
assert "Cancelled." in result.output
2198+
2199+
2200+
def _create_file(path: Path, size_bytes: int) -> None:
2201+
"""Create a file of the given size."""
2202+
path.parent.mkdir(parents=True, exist_ok=True)
2203+
with open(path, "wb") as f:
2204+
if size_bytes > 0:
2205+
f.seek(size_bytes - 1)
2206+
f.write(b"\0")
2207+
2208+
2209+
@pytest.mark.respx
2210+
def test_large_file_threshold_warning(
2211+
logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
2212+
) -> None:
2213+
app_data = _get_random_app()
2214+
app_id = app_data["id"]
2215+
team_id = "some-team-id"
2216+
deployment_data = _get_random_deployment(app_id=app_id)
2217+
2218+
_setup_deployment_mocks(respx_mock, app_id, team_id, deployment_data, tmp_path)
2219+
respx_mock.get(f"/apps/{app_id}/deployments/{deployment_data['id']}").mock(
2220+
return_value=Response(200, json={**deployment_data, "status": "success"})
2221+
)
2222+
2223+
_create_file(tmp_path / "model.bin", 12 * 1024 * 1024) # 12 MB
2224+
_create_file(tmp_path / "data.csv", 10 * 1024 * 1024 + 1) # 10+ MB
2225+
2226+
with changing_dir(tmp_path):
2227+
result = runner.invoke(app, ["deploy"])
2228+
2229+
assert result.exit_code == 0
2230+
assert "Some uploaded files are larger than 10 MB" in result.output
2231+
assert "model.bin" in result.output
2232+
assert "12 MB" in result.output
2233+
assert "data.csv" in result.output
2234+
assert "10 MB" in result.output
2235+
2236+
2237+
@pytest.mark.respx
2238+
def test_large_file_threshold_only_top_three_files_with_more_indicator(
2239+
logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
2240+
) -> None:
2241+
app_data = _get_random_app()
2242+
app_id = app_data["id"]
2243+
team_id = "some-team-id"
2244+
deployment_data = _get_random_deployment(app_id=app_id)
2245+
2246+
_setup_deployment_mocks(respx_mock, app_id, team_id, deployment_data, tmp_path)
2247+
respx_mock.get(f"/apps/{app_id}/deployments/{deployment_data['id']}").mock(
2248+
return_value=Response(200, json={**deployment_data, "status": "success"})
2249+
)
2250+
2251+
_create_file(tmp_path / "huge.bin", 50 * 1024 * 1024)
2252+
_create_file(tmp_path / "big.bin", 40 * 1024 * 1024)
2253+
_create_file(tmp_path / "medium.bin", 30 * 1024 * 1024)
2254+
_create_file(tmp_path / "smaller.bin", 20 * 1024 * 1024)
2255+
_create_file(tmp_path / "smallest.bin", 15 * 1024 * 1024)
2256+
2257+
with changing_dir(tmp_path):
2258+
result = runner.invoke(app, ["deploy"])
2259+
2260+
assert result.exit_code == 0
2261+
assert "huge.bin" in result.output
2262+
assert "big.bin" in result.output
2263+
assert "medium.bin" in result.output
2264+
assert "smaller.bin" not in result.output
2265+
assert "smallest.bin" not in result.output
2266+
assert "...and 2 more" in result.output
2267+
2268+
2269+
@pytest.mark.respx
2270+
def test_large_file_threshold_does_not_warn_when_no_large_files(
2271+
logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
2272+
) -> None:
2273+
app_data = _get_random_app()
2274+
app_id = app_data["id"]
2275+
team_id = "some-team-id"
2276+
deployment_data = _get_random_deployment(app_id=app_id)
2277+
2278+
_setup_deployment_mocks(respx_mock, app_id, team_id, deployment_data, tmp_path)
2279+
respx_mock.get(f"/apps/{app_id}/deployments/{deployment_data['id']}").mock(
2280+
return_value=Response(200, json={**deployment_data, "status": "success"})
2281+
)
2282+
2283+
# Files are less or equal to 10 MB (default threshold), so no warning should be shown
2284+
_create_file(tmp_path / "data.bin", 5 * 1024 * 1024)
2285+
_create_file(tmp_path / "data.bin", 10 * 1024 * 1024)
2286+
2287+
with changing_dir(tmp_path):
2288+
result = runner.invoke(app, ["deploy"])
2289+
2290+
assert result.exit_code == 0
2291+
assert "Some uploaded files are larger than" not in result.output
2292+
assert "data.bin" not in result.output
2293+
2294+
2295+
@pytest.mark.respx
2296+
def test_large_file_threshold_custom_threshold(
2297+
logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
2298+
) -> None:
2299+
app_data = _get_random_app()
2300+
app_id = app_data["id"]
2301+
team_id = "some-team-id"
2302+
deployment_data = _get_random_deployment(app_id=app_id)
2303+
2304+
_setup_deployment_mocks(respx_mock, app_id, team_id, deployment_data, tmp_path)
2305+
respx_mock.get(f"/apps/{app_id}/deployments/{deployment_data['id']}").mock(
2306+
return_value=Response(200, json={**deployment_data, "status": "success"})
2307+
)
2308+
2309+
# 5 MB file: above a 1 MB threshold, below the default 10 MB threshold
2310+
_create_file(tmp_path / "data.bin", 5 * 1024 * 1024)
2311+
2312+
with changing_dir(tmp_path):
2313+
result = runner.invoke(app, ["deploy", "--large-file-threshold", "1"])
2314+
2315+
assert result.exit_code == 0
2316+
assert "Some uploaded files are larger than 1 MB" in result.output
2317+
assert "data.bin" in result.output
2318+
2319+
2320+
@pytest.mark.respx
2321+
def test_large_file_threshold_custom_threshold_envvar(
2322+
logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
2323+
) -> None:
2324+
app_data = _get_random_app()
2325+
app_id = app_data["id"]
2326+
team_id = "some-team-id"
2327+
deployment_data = _get_random_deployment(app_id=app_id)
2328+
2329+
_setup_deployment_mocks(respx_mock, app_id, team_id, deployment_data, tmp_path)
2330+
respx_mock.get(f"/apps/{app_id}/deployments/{deployment_data['id']}").mock(
2331+
return_value=Response(200, json={**deployment_data, "status": "success"})
2332+
)
2333+
2334+
# 5 MB file: above a 1 MB threshold, below the default 10 MB threshold
2335+
_create_file(tmp_path / "data.bin", 5 * 1024 * 1024)
2336+
2337+
with changing_dir(tmp_path):
2338+
result = runner.invoke(
2339+
app, ["deploy"], env={"FASTAPI_CLOUD_LARGE_FILE_THRESHOLD": "1"}
2340+
)
2341+
2342+
assert result.exit_code == 0
2343+
assert "Some uploaded files are larger than 1 MB" in result.output
2344+
assert "data.bin" in result.output
2345+
2346+
2347+
@pytest.mark.respx
2348+
def test_invalid_large_file_threshold(
2349+
logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
2350+
) -> None:
2351+
with changing_dir(tmp_path):
2352+
result = runner.invoke(app, ["deploy", "--large-file-threshold", "0"])
2353+
2354+
assert result.exit_code == 2
2355+
assert "Invalid value for '--large-file-threshold'" in result.output

tests/test_deploy_utils.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,21 @@
33
import pytest
44

55
from fastapi_cloud_cli.commands.deploy import (
6+
_get_large_files,
67
_should_exclude_entry,
78
validate_app_directory,
89
)
910
from fastapi_cloud_cli.utils.api import DeploymentStatus
1011

1112

13+
def _create_file(path: Path, size_bytes: int) -> None:
14+
path.parent.mkdir(parents=True, exist_ok=True)
15+
with open(path, "wb") as f:
16+
if size_bytes > 0:
17+
f.seek(size_bytes - 1)
18+
f.write(b"\0")
19+
20+
1221
@pytest.mark.parametrize(
1322
"path",
1423
[
@@ -135,3 +144,46 @@ def test_validate_app_directory_invalid(value: str, expected_message: str) -> No
135144
validate_app_directory(value)
136145

137146
assert str(exc_info.value) == expected_message
147+
148+
149+
def test_get_large_files_no_files_above_threshold(tmp_path: Path) -> None:
150+
"""Should not return files smaller than the threshold."""
151+
_create_file(tmp_path / "small.bin", 512 * 1024) # 0.5 MB
152+
153+
assert _get_large_files(tmp_path, threshold_mb=1) == []
154+
155+
156+
def test_get_large_files_returns_files_at_or_above_threshold(tmp_path: Path) -> None:
157+
"""Should return files at or above the threshold with sizes and relative paths."""
158+
_create_file(tmp_path / "big.bin", 2 * 1024 * 1024) # 2 MB
159+
_create_file(tmp_path / "subdir" / "huge.bin", 5 * 1024 * 1024) # 5 MB
160+
_create_file(tmp_path / "small.bin", 100 * 1024) # 0.1 MB
161+
162+
result = _get_large_files(tmp_path, threshold_mb=1)
163+
164+
assert result == [
165+
(Path("subdir") / "huge.bin", 5 * 1024 * 1024),
166+
(Path("big.bin"), 2 * 1024 * 1024),
167+
]
168+
169+
170+
def test_get_large_files_excludes_default_exclusions(tmp_path: Path) -> None:
171+
"""Should not count files in excluded directories like .venv or __pycache__."""
172+
_create_file(tmp_path / ".venv" / "lib" / "huge.so", 5 * 1024 * 1024)
173+
_create_file(tmp_path / "__pycache__" / "module.cpython-311.pyc", 5 * 1024 * 1024)
174+
_create_file(tmp_path / "main.py", 5 * 1024 * 1024)
175+
176+
assert _get_large_files(tmp_path, threshold_mb=1) == [
177+
(Path("main.py"), 5 * 1024 * 1024)
178+
]
179+
180+
181+
def test_get_large_files_respects_fastapicloudignore(tmp_path: Path) -> None:
182+
"""Should not count files matching .fastapicloudignore patterns."""
183+
_create_file(tmp_path / "data" / "huge.bin", 5 * 1024 * 1024)
184+
_create_file(tmp_path / "main.bin", 5 * 1024 * 1024)
185+
(tmp_path / ".fastapicloudignore").write_text("data/\n")
186+
187+
assert _get_large_files(tmp_path, threshold_mb=1) == [
188+
(Path("main.bin"), 5 * 1024 * 1024)
189+
]

0 commit comments

Comments
 (0)