fastapi-cloud-cli 0.17.1__tar.gz → 0.18.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.18.0}/PKG-INFO +2 -1
  2. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.18.0}/pyproject.toml +10 -2
  3. fastapi_cloud_cli-0.18.0/src/fastapi_cloud_cli/__init__.py +1 -0
  4. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.18.0}/src/fastapi_cloud_cli/commands/deploy.py +65 -10
  5. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.18.0}/src/fastapi_cloud_cli/commands/env.py +12 -3
  6. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.18.0}/src/fastapi_cloud_cli/commands/login.py +24 -22
  7. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.18.0}/src/fastapi_cloud_cli/commands/logs.py +4 -1
  8. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.18.0}/src/fastapi_cloud_cli/commands/setup_ci.py +4 -1
  9. fastapi_cloud_cli-0.18.0/src/fastapi_cloud_cli/commands/whoami.py +37 -0
  10. fastapi_cloud_cli-0.18.0/src/fastapi_cloud_cli/utils/cli.py +128 -0
  11. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.18.0}/src/fastapi_cloud_cli/utils/config.py +4 -0
  12. fastapi_cloud_cli-0.18.0/src/fastapi_cloud_cli/utils/version_check.py +187 -0
  13. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.18.0}/tests/conftest.py +1 -0
  14. fastapi_cloud_cli-0.18.0/tests/test_cli.py +84 -0
  15. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.18.0}/tests/test_cli_deploy.py +159 -0
  16. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.18.0}/tests/test_deploy_utils.py +52 -0
  17. fastapi_cloud_cli-0.18.0/tests/test_version_check.py +381 -0
  18. fastapi_cloud_cli-0.17.1/src/fastapi_cloud_cli/__init__.py +0 -1
  19. fastapi_cloud_cli-0.17.1/src/fastapi_cloud_cli/commands/whoami.py +0 -33
  20. fastapi_cloud_cli-0.17.1/src/fastapi_cloud_cli/utils/cli.py +0 -68
  21. fastapi_cloud_cli-0.17.1/tests/test_cli.py +0 -37
  22. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.18.0}/LICENSE +0 -0
  23. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.18.0}/README.md +0 -0
  24. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.18.0}/scripts/add_latest_release_date.py +0 -0
  25. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.18.0}/scripts/format.sh +0 -0
  26. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.18.0}/scripts/lint.sh +0 -0
  27. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.18.0}/scripts/test-cov-html.sh +0 -0
  28. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.18.0}/scripts/test.sh +0 -0
  29. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.18.0}/src/fastapi_cloud_cli/__main__.py +0 -0
  30. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.18.0}/src/fastapi_cloud_cli/cli.py +0 -0
  31. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.18.0}/src/fastapi_cloud_cli/commands/__init__.py +0 -0
  32. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.18.0}/src/fastapi_cloud_cli/commands/link.py +0 -0
  33. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.18.0}/src/fastapi_cloud_cli/commands/logout.py +0 -0
  34. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.18.0}/src/fastapi_cloud_cli/commands/unlink.py +0 -0
  35. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.18.0}/src/fastapi_cloud_cli/config.py +0 -0
  36. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.18.0}/src/fastapi_cloud_cli/logging.py +0 -0
  37. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.18.0}/src/fastapi_cloud_cli/py.typed +0 -0
  38. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.18.0}/src/fastapi_cloud_cli/utils/__init__.py +0 -0
  39. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.18.0}/src/fastapi_cloud_cli/utils/api.py +0 -0
  40. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.18.0}/src/fastapi_cloud_cli/utils/apps.py +0 -0
  41. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.18.0}/src/fastapi_cloud_cli/utils/auth.py +0 -0
  42. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.18.0}/src/fastapi_cloud_cli/utils/env.py +0 -0
  43. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.18.0}/src/fastapi_cloud_cli/utils/progress_file.py +0 -0
  44. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.18.0}/src/fastapi_cloud_cli/utils/sentry.py +0 -0
  45. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.18.0}/tests/__init__.py +0 -0
  46. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.18.0}/tests/test_api_client.py +0 -0
  47. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.18.0}/tests/test_archive.py +0 -0
  48. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.18.0}/tests/test_auth.py +0 -0
  49. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.18.0}/tests/test_cli_link.py +0 -0
  50. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.18.0}/tests/test_cli_login.py +0 -0
  51. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.18.0}/tests/test_cli_logout.py +0 -0
  52. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.18.0}/tests/test_cli_setup_ci.py +0 -0
  53. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.18.0}/tests/test_cli_unlink.py +0 -0
  54. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.18.0}/tests/test_cli_whoami.py +0 -0
  55. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.18.0}/tests/test_config.py +0 -0
  56. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.18.0}/tests/test_env_delete.py +0 -0
  57. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.18.0}/tests/test_env_list.py +0 -0
  58. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.18.0}/tests/test_env_set.py +0 -0
  59. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.18.0}/tests/test_logs.py +0 -0
  60. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.18.0}/tests/test_progress_file.py +0 -0
  61. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.18.0}/tests/test_sentry.py +0 -0
  62. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.18.0}/tests/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: fastapi-cloud-cli
3
- Version: 0.17.1
3
+ Version: 0.18.0
4
4
  Summary: Deploy and manage FastAPI Cloud apps from the command line 🚀
5
5
  Author-Email: Patrick Arminio <patrick@fastapilabs.com>
6
6
  License: MIT
@@ -39,6 +39,7 @@ Requires-Dist: pydantic[email]>=2.8.0; python_version == "3.13"
39
39
  Requires-Dist: pydantic[email]>=2.12.0; python_version >= "3.14"
40
40
  Requires-Dist: sentry-sdk>=2.20.0
41
41
  Requires-Dist: fastar>=0.10.0
42
+ Requires-Dist: detect-installer>=0.1.0
42
43
  Provides-Extra: standard
43
44
  Requires-Dist: uvicorn[standard]>=0.15.0; extra == "standard"
44
45
  Description-Content-Type: text/markdown
@@ -39,8 +39,9 @@ dependencies = [
39
39
  "pydantic[email] >= 2.12.0; python_version >= '3.14'",
40
40
  "sentry-sdk >= 2.20.0",
41
41
  "fastar >= 0.10.0",
42
+ "detect-installer>=0.1.0",
42
43
  ]
43
- version = "0.17.1"
44
+ version = "0.18.0"
44
45
 
45
46
  [project.license]
46
47
  text = "MIT"
@@ -66,7 +67,11 @@ dev = [
66
67
  "ruff==0.13.0",
67
68
  "respx==0.22.0",
68
69
  "time-machine==2.15.0",
69
- "ty>=0.0.9",
70
+ "ty>=0.0.25",
71
+ "zizmor>=1.24.1",
72
+ ]
73
+ github-actions = [
74
+ "smokeshow>=0.5.0",
70
75
  ]
71
76
 
72
77
  [build-system]
@@ -134,6 +139,9 @@ exclude = [
134
139
  "tests/assets/**",
135
140
  ]
136
141
 
142
+ [tool.ty.terminal]
143
+ error-on-warning = true
144
+
137
145
  [tool.ruff.lint]
