softmax-cli 0.26.8__tar.gz → 0.26.10__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 (21) hide show
  1. {softmax_cli-0.26.8/src/softmax_cli.egg-info → softmax_cli-0.26.10}/PKG-INFO +1 -1
  2. {softmax_cli-0.26.8 → softmax_cli-0.26.10}/src/softmax/auth.py +5 -0
  3. {softmax_cli-0.26.8 → softmax_cli-0.26.10}/src/softmax/cli.py +68 -43
  4. {softmax_cli-0.26.8 → softmax_cli-0.26.10}/src/softmax/cogames.py +6 -9
  5. {softmax_cli-0.26.8 → softmax_cli-0.26.10/src/softmax_cli.egg-info}/PKG-INFO +1 -1
  6. {softmax_cli-0.26.8 → softmax_cli-0.26.10}/tests/test_auth_login.py +59 -6
  7. {softmax_cli-0.26.8 → softmax_cli-0.26.10}/tests/test_python_api.py +3 -7
  8. {softmax_cli-0.26.8 → softmax_cli-0.26.10}/BUILD.bazel +0 -0
  9. {softmax_cli-0.26.8 → softmax_cli-0.26.10}/pyproject.toml +0 -0
  10. {softmax_cli-0.26.8 → softmax_cli-0.26.10}/setup.cfg +0 -0
  11. {softmax_cli-0.26.8 → softmax_cli-0.26.10}/src/softmax/__init__.py +0 -0
  12. {softmax_cli-0.26.8 → softmax_cli-0.26.10}/src/softmax/_console.py +0 -0
  13. {softmax_cli-0.26.8 → softmax_cli-0.26.10}/src/softmax/perform_login.py +0 -0
  14. {softmax_cli-0.26.8 → softmax_cli-0.26.10}/src/softmax/token_storage.py +0 -0
  15. {softmax_cli-0.26.8 → softmax_cli-0.26.10}/src/softmax_cli.egg-info/SOURCES.txt +0 -0
  16. {softmax_cli-0.26.8 → softmax_cli-0.26.10}/src/softmax_cli.egg-info/dependency_links.txt +0 -0
  17. {softmax_cli-0.26.8 → softmax_cli-0.26.10}/src/softmax_cli.egg-info/entry_points.txt +0 -0
  18. {softmax_cli-0.26.8 → softmax_cli-0.26.10}/src/softmax_cli.egg-info/requires.txt +0 -0
  19. {softmax_cli-0.26.8 → softmax_cli-0.26.10}/src/softmax_cli.egg-info/top_level.txt +0 -0
  20. {softmax_cli-0.26.8 → softmax_cli-0.26.10}/tests/BUILD.bazel +0 -0
  21. {softmax_cli-0.26.8 → softmax_cli-0.26.10}/tests/test_cli_plugins.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: softmax-cli
3
- Version: 0.26.8
3
+ Version: 0.26.10
4
4
  Summary: Softmax CLI — authentication and account tools
5
5
  Classifier: Programming Language :: Python :: 3
6
6
  Classifier: Programming Language :: Python :: 3.11
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import os
5
6
  from urllib.parse import urlencode, urlsplit, urlunsplit
6
7
 
7
8
  import httpx
@@ -16,6 +17,10 @@ DEFAULT_COGAMES_SERVER = "https://softmax.com/api"
16
17
  DEFAULT_COGAMES_API_SERVER = "https://api.observatory.softmax-research.net"
17
18
 
18
19
 
20
+ def get_login_server() -> str:
21
+ return os.environ.get("COGAMES_LOGIN_URL", DEFAULT_COGAMES_SERVER)
22
+
23
+
19
24
  class WhoAmIResponse(BaseModel):
20
25
  user_email: str
21
26
  is_softmax_team_member: bool = False
@@ -4,6 +4,7 @@ import importlib
4
4
  import importlib.util
5
5
  import sys
6
6
 
7
+ import httpx
7
8
  import typer
8
9
  from rich.panel import Panel
9
10
 
