Skip to content

Commit 84f9509

Browse files
authored
Merge branch 'main' into ref/update-login
2 parents 96cfe83 + 38c8049 commit 84f9509

File tree

14 files changed

+295
-73
lines changed

14 files changed

+295
-73
lines changed

release-notes.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,14 @@
44

55
### Features
66

7+
* ✨ Add fastapi cloud sub-command. PR [#104](https://github.com/fastapilabs/fastapi-cloud-cli/pull/104) by [@buurro](https://github.com/buurro).
8+
* ✨ Check if token is expired when checking if user is logged in. PR [#105](https://github.com/fastapilabs/fastapi-cloud-cli/pull/105) by [@patrick91](https://github.com/patrick91).
79
* ✨ Support the verification skipped status. PR [#99](https://github.com/fastapilabs/fastapi-cloud-cli/pull/99) by [@DoctorJohn](https://github.com/DoctorJohn).
810

11+
### Fixes
12+
13+
* ♻️ Clean up code archives after uploading. PR [#106](https://github.com/fastapilabs/fastapi-cloud-cli/pull/106) by [@DoctorJohn](https://github.com/DoctorJohn).
14+
915
## 0.3.1
1016

1117
### Fixes

src/fastapi_cloud_cli/cli.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,17 +13,29 @@
1313

1414
app = typer.Typer(rich_markup_mode="rich")
1515

16+
cloud_app = typer.Typer(
17+
rich_markup_mode="rich",
18+
help="Manage [bold]FastAPI[/bold] Cloud deployments. 🚀",
19+
)
1620

1721
# TODO: use the app structure
1822

1923
# Additional commands
24+
25+
# fastapi cloud [command]
26+
cloud_app.command()(deploy)
27+
cloud_app.command()(login)
28+
cloud_app.command()(logout)
29+
cloud_app.command()(whoami)
30+
cloud_app.command()(unlink)
31+
32+
cloud_app.add_typer(env_app, name="env")
33+
34+
# fastapi [command]
2035
app.command()(deploy)
2136
app.command()(login)
22-
app.command()(logout)
23-
app.command()(whoami)
24-
app.command()(unlink)
2537

26-
app.add_typer(env_app, name="env")
38+
app.add_typer(cloud_app, name="cloud")
2739

2840

2941
def main() -> None:

src/fastapi_cloud_cli/commands/deploy.py

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
import tarfile
66
import tempfile
77
import time
8-
import uuid
98
from enum import Enum
109
from itertools import cycle
1110
from pathlib import Path
@@ -46,19 +45,14 @@ def _should_exclude_entry(path: Path) -> bool:
4645
return False
4746

4847

49-
def archive(path: Path) -> Path:
48+
def archive(path: Path, tar_path: Path) -> Path:
5049
logger.debug("Starting archive creation for path: %s", path)
5150
files = rignore.walk(
5251
path,
5352
should_exclude_entry=_should_exclude_entry,
5453
additional_ignore_paths=[".fastapicloudignore"],
5554
)
5655

57-
temp_dir = tempfile.mkdtemp()
58-
logger.debug("Created temp directory: %s", temp_dir)
59-
60-
name = f"fastapi-cloud-deploy-{uuid.uuid4()}"
61-
tar_path = Path(temp_dir) / f"{name}.tar"
6256
logger.debug("Archive will be created at: %s", tar_path)
6357

6458
file_count = 0
@@ -496,7 +490,7 @@ def _waitlist_form(toolkit: RichToolkit) -> None:
496490

497491
with contextlib.suppress(Exception):
498492
subprocess.run(
499-
["open", "raycast://confetti"],
493+
["open", "raycast://confetti?emojis=🐔⚡"],
500494
stdout=subprocess.DEVNULL,
501495
stderr=subprocess.DEVNULL,
502496
check=False,
@@ -581,16 +575,19 @@ def deploy(
581575
if not app:
582576
toolkit.print_line()
583577
toolkit.print(
584-
"If you deleted this app, you can run [bold]fastapi unlink[/] to unlink the local configuration.",
578+
"If you deleted this app, you can run [bold]fastapi cloud unlink[/] to unlink the local configuration.",
585579
tag="tip",
586580
)
587581
raise typer.Exit(1)
588582

589-
logger.debug("Creating archive for deployment")
590-
archive_path = archive(path or Path.cwd()) # noqa: F841
583+
with tempfile.TemporaryDirectory() as temp_dir:
584+
logger.debug("Creating archive for deployment")
585+
archive_path = Path(temp_dir) / "archive.tar"
586+
archive(path or Path.cwd(), archive_path)
591587

592-
with toolkit.progress(title="Creating deployment") as progress:
593-
with handle_http_errors(progress):
588+
with toolkit.progress(
589+
title="Creating deployment"
590+
) as progress, handle_http_errors(progress):
594591
logger.debug("Creating deployment for app: %s", app.id)
595592
deployment = _create_deployment(app.id)
596593

@@ -602,7 +599,7 @@ def deploy(
602599

603600
_upload_deployment(deployment.id, archive_path)
604601

605-
progress.log("Deployment uploaded successfully!")
602+
progress.log("Deployment uploaded successfully!")
606603

607604
toolkit.print_line()
608605

src/fastapi_cloud_cli/utils/auth.py

Lines changed: 64 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1+
import base64
2+
import binascii
3+
import json
14
import logging
5+
import time
26
from typing import Optional
37

48
from pydantic import BaseModel
@@ -55,7 +59,64 @@ def get_auth_token() -> Optional[str]:
5559
return auth_data.access_token
5660

5761

62+
def is_token_expired(token: str) -> bool:
63+
try:
64+
parts = token.split(".")
65+
66+
if len(parts) != 3:
67+
logger.debug("Invalid JWT format: expected 3 parts, got %d", len(parts))
68+
return True
69+
70+
payload = parts[1]
71+
72+
# Add padding if needed (JWT uses base64url encoding without padding)
73+
if padding := len(payload) % 4:
74+
payload += "=" * (4 - padding)
75+
76+
payload = payload.replace("-", "+").replace("_", "/")
77+
decoded_bytes = base64.b64decode(payload)
78+
payload_data = json.loads(decoded_bytes)
79+
80+
exp = payload_data.get("exp")
81+
82+
if exp is None:
83+
logger.debug("No 'exp' claim found in token")
84+
85+
return False
86+
87+
if not isinstance(exp, int): # pragma: no cover
88+
logger.debug("Invalid 'exp' claim: expected int, got %s", type(exp))
89+
90+
return True
91+
92+
current_time = time.time()
93+
94+
is_expired = current_time >= exp
95+
96+
logger.debug(
97+
"Token expiration check: current=%d, exp=%d, expired=%s",
98+
current_time,
99+
exp,
100+
is_expired,
101+
)
102+
103+
return is_expired
104+
except (binascii.Error, json.JSONDecodeError) as e:
105+
logger.debug("Error parsing JWT token: %s", e)
106+
107+
return True
108+
109+
58110
def is_logged_in() -> bool:
59-
result = get_auth_token() is not None
60-
logger.debug("Login status: %s", result)
61-
return result
111+
token = get_auth_token()
112+
113+
if token is None:
114+
logger.debug("Login status: False (no token)")
115+
return False
116+
117+
if is_token_expired(token):
118+
logger.debug("Login status: False (token expired)")
119+
return False
120+
121+
logger.debug("Login status: True")
122+
return True

tests/conftest.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
import pytest
88
from typer import rich_utils
99

10+
from .utils import create_jwt_token
11+
1012

1113
@pytest.fixture(autouse=True)
1214
def reset_syspath() -> Generator[None, None, None]:
@@ -26,7 +28,9 @@ def setup_terminal() -> None:
2628

2729
@pytest.fixture
2830
def logged_in_cli(temp_auth_config: Path) -> Generator[None, None, None]:
29-
temp_auth_config.write_text('{"access_token": "test_token_12345"}')
31+
valid_token = create_jwt_token({"sub": "test_user_12345"})
32+
33+
temp_auth_config.write_text(f'{{"access_token": "{valid_token}"}}')
3034

3135
yield
3236

tests/test_archive.py

Lines changed: 59 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,76 +1,89 @@
11
import tarfile
22
from pathlib import Path
33

4+
import pytest
5+
46
from fastapi_cloud_cli.commands.deploy import archive
57

68

7-
def test_archive_creates_tar_file(tmp_path: Path) -> None:
8-
(tmp_path / "main.py").write_text("print('hello')")
9-
(tmp_path / "config.json").write_text('{"key": "value"}')
10-
(tmp_path / "subdir").mkdir()
11-
(tmp_path / "subdir" / "utils.py").write_text("def helper(): pass")
9+
@pytest.fixture
10+
def src_path(tmp_path: Path) -> Path:
11+
path = tmp_path / "source"
12+
path.mkdir()
13+
return path
14+
15+
16+
@pytest.fixture
17+
def tar_path(tmp_path: Path) -> Path:
18+
return tmp_path / "archive.tar"
19+
1220

13-
tar_path = archive(tmp_path)
21+
def test_archive_creates_tar_file(src_path: Path, tar_path: Path) -> None:
22+
(src_path / "main.py").write_text("print('hello')")
23+
(src_path / "config.json").write_text('{"key": "value"}')
24+
(src_path / "subdir").mkdir()
25+
(src_path / "subdir" / "utils.py").write_text("def helper(): pass")
1426

27+
archive(src_path, tar_path)
1528
assert tar_path.exists()
16-
assert tar_path.suffix == ".tar"
17-
assert tar_path.name.startswith("fastapi-cloud-deploy-")
1829

1930

20-
def test_archive_excludes_venv_and_similar_folders(tmp_path: Path) -> None:
31+
def test_archive_excludes_venv_and_similar_folders(
32+
src_path: Path, tar_path: Path
33+
) -> None:
2134
"""Should exclude .venv directory from archive."""
2235
# the only files we want to include
23-
(tmp_path / "main.py").write_text("print('hello')")
24-
(tmp_path / "static").mkdir()
25-
(tmp_path / "static" / "index.html").write_text("<html></html>")
36+
(src_path / "main.py").write_text("print('hello')")
37+
(src_path / "static").mkdir()
38+
(src_path / "static" / "index.html").write_text("<html></html>")
2639
# virtualenv
27-
(tmp_path / ".venv").mkdir()
28-
(tmp_path / ".venv" / "lib").mkdir()
29-
(tmp_path / ".venv" / "lib" / "package.py").write_text("# package")
40+
(src_path / ".venv").mkdir()
41+
(src_path / ".venv" / "lib").mkdir()
42+
(src_path / ".venv" / "lib" / "package.py").write_text("# package")
3043
# pycache
31-
(tmp_path / "__pycache__").mkdir()
32-
(tmp_path / "__pycache__" / "main.cpython-311.pyc").write_text("bytecode")
44+
(src_path / "__pycache__").mkdir()
45+
(src_path / "__pycache__" / "main.cpython-311.pyc").write_text("bytecode")
3346
# pyc files
34-
(tmp_path / "main.pyc").write_text("bytecode")
47+
(src_path / "main.pyc").write_text("bytecode")
3548
# mypy/pytest
36-
(tmp_path / ".mypy_cache").mkdir()
37-
(tmp_path / ".mypy_cache" / "file.json").write_text("{}")
38-
(tmp_path / ".pytest_cache").mkdir()
39-
(tmp_path / ".pytest_cache" / "cache.db").write_text("data")
49+
(src_path / ".mypy_cache").mkdir()
50+
(src_path / ".mypy_cache" / "file.json").write_text("{}")
51+
(src_path / ".pytest_cache").mkdir()
52+
(src_path / ".pytest_cache" / "cache.db").write_text("data")
4053

41-
tar_path = archive(tmp_path)
54+
archive(src_path, tar_path)
4255

4356
with tarfile.open(tar_path, "r") as tar:
4457
names = tar.getnames()
4558
assert set(names) == {"main.py", "static/index.html"}
4659

4760

48-
def test_archive_preserves_relative_paths(tmp_path: Path) -> None:
49-
(tmp_path / "src").mkdir()
50-
(tmp_path / "src" / "app").mkdir()
51-
(tmp_path / "src" / "app" / "main.py").write_text("print('hello')")
61+
def test_archive_preserves_relative_paths(src_path: Path, tar_path: Path) -> None:
62+
(src_path / "src").mkdir()
63+
(src_path / "src" / "app").mkdir()
64+
(src_path / "src" / "app" / "main.py").write_text("print('hello')")
5265

53-
tar_path = archive(tmp_path)
66+
archive(src_path, tar_path)
5467

5568
with tarfile.open(tar_path, "r") as tar:
5669
names = tar.getnames()
5770
assert names == ["src/app/main.py"]
5871

5972

60-
def test_archive_respects_fastapicloudignore(tmp_path: Path) -> None:
73+
def test_archive_respects_fastapicloudignore(src_path: Path, tar_path: Path) -> None:
6174
"""Should exclude files specified in .fastapicloudignore."""
6275
# Create test files
63-
(tmp_path / "main.py").write_text("print('hello')")
64-
(tmp_path / "config.py").write_text("CONFIG = 'value'")
65-
(tmp_path / "secrets.env").write_text("SECRET_KEY=xyz")
66-
(tmp_path / "data").mkdir()
67-
(tmp_path / "data" / "file.txt").write_text("data")
76+
(src_path / "main.py").write_text("print('hello')")
77+
(src_path / "config.py").write_text("CONFIG = 'value'")
78+
(src_path / "secrets.env").write_text("SECRET_KEY=xyz")
79+
(src_path / "data").mkdir()
80+
(src_path / "data" / "file.txt").write_text("data")
6881

6982
# Create .fastapicloudignore file
70-
(tmp_path / ".fastapicloudignore").write_text("secrets.env\ndata/\n")
83+
(src_path / ".fastapicloudignore").write_text("secrets.env\ndata/\n")
7184

7285
# Create archive
73-
tar_path = archive(tmp_path)
86+
archive(src_path, tar_path)
7487

7588
# Verify ignored files are excluded
7689
with tarfile.open(tar_path, "r") as tar:
@@ -81,21 +94,23 @@ def test_archive_respects_fastapicloudignore(tmp_path: Path) -> None:
8194
}
8295

8396

84-
def test_archive_respects_fastapicloudignore_unignore(tmp_path: Path) -> None:
97+
def test_archive_respects_fastapicloudignore_unignore(
98+
src_path: Path, tar_path: Path
99+
) -> None:
85100
"""Test we can use .fastapicloudignore to unignore files inside .gitignore"""
86101
# Create test files
87-
(tmp_path / "main.py").write_text("print('hello')")
88-
(tmp_path / "static/build").mkdir(exist_ok=True, parents=True)
89-
(tmp_path / "static/build/style.css").write_text("body { background: #bada55 }")
102+
(src_path / "main.py").write_text("print('hello')")
103+
(src_path / "static/build").mkdir(exist_ok=True, parents=True)
104+
(src_path / "static/build/style.css").write_text("body { background: #bada55 }")
90105
# Rignore needs a .git folder to make .gitignore work
91-
(tmp_path / ".git").mkdir(exist_ok=True, parents=True)
92-
(tmp_path / ".gitignore").write_text("build/")
106+
(src_path / ".git").mkdir(exist_ok=True, parents=True)
107+
(src_path / ".gitignore").write_text("build/")
93108

94109
# Create .fastapicloudignore file
95-
(tmp_path / ".fastapicloudignore").write_text("!static/build")
110+
(src_path / ".fastapicloudignore").write_text("!static/build")
96111

97112
# Create archive
98-
tar_path = archive(tmp_path)
113+
archive(src_path, tar_path)
99114

100115
# Verify ignored files are excluded
101116
with tarfile.open(tar_path, "r") as tar:

0 commit comments

Comments
 (0)