138
146
  select = [
139
147
  "E",
@@ -0,0 +1 @@
1
+ __version__ = "0.18.0"
@@ -108,15 +108,19 @@ def _should_exclude_entry(path: Path) -> bool:
108
108
  return False
109
109
 
110
110
 
111
- def archive(path: Path, tar_path: Path) -> Path:
112
- logger.debug("Starting archive creation for path: %s", path)
113
- files = rignore.walk(
111
+ def _rignore_walk(path: Path) -> rignore.Walker:
112
+ return rignore.walk(
114
113
  path,
115
114
  should_exclude_entry=_should_exclude_entry,
116
115
  additional_ignore_paths=[".fastapicloudignore"],
117
116
  ignore_hidden=False,
118
117
  )
119
118
 
119
+
120
+ def archive(path: Path, tar_path: Path) -> Path:
121
+ logger.debug("Starting archive creation for path: %s", path)
122
+ files = _rignore_walk(path)
123
+
120
124
  logger.debug("Archive will be created at: %s", tar_path)
121
125
 
122
126
  file_count = 0
@@ -134,6 +138,20 @@ def archive(path: Path, tar_path: Path) -> Path:
134
138
  return tar_path
135
139
 
136
140
 
141
+ def _get_large_files(path: Path, threshold_mb: int) -> list[tuple[Path, int]]:
142
+ threshold_bytes = threshold_mb * 1024 * 1024
143
+ large_files = []
144
+ files = _rignore_walk(path)
145
+ for filename in files:
146
+ if filename.is_dir():
147
+ continue
148
+ file_size = filename.stat().st_size
149
+ if file_size > threshold_bytes:
150
+ large_files.append((filename.relative_to(path), file_size))
151
+
152
+ return sorted(large_files, key=lambda x: x[1], reverse=True)
153
+
154
+
137
155
  class Team(BaseModel):
138
156
  id: str
139
157
  slug: str
@@ -263,8 +281,8 @@ def _upload_deployment(
263
281
  logger.debug("Upload notification sent successfully")
264
282
 
265
283
 
266
- def _get_app(client: APIClient, app_slug: str) -> AppResponse | None:
267
- response = client.get(f"/apps/{app_slug}")
284
+ def _get_app(client: APIClient, app_id: str) -> AppResponse | None:
285
+ response = client.get(f"/apps/{app_id}")
268
286
 
269
287
  if response.status_code == 404:
270
288
  return None
@@ -378,10 +396,14 @@ def _configure_app(
378
396
  initial_directory = selected_app.directory if selected_app else ""
379
397
 
380
398
  directory_input = toolkit.input(
381
- title="Path to the directory containing your app (e.g. src, backend):",
399
+ title=(
400
+ "Directory where your app's pyproject.toml file lives (e.g. src, backend):"
401
+ ),
382
402
  tag="dir",
383
403
  value=initial_directory or "",
384
- placeholder="[italic]Leave empty if it's the current directory[/italic]",
404
+ placeholder=(
405
+ "[italic]Leave empty if pyproject.toml is in the current directory[/italic]"
406
+ ),
385
407
  validator=TypeAdapter(AppDirectory),
386
408
  )
387
409
 
@@ -665,7 +687,10 @@ def deploy(
665
687
  path: Annotated[
666
688
  Path | None,
667
689
  typer.Argument(
668
- help="A path to the folder containing the app you want to deploy"
690
+ help=(
691
+ "Path to the directory with your app's pyproject.toml "
692
+ "(defaults to current directory)"
693
+ )
669
694
  ),
670
695
  ] = None,
671
696
  skip_wait: Annotated[
@@ -679,6 +704,14 @@ def deploy(
679
704
  envvar="FASTAPI_CLOUD_APP_ID",
680
705
  ),
681
706
  ] = None,
707
+ large_file_threshold: Annotated[
708
+ int,
709
+ typer.Option(
710
+ help="File size threshold in MB for warning about large files",
711
+ min=1,
712
+ envvar="FASTAPI_CLOUD_LARGE_FILE_THRESHOLD",
713
+ ),
714
+ ] = 10, # 10 MB
682
715
  ) -> Any:
683
716
  """
684
717
  Deploy a [bold]FastAPI[/bold] app to FastAPI Cloud. 🚀
@@ -786,7 +819,7 @@ def deploy(
786
819
  with toolkit.progress("Checking app...", transient=True) as progress:
787
820
  with client.handle_http_errors(progress):
788
821
  logger.debug("Checking app with ID: %s", target_app_id)
789
- app = _get_app(client=client, app_slug=target_app_id)
822
+ app = _get_app(client=client, app_id=target_app_id)
790
823
 
791
824
  if not app:
792
825
  logger.debug("App not found in API")
@@ -804,10 +837,32 @@ def deploy(
804
837
  )
805
838
  raise typer.Exit(1)
806
839
 
840
+ large_files = _get_large_files(
841
+ path_to_deploy, threshold_mb=large_file_threshold
842
+ )
843
+ if large_files:
844
+ toolkit.print(
845
+ f"⚠️ Some uploaded files are larger than {large_file_threshold} MB ⚖️ :",
846
+ tag="warning",
847
+ )
848
+ for fname, fsize in large_files[:3]:
849
+ fsize_mb = fsize // (1024 * 1024)
850
+ toolkit.print(f" • {fname} [yellow]({fsize_mb} MB)[/yellow]")
851
+ is_more = len(large_files) > 3
852
+ if is_more:
853
+ toolkit.print(f" [dim]...and {len(large_files) - 3} more[/dim]")
854
+
855
+ large_files_docs_url = "https://fastapicloud.com/docs/fastapi-cloud-cli/deploy/#large-files-warning"
856
+ toolkit.print(
857
+ f"Read more: [link={large_files_docs_url}]{large_files_docs_url}[/link]",
858
+ tag="tip",
859
+ )
860
+ toolkit.print_line()
861
+
807
862
  with tempfile.TemporaryDirectory() as temp_dir:
808
863
  logger.debug("Creating archive for deployment")
809
864
  archive_path = Path(temp_dir) / "archive.tar"
810
- archive(path or Path.cwd(), archive_path)
865
+ archive(path_to_deploy, archive_path)
811
866
 
812
867
  with (
813
868
  toolkit.progress(
@@ -61,7 +61,10 @@ def list(
61
61
  path: Annotated[
62
62
  Path | None,
63
63
  typer.Argument(
64
- help="A path to the folder containing the app you want to deploy"
64
+ help=(
65
+ "Path to the directory with your app's pyproject.toml "
66
+ "(defaults to current directory)"
67
+ )
65
68
  ),
66
69
  ] = None,
67
70
  ) -> Any:
@@ -119,7 +122,10 @@ def delete(
119
122
  path: Annotated[
120
123
  Path | None,
121
124
  typer.Argument(
122
- help="A path to the folder containing the app you want to deploy"
125
+ help=(
126
+ "Path to the directory with your app's pyproject.toml "
127
+ "(defaults to current directory)"
128
+ )
123
129
  ),
124
130
  ] = None,
125
131
  ) -> Any:
@@ -208,7 +214,10 @@ def set(
208
214
  path: Annotated[
209
215
  Path | None,
210
216
  typer.Argument(
211
- help="A path to the folder containing the app you want to deploy"
217
+ help=(
218
+ "Path to the directory with your app's pyproject.toml "
219
+ "(defaults to current directory)"
220
+ )
212
221
  ),
213
222
  ] = None,
214
223
  secret: Annotated[
@@ -77,18 +77,18 @@ def login() -> Any:
77
77
  Login to FastAPI Cloud. 🚀
78
78
  """
79
79
  identity = Identity()
80
+ is_logged_in = identity.is_logged_in()
80
81
 
81
- if identity.is_logged_in():
82
- with get_rich_toolkit(minimal=True) as toolkit:
82
+ with get_rich_toolkit(minimal=is_logged_in) as toolkit:
83
+ if is_logged_in:
83
84
  toolkit.print("You are already logged in.")
84
85
  toolkit.print(
85
86
  "Run [bold]fastapi cloud logout[/bold] first if you want to switch accounts."
86
87
  )
87
88
 
88
- return
89
+ return
89
90
 
90
- if identity.has_deploy_token():
91
- with get_rich_toolkit() as toolkit:
91
+ if identity.has_deploy_token():
92
92
  toolkit.print(
93
93
  "You have [bold blue]FASTAPI_CLOUD_TOKEN[/] environment variable set.\n"
94
94
  "This token will take precedence over the user token for "
@@ -96,29 +96,31 @@ def login() -> Any:
96
96
  tag="Warning",
97
97
  )
98
98
 
99
- with get_rich_toolkit() as toolkit, APIClient() as client:
100
- toolkit.print_title("Login to FastAPI Cloud", tag="FastAPI")
99
+ with APIClient() as client:
100
+ toolkit.print_title("Login to FastAPI Cloud", tag="FastAPI")
101
101
 
102
- toolkit.print_line()
102
+ toolkit.print_line()
103
103
 
104
- with toolkit.progress("Starting authorization") as progress:
105
- with client.handle_http_errors(progress):
106
- authorization_data = _start_device_authorization(client)
104
+ with toolkit.progress("Starting authorization") as progress:
105
+ with client.handle_http_errors(progress):
106
+ authorization_data = _start_device_authorization(client)
107
107
 
108
- url = authorization_data.verification_uri_complete
108
+ url = authorization_data.verification_uri_complete
109
109
 
110
- progress.log(f"Opening [link={url}]{url}[/link]")
110
+ progress.log(f"Opening [link={url}]{url}[/link]")
111
111
 
112
- toolkit.print_line()
112
+ toolkit.print_line()
113
113
 
114
- with toolkit.progress("Waiting for user to authorize...") as progress:
115
- typer.launch(url)
114
+ with toolkit.progress("Waiting for user to authorize...") as progress:
115
+ typer.launch(url)
116
116
 
117
- with client.handle_http_errors(progress):
118
- access_token = _fetch_access_token(
119
- client, authorization_data.device_code, authorization_data.interval
120
- )
117
+ with client.handle_http_errors(progress):
118
+ access_token = _fetch_access_token(
119
+ client,
120
+ authorization_data.device_code,
121
+ authorization_data.interval,
122
+ )
121
123
 
122
- write_auth_config(AuthConfig(access_token=access_token))
124
+ write_auth_config(AuthConfig(access_token=access_token))
123
125
 
124
- progress.log("Now you are logged in! 🚀")
126
+ progress.log("Now you are logged in! 🚀")
@@ -113,7 +113,10 @@ def logs(
113
113
  path: Annotated[
114
114
  Path | None,
115
115
  typer.Argument(
116
- help="Path to the folder containing the app (defaults to current directory)"
116
+ help=(
117
+ "Path to the directory with your app's pyproject.toml "
118
+ "(defaults to current directory)"
119
+ )
117
120
  ),
118
121
  ] = None,
119
122
  tail: int = typer.Option(
@@ -157,7 +157,10 @@ def setup_ci(
157
157
  path: Annotated[
158
158
  Path | None,
159
159
  typer.Argument(
160
- help="Path to the folder containing the app (defaults to current directory)"
160
+ help=(
161
+ "Path to the directory with your app's pyproject.toml "
162
+ "(defaults to current directory)"
163
+ )
161
164
  ),
162
165
  ] = None,
163
166
  branch: str | None = typer.Option(
@@ -0,0 +1,37 @@
1
+ import logging
2
+ from typing import Any
3
+
4
+ from fastapi_cloud_cli.utils.api import APIClient
5
+ from fastapi_cloud_cli.utils.auth import Identity
6
+ from fastapi_cloud_cli.utils.cli import get_rich_toolkit
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ def whoami() -> Any:
12
+ identity = Identity()
13
+
14
+ with get_rich_toolkit(minimal=True) as toolkit:
15
+ if not identity.is_logged_in():
16
+ toolkit.print(
17
+ "No credentials found. Use [blue]`fastapi login`[/] to login."
18
+ )
19
+ else:
20
+ with APIClient() as client:
21
+ with toolkit.progress(
22
+ title="⚡ Fetching profile",
23
+ transient=True,
24
+ ) as progress:
25
+ with client.handle_http_errors(progress, default_message=""):
26
+ response = client.get("/users/me")
27
+ response.raise_for_status()
28
+
29
+ data = response.json()
30
+
31
+ toolkit.print(f"⚡ [bold]{data['email']}[/bold]")
32
+
33
+ if identity.has_deploy_token():
34
+ toolkit.print(
35
+ "⚡ [bold]Using API token from environment variable for "
36
+ "[blue]`fastapi deploy`[/blue] command.[/bold]"
37
+ )
@@ -0,0 +1,128 @@
1
+ import logging
2
+ import os
3
+ from types import TracebackType
4
+ from typing import Any, Literal
5
+
6
+ from rich.segment import Segment
7
+ from rich.style import Style
8
+ from rich.text import Text
9
+ from rich_toolkit import RichToolkit, RichToolkitTheme
10
+ from rich_toolkit.styles import BaseStyle, MinimalStyle, TaggedStyle
11
+
12
+ from fastapi_cloud_cli.utils.version_check import (
13
+ DISABLE_VERSION_CHECK_ENV,
14
+ BackgroundVersionCheck,
15
+ )
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ class FastAPIStyle(TaggedStyle):
21
+ def __init__(self, tag_width: int = 11):
22
+ super().__init__(tag_width=tag_width)
23
+
24
+ def _get_tag_segments(
25
+ self,
26
+ metadata: dict[str, Any],
27
+ is_animated: bool = False,
28
+ done: bool = False,
29
+ animation_status: Literal["started", "stopped", "error"] | None = None,
30
+ ) -> tuple[list[Segment], int]:
31
+ if not is_animated:
32
+ tag_segments, left_padding = super()._get_tag_segments(
33
+ metadata, is_animated, done, animation_status=animation_status
34
+ )
35
+
36
+ tag_style = metadata.get("tag_style")
37
+
38
+ if isinstance(tag_style, (str, Style)):
39
+ style = self.console.get_style(tag_style)
40
+ tag_segments = [
41
+ Segment(segment.text, style=style) for segment in tag_segments
42
+ ]
43
+
44
+ return tag_segments, left_padding
45
+
46
+ emojis = [
47
+ "🥚",
48
+ "🐣",
49
+ "🐤",
50
+ "🐥",
51
+ "🐓",
52
+ "🐔",
53
+ ]
54
+
55
+ tag = emojis[self.animation_counter % len(emojis)]
56
+
57
+ if done:
58
+ tag = metadata.get("done_emoji", emojis[-1])
59
+
60
+ if animation_status == "error":
61
+ tag = "🟡"
62
+
63
+ left_padding = self.tag_width - 1
64
+ left_padding = max(0, left_padding)
65
+
66
+ return [Segment(tag)], left_padding
67
+
68
+
69
+ class FastAPIRichToolkit(RichToolkit):
70
+ def __init__(
71
+ self,
72
+ style: BaseStyle | None = None,
73
+ theme: RichToolkitTheme | None = None,
74
+ ) -> None:
75
+ super().__init__(style=style, theme=theme)
76
+ self._version_check = self._get_version_check()
77
+
78
+ def __exit__(
79
+ self,
80
+ exc_type: type[BaseException] | None,
81
+ exc_value: BaseException | None,
82
+ traceback: TracebackType | None,
83
+ ) -> bool | None:
84
+ self._print_update_message()
85
+
86
+ return super().__exit__(
87
+ exc_type,
88
+ exc_value,
89
+ traceback,
90
+ )
91
+
92
+ def _get_version_check(self) -> BackgroundVersionCheck | None:
93
+ if os.environ.get(DISABLE_VERSION_CHECK_ENV) == "1":
94
+ return None
95
+
96
+ version_check = BackgroundVersionCheck()
97
+ version_check.start()
98
+
99
+ return version_check
100
+
101
+ def _print_update_message(self) -> None:
102
+ if self._version_check is None:
103
+ return
104
+
105
+ if message := self._version_check.get_update_message():
106
+ self.print(Text.from_markup(message), tag="update", tag_style="tag.update")
107
+
108
+
109
+ def get_rich_toolkit(minimal: bool = False) -> RichToolkit:
110
+ style = MinimalStyle() if minimal else FastAPIStyle(tag_width=11)
111
+
112
+ theme = RichToolkitTheme(
113
+ style=style,
114
+ theme={
115
+ "tag.title": "white on #009485",
116
+ "tag": "white on #007166",
117
+ "tag.update": "black on yellow",
118
+ "placeholder": "grey62",
119
+ "text": "white",
120
+ "selected": "#007166",
121
+ "result": "grey85",
122
+ "progress": "on #007166",
123
+ "error": "red",
124
+ "cancelled": "indian_red italic",
125
+ },
126
+ )
127
+
128
+ return FastAPIRichToolkit(theme=theme)
@@ -25,3 +25,7 @@ def get_cli_config_path() -> Path:
25
25
  cli_config_path.parent.mkdir(parents=True, exist_ok=True)
26
26
 
27
27
  return cli_config_path
28
+
29
+
30
+ def get_version_check_cache_path() -> Path:
31
+ return get_config_folder() / "version-check.json"
@@ -0,0 +1,187 @@
1
+ import json
2
+ import logging
3
+ import re
4
+ import threading
5
+ from contextlib import suppress
6
+ from dataclasses import dataclass
7
+ from datetime import datetime, timedelta, timezone
8
+ from pathlib import Path
9
+
10
+ import httpx
11
+ from detect_installer import detect_installer
12
+ from pydantic import AwareDatetime, BaseModel, ValidationError
13
+
14
+ from fastapi_cloud_cli import __version__
15
+ from fastapi_cloud_cli.utils.config import get_version_check_cache_path
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ PACKAGE_NAME = "fastapi-cloud-cli"
20
+ DEFAULT_UPGRADE_COMMAND = f"pip install --upgrade {PACKAGE_NAME}"
21
+ PYPI_JSON_URL = f"https://pypi.org/pypi/{PACKAGE_NAME}/json"
22
+ VERSION_CHECK_TIMEOUT_SECONDS = 2.0
23
+ VERSION_CHECK_JOIN_TIMEOUT_SECONDS = 0.2
24
+ VERSION_CHECK_CACHE_TTL = timedelta(hours=24)
25
+ DISABLE_VERSION_CHECK_ENV = "FASTAPI_CLOUD_DISABLE_VERSION_CHECK"
26
+ SIMPLE_RELEASE_VERSION_RE = re.compile(r"\d+(?:\.\d+)*")
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class VersionUpdate:
31
+ current: str
32
+ latest: str
33
+
34
+
35
+ class VersionCheckCache(BaseModel):
36
+ latest_version: str
37
+ checked_at: AwareDatetime
38
+
39
+
40
+ class PyPIProjectInfo(BaseModel):
41
+ version: str
42
+
43
+
44
+ class PyPIProjectResponse(BaseModel):
45
+ info: PyPIProjectInfo
46
+
47
+
48
+ def _parse_simple_release_version(version: str) -> tuple[int, ...] | None:
49
+ if not SIMPLE_RELEASE_VERSION_RE.fullmatch(version):
50
+ logger.debug("Skipping non-simple version string: %r", version)
51
+ return None
52
+
53
+ return tuple(int(part) for part in version.split("."))
54
+
55
+
56
+ def is_newer_version(latest: str, current: str) -> bool:
57
+ latest_parts = _parse_simple_release_version(latest)
58
+ current_parts = _parse_simple_release_version(current)
59
+
60
+ if latest_parts is None or current_parts is None:
61
+ return False
62
+
63
+ return latest_parts > current_parts
64
+
65
+
66
+ def read_cached_latest_version(
67
+ cache_path: Path,
68
+ *,
69
+ ttl: timedelta = VERSION_CHECK_CACHE_TTL,
70
+ ) -> str | None:
71
+ now = datetime.now(timezone.utc)
72
+
73
+ try:
74
+ cache = VersionCheckCache.model_validate_json(
75
+ cache_path.read_text(encoding="utf-8")
76
+ )
77
+ except (OSError, ValidationError) as error:
78
+ logger.debug("Could not read CLI version cache: %s", error)
79
+ return None
80
+
81
+ if _parse_simple_release_version(cache.latest_version) is None:
82
+ return None
83
+
84
+ if now - cache.checked_at > ttl:
85
+ return None
86
+
87
+ return cache.latest_version
88
+
89
+
90
+ def write_latest_version_cache(
91
+ cache_path: Path,
92
+ *,
93
+ latest_version: str,
94
+ now: datetime | None = None,
95
+ ) -> None:
96
+ now = now or datetime.now(timezone.utc)
97
+ data = {
98
+ "latest_version": latest_version,
99
+ "checked_at": now.isoformat(),
100
+ }
101
+
102
+ try:
103
+ cache_path.parent.mkdir(parents=True, exist_ok=True)
104
+ cache_path.write_text(json.dumps(data), encoding="utf-8")
105
+ except OSError as error:
106
+ logger.debug("Could not write CLI version cache: %s", error)
107
+
108
+
109
+ def fetch_latest_version() -> str | None:
110
+ headers = {"User-Agent": f"fastapi-cloud-cli/{__version__}"}
111
+
112
+ try:
113
+ with httpx.Client(
114
+ timeout=httpx.Timeout(VERSION_CHECK_TIMEOUT_SECONDS),
115
+ headers=headers,
116
+ ) as client:
117
+ response = client.get(PYPI_JSON_URL)
118
+ response.raise_for_status()
119
+ data = PyPIProjectResponse.model_validate_json(response.text)
120
+ except (httpx.HTTPError, ValidationError) as error:
121
+ logger.debug("Could not check latest CLI version: %s", error)
122
+ return None
123
+
124
+ return data.info.version
125
+
126
+
127
+ def check_for_update() -> VersionUpdate | None:
128
+ cache_path = get_version_check_cache_path()
129
+
130
+ if (latest_version := read_cached_latest_version(cache_path)) is None:
131
+ if (latest_version := fetch_latest_version()) is None:
132
+ return None
133
+
134
+ write_latest_version_cache(
135
+ cache_path,
136
+ latest_version=latest_version,
137
+ )
138
+
139
+ if not is_newer_version(latest_version, __version__):
140
+ return None
141
+
142
+ return VersionUpdate(current=__version__, latest=latest_version)
143
+
144
+
145
+ def get_upgrade_command() -> str:
146
+ installer_info = detect_installer(PACKAGE_NAME)
147
+
148
+ if installer_info is None or installer_info.upgrade_cmd is None:
149
+ return DEFAULT_UPGRADE_COMMAND
150
+
151
+ return installer_info.upgrade_cmd
152
+
153
+
154
+ def format_update_message(
155
+ update: VersionUpdate,
156
+ ) -> str:
157
+ return (
158
+ "A newer FastAPI Cloud CLI version is available: "
159
+ f"{update.current} → [bold]{update.latest}[/]\n\n"
160
+ f'Run "[blue]{get_upgrade_command()}[/]" to upgrade.'
161
+ )
162
+
163
+
164
+ class BackgroundVersionCheck:
165
+ def __init__(self) -> None:
166
+ self._thread = threading.Thread(target=self._run, daemon=True)
167
+ self._update: VersionUpdate | None = None
168
+ self._message_returned = False
169
+
170
+ def start(self) -> None:
171
+ self._thread.start()
172
+
173
+ def _run(self) -> None:
174
+ with suppress(Exception):
175
+ self._update = check_for_update()
176
+
177
+ def get_update_message(self) -> str | None:
178
+ if self._message_returned:
179
+ return None
180
+
181
+ self._thread.join(timeout=VERSION_CHECK_JOIN_TIMEOUT_SECONDS)
182
+
183
+ if self._update:
184
+ self._message_returned = True
185
+ return format_update_message(self._update)
186
+
187
+ return None