softmax-cli 0.23.0__tar.gz → 0.25.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: softmax-cli
3
- Version: 0.23.0
3
+ Version: 0.25.0
4
4
  Summary: Softmax CLI — authentication and account tools
5
5
  Classifier: Programming Language :: Python :: 3
6
6
  Classifier: Programming Language :: Python :: 3.12
@@ -12,3 +12,5 @@ Requires-Dist: fastapi>=0.115.0
12
12
  Requires-Dist: uvicorn>=0.34.0
13
13
  Requires-Dist: httpx>=0.28.1
14
14
  Requires-Dist: pyyaml>=6.0.2
15
+ Provides-Extra: cogames
16
+ Requires-Dist: cogames; extra == "cogames"
@@ -18,6 +18,9 @@ dependencies = [
18
18
  ]
19
19
  dynamic = ["version"]
20
20
 
21
+ [project.optional-dependencies]
22
+ cogames = ["cogames"]
23
+
21
24
  [project.scripts]
22
25
  softmax = "softmax.cli:app"
23
26
 
@@ -0,0 +1,61 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib
4
+ import sys
5
+ from pkgutil import extend_path
6
+
7
+ from softmax.auth import (
8
+ DEFAULT_COGAMES_SERVER,
9
+ load_cogames_user_token,
10
+ save_cogames_active_token,
11
+ )
12
+ from softmax.perform_login import do_interactive_login_for_token
13
+ from softmax.token_storage import TokenKind
14
+
15
+ __path__ = extend_path(__path__, __name__)
16
+
17
+
18
+ def login(
19
+ *,
20
+ login_server: str = DEFAULT_COGAMES_SERVER,
21
+ force: bool = False,
22
+ open_browser: bool = True,
23
+ ) -> str:
24
+ token = None if force else load_cogames_user_token(login_server=login_server)
25
+ if token is not None:
26
+ save_cogames_active_token(login_server=login_server, token=token)
27
+ return token
28
+
29
+ if not sys.stdin.isatty():
30
+ raise RuntimeError(
31
+ "No saved Softmax token found and interactive login requires a TTY. "
32
+ "Run `softmax login` or `softmax set-token` first."
33
+ )
34
+
35
+ do_interactive_login_for_token(
36
+ login_server=login_server,
37
+ server_to_save_token_under=login_server,
38
+ token_kind=TokenKind.COGAMES_USER,
39
+ agent_hint=(
40
+ "If you are a coding agent, ask your human to open the URL below and give you "
41
+ "the auth token. Then paste the token into this window or run:\n"
42
+ "\n"
43
+ "softmax set-token '<TOKEN>'"
44
+ ),
45
+ open_browser=open_browser,
46
+ )
47
+
48
+ token = load_cogames_user_token(login_server=login_server)
49
+ if token is None:
50
+ raise RuntimeError(f"Interactive login did not save a token for {login_server}")
51
+ save_cogames_active_token(login_server=login_server, token=token)
52
+ return token
53
+
54
+
55
+ def __getattr__(name: str) -> object:
56
+ if name == "cogames":
57
+ return importlib.import_module("softmax.cogames")
58
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
59
+
60
+
61
+ __all__ = ["login", "cogames"]
@@ -0,0 +1,98 @@
1
+ """Token storage and browser URL helpers for CLI auth."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from urllib.parse import urlencode, urlsplit, urlunsplit
6
+
7
+ import httpx
8
+ from pydantic import BaseModel, Field
9
+
10
+ from softmax.token_storage import TokenKind
11
+ from softmax.token_storage import delete_token as delete_stored_token
12
+ from softmax.token_storage import load_token as load_saved_token
13
+ from softmax.token_storage import save_token as save_stored_token
14
+
15
+ DEFAULT_COGAMES_SERVER = "https://softmax.com/api"
16
+ DEFAULT_COGAMES_API_SERVER = "https://api.observatory.softmax-research.net"
17
+
18
+
19
+ class WhoAmIResponse(BaseModel):
20
+ user_email: str
21
+ is_softmax_team_member: bool = False
22
+ is_softmax_admin: bool = False
23
+ subject_type: str = "user"
24
+ subject_id: str | None = None
25
+ owner_user_id: str | None = None
26
+ scopes: list[str] = Field(default_factory=list)
27
+
28
+
29
+ def build_browser_login_url(login_server: str, *, callback_url: str | None = None) -> str:
30
+ """Build the hosted browser sign-in URL for CLI login."""
31
+ params: dict[str, str] = {}
32
+ if callback_url:
33
+ params["callback"] = callback_url
34
+
35
+ query = urlencode(params)
36
+ parsed = urlsplit(login_server)
37
+ browser_path = parsed.path.rstrip("/").removesuffix("/api") + "/cli-login"
38
+ return urlunsplit((parsed.scheme, parsed.netloc, browser_path, query, ""))
39
+
40
+
41
+ def load_token(*, token_kind: TokenKind, server: str) -> str | None:
42
+ return load_saved_token(token_kind=token_kind, server=server)
43
+
44
+
45
+ def has_saved_token(*, token_kind: TokenKind, server: str) -> bool:
46
+ return load_token(token_kind=token_kind, server=server) is not None
47
+
48
+
49
+ def save_token(*, token_kind: TokenKind, server: str, token: str) -> None:
50
+ save_stored_token(token_kind=token_kind, server=server, token=token)
51
+
52
+
53
+ def delete_token(*, token_kind: TokenKind, server: str) -> bool:
54
+ return delete_stored_token(token_kind=token_kind, server=server)
55
+
56
+
57
+ def load_cogames_user_token(*, login_server: str) -> str | None:
58
+ return load_token(token_kind=TokenKind.COGAMES_USER, server=login_server)
59
+
60
+
61
+ def load_current_cogames_token(*, login_server: str) -> str | None:
62
+ return load_token(token_kind=TokenKind.COGAMES, server=login_server) or load_cogames_user_token(
63
+ login_server=login_server
64
+ )
65
+
66
+
67
+ def save_cogames_active_token(*, login_server: str, token: str) -> None:
68
+ save_token(token_kind=TokenKind.COGAMES, server=login_server, token=token)
69
+
70
+
71
+ def save_cogames_user_token(*, login_server: str, token: str) -> None:
72
+ save_token(token_kind=TokenKind.COGAMES_USER, server=login_server, token=token)
73
+ save_cogames_active_token(login_server=login_server, token=token)
74
+
75
+
76
+ def delete_cogames_tokens(*, login_server: str) -> bool:
77
+ deleted_active = delete_token(token_kind=TokenKind.COGAMES, server=login_server)
78
+ deleted_user = delete_token(token_kind=TokenKind.COGAMES_USER, server=login_server)
79
+ return deleted_active or deleted_user
80
+
81
+
82
+ def fetch_cogames_whoami(*, api_server: str | None = None, token: str) -> WhoAmIResponse:
83
+ server = api_server or DEFAULT_COGAMES_API_SERVER
84
+ response = httpx.get(
85
+ f"{server.rstrip('/')}/whoami",
86
+ headers={"Authorization": f"Bearer {token}"},
87
+ timeout=10.0,
88
+ )
89
+ response.raise_for_status()
90
+ return WhoAmIResponse.model_validate(response.json())
91
+
92
+
93
+ def restore_cogames_user_session(*, login_server: str) -> str | None:
94
+ user_token = load_cogames_user_token(login_server=login_server)
95
+ if user_token is None:
96
+ return None
97
+ save_cogames_active_token(login_server=login_server, token=user_token)
98
+ return user_token
@@ -1,8 +1,9 @@
1
1
  """softmax CLI — authentication and account tools."""
2
2
 
3
+ import importlib
4
+ import importlib.util
3
5
  import sys
4
6
 
5
- import httpx
6
7
  import typer
7
8
  from rich.panel import Panel
8
9
 
@@ -10,10 +11,12 @@ from softmax._console import console
10
11
  from softmax.auth import (
11
12
  DEFAULT_COGAMES_SERVER,
12
13
  build_browser_login_url,
13
- delete_token,
14
- has_saved_token,
15
- load_token,
16
- save_token,
14
+ delete_cogames_tokens,
15
+ fetch_cogames_whoami,
16
+ load_cogames_user_token,
17
+ load_current_cogames_token,
18
+ save_cogames_active_token,
19
+ save_cogames_user_token,
17
20
  )
18
21
  from softmax.perform_login import do_interactive_login_for_token
19
22
  from softmax.token_storage import TokenKind
@@ -26,6 +29,14 @@ app = typer.Typer(
26
29
  )
27
30
 
28
31
 
32
+ def _register_optional_apps() -> None:
33
+ if importlib.util.find_spec("cogames.softmax_cli") is None:
34
+ return
35
+
36
+ cogames_cli = importlib.import_module("cogames.softmax_cli")
37
+ app.add_typer(cogames_cli.app, name="cogames", rich_help_panel="Local Games")
38
+
39
+
29
40
  def _build_manual_set_token_command(*, login_server: str) -> str:
30
41
  command = "softmax set-token '<TOKEN>'"
31
42
  if login_server != DEFAULT_COGAMES_SERVER:
@@ -78,7 +89,9 @@ def login_cmd(
78
89
  """Sign in to Softmax."""
79
90
  from urllib.parse import urlparse # noqa: PLC0415
80
91
 
81
- if has_saved_token(token_kind=TokenKind.COGAMES, server=login_server) and not force:
92
+ user_token = None if force else load_cogames_user_token(login_server=login_server)
93
+ if user_token is not None:
94
+ save_cogames_active_token(login_server=login_server, token=user_token)
82
95
  console.print(f"Already authenticated with {urlparse(login_server).hostname}", style="green")
83
96
  return
84
97
 
@@ -90,7 +103,7 @@ def login_cmd(
90
103
  do_interactive_login_for_token(
91
104
  login_server=login_server,
92
105
  server_to_save_token_under=login_server,
93
- token_kind=TokenKind.COGAMES,
106
+ token_kind=TokenKind.COGAMES_USER,
94
107
  agent_hint=(
95
108
  "If you are a coding agent, ask your human to open the URL below and give you "
96
109
  "the auth token. Then paste the token into this window or run:\n"
@@ -105,6 +118,9 @@ def login_cmd(
105
118
  console.print("Authentication failed.", style="red")
106
119
  raise typer.Exit(1) from e
107
120
 
121
+ user_token = load_cogames_user_token(login_server=login_server)
122
+ assert user_token is not None
123
+ save_cogames_active_token(login_server=login_server, token=user_token)
108
124
  console.print("Authentication successful.", style="green")
109
125
 
110
126
 
@@ -118,7 +134,7 @@ def logout_cmd(
118
134
  ),
119
135
  ) -> None:
120
136
  """Remove saved authentication token."""
121
- if delete_token(token_kind=TokenKind.COGAMES, server=login_server):
137
+ if delete_cogames_tokens(login_server=login_server):
122
138
  console.print("Logged out.", style="green")
123
139
  else:
124
140
  console.print("No token found — already logged out.", style="yellow")
@@ -145,21 +161,28 @@ def status_cmd(
145
161
  metavar="URL",
146
162
  help="Authentication server URL",
147
163
  ),
164
+ server: str | None = typer.Option(
165
+ None,
166
+ "--server",
167
+ "-s",
168
+ metavar="URL",
169
+ help="API server URL for /whoami verification. Defaults to --login-server when"
170
+ " that is overridden, otherwise the production Observatory API.",
171
+ ),
148
172
  ) -> None:
149
173
  """Check authentication status via /whoami."""
150
- token = load_token(token_kind=TokenKind.COGAMES, server=login_server)
174
+ token = load_current_cogames_token(login_server=login_server)
151
175
  if not token:
152
176
  console.print("[red]Not authenticated.[/red] Run [cyan]softmax login[/cyan] first.")
153
177
  raise typer.Exit(1)
154
178
 
155
- response = httpx.get(
156
- f"{login_server.rstrip('/')}/whoami",
157
- headers={"Authorization": f"Bearer {token}"},
158
- timeout=10.0,
159
- )
160
- response.raise_for_status()
161
- email = response.json().get("user_email", "unknown")
162
- console.print(f"[green]Authenticated as {email}[/green]")
179
+ api_server = server or (login_server if login_server != DEFAULT_COGAMES_SERVER else None)
180
+ session = fetch_cogames_whoami(api_server=api_server, token=token)
181
+ console.print("[green]Authenticated[/green]")
182
+ console.print(f"user_email: {session.user_email}")
183
+ console.print(f"subject_type: {session.subject_type}")
184
+ console.print(f"subject_id: {session.subject_id or '-'}")
185
+ console.print(f"owner_user_id: {session.owner_user_id or '-'}")
163
186
 
164
187
 
165
188
  @app.command(name="get-token")
@@ -172,7 +195,7 @@ def get_token_cmd(
172
195
  ),
173
196
  ) -> None:
174
197
  """Print the saved token to stdout (for scripting)."""
175
- token = load_token(token_kind=TokenKind.COGAMES, server=login_server)
198
+ token = load_current_cogames_token(login_server=login_server)
176
199
  if not token:
177
200
  console.print("[red]No token found.[/red] Run [cyan]softmax login[/cyan] first.", style="bold")
178
201
  raise typer.Exit(1)
@@ -190,5 +213,8 @@ def set_token_cmd(
190
213
  ),
191
214
  ) -> None:
192
215
  """Manually set a token (for CI or headless environments)."""
193
- save_token(token_kind=TokenKind.COGAMES, token=token, server=login_server)
216
+ save_cogames_user_token(login_server=login_server, token=token)
194
217
  print(f"\nToken saved for {login_server}")
218
+
219
+
220
+ _register_optional_apps()
@@ -0,0 +1,61 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib
4
+ from typing import Any
5
+
6
+ from softmax.auth import DEFAULT_COGAMES_API_SERVER, DEFAULT_COGAMES_SERVER
7
+
8
+
9
+ def _get_tournament_client_class() -> type[Any]:
10
+ try:
11
+ tournament_client_module = importlib.import_module("cogames.cli.client")
12
+ except ModuleNotFoundError as exc:
13
+ raise RuntimeError(
14
+ "softmax.cogames requires the `cogames` package. Install `softmax-cli[cogames]` or `cogames`."
15
+ ) from exc
16
+ return tournament_client_module.TournamentServerClient
17
+
18
+
19
+ def _create_client(*, token: str, server: str, login_server: str) -> Any:
20
+ client_class = _get_tournament_client_class()
21
+ return client_class(server_url=server, token=token, login_server=login_server)
22
+
23
+
24
+ class _PlayerAPI:
25
+ def list(
26
+ self,
27
+ token: str,
28
+ *,
29
+ server: str = DEFAULT_COGAMES_API_SERVER,
30
+ login_server: str = DEFAULT_COGAMES_SERVER,
31
+ ) -> list[Any]:
32
+ with _create_client(token=token, server=server, login_server=login_server) as client:
33
+ return client.list_players()
34
+
35
+
36
+ player = _PlayerAPI()
37
+
38
+
39
+ def login(
40
+ token: str,
41
+ player_id: str,
42
+ *,
43
+ server: str = DEFAULT_COGAMES_API_SERVER,
44
+ login_server: str = DEFAULT_COGAMES_SERVER,
45
+ ) -> str:
46
+ with _create_client(token=token, server=server, login_server=login_server) as client:
47
+ return client.login_player(player_id).token
48
+
49
+
50
+ def login_response(
51
+ token: str,
52
+ player_id: str,
53
+ *,
54
+ server: str = DEFAULT_COGAMES_API_SERVER,
55
+ login_server: str = DEFAULT_COGAMES_SERVER,
56
+ ) -> Any:
57
+ with _create_client(token=token, server=server, login_server=login_server) as client:
58
+ return client.login_player(player_id)
59
+
60
+
61
+ __all__ = ["player", "login", "login_response"]
@@ -7,11 +7,12 @@ import yaml
7
7
 
8
8
  class TokenKind(StrEnum):
9
9
  COGAMES = "cogames"
10
+ COGAMES_USER = "cogames_user"
10
11
  OBSERVATORY = "observatory"
11
12
 
12
13
 
13
14
  def _token_file_name(*, token_kind: TokenKind) -> str:
14
- if token_kind == TokenKind.COGAMES:
15
+ if token_kind in {TokenKind.COGAMES, TokenKind.COGAMES_USER}:
15
16
  return "cogames.yaml"
16
17
  if token_kind == TokenKind.OBSERVATORY:
17
18
  return "config.yaml"
@@ -21,6 +22,8 @@ def _token_file_name(*, token_kind: TokenKind) -> str:
21
22
  def _token_storage_key(*, token_kind: TokenKind) -> str | None:
22
23
  if token_kind == TokenKind.COGAMES:
23
24
  return "login_tokens"
25
+ if token_kind == TokenKind.COGAMES_USER:
26
+ return "user_tokens"
24
27
  if token_kind == TokenKind.OBSERVATORY:
25
28
  return "observatory_tokens"
26
29
  raise AssertionError(f"Unhandled token kind: {token_kind}")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: softmax-cli
3
- Version: 0.23.0
3
+ Version: 0.25.0
4
4
  Summary: Softmax CLI — authentication and account tools
5
5
  Classifier: Programming Language :: Python :: 3
6
6
  Classifier: Programming Language :: Python :: 3.12
@@ -12,3 +12,5 @@ Requires-Dist: fastapi>=0.115.0
12
12
  Requires-Dist: uvicorn>=0.34.0
13
13
  Requires-Dist: httpx>=0.28.1
14
14
  Requires-Dist: pyyaml>=6.0.2
15
+ Provides-Extra: cogames
16
+ Requires-Dist: cogames; extra == "cogames"
@@ -3,6 +3,7 @@ src/softmax/__init__.py
3
3
  src/softmax/_console.py
4
4
  src/softmax/auth.py
5
5
  src/softmax/cli.py
6
+ src/softmax/cogames.py
6
7
  src/softmax/perform_login.py
7
8
  src/softmax/token_storage.py
8
9
  src/softmax_cli.egg-info/PKG-INFO
@@ -11,4 +12,6 @@ src/softmax_cli.egg-info/dependency_links.txt
11
12
  src/softmax_cli.egg-info/entry_points.txt
12
13
  src/softmax_cli.egg-info/requires.txt
13
14
  src/softmax_cli.egg-info/top_level.txt
14
- tests/test_auth_login.py
15
+ tests/test_auth_login.py
16
+ tests/test_cli_plugins.py
17
+ tests/test_python_api.py
@@ -4,3 +4,6 @@ fastapi>=0.115.0
4
4
  uvicorn>=0.34.0
5
5
  httpx>=0.28.1
6
6
  pyyaml>=6.0.2
7
+
8
+ [cogames]
9
+ cogames
@@ -4,10 +4,11 @@ import builtins
4
4
  import sys
5
5
 
6
6
  import pytest
7
+ from typer.testing import CliRunner
7
8
 
8
9
  import softmax.perform_login as auth_module
9
10
  from softmax.auth import build_browser_login_url, load_token
10
- from softmax.cli import _build_manual_set_token_command
11
+ from softmax.cli import _build_manual_set_token_command, app
11
12
  from softmax.perform_login import do_interactive_login_for_token
12
13
  from softmax.token_storage import TokenKind, save_token
13
14
 
@@ -15,6 +16,7 @@ COGAMES_AGENT_HINT = (
15
16
  "🤖 If you are a coding agent, ask your human to open the URL above and give you the resulting auth token. "
16
17
  "You can paste the token into this window or run: softmax set-token '<TOKEN>'"
17
18
  )
19
+ runner = CliRunner()
18
20
 
19
21
 
20
22
  def test_authenticate_accepts_pasted_token(
@@ -218,6 +220,37 @@ def test_build_browser_login_url_uses_cli_login_path() -> None:
218
220
  )
219
221
 
220
222
 
223
+ def test_status_prints_active_subject_details(
224
+ monkeypatch: pytest.MonkeyPatch,
225
+ tmp_path,
226
+ ) -> None:
227
+ monkeypatch.setenv("HOME", str(tmp_path))
228
+ save_token(token_kind=TokenKind.COGAMES, server="https://softmax.com/api", token="player-session-token")
229
+
230
+ class FakeResponse:
231
+ def raise_for_status(self) -> None:
232
+ return None
233
+
234
+ def json(self) -> dict[str, object]:
235
+ return {
236
+ "user_email": "regular@example.com",
237
+ "is_softmax_team_member": False,
238
+ "is_softmax_admin": False,
239
+ "subject_type": "player",
240
+ "subject_id": "ply_alpha",
241
+ "owner_user_id": "regular@example.com",
242
+ "scopes": [],
243
+ }
244
+
245
+ monkeypatch.setattr("softmax.auth.httpx.get", lambda *args, **kwargs: FakeResponse())
246
+
247
+ result = runner.invoke(app, ["status"])
248
+ assert result.exit_code == 0
249
+ assert "subject_type: player" in result.stdout
250
+ assert "subject_id: ply_alpha" in result.stdout
251
+ assert "owner_user_id: regular@example.com" in result.stdout
252
+
253
+
221
254
  def test_interactive_login_requires_tty(
222
255
  monkeypatch: pytest.MonkeyPatch,
223
256
  ) -> None:
@@ -0,0 +1,24 @@
1
+ from typer.testing import CliRunner
2
+
3
+ from softmax.cli import app
4
+
5
+ runner = CliRunner()
6
+
7
+
8
+ def test_softmax_help_lists_cogames_command() -> None:
9
+ result = runner.invoke(app, ["--help"])
10
+
11
+ assert result.exit_code == 0
12
+ assert "cogames" in result.stdout
13
+
14
+
15
+ def test_softmax_cogames_help_lists_local_commands() -> None:
16
+ result = runner.invoke(app, ["cogames", "--help"])
17
+
18
+ assert result.exit_code == 0
19
+ assert "play" in result.stdout
20
+ assert "train" in result.stdout
21
+ assert "eval" in result.stdout
22
+ assert "bundle" in result.stdout
23
+ assert "player" in result.stdout
24
+ assert "tutorial" in result.stdout
@@ -0,0 +1,185 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib
4
+ import sys
5
+ from typing import Any, cast
6
+
7
+ import pytest
8
+
9
+ import softmax
10
+ import softmax.cogames as softmax_cogames
11
+ from softmax.auth import load_token
12
+ from softmax.token_storage import TokenKind, save_token
13
+
14
+
15
+ def test_login_returns_saved_token(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None:
16
+ monkeypatch.setenv("HOME", str(tmp_path))
17
+ save_token(token_kind=TokenKind.COGAMES_USER, server="https://softmax.com/api", token="saved-token")
18
+
19
+ called = {"interactive": False}
20
+ monkeypatch.setattr(
21
+ "softmax.do_interactive_login_for_token",
22
+ lambda **_: called.__setitem__("interactive", True),
23
+ )
24
+
25
+ assert softmax.login() == "saved-token"
26
+ assert load_token(token_kind=TokenKind.COGAMES, server="https://softmax.com/api") == "saved-token"
27
+ assert called["interactive"] is False
28
+
29
+
30
+ def test_login_runs_interactive_flow_when_missing_token(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None:
31
+ monkeypatch.setenv("HOME", str(tmp_path))
32
+ monkeypatch.setattr(sys.stdin, "isatty", lambda: True)
33
+
34
+ def fake_login(**_: object) -> None:
35
+ save_token(token_kind=TokenKind.COGAMES_USER, server="https://softmax.com/api", token="fresh-token")
36
+
37
+ monkeypatch.setattr("softmax.do_interactive_login_for_token", fake_login)
38
+
39
+ assert softmax.login() == "fresh-token"
40
+ assert load_token(token_kind=TokenKind.COGAMES, server="https://softmax.com/api") == "fresh-token"
41
+
42
+
43
+ def test_login_ignores_active_only_token_without_saved_user_session(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None:
44
+ monkeypatch.setenv("HOME", str(tmp_path))
45
+ monkeypatch.setattr(sys.stdin, "isatty", lambda: True)
46
+ save_token(token_kind=TokenKind.COGAMES, server="https://softmax.com/api", token="active-only-token")
47
+
48
+ called = {"interactive": False}
49
+
50
+ def fake_login(**_: object) -> None:
51
+ called["interactive"] = True
52
+ save_token(token_kind=TokenKind.COGAMES_USER, server="https://softmax.com/api", token="fresh-token")
53
+
54
+ monkeypatch.setattr("softmax.do_interactive_login_for_token", fake_login)
55
+
56
+ assert softmax.login() == "fresh-token"
57
+ assert load_token(token_kind=TokenKind.COGAMES, server="https://softmax.com/api") == "fresh-token"
58
+ assert called["interactive"] is True
59
+
60
+
61
+ def test_login_requires_tty_when_missing_token(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None:
62
+ monkeypatch.setenv("HOME", str(tmp_path))
63
+ monkeypatch.setattr(sys.stdin, "isatty", lambda: False)
64
+
65
+ with pytest.raises(RuntimeError, match="interactive login requires a TTY"):
66
+ softmax.login()
67
+
68
+
69
+ def test_softmax_module_exposes_cogames_submodule() -> None:
70
+ assert cast(Any, softmax).cogames is importlib.import_module("softmax.cogames")
71
+
72
+
73
+ def test_softmax_cogames_player_list_uses_expected_defaults(monkeypatch: pytest.MonkeyPatch) -> None:
74
+ captured: dict[str, object] = {}
75
+
76
+ class FakeClient:
77
+ def __init__(self, *, server_url: str, token: str, login_server: str) -> None:
78
+ captured["server_url"] = server_url
79
+ captured["token"] = token
80
+ captured["login_server"] = login_server
81
+
82
+ def __enter__(self) -> "FakeClient":
83
+ return self
84
+
85
+ def __exit__(self, exc_type, exc, tb) -> None:
86
+ return None
87
+
88
+ def list_players(self) -> list[str]:
89
+ return ["alpha", "beta"]
90
+
91
+ monkeypatch.setattr(softmax_cogames, "_get_tournament_client_class", lambda: FakeClient)
92
+
93
+ assert cast(Any, softmax).cogames.player.list("softmax-token") == ["alpha", "beta"]
94
+ assert captured == {
95
+ "server_url": "https://api.observatory.softmax-research.net",
96
+ "token": "softmax-token",
97
+ "login_server": "https://softmax.com/api",
98
+ }
99
+
100
+
101
+ def test_softmax_cogames_login_returns_player_token(monkeypatch: pytest.MonkeyPatch) -> None:
102
+ class FakeLoginResponse:
103
+ token = "player-token"
104
+
105
+ class FakeClient:
106
+ def __init__(self, *, server_url: str, token: str, login_server: str) -> None:
107
+ self.server_url = server_url
108
+ self.token = token
109
+ self.login_server = login_server
110
+
111
+ def __enter__(self) -> "FakeClient":
112
+ return self
113
+
114
+ def __exit__(self, exc_type, exc, tb) -> None:
115
+ return None
116
+
117
+ def login_player(self, player_id: str) -> FakeLoginResponse:
118
+ assert self.server_url == "https://api.observatory.softmax-research.net"
119
+ assert self.token == "softmax-token"
120
+ assert self.login_server == "https://softmax.com/api"
121
+ assert player_id == "ply_alpha"
122
+ return FakeLoginResponse()
123
+
124
+ monkeypatch.setattr(softmax_cogames, "_get_tournament_client_class", lambda: FakeClient)
125
+
126
+ assert cast(Any, softmax).cogames.login("softmax-token", "ply_alpha") == "player-token"
127
+
128
+
129
+ def test_softmax_cogames_login_response_returns_full_response(monkeypatch: pytest.MonkeyPatch) -> None:
130
+ class FakeLoginResponse:
131
+ token = "player-token"
132
+ expires_at = "2026-02-21T12:00:00Z"
133
+
134
+ class FakeClient:
135
+ def __init__(self, *, server_url: str, token: str, login_server: str) -> None:
136
+ pass
137
+
138
+ def __enter__(self) -> "FakeClient":
139
+ return self
140
+
141
+ def __exit__(self, exc_type, exc, tb) -> None:
142
+ return None
143
+
144
+ def login_player(self, player_id: str) -> FakeLoginResponse:
145
+ assert player_id == "ply_alpha"
146
+ return FakeLoginResponse()
147
+
148
+ monkeypatch.setattr(softmax_cogames, "_get_tournament_client_class", lambda: FakeClient)
149
+
150
+ response = cast(Any, softmax).cogames.login_response("softmax-token", "ply_alpha")
151
+ assert response.token == "player-token"
152
+ assert response.expires_at == "2026-02-21T12:00:00Z"
153
+
154
+
155
+ def test_login_can_force_refresh_existing_token(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None:
156
+ monkeypatch.setenv("HOME", str(tmp_path))
157
+ save_token(token_kind=TokenKind.COGAMES_USER, server="https://softmax.com/api", token="old-token")
158
+ monkeypatch.setattr(sys.stdin, "isatty", lambda: True)
159
+
160
+ def fake_login(**_: object) -> None:
161
+ save_token(token_kind=TokenKind.COGAMES_USER, server="https://softmax.com/api", token="new-token")
162
+
163
+ monkeypatch.setattr("softmax.do_interactive_login_for_token", fake_login)
164
+
165
+ assert softmax.login(force=True) == "new-token"
166
+ assert load_token(token_kind=TokenKind.COGAMES, server="https://softmax.com/api") == "new-token"
167
+
168
+
169
+ def test_login_restores_saved_user_session_over_active_player_session(
170
+ monkeypatch: pytest.MonkeyPatch,
171
+ tmp_path,
172
+ ) -> None:
173
+ monkeypatch.setenv("HOME", str(tmp_path))
174
+ save_token(token_kind=TokenKind.COGAMES, server="https://softmax.com/api", token="player-token")
175
+ save_token(token_kind=TokenKind.COGAMES_USER, server="https://softmax.com/api", token="user-token")
176
+
177
+ called = {"interactive": False}
178
+ monkeypatch.setattr(
179
+ "softmax.do_interactive_login_for_token",
180
+ lambda **_: called.__setitem__("interactive", True),
181
+ )
182
+
183
+ assert softmax.login() == "user-token"
184
+ assert load_token(token_kind=TokenKind.COGAMES, server="https://softmax.com/api") == "user-token"
185
+ assert called["interactive"] is False
File without changes
@@ -1,40 +0,0 @@
1
- """Token storage and browser URL helpers for CLI auth."""
2
-
3
- from __future__ import annotations
4
-
5
- from urllib.parse import urlencode, urlsplit, urlunsplit
6
-
7
- from softmax.token_storage import TokenKind
8
- from softmax.token_storage import delete_token as delete_stored_token
9
- from softmax.token_storage import load_token as load_saved_token
10
- from softmax.token_storage import save_token as save_stored_token
11
-
12
- DEFAULT_COGAMES_SERVER = "https://softmax.com/api"
13
-
14
-
15
- def build_browser_login_url(login_server: str, *, callback_url: str | None = None) -> str:
16
- """Build the hosted browser sign-in URL for CLI login."""
17
- params: dict[str, str] = {}
18
- if callback_url:
19
- params["callback"] = callback_url
20
-
21
- query = urlencode(params)
22
- parsed = urlsplit(login_server)
23
- browser_path = parsed.path.rstrip("/").removesuffix("/api") + "/cli-login"
24
- return urlunsplit((parsed.scheme, parsed.netloc, browser_path, query, ""))
25
-
26
-
27
- def load_token(*, token_kind: TokenKind, server: str) -> str | None:
28
- return load_saved_token(token_kind=token_kind, server=server)
29
-
30
-
31
- def has_saved_token(*, token_kind: TokenKind, server: str) -> bool:
32
- return load_token(token_kind=token_kind, server=server) is not None
33
-
34
-
35
- def save_token(*, token_kind: TokenKind, server: str, token: str) -> None:
36
- save_stored_token(token_kind=token_kind, server=server, token=token)
37
-
38
-
39
- def delete_token(*, token_kind: TokenKind, server: str) -> bool:
40
- return delete_stored_token(token_kind=token_kind, server=server)
File without changes