@@ -13,6 +14,7 @@ from softmax.auth import (
13
14
  build_browser_login_url,
14
15
  delete_cogames_tokens,
15
16
  fetch_cogames_whoami,
17
+ get_login_server,
16
18
  load_cogames_user_token,
17
19
  load_current_cogames_token,
18
20
  save_cogames_active_token,
@@ -39,15 +41,16 @@ def _register_optional_apps() -> None:
39
41
  app.add_typer(cogames_cli.app, name="cogames", rich_help_panel="Local Games")
40
42
 
41
43
 
42
- def _build_manual_set_token_command(*, login_server: str) -> str:
43
- command = "softmax set-token '<TOKEN>'"
44
- if login_server != DEFAULT_COGAMES_SERVER:
45
- command += f" --login-server '{login_server}'"
46
- return command
44
+ def _build_manual_set_token_command(server: str | None = None) -> str:
45
+ if server:
46
+ return f"softmax set-token --server '{server}' '<TOKEN>'"
47
+ return "softmax set-token '<TOKEN>'"
47
48
 
48
49
 
49
- def _print_non_tty_login_instructions(*, login_server: str) -> None:
50
- auth_url = build_browser_login_url(login_server)
50
+ def _print_non_tty_login_instructions(login_server: str | None = None) -> None:
51
+ resolved = login_server or get_login_server()
52
+ is_custom = resolved != DEFAULT_COGAMES_SERVER
53
+ auth_url = build_browser_login_url(resolved)
51
54
  console.print("Interactive login requires a TTY.", style="red")
52
55
  console.print()
53
56
  console.print("Open this URL in any browser to sign in:", style="yellow")
@@ -56,7 +59,7 @@ def _print_non_tty_login_instructions(*, login_server: str) -> None:
56
59
  console.print()
57
60
  console.print("Copy the auth token from the browser, then run:", style="yellow")
58
61
  console.print()
59
- console.print(" ", _build_manual_set_token_command(login_server=login_server))
62
+ console.print(" ", _build_manual_set_token_command(resolved if is_custom else None))
60
63
  console.print()
61
64
  console.print(
62
65
  Panel(
@@ -70,12 +73,6 @@ def _print_non_tty_login_instructions(*, login_server: str) -> None:
70
73
 
71
74
  @app.command(name="login")
72
75
  def login_cmd(
73
- login_server: str = typer.Option(
74
- DEFAULT_COGAMES_SERVER,
75
- "--login-server",
76
- metavar="URL",
77
- help="Authentication server URL",
78
- ),
79
76
  no_browser: bool = typer.Option(
80
77
  False,
81
78
  "--no-browser",
@@ -87,18 +84,43 @@ def login_cmd(
87
84
  "-f",
88
85
  help="Re-authenticate even if already logged in",
89
86
  ),
87
+ server: str | None = typer.Option(
88
+ None,
89
+ "--server",
90
+ "-s",
91
+ metavar="URL",
92
+ help="Authentication server URL.",
93
+ ),
90
94
  ) -> None:
91
95
  """Sign in to Softmax."""
92
96
  from urllib.parse import urlparse # noqa: PLC0415
93
97
 
98
+ login_server = server or get_login_server()
99
+ is_default_server = login_server == DEFAULT_COGAMES_SERVER
94
100
  user_token = None if force else load_cogames_user_token(login_server=login_server)
101
+ if user_token is not None:
102
+ api_server = None if is_default_server else login_server
103
+ try:
104
+ whoami = fetch_cogames_whoami(api_server=api_server, token=user_token)
105
+ if whoami.subject_type == "anonymous":
106
+ console.print("Saved token is no longer valid. Re-authenticating...", style="yellow")
107
+ user_token = None
108
+ except httpx.HTTPStatusError as exc:
109
+ if exc.response.status_code == 401:
110
+ console.print("Saved token is no longer valid. Re-authenticating...", style="yellow")
111
+ user_token = None
112
+ else:
113
+ console.print(f"Could not verify token (HTTP {exc.response.status_code}), proceeding.", style="yellow")
114
+ except httpx.HTTPError:
115
+ console.print("Could not reach server to verify token. Proceeding with saved token.", style="yellow")
116
+
95
117
  if user_token is not None:
96
118
  save_cogames_active_token(login_server=login_server, token=user_token)
97
119
  console.print(f"Already authenticated with {urlparse(login_server).hostname}", style="green")
98
120
  return
99
121
 
100
122
  if not sys.stdin.isatty():
101
- _print_non_tty_login_instructions(login_server=login_server)
123
+ _print_non_tty_login_instructions(login_server)
102
124
  raise typer.Exit(1)
103
125
 
104
126
  try:
@@ -110,7 +132,7 @@ def login_cmd(
110
132
  "If you are a coding agent, ask your human to open the URL below and give you "
111
133
  "the auth token. Then paste the token into this window or run:\n"
112
134
  "\n"
113
- f"{_build_manual_set_token_command(login_server=login_server)}"
135
+ f"{_build_manual_set_token_command(server)}"
114
136
  ),
115
137
  open_browser=not no_browser,
116
138
  )
@@ -128,14 +150,16 @@ def login_cmd(
128
150
 
129
151
  @app.command(name="logout")
130
152
  def logout_cmd(
131
- login_server: str = typer.Option(
132
- DEFAULT_COGAMES_SERVER,
133
- "--login-server",
153
+ server: str | None = typer.Option(
154
+ None,
155
+ "--server",
156
+ "-s",
134
157
  metavar="URL",
135
- help="Authentication server URL",
158
+ help="Authentication server URL.",
136
159
  ),
137
160
  ) -> None:
138
161
  """Remove saved authentication token."""
162
+ login_server = server or get_login_server()
139
163
  if delete_cogames_tokens(login_server=login_server):
140
164
  console.print("Logged out.", style="green")
141
165
  else:
@@ -144,42 +168,39 @@ def logout_cmd(
144
168
 
145
169
  @app.command(name="get-login-url")
146
170
  def get_login_url_cmd(
147
- login_server: str = typer.Option(
148
- DEFAULT_COGAMES_SERVER,
149
- "--login-server",
171
+ server: str | None = typer.Option(
172
+ None,
173
+ "--server",
174
+ "-s",
150
175
  metavar="URL",
151
- help="Authentication server URL",
176
+ help="Authentication server URL.",
152
177
  ),
153
178
  ) -> None:
154
179
  """Print a browser sign-in URL for manual login."""
155
- print(build_browser_login_url(login_server))
180
+ print(build_browser_login_url(server or get_login_server()))
156
181
 
157
182
 
158
183
  @app.command(name="status")
159
184
  def status_cmd(
160
- login_server: str = typer.Option(
161
- DEFAULT_COGAMES_SERVER,
162
- "--login-server",
163
- metavar="URL",
164
- help="Authentication server URL",
165
- ),
166
185
  server: str | None = typer.Option(
167
186
  None,
168
187
  "--server",
169
188
  "-s",
170
189
  metavar="URL",
171
- help="API server URL for /whoami verification. Defaults to --login-server when"
172
- " that is overridden, otherwise the production Observatory API.",
190
+ help="Authentication server URL.",
173
191
  ),
174
192
  ) -> None:
175
193
  """Check authentication status via /whoami."""
194
+ login_server = server or get_login_server()
176
195
  token = load_current_cogames_token(login_server=login_server)
177
196
  if not token:
178
197
  console.print("[red]Not authenticated.[/red] Run [cyan]softmax login[/cyan] first.")
179
198
  raise typer.Exit(1)
180
199
 
181
- api_server = server or (login_server if login_server != DEFAULT_COGAMES_SERVER else None)
182
- session = fetch_cogames_whoami(api_server=api_server, token=token)
200
+ session = fetch_cogames_whoami(api_server=server, token=token)
201
+ if session.subject_type == "anonymous":
202
+ console.print("[red]Token is invalid or expired.[/red] Run [cyan]softmax login[/cyan] to re-authenticate.")
203
+ raise typer.Exit(1)
183
204
  console.print("[green]Authenticated[/green]")
184
205
  console.print(f"user_email: {session.user_email}")
185
206
  console.print(f"subject_type: {session.subject_type}")
@@ -189,14 +210,16 @@ def status_cmd(
189
210
 
190
211
  @app.command(name="get-token")
191
212
  def get_token_cmd(
192
- login_server: str = typer.Option(
193
- DEFAULT_COGAMES_SERVER,
194
- "--login-server",
213
+ server: str | None = typer.Option(
214
+ None,
215
+ "--server",
216
+ "-s",
195
217
  metavar="URL",
196
- help="Authentication server URL",
218
+ help="Authentication server URL.",
197
219
  ),
198
220
  ) -> None:
199
221
  """Print the saved token to stdout (for scripting)."""
222
+ login_server = server or get_login_server()
200
223
  token = load_current_cogames_token(login_server=login_server)
201
224
  if not token:
202
225
  console.print("[red]No token found.[/red] Run [cyan]softmax login[/cyan] first.", style="bold")
@@ -207,14 +230,16 @@ def get_token_cmd(
207
230
  @app.command(name="set-token")
208
231
  def set_token_cmd(
209
232
  token: str = typer.Argument(help="Bearer token to save"),
210
- login_server: str = typer.Option(
211
- DEFAULT_COGAMES_SERVER,
212
- "--login-server",
233
+ server: str | None = typer.Option(
234
+ None,
235
+ "--server",
236
+ "-s",
213
237
  metavar="URL",
214
- help="Authentication server URL",
238
+ help="Authentication server URL.",
215
239
  ),
216
240
  ) -> None:
217
241
  """Manually set a token (for CI or headless environments)."""
242
+ login_server = server or get_login_server()
218
243
  save_cogames_user_token(login_server=login_server, token=token)
219
244
  print(f"\nToken saved for {login_server}")
220
245
 
@@ -3,7 +3,7 @@ from __future__ import annotations
3
3
  import importlib
4
4
  from typing import Any
5
5
 
6
- from softmax.auth import DEFAULT_COGAMES_API_SERVER, DEFAULT_COGAMES_SERVER
6
+ from softmax.auth import DEFAULT_COGAMES_API_SERVER
7
7
 
8
8
 
9
9
  def _get_tournament_client_class() -> type[Any]:
@@ -16,9 +16,9 @@ def _get_tournament_client_class() -> type[Any]:
16
16
  return tournament_client_module.TournamentServerClient
17
17
 
18
18
 
19
- def _create_client(*, token: str, server: str, login_server: str) -> Any:
19
+ def _create_client(*, token: str, server: str) -> Any:
20
20
  client_class = _get_tournament_client_class()
21
- return client_class(server_url=server, token=token, login_server=login_server)
21
+ return client_class(server_url=server, token=token)
22
22
 
23
23
 
24
24
  class _PlayerAPI:
@@ -27,9 +27,8 @@ class _PlayerAPI:
27
27
  token: str,
28
28
  *,
29
29
  server: str = DEFAULT_COGAMES_API_SERVER,
30
- login_server: str = DEFAULT_COGAMES_SERVER,
31
30
  ) -> list[Any]:
32
- with _create_client(token=token, server=server, login_server=login_server) as client:
31
+ with _create_client(token=token, server=server) as client:
33
32
  return client.list_players()
34
33
 
35
34
 
@@ -41,9 +40,8 @@ def login(
41
40
  player_id: str,
42
41
  *,
43
42
  server: str = DEFAULT_COGAMES_API_SERVER,
44
- login_server: str = DEFAULT_COGAMES_SERVER,
45
43
  ) -> str:
46
- with _create_client(token=token, server=server, login_server=login_server) as client:
44
+ with _create_client(token=token, server=server) as client:
47
45
  return client.login_player(player_id).token
48
46
 
49
47
 
@@ -52,9 +50,8 @@ def login_response(
52
50
  player_id: str,
53
51
  *,
54
52
  server: str = DEFAULT_COGAMES_API_SERVER,
55
- login_server: str = DEFAULT_COGAMES_SERVER,
56
53
  ) -> Any:
57
- with _create_client(token=token, server=server, login_server=login_server) as client:
54
+ with _create_client(token=token, server=server) as client:
58
55
  return client.login_player(player_id)
59
56
 
60
57
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: softmax-cli
3
- Version: 0.26.8
3
+ Version: 0.26.10
4
4
  Summary: Softmax CLI — authentication and account tools
5
5
  Classifier: Programming Language :: Python :: 3
6
6
  Classifier: Programming Language :: Python :: 3.11
@@ -146,12 +146,8 @@ def test_authenticate_reprompts_after_invalid_token(
146
146
  assert "Invalid token. Please try again." in capsys.readouterr().out
147
147
 
148
148
 
149
- def test_manual_command_includes_nondefault_login_server() -> None:
150
- assert _build_manual_set_token_command(login_server="https://softmax.com/api") == "softmax set-token '<TOKEN>'"
151
- assert (
152
- _build_manual_set_token_command(login_server="https://example.ngrok.app/api")
153
- == "softmax set-token '<TOKEN>' --login-server 'https://example.ngrok.app/api'"
154
- )
149
+ def test_manual_command_format() -> None:
150
+ assert _build_manual_set_token_command() == "softmax set-token '<TOKEN>'"
155
151
 
156
152
 
157
153
  def test_generic_authenticator_does_not_print_cogames_agent_hint(
@@ -294,3 +290,60 @@ def test_save_token_raises_for_malformed_storage_section(
294
290
  server="https://softmax.com/api",
295
291
  token="abc",
296
292
  )
293
+
294
+
295
+ def test_status_fails_for_anonymous_session(
296
+ monkeypatch: pytest.MonkeyPatch,
297
+ tmp_path,
298
+ ) -> None:
299
+ monkeypatch.setenv("HOME", str(tmp_path))
300
+ save_token(token_kind=TokenKind.COGAMES, server="https://softmax.com/api", token="bad-token")
301
+
302
+ class FakeResponse:
303
+ def raise_for_status(self) -> None:
304
+ return None
305
+
306
+ def json(self) -> dict[str, object]:
307
+ return {
308
+ "user_email": "unknown",
309
+ "is_softmax_team_member": False,
310
+ "is_softmax_admin": False,
311
+ "subject_type": "anonymous",
312
+ "subject_id": None,
313
+ "owner_user_id": None,
314
+ "scopes": [],
315
+ }
316
+
317
+ monkeypatch.setattr("softmax.auth.httpx.get", lambda *args, **kwargs: FakeResponse())
318
+
319
+ result = runner.invoke(app, ["status"])
320
+ assert result.exit_code == 1
321
+ assert "invalid or expired" in result.stdout
322
+
323
+
324
+ def test_login_detects_anonymous_whoami_as_invalid_token(
325
+ monkeypatch: pytest.MonkeyPatch,
326
+ tmp_path,
327
+ ) -> None:
328
+ monkeypatch.setenv("HOME", str(tmp_path))
329
+ save_token(token_kind=TokenKind.COGAMES_USER, server="https://softmax.com/api", token="stale-token")
330
+
331
+ class FakeResponse:
332
+ def raise_for_status(self) -> None:
333
+ return None
334
+
335
+ def json(self) -> dict[str, object]:
336
+ return {
337
+ "user_email": "unknown",
338
+ "is_softmax_team_member": False,
339
+ "is_softmax_admin": False,
340
+ "subject_type": "anonymous",
341
+ "subject_id": None,
342
+ "owner_user_id": None,
343
+ "scopes": [],
344
+ }
345
+
346
+ monkeypatch.setattr("softmax.auth.httpx.get", lambda *args, **kwargs: FakeResponse())
347
+
348
+ result = runner.invoke(app, ["login", "--no-browser"])
349
+ assert "no longer valid" in result.stdout
@@ -74,10 +74,9 @@ def test_softmax_cogames_player_list_uses_expected_defaults(monkeypatch: pytest.
74
74
  captured: dict[str, object] = {}
75
75
 
76
76
  class FakeClient:
77
- def __init__(self, *, server_url: str, token: str, login_server: str) -> None:
77
+ def __init__(self, *, server_url: str, token: str) -> None:
78
78
  captured["server_url"] = server_url
79
79
  captured["token"] = token
80
- captured["login_server"] = login_server
81
80
 
82
81
  def __enter__(self) -> "FakeClient":
83
82
  return self
@@ -94,7 +93,6 @@ def test_softmax_cogames_player_list_uses_expected_defaults(monkeypatch: pytest.
94
93
  assert captured == {
95
94
  "server_url": "https://api.observatory.softmax-research.net",
96
95
  "token": "softmax-token",
97
- "login_server": "https://softmax.com/api",
98
96
  }
99
97
 
100
98
 
@@ -103,10 +101,9 @@ def test_softmax_cogames_login_returns_player_token(monkeypatch: pytest.MonkeyPa
103
101
  token = "player-token"
104
102
 
105
103
  class FakeClient:
106
- def __init__(self, *, server_url: str, token: str, login_server: str) -> None:
104
+ def __init__(self, *, server_url: str, token: str) -> None:
107
105
  self.server_url = server_url
108
106
  self.token = token
109
- self.login_server = login_server
110
107
 
111
108
  def __enter__(self) -> "FakeClient":
112
109
  return self
@@ -117,7 +114,6 @@ def test_softmax_cogames_login_returns_player_token(monkeypatch: pytest.MonkeyPa
117
114
  def login_player(self, player_id: str) -> FakeLoginResponse:
118
115
  assert self.server_url == "https://api.observatory.softmax-research.net"
119
116
  assert self.token == "softmax-token"
120
- assert self.login_server == "https://softmax.com/api"
121
117
  assert player_id == "ply_alpha"
122
118
  return FakeLoginResponse()
123
119
 
@@ -132,7 +128,7 @@ def test_softmax_cogames_login_response_returns_full_response(monkeypatch: pytes
132
128
  expires_at = "2026-02-21T12:00:00Z"
133
129
 
134
130
  class FakeClient:
135
- def __init__(self, *, server_url: str, token: str, login_server: str) -> None:
131
+ def __init__(self, *, server_url: str, token: str) -> None:
136
132
  pass
137
133
 
138
134
  def __enter__(self) -> "FakeClient":
File without changes
File without changes