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.
- agilerl_arena-0.1.0.dev0/PKG-INFO +113 -0
- agilerl_arena-0.1.0.dev0/README.md +95 -0
- agilerl_arena-0.1.0.dev0/agilerl/arena/__init__.py +45 -0
- agilerl_arena-0.1.0.dev0/agilerl/arena/auth.py +284 -0
- agilerl_arena-0.1.0.dev0/agilerl/arena/cli.py +993 -0
- agilerl_arena-0.1.0.dev0/agilerl/arena/cli_manifest.py +350 -0
- agilerl_arena-0.1.0.dev0/agilerl/arena/client.py +1903 -0
- agilerl_arena-0.1.0.dev0/agilerl/arena/config.py +90 -0
- agilerl_arena-0.1.0.dev0/agilerl/arena/exceptions.py +307 -0
- agilerl_arena-0.1.0.dev0/agilerl/arena/inference/__init__.py +52 -0
- agilerl_arena-0.1.0.dev0/agilerl/arena/inference/agent.py +618 -0
- agilerl_arena-0.1.0.dev0/agilerl/arena/inference/cache.py +138 -0
- agilerl_arena-0.1.0.dev0/agilerl/arena/inference/serde.py +78 -0
- agilerl_arena-0.1.0.dev0/agilerl/arena/models/__init__.py +47 -0
- agilerl_arena-0.1.0.dev0/agilerl/arena/models/algo.py +177 -0
- agilerl_arena-0.1.0.dev0/agilerl/arena/models/algorithms/__init__.py +37 -0
- agilerl_arena-0.1.0.dev0/agilerl/arena/models/algorithms/cispo.py +11 -0
- agilerl_arena-0.1.0.dev0/agilerl/arena/models/algorithms/ddpg.py +25 -0
- agilerl_arena-0.1.0.dev0/agilerl/arena/models/algorithms/dpo.py +17 -0
- agilerl_arena-0.1.0.dev0/agilerl/arena/models/algorithms/dqn.py +20 -0
- agilerl_arena-0.1.0.dev0/agilerl/arena/models/algorithms/grpo.py +34 -0
- agilerl_arena-0.1.0.dev0/agilerl/arena/models/algorithms/gspo.py +11 -0
- agilerl_arena-0.1.0.dev0/agilerl/arena/models/algorithms/ippo.py +26 -0
- agilerl_arena-0.1.0.dev0/agilerl/arena/models/algorithms/llmppo.py +38 -0
- agilerl_arena-0.1.0.dev0/agilerl/arena/models/algorithms/llmreinforce.py +35 -0
- agilerl_arena-0.1.0.dev0/agilerl/arena/models/algorithms/maddpg.py +24 -0
- agilerl_arena-0.1.0.dev0/agilerl/arena/models/algorithms/matd3.py +25 -0
- agilerl_arena-0.1.0.dev0/agilerl/arena/models/algorithms/ppo.py +32 -0
- agilerl_arena-0.1.0.dev0/agilerl/arena/models/algorithms/rainbow_dqn.py +32 -0
- agilerl_arena-0.1.0.dev0/agilerl/arena/models/algorithms/sft.py +17 -0
- agilerl_arena-0.1.0.dev0/agilerl/arena/models/algorithms/td3.py +25 -0
- agilerl_arena-0.1.0.dev0/agilerl/arena/models/env.py +35 -0
- agilerl_arena-0.1.0.dev0/agilerl/arena/models/hpo.py +81 -0
- agilerl_arena-0.1.0.dev0/agilerl/arena/models/manifest.py +300 -0
- agilerl_arena-0.1.0.dev0/agilerl/arena/models/networks.py +369 -0
- agilerl_arena-0.1.0.dev0/agilerl/arena/models/training.py +153 -0
- agilerl_arena-0.1.0.dev0/agilerl/arena/on_prem/__init__.py +50 -0
- agilerl_arena-0.1.0.dev0/agilerl/arena/on_prem/api.py +125 -0
- agilerl_arena-0.1.0.dev0/agilerl/arena/on_prem/bundle.py +173 -0
- agilerl_arena-0.1.0.dev0/agilerl/arena/on_prem/commands.py +366 -0
- agilerl_arena-0.1.0.dev0/agilerl/arena/on_prem/endpoints.py +69 -0
- agilerl_arena-0.1.0.dev0/agilerl/arena/on_prem/group.py +287 -0
- agilerl_arena-0.1.0.dev0/agilerl/arena/on_prem/installer.py +1054 -0
- agilerl_arena-0.1.0.dev0/agilerl/arena/on_prem/scripts.py +154 -0
- agilerl_arena-0.1.0.dev0/agilerl/arena/on_prem/ssh.py +216 -0
- agilerl_arena-0.1.0.dev0/agilerl/arena/output.py +359 -0
- agilerl_arena-0.1.0.dev0/agilerl/arena/payloads.py +69 -0
- agilerl_arena-0.1.0.dev0/agilerl/arena/stream.py +364 -0
- agilerl_arena-0.1.0.dev0/agilerl/arena/utils.py +152 -0
- agilerl_arena-0.1.0.dev0/agilerl_arena.egg-info/PKG-INFO +113 -0
- agilerl_arena-0.1.0.dev0/agilerl_arena.egg-info/SOURCES.txt +68 -0
- agilerl_arena-0.1.0.dev0/agilerl_arena.egg-info/dependency_links.txt +1 -0
- agilerl_arena-0.1.0.dev0/agilerl_arena.egg-info/entry_points.txt +2 -0
- agilerl_arena-0.1.0.dev0/agilerl_arena.egg-info/requires.txt +9 -0
- agilerl_arena-0.1.0.dev0/agilerl_arena.egg-info/top_level.txt +1 -0
- agilerl_arena-0.1.0.dev0/pyproject.toml +31 -0
- agilerl_arena-0.1.0.dev0/setup.cfg +4 -0
- agilerl_arena-0.1.0.dev0/tests/test_arena_models.py +268 -0
- agilerl_arena-0.1.0.dev0/tests/test_arena_networks.py +189 -0
- agilerl_arena-0.1.0.dev0/tests/test_arena_utils.py +200 -0
- agilerl_arena-0.1.0.dev0/tests/test_auth.py +503 -0
- agilerl_arena-0.1.0.dev0/tests/test_cli.py +1105 -0
- agilerl_arena-0.1.0.dev0/tests/test_cli_manifest.py +554 -0
- agilerl_arena-0.1.0.dev0/tests/test_client.py +1801 -0
- agilerl_arena-0.1.0.dev0/tests/test_datasets.py +257 -0
- agilerl_arena-0.1.0.dev0/tests/test_exceptions.py +482 -0
- agilerl_arena-0.1.0.dev0/tests/test_inference.py +997 -0
- agilerl_arena-0.1.0.dev0/tests/test_output.py +429 -0
- agilerl_arena-0.1.0.dev0/tests/test_payloads.py +123 -0
- 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
|