Changed code to support older Python versions
This commit is contained in:
parent
eb92d2d36f
commit
582458cdd0
5027 changed files with 794942 additions and 4 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
195
venv/lib/python3.11/site-packages/fastapi_cloud_cli/utils/api.py
Normal file
195
venv/lib/python3.11/site-packages/fastapi_cloud_cli/utils/api.py
Normal 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
|
||||
|
|
@ -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")
|
||||
|
|
@ -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
|
||||
101
venv/lib/python3.11/site-packages/fastapi_cloud_cli/utils/cli.py
Normal file
101
venv/lib/python3.11/site-packages/fastapi_cloud_cli/utils/cli.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
def validate_environment_variable_name(name: str) -> bool:
|
||||
if name.isidentifier():
|
||||
return True
|
||||
|
||||
return False
|
||||
|
|
@ -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]
|
||||
|
|
@ -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,
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue