arcade-core 3.4.0__py3-none-any.whl → 4.0.0__py3-none-any.whl

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.
@@ -0,0 +1,110 @@
1
+ """
2
+ Shared access-token utilities used by both the CLI and MCP server.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from datetime import datetime, timedelta
8
+
9
+ import httpx
10
+ from pydantic import BaseModel
11
+
12
+ from arcade_core.config_model import Config
13
+ from arcade_core.constants import PROD_COORDINATOR_HOST
14
+
15
+
16
+ class CLIConfig(BaseModel):
17
+ """OAuth configuration returned by the Coordinator."""
18
+
19
+ client_id: str
20
+ authorization_endpoint: str
21
+ token_endpoint: str
22
+
23
+
24
+ class TokenResponse(BaseModel):
25
+ """OAuth token response."""
26
+
27
+ access_token: str
28
+ refresh_token: str
29
+ expires_in: int
30
+ token_type: str
31
+
32
+
33
+ def fetch_cli_config(coordinator_url: str) -> CLIConfig:
34
+ """Fetch OAuth configuration from the Coordinator."""
35
+ url = f"{coordinator_url}/api/v1/auth/cli_config"
36
+ response = httpx.get(url, timeout=30)
37
+ response.raise_for_status()
38
+ return CLIConfig.model_validate(response.json())
39
+
40
+
41
+ def refresh_access_token(
42
+ cli_config: CLIConfig,
43
+ refresh_token: str,
44
+ ) -> TokenResponse:
45
+ """Refresh the access token using authlib-compatible token endpoint."""
46
+ response = httpx.post(
47
+ cli_config.token_endpoint,
48
+ data={
49
+ "grant_type": "refresh_token",
50
+ "refresh_token": refresh_token,
51
+ "client_id": cli_config.client_id,
52
+ },
53
+ timeout=30,
54
+ )
55
+ response.raise_for_status()
56
+ token = response.json()
57
+ return TokenResponse(
58
+ access_token=token["access_token"],
59
+ refresh_token=token.get("refresh_token", refresh_token),
60
+ expires_in=token["expires_in"],
61
+ token_type=token["token_type"],
62
+ )
63
+
64
+
65
+ def get_valid_access_token(coordinator_url: str | None = None) -> str:
66
+ """
67
+ Get a valid access token, refreshing if necessary.
68
+
69
+ Returns:
70
+ Valid access token
71
+
72
+ Raises:
73
+ ValueError: If not logged in or token refresh fails
74
+ """
75
+ try:
76
+ config = Config.load_from_file()
77
+ except FileNotFoundError:
78
+ raise ValueError("Not logged in. Please run 'arcade login' first.")
79
+
80
+ resolved_coordinator_url = (
81
+ coordinator_url
82
+ or (config.coordinator_url if config.coordinator_url else None)
83
+ or f"https://{PROD_COORDINATOR_HOST}"
84
+ )
85
+
86
+ if not config.auth:
87
+ raise ValueError("Not logged in. Please run 'arcade login' first.")
88
+
89
+ # Check if token needs refresh
90
+ if config.is_token_expired():
91
+ cli_config = fetch_cli_config(resolved_coordinator_url)
92
+
93
+ try:
94
+ new_tokens = refresh_access_token(cli_config, config.auth.refresh_token)
95
+ except httpx.HTTPError as e:
96
+ raise ValueError(
97
+ f"Failed to refresh token: {e}. Please run 'arcade login' to re-authenticate."
98
+ )
99
+
100
+ # Update stored credentials
101
+ expires_at = datetime.now() + timedelta(seconds=new_tokens.expires_in)
102
+ config.coordinator_url = resolved_coordinator_url
103
+ config.auth.access_token = new_tokens.access_token
104
+ config.auth.refresh_token = new_tokens.refresh_token
105
+ config.auth.expires_at = expires_at
106
+ config.save_to_file()
107
+
108
+ return new_tokens.access_token
109
+
110
+ return config.auth.access_token
@@ -1,4 +1,5 @@
1
1
  import os
2
+ from datetime import datetime, timedelta
2
3
  from pathlib import Path
3
4
  from typing import Any
4
5
 
@@ -10,18 +11,22 @@ class BaseConfig(BaseModel):
10
11
  model_config = ConfigDict(extra="ignore")
11
12
 
12
13
 
13
- class ApiConfig(BaseConfig):
14
+ class AuthConfig(BaseConfig):
14
15
  """
15
- Arcade API configuration.
16
+ OAuth authentication configuration.
16
17
  """
17
18
 
18
- key: str
19
+ access_token: str
19
20
  """
20
- Arcade API key.
21
+ OAuth access token (JWT).
21
22
  """
22
- version: str = "v1"
23
+ refresh_token: str
23
24
  """
24
- Arcade API version.
25
+ OAuth refresh token for obtaining new access tokens.
26
+ """
27
+ expires_at: datetime
28
+ """
29
+ When the access token expires.
25
30
  """
26
31
 
27
32
 
@@ -36,15 +41,51 @@ class UserConfig(BaseConfig):
36
41
  """
37
42
 
38
43
 
44
+ class ContextConfig(BaseConfig):
45
+ """
46
+ Active organization and project context.
47
+ """
48
+
49
+ org_id: str
50
+ """
51
+ Active organization ID.
52
+ """
53
+ org_name: str
54
+ """
55
+ Active organization name.
56
+ """
57
+ project_id: str
58
+ """
59
+ Active project ID.
60
+ """
61
+ project_name: str
62
+ """
63
+ Active project name.
64
+ """
65
+
66
+
39
67
  class Config(BaseConfig):
40
68
  """
41
- Configuration for Arcade.
69
+ Configuration for Arcade CLI.
70
+ """
71
+
72
+ coordinator_url: str | None = None
73
+ """
74
+ Base URL of the Arcade Coordinator used for authentication flows.
75
+ """
76
+
77
+ auth: AuthConfig | None = None
78
+ """
79
+ OAuth authentication configuration.
42
80
  """
43
81
 
44
- api: ApiConfig
82
+ # Active org/project context
83
+ context: ContextConfig | None = None
45
84
  """
46
- Arcade API configuration.
85
+ Active organization and project context.
47
86
  """
87
+
88
+ # User info
48
89
  user: UserConfig | None = None
49
90
  """
50
91
  Arcade user configuration.
@@ -53,6 +94,48 @@ class Config(BaseConfig):
53
94
  def __init__(self, **data: Any):
54
95
  super().__init__(**data)
55
96
 
97
+ def is_authenticated(self) -> bool:
98
+ """
99
+ Check if the user is authenticated (has valid auth config).
100
+ """
101
+ return self.auth is not None
102
+
103
+ def is_token_expired(self) -> bool:
104
+ """
105
+ Check if the access token is expired or will expire within 5 minutes.
106
+ """
107
+ if not self.auth:
108
+ return True
109
+ # Consider expired if less than 5 minutes remaining
110
+ buffer_seconds = 300
111
+ return datetime.now() >= self.auth.expires_at.replace(tzinfo=None) - timedelta(
112
+ seconds=buffer_seconds
113
+ )
114
+
115
+ def get_access_token(self) -> str | None:
116
+ """
117
+ Get the current access token if available.
118
+ """
119
+ if self.auth:
120
+ return self.auth.access_token
121
+ return None
122
+
123
+ def get_active_org_id(self) -> str | None:
124
+ """
125
+ Get the active organization ID.
126
+ """
127
+ if self.context:
128
+ return self.context.org_id
129
+ return None
130
+
131
+ def get_active_project_id(self) -> str | None:
132
+ """
133
+ Get the active project ID.
134
+ """
135
+ if self.context:
136
+ return self.context.project_id
137
+ return None
138
+
56
139
  @classmethod
57
140
  def get_config_dir_path(cls) -> Path:
58
141
  """
@@ -82,16 +165,11 @@ class Config(BaseConfig):
82
165
  """
83
166
  Load the configuration from the YAML file in the configuration directory.
84
167
 
85
- If no configuration file exists, this method will create a new one with default values.
86
- The default configuration includes:
87
- - An empty API configuration
88
- - A default Engine configuration (host: "api.arcade.dev", port: None, tls: True)
89
- - No user configuration
90
-
91
168
  Returns:
92
- Config: The loaded or newly created configuration.
169
+ Config: The loaded configuration.
93
170
 
94
171
  Raises:
172
+ FileNotFoundError: If no configuration file exists.
95
173
  ValueError: If the existing configuration file is invalid.
96
174
  """
97
175
  cls.ensure_config_dir_exists()
@@ -108,13 +186,13 @@ class Config(BaseConfig):
108
186
 
109
187
  if config_data is None:
110
188
  raise ValueError(
111
- "Invalid credentials.yaml file. Please ensure it is a valid YAML file or"
189
+ "Invalid credentials.yaml file. Please ensure it is a valid YAML file or "
112
190
  "run `arcade logout`, then `arcade login` to start from a clean slate."
113
191
  )
114
192
 
115
193
  if "cloud" not in config_data:
116
194
  raise ValueError(
117
- "Invalid credentials.yaml file. Expected a 'cloud' key."
195
+ "Invalid credentials.yaml file. Expected a 'cloud' key. "
118
196
  "Run `arcade logout`, then `arcade login` to start from a clean slate."
119
197
  )
120
198
 
@@ -123,7 +201,6 @@ class Config(BaseConfig):
123
201
  except ValidationError as e:
124
202
  # Get only the errors with {type:missing} and combine them
125
203
  # into a nicely-formatted string message.
126
- # Any other errors without {type:missing} should just be str()ed
127
204
  missing_field_errors = [
128
205
  ".".join(map(str, error["loc"]))
129
206
  for error in e.errors()
@@ -145,7 +222,15 @@ class Config(BaseConfig):
145
222
  def save_to_file(self) -> None:
146
223
  """
147
224
  Save the configuration to the YAML file in the configuration directory.
225
+
226
+ Sets file permissions to 600 (owner read/write only) for security.
148
227
  """
149
228
  Config.ensure_config_dir_exists()
150
229
  config_file_path = Config.get_config_file_path()
151
- config_file_path.write_text(yaml.dump(self.model_dump()))
230
+
231
+ # Convert to dict, excluding None values for cleaner output
232
+ data = {"cloud": self.model_dump(exclude_none=True, mode="json")}
233
+ config_file_path.write_text(yaml.dump(data, default_flow_style=False))
234
+
235
+ # Set restrictive permissions (owner read/write only)
236
+ config_file_path.chmod(0o600)
arcade_core/constants.py CHANGED
@@ -4,3 +4,8 @@ import os
4
4
  ARCADE_CONFIG_PATH = os.path.join(os.path.expanduser(os.getenv("ARCADE_WORK_DIR", "~")), ".arcade")
5
5
  # The path to the file containing the user's Arcade-related credentials (e.g., ARCADE_API_KEY).
6
6
  CREDENTIALS_FILE_PATH = os.path.join(ARCADE_CONFIG_PATH, "credentials.yaml")
7
+
8
+ # Host defaults used by both the CLI and MCP server
9
+ PROD_COORDINATOR_HOST = "cloud.arcade.dev"
10
+ PROD_ENGINE_HOST = "api.arcade.dev"
11
+ LOCALHOST = "localhost"
@@ -0,0 +1 @@
1
+ # Network utilities shared across Arcade components.
@@ -0,0 +1,99 @@
1
+ """
2
+ Shared HTTP transport helpers for org-scoped Arcade API access.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import httpx
8
+
9
+
10
+ def _rewrite_request_path(request: httpx.Request, org_id: str, project_id: str) -> httpx.Request:
11
+ """Return a request with its path rewritten to include org/project scope."""
12
+ path = request.url.path
13
+ if path.startswith("/v1/") and "/v1/orgs/" not in path:
14
+ scoped_path = path.replace("/v1/", f"/v1/orgs/{org_id}/projects/{project_id}/", 1)
15
+ scoped_url = request.url.copy_with(path=scoped_path)
16
+ return httpx.Request(
17
+ method=request.method,
18
+ url=scoped_url,
19
+ headers=request.headers,
20
+ content=request.content,
21
+ extensions=request.extensions,
22
+ )
23
+ return request
24
+
25
+
26
+ class OrgScopedTransport(httpx.BaseTransport):
27
+ """Sync transport that rewrites requests to include org/project scope."""
28
+
29
+ def __init__(
30
+ self,
31
+ wrapped_transport: httpx.BaseTransport,
32
+ org_id: str,
33
+ project_id: str,
34
+ ) -> None:
35
+ self.wrapped = wrapped_transport
36
+ self.org_id = org_id
37
+ self.project_id = project_id
38
+
39
+ def handle_request(self, request: httpx.Request) -> httpx.Response:
40
+ scoped_request = _rewrite_request_path(request, self.org_id, self.project_id)
41
+ return self.wrapped.handle_request(scoped_request)
42
+
43
+ def close(self) -> None:
44
+ self.wrapped.close()
45
+
46
+
47
+ class AsyncOrgScopedTransport(httpx.AsyncBaseTransport):
48
+ """Async transport that rewrites requests to include org/project scope."""
49
+
50
+ def __init__(
51
+ self,
52
+ wrapped_transport: httpx.AsyncBaseTransport,
53
+ org_id: str,
54
+ project_id: str,
55
+ ) -> None:
56
+ self.wrapped = wrapped_transport
57
+ self.org_id = org_id
58
+ self.project_id = project_id
59
+
60
+ async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
61
+ scoped_request = _rewrite_request_path(request, self.org_id, self.project_id)
62
+ return await self.wrapped.handle_async_request(scoped_request)
63
+
64
+ async def aclose(self) -> None:
65
+ await self.wrapped.aclose()
66
+
67
+
68
+ def build_org_scoped_http_client(
69
+ org_id: str,
70
+ project_id: str,
71
+ *,
72
+ base_transport: httpx.BaseTransport | None = None,
73
+ client_kwargs: dict | None = None,
74
+ ) -> httpx.Client:
75
+ """
76
+ Build a sync httpx.Client that rewrites /v1 requests with org/project scope.
77
+ """
78
+ client_kwargs = client_kwargs or {}
79
+ transport = OrgScopedTransport(
80
+ base_transport or httpx.HTTPTransport(), org_id=org_id, project_id=project_id
81
+ )
82
+ return httpx.Client(transport=transport, **client_kwargs)
83
+
84
+
85
+ def build_org_scoped_async_http_client(
86
+ org_id: str,
87
+ project_id: str,
88
+ *,
89
+ base_transport: httpx.AsyncBaseTransport | None = None,
90
+ client_kwargs: dict | None = None,
91
+ ) -> httpx.AsyncClient:
92
+ """
93
+ Build an async httpx.AsyncClient that rewrites /v1 requests with org/project scope.
94
+ """
95
+ client_kwargs = client_kwargs or {}
96
+ transport = AsyncOrgScopedTransport(
97
+ base_transport or httpx.AsyncHTTPTransport(), org_id=org_id, project_id=project_id
98
+ )
99
+ return httpx.AsyncClient(transport=transport, **client_kwargs)
@@ -124,34 +124,68 @@ class UsageIdentity:
124
124
  return str(data[KEY_ANON_ID])
125
125
 
126
126
  def get_principal_id(self) -> str | None:
127
- """Fetch principal_id from Arcade Cloud API.
127
+ """Fetch principal_id (account_id) from Arcade Cloud API.
128
+
129
+ Prefers any linked principal already stored in usage.json. If not
130
+ found, attempts to fetch via OAuth access token and falls
131
+ back to legacy API key validation if present.
128
132
 
129
133
  Returns:
130
134
  str | None: Principal ID if authenticated and API call succeeds, None otherwise
131
135
  """
136
+ # Prefer already-linked principal_id in usage.json
137
+ data = self.load_or_create()
138
+ linked_principal_id = data.get(KEY_LINKED_PRINCIPAL_ID)
139
+ if linked_principal_id:
140
+ return str(linked_principal_id)
141
+
132
142
  if not os.path.exists(CREDENTIALS_FILE_PATH):
133
143
  return None
134
144
 
135
145
  try:
136
146
  with open(CREDENTIALS_FILE_PATH) as f:
137
- config = yaml.safe_load(f)
138
-
139
- cloud_config = config.get("cloud", {})
140
- api_key = cloud_config.get("api", {}).get("key")
141
-
142
- if not api_key:
143
- return None
144
-
145
- response = httpx.get(
146
- "https://cloud.arcade.dev/api/v1/auth/validate",
147
- headers={"accept": "application/json", "Authorization": f"Bearer {api_key}"},
148
- timeout=TIMEOUT_ARCADE_API,
147
+ config = yaml.safe_load(f) or {}
148
+
149
+ cloud_config = config.get("cloud", {}) if isinstance(config, dict) else {}
150
+
151
+ # Determine coordinator/authority URL for auth calls
152
+ coordinator_url = cloud_config.get("coordinator_url") or "https://cloud.arcade.dev"
153
+ whoami_url = f"{coordinator_url}/api/v1/auth/whoami"
154
+ validate_url = f"{coordinator_url}/api/v1/auth/validate"
155
+
156
+ # OAuth credentials: use access_token to call /whoami
157
+ auth_config = cloud_config.get("auth", {}) if isinstance(cloud_config, dict) else {}
158
+ access_token = auth_config.get("access_token")
159
+ if access_token:
160
+ response = httpx.get(
161
+ whoami_url,
162
+ headers={
163
+ "accept": "application/json",
164
+ "Authorization": f"Bearer {access_token}",
165
+ },
166
+ timeout=TIMEOUT_ARCADE_API,
167
+ )
168
+ if response.status_code == 200:
169
+ data = response.json().get("data", {})
170
+ principal_id = data.get("account_id") or data.get("principal_id")
171
+ if principal_id:
172
+ return str(principal_id)
173
+
174
+ # Legacy API key credentials (deprecated)
175
+ api_key = (
176
+ cloud_config.get("api", {}).get("key") if isinstance(cloud_config, dict) else None
149
177
  )
150
-
151
- if response.status_code == 200:
152
- data = response.json()
153
- principal_id = data.get("data", {}).get("principal_id")
154
- return str(principal_id) if principal_id else None
178
+ if api_key:
179
+ response = httpx.get(
180
+ validate_url,
181
+ headers={"accept": "application/json", "Authorization": f"Bearer {api_key}"},
182
+ timeout=TIMEOUT_ARCADE_API,
183
+ )
184
+ if response.status_code == 200:
185
+ data = response.json()
186
+ principal_id = data.get("data", {}).get("principal_id")
187
+ if principal_id:
188
+ return str(principal_id)
155
189
 
156
190
  except Exception: # noqa: S110
157
191
  # Silent failure - don't disrupt CLI
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: arcade-core
3
- Version: 3.4.0
3
+ Version: 4.0.0
4
4
  Summary: Arcade Core - Core library for Arcade platform
5
5
  Author-email: Arcade <dev@arcade.dev>
6
6
  License: MIT
@@ -13,6 +13,7 @@ Classifier: Programming Language :: Python :: 3.11
13
13
  Classifier: Programming Language :: Python :: 3.12
14
14
  Classifier: Programming Language :: Python :: 3.13
15
15
  Requires-Python: >=3.10
16
+ Requires-Dist: httpx>=0.27.0
16
17
  Requires-Dist: loguru>=0.7.0
17
18
  Requires-Dist: packaging>=24.1
18
19
  Requires-Dist: pydantic>=2.7.0
@@ -1,10 +1,11 @@
1
1
  arcade_core/__init__.py,sha256=1heu3AROAjpistehPzY2H-2nkj_IjQEh-vVlVOCRF1E,88
2
2
  arcade_core/annotations.py,sha256=Nst6aejLWXlpTu7GwzWETu1gQCG1XVAUR_qcFbNvyRc,198
3
3
  arcade_core/auth.py,sha256=flz3UNso5VXiKvrwygapiyQdBmI1Ca6JzFOO2caUSQQ,6145
4
+ arcade_core/auth_tokens.py,sha256=4VAd634lgGigNHezoy2dBpLztliG4Qd2GH8ZBdZVDNo,3169
4
5
  arcade_core/catalog.py,sha256=AA13L-ZG4O0S5rB1Fjc-wpLaa6im-dwIBx7GsE2ZW80,43527
5
6
  arcade_core/config.py,sha256=e98XQAkYySGW9T_yrJg54BB8Wuq06GPVHp7xqe2d1vU,572
6
- arcade_core/config_model.py,sha256=78BR6Ch9BDuG4ddWGfpuEKqWcb1fyOF6kxiF4qLFogM,4481
7
- arcade_core/constants.py,sha256=wakdklI7TyJ0agq9n-Cmb2lbVa95D0oUaMGm30eiv9Y,375
7
+ arcade_core/config_model.py,sha256=EA6XQWj-oJu_e4j0kuVT1K5fbwRvwv-lUYMrctcW2Ws,6504
8
+ arcade_core/constants.py,sha256=E3s22aGDhYDM_vkxdo7PZf8F3pznqM-NqQQzgIjhhD8,531
8
9
  arcade_core/context.py,sha256=J2MgbVznhJC2qarHq3dTL72W4NGYOM1pjXdI_YwgkA4,3316
9
10
  arcade_core/discovery.py,sha256=PluKGhNtJ7RYjJuPDMB8LCNinQLKzlqoAtc3dwKb6IA,8397
10
11
  arcade_core/errors.py,sha256=fsi7m6TQQSsdSNHl4rBoSN_YH3ZV910gjvBFqB207f4,13326
@@ -17,12 +18,14 @@ arcade_core/toolkit.py,sha256=lLlOL6fA6Lmo-dtLTMMcPCzKDf9YQObwxG1LdVADv3E,14431
17
18
  arcade_core/utils.py,sha256=_3bM-yfIDFmMVqt-NFYp2Lx1QcNWp7xytGjUQzPs2LY,3255
18
19
  arcade_core/version.py,sha256=CpXi3jGlx23RvRyU7iytOMZrnspdWw4yofS8lpP1AJU,18
19
20
  arcade_core/converters/openai.py,sha256=4efdgTkvdwT44VGStBhdUmzCnoP5dysceIqPVVPG-vk,7408
21
+ arcade_core/network/__init__.py,sha256=8GEcg6QAr63U8U1n4bnf7cL-F7FrUrc9otHM2J9ODwk,53
22
+ arcade_core/network/org_transport.py,sha256=DZdIYdldtYZ6gtmWA1C8dXmsNuOv7tePhlpBuAvkhRQ,3222
20
23
  arcade_core/usage/__init__.py,sha256=SUR5mqF-bjdbl-P-OOHN6OFAjXZu4agXyPhr7xdVXCw,234
21
24
  arcade_core/usage/__main__.py,sha256=rSJkE1G9hlV3HRRA6EJE5Lmy3wKyan7rAxBXHX9A1cI,1577
22
25
  arcade_core/usage/constants.py,sha256=1FQIhkFFMZUhU-H4A7GvMb7KQ3qLFrNAZb2-LEvSF3k,1052
23
- arcade_core/usage/identity.py,sha256=2dP1iusI9pE_GrPlz3VXEdz51R5JlNo9_-OXbe6vn7I,6716
26
+ arcade_core/usage/identity.py,sha256=egclRR26jGP1vVvoOoaaZdcS4AtSTZ8fLHpBq1HRgHw,8452
24
27
  arcade_core/usage/usage_service.py,sha256=xzWWSEktm58liiNYugBHRactSru8V5foriHcsoH0j1A,3407
25
28
  arcade_core/usage/utils.py,sha256=FqBOmlhwT68cbnpI5Vx9ZW6vLRYPVg4FJ0GaMEp8qEM,398
26
- arcade_core-3.4.0.dist-info/METADATA,sha256=ia0b5S1wwMu5z50qJwsj7wRVRhQOcDIqLTHFlGBf-co,2383
27
- arcade_core-3.4.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
28
- arcade_core-3.4.0.dist-info/RECORD,,
29
+ arcade_core-4.0.0.dist-info/METADATA,sha256=6JLUpZN5EFPFt6LqXK3dHYMo7xgrWr_8wh_M7ahWBq0,2412
30
+ arcade_core-4.0.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
31
+ arcade_core-4.0.0.dist-info/RECORD,,