fix: flatten React Router structure — no nested Routes, each page is a top-level route

This commit is contained in:
enzo
2026-07-22 19:21:29 +08:00
parent 07ff8c2a12
commit 1de5fcf698
3720 changed files with 1254827 additions and 49 deletions
@@ -0,0 +1,515 @@
import json
import logging
import time
from collections.abc import Callable, Generator
from contextlib import contextmanager
from datetime import timedelta
from enum import Enum
from functools import wraps
from typing import (
Annotated,
Literal,
TypeVar,
)
import httpx
import typer
from pydantic import BaseModel, Field, TypeAdapter, ValidationError
from rich_toolkit.progress import Progress
from typing_extensions import ParamSpec
from fastapi_cloud_cli import __version__
from fastapi_cloud_cli.config import Settings
from fastapi_cloud_cli.utils.errors import ErrorCode, ErrorToolkit
from .auth import AuthMode, Identity, delete_auth_config
logger = logging.getLogger(__name__)
STREAM_LOGS_MAX_RETRIES = 3
STREAM_LOGS_TIMEOUT = timedelta(minutes=5)
class StreamLogError(Exception):
"""Raised when there's an error streaming logs (build or app logs)."""
def __init__(self, message: str, *, status_code: int | None = None) -> None:
super().__init__(message)
self.status_code = status_code
class TooManyRetriesError(Exception):
pass
class AppLogEntry(BaseModel):
timestamp: str
message: str
level: str
class BuildLogLineGeneric(BaseModel):
type: Literal["complete", "failed", "timeout", "heartbeat"]
id: str | None = None
class BuildLogLineMessage(BaseModel):
type: Literal["message"] = "message"
message: str
id: str | None = None
BuildLogLine = BuildLogLineMessage | BuildLogLineGeneric
BuildLogAdapter: TypeAdapter[BuildLogLine] = TypeAdapter(
Annotated[BuildLogLine, Field(discriminator="type")]
)
@contextmanager
def attempt(attempt_number: int) -> Generator[None, None, None]:
def _backoff() -> None:
backoff_seconds = min(2**attempt_number, 30)
logger.debug(
"Retrying in %ds (attempt %d)",
backoff_seconds,
attempt_number,
)
time.sleep(backoff_seconds)
try:
yield
except (
httpx.TimeoutException,
httpx.NetworkError,
httpx.RemoteProtocolError,
) as error:
logger.debug("Network error (will retry): %s", error)
_backoff()
except httpx.HTTPStatusError as error:
if error.response.status_code >= 500:
logger.debug(
"Server error %d (will retry): %s",
error.response.status_code,
error,
)
_backoff()
else:
# The streaming callers read the body before raising, so the
# server's error detail is available here.
raise StreamLogError(
f"HTTP {error.response.status_code}: {error.response.text}",
status_code=error.response.status_code,
) from error
P = ParamSpec("P")
T = TypeVar("T")
def attempts(
total_attempts: int = 3, timeout: timedelta = timedelta(minutes=5)
) -> Callable[
[Callable[P, Generator[T, None, None]]], Callable[P, Generator[T, None, None]]
]:
def decorator(
func: Callable[P, Generator[T, None, None]],
) -> Callable[P, Generator[T, None, None]]:
@wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> Generator[T, None, None]:
start = time.monotonic()
for attempt_number in range(total_attempts):
if time.monotonic() - start > timeout.total_seconds():
raise TimeoutError(
f"Log streaming timed out after {timeout.total_seconds():.0f}s"
)
with attempt(attempt_number):
yield from func(*args, **kwargs)
# If we get here without exception, the generator completed successfully
return
raise TooManyRetriesError(f"Failed after {total_attempts} attempts")
return wrapper
return decorator
class DeploymentStatus(str, Enum):
waiting_upload = "waiting_upload"
upload_cancelled = "upload_cancelled"
ready_for_build = "ready_for_build"
building = "building"
extracting = "extracting"
extracting_failed = "extracting_failed"
building_image = "building_image"
building_image_failed = "building_image_failed"
deploying = "deploying"
deploying_failed = "deploying_failed"
verifying = "verifying"
verifying_failed = "verifying_failed"
verifying_skipped = "verifying_skipped"
success = "success"
expired = "expired"
failed = "failed"
@classmethod
def to_human_readable(cls, status: "DeploymentStatus") -> str:
return {
cls.waiting_upload: "Awaiting Upload",
cls.upload_cancelled: "Upload Cancelled",
cls.ready_for_build: "Build Queued",
cls.building: "Building",
cls.extracting: "Extracting Upload",
cls.extracting_failed: "Extraction Failed",
cls.building_image: "Building Image",
cls.building_image_failed: "Build Failed",
cls.deploying: "Deploying Image",
cls.deploying_failed: "Deployment Failed",
cls.verifying: "Verifying Readiness",
cls.verifying_failed: "Verification Failed",
cls.verifying_skipped: "Verification Skipped",
cls.success: "Ready",
cls.expired: "Expired",
cls.failed: "Failed",
}[status]
SUCCESSFUL_STATUSES = {DeploymentStatus.success, DeploymentStatus.verifying_skipped}
FAILED_STATUSES = {
DeploymentStatus.failed,
DeploymentStatus.verifying_failed,
DeploymentStatus.deploying_failed,
DeploymentStatus.building_image_failed,
DeploymentStatus.extracting_failed,
}
TERMINAL_STATUSES = SUCCESSFUL_STATUSES | FAILED_STATUSES
POLL_INTERVAL = 2.0
POLL_TIMEOUT = timedelta(seconds=120)
POLL_MAX_RETRIES = 5
def _handle_unauthorized(auth_mode: AuthMode) -> str:
message = "The specified token is not valid. "
if auth_mode == "user":
delete_auth_config()
message += "Use `fastapi login` to generate a new token."
else:
message += "Make sure to use a valid token."
return message
def _get_response_error_message(response: httpx.Response) -> str | None:
try:
data = response.json()
except (json.JSONDecodeError, httpx.ResponseNotRead):
return None
if not isinstance(data, dict):
return None # pragma: no cover
detail = data.get("detail")
if not isinstance(detail, str):
return None # pragma: no cover
return detail
def handle_http_error(
error: httpx.HTTPError,
default_message: str | None = None,
not_found_message: str | None = None,
auth_mode: AuthMode = "user",
) -> str:
message: str | None = None
if isinstance(error, httpx.HTTPStatusError):
status_code = error.response.status_code
# Handle validation errors from Pydantic models, this should make it easier to debug :)
if status_code == 422:
logger.debug(error.response.json()) # pragma: no cover
elif status_code == 400:
message = _get_response_error_message(error.response)
elif status_code == 409:
message = _get_response_error_message(error.response)
elif status_code == 401:
message = _handle_unauthorized(auth_mode=auth_mode)
elif status_code == 403:
message = (
_get_response_error_message(error.response)
or "You don't have permissions for this resource"
)
elif status_code == 404:
message = (
_get_response_error_message(error.response)
or not_found_message
or "Resource not found."
)
if not message:
message = (
default_message
or f"Something went wrong while contacting the FastAPI Cloud server. Please try again later. \n\n{error}"
)
return message
def get_http_error_code(error: httpx.HTTPError) -> ErrorCode:
if isinstance(error, httpx.TimeoutException | httpx.NetworkError):
return "network_error"
if isinstance(error, httpx.HTTPStatusError):
status_code = error.response.status_code
if status_code in {400, 409}:
return "invalid_input"
if status_code == 401:
return "invalid_token"
if status_code == 403:
return "permission_denied"
if status_code == 404:
return "not_found"
return "api_error"
def get_http_error_hint(code: ErrorCode, *, auth_mode: AuthMode = "user") -> str | None:
if code == "invalid_token":
if auth_mode == "user":
return "Run `fastapi cloud login` to generate a new token."
return "Make sure FASTAPI_CLOUD_TOKEN contains a valid token."
return None
class APIClient(httpx.Client):
auth_mode: AuthMode
def __init__(self, use_deploy_token: bool = False) -> None:
settings = Settings.get()
identity = Identity()
token: str | None
if use_deploy_token and identity.deploy_token:
token = identity.deploy_token
self.auth_mode = "token"
else:
token = identity.user_token
self.auth_mode = "user"
headers = {"User-Agent": f"fastapi-cloud-cli/{__version__}"}
if token:
headers["Authorization"] = f"Bearer {token}"
super().__init__(
base_url=settings.base_api_url,
timeout=httpx.Timeout(20),
headers=headers,
)
@contextmanager
def handle_http_errors(
self,
progress: Progress,
default_message: str | None = None,
*,
not_found_message: str | None = None,
toolkit: ErrorToolkit | None = None,
) -> Generator[None, None, None]:
# TODO: Once every command supports JSON output, require toolkit here
# and let it be the single human/JSON error rendering boundary.
mode = toolkit.mode if toolkit else "human"
try:
yield
except httpx.ReadTimeout as e:
logger.debug(e)
message = (
"The request to the FastAPI Cloud server timed out."
" Please try again later."
)
if mode == "json" and toolkit:
toolkit.fail(
"network_error",
message,
hint="Please try again later.",
)
progress.set_error(message)
raise typer.Exit(1) from None # pragma: no cover
except httpx.HTTPError as e:
logger.debug(e)
message = handle_http_error(
e,
default_message,
not_found_message=not_found_message,
auth_mode=self.auth_mode,
)
code = get_http_error_code(e)
if mode == "json" and toolkit:
toolkit.fail(
code,
message,
hint=get_http_error_hint(code, auth_mode=self.auth_mode),
)
else:
progress.set_error(message)
raise typer.Exit(1) from None
@attempts(STREAM_LOGS_MAX_RETRIES, STREAM_LOGS_TIMEOUT)
def stream_build_logs(
self, deployment_id: str, *, follow: bool = True
) -> Generator[BuildLogLine, None, None]:
last_id = None
while True:
params = {"last_id": last_id} if last_id else None
with self.stream(
"GET",
f"/deployments/{deployment_id}/build-logs",
timeout=60,
params=params,
) as response:
if response.is_error:
# Load the body while the stream is open so error handlers
# can surface the server's error detail.
response.read()
response.raise_for_status()
for line in response.iter_lines():
if not line or not line.strip():
continue
if log_line := self._parse_log_line(line):
if log_line.id:
last_id = log_line.id
if log_line.type == "message":
yield log_line
if log_line.type in ("complete", "failed"):
yield log_line
return
if log_line.type == "timeout":
logger.debug("Received timeout; reconnecting")
if not follow:
return
break # Breaks for loop to reconnect
else:
if not follow:
return
logger.debug("Connection closed by server unexpectedly; will retry")
raise httpx.NetworkError("Connection closed without terminal state")
time.sleep(0.5)
def _parse_log_line(self, line: str) -> BuildLogLine | None:
try:
return BuildLogAdapter.validate_json(line)
except (ValidationError, json.JSONDecodeError) as e:
logger.debug("Skipping malformed log: %s (error: %s)", line[:100], e)
return None
@attempts(STREAM_LOGS_MAX_RETRIES, STREAM_LOGS_TIMEOUT)
def stream_app_logs(
self,
app_id: str,
tail: int,
since: str,
follow: bool,
) -> Generator[AppLogEntry, None, None]:
timeout = 120 if follow else 30
with self.stream(
"GET",
f"/apps/{app_id}/logs/stream",
params={
"tail": tail,
"since": since,
"follow": follow,
},
timeout=timeout,
) as response:
if response.is_error:
# Load the body while the stream is open so error handlers
# can surface the server's error detail.
response.read()
response.raise_for_status()
for line in response.iter_lines():
if not line or not line.strip(): # pragma: no cover
continue
try:
data = json.loads(line)
except json.JSONDecodeError:
logger.debug("Failed to parse log line: %s", line)
continue
if data.get("type") == "heartbeat":
continue
if data.get("type") == "error":
raise StreamLogError(data.get("message", "Unknown error"))
try:
yield AppLogEntry.model_validate(data)
except ValidationError as e: # pragma: no cover
logger.debug("Failed to parse log entry: %s - %s", data, e)
continue
def poll_deployment_status(
self,
deployment_id: str,
) -> DeploymentStatus:
start = time.monotonic()
error_count = 0
while True:
if time.monotonic() - start > POLL_TIMEOUT.total_seconds():
raise TimeoutError("Deployment verification timed out")
with attempt(error_count):
response = self.get(f"/deployments/{deployment_id}")
response.raise_for_status()
status = DeploymentStatus(response.json()["status"])
error_count = 0
if status in TERMINAL_STATUSES:
return status
time.sleep(POLL_INTERVAL)
continue
error_count += 1
if error_count >= POLL_MAX_RETRIES:
raise TooManyRetriesError(
f"Failed after {POLL_MAX_RETRIES} attempts polling deployment status"
)
@@ -0,0 +1,94 @@
import logging
from pathlib import Path
from typing import TYPE_CHECKING
from pydantic import BaseModel
if TYPE_CHECKING:
from fastapi_cloud_cli.utils.cli import FastAPIRichToolkit
logger = logging.getLogger("fastapi_cli")
class AppConfig(BaseModel):
app_id: str
team_id: str
def get_app_config(path_to_deploy: Path) -> AppConfig | None:
config_path = path_to_deploy / ".fastapicloud/cloud.json"
logger.debug("Looking for app config at: %s", config_path)
if not config_path.exists():
logger.debug("App config file doesn't exist")
return None
logger.debug("App config loaded successfully")
return AppConfig.model_validate_json(config_path.read_text(encoding="utf-8"))
def resolve_app_id(
*, app_id: str | None = None, path: Path | None = None
) -> str | None:
if app_id is not None:
return app_id
app_config = get_app_config(path or Path.cwd())
if app_config is None:
return None
return app_config.app_id
def resolve_app_id_or_fail(
toolkit: "FastAPIRichToolkit",
*,
app_id: str | None = None,
path: Path | None = None,
hint: str = "Pass --app-id or run `fastapi cloud apps create --link` first.",
) -> str:
target_app_id = resolve_app_id(app_id=app_id, path=path)
if target_app_id is None:
toolkit.fail(
"missing_required_input",
"App ID is required.",
hint=hint,
)
return target_app_id
README = """
> Why do I have a folder named ".fastapicloud" in my project? 🤔
The ".fastapicloud" folder is created when you link a directory to a FastAPI Cloud project.
> What does the "cloud.json" file contain?
The "cloud.json" file contains:
- The ID of the FastAPI app that you linked ("app_id")
- The ID of the team your FastAPI Cloud project is owned by ("team_id")
> Should I commit the ".fastapicloud" folder?
No, you should not commit the ".fastapicloud" folder to your version control system.
That's why there's a ".gitignore" file in this folder.
"""
def write_app_config(path_to_deploy: Path, app_config: AppConfig) -> None:
config_path = path_to_deploy / ".fastapicloud/cloud.json"
readme_path = path_to_deploy / ".fastapicloud/README.md"
gitignore_path = path_to_deploy / ".fastapicloud/.gitignore"
logger.debug("Writing app config to: %s", config_path)
logger.debug("App config data: %s", app_config)
config_path.parent.mkdir(parents=True, exist_ok=True)
config_path.write_text(
app_config.model_dump_json(),
encoding="utf-8",
)
readme_path.write_text(README, encoding="utf-8")
gitignore_path.write_text("*")
logger.debug("App config files written successfully")
@@ -0,0 +1,150 @@
import base64
import binascii
import json
import logging
import os
import time
from typing import Literal
from pydantic import BaseModel
from .config import get_auth_path
logger = logging.getLogger("fastapi_cli")
AuthMode = Literal["token", "user"]
class AuthConfig(BaseModel):
access_token: str
def write_auth_config(auth_data: AuthConfig) -> None:
auth_path = get_auth_path()
logger.debug("Writing auth config to: %s", auth_path)
auth_path.write_text(auth_data.model_dump_json(), encoding="utf-8")
logger.debug("Auth config written successfully")
def delete_auth_config() -> None:
auth_path = get_auth_path()
logger.debug("Deleting auth config at: %s", auth_path)
if auth_path.exists():
auth_path.unlink()
logger.debug("Auth config deleted successfully")
else:
logger.debug("Auth config file doesn't exist, nothing to delete")
def read_auth_config() -> AuthConfig | None:
auth_path = get_auth_path()
logger.debug("Reading auth config from: %s", auth_path)
if not auth_path.exists():
logger.debug("Auth config file doesn't exist")
return None
logger.debug("Auth config loaded successfully")
return AuthConfig.model_validate_json(auth_path.read_text(encoding="utf-8"))
def _get_auth_token() -> str | None:
logger.debug("Getting auth token")
auth_data = read_auth_config()
if auth_data is None:
logger.debug("No auth data found")
return None
logger.debug("Auth token retrieved successfully")
return auth_data.access_token
def _is_jwt_expired(token: str) -> bool:
try:
parts = token.split(".")
if len(parts) != 3:
logger.debug("Invalid JWT format: expected 3 parts, got %d", len(parts))
return True
payload = parts[1]
# Add padding if needed (JWT uses base64url encoding without padding)
if padding := len(payload) % 4:
payload += "=" * (4 - padding)
payload = payload.replace("-", "+").replace("_", "/")
decoded_bytes = base64.b64decode(payload)
payload_data = json.loads(decoded_bytes)
exp = payload_data.get("exp")
if exp is None:
logger.debug("No 'exp' claim found in token")
return False
if not isinstance(exp, int): # pragma: no cover
logger.debug("Invalid 'exp' claim: expected int, got %s", type(exp))
return True
current_time = time.time()
is_expired = current_time >= exp
logger.debug(
"Token expiration check: current=%d, exp=%d, expired=%s",
current_time,
exp,
is_expired,
)
return is_expired
except (binascii.Error, json.JSONDecodeError) as e:
logger.debug("Error parsing JWT token: %s", e)
return True
class Identity:
def __init__(self) -> None:
self._user_token = _get_auth_token()
self._deploy_token: str | None = os.environ.get("FASTAPI_CLOUD_TOKEN")
@property
def user_token(self) -> str | None:
return self._user_token
@property
def deploy_token(self) -> str | None:
return self._deploy_token
def is_user_token_expired(self) -> bool:
if not self._user_token:
return True
return _is_jwt_expired(self._user_token)
def is_logged_in(self) -> bool:
if self._user_token is None:
logger.debug("Login status: False (no token)")
return False
if self.is_user_token_expired():
logger.debug("Login status: False (token expired)")
return False
logger.debug("Login status: True")
return True
def has_deploy_token(self) -> bool:
if self._deploy_token is None:
logger.debug("Deploy token is not provided")
return False
logger.debug("Deploy token found")
return True
@@ -0,0 +1,458 @@
import logging
import os
import time
from collections.abc import Callable, Iterable, Iterator
from types import TracebackType
from typing import Any, Literal, NoReturn, Protocol, TypeVar, cast
import typer
from pydantic import BaseModel
from rich._loop import loop_first_last
from rich.console import Console, ConsoleOptions, Group, RenderableType, RenderResult
from rich.padding import Padding
from rich.segment import Segment
from rich.style import Style
from rich.table import Table
from rich.text import Text
from rich_toolkit import RichToolkit, RichToolkitTheme
from rich_toolkit.container import Container
from rich_toolkit.element import CursorOffset, Element
from rich_toolkit.input import Input
from rich_toolkit.progress import Progress
from rich_toolkit.styles import BaseStyle, MinimalStyle
from fastapi_cloud_cli.utils.errors import ErrorCode
from fastapi_cloud_cli.utils.execution import is_ci_enabled
from fastapi_cloud_cli.utils.version_check import (
DISABLE_VERSION_CHECK_ENV,
BackgroundVersionCheck,
)
logger = logging.getLogger(__name__)
OutputT = TypeVar("OutputT", bound=BaseModel)
OutputRenderer = Callable[[OutputT, "FastAPIRichToolkit"], None]
class ErrorRenderer(Protocol):
def __call__(
self,
toolkit: "FastAPIRichToolkit",
*,
code: ErrorCode,
message: str,
hint: str,
) -> None: ...
# the leading space right-aligns the one-cell ✗ within the two-cell emoji
# slot, so its right edge and gap to the text match the emoji bullets
ERROR_BULLET = " [bold][error]✗[/][/]"
TITLE_SWEEP_SHADES = ("█", "▓", "▓", "▒", "░")
TITLE_SWEEP_DELAY = 0.015
def _title_sweep_frames(text: str) -> Iterator[tuple[str, str, str]]:
"""Frames of a gradient sweep painting the title chip into existence.
Each frame is split into the part of the chip already swept (rendered
with the chip's background), the visible sweep shades, and the still
untouched tail, all together exactly as wide as the chip (one space of
padding around the text) so the real chip prints cleanly over the last
frame."""
chip = f" {text} "
width = len(chip)
for light_pos in range(-len(TITLE_SWEEP_SHADES), width + len(TITLE_SWEEP_SHADES)):
sweep_start = light_pos - len(TITLE_SWEEP_SHADES) + 1
chip_end = max(0, min(width, sweep_start))
shades = "".join(
shade
for index, shade in enumerate(TITLE_SWEEP_SHADES)
if 0 <= sweep_start + index < width
)
tail = " " * (width - chip_end - len(shades))
yield chip[:chip_end], shades, tail
def _strip_rich_markup(value: str | None) -> str | None:
if value is None:
return None
return Text.from_markup(value).plain
class IndentedBlock:
"""Indent a renderable, hanging a prefix (e.g. an emoji bullet) on the
first line.
Blank lines stay truly empty: live renders (inputs, menus) don't end
with a newline, so any padding on a final blank line would leave the
terminal cursor mid-line and shift whatever gets printed next.
"""
def __init__(
self,
renderable: RenderableType,
*,
first_prefix: Text,
prefix: Text,
) -> None:
self.renderable = renderable
self.first_prefix = first_prefix
self.prefix = prefix
# Text renders its `end` ("\n" by default), which would break lines
self.first_prefix.end = ""
self.prefix.end = ""
def __rich_console__(
self, console: Console, options: ConsoleOptions
) -> RenderResult:
prefix_width = max(self.first_prefix.cell_len, self.prefix.cell_len)
lines = console.render_lines(
self.renderable,
options.update_width(options.max_width - prefix_width),
pad=False,
)
new_line = Segment.line()
for first, last, line in loop_first_last(lines):
if any(segment.text.strip() for segment in line):
yield from console.render(
self.first_prefix if first else self.prefix, options
)
yield from line
elif last:
# a zero-width space stops live renders from stripping the
# final blank line, keeping the cursor at column 0
yield Segment("​")
yield new_line
class FastAPIStyle(BaseStyle):
"""Header chip + uniform indent, without the per-line tag gutter.
Titles render as a single chip at the top of the command's output and
everything else gets a fixed left indent, so renderables don't need to
be wrapped in `Padding` manually. Emojis (`emoji=` metadata, or the
progress animation/done emoji) hang to the left of the text like list
bullets.
"""
content_padding = 1
emoji_column_width = 3
animation_emojis = [
"🥚",
"🐣",
"🐤",
"🐥",
"🐓",
"🐔",
]
def render_element(
self,
element: Any,
is_active: bool = False,
done: bool = False,
parent: Element | None = None,
**kwargs: Any,
) -> RenderableType:
rendered = super().render_element(
element=element, is_active=is_active, done=done, parent=parent, **kwargs
)
# progress log lines and container children are already part of
# their parent's render, which gets indented as a whole
if isinstance(parent, (Progress, Container)):
return rendered
metadata = kwargs
if isinstance(element, Element) and element.metadata:
metadata = {**element.metadata, **metadata}
# Input.ask wraps the element in a metadata-less Container; pull the
# child's metadata so flags like bullet= still apply
if isinstance(element, Container) and element.elements:
child = element.elements[0]
if isinstance(child, Element) and child.metadata:
metadata = {**child.metadata, **metadata}
if metadata.get("title", False):
return self._render_title(element, metadata)
if isinstance(element, Progress):
emoji = self._get_progress_status_emoji(element, done)
else:
emoji = metadata.get("emoji", "")
if not emoji and not metadata.get("bullet", True):
# skip the bullet column and align with the title chip's text
indent = Text(" " * (self.title_padding + 1))
return IndentedBlock(rendered, first_prefix=indent, prefix=indent)
return self._render_with_emoji_bullet(rendered, emoji)
@property
def title_padding(self) -> int:
# align the chip with the emoji bullet column
return self.content_padding
def _render_title(self, title: Any, metadata: dict[str, Any]) -> RenderableType:
tag = metadata.get("tag", "")
if metadata.get("animate", False):
self._animate_title_sweep(tag or title)
chip = Padding(
Text(f" {tag or title} ", style="tag.title"),
(0, 0, 0, self.title_padding),
expand=False,
)
if not (tag and title):
return chip
title_text = self._render_with_emoji_bullet(
Text.from_markup(f"[bold]{title}[/bold]"),
metadata.get("emoji", ""),
)
return Group(chip, "", title_text)
def _animate_title_sweep(self, text: str) -> None:
"""Sweep a gradient across the chip's line right before it prints,
painting the chip background in behind the light."""
if not self.console.is_terminal or is_ci_enabled():
return
indent = " " * self.title_padding
# the shades sweep in the chip's background color over the bare
# terminal, so the solid trailing edge blends into the painted chip
sweep_style = Style(color=self.console.get_style("tag.title").bgcolor)
self.console.show_cursor(False)
try:
for chip, shades, tail in _title_sweep_frames(text):
self.console.print(
Text.assemble(
indent, (chip, "tag.title"), (shades, sweep_style), tail
),
end="\r",
)
time.sleep(TITLE_SWEEP_DELAY)
finally:
self.console.show_cursor(True)
def _get_progress_status_emoji(self, element: Progress, done: bool) -> str:
if element._cancelled:
return "🟡"
if element.is_error:
return ERROR_BULLET
if done:
return cast(str, element.metadata.get("done_emoji", "🐔"))
if emoji := element.metadata.get("emoji"):
return cast(str, emoji)
return self.animation_emojis[
self.animation_counter % len(self.animation_emojis)
]
def _render_with_emoji_bullet(
self, rendered: RenderableType, emoji: str
) -> RenderableType:
return IndentedBlock(
rendered,
first_prefix=self._get_bullet_prefix(emoji),
prefix=Text(" " * (self.content_padding + self.emoji_column_width)),
)
def _get_bullet_prefix(self, emoji: str) -> Text:
prefix = Text(" " * self.content_padding)
if emoji:
prefix.append_text(Text.from_markup(emoji))
prefix.pad_right(
self.content_padding + self.emoji_column_width - prefix.cell_len
)
return prefix
def get_cursor_offset_for_element(
self, element: Element, parent: Element | None = None
) -> CursorOffset:
has_bullet_column = bool(element.metadata.get("emoji")) or element.metadata.get(
"bullet", True
)
decoration_width = (
self.content_padding + self.emoji_column_width
if has_bullet_column
else self.title_padding + 1
)
offset = element.cursor_offset
top = offset.top
left = decoration_width + offset.left
if isinstance(element, Input) and not element.inline and element.label:
label_lines = self._count_label_lines(
element.label, decoration_width=decoration_width
)
top = label_lines + 1
return CursorOffset(top=top, left=left)
class FastAPIRichToolkit(RichToolkit):
mode: Literal["human", "json"]
def __init__(
self,
style: BaseStyle | None = None,
theme: RichToolkitTheme | None = None,
mode: Literal["human", "json"] = "human",
) -> None:
super().__init__(style=style, theme=theme, mode=mode)
self._version_check = self._get_version_check()
def __exit__(
self,
exc_type: type[BaseException] | None,
exc_value: BaseException | None,
traceback: TracebackType | None,
) -> bool | None:
self._print_update_message()
return super().__exit__(
exc_type,
exc_value,
traceback,
)
def _get_version_check(self) -> BackgroundVersionCheck | None:
if os.environ.get(DISABLE_VERSION_CHECK_ENV) == "1":
return None
version_check = BackgroundVersionCheck()
version_check.start()
return version_check
def _print_update_message(self) -> None:
if self._version_check is None:
return
if message := self._version_check.get_update_message():
self.print_line()
self.print(Text.from_markup(message), emoji="⬆️")
def print_error(self, message: str) -> None:
self.print(f"[bold][error]error:[/][/] {message}", emoji=ERROR_BULLET)
def print_hint(self, message: str) -> None:
self.print(f"[dim]hint: {message}[/]")
def success(
self,
data: OutputT,
*,
warnings: list[dict[str, Any]] | None = None,
hint: str | None = None,
render_output: OutputRenderer[OutputT] | None = None,
) -> None:
if self.mode != "json":
# the base class types render_output as taking a plain RichToolkit,
# but output() always passes self, so the narrowing is safe
self.output(data, render_output=cast(Any, render_output))
return
output: dict[str, Any] = {"data": data}
if warnings:
output["warnings"] = warnings
if hint is not None:
output["hint"] = hint
self.output(output)
def fail(
self,
code: ErrorCode,
message: str,
*,
render_output: ErrorRenderer | None = None,
hint: str | None = None,
exit_code: int = 1,
) -> NoReturn:
if self.mode == "json":
self.output(
{
"error": {
"code": code,
"message": _strip_rich_markup(message),
"hint": _strip_rich_markup(hint),
}
}
)
elif render_output is not None:
render_output(self, code=code, message=message, hint=hint or "")
else:
self.print_error(message)
if hint:
self.print_line()
self.print_hint(hint)
raise typer.Exit(exit_code)
def get_details_table(rows: Iterable[tuple[str, RenderableType]]) -> Table:
"""Build a label/value grid for `get` views (labels dimmed)."""
table = Table.grid(padding=(0, 2), pad_edge=False)
table.add_column(style="dim", no_wrap=True)
table.add_column(overflow="fold")
for label, value in rows:
table.add_row(label, value)
return table
def get_rich_toolkit(
minimal: bool = False,
*,
json_output: bool | None = None,
) -> FastAPIRichToolkit:
style: BaseStyle = MinimalStyle() if minimal else FastAPIStyle()
theme = RichToolkitTheme(
style=style,
theme={
"tag.title": "#ffffff on #009485",
"placeholder": "grey62",
"text": "white",
"selected": "#007166",
"result": "grey85",
"progress": "on #007166",
"error": "red",
"cancelled": "indian_red italic",
},
)
mode: Literal["human", "json"] = "json" if json_output else "human"
return FastAPIRichToolkit(theme=theme, mode=mode)
@@ -0,0 +1,31 @@
import os
from pathlib import Path
import typer
def get_config_folder() -> Path:
config_dir = os.getenv("FASTAPI_CLOUD_CLI_CONFIG_DIR")
if config_dir:
return Path(config_dir).expanduser()
return Path(typer.get_app_dir("fastapi-cli"))
def get_auth_path() -> Path:
auth_path = get_config_folder() / "auth.json"
auth_path.parent.mkdir(parents=True, exist_ok=True)
return auth_path
def get_cli_config_path() -> Path:
cli_config_path = get_config_folder() / "cli.json"
cli_config_path.parent.mkdir(parents=True, exist_ok=True)
return cli_config_path
def get_version_check_cache_path() -> Path:
return get_config_folder() / "version-check.json"
@@ -0,0 +1,45 @@
from datetime import datetime, timezone
def format_last_updated(updated_at: str | None) -> str:
if updated_at is None:
return "-"
try:
updated = datetime.fromisoformat(updated_at.replace("Z", "+00:00"))
except ValueError:
return updated_at
if updated.tzinfo is None:
updated = updated.replace(tzinfo=timezone.utc)
now = datetime.now(timezone.utc)
seconds = int((now - updated).total_seconds())
if seconds < 60:
return "just now"
minutes = seconds // 60
if minutes < 60:
return _format_time_ago(minutes, "minute")
hours = minutes // 60
if hours < 24:
return _format_time_ago(hours, "hour")
days = hours // 24
if days < 30:
return _format_time_ago(days, "day")
months = days // 30
if months < 12:
return _format_time_ago(months, "month")
years = days // 365
return _format_time_ago(years, "year")
def _format_time_ago(value: int, unit: str) -> str:
suffix = "" if value == 1 else "s"
return f"{value} {unit}{suffix} ago"
@@ -0,0 +1,5 @@
def validate_environment_variable_name(name: str) -> bool:
if name.isidentifier():
return True
return False
@@ -0,0 +1,30 @@
from typing import Literal, NoReturn, Protocol
ErrorCode = Literal[
"already_linked",
"api_error",
"cancelled",
"dependency_missing",
"invalid_token",
"invalid_input",
"missing_required_input",
"network_error",
"not_found",
"not_linked",
"not_logged_in",
"permission_denied",
"timeout",
]
class ErrorToolkit(Protocol):
mode: Literal["json", "human"]
def fail(
self,
code: ErrorCode,
message: str,
*,
hint: str | None = None,
exit_code: int = 1,
) -> NoReturn: ...
@@ -0,0 +1,22 @@
import os
from typing import Annotated
import typer
JsonOutputOption = Annotated[
bool,
typer.Option(
"--json",
envvar="FASTAPI_CLOUD_JSON",
help="Print structured JSON to stdout.",
),
]
def is_ci_enabled() -> bool:
value = os.environ.get("CI")
if value is None:
return False
return value.lower() not in {"", "0", "false", "no", "off"}
@@ -0,0 +1,30 @@
from collections.abc import Callable
from datetime import datetime
from typing import Any, BinaryIO
class ProgressFile:
"""Wrap a binary file object and report upload progress as it is read."""
def __init__(
self,
file: BinaryIO,
progress_callback: Callable[[int], None],
update_interval: float = 0.5,
) -> None:
self._file = file
self._progress_callback = progress_callback
self._update_interval = update_interval
self._last_update_time = 0.0
def read(self, n: int = -1) -> bytes:
data = self._file.read(n)
now_ = datetime.now().timestamp()
is_eof = (len(data) == 0) or (n > 0 and len(data) < n)
if (now_ - self._last_update_time >= self._update_interval) or is_eof:
self._progress_callback(self._file.tell())
self._last_update_time = now_
return data
def __getattr__(self, name: str) -> Any:
return getattr(self._file, name)
@@ -0,0 +1,20 @@
import sentry_sdk
from sentry_sdk.integrations.typer import TyperIntegration
from fastapi_cloud_cli.utils.auth import Identity
SENTRY_DSN = "https://230250605ea4b58a0b69c768e9ec1168@o4506985151856640.ingest.us.sentry.io/4508449198899200"
def init_sentry() -> None:
"""Initialize Sentry error tracking only if user is logged in or has a deploy token."""
identity = Identity()
if not (identity.is_logged_in() or identity.has_deploy_token()):
return
sentry_sdk.init(
dsn=SENTRY_DSN,
integrations=[TyperIntegration()],
send_default_pii=False,
)
@@ -0,0 +1,187 @@
import json
import logging
import re
import threading
from contextlib import suppress
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from pathlib import Path
import httpx
from detect_installer import detect_installer
from pydantic import AwareDatetime, BaseModel, ValidationError
from fastapi_cloud_cli import __version__
from fastapi_cloud_cli.utils.config import get_version_check_cache_path
logger = logging.getLogger(__name__)
PACKAGE_NAME = "fastapi-cloud-cli"
DEFAULT_UPGRADE_COMMAND = f"pip install --upgrade {PACKAGE_NAME}"
PYPI_JSON_URL = f"https://pypi.org/pypi/{PACKAGE_NAME}/json"
VERSION_CHECK_TIMEOUT_SECONDS = 2.0
VERSION_CHECK_JOIN_TIMEOUT_SECONDS = 0.2
VERSION_CHECK_CACHE_TTL = timedelta(hours=24)
DISABLE_VERSION_CHECK_ENV = "FASTAPI_CLOUD_DISABLE_VERSION_CHECK"
SIMPLE_RELEASE_VERSION_RE = re.compile(r"\d+(?:\.\d+)*")
@dataclass(frozen=True)
class VersionUpdate:
current: str
latest: str
class VersionCheckCache(BaseModel):
latest_version: str
checked_at: AwareDatetime
class PyPIProjectInfo(BaseModel):
version: str
class PyPIProjectResponse(BaseModel):
info: PyPIProjectInfo
def _parse_simple_release_version(version: str) -> tuple[int, ...] | None:
if not SIMPLE_RELEASE_VERSION_RE.fullmatch(version):
logger.debug("Skipping non-simple version string: %r", version)
return None
return tuple(int(part) for part in version.split("."))
def is_newer_version(latest: str, current: str) -> bool:
latest_parts = _parse_simple_release_version(latest)
current_parts = _parse_simple_release_version(current)
if latest_parts is None or current_parts is None:
return False
return latest_parts > current_parts
def read_cached_latest_version(
cache_path: Path,
*,
ttl: timedelta = VERSION_CHECK_CACHE_TTL,
) -> str | None:
now = datetime.now(timezone.utc)
try:
cache = VersionCheckCache.model_validate_json(
cache_path.read_text(encoding="utf-8")
)
except (OSError, ValidationError) as error:
logger.debug("Could not read CLI version cache: %s", error)
return None
if _parse_simple_release_version(cache.latest_version) is None:
return None
if now - cache.checked_at > ttl:
return None
return cache.latest_version
def write_latest_version_cache(
cache_path: Path,
*,
latest_version: str,
now: datetime | None = None,
) -> None:
now = now or datetime.now(timezone.utc)
data = {
"latest_version": latest_version,
"checked_at": now.isoformat(),
}
try:
cache_path.parent.mkdir(parents=True, exist_ok=True)
cache_path.write_text(json.dumps(data), encoding="utf-8")
except OSError as error:
logger.debug("Could not write CLI version cache: %s", error)
def fetch_latest_version() -> str | None:
headers = {"User-Agent": f"fastapi-cloud-cli/{__version__}"}
try:
with httpx.Client(
timeout=httpx.Timeout(VERSION_CHECK_TIMEOUT_SECONDS),
headers=headers,
) as client:
response = client.get(PYPI_JSON_URL)
response.raise_for_status()
data = PyPIProjectResponse.model_validate_json(response.text)
except (httpx.HTTPError, ValidationError) as error:
logger.debug("Could not check latest CLI version: %s", error)
return None
return data.info.version
def check_for_update() -> VersionUpdate | None:
cache_path = get_version_check_cache_path()
if (latest_version := read_cached_latest_version(cache_path)) is None:
if (latest_version := fetch_latest_version()) is None:
return None
write_latest_version_cache(
cache_path,
latest_version=latest_version,
)
if not is_newer_version(latest_version, __version__):
return None
return VersionUpdate(current=__version__, latest=latest_version)
def get_upgrade_command() -> str:
installer_info = detect_installer(PACKAGE_NAME)
if installer_info is None or installer_info.upgrade_cmd is None:
return DEFAULT_UPGRADE_COMMAND
return installer_info.upgrade_cmd
def format_update_message(
update: VersionUpdate,
) -> str:
return (
"A newer FastAPI Cloud CLI version is available: "
f"{update.current} → [bold]{update.latest}[/]\n\n"
f'Run "[blue]{get_upgrade_command()}[/]" to upgrade.'
)
class BackgroundVersionCheck:
def __init__(self) -> None:
self._thread = threading.Thread(target=self._run, daemon=True)
self._update: VersionUpdate | None = None
self._message_returned = False
def start(self) -> None:
self._thread.start()
def _run(self) -> None:
with suppress(Exception):
self._update = check_for_update()
def get_update_message(self) -> str | None:
if self._message_returned:
return None
self._thread.join(timeout=VERSION_CHECK_JOIN_TIMEOUT_SECONDS)
if self._update:
self._message_returned = True
return format_update_message(self._update)
return None