agilerl-arena 0.1.0.dev0__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 (70) hide show
  1. agilerl_arena-0.1.0.dev0/PKG-INFO +113 -0
  2. agilerl_arena-0.1.0.dev0/README.md +95 -0
  3. agilerl_arena-0.1.0.dev0/agilerl/arena/__init__.py +45 -0
  4. agilerl_arena-0.1.0.dev0/agilerl/arena/auth.py +284 -0
  5. agilerl_arena-0.1.0.dev0/agilerl/arena/cli.py +993 -0
  6. agilerl_arena-0.1.0.dev0/agilerl/arena/cli_manifest.py +350 -0
  7. agilerl_arena-0.1.0.dev0/agilerl/arena/client.py +1903 -0
  8. agilerl_arena-0.1.0.dev0/agilerl/arena/config.py +90 -0
  9. agilerl_arena-0.1.0.dev0/agilerl/arena/exceptions.py +307 -0
  10. agilerl_arena-0.1.0.dev0/agilerl/arena/inference/__init__.py +52 -0
  11. agilerl_arena-0.1.0.dev0/agilerl/arena/inference/agent.py +618 -0
  12. agilerl_arena-0.1.0.dev0/agilerl/arena/inference/cache.py +138 -0
  13. agilerl_arena-0.1.0.dev0/agilerl/arena/inference/serde.py +78 -0
  14. agilerl_arena-0.1.0.dev0/agilerl/arena/models/__init__.py +47 -0
  15. agilerl_arena-0.1.0.dev0/agilerl/arena/models/algo.py +177 -0
  16. agilerl_arena-0.1.0.dev0/agilerl/arena/models/algorithms/__init__.py +37 -0
  17. agilerl_arena-0.1.0.dev0/agilerl/arena/models/algorithms/cispo.py +11 -0
  18. agilerl_arena-0.1.0.dev0/agilerl/arena/models/algorithms/ddpg.py +25 -0
  19. agilerl_arena-0.1.0.dev0/agilerl/arena/models/algorithms/dpo.py +17 -0
  20. agilerl_arena-0.1.0.dev0/agilerl/arena/models/algorithms/dqn.py +20 -0
  21. agilerl_arena-0.1.0.dev0/agilerl/arena/models/algorithms/grpo.py +34 -0
  22. agilerl_arena-0.1.0.dev0/agilerl/arena/models/algorithms/gspo.py +11 -0
  23. agilerl_arena-0.1.0.dev0/agilerl/arena/models/algorithms/ippo.py +26 -0
  24. agilerl_arena-0.1.0.dev0/agilerl/arena/models/algorithms/llmppo.py +38 -0
  25. agilerl_arena-0.1.0.dev0/agilerl/arena/models/algorithms/llmreinforce.py +35 -0
  26. agilerl_arena-0.1.0.dev0/agilerl/arena/models/algorithms/maddpg.py +24 -0
  27. agilerl_arena-0.1.0.dev0/agilerl/arena/models/algorithms/matd3.py +25 -0
  28. agilerl_arena-0.1.0.dev0/agilerl/arena/models/algorithms/ppo.py +32 -0
  29. agilerl_arena-0.1.0.dev0/agilerl/arena/models/algorithms/rainbow_dqn.py +32 -0
  30. agilerl_arena-0.1.0.dev0/agilerl/arena/models/algorithms/sft.py +17 -0
  31. agilerl_arena-0.1.0.dev0/agilerl/arena/models/algorithms/td3.py +25 -0
  32. agilerl_arena-0.1.0.dev0/agilerl/arena/models/env.py +35 -0
  33. agilerl_arena-0.1.0.dev0/agilerl/arena/models/hpo.py +81 -0
  34. agilerl_arena-0.1.0.dev0/agilerl/arena/models/manifest.py +300 -0
  35. agilerl_arena-0.1.0.dev0/agilerl/arena/models/networks.py +369 -0
  36. agilerl_arena-0.1.0.dev0/agilerl/arena/models/training.py +153 -0
  37. agilerl_arena-0.1.0.dev0/agilerl/arena/on_prem/__init__.py +50 -0
  38. agilerl_arena-0.1.0.dev0/agilerl/arena/on_prem/api.py +125 -0
  39. agilerl_arena-0.1.0.dev0/agilerl/arena/on_prem/bundle.py +173 -0
  40. agilerl_arena-0.1.0.dev0/agilerl/arena/on_prem/commands.py +366 -0
  41. agilerl_arena-0.1.0.dev0/agilerl/arena/on_prem/endpoints.py +69 -0
  42. agilerl_arena-0.1.0.dev0/agilerl/arena/on_prem/group.py +287 -0
  43. agilerl_arena-0.1.0.dev0/agilerl/arena/on_prem/installer.py +1054 -0
  44. agilerl_arena-0.1.0.dev0/agilerl/arena/on_prem/scripts.py +154 -0
  45. agilerl_arena-0.1.0.dev0/agilerl/arena/on_prem/ssh.py +216 -0
  46. agilerl_arena-0.1.0.dev0/agilerl/arena/output.py +359 -0
  47. agilerl_arena-0.1.0.dev0/agilerl/arena/payloads.py +69 -0
  48. agilerl_arena-0.1.0.dev0/agilerl/arena/stream.py +364 -0
  49. agilerl_arena-0.1.0.dev0/agilerl/arena/utils.py +152 -0
  50. agilerl_arena-0.1.0.dev0/agilerl_arena.egg-info/PKG-INFO +113 -0
  51. agilerl_arena-0.1.0.dev0/agilerl_arena.egg-info/SOURCES.txt +68 -0
  52. agilerl_arena-0.1.0.dev0/agilerl_arena.egg-info/dependency_links.txt +1 -0
  53. agilerl_arena-0.1.0.dev0/agilerl_arena.egg-info/entry_points.txt +2 -0
  54. agilerl_arena-0.1.0.dev0/agilerl_arena.egg-info/requires.txt +9 -0
  55. agilerl_arena-0.1.0.dev0/agilerl_arena.egg-info/top_level.txt +1 -0
  56. agilerl_arena-0.1.0.dev0/pyproject.toml +31 -0
  57. agilerl_arena-0.1.0.dev0/setup.cfg +4 -0
  58. agilerl_arena-0.1.0.dev0/tests/test_arena_models.py +268 -0
  59. agilerl_arena-0.1.0.dev0/tests/test_arena_networks.py +189 -0
  60. agilerl_arena-0.1.0.dev0/tests/test_arena_utils.py +200 -0
  61. agilerl_arena-0.1.0.dev0/tests/test_auth.py +503 -0
  62. agilerl_arena-0.1.0.dev0/tests/test_cli.py +1105 -0
  63. agilerl_arena-0.1.0.dev0/tests/test_cli_manifest.py +554 -0
  64. agilerl_arena-0.1.0.dev0/tests/test_client.py +1801 -0
  65. agilerl_arena-0.1.0.dev0/tests/test_datasets.py +257 -0
  66. agilerl_arena-0.1.0.dev0/tests/test_exceptions.py +482 -0
  67. agilerl_arena-0.1.0.dev0/tests/test_inference.py +997 -0
  68. agilerl_arena-0.1.0.dev0/tests/test_output.py +429 -0
  69. agilerl_arena-0.1.0.dev0/tests/test_payloads.py +123 -0
  70. agilerl_arena-0.1.0.dev0/tests/test_stream.py +769 -0
