fastapi-cloud-cli 0.14.1__tar.gz → 0.15.1__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 (85) hide show
  1. {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/PKG-INFO +1 -1
  2. {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/pyproject.toml +7 -1
  3. {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/scripts/lint.sh +1 -0
  4. fastapi_cloud_cli-0.15.1/src/fastapi_cloud_cli/__init__.py +1 -0
  5. {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/src/fastapi_cloud_cli/cli.py +2 -0
  6. {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/src/fastapi_cloud_cli/commands/deploy.py +5 -3
  7. fastapi_cloud_cli-0.15.1/src/fastapi_cloud_cli/commands/setup_ci.py +360 -0
  8. {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/tests/test_api_client.py +3 -3
  9. {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/tests/test_cli_deploy.py +80 -2
  10. fastapi_cloud_cli-0.15.1/tests/test_cli_setup_ci.py +700 -0
  11. fastapi_cloud_cli-0.14.1/src/fastapi_cloud_cli/__init__.py +0 -1
  12. fastapi_cloud_cli-0.14.1/tests/assets/broken_package/mod/__init__.py +0 -1
  13. fastapi_cloud_cli-0.14.1/tests/assets/broken_package/mod/app.py +0 -10
  14. fastapi_cloud_cli-0.14.1/tests/assets/broken_package/utils.py +0 -2
  15. fastapi_cloud_cli-0.14.1/tests/assets/default_files/default_api/api.py +0 -8
  16. fastapi_cloud_cli-0.14.1/tests/assets/default_files/default_app/api.py +0 -8
  17. fastapi_cloud_cli-0.14.1/tests/assets/default_files/default_app/app.py +0 -8
  18. fastapi_cloud_cli-0.14.1/tests/assets/default_files/default_app_dir_api/app/__init__.py +0 -0
  19. fastapi_cloud_cli-0.14.1/tests/assets/default_files/default_app_dir_api/app/api.py +0 -8
  20. fastapi_cloud_cli-0.14.1/tests/assets/default_files/default_app_dir_app/app/__init__.py +0 -0
  21. fastapi_cloud_cli-0.14.1/tests/assets/default_files/default_app_dir_app/app/api.py +0 -8
  22. fastapi_cloud_cli-0.14.1/tests/assets/default_files/default_app_dir_app/app/app.py +0 -8
  23. fastapi_cloud_cli-0.14.1/tests/assets/default_files/default_app_dir_main/app/__init__.py +0 -0
  24. fastapi_cloud_cli-0.14.1/tests/assets/default_files/default_app_dir_main/app/api.py +0 -8
  25. fastapi_cloud_cli-0.14.1/tests/assets/default_files/default_app_dir_main/app/app.py +0 -8
  26. fastapi_cloud_cli-0.14.1/tests/assets/default_files/default_app_dir_main/app/main.py +0 -8
  27. fastapi_cloud_cli-0.14.1/tests/assets/default_files/default_app_dir_non_default/app/__init__.py +0 -0
  28. fastapi_cloud_cli-0.14.1/tests/assets/default_files/default_app_dir_non_default/app/nondefault.py +0 -8
  29. fastapi_cloud_cli-0.14.1/tests/assets/default_files/default_main/api.py +0 -8
  30. fastapi_cloud_cli-0.14.1/tests/assets/default_files/default_main/app.py +0 -8
  31. fastapi_cloud_cli-0.14.1/tests/assets/default_files/default_main/main.py +0 -8
  32. fastapi_cloud_cli-0.14.1/tests/assets/default_files/non_default/nonstandard.py +0 -8
  33. fastapi_cloud_cli-0.14.1/tests/assets/package/__init__.py +0 -2
  34. fastapi_cloud_cli-0.14.1/tests/assets/package/core/__init__.py +0 -0
  35. fastapi_cloud_cli-0.14.1/tests/assets/package/core/utils.py +0 -2
  36. fastapi_cloud_cli-0.14.1/tests/assets/package/mod/__init__.py +0 -1
  37. fastapi_cloud_cli-0.14.1/tests/assets/package/mod/api.py +0 -24
  38. fastapi_cloud_cli-0.14.1/tests/assets/package/mod/app.py +0 -32
  39. fastapi_cloud_cli-0.14.1/tests/assets/package/mod/other.py +0 -16
  40. fastapi_cloud_cli-0.14.1/tests/assets/single_file_api.py +0 -24
  41. fastapi_cloud_cli-0.14.1/tests/assets/single_file_app.py +0 -32
  42. fastapi_cloud_cli-0.14.1/tests/assets/single_file_other.py +0 -16
  43. {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/LICENSE +0 -0
  44. {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/README.md +0 -0
  45. {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/scripts/format.sh +0 -0
  46. {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/scripts/test-cov-html.sh +0 -0
  47. {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/scripts/test.sh +0 -0
  48. {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/src/fastapi_cloud_cli/__main__.py +0 -0
  49. {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/src/fastapi_cloud_cli/commands/__init__.py +0 -0
  50. {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/src/fastapi_cloud_cli/commands/env.py +0 -0
  51. {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/src/fastapi_cloud_cli/commands/link.py +0 -0
  52. {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/src/fastapi_cloud_cli/commands/login.py +0 -0
  53. {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/src/fastapi_cloud_cli/commands/logout.py +0 -0
  54. {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/src/fastapi_cloud_cli/commands/logs.py +0 -0
  55. {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/src/fastapi_cloud_cli/commands/unlink.py +0 -0
  56. {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/src/fastapi_cloud_cli/commands/whoami.py +0 -0
  57. {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/src/fastapi_cloud_cli/config.py +0 -0
  58. {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/src/fastapi_cloud_cli/logging.py +0 -0
  59. {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/src/fastapi_cloud_cli/py.typed +0 -0
  60. {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/src/fastapi_cloud_cli/utils/__init__.py +0 -0
  61. {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/src/fastapi_cloud_cli/utils/api.py +0 -0
  62. {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/src/fastapi_cloud_cli/utils/apps.py +0 -0
  63. {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/src/fastapi_cloud_cli/utils/auth.py +0 -0
  64. {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/src/fastapi_cloud_cli/utils/cli.py +0 -0
  65. {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/src/fastapi_cloud_cli/utils/config.py +0 -0
  66. {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/src/fastapi_cloud_cli/utils/env.py +0 -0
  67. {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/src/fastapi_cloud_cli/utils/sentry.py +0 -0
  68. {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/tests/__init__.py +0 -0
  69. {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/tests/conftest.py +0 -0
  70. {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/tests/test_archive.py +0 -0
  71. {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/tests/test_auth.py +0 -0
  72. {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/tests/test_cli.py +0 -0
  73. {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/tests/test_cli_link.py +0 -0
  74. {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/tests/test_cli_login.py +0 -0
  75. {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/tests/test_cli_logout.py +0 -0
  76. {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/tests/test_cli_unlink.py +0 -0
  77. {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/tests/test_cli_whoami.py +0 -0
  78. {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/tests/test_config.py +0 -0
  79. {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/tests/test_deploy_utils.py +0 -0
  80. {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/tests/test_env_delete.py +0 -0
  81. {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/tests/test_env_list.py +0 -0
  82. {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/tests/test_env_set.py +0 -0
  83. {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/tests/test_logs.py +0 -0
  84. {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/tests/test_sentry.py +0 -0
  85. {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/tests/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: fastapi-cloud-cli
3
- Version: 0.14.1
3
+ Version: 0.15.1
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
@@ -40,7 +40,7 @@ dependencies = [
40
40
  "sentry-sdk >= 2.20.0",
41
41
  "fastar >= 0.8.0",
42
42
  ]
43
- version = "0.14.1"
43
+ version = "0.15.1"
44
44
 
45
45
  [project.license]
46
46
  text = "MIT"
@@ -66,6 +66,7 @@ dev = [
66
66
  "ruff==0.13.0",
67
67
  "respx==0.22.0",
68
68
  "time-machine==2.15.0",
69
+ "ty>=0.0.9",
69
70
  ]
70
71
 
71
72
  [build-system]
@@ -128,6 +129,11 @@ exclude = [
128
129
  "tests/assets/*",
129
130
  ]
130
131
 
132
+ [tool.ty.src]
133
+ exclude = [
134
+ "tests/assets/**",
135
+ ]
136
+
131
137
  [tool.ruff.lint]
132
138
  select = [
133
139
  "E",
@@ -4,5 +4,6 @@ set -e
4
4
  set -x
5
5
 
6
6
  mypy src tests
7
+ ty check src tests
7
8
  ruff check src tests scripts
8
9
  ruff format src tests --check
@@ -0,0 +1 @@
1
+ __version__ = "0.15.1"
@@ -6,6 +6,7 @@ from .commands.link import link
6
6
  from .commands.login import login
7
7
  from .commands.logout import logout
8
8
  from .commands.logs import logs
9
+ from .commands.setup_ci import setup_ci
9
10
  from .commands.unlink import unlink
10
11
  from .commands.whoami import whoami
11
12
  from .logging import setup_logging
@@ -32,6 +33,7 @@ cloud_app.command()(logs)
32
33
  cloud_app.command()(logout)
33
34
  cloud_app.command()(whoami)
34
35
  cloud_app.command()(unlink)
36
+ cloud_app.command()(setup_ci)
35
37
 
36
38
  cloud_app.add_typer(env_app, name="env")
37
39
 
@@ -304,6 +304,7 @@ def _configure_app(toolkit: RichToolkit, path_to_deploy: Path) -> AppConfig:
304
304
  "Select the team you want to deploy to:",
305
305
  tag="team",
306
306
  options=[Option({"name": team.name, "value": team}) for team in teams],
307
+ allow_filtering=True,
307
308
  )
308
309
 
309
310
  toolkit.print_line()
@@ -335,6 +336,7 @@ def _configure_app(toolkit: RichToolkit, path_to_deploy: Path) -> AppConfig:
335
336
  selected_app = toolkit.ask(
336
337
  "Select the app you want to deploy to:",
337
338
  options=[Option({"name": app.slug, "value": app}) for app in apps],
339
+ allow_filtering=True,
338
340
  )
339
341
 
340
342
  app_name = (
@@ -478,7 +480,7 @@ def _wait_for_deployment(
478
480
  time_elapsed = time.monotonic() - started_at
479
481
 
480
482
  if log.type == "message":
481
- progress.log(Text.from_ansi(log.message.rstrip()))
483
+ progress.log(Text.from_ansi(log.message.rstrip())) # ty: ignore[unresolved-attribute]
482
484
 
483
485
  if log.type == "complete":
484
486
  build_complete = True
@@ -581,13 +583,13 @@ def _waitlist_form(toolkit: RichToolkit) -> None:
581
583
  )
582
584
  form.add_input("secret_code", label="Secret code", placeholder="123456")
583
585
 
584
- result = form.run() # type: ignore
586
+ result = form.run() # type: ignore # ty: ignore[unused-ignore-comment]
585
587
 
586
588
  try:
587
589
  result = SignupToWaitingList.model_validate(
588
590
  {
589
591
  "email": email,
590
- **result, # type: ignore
592
+ **result, # type: ignore # ty: ignore[unused-ignore-comment]
591
593
  },
592
594
  )
593
595
  except ValidationError:
@@ -0,0 +1,360 @@
1
+ import logging
2
+ import re
3
+ import shutil
4
+ import subprocess
5
+ from pathlib import Path
6
+ from typing import Annotated
7
+
8
+ import typer
9
+
10
+ from fastapi_cloud_cli.utils.api import APIClient
11
+ from fastapi_cloud_cli.utils.apps import get_app_config
12
+ from fastapi_cloud_cli.utils.auth import Identity
13
+ from fastapi_cloud_cli.utils.cli import get_rich_toolkit, handle_http_errors
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ TOKEN_EXPIRES_DAYS = 365
18
+ DEFAULT_WORKFLOW_PATH = Path(".github/workflows/deploy.yml")
19
+
20
+
21
+ class GitHubSecretError(Exception):
22
+ """Raised when setting a GitHub Actions secret fails."""
23
+
24
+ pass
25
+
26
+
27
+ def _get_github_host(origin: str) -> str:
28
+ """Extract the GitHub host from a git remote URL.
29
+
30
+ Supports both github.com and GitHub Enterprise hosts.
31
+ Examples:
32
+ git@github.com:owner/repo.git -> github.com
33
+ https://github.com/owner/repo.git -> github.com
34
+ git@enterprise.github.com:owner/repo.git -> enterprise.github.com
35
+ """
36
+ # Match git@HOST:owner/repo or https://HOST/owner/repo
37
+ match = re.search(r"(?:git@|https://)([^:/]+)", origin)
38
+ return match.group(1) if match else "github.com"
39
+
40
+
41
+ def _repo_slug_from_origin(origin: str) -> str | None:
42
+ """Extract 'owner/repo' from a GitHub remote URL."""
43
+ # Handles URLs like: git@github.com:owner/repo.git or https://github.com/owner/repo.git
44
+ # Also supports GitHub Enterprise hosts like git@github.enterprise.com:owner/repo.git
45
+ # Match the part after the last : or / (which is owner/repo)
46
+ match = re.search(r"[:/]([^:/]+/[^/]+?)(?:\.git)?$", origin)
47
+ return match.group(1) if match else None
48
+
49
+
50
+ def _check_git_installed() -> bool:
51
+ """Check if git is installed and available."""
52
+ return shutil.which("git") is not None
53
+
54
+
55
+ def _check_gh_cli_installed() -> bool:
56
+ """Check if the GitHub CLI (gh) is installed and available."""
57
+ return shutil.which("gh") is not None
58
+
59
+
60
+ def _get_remote_origin() -> str:
61
+ """Get the remote origin URL of the Git repository."""
62
+ try:
63
+ # Try gh first (to respect gh repo set-default)
64
+ result = subprocess.run(
65
+ ["gh", "repo", "view", "--json", "url", "-q", ".url"],
66
+ capture_output=True,
67
+ text=True,
68
+ check=True,
69
+ )
70
+ return result.stdout.strip()
71
+ # CalledProcessError if gh command fails, FileNotFoundError if gh is not installed
72
+ except (subprocess.CalledProcessError, FileNotFoundError):
73
+ # Fallback to git command
74
+ result = subprocess.run(
75
+ ["git", "config", "--get", "remote.origin.url"],
76
+ capture_output=True,
77
+ text=True,
78
+ check=True,
79
+ )
80
+ return result.stdout.strip()
81
+
82
+
83
+ def _set_github_secret(name: str, value: str) -> None:
84
+ """Set a GitHub Actions secret via the gh CLI.
85
+
86
+ Raises:
87
+ GitHubSecretError: If setting the secret fails.
88
+ """
89
+ try:
90
+ subprocess.run(
91
+ ["gh", "secret", "set", name, "--body", value],
92
+ capture_output=True,
93
+ check=True,
94
+ )
95
+ except (subprocess.CalledProcessError, FileNotFoundError) as e:
96
+ raise GitHubSecretError(f"Failed to set GitHub secret '{name}'") from e
97
+
98
+
99
+ def _create_token(app_id: str, token_name: str) -> dict[str, str]:
100
+ """Create a new deploy token.
101
+
102
+ Returns token_data dict with 'value' and 'expired_at' keys.
103
+ """
104
+ with APIClient() as client:
105
+ response = client.post(
106
+ f"/apps/{app_id}/tokens",
107
+ json={"name": token_name, "expires_in_days": TOKEN_EXPIRES_DAYS},
108
+ )
109
+ response.raise_for_status()
110
+ data = response.json()
111
+ return {"value": data["value"], "expired_at": data["expired_at"]}
112
+
113
+
114
+ def _get_default_branch() -> str:
115
+ """Get the default branch of the Git repository."""
116
+ try:
117
+ result = subprocess.run(
118
+ [
119
+ "gh",
120
+ "repo",
121
+ "view",
122
+ "--json",
123
+ "defaultBranchRef",
124
+ "-q",
125
+ ".defaultBranchRef.name",
126
+ ],
127
+ capture_output=True,
128
+ text=True,
129
+ check=True,
130
+ )
131
+ return result.stdout.strip()
132
+ except (subprocess.CalledProcessError, FileNotFoundError):
133
+ return "main"
134
+
135
+
136
+ def _write_workflow_file(branch: str, workflow_path: Path) -> None:
137
+ workflow_content = f"""\
138
+ name: Deploy to FastAPI Cloud
139
+ on:
140
+ push:
141
+ branches: [{branch}]
142
+ jobs:
143
+ deploy:
144
+ runs-on: ubuntu-latest
145
+ steps:
146
+ - uses: actions/checkout@v5
147
+ - uses: astral-sh/setup-uv@v7
148
+ - run: uv run fastapi deploy
149
+ env:
150
+ FASTAPI_CLOUD_TOKEN: ${{{{ secrets.FASTAPI_CLOUD_TOKEN }}}}
151
+ FASTAPI_CLOUD_APP_ID: ${{{{ secrets.FASTAPI_CLOUD_APP_ID }}}}
152
+ """
153
+ workflow_path.parent.mkdir(parents=True, exist_ok=True)
154
+ workflow_path.write_text(workflow_content)
155
+
156
+
157
+ def setup_ci(
158
+ path: Annotated[
159
+ Path | None,
160
+ typer.Argument(
161
+ help="Path to the folder containing the app (defaults to current directory)"
162
+ ),
163
+ ] = None,
164
+ branch: str | None = typer.Option(
165
+ None,
166
+ "--branch",
167
+ "-b",
168
+ help="Branch that triggers deploys (defaults to the repo's default branch)",
169
+ ),
170
+ secrets_only: bool = typer.Option(
171
+ False,
172
+ "--secrets-only",
173
+ "-s",
174
+ help="Provisions token and sets secrets, skips writing the workflow file",
175
+ show_default=True,
176
+ ),
177
+ dry_run: bool = typer.Option(
178
+ False,
179
+ "--dry-run",
180
+ "-d",
181
+ help="Prints steps that would be taken without actually performing them",
182
+ show_default=True,
183
+ ),
184
+ file: str | None = typer.Option(
185
+ None,
186
+ "--file",
187
+ "-f",
188
+ help="Custom workflow filename (written to .github/workflows/)",
189
+ ),
190
+ ) -> None:
191
+ """Configures a GitHub Actions workflow for deploying the app on push to the specified branch.
192
+
193
+ Examples:
194
+ fastapi cloud setup-ci # Provisions token, sets secrets, and writes workflow file for the 'main' branch
195
+ fastapi cloud setup-ci --branch develop # Same as above but for the 'develop' branch
196
+ fastapi cloud setup-ci --secrets-only # Only provisions token and sets secrets, does not write workflow file
197
+ fastapi cloud setup-ci --dry-run # Prints the steps that would be taken without performing them
198
+ fastapi cloud setup-ci --file ci.yml # Writes workflow to .github/workflows/ci.yml
199
+ """
200
+
201
+ identity = Identity()
202
+
203
+ with get_rich_toolkit() as toolkit:
204
+ if not identity.is_logged_in():
205
+ toolkit.print(
206
+ "No credentials found. Use [blue]`fastapi login`[/] to login.",
207
+ tag="auth",
208
+ )
209
+ raise typer.Exit(1)
210
+
211
+ app_path = path or Path.cwd()
212
+ app_config = get_app_config(app_path)
213
+
214
+ if not app_config:
215
+ toolkit.print(
216
+ "No app linked to this directory. Run [blue]`fastapi deploy`[/] first.",
217
+ tag="error",
218
+ )
219
+ raise typer.Exit(1)
220
+
221
+ if not _check_git_installed():
222
+ toolkit.print(
223
+ "git is not installed. Please install git to use this command.",
224
+ tag="error",
225
+ )
226
+ raise typer.Exit(1)
227
+
228
+ try:
229
+ origin = _get_remote_origin()
230
+ except subprocess.CalledProcessError:
231
+ toolkit.print(
232
+ "Error retrieving git remote origin URL. Make sure you're in a git repository with a remote origin set.",
233
+ tag="error",
234
+ )
235
+ raise typer.Exit(1) from None
236
+
237
+ # Check if it's a GitHub host (github.com or GitHub Enterprise)
238
+ if "github" not in origin.lower():
239
+ toolkit.print(
240
+ "Remote origin is not a GitHub repository. Please set up a GitHub repo and add it as the remote origin.",
241
+ tag="error",
242
+ )
243
+ raise typer.Exit(1)
244
+
245
+ repo_slug = _repo_slug_from_origin(origin) or origin
246
+ github_host = _get_github_host(origin)
247
+ has_gh = _check_gh_cli_installed()
248
+
249
+ if not branch:
250
+ branch = _get_default_branch()
251
+
252
+ if dry_run:
253
+ toolkit.print(
254
+ "[yellow]This is a dry run — no changes will be made[/yellow]"
255
+ )
256
+ toolkit.print_line()
257
+
258
+ toolkit.print_title("Configuring CI", tag="FastAPI")
259
+ toolkit.print_line()
260
+
261
+ toolkit.print(f"Setting up CI for [bold]{repo_slug}[/bold] (branch: {branch})")
262
+ toolkit.print_line()
263
+
264
+ msg_token = "Created deploy token"
265
+ msg_secrets = (
266
+ "Set [bold]FASTAPI_CLOUD_TOKEN[/bold] and [bold]FASTAPI_CLOUD_APP_ID[/bold]"
267
+ )
268
+ workflow_file = file or DEFAULT_WORKFLOW_PATH.name
269
+ msg_workflow = (
270
+ f"Wrote [bold].github/workflows/{workflow_file}[/bold] (branch: {branch})"
271
+ )
272
+ msg_done = "Done — commit and push to start deploying."
273
+
274
+ if dry_run:
275
+ toolkit.print(msg_token)
276
+ toolkit.print(msg_secrets)
277
+ if not secrets_only:
278
+ toolkit.print(msg_workflow)
279
+ return
280
+
281
+ from datetime import datetime, timezone
282
+
283
+ # Create unique token name with timestamp to avoid duplicates
284
+ timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
285
+ token_name = f"GitHub Actions — {repo_slug} ({timestamp})"
286
+
287
+ with (
288
+ toolkit.progress(title="Generating deploy token...") as progress,
289
+ handle_http_errors(
290
+ progress, default_message="Error creating deploy token."
291
+ ),
292
+ ):
293
+ token_data = _create_token(app_config.app_id, token_name)
294
+ progress.log(msg_token)
295
+
296
+ toolkit.print_line()
297
+
298
+ if has_gh:
299
+ with toolkit.progress(title="Setting repo secrets...") as progress:
300
+ try:
301
+ _set_github_secret("FASTAPI_CLOUD_TOKEN", token_data["value"])
302
+ _set_github_secret("FASTAPI_CLOUD_APP_ID", app_config.app_id)
303
+ except GitHubSecretError:
304
+ progress.set_error("Failed to set GitHub secrets via gh CLI.")
305
+ raise typer.Exit(1) from None
306
+ progress.log(msg_secrets)
307
+ else:
308
+ secrets_url = f"https://{github_host}/{repo_slug}/settings/secrets/actions"
309
+ toolkit.print(
310
+ "[yellow]gh CLI not found. Set these secrets manually:[/yellow]",
311
+ tag="info",
312
+ )
313
+ toolkit.print_line()
314
+ toolkit.print(f" Repository: [blue]{secrets_url}[/]")
315
+ toolkit.print_line()
316
+ toolkit.print(f" [bold]FASTAPI_CLOUD_TOKEN[/bold] = {token_data['value']}")
317
+ toolkit.print(f" [bold]FASTAPI_CLOUD_APP_ID[/bold] = {app_config.app_id}")
318
+
319
+ toolkit.print_line()
320
+
321
+ if not secrets_only:
322
+ if file:
323
+ workflow_path = Path(f".github/workflows/{file}")
324
+ else:
325
+ workflow_path = DEFAULT_WORKFLOW_PATH
326
+
327
+ write_workflow = True
328
+ if not file and workflow_path.exists():
329
+ overwrite = toolkit.confirm(
330
+ f"Workflow file [bold]{workflow_path}[/bold] already exists. Overwrite?",
331
+ tag="workflow",
332
+ default=False,
333
+ )
334
+ if not overwrite:
335
+ new_name = toolkit.input(
336
+ "Enter a new filename (without path) or leave blank to skip writing the workflow file:",
337
+ tag="workflow",
338
+ ).strip()
339
+ if new_name:
340
+ workflow_path = Path(f".github/workflows/{new_name}")
341
+ else:
342
+ toolkit.print("Skipped writing workflow file.")
343
+ toolkit.print_line()
344
+ write_workflow = False
345
+ toolkit.print_line()
346
+ if write_workflow:
347
+ msg_workflow = f"Wrote [bold]{workflow_path}[/bold] (branch: {branch})"
348
+ with toolkit.progress(title="Writing workflow file...") as progress:
349
+ _write_workflow_file(branch, workflow_path)
350
+ progress.log(msg_workflow)
351
+
352
+ toolkit.print_line()
353
+
354
+ toolkit.print(msg_done)
355
+ toolkit.print_line()
356
+ # Token expiration date is in ISO 8601 format (YYYY-MM-DDTHH:MM:SSZ), extract date portion
357
+ toolkit.print(
358
+ f"Your deploy token expires on [bold]{token_data['expired_at'][:10]}[/bold]. "
359
+ "Regenerate it from the dashboard or re-run this command before then.",
360
+ )
@@ -55,10 +55,10 @@ def test_stream_build_logs_successful(
55
55
  assert len(logs) == 3
56
56
 
57
57
  assert logs[0].type == "message"
58
- assert logs[0].message == "Building..."
58
+ assert logs[0].message == "Building..." # ty: ignore[unresolved-attribute]
59
59
 
60
60
  assert logs[1].type == "message"
61
- assert logs[1].message == "Done!"
61
+ assert logs[1].message == "Done!" # ty: ignore[unresolved-attribute]
62
62
 
63
63
  assert logs[2].type == "complete"
64
64
 
@@ -201,7 +201,7 @@ def test_stream_build_logs_network_error_retry(
201
201
 
202
202
  assert len(logs) == 2
203
203
  assert logs[0].type == "message"
204
- assert logs[0].message == "Success after retry"
204
+ assert logs[0].message == "Success after retry" # ty: ignore[unresolved-attribute]
205
205
 
206
206
 
207
207
  def test_stream_build_logs_server_error_retry(
@@ -24,8 +24,8 @@ runner = CliRunner()
24
24
  assets_path = Path(__file__).parent / "assets"
25
25
 
26
26
 
27
- def _get_random_team() -> dict[str, str]:
28
- name = "".join(random.choices(string.ascii_lowercase, k=10))
27
+ def _get_random_team(name: str | None = None) -> dict[str, str]:
28
+ name = name or "".join(random.choices(string.ascii_lowercase, k=10))
29
29
  slug = "".join(random.choices(string.ascii_lowercase, k=10))
30
30
  id = "".join(random.choices(string.digits, k=10))
31
31
 
@@ -323,6 +323,42 @@ def test_shows_teams(
323
323
  assert team_2["name"] in result.output
324
324
 
325
325
 
326
+ @pytest.mark.respx
327
+ def test_filter_teams(
328
+ logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
329
+ ) -> None:
330
+ steps = [*"al", Keys.ENTER, Keys.CTRL_C]
331
+
332
+ team_1 = _get_random_team(name="Alpha Team")
333
+ team_2 = _get_random_team(name="Beta Team")
334
+
335
+ respx_mock.get("/teams/").mock(
336
+ return_value=Response(
337
+ 200,
338
+ json={"data": [team_1, team_2]},
339
+ )
340
+ )
341
+
342
+ with (
343
+ changing_dir(tmp_path),
344
+ patch("rich_toolkit.container.getchar") as mock_getchar,
345
+ ):
346
+ mock_getchar.side_effect = steps
347
+
348
+ result = runner.invoke(app, ["deploy"])
349
+
350
+ assert result.exit_code == 1
351
+
352
+ assert "Filter: al" in result.output
353
+
354
+ # Truncate part of the output before "Filter: al"
355
+ filer_pos = result.output.rfind("Filter: al")
356
+ last_output = result.output[filer_pos:]
357
+
358
+ assert team_1["name"] in last_output
359
+ assert team_2["name"] not in last_output
360
+
361
+
326
362
  @pytest.mark.respx
327
363
  def test_asks_for_app_name_after_team(
328
364
  logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
@@ -349,6 +385,48 @@ def test_asks_for_app_name_after_team(
349
385
  assert "What's your app name?" in result.output
350
386
 
351
387
 
388
+ @pytest.mark.respx
389
+ def test_filter_apps(
390
+ logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
391
+ ) -> None:
392
+ steps = [Keys.ENTER, Keys.RIGHT_ARROW, Keys.ENTER, *"an", Keys.ENTER, Keys.CTRL_C]
393
+
394
+ team = _get_random_team()
395
+
396
+ respx_mock.get("/teams/").mock(
397
+ return_value=Response(
398
+ 200,
399
+ json={"data": [team]},
400
+ )
401
+ )
402
+
403
+ app_1 = _get_random_app(team_id=team["id"], slug="My App")
404
+ app_2 = _get_random_app(team_id=team["id"], slug="Another App")
405
+
406
+ respx_mock.get("/apps/", params={"team_id": team["id"]}).mock(
407
+ return_value=Response(200, json={"data": [app_1, app_2]})
408
+ )
409
+
410
+ with (
411
+ changing_dir(tmp_path),
412
+ patch("rich_toolkit.container.getchar") as mock_getchar,
413
+ ):
414
+ mock_getchar.side_effect = steps
415
+
416
+ result = runner.invoke(app, ["deploy"])
417
+
418
+ assert result.exit_code == 1
419
+
420
+ assert "Filter: an" in result.output
421
+
422
+ # Truncate part of the output before "Filter: an"
423
+ filer_pos = result.output.rfind("Filter: an")
424
+ last_output = result.output[filer_pos:]
425
+
426
+ assert app_1["slug"] not in last_output
427
+ assert app_2["slug"] in last_output
428
+
429
+
352
430
  @pytest.mark.respx
353
431
  def test_creates_app_on_backend(
354
432
  logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter