softmax-cli 0.26.27__tar.gz → 0.26.30__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 (23) hide show
  1. {softmax_cli-0.26.27/src/softmax_cli.egg-info → softmax_cli-0.26.30}/PKG-INFO +1 -1
  2. {softmax_cli-0.26.27 → softmax_cli-0.26.30}/src/softmax/auth.py +34 -43
  3. {softmax_cli-0.26.27 → softmax_cli-0.26.30/src/softmax_cli.egg-info}/PKG-INFO +1 -1
  4. {softmax_cli-0.26.27 → softmax_cli-0.26.30}/tests/test_python_api.py +62 -0
  5. {softmax_cli-0.26.27 → softmax_cli-0.26.30}/AGENTS.md +0 -0
  6. {softmax_cli-0.26.27 → softmax_cli-0.26.30}/BUILD.bazel +0 -0
  7. {softmax_cli-0.26.27 → softmax_cli-0.26.30}/CLAUDE.md +0 -0
  8. {softmax_cli-0.26.27 → softmax_cli-0.26.30}/README.md +0 -0
  9. {softmax_cli-0.26.27 → softmax_cli-0.26.30}/pyproject.toml +0 -0
  10. {softmax_cli-0.26.27 → softmax_cli-0.26.30}/setup.cfg +0 -0
  11. {softmax_cli-0.26.27 → softmax_cli-0.26.30}/src/softmax/__init__.py +0 -0
  12. {softmax_cli-0.26.27 → softmax_cli-0.26.30}/src/softmax/_console.py +0 -0
  13. {softmax_cli-0.26.27 → softmax_cli-0.26.30}/src/softmax/cli.py +0 -0
  14. {softmax_cli-0.26.27 → softmax_cli-0.26.30}/src/softmax/perform_login.py +0 -0
  15. {softmax_cli-0.26.27 → softmax_cli-0.26.30}/src/softmax/players.py +0 -0
  16. {softmax_cli-0.26.27 → softmax_cli-0.26.30}/src/softmax_cli.egg-info/SOURCES.txt +0 -0
  17. {softmax_cli-0.26.27 → softmax_cli-0.26.30}/src/softmax_cli.egg-info/dependency_links.txt +0 -0
  18. {softmax_cli-0.26.27 → softmax_cli-0.26.30}/src/softmax_cli.egg-info/entry_points.txt +0 -0
  19. {softmax_cli-0.26.27 → softmax_cli-0.26.30}/src/softmax_cli.egg-info/requires.txt +0 -0
  20. {softmax_cli-0.26.27 → softmax_cli-0.26.30}/src/softmax_cli.egg-info/top_level.txt +0 -0
  21. {softmax_cli-0.26.27 → softmax_cli-0.26.30}/tests/BUILD.bazel +0 -0
  22. {softmax_cli-0.26.27 → softmax_cli-0.26.30}/tests/test_auth_login.py +0 -0
  23. {softmax_cli-0.26.27 → softmax_cli-0.26.30}/tests/test_player_cli.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: softmax-cli
3
- Version: 0.26.27
3
+ Version: 0.26.30
4
4
  Summary: Softmax CLI — authentication and account tools
5
5
  Classifier: Programming Language :: Python :: 3
6
6
  Classifier: Programming Language :: Python :: 3.11
@@ -9,7 +9,7 @@ from urllib.parse import urlencode, urlsplit, urlunsplit
9
9
 
10
10
  import httpx
11
11
  import yaml
12
- from pydantic import BaseModel, Field
12
+ from pydantic import BaseModel, ConfigDict, Field
13
13
 
14
14
  DEFAULT_API_SERVER = "https://softmax.com/api"
15
15
 
@@ -44,6 +44,23 @@ def _is_trusted_exchange_host(api_server: str) -> bool:
44
44
  # --- on-disk storage ---
45
45
 
46
46
 
47
+ class CachedPlayerSession(BaseModel):
48
+ token: str
49
+ expires_at: datetime
50
+
51
+
52
+ class ServerPlayerSessions(BaseModel):
53
+ active: str | None = None
54
+ cache: dict[str, CachedPlayerSession] = Field(default_factory=dict)
55
+
56
+
57
+ class Credentials(BaseModel):
58
+ model_config = ConfigDict(extra="allow")
59
+
60
+ tokens: dict[str, str] = Field(default_factory=dict)
61
+ player_sessions: dict[str, ServerPlayerSessions] = Field(default_factory=dict)
62
+
63
+
47
64
  def _config_dir() -> Path:
48
65
  config_dir = Path.home() / ".softmax"
49
66
  config_dir.mkdir(parents=True, exist_ok=True)
@@ -54,19 +71,19 @@ def _credentials_path() -> Path:
54
71
  return _config_dir() / CREDENTIALS_FILE
55
72
 
56
73
 
57
- def _load_data() -> dict:
74
+ def _load_data() -> Credentials:
58
75
  cred_path = _credentials_path()
59
76
  if not cred_path.exists():
60
- return {"tokens": {}, "player_sessions": {}}
77
+ return Credentials()
61
78
 
62
79
  with open(cred_path, "r") as f:
63
- return yaml.safe_load(f) or {"tokens": {}, "player_sessions": {}}
80
+ return Credentials.model_validate(yaml.safe_load(f) or {})
64
81
 
65
82
 
66
- def _save_data(data: dict) -> None:
83
+ def _save_data(data: Credentials) -> None:
67
84
  cred_path = _credentials_path()
68
85
  with open(cred_path, "w") as f:
69
- yaml.safe_dump(data, f, default_flow_style=False)
86
+ yaml.safe_dump(data.model_dump(mode="json"), f, default_flow_style=False)
70
87
  os.chmod(cred_path, 0o600)
71
88
 
72
89
 
@@ -75,34 +92,20 @@ def _save_data(data: dict) -> None:
75
92
 
76
93
  def load_user_token(*, server: str) -> str | None:
77
94
  server = server.rstrip("/")
78
- tokens = _load_data().get("tokens", {})
79
- token = tokens.get(server)
80
- return token if isinstance(token, str) else None
95
+ return _load_data().tokens.get(server)
81
96
 
82
97
 
83
98
  def save_user_token(*, server: str, token: str) -> None:
84
99
  server = server.rstrip("/")
85
100
  data = _load_data()
86
- tokens = data.setdefault("tokens", {})
87
101
  # Player sessions are owned by whoever held the user token. When the user
88
102
  # credential changes (account switch, `login --force`, CI token rotation),
89
103
  # the old player sessions belong to the previous user, so drop them rather
90
104
  # than letting load_current_token keep acting as that stale player.
91
- if tokens.get(server) != token:
92
- data.get("player_sessions", {}).pop(server, None)
93
- tokens[server] = token
94
- _save_data(data)
95
-
96
-
97
- def _delete_user_token(*, server: str) -> bool:
98
- server = server.rstrip("/")
99
- data = _load_data()
100
- tokens = data.get("tokens", {})
101
- if server not in tokens:
102
- return False
103
- del tokens[server]
105
+ if data.tokens.get(server) != token:
106
+ data.player_sessions.pop(server, None)
107
+ data.tokens[server] = token
104
108
  _save_data(data)
105
- return True
106
109
 
107
110
 
108
111
  # --- player sessions ---
@@ -119,23 +122,12 @@ def _delete_user_token(*, server: str) -> bool:
119
122
  # ply_aaa: {token: ply_..., expires_at: "2026-..."}
120
123
 
121
124
 
122
- class CachedPlayerSession(BaseModel):
123
- token: str
124
- expires_at: datetime
125
-
126
-
127
- class ServerPlayerSessions(BaseModel):
128
- active: str | None = None
129
- cache: dict[str, CachedPlayerSession] = Field(default_factory=dict)
130
-
131
-
132
- def _server_sessions(data: dict, server: str) -> ServerPlayerSessions:
133
- raw = (data.get("player_sessions") or {}).get(server.rstrip("/")) or {}
134
- return ServerPlayerSessions.model_validate(raw)
125
+ def _server_sessions(data: Credentials, server: str) -> ServerPlayerSessions:
126
+ return data.player_sessions.get(server.rstrip("/"), ServerPlayerSessions())
135
127
 
136
128
 
137
- def _store_server_sessions(data: dict, server: str, sessions: ServerPlayerSessions) -> None:
138
- data.setdefault("player_sessions", {})[server.rstrip("/")] = sessions.model_dump(mode="json")
129
+ def _store_server_sessions(data: Credentials, server: str, sessions: ServerPlayerSessions) -> None:
130
+ data.player_sessions[server.rstrip("/")] = sessions
139
131
 
140
132
 
141
133
  def load_player_session(*, server: str) -> str | None:
@@ -199,11 +191,10 @@ def has_saved_token(*, server: str) -> bool:
199
191
 
200
192
  def delete_all_tokens(*, server: str) -> bool:
201
193
  server = server.rstrip("/")
202
- deleted_token = _delete_user_token(server=server)
203
194
  data = _load_data()
204
- sessions = data.get("player_sessions", {})
205
- deleted_session = sessions.pop(server, None) is not None
206
- if deleted_session:
195
+ deleted_token = data.tokens.pop(server, None) is not None
196
+ deleted_session = data.player_sessions.pop(server, None) is not None
197
+ if deleted_token or deleted_session:
207
198
  _save_data(data)
208
199
  return deleted_token or deleted_session
209
200
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: softmax-cli
3
- Version: 0.26.27
3
+ Version: 0.26.30
4
4
  Summary: Softmax CLI — authentication and account tools
5
5
  Classifier: Programming Language :: Python :: 3
6
6
  Classifier: Programming Language :: Python :: 3.11
@@ -4,8 +4,11 @@ import sys
4
4
  from datetime import UTC, datetime, timedelta
5
5
 
6
6
  import pytest
7
+ import yaml
8
+ from pydantic import ValidationError
7
9
 
8
10
  import softmax
11
+ import softmax.auth as auth
9
12
  from softmax.auth import (
10
13
  clear_active_player_session,
11
14
  get_cached_player_session,
@@ -26,6 +29,65 @@ def _activate_player(server: str, token: str, player_id: str = "ply_alpha") -> N
26
29
  )
27
30
 
28
31
 
32
+ @pytest.mark.parametrize(
33
+ "credentials",
34
+ [
35
+ {"tokens": {"https://softmax.com/api": 123}},
36
+ {
37
+ "player_sessions": {
38
+ "https://softmax.com/api": {"active": ["ply_alpha"], "cache": {}},
39
+ },
40
+ },
41
+ ],
42
+ )
43
+ def test_malformed_credentials_fail_validation(monkeypatch: pytest.MonkeyPatch, tmp_path, credentials) -> None:
44
+ monkeypatch.setenv("HOME", str(tmp_path))
45
+ config_dir = tmp_path / ".softmax"
46
+ config_dir.mkdir()
47
+ (config_dir / "credentials.yaml").write_text(yaml.safe_dump(credentials))
48
+
49
+ with pytest.raises(ValidationError):
50
+ auth._load_data()
51
+
52
+
53
+ def test_unknown_credential_fields_survive_save(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None:
54
+ monkeypatch.setenv("HOME", str(tmp_path))
55
+ config_dir = tmp_path / ".softmax"
56
+ config_dir.mkdir()
57
+ credentials_path = config_dir / "credentials.yaml"
58
+ credentials_path.write_text(yaml.safe_dump({"credential_version": {"major": 2}}))
59
+
60
+ save_user_token(server="https://softmax.com/api", token="user-token")
61
+
62
+ saved = yaml.safe_load(credentials_path.read_text())
63
+ assert saved["credential_version"] == {"major": 2}
64
+
65
+
66
+ def test_delete_all_tokens_is_one_credentials_update(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None:
67
+ monkeypatch.setenv("HOME", str(tmp_path))
68
+ server = "https://softmax.com/api"
69
+ save_user_token(server=server, token="user-token")
70
+ _activate_player(server, "player-token")
71
+ credentials = auth._load_data()
72
+ calls = {"loads": 0, "saves": 0}
73
+
74
+ def load() -> auth.Credentials:
75
+ calls["loads"] += 1
76
+ return credentials
77
+
78
+ def save(data: auth.Credentials) -> None:
79
+ calls["saves"] += 1
80
+ assert data is credentials
81
+
82
+ monkeypatch.setattr(auth, "_load_data", load)
83
+ monkeypatch.setattr(auth, "_save_data", save)
84
+
85
+ assert auth.delete_all_tokens(server=server) is True
86
+ assert calls == {"loads": 1, "saves": 1}
87
+ assert server not in credentials.tokens
88
+ assert server not in credentials.player_sessions
89
+
90
+
29
91
  def test_login_returns_saved_token(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None:
30
92
  monkeypatch.setenv("HOME", str(tmp_path))
31
93
  save_user_token(server="https://softmax.com/api", token="saved-token")
File without changes
File without changes
File without changes
File without changes
File without changes