fix: flatten React Router structure — no nested Routes, each page is a top-level route
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user