Skip to content

Commit ec79f94

Browse files
authored
Merge pull request #25 from fastapilabs/01-13-_show_build_logs_in_real_time
✨ Show build logs in real time
2 parents a7d7bbe + 9951bab commit ec79f94

File tree

4 files changed

+97
-138
lines changed

4 files changed

+97
-138
lines changed

pyproject.toml

Lines changed: 13 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,10 @@
22
name = "fastapi-cloud-cli"
33
dynamic = ["version"]
44
description = "Deploy and manage FastAPI Cloud apps from the command line 🚀"
5-
authors = [
6-
{name = "Patrick Arminio", email = "[email protected]"},
7-
]
5+
authors = [{ name = "Patrick Arminio", email = "[email protected]" }]
86
requires-python = ">=3.8"
97
readme = "README.md"
10-
license = {text = "MIT"}
8+
license = { text = "MIT" }
119
classifiers = [
1210
"Intended Audience :: Information Technology",
1311
"Intended Audience :: System Administrators",
@@ -37,14 +35,12 @@ dependencies = [
3735
"uvicorn[standard] >= 0.15.0",
3836
"rignore >= 0.5.1",
3937
"httpx >= 0.27.0,< 0.28.0",
40-
"rich-toolkit >= 0.12.0",
38+
"rich-toolkit >= 0.14.3",
4139
"pydantic >= 1.6.1",
4240
]
4341

4442
[project.optional-dependencies]
45-
standard = [
46-
"uvicorn[standard] >= 0.15.0",
47-
]
43+
standard = ["uvicorn[standard] >= 0.15.0"]
4844

4945
[project.urls]
5046
Homepage = "https://github.com/fastapi/fastapi-cloud-cli"
@@ -62,32 +58,20 @@ version = { source = "file", path = "src/fastapi_cloud_cli/__init__.py" }
6258
distribution = true
6359

6460
[tool.pdm.build]
65-
source-includes = [
66-
"tests/",
67-
"requirements*.txt",
68-
"scripts/",
69-
]
61+
source-includes = ["tests/", "requirements*.txt", "scripts/"]
7062

7163
[tool.pytest.ini_options]
72-
addopts = [
73-
"--strict-config",
74-
"--strict-markers",
75-
]
64+
addopts = ["--strict-config", "--strict-markers"]
7665
xfail_strict = true
7766
junit_family = "xunit2"
7867

7968
[tool.coverage.run]
8069
parallel = true
8170
data_file = "coverage/.coverage"
82-
source = [
83-
"src",
84-
"tests",
85-
]
71+
source = ["src", "tests"]
8672
context = '${CONTEXT}'
8773
dynamic_context = "test_function"
88-
omit = [
89-
"tests/assets/*",
90-
]
74+
omit = ["tests/assets/*"]
9175

9276
[tool.coverage.report]
9377
show_missing = true
@@ -104,9 +88,7 @@ show_contexts = true
10488

10589
[tool.mypy]
10690
strict = true
107-
exclude = [
108-
"tests/assets/*",
109-
]
91+
exclude = ["tests/assets/*"]
11092

11193
[tool.ruff.lint]
11294
select = [
@@ -115,13 +97,13 @@ select = [
11597
"F", # pyflakes
11698
"I", # isort
11799
"B", # flake8-bugbear
118-
"C4", # flake8-comprehensions
100+
"C4", # flake8-comprehensions
119101
"UP", # pyupgrade
120102
]
121103
ignore = [
122-
"E501", # line too long, handled by black
123-
"B008", # do not perform function calls in argument defaults
124-
"C901", # too complex
104+
"E501", # line too long, handled by black
105+
"B008", # do not perform function calls in argument defaults
106+
"C901", # too complex
125107
"W191", # indentation contains tabs
126108
]
127109

src/fastapi_cloud_cli/commands/deploy.py

Lines changed: 36 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import json
12
import logging
23
import tarfile
34
import tempfile
@@ -6,12 +7,13 @@
67
from enum import Enum
78
from itertools import cycle
89
from pathlib import Path
9-
from typing import Any, Dict, List, Optional, Union
10+
from typing import Any, Dict, Generator, List, Optional, Union
1011

1112
import rignore
1213
import typer
1314
from httpx import Client
1415
from pydantic import BaseModel
16+
from rich.text import Text
1517
from rich_toolkit import RichToolkit
1618
from rich_toolkit.menu import Option
1719
from typing_extensions import Annotated
@@ -212,6 +214,16 @@ def _create_environment_variables(app_id: str, env_vars: Dict[str, str]) -> None
212214
response.raise_for_status()
213215

214216

217+
def _stream_build_logs(deployment_id: str) -> Generator[str, None, None]:
218+
with APIClient() as client:
219+
with client.stream(
220+
"GET", f"/deployments/{deployment_id}/build-logs", timeout=60
221+
) as response:
222+
response.raise_for_status()
223+
224+
yield from response.iter_lines()
225+
226+
215227
WAITING_MESSAGES = [
216228
"🚀 Preparing for liftoff! Almost there...",
217229
"👹 Sneaking past the dependency gremlins... Don't wake them up!",
@@ -314,40 +326,35 @@ def _wait_for_deployment(
314326
toolkit.print_line()
315327

316328
toolkit.print(
317-
f"You can also check the status at [link]{check_deployment_url}[/link]",
329+
f"You can also check the status at [link={check_deployment_url}]{check_deployment_url}[/link]",
318330
)
319331
toolkit.print_line()
320332

321-
time_elapsed = 0
333+
time_elapsed = 0.0
322334

323-
with toolkit.progress("Deploying...") as progress:
324-
while True:
325-
with handle_http_errors(progress):
326-
deployment = _get_deployment(app_id, deployment_id)
335+
started_at = time.monotonic()
327336

328-
if deployment.status == DeploymentStatus.success:
329-
progress.log(
330-
f"🐔 Ready the chicken! Your app is ready at {deployment.url}"
331-
)
332-
break
333-
elif deployment.status == DeploymentStatus.failed:
334-
progress.set_error(
335-
f"Deployment failed. Please check the logs for more information.\n\n[link={check_deployment_url}]{check_deployment_url}[/link]"
336-
)
337+
last_message_changed_at = time.monotonic()
337338

338-
raise typer.Exit(1)
339-
else:
340-
message = next(messages)
341-
progress.log(
342-
f"{message} ({DeploymentStatus.to_human_readable(deployment.status)})"
343-
)
339+
with toolkit.progress(
340+
next(messages), inline_logs=True, lines_to_show=20
341+
) as progress:
342+
for line in _stream_build_logs(deployment_id):
343+
time_elapsed = time.monotonic() - started_at
344344

345-
time.sleep(4)
346-
time_elapsed += 4
345+
data = json.loads(line)
347346

348-
if time_elapsed == len(WAITING_MESSAGES) * 4:
347+
if "message" in data:
348+
progress.log(Text.from_ansi(data["message"].rstrip()))
349+
350+
if time_elapsed > 10:
349351
messages = cycle(LONG_WAIT_MESSAGES)
350352

353+
if (time.monotonic() - last_message_changed_at) > 2:
354+
progress.title = next(messages)
355+
356+
last_message_changed_at = time.monotonic()
357+
351358

352359
def _setup_environment_variables(toolkit: RichToolkit, app_id: str) -> None:
353360
if not toolkit.confirm("Do you want to setup environment variables?", tag="env"):
@@ -358,7 +365,9 @@ def _setup_environment_variables(toolkit: RichToolkit, app_id: str) -> None:
358365
env_vars = {}
359366

360367
while True:
361-
key = toolkit.input("Enter the environment variable name: [ENTER to skip]")
368+
key = toolkit.input(
369+
"Enter the environment variable name: [ENTER to skip]", required=False
370+
)
362371

363372
if key.strip() == "":
364373
break
@@ -462,5 +471,5 @@ def deploy(
462471
_wait_for_deployment(toolkit, app.id, deployment.id, check_deployment_url)
463472
else:
464473
toolkit.print(
465-
f"Check the status of your deployment at [link]{check_deployment_url}[/link]"
474+
f"Check the status of your deployment at [link={check_deployment_url}]{check_deployment_url}[/link]"
466475
)

src/fastapi_cloud_cli/utils/cli.py

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,52 @@
11
import contextlib
22
import logging
3-
from typing import Generator, Optional
3+
from typing import Any, Dict, Generator, List, Optional, Tuple
44

55
import typer
66
from httpx import HTTPError, HTTPStatusError, ReadTimeout
7+
from rich.segment import Segment
78
from rich_toolkit import RichToolkit, RichToolkitTheme
89
from rich_toolkit.progress import Progress
910
from rich_toolkit.styles import MinimalStyle, TaggedStyle
1011

1112
logger = logging.getLogger(__name__)
1213

1314

15+
class FastAPIStyle(TaggedStyle):
16+
def __init__(self, tag_width: int = 11):
17+
super().__init__(tag_width=tag_width)
18+
19+
def _get_tag_segments(
20+
self,
21+
metadata: Dict[str, Any],
22+
is_animated: bool = False,
23+
done: bool = False,
24+
) -> Tuple[List[Segment], int]:
25+
if not is_animated:
26+
return super()._get_tag_segments(metadata, is_animated, done)
27+
28+
emojis = [
29+
"🥚",
30+
"🐣",
31+
"🐤",
32+
"🐥",
33+
"🐓",
34+
"🐔",
35+
]
36+
37+
tag = emojis[self.animation_counter % len(emojis)]
38+
39+
if done:
40+
tag = emojis[-1]
41+
42+
left_padding = self.tag_width - 1
43+
left_padding = max(0, left_padding)
44+
45+
return [Segment(tag)], left_padding
46+
47+
1448
def get_rich_toolkit(minimal: bool = False) -> RichToolkit:
15-
style = MinimalStyle() if minimal else TaggedStyle(tag_width=11)
49+
style = MinimalStyle() if minimal else FastAPIStyle(tag_width=11)
1650

1751
theme = RichToolkitTheme(
1852
style=style,

tests/test_cli_deploy.py

Lines changed: 12 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -221,78 +221,6 @@ def test_uses_existing_app(
221221
assert app_data["slug"] in result.output
222222

223223

224-
@pytest.mark.respx(base_url=settings.base_api_url)
225-
def test_creates_and_uploads_deployment_then_fails(
226-
logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
227-
) -> None:
228-
steps = [
229-
Keys.ENTER,
230-
Keys.ENTER,
231-
Keys.ENTER,
232-
*"demo",
233-
Keys.ENTER,
234-
Keys.RIGHT_ARROW,
235-
Keys.ENTER,
236-
]
237-
238-
team = _get_random_team()
239-
app_data = _get_random_app(team_id=team["id"])
240-
241-
respx_mock.get("/teams/").mock(return_value=Response(200, json={"data": [team]}))
242-
243-
respx_mock.post("/apps/", json={"name": "demo", "team_id": team["id"]}).mock(
244-
return_value=Response(201, json=app_data)
245-
)
246-
247-
respx_mock.get(f"/apps/{app_data['id']}").mock(
248-
return_value=Response(200, json=app_data)
249-
)
250-
251-
deployment_data = _get_random_deployment(app_id=app_data["id"])
252-
253-
respx_mock.post(f"/apps/{app_data['id']}/deployments/").mock(
254-
return_value=Response(201, json=deployment_data)
255-
)
256-
respx_mock.post(
257-
f"/deployments/{deployment_data['id']}/upload",
258-
).mock(
259-
return_value=Response(
260-
200,
261-
json={
262-
"url": "http://test.com",
263-
"fields": {"key": "value"},
264-
},
265-
)
266-
)
267-
268-
respx_mock.post(
269-
"http://test.com",
270-
data={"key": "value"},
271-
).mock(return_value=Response(200))
272-
273-
respx_mock.get(
274-
f"/apps/{app_data['id']}/deployments/{deployment_data['id']}",
275-
).mock(
276-
return_value=Response(
277-
200,
278-
json=_get_random_deployment(app_id=app_data["id"], status="failed"),
279-
)
280-
)
281-
282-
respx_mock.post(
283-
f"/deployments/{deployment_data['id']}/upload-complete",
284-
).mock(return_value=Response(200))
285-
286-
with changing_dir(tmp_path), patch("click.getchar") as mock_getchar:
287-
mock_getchar.side_effect = steps
288-
289-
result = runner.invoke(app, ["deploy"])
290-
291-
assert result.exit_code == 1
292-
293-
assert "Checking the status of your deployment" in result.output
294-
295-
296224
@pytest.mark.respx(base_url=settings.base_api_url)
297225
def test_exits_successfully_when_deployment_is_done(
298226
logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
@@ -346,9 +274,12 @@ def test_exits_successfully_when_deployment_is_done(
346274
data={"key": "value"},
347275
).mock(return_value=Response(200))
348276

349-
respx_mock.get(f"/apps/{app_data['id']}/deployments/{deployment_data['id']}").mock(
277+
respx_mock.get(f"/deployments/{deployment_data['id']}/build-logs").mock(
350278
return_value=Response(
351-
200, json=_get_random_deployment(app_id=app_data["id"], status="success")
279+
200,
280+
json={
281+
"message": "Hello, world!",
282+
},
352283
)
353284
)
354285

@@ -359,7 +290,7 @@ def test_exits_successfully_when_deployment_is_done(
359290

360291
assert result.exit_code == 0
361292

362-
assert "Ready the chicken! Your app is ready at" in result.output
293+
# TODO: show a message when the deployment is done (based on the status)
363294

364295

365296
@pytest.mark.respx(base_url=settings.base_api_url)
@@ -394,9 +325,12 @@ def test_exists_successfully_when_deployment_is_done_when_app_is_configured(
394325
return_value=Response(200)
395326
)
396327

397-
respx_mock.get(f"/apps/{app_id}/deployments/{deployment_data['id']}").mock(
328+
respx_mock.get(f"/deployments/{deployment_data['id']}/build-logs").mock(
398329
return_value=Response(
399-
200, json=_get_random_deployment(app_id=app_id, status="success")
330+
200,
331+
json={
332+
"message": "Hello, world!",
333+
},
400334
)
401335
)
402336

@@ -409,7 +343,7 @@ def test_exists_successfully_when_deployment_is_done_when_app_is_configured(
409343

410344
assert result.exit_code == 0
411345

412-
assert "Ready the chicken! Your app is ready at" in result.output
346+
# TODO: show a message when the deployment is done (based on the status)
413347

414348

415349
@pytest.mark.respx(base_url=settings.base_api_url)

0 commit comments

Comments
 (0)