Changed code to support older Python versions

This commit is contained in:
Malasaur 2025-12-01 23:27:09 +01:00
parent eb92d2d36f
commit 582458cdd0
5027 changed files with 794942 additions and 4 deletions

View file

@ -0,0 +1,195 @@
import json
import logging
import time
from contextlib import contextmanager
from datetime import timedelta
from functools import wraps
from typing import (
Callable,
Generator,
Literal,
Optional,
TypeVar,
Union,
)
import httpx
from pydantic import BaseModel, Field, ValidationError
from typing_extensions import Annotated, ParamSpec
from fastapi_cloud_cli import __version__
from fastapi_cloud_cli.config import Settings
from fastapi_cloud_cli.utils.auth import get_auth_token
from fastapi_cloud_cli.utils.pydantic_compat import TypeAdapter
logger = logging.getLogger(__name__)
BUILD_LOG_MAX_RETRIES = 3
BUILD_LOG_TIMEOUT = timedelta(minutes=5)
class BuildLogError(Exception):
pass
class TooManyRetriesError(Exception):
pass
class BuildLogLineGeneric(BaseModel):
type: Literal["complete", "failed", "timeout", "heartbeat"]
id: Optional[str] = None
class BuildLogLineMessage(BaseModel):
type: Literal["message"] = "message"
message: str
id: Optional[str] = None
BuildLogLine = Union[BuildLogLineMessage, BuildLogLineGeneric]
BuildLogAdapter = TypeAdapter[BuildLogLine](
Annotated[BuildLogLine, Field(discriminator="type")] # type: ignore
)
@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:
# Try to get response text, but handle streaming responses gracefully
try:
error_detail = error.response.text
except Exception:
error_detail = "(response body unavailable)"
raise BuildLogError(
f"HTTP {error.response.status_code}: {error_detail}"
) 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(
"Build log streaming timed out after %ds",
timeout.total_seconds(),
)
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 APIClient(httpx.Client):
def __init__(self) -> None:
settings = Settings.get()
token = get_auth_token()
super().__init__(
base_url=settings.base_api_url,
timeout=httpx.Timeout(20),
headers={
"Authorization": f"Bearer {token}",
"User-Agent": f"fastapi-cloud-cli/{__version__}",
},
)
@attempts(BUILD_LOG_MAX_RETRIES, BUILD_LOG_TIMEOUT)
def stream_build_logs(
self, deployment_id: str
) -> 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:
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")
break # Breaks for loop to reconnect
else:
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) -> Optional[BuildLogLine]:
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

View file

@ -0,0 +1,61 @@
import logging
from pathlib import Path
from typing import Optional
from pydantic import BaseModel
from fastapi_cloud_cli.utils.pydantic_compat import model_dump_json, model_validate_json
logger = logging.getLogger("fastapi_cli")
class AppConfig(BaseModel):
app_id: str
team_id: str
def get_app_config(path_to_deploy: Path) -> Optional[AppConfig]:
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 model_validate_json(AppConfig, config_path.read_text(encoding="utf-8"))
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(
model_dump_json(app_config),
encoding="utf-8",
)
readme_path.write_text(README, encoding="utf-8")
gitignore_path.write_text("*")
logger.debug("App config files written successfully")

View file