@@ -0,0 +1,113 @@
1
+ Metadata-Version: 2.4
2
+ Name: agilerl-arena
3
+ Version: 0.1.0.dev0
4
+ Summary: Arena Platform Client Library and CLI.
5
+ Author-email: Nick Ustaran-Anderegg <dev@agilerl.com>
6
+ License-Expression: Apache-2.0
7
+ Requires-Python: <3.14,>=3.10
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: click>=8.3.0
10
+ Requires-Dist: lazy-loader>=0.4
11
+ Requires-Dist: httpx<1,>=0.27
12
+ Requires-Dist: numpy<3.0,>=2.0.0
13
+ Requires-Dist: packaging>=20.0
14
+ Requires-Dist: pydantic<3,>=2.0
15
+ Requires-Dist: python-keycloak<6,>=4.0
16
+ Requires-Dist: pyyaml>=6.0
17
+ Requires-Dist: rich<14,>=13.0
18
+
19
+ # agilerl-arena
20
+
21
+ `agilerl-arena` is the standalone Arena SDK + CLI package for AgileRL.
22
+
23
+ It provides:
24
+
25
+ - Python client for Arena workflows (auth, environment validation, experiment submission, deployment, inference)
26
+ - `arena` CLI for scripting and CI usage
27
+ - Lightweight manifest validation models for Arena job manifests
28
+
29
+ This package is distributed independently from core `agilerl`, but exposes modules through the shared namespace:
30
+
31
+ ```python
32
+ from agilerl.arena import ArenaClient, Agent
33
+ ```
34
+
35
+ ## Installation
36
+
37
+ Install directly:
38
+
39
+ ```bash
40
+ pip install agilerl-arena
41
+ ```
42
+
43
+ Or install through core AgileRL extras:
44
+
45
+ ```bash
46
+ pip install "agilerl[arena]"
47
+ ```
48
+
49
+ ## Quickstart
50
+
51
+ ### 1) Authenticate
52
+
53
+ Preferred for CI/automation:
54
+
55
+ ```bash
56
+ export ARENA_API_KEY="arena_pat_..."
57
+ ```
58
+
59
+ Or interactive login:
60
+
61
+ ```bash
62
+ arena login
63
+ ```
64
+
65
+ ### 2) Validate an environment
66
+
67
+ ```bash
68
+ arena env validate --source path/to/my_env.py --name my-env
69
+ ```
70
+
71
+ ### 3) Submit a training manifest
72
+
73
+ ```bash
74
+ arena experiments submit path/to/manifest.yaml --project my-project
75
+ ```
76
+
77
+ ## Python SDK example
78
+
79
+ ```python
80
+ from agilerl.arena import ArenaClient
81
+
82
+ client = ArenaClient() # uses ARENA_API_KEY if set
83
+
84
+ client.validate_environment(
85
+ source="acrobot.py",
86
+ name="acrobot-env",
87
+ )
88
+
89
+ result = client.submit_experiment(
90
+ manifest="dqn.yaml",
91
+ resource_id="arena-medium",
92
+ project="my-project",
93
+ )
94
+
95
+ print(result)
96
+ ```
97
+
98
+ ## Inference example
99
+
100
+ ```python
101
+ from agilerl.arena import Agent
102
+
103
+ agent = Agent("https://<deployment-id>.inference.agilerl.com", api_key="arena_pat_...")
104
+ action, _ = agent.get_action(observation)
105
+ ```
106
+
107
+ ## Notes on packaging and imports
108
+
109
+ - Distribution name: `agilerl-arena`
110
+ - Python import namespace: `agilerl.arena`
111
+ - CLI command: `arena`
112
+
113
+ `agilerl-arena` and `agilerl` intentionally share the `agilerl.*` namespace as separate packages.
@@ -0,0 +1,95 @@
1
+ # agilerl-arena
2
+
3
+ `agilerl-arena` is the standalone Arena SDK + CLI package for AgileRL.
4
+
5
+ It provides:
6
+
7
+ - Python client for Arena workflows (auth, environment validation, experiment submission, deployment, inference)
8
+ - `arena` CLI for scripting and CI usage
9
+ - Lightweight manifest validation models for Arena job manifests
10
+
11
+ This package is distributed independently from core `agilerl`, but exposes modules through the shared namespace:
12
+
13
+ ```python
14
+ from agilerl.arena import ArenaClient, Agent
15
+ ```
16
+
17
+ ## Installation
18
+
19
+ Install directly:
20
+
21
+ ```bash
22
+ pip install agilerl-arena
23
+ ```
24
+
25
+ Or install through core AgileRL extras:
26
+
27
+ ```bash
28
+ pip install "agilerl[arena]"
29
+ ```
30
+
31
+ ## Quickstart
32
+
33
+ ### 1) Authenticate
34
+
35
+ Preferred for CI/automation:
36
+
37
+ ```bash
38
+ export ARENA_API_KEY="arena_pat_..."
39
+ ```
40
+
41
+ Or interactive login:
42
+
43
+ ```bash
44
+ arena login
45
+ ```
46
+
47
+ ### 2) Validate an environment
48
+
49
+ ```bash
50
+ arena env validate --source path/to/my_env.py --name my-env
51
+ ```
52
+
53
+ ### 3) Submit a training manifest
54
+
55
+ ```bash
56
+ arena experiments submit path/to/manifest.yaml --project my-project
57
+ ```
58
+
59
+ ## Python SDK example
60
+
61
+ ```python
62
+ from agilerl.arena import ArenaClient
63
+
64
+ client = ArenaClient() # uses ARENA_API_KEY if set
65
+
66
+ client.validate_environment(
67
+ source="acrobot.py",
68
+ name="acrobot-env",
69
+ )
70
+
71
+ result = client.submit_experiment(
72
+ manifest="dqn.yaml",
73
+ resource_id="arena-medium",
74
+ project="my-project",
75
+ )
76
+
77
+ print(result)
78
+ ```
79
+
80
+ ## Inference example
81
+
82
+ ```python
83
+ from agilerl.arena import Agent
84
+
85
+ agent = Agent("https://<deployment-id>.inference.agilerl.com", api_key="arena_pat_...")
86
+ action, _ = agent.get_action(observation)
87
+ ```
88
+
89
+ ## Notes on packaging and imports
90
+
91
+ - Distribution name: `agilerl-arena`
92
+ - Python import namespace: `agilerl.arena`
93
+ - CLI command: `arena`
94
+
95
+ `agilerl-arena` and `agilerl` intentionally share the `agilerl.*` namespace as separate packages.
@@ -0,0 +1,45 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from enum import Enum
5
+ from typing import TYPE_CHECKING
6
+
7
+ import lazy_loader as lazy
8
+ from rich.console import Console
9
+ from rich.logging import RichHandler
10
+
11
+ if TYPE_CHECKING:
12
+ from agilerl.arena.client import ArenaClient
13
+ from agilerl.arena.inference import Agent
14
+
15
+ console = Console()
16
+ error_console = Console(stderr=True)
17
+
18
+ _logger = logging.getLogger("agilerl.arena")
19
+ if not _logger.handlers:
20
+ _logger.setLevel(logging.INFO)
21
+ _logger.addHandler(
22
+ RichHandler(show_time=False, show_path=False, markup=True, console=console)
23
+ )
24
+ _logger.propagate = False
25
+
26
+ logging.getLogger("httpx").setLevel(logging.WARNING)
27
+ logging.getLogger("httpcore").setLevel(logging.WARNING)
28
+
29
+ __getattr__, __dir__, _ = lazy.attach(
30
+ __name__,
31
+ submod_attrs={
32
+ "client": ["ArenaClient"],
33
+ "inference": ["Agent"],
34
+ },
35
+ )
36
+
37
+ __all__ = ["Agent", "ArenaClient"]
38
+
39
+
40
+ class AgentType(Enum):
41
+ """Enumeration of supported agent types."""
42
+
43
+ SingleAgent = "single_agent"
44
+ MultiAgent = "multi_agent"
45
+ LLMAgent = "llm_agent"
@@ -0,0 +1,284 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import binascii
5
+ import json
6
+ import logging
7
+ import os
8
+ import stat
9
+ import time
10
+ import webbrowser
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from agilerl.arena.exceptions import ArenaAuthError, ArenaTimeoutError
15
+ from keycloak import KeycloakOpenID
16
+ from keycloak.exceptions import KeycloakError
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ _DEFAULT_ACCESS_SKEW_SECONDS = 60
21
+
22
+
23
+ def oauth_access_token_expires_at(access_token: str | None) -> float | None:
24
+ """Return JWT ``exp`` claim as Unix timestamp, or ``None`` if not a decodable JWT."""
25
+ if not access_token or "." not in access_token:
26
+ return None
27
+ try:
28
+ _h, payload_b64, *_rest = access_token.split(".")
29
+ pad = "=" * (-len(payload_b64) % 4)
30
+ raw = base64.urlsafe_b64decode(payload_b64 + pad)
31
+ payload = json.loads(raw.decode("utf-8"))
32
+ exp = payload.get("exp")
33
+ if exp is None:
34
+ return None
35
+ return float(exp)
36
+ except (
37
+ json.JSONDecodeError,
38
+ ValueError,
39
+ UnicodeDecodeError,
40
+ binascii.Error,
41
+ KeyError,
42
+ ):
43
+ return None
44
+
45
+
46
+ def is_oauth_access_token_valid(
47
+ access_token: str | None,
48
+ *,
49
+ skew_seconds: int = _DEFAULT_ACCESS_SKEW_SECONDS,
50
+ ) -> bool:
51
+ """True if *access_token* is a JWT and ``exp`` is after now (plus *skew_seconds*)."""
52
+ exp = oauth_access_token_expires_at(access_token)
53
+ if exp is None:
54
+ return False
55
+ return exp > time.time() + skew_seconds
56
+
57
+
58
+ def load_credentials_payload(
59
+ credentials_path: Path | os.PathLike[str] | None = None,
60
+ ) -> dict[str, Any]:
61
+ """Load the raw JSON object from the credentials file (no token validation).
62
+
63
+ Used when merging OAuth tokens with other persisted keys.
64
+ Deployment bindings live in ``~/.arena/inference.json``.
65
+ """
66
+ path = (
67
+ Path(os.fspath(credentials_path)).expanduser().resolve()
68
+ if credentials_path is not None
69
+ else Path.home() / ".arena" / "credentials.json"
70
+ )
71
+ if not path.is_file():
72
+ return {}
73
+ try:
74
+ data = json.loads(path.read_text(encoding="utf-8"))
75
+ return data if isinstance(data, dict) else {}
76
+ except (json.JSONDecodeError, OSError):
77
+ return {}
78
+
79
+
80
+ def load_credentials(
81
+ credentials_path: Path | os.PathLike[str] = "~/.arena/credentials.json",
82
+ ) -> dict[str, Any] | None:
83
+ """Read stored credentials from ``~/.arena/credentials.json``.
84
+
85
+ :param credentials_path: The path to the credentials file.
86
+ :type credentials_path: Path | os.PathLike[str]
87
+
88
+ :returns: Token dictionary, or ``None`` if absent or malformed.
89
+ :rtype: dict[str, Any] | None
90
+ """
91
+ credentials_path = Path(os.fspath(credentials_path)).expanduser().resolve()
92
+ if not credentials_path.is_file():
93
+ return None
94
+ try:
95
+ data = json.loads(credentials_path.read_text(encoding="utf-8"))
96
+ if not isinstance(data, dict) or "access_token" not in data:
97
+ return None
98
+ return data
99
+ except (json.JSONDecodeError, OSError):
100
+ return None
101
+
102
+
103
+ class ArenaOAuth2:
104
+ """Authentication for the Arena RLOps platform.
105
+
106
+ Handles authentication with Keycloak. Supports OAuth 2.0 Device Authorization Grant flow.
107
+ """
108
+
109
+ CREDENTIALS_DIR = Path.home() / ".arena"
110
+ CREDENTIALS_FILE = CREDENTIALS_DIR / "credentials.json"
111
+
112
+ # TODO: Remove this once we have a production URL
113
+ # KEYCLOAK_URL = "https://auth.arena.agilerl.com"
114
+ # KEYCLOAK_URL = "https://arena-dev-auth.agilerl.rlops.ai/"
115
+ KEYCLOAK_URL = "http://localhost:8023"
116
+ REALM = "arena"
117
+ CLIENT_ID = "arena-cli"
118
+
119
+ def __init__(self):
120
+ # Create a Keycloak OpenID client with the configured URL, realm, and client ID.
121
+ self.kc = KeycloakOpenID(
122
+ server_url=os.environ.get("ARENA_KEYCLOAK_URL") or self.KEYCLOAK_URL,
123
+ realm_name=self.REALM,
124
+ client_id=self.CLIENT_ID,
125
+ )
126
+
127
+ @classmethod
128
+ def configure(
129
+ cls,
130
+ *,
131
+ keycloak_url: str | None = None,
132
+ realm: str | None = None,
133
+ client_id: str | None = None,
134
+ credentials_dir: Path | None = None,
135
+ credentials_file: Path | None = None,
136
+ ) -> type[ArenaOAuth2]:
137
+ """Configure the ArenaOAuth2 instance.
138
+
139
+ :param keycloak_url: The URL of the Keycloak server.
140
+ :param realm: The realm to use for authentication.
141
+ :param client_id: The client ID to use for authentication.
142
+ :param credentials_dir: The directory to store the credentials. Defaults to ``~/.arena``.
143
+ :param credentials_file: The file to store the credentials. Defaults to ``~/.arena/credentials.json``.
144
+ :returns: The configured ArenaOAuth2 instance.
145
+ """
146
+ if keycloak_url is not None:
147
+ cls.KEYCLOAK_URL = keycloak_url
148
+ if realm is not None:
149
+ cls.REALM = realm
150
+ if client_id is not None:
151
+ cls.CLIENT_ID = client_id
152
+ if credentials_dir is not None:
153
+ cls.CREDENTIALS_DIR = credentials_dir
154
+ if credentials_file is not None:
155
+ cls.CREDENTIALS_FILE = credentials_file
156
+ return cls
157
+
158
+ @classmethod
159
+ def _write_credentials(cls, data: dict[str, Any]) -> None:
160
+ """Persist credentials with owner-only file permissions.
161
+
162
+ :param data: The credentials to persist.
163
+ :returns: The credentials.
164
+ """
165
+ cls.CREDENTIALS_DIR.mkdir(parents=True, exist_ok=True)
166
+ os.chmod(cls.CREDENTIALS_DIR, stat.S_IRWXU)
167
+ cls.CREDENTIALS_FILE.write_text(json.dumps(data, indent=2), encoding="utf-8")
168
+ os.chmod(cls.CREDENTIALS_FILE, stat.S_IRUSR | stat.S_IWUSR)
169
+
170
+ @staticmethod
171
+ def _extract_error(exc: KeycloakError) -> str:
172
+ """Pull the ``error`` field from a Keycloak error response body."""
173
+ try:
174
+ body = getattr(exc, "response_body", None)
175
+ if isinstance(body, bytes):
176
+ body = body.decode("utf-8", errors="replace")
177
+ if isinstance(body, str):
178
+ return json.loads(body).get("error", "")
179
+ except (json.JSONDecodeError, AttributeError):
180
+ pass
181
+
182
+ msg = str(exc)
183
+ for known in ("authorization_pending", "slow_down", "expired_token"):
184
+ if known in msg:
185
+ return known
186
+ return ""
187
+
188
+ def device_login(self, timeout: int = 300) -> dict[str, Any]:
189
+ """Run the OAuth 2.0 Device Authorization Grant flow.
190
+
191
+ Requests a device code from Keycloak, opens the verification URL in a browser,
192
+ then polls until the user authorizes or *timeout* seconds elapse.
193
+
194
+ :param timeout: Maximum seconds to wait for user authorization.
195
+ :returns: Token dict with ``access_token``, ``refresh_token``, etc.
196
+ :raises ArenaAuthError: If Keycloak rejects the request.
197
+ :raises ArenaTimeoutError: If the user does not authorize in time.
198
+ """
199
+ try:
200
+ # Request a device code from Keycloak.
201
+ device_resp = self.kc.device(scope="openid profile email")
202
+ except KeycloakError as exc:
203
+ msg = f"Failed to initiate device authorization: {exc}"
204
+ raise ArenaAuthError(msg) from exc
205
+
206
+ # Extract the device code and verification URI from the response.
207
+ device_code = device_resp["device_code"]
208
+ verification_uri = device_resp.get(
209
+ "verification_uri_complete", device_resp.get("verification_uri", "")
210
+ )
211
+ interval = device_resp.get("interval", 5)
212
+
213
+ logger.info("Opening browser for authentication... %s", verification_uri)
214
+ if not webbrowser.open(verification_uri):
215
+ logger.warning(
216
+ "Could not open browser automatically. Please visit the URL above."
217
+ )
218
+
219
+ deadline = time.monotonic() + timeout
220
+ while time.monotonic() < deadline:
221
+ time.sleep(interval)
222
+ try:
223
+ tokens = self.kc.token(
224
+ grant_type="urn:ietf:params:oauth:grant-type:device_code",
225
+ device_code=device_code,
226
+ )
227
+ merged = load_credentials_payload(self.CREDENTIALS_FILE)
228
+ merged.update(tokens)
229
+ ArenaOAuth2._write_credentials(merged)
230
+ return tokens
231
+ except KeycloakError as exc:
232
+ error = ArenaOAuth2._extract_error(exc)
233
+ if error == "authorization_pending":
234
+ continue
235
+ if error == "slow_down":
236
+ interval += 5
237
+ continue
238
+ if error == "expired_token":
239
+ msg = "Device code expired before authorization was completed."
240
+ raise ArenaTimeoutError(msg) from exc
241
+ msg = f"Device authorization failed: {exc}"
242
+ raise ArenaAuthError(msg) from exc
243
+
244
+ msg = f"Authentication timed out after {timeout}s. No authorization received."
245
+ raise ArenaTimeoutError(msg)
246
+
247
+ def refresh_access_token(self, refresh_token: str) -> dict[str, Any]:
248
+ """Obtain a fresh access token using a refresh token.
249
+
250
+ Persists the updated token set to ``~/.arena/credentials.json``.
251
+
252
+ :param refresh_token: The stored refresh token.
253
+ :returns: Updated token dict.
254
+ :raises ArenaAuthError: If the refresh is rejected (session expired).
255
+ """
256
+ try:
257
+ tokens = self.kc.refresh_token(refresh_token)
258
+ except KeycloakError as exc:
259
+ msg = "Token refresh failed — your session may have expired."
260
+ raise ArenaAuthError(
261
+ msg,
262
+ sdk_hint="Please run client.login() again.",
263
+ cli_hint="Please run 'arena login' to re-authenticate.",
264
+ ) from exc
265
+
266
+ creds = load_credentials_payload(self.CREDENTIALS_FILE)
267
+ creds.update(tokens)
268
+ ArenaOAuth2._write_credentials(creds)
269
+ return tokens
270
+
271
+ def revoke(self, refresh_token: str) -> None:
272
+ """Revoke a Keycloak session and delete stored credentials.
273
+
274
+ :param refresh_token: The refresh token to revoke.
275
+ """
276
+ try:
277
+ self.kc.logout(refresh_token)
278
+ except KeycloakError:
279
+ logger.debug("Keycloak logout failed (token may already be expired).")
280
+
281
+ try:
282
+ self.CREDENTIALS_FILE.unlink(missing_ok=True)
283
+ except OSError:
284
+ pass