From 94f8ce1c3f888a9e13c90c2a1c43a05058da99da Mon Sep 17 00:00:00 2001 From: Patrick Arminio Date: Mon, 13 Jan 2025 19:35:06 +0000 Subject: [PATCH 1/4] =?UTF-8?q?=E2=9C=A8=20Show=20build=20logs=20in=20real?= =?UTF-8?q?=20time?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 2 +- src/fastapi_cloud_cli/commands/deploy.py | 23 +++++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 90cfcf4..56c7668 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,7 +37,7 @@ dependencies = [ "uvicorn[standard] >= 0.15.0", "rignore >= 0.5.1", "httpx >= 0.27.0,< 0.28.0", - "rich-toolkit >= 0.12.0", + "rich-toolkit >= 0.13.2", "pydantic >= 1.6.1", ] diff --git a/src/fastapi_cloud_cli/commands/deploy.py b/src/fastapi_cloud_cli/commands/deploy.py index 70fca43..1fde973 100644 --- a/src/fastapi_cloud_cli/commands/deploy.py +++ b/src/fastapi_cloud_cli/commands/deploy.py @@ -3,6 +3,7 @@ import tempfile import time import uuid +from collections.abc import Generator from enum import Enum from itertools import cycle from pathlib import Path @@ -212,6 +213,16 @@ def _create_environment_variables(app_id: str, env_vars: Dict[str, str]) -> None response.raise_for_status() +def _stream_build_logs(deployment_id: str) -> Generator[str, None, None]: + with APIClient() as client: + with client.stream( + "GET", f"/deployments/{deployment_id}/build-logs", timeout=60 + ) as response: + response.raise_for_status() + + yield from response.iter_lines() + + WAITING_MESSAGES = [ "🚀 Preparing for liftoff! Almost there...", "👹 Sneaking past the dependency gremlins... Don't wake them up!", @@ -320,6 +331,18 @@ def _wait_for_deployment( time_elapsed = 0 + with toolkit.progress( + "Deploying...", inline_logs=True, lines_to_show=20 + ) as progress: + for line in _stream_build_logs(deployment_id): + import json + + data = json.loads(line) + if "message" in data: + progress.log(data["message"].rstrip()) + + toolkit.print_line() + with toolkit.progress("Deploying...") as progress: while True: with handle_http_errors(progress): From 5f5d1035292eb271f02c472e1b3e366e8829356b Mon Sep 17 00:00:00 2001 From: Patrick Arminio Date: Fri, 14 Mar 2025 14:33:41 +0000 Subject: [PATCH 2/4] Ansi support --- src/fastapi_cloud_cli/commands/deploy.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/fastapi_cloud_cli/commands/deploy.py b/src/fastapi_cloud_cli/commands/deploy.py index 1fde973..7469c8c 100644 --- a/src/fastapi_cloud_cli/commands/deploy.py +++ b/src/fastapi_cloud_cli/commands/deploy.py @@ -13,6 +13,7 @@ import typer from httpx import Client from pydantic import BaseModel +from rich.text import Text from rich_toolkit import RichToolkit from rich_toolkit.menu import Option from typing_extensions import Annotated @@ -338,8 +339,9 @@ def _wait_for_deployment( import json data = json.loads(line) + if "message" in data: - progress.log(data["message"].rstrip()) + progress.log(Text.from_ansi(data["message"].rstrip())) toolkit.print_line() From 439e937a6613f3bcff1d32128a6cd98efafe3638 Mon Sep 17 00:00:00 2001 From: Patrick Arminio Date: Wed, 23 Apr 2025 15:42:49 +0100 Subject: [PATCH 3/4] Improvements --- src/fastapi_cloud_cli/commands/deploy.py | 49 ++++++++---------------- src/fastapi_cloud_cli/utils/cli.py | 38 +++++++++++++++++- 2 files changed, 53 insertions(+), 34 deletions(-) diff --git a/src/fastapi_cloud_cli/commands/deploy.py b/src/fastapi_cloud_cli/commands/deploy.py index 7469c8c..ec17110 100644 --- a/src/fastapi_cloud_cli/commands/deploy.py +++ b/src/fastapi_cloud_cli/commands/deploy.py @@ -1,3 +1,4 @@ +import json import logging import tarfile import tempfile @@ -326,52 +327,34 @@ def _wait_for_deployment( toolkit.print_line() toolkit.print( - f"You can also check the status at [link]{check_deployment_url}[/link]", + f"You can also check the status at [link={check_deployment_url}]{check_deployment_url}[/link]", ) toolkit.print_line() time_elapsed = 0 + started_at = time.monotonic() + + last_message_changed_at = time.monotonic() + with toolkit.progress( - "Deploying...", inline_logs=True, lines_to_show=20 + next(messages), inline_logs=True, lines_to_show=20 ) as progress: for line in _stream_build_logs(deployment_id): - import json + time_elapsed = time.monotonic() - started_at data = json.loads(line) if "message" in data: progress.log(Text.from_ansi(data["message"].rstrip())) - toolkit.print_line() - - with toolkit.progress("Deploying...") as progress: - while True: - with handle_http_errors(progress): - deployment = _get_deployment(app_id, deployment_id) - - if deployment.status == DeploymentStatus.success: - progress.log( - f"🐔 Ready the chicken! Your app is ready at {deployment.url}" - ) - break - elif deployment.status == DeploymentStatus.failed: - progress.set_error( - f"Deployment failed. Please check the logs for more information.\n\n[link={check_deployment_url}]{check_deployment_url}[/link]" - ) - - raise typer.Exit(1) - else: - message = next(messages) - progress.log( - f"{message} ({DeploymentStatus.to_human_readable(deployment.status)})" - ) + if time_elapsed > 10: + messages = cycle(LONG_WAIT_MESSAGES) - time.sleep(4) - time_elapsed += 4 + if (time.monotonic() - last_message_changed_at) > 2: + progress.title = next(messages) - if time_elapsed == len(WAITING_MESSAGES) * 4: - messages = cycle(LONG_WAIT_MESSAGES) + last_message_changed_at = time.monotonic() def _setup_environment_variables(toolkit: RichToolkit, app_id: str) -> None: @@ -383,7 +366,9 @@ def _setup_environment_variables(toolkit: RichToolkit, app_id: str) -> None: env_vars = {} while True: - key = toolkit.input("Enter the environment variable name: [ENTER to skip]") + key = toolkit.input( + "Enter the environment variable name: [ENTER to skip]", required=False + ) if key.strip() == "": break @@ -487,5 +472,5 @@ def deploy( _wait_for_deployment(toolkit, app.id, deployment.id, check_deployment_url) else: toolkit.print( - f"Check the status of your deployment at [link]{check_deployment_url}[/link]" + f"Check the status of your deployment at [link={check_deployment_url}]{check_deployment_url}[/link]" ) diff --git a/src/fastapi_cloud_cli/utils/cli.py b/src/fastapi_cloud_cli/utils/cli.py index b917e7d..de79bf4 100644 --- a/src/fastapi_cloud_cli/utils/cli.py +++ b/src/fastapi_cloud_cli/utils/cli.py @@ -1,9 +1,10 @@ import contextlib import logging -from typing import Generator, Optional +from typing import Any, Dict, Generator, List, Optional, Tuple import typer from httpx import HTTPError, HTTPStatusError, ReadTimeout +from rich.segment import Segment from rich_toolkit import RichToolkit, RichToolkitTheme from rich_toolkit.progress import Progress from rich_toolkit.styles import MinimalStyle, TaggedStyle @@ -11,8 +12,41 @@ logger = logging.getLogger(__name__) +class FastAPIStyle(TaggedStyle): + def __init__(self, tag_width: int = 11): + super().__init__(tag_width=tag_width) + + def _get_tag_segments( + self, + metadata: Dict[str, Any], + is_animated: bool = False, + done: bool = False, + ) -> Tuple[List[Segment], int]: + if not is_animated: + return super()._get_tag_segments(metadata, is_animated, done) + + emojis = [ + "🥚", + "🐣", + "🐤", + "🐥", + "🐓", + "🐔", + ] + + tag = emojis[self.animation_counter % len(emojis)] + + if done: + tag = emojis[-1] + + left_padding = self.tag_width - 1 + left_padding = max(0, left_padding) + + return [Segment(tag)], left_padding + + def get_rich_toolkit(minimal: bool = False) -> RichToolkit: - style = MinimalStyle() if minimal else TaggedStyle(tag_width=11) + style = MinimalStyle() if minimal else FastAPIStyle(tag_width=11) theme = RichToolkitTheme( style=style, From 9951bab5a6640bfce7075f62b6f4fc9cfff7a463 Mon Sep 17 00:00:00 2001 From: Patrick Arminio Date: Wed, 23 Apr 2025 15:43:24 +0100 Subject: [PATCH 4/4] Bump version --- pyproject.toml | 44 ++++-------- src/fastapi_cloud_cli/commands/deploy.py | 5 +- tests/test_cli_deploy.py | 90 ++++-------------------- 3 files changed, 27 insertions(+), 112 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 56c7668..8a450da 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,12 +2,10 @@ name = "fastapi-cloud-cli" dynamic = ["version"] description = "Deploy and manage FastAPI Cloud apps from the command line 🚀" -authors = [ - {name = "Patrick Arminio", email = "patrick@fastapilabs.com"}, -] +authors = [{ name = "Patrick Arminio", email = "patrick@fastapilabs.com" }] requires-python = ">=3.8" readme = "README.md" -license = {text = "MIT"} +license = { text = "MIT" } classifiers = [ "Intended Audience :: Information Technology", "Intended Audience :: System Administrators", @@ -37,14 +35,12 @@ dependencies = [ "uvicorn[standard] >= 0.15.0", "rignore >= 0.5.1", "httpx >= 0.27.0,< 0.28.0", - "rich-toolkit >= 0.13.2", + "rich-toolkit >= 0.14.3", "pydantic >= 1.6.1", ] [project.optional-dependencies] -standard = [ - "uvicorn[standard] >= 0.15.0", -] +standard = ["uvicorn[standard] >= 0.15.0"] [project.urls] Homepage = "https://github.com/fastapi/fastapi-cloud-cli" @@ -62,32 +58,20 @@ version = { source = "file", path = "src/fastapi_cloud_cli/__init__.py" } distribution = true [tool.pdm.build] -source-includes = [ - "tests/", - "requirements*.txt", - "scripts/", -] +source-includes = ["tests/", "requirements*.txt", "scripts/"] [tool.pytest.ini_options] -addopts = [ - "--strict-config", - "--strict-markers", -] +addopts = ["--strict-config", "--strict-markers"] xfail_strict = true junit_family = "xunit2" [tool.coverage.run] parallel = true data_file = "coverage/.coverage" -source = [ - "src", - "tests", -] +source = ["src", "tests"] context = '${CONTEXT}' dynamic_context = "test_function" -omit = [ - "tests/assets/*", -] +omit = ["tests/assets/*"] [tool.coverage.report] show_missing = true @@ -104,9 +88,7 @@ show_contexts = true [tool.mypy] strict = true -exclude = [ - "tests/assets/*", -] +exclude = ["tests/assets/*"] [tool.ruff.lint] select = [ @@ -115,13 +97,13 @@ select = [ "F", # pyflakes "I", # isort "B", # flake8-bugbear - "C4", # flake8-comprehensions + "C4", # flake8-comprehensions "UP", # pyupgrade ] ignore = [ - "E501", # line too long, handled by black - "B008", # do not perform function calls in argument defaults - "C901", # too complex + "E501", # line too long, handled by black + "B008", # do not perform function calls in argument defaults + "C901", # too complex "W191", # indentation contains tabs ] diff --git a/src/fastapi_cloud_cli/commands/deploy.py b/src/fastapi_cloud_cli/commands/deploy.py index ec17110..685b916 100644 --- a/src/fastapi_cloud_cli/commands/deploy.py +++ b/src/fastapi_cloud_cli/commands/deploy.py @@ -4,11 +4,10 @@ import tempfile import time import uuid -from collections.abc import Generator from enum import Enum from itertools import cycle from pathlib import Path -from typing import Any, Dict, List, Optional, Union +from typing import Any, Dict, Generator, List, Optional, Union import rignore import typer @@ -331,7 +330,7 @@ def _wait_for_deployment( ) toolkit.print_line() - time_elapsed = 0 + time_elapsed = 0.0 started_at = time.monotonic() diff --git a/tests/test_cli_deploy.py b/tests/test_cli_deploy.py index cd8ae4a..ad02ce7 100644 --- a/tests/test_cli_deploy.py +++ b/tests/test_cli_deploy.py @@ -221,78 +221,6 @@ def test_uses_existing_app( assert app_data["slug"] in result.output -@pytest.mark.respx(base_url=settings.base_api_url) -def test_creates_and_uploads_deployment_then_fails( - logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter -) -> None: - steps = [ - Keys.ENTER, - Keys.ENTER, - Keys.ENTER, - *"demo", - Keys.ENTER, - Keys.RIGHT_ARROW, - Keys.ENTER, - ] - - team = _get_random_team() - app_data = _get_random_app(team_id=team["id"]) - - respx_mock.get("/teams/").mock(return_value=Response(200, json={"data": [team]})) - - respx_mock.post("/apps/", json={"name": "demo", "team_id": team["id"]}).mock( - return_value=Response(201, json=app_data) - ) - - respx_mock.get(f"/apps/{app_data['id']}").mock( - return_value=Response(200, json=app_data) - ) - - deployment_data = _get_random_deployment(app_id=app_data["id"]) - - respx_mock.post(f"/apps/{app_data['id']}/deployments/").mock( - return_value=Response(201, json=deployment_data) - ) - respx_mock.post( - f"/deployments/{deployment_data['id']}/upload", - ).mock( - return_value=Response( - 200, - json={ - "url": "http://test.com", - "fields": {"key": "value"}, - }, - ) - ) - - respx_mock.post( - "http://test.com", - data={"key": "value"}, - ).mock(return_value=Response(200)) - - respx_mock.get( - f"/apps/{app_data['id']}/deployments/{deployment_data['id']}", - ).mock( - return_value=Response( - 200, - json=_get_random_deployment(app_id=app_data["id"], status="failed"), - ) - ) - - respx_mock.post( - f"/deployments/{deployment_data['id']}/upload-complete", - ).mock(return_value=Response(200)) - - with changing_dir(tmp_path), patch("click.getchar") as mock_getchar: - mock_getchar.side_effect = steps - - result = runner.invoke(app, ["deploy"]) - - assert result.exit_code == 1 - - assert "Checking the status of your deployment" in result.output - - @pytest.mark.respx(base_url=settings.base_api_url) def test_exits_successfully_when_deployment_is_done( logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter @@ -346,9 +274,12 @@ def test_exits_successfully_when_deployment_is_done( data={"key": "value"}, ).mock(return_value=Response(200)) - respx_mock.get(f"/apps/{app_data['id']}/deployments/{deployment_data['id']}").mock( + respx_mock.get(f"/deployments/{deployment_data['id']}/build-logs").mock( return_value=Response( - 200, json=_get_random_deployment(app_id=app_data["id"], status="success") + 200, + json={ + "message": "Hello, world!", + }, ) ) @@ -359,7 +290,7 @@ def test_exits_successfully_when_deployment_is_done( assert result.exit_code == 0 - assert "Ready the chicken! Your app is ready at" in result.output + # TODO: show a message when the deployment is done (based on the status) @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( return_value=Response(200) ) - respx_mock.get(f"/apps/{app_id}/deployments/{deployment_data['id']}").mock( + respx_mock.get(f"/deployments/{deployment_data['id']}/build-logs").mock( return_value=Response( - 200, json=_get_random_deployment(app_id=app_id, status="success") + 200, + json={ + "message": "Hello, world!", + }, ) ) @@ -409,7 +343,7 @@ def test_exists_successfully_when_deployment_is_done_when_app_is_configured( assert result.exit_code == 0 - assert "Ready the chicken! Your app is ready at" in result.output + # TODO: show a message when the deployment is done (based on the status) @pytest.mark.respx(base_url=settings.base_api_url)