@ -0,0 +1,124 @@
import base64
import binascii
import json
import logging
import time
from typing import Optional
from pydantic import BaseModel
from fastapi_cloud_cli.utils.pydantic_compat import model_dump_json, model_validate_json
from .config import get_auth_path
logger = logging.getLogger("fastapi_cli")
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(model_dump_json(auth_data), 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() -> Optional[AuthConfig]:
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 model_validate_json(AuthConfig, auth_path.read_text(encoding="utf-8"))
def get_auth_token() -> Optional[str]:
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_token_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
def is_logged_in() -> bool:
token = get_auth_token()
if token is None:
logger.debug("Login status: False (no token)")
return False
if is_token_expired(token):
logger.debug("Login status: False (token expired)")
return False
logger.debug("Login status: True")
return True

View file

@ -0,0 +1,101 @@
import contextlib
import logging
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
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 FastAPIStyle(tag_width=11)
theme = RichToolkitTheme(
style=style,
theme={
"tag.title": "white on #009485",
"tag": "white on #007166",
"placeholder": "grey85",
"text": "white",
"selected": "#007166",
"result": "grey85",
"progress": "on #007166",
"error": "red",
},
)
return RichToolkit(theme=theme)
@contextlib.contextmanager
def handle_http_errors(
progress: Progress,
message: Optional[str] = None,
) -> Generator[None, None, None]:
try:
yield
except ReadTimeout as e:
logger.debug(e)
progress.set_error(
"The request to the FastAPI Cloud server timed out. Please try again later."
)
raise typer.Exit(1) from None
except HTTPError as e:
logger.debug(e)
# Handle validation errors from Pydantic models, this should make it easier to debug :)
if isinstance(e, HTTPStatusError) and e.response.status_code == 422:
logger.debug(e.response.json()) # pragma: no cover
if isinstance(e, HTTPStatusError) and e.response.status_code in (401, 403):
message = "The specified token is not valid. Use `fastapi login` to generate a new token."
else:
message = (
message
or f"Something went wrong while contacting the FastAPI Cloud server. Please try again later. \n\n{e}"
)
progress.set_error(message)
raise typer.Exit(1) from None

View file

@ -0,0 +1,21 @@
from pathlib import Path
import typer
def get_config_folder() -> Path:
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

View file

@ -0,0 +1,5 @@
def validate_environment_variable_name(name: str) -> bool:
if name.isidentifier():
return True
return False

View file

@ -0,0 +1,72 @@
from typing import Any, Dict, Generic, Type, TypeVar
from pydantic import BaseModel
from pydantic.version import VERSION as PYDANTIC_VERSION
PYDANTIC_VERSION_MINOR_TUPLE = tuple(int(x) for x in PYDANTIC_VERSION.split(".")[:2])
PYDANTIC_V2 = PYDANTIC_VERSION_MINOR_TUPLE[0] == 2
T = TypeVar("T")
Model = TypeVar("Model", bound=BaseModel)
def model_validate(model_class: Type[Model], data: Dict[Any, Any]) -> Model:
if PYDANTIC_V2:
return model_class.model_validate(data) # type: ignore[no-any-return, unused-ignore, attr-defined]
else:
return model_class.parse_obj(data) # type: ignore[no-any-return, unused-ignore, attr-defined]
def model_validate_json(model_class: Type[Model], data: str) -> Model:
if PYDANTIC_V2:
return model_class.model_validate_json(data) # type: ignore[no-any-return, unused-ignore, attr-defined]
else:
return model_class.parse_raw(data) # type: ignore[no-any-return, unused-ignore, attr-defined]
def model_dump(obj: BaseModel, **kwargs: Any) -> Dict[Any, Any]:
if PYDANTIC_V2:
return obj.model_dump(**kwargs) # type: ignore[no-any-return, unused-ignore, attr-defined]
else:
return obj.dict(**kwargs) # type: ignore[no-any-return, unused-ignore, attr-defined]
def model_dump_json(obj: BaseModel) -> str:
if PYDANTIC_V2:
return obj.model_dump_json() # type: ignore[no-any-return, unused-ignore, attr-defined]
else:
# Use compact separators to match Pydantic v2's output format
return obj.json(separators=(",", ":")) # type: ignore[no-any-return, unused-ignore, attr-defined]
class TypeAdapter(Generic[T]):
def __init__(self, type_: Type[T]) -> None:
self.type_ = type_
if PYDANTIC_V2:
from pydantic import ( # type: ignore[attr-defined, unused-ignore]
TypeAdapter as PydanticTypeAdapter,
)
self._adapter = PydanticTypeAdapter(type_)
else:
self._adapter = None # type: ignore[assignment, unused-ignore]
def validate_python(self, value: Any) -> T:
"""Validate a Python object against the type."""
if PYDANTIC_V2:
return self._adapter.validate_python(value) # type: ignore[no-any-return, union-attr, unused-ignore]
else:
from pydantic import parse_obj_as
return parse_obj_as(self.type_, value) # type: ignore[no-any-return, unused-ignore]
def validate_json(self, value: str) -> T:
"""Validate a JSON string against the type."""
if PYDANTIC_V2:
return self._adapter.validate_json(value) # type: ignore[no-any-return, union-attr, unused-ignore]
else:
from pydantic import parse_raw_as
return parse_raw_as(self.type_, value) # type: ignore[no-any-return, unused-ignore, operator]

View file

@ -0,0 +1,18 @@
import sentry_sdk
from sentry_sdk.integrations.typer import TyperIntegration
from .auth import is_logged_in
SENTRY_DSN = "https://230250605ea4b58a0b69c768e9ec1168@o4506985151856640.ingest.us.sentry.io/4508449198899200"
def init_sentry() -> None:
"""Initialize Sentry error tracking only if user is logged in."""
if not is_logged_in():
return
sentry_sdk.init(
dsn=SENTRY_DSN,
integrations=[TyperIntegration()],
send_default_pii=False,
)