sweatstack-cli 0.3.0__tar.gz → 0.4.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.
- {sweatstack_cli-0.3.0 → sweatstack_cli-0.4.0}/PKG-INFO +1 -1
- {sweatstack_cli-0.3.0 → sweatstack_cli-0.4.0}/pyproject.toml +1 -1
- {sweatstack_cli-0.3.0 → sweatstack_cli-0.4.0}/src/sweatstack_cli/__init__.py +1 -1
- sweatstack_cli-0.4.0/src/sweatstack_cli/commands/auth.py +100 -0
- {sweatstack_cli-0.3.0 → sweatstack_cli-0.4.0}/src/sweatstack_cli/main.py +1 -2
- {sweatstack_cli-0.3.0 → sweatstack_cli-0.4.0}/tests/test_commands/test_cli.py +1 -1
- {sweatstack_cli-0.3.0 → sweatstack_cli-0.4.0}/uv.lock +1 -1
- sweatstack_cli-0.3.0/src/sweatstack_cli/commands/auth.py +0 -127
- {sweatstack_cli-0.3.0 → sweatstack_cli-0.4.0}/.claude/settings.local.json +0 -0
- {sweatstack_cli-0.3.0 → sweatstack_cli-0.4.0}/.gitignore +0 -0
- {sweatstack_cli-0.3.0 → sweatstack_cli-0.4.0}/.python-version +0 -0
- {sweatstack_cli-0.3.0 → sweatstack_cli-0.4.0}/CREATE_PRIVATE_APP.md +0 -0
- {sweatstack_cli-0.3.0 → sweatstack_cli-0.4.0}/DEVELOPMENT.md +0 -0
- {sweatstack_cli-0.3.0 → sweatstack_cli-0.4.0}/Makefile +0 -0
- {sweatstack_cli-0.3.0 → sweatstack_cli-0.4.0}/PLAN.md +0 -0
- {sweatstack_cli-0.3.0 → sweatstack_cli-0.4.0}/README.md +0 -0
- {sweatstack_cli-0.3.0 → sweatstack_cli-0.4.0}/src/sweatstack_cli/api/__init__.py +0 -0
- {sweatstack_cli-0.3.0 → sweatstack_cli-0.4.0}/src/sweatstack_cli/api/client.py +0 -0
- {sweatstack_cli-0.3.0 → sweatstack_cli-0.4.0}/src/sweatstack_cli/auth/__init__.py +0 -0
- {sweatstack_cli-0.3.0 → sweatstack_cli-0.4.0}/src/sweatstack_cli/auth/callback_server.py +0 -0
- {sweatstack_cli-0.3.0 → sweatstack_cli-0.4.0}/src/sweatstack_cli/auth/jwt.py +0 -0
- {sweatstack_cli-0.3.0 → sweatstack_cli-0.4.0}/src/sweatstack_cli/auth/pkce.py +0 -0
- {sweatstack_cli-0.3.0 → sweatstack_cli-0.4.0}/src/sweatstack_cli/auth/tokens.py +0 -0
- {sweatstack_cli-0.3.0 → sweatstack_cli-0.4.0}/src/sweatstack_cli/commands/__init__.py +0 -0
- {sweatstack_cli-0.3.0 → sweatstack_cli-0.4.0}/src/sweatstack_cli/commands/app.py +0 -0
- {sweatstack_cli-0.3.0 → sweatstack_cli-0.4.0}/src/sweatstack_cli/commands/page.py +0 -0
- {sweatstack_cli-0.3.0 → sweatstack_cli-0.4.0}/src/sweatstack_cli/config.py +0 -0
- {sweatstack_cli-0.3.0 → sweatstack_cli-0.4.0}/src/sweatstack_cli/console.py +0 -0
- {sweatstack_cli-0.3.0 → sweatstack_cli-0.4.0}/src/sweatstack_cli/exceptions.py +0 -0
- {sweatstack_cli-0.3.0 → sweatstack_cli-0.4.0}/src/sweatstack_cli/py.typed +0 -0
- {sweatstack_cli-0.3.0 → sweatstack_cli-0.4.0}/src/sweatstack_cli/version_check.py +0 -0
- {sweatstack_cli-0.3.0 → sweatstack_cli-0.4.0}/tests/__init__.py +0 -0
- {sweatstack_cli-0.3.0 → sweatstack_cli-0.4.0}/tests/conftest.py +0 -0
- {sweatstack_cli-0.3.0 → sweatstack_cli-0.4.0}/tests/test_api/__init__.py +0 -0
- {sweatstack_cli-0.3.0 → sweatstack_cli-0.4.0}/tests/test_api/test_client.py +0 -0
- {sweatstack_cli-0.3.0 → sweatstack_cli-0.4.0}/tests/test_auth/__init__.py +0 -0
- {sweatstack_cli-0.3.0 → sweatstack_cli-0.4.0}/tests/test_auth/test_callback_server.py +0 -0
- {sweatstack_cli-0.3.0 → sweatstack_cli-0.4.0}/tests/test_auth/test_jwt.py +0 -0
- {sweatstack_cli-0.3.0 → sweatstack_cli-0.4.0}/tests/test_auth/test_pkce.py +0 -0
- {sweatstack_cli-0.3.0 → sweatstack_cli-0.4.0}/tests/test_auth/test_tokens.py +0 -0
- {sweatstack_cli-0.3.0 → sweatstack_cli-0.4.0}/tests/test_commands/__init__.py +0 -0
- {sweatstack_cli-0.3.0 → sweatstack_cli-0.4.0}/tests/test_commands/test_app.py +0 -0
- {sweatstack_cli-0.3.0 → sweatstack_cli-0.4.0}/tests/test_version_check.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sweatstack-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Command-line interface for SweatStack — the sports data platform for developers
|
|
5
5
|
Project-URL: Homepage, https://sweatstack.no
|
|
6
6
|
Project-URL: Documentation, https://docs.sweatstack.no
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Authentication commands: login, logout, whoami, status."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from sweatstack_cli.api import APIClient
|
|
8
|
+
from sweatstack_cli.auth import Authenticator
|
|
9
|
+
from sweatstack_cli.console import console
|
|
10
|
+
from sweatstack_cli.exceptions import APIError, AuthenticationError
|
|
11
|
+
from sweatstack_cli.version_check import check_for_update
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def login(
|
|
15
|
+
force: bool = typer.Option(
|
|
16
|
+
False,
|
|
17
|
+
"--force",
|
|
18
|
+
"-f",
|
|
19
|
+
help="Force re-authentication even if already logged in.",
|
|
20
|
+
),
|
|
21
|
+
) -> None:
|
|
22
|
+
"""Authenticate with SweatStack via browser."""
|
|
23
|
+
auth = Authenticator()
|
|
24
|
+
|
|
25
|
+
# Check if already logged in
|
|
26
|
+
if not force:
|
|
27
|
+
existing = auth.get_valid_tokens()
|
|
28
|
+
if existing:
|
|
29
|
+
try:
|
|
30
|
+
client = APIClient(authenticator=auth)
|
|
31
|
+
user = client.get("/api/v1/oauth/userinfo")
|
|
32
|
+
console.print(
|
|
33
|
+
f"Already logged in as [bold]{user['name']}[/bold]. "
|
|
34
|
+
f"Use [cyan]--force[/cyan] to re-authenticate."
|
|
35
|
+
)
|
|
36
|
+
return
|
|
37
|
+
except AuthenticationError:
|
|
38
|
+
pass # Token invalid, proceed with login
|
|
39
|
+
|
|
40
|
+
with console.status("[bold]Opening browser for authentication...[/bold]"):
|
|
41
|
+
try:
|
|
42
|
+
auth.login(force=True)
|
|
43
|
+
except AuthenticationError as e:
|
|
44
|
+
console.print(f"[red]Authentication failed:[/red] {e}")
|
|
45
|
+
raise typer.Exit(2)
|
|
46
|
+
|
|
47
|
+
# Verify by fetching user info
|
|
48
|
+
try:
|
|
49
|
+
client = APIClient(authenticator=auth)
|
|
50
|
+
user = client.get("/api/v1/oauth/userinfo")
|
|
51
|
+
console.print(f"[green]✓[/green] Logged in as [bold]{user['name']}[/bold]")
|
|
52
|
+
except AuthenticationError as e:
|
|
53
|
+
console.print(f"[red]Login succeeded but verification failed:[/red] {e}")
|
|
54
|
+
raise typer.Exit(2)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def logout() -> None:
|
|
58
|
+
"""Remove stored credentials."""
|
|
59
|
+
auth = Authenticator()
|
|
60
|
+
auth.logout()
|
|
61
|
+
console.print("[green]✓[/green] Logged out")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def status() -> None:
|
|
65
|
+
"""Show authentication status and version info."""
|
|
66
|
+
from sweatstack_cli import __version__
|
|
67
|
+
|
|
68
|
+
# Check authentication and get user info
|
|
69
|
+
auth = Authenticator()
|
|
70
|
+
tokens = auth.get_valid_tokens()
|
|
71
|
+
|
|
72
|
+
if tokens is None:
|
|
73
|
+
console.print("[red]✗[/red] Not authenticated")
|
|
74
|
+
else:
|
|
75
|
+
console.print("[green]✓[/green] Authenticated")
|
|
76
|
+
try:
|
|
77
|
+
client = APIClient(authenticator=auth)
|
|
78
|
+
user = client.get("/api/v1/oauth/userinfo")
|
|
79
|
+
console.print(f" Name: {user.get('name', 'unknown')}")
|
|
80
|
+
console.print(f" Email: {user.get('email', 'unknown')}")
|
|
81
|
+
console.print(f" ID: {user.get('sub', 'unknown')}")
|
|
82
|
+
except (AuthenticationError, APIError):
|
|
83
|
+
pass # Silently skip user info on error
|
|
84
|
+
|
|
85
|
+
# Check version
|
|
86
|
+
update_available, latest = check_for_update()
|
|
87
|
+
if update_available and latest:
|
|
88
|
+
console.print(
|
|
89
|
+
f"[yellow]⚠[/yellow] Update available: {latest} (current: {__version__})\n"
|
|
90
|
+
f" Run: [cyan]pip install --upgrade sweatstack-cli[/cyan]"
|
|
91
|
+
)
|
|
92
|
+
elif latest:
|
|
93
|
+
console.print(f"[green]✓[/green] Up to date ({__version__})")
|
|
94
|
+
else:
|
|
95
|
+
console.print(f"[dim]?[/dim] Version {__version__} (update check failed)")
|
|
96
|
+
|
|
97
|
+
# Exit with error if not authenticated
|
|
98
|
+
if tokens is None:
|
|
99
|
+
console.print("\nRun [bold]sweatstack login[/bold] to authenticate.")
|
|
100
|
+
raise typer.Exit(1)
|
|
@@ -28,8 +28,7 @@ app.add_typer(app_commands.app, name="app")
|
|
|
28
28
|
# These are the most common operations, so we promote them to top level
|
|
29
29
|
app.command(name="login", help="Authenticate with SweatStack via browser.")(auth.login)
|
|
30
30
|
app.command(name="logout", help="Remove stored credentials.")(auth.logout)
|
|
31
|
-
app.command(name="
|
|
32
|
-
app.command(name="status", help="Show authentication status and token details.")(auth.status)
|
|
31
|
+
app.command(name="status", help="Show authentication and version status.")(auth.status)
|
|
33
32
|
|
|
34
33
|
|
|
35
34
|
def version_callback(value: bool) -> None:
|
|
@@ -20,7 +20,7 @@ class TestCLI:
|
|
|
20
20
|
assert "SweatStack CLI" in result.stdout
|
|
21
21
|
assert "login" in result.stdout
|
|
22
22
|
assert "logout" in result.stdout
|
|
23
|
-
assert "
|
|
23
|
+
assert "status" in result.stdout
|
|
24
24
|
assert "page" in result.stdout
|
|
25
25
|
|
|
26
26
|
def test_version(self) -> None:
|
|
@@ -1,127 +0,0 @@
|
|
|
1
|
-
"""Authentication commands: login, logout, whoami, status."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import datetime
|
|
6
|
-
|
|
7
|
-
import typer
|
|
8
|
-
|
|
9
|
-
from sweatstack_cli.api import APIClient
|
|
10
|
-
from sweatstack_cli.auth import Authenticator
|
|
11
|
-
from sweatstack_cli.auth.jwt import decode_jwt_payload
|
|
12
|
-
from sweatstack_cli.console import console
|
|
13
|
-
from sweatstack_cli.exceptions import APIError, AuthenticationError
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
def login(
|
|
17
|
-
force: bool = typer.Option(
|
|
18
|
-
False,
|
|
19
|
-
"--force",
|
|
20
|
-
"-f",
|
|
21
|
-
help="Force re-authentication even if already logged in.",
|
|
22
|
-
),
|
|
23
|
-
) -> None:
|
|
24
|
-
"""Authenticate with SweatStack via browser."""
|
|
25
|
-
auth = Authenticator()
|
|
26
|
-
|
|
27
|
-
# Check if already logged in
|
|
28
|
-
if not force:
|
|
29
|
-
existing = auth.get_valid_tokens()
|
|
30
|
-
if existing:
|
|
31
|
-
try:
|
|
32
|
-
client = APIClient(authenticator=auth)
|
|
33
|
-
user = client.get("/api/v1/oauth/userinfo")
|
|
34
|
-
console.print(
|
|
35
|
-
f"Already logged in as [bold]{user['name']}[/bold]. "
|
|
36
|
-
f"Use [cyan]--force[/cyan] to re-authenticate."
|
|
37
|
-
)
|
|
38
|
-
return
|
|
39
|
-
except AuthenticationError:
|
|
40
|
-
pass # Token invalid, proceed with login
|
|
41
|
-
|
|
42
|
-
with console.status("[bold]Opening browser for authentication...[/bold]"):
|
|
43
|
-
try:
|
|
44
|
-
auth.login(force=True)
|
|
45
|
-
except AuthenticationError as e:
|
|
46
|
-
console.print(f"[red]Authentication failed:[/red] {e}")
|
|
47
|
-
raise typer.Exit(2)
|
|
48
|
-
|
|
49
|
-
# Verify by fetching user info
|
|
50
|
-
try:
|
|
51
|
-
client = APIClient(authenticator=auth)
|
|
52
|
-
user = client.get("/api/v1/oauth/userinfo")
|
|
53
|
-
console.print(f"[green]✓[/green] Logged in as [bold]{user['name']}[/bold]")
|
|
54
|
-
except AuthenticationError as e:
|
|
55
|
-
console.print(f"[red]Login succeeded but verification failed:[/red] {e}")
|
|
56
|
-
raise typer.Exit(2)
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
def logout() -> None:
|
|
60
|
-
"""Remove stored credentials."""
|
|
61
|
-
auth = Authenticator()
|
|
62
|
-
auth.logout()
|
|
63
|
-
console.print("[green]✓[/green] Logged out")
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
def whoami() -> None:
|
|
67
|
-
"""Show current authenticated user."""
|
|
68
|
-
try:
|
|
69
|
-
client = APIClient()
|
|
70
|
-
user = client.get("/api/v1/oauth/userinfo")
|
|
71
|
-
console.print(
|
|
72
|
-
f"Logged in as: [bold]{user['name']}[/bold] (email={user.get('email', 'no email')}, id={user.get('sub', 'no id')})"
|
|
73
|
-
)
|
|
74
|
-
except AuthenticationError:
|
|
75
|
-
console.print("[yellow]Not logged in[/yellow]")
|
|
76
|
-
console.print("Run [bold]sweatstack login[/bold] to authenticate.")
|
|
77
|
-
raise typer.Exit(1)
|
|
78
|
-
except APIError as e:
|
|
79
|
-
console.print(f"[red]Error:[/red] {e}")
|
|
80
|
-
raise typer.Exit(3)
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
def status() -> None:
|
|
84
|
-
"""Show authentication status and token details."""
|
|
85
|
-
auth = Authenticator()
|
|
86
|
-
tokens = auth.get_valid_tokens()
|
|
87
|
-
|
|
88
|
-
if tokens is None:
|
|
89
|
-
console.print("[yellow]Not authenticated[/yellow]")
|
|
90
|
-
console.print("Run [bold]sweatstack login[/bold] to authenticate.")
|
|
91
|
-
raise typer.Exit(1)
|
|
92
|
-
|
|
93
|
-
try:
|
|
94
|
-
payload = decode_jwt_payload(tokens.access_token)
|
|
95
|
-
except ValueError as e:
|
|
96
|
-
console.print(f"[red]Invalid token format:[/red] {e}")
|
|
97
|
-
console.print("Run [bold]sweatstack login --force[/bold] to re-authenticate.")
|
|
98
|
-
raise typer.Exit(2)
|
|
99
|
-
|
|
100
|
-
# Format expiry time
|
|
101
|
-
exp_timestamp = payload.get("exp", 0)
|
|
102
|
-
exp_dt = datetime.datetime.fromtimestamp(exp_timestamp, tz=datetime.UTC)
|
|
103
|
-
now = datetime.datetime.now(tz=datetime.UTC)
|
|
104
|
-
|
|
105
|
-
if exp_dt > now:
|
|
106
|
-
remaining = exp_dt - now
|
|
107
|
-
hours, remainder = divmod(int(remaining.total_seconds()), 3600)
|
|
108
|
-
minutes, seconds = divmod(remainder, 60)
|
|
109
|
-
|
|
110
|
-
if hours > 0:
|
|
111
|
-
time_str = f"{hours}h {minutes}m remaining"
|
|
112
|
-
elif minutes > 0:
|
|
113
|
-
time_str = f"{minutes}m {seconds}s remaining"
|
|
114
|
-
else:
|
|
115
|
-
time_str = f"{seconds}s remaining"
|
|
116
|
-
|
|
117
|
-
expiry_display = f"{exp_dt.strftime('%Y-%m-%d %H:%M:%S')} UTC ({time_str})"
|
|
118
|
-
else:
|
|
119
|
-
expiry_display = f"{exp_dt.strftime('%Y-%m-%d %H:%M:%S')} UTC [red](expired)[/red]"
|
|
120
|
-
|
|
121
|
-
console.print("[green]✓[/green] Authenticated")
|
|
122
|
-
console.print(f" User ID: {payload.get('sub', 'unknown')}")
|
|
123
|
-
console.print(f" Expires: {expiry_display}")
|
|
124
|
-
|
|
125
|
-
# Show timezone if available
|
|
126
|
-
if tz := payload.get("tz"):
|
|
127
|
-
console.print(f" Timezone: {tz}")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|