plotly-cloud 0.1.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.
- plotly_cloud/__init__.py +3 -0
- plotly_cloud/_api_types.py +37 -0
- plotly_cloud/_changes.py +77 -0
- plotly_cloud/_cloud_env.py +93 -0
- plotly_cloud/_commands.py +880 -0
- plotly_cloud/_definitions.py +109 -0
- plotly_cloud/_deploy.py +470 -0
- plotly_cloud/_devtool_hooks.py +61 -0
- plotly_cloud/_devtool_publish_rpc.py +294 -0
- plotly_cloud/_oauth.py +335 -0
- plotly_cloud/_parser.py +171 -0
- plotly_cloud/cli.py +300 -0
- plotly_cloud/cloud-env.toml +6 -0
- plotly_cloud/cloud_devtools.css +1 -0
- plotly_cloud/cloud_devtools.js +15 -0
- plotly_cloud/exceptions.py +198 -0
- plotly_cloud-0.1.0.dist-info/METADATA +294 -0
- plotly_cloud-0.1.0.dist-info/RECORD +21 -0
- plotly_cloud-0.1.0.dist-info/WHEEL +4 -0
- plotly_cloud-0.1.0.dist-info/entry_points.txt +5 -0
- plotly_cloud-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"""RPC interface for Plotly Cloud publishing in dev tools."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import importlib
|
|
5
|
+
import os
|
|
6
|
+
import tempfile
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import dash
|
|
10
|
+
from typing_extensions import Literal, NotRequired, TypedDict
|
|
11
|
+
|
|
12
|
+
from plotly_cloud._cloud_env import cloud_config
|
|
13
|
+
from plotly_cloud._definitions import AppDeploymentConfig
|
|
14
|
+
from plotly_cloud._deploy import (
|
|
15
|
+
MAX_ZIP_SIZE,
|
|
16
|
+
DeploymentClient,
|
|
17
|
+
create_deployment_zip,
|
|
18
|
+
format_app_url,
|
|
19
|
+
get_config_path,
|
|
20
|
+
load_deployment_config,
|
|
21
|
+
parse_gitignore,
|
|
22
|
+
save_deployment_config,
|
|
23
|
+
should_exclude_path,
|
|
24
|
+
)
|
|
25
|
+
from plotly_cloud._oauth import OAuthClient
|
|
26
|
+
from plotly_cloud.exceptions import TokenError
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class PublishOperations:
|
|
30
|
+
check_auth = "initialize"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class PublishOperation(TypedDict):
|
|
34
|
+
"""RPC operation structure for dev tools publishing."""
|
|
35
|
+
|
|
36
|
+
operation: Literal["initialize", "authenticate", "auth_poll", "publish", "status"]
|
|
37
|
+
data: Any
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class RPCResponse(TypedDict):
|
|
41
|
+
"""Standard RPC response structure."""
|
|
42
|
+
|
|
43
|
+
result: NotRequired[Any]
|
|
44
|
+
error: NotRequired[str]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class PlotlyCloudPublishRPC:
|
|
48
|
+
"""RPC handler for Plotly Cloud publishing operations in dev tools."""
|
|
49
|
+
|
|
50
|
+
def __init__(self) -> None:
|
|
51
|
+
"""Initialize the RPC handler."""
|
|
52
|
+
client_id = cloud_config.get_oauth_client_id()
|
|
53
|
+
self.oauth_client = OAuthClient(client_id)
|
|
54
|
+
self._app_setup = None # Fallback incase get_app fails.
|
|
55
|
+
|
|
56
|
+
def get_project_path(self):
|
|
57
|
+
app = None
|
|
58
|
+
try:
|
|
59
|
+
app = dash.det_app() # type: ignore
|
|
60
|
+
except Exception:
|
|
61
|
+
app = self._app_setup
|
|
62
|
+
|
|
63
|
+
assert app
|
|
64
|
+
app_module = importlib.import_module(app.config.name)
|
|
65
|
+
return os.path.dirname(str(app_module.__file__))
|
|
66
|
+
|
|
67
|
+
def check_directory_size(self, project_path: str) -> tuple[int, bool, str]:
|
|
68
|
+
"""Check if directory size would exceed limit.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
project_path: Path to the project directory
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
Tuple of (size_in_bytes, exceeds_limit, error_message)
|
|
75
|
+
"""
|
|
76
|
+
exclude_patterns = parse_gitignore(project_path)
|
|
77
|
+
total_size = 0
|
|
78
|
+
|
|
79
|
+
for root, dirs, files in os.walk(project_path):
|
|
80
|
+
# Remove excluded directories from dirs list
|
|
81
|
+
dirs[:] = [d for d in dirs if not should_exclude_path(os.path.join(root, d), exclude_patterns)]
|
|
82
|
+
|
|
83
|
+
for file in files:
|
|
84
|
+
file_path = os.path.join(root, file)
|
|
85
|
+
relative_path = os.path.relpath(file_path, project_path)
|
|
86
|
+
|
|
87
|
+
# Ensure relative_path is a string
|
|
88
|
+
if isinstance(relative_path, bytes):
|
|
89
|
+
relative_path = relative_path.decode("utf-8")
|
|
90
|
+
|
|
91
|
+
# Skip if file should be excluded
|
|
92
|
+
if should_exclude_path(relative_path, exclude_patterns):
|
|
93
|
+
continue
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
total_size += os.path.getsize(file_path)
|
|
97
|
+
# Early exit if we exceed the limit - no need to count further
|
|
98
|
+
if total_size > MAX_ZIP_SIZE:
|
|
99
|
+
max_size_mb = MAX_ZIP_SIZE / (1024 * 1024)
|
|
100
|
+
error_msg = (
|
|
101
|
+
f"This directory is greater than {max_size_mb:.0f}MB and cannot be published. "
|
|
102
|
+
"Consider excluding large files in your .gitignore."
|
|
103
|
+
)
|
|
104
|
+
return (total_size, True, error_msg)
|
|
105
|
+
except (OSError, PermissionError):
|
|
106
|
+
continue
|
|
107
|
+
|
|
108
|
+
# All files checked, under the limit
|
|
109
|
+
return (total_size, False, "")
|
|
110
|
+
|
|
111
|
+
def resolve_entrypoint_module(self) -> str:
|
|
112
|
+
"""Resolve the entrypoint module for the current Dash app.
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
Entrypoint module name (e.g., 'app' or 'src.app')
|
|
116
|
+
"""
|
|
117
|
+
app = None
|
|
118
|
+
try:
|
|
119
|
+
app = dash.det_app() # type: ignore
|
|
120
|
+
except Exception:
|
|
121
|
+
app = self._app_setup
|
|
122
|
+
|
|
123
|
+
if not app:
|
|
124
|
+
return "app" # Default fallback
|
|
125
|
+
|
|
126
|
+
try:
|
|
127
|
+
# Get the module name and import it
|
|
128
|
+
module_name = app.config.name
|
|
129
|
+
app_module = importlib.import_module(module_name)
|
|
130
|
+
|
|
131
|
+
# Get the absolute path of the module file
|
|
132
|
+
module_file = str(app_module.__file__)
|
|
133
|
+
|
|
134
|
+
# Get the project path
|
|
135
|
+
project_path = self.get_project_path()
|
|
136
|
+
|
|
137
|
+
# Make it relative to the project path
|
|
138
|
+
rel_path = os.path.relpath(module_file, project_path)
|
|
139
|
+
|
|
140
|
+
# Remove .py extension and convert path separators to dots
|
|
141
|
+
entrypoint_module = str(rel_path).replace(".py", "").replace(os.sep, ".")
|
|
142
|
+
|
|
143
|
+
return entrypoint_module
|
|
144
|
+
except Exception:
|
|
145
|
+
return "app" # Fallback
|
|
146
|
+
|
|
147
|
+
async def handle_operation(self, publish_operation: PublishOperation) -> RPCResponse:
|
|
148
|
+
"""Handle a publish operation from dev tools.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
publish_operation: The operation to perform with its data
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
RPCResponse with data and optional error
|
|
155
|
+
|
|
156
|
+
Raises:
|
|
157
|
+
ValueError: If operation is not supported
|
|
158
|
+
"""
|
|
159
|
+
operation_name = publish_operation["operation"]
|
|
160
|
+
data = publish_operation.get("data")
|
|
161
|
+
|
|
162
|
+
# Get the method by operation name (direct match)
|
|
163
|
+
if not hasattr(self, operation_name):
|
|
164
|
+
raise ValueError(f"Unsupported operation: {operation_name}")
|
|
165
|
+
|
|
166
|
+
try:
|
|
167
|
+
method = getattr(self, operation_name)
|
|
168
|
+
return await method(data)
|
|
169
|
+
except Exception as e:
|
|
170
|
+
return {"error": str(e)}
|
|
171
|
+
|
|
172
|
+
async def initialize(self, data: Any) -> RPCResponse:
|
|
173
|
+
is_authenticated = await self.oauth_client.is_authenticated()
|
|
174
|
+
|
|
175
|
+
try:
|
|
176
|
+
# Try to refresh the access token so it's still valid
|
|
177
|
+
await self.oauth_client.refresh_access_token()
|
|
178
|
+
except TokenError:
|
|
179
|
+
is_authenticated = False
|
|
180
|
+
self.oauth_client.clear_credentials()
|
|
181
|
+
|
|
182
|
+
project_path = self.get_project_path()
|
|
183
|
+
|
|
184
|
+
config_path = get_config_path(project_path, os.path.join(project_path, "plotly-cloud.toml"))
|
|
185
|
+
config = load_deployment_config(config_path)
|
|
186
|
+
|
|
187
|
+
app_id = config.get("app_id")
|
|
188
|
+
existing = app_id is not None
|
|
189
|
+
status = "new"
|
|
190
|
+
app_name = config.get("name", os.path.basename(project_path))
|
|
191
|
+
app_url = ""
|
|
192
|
+
|
|
193
|
+
if app_id is not None and is_authenticated:
|
|
194
|
+
async with DeploymentClient(self.oauth_client) as deploy_client:
|
|
195
|
+
status_data = await deploy_client.get_app_status(app_id)
|
|
196
|
+
status = status_data.get("status", "")
|
|
197
|
+
app_url = format_app_url(status_data.get("app_url", ""))
|
|
198
|
+
|
|
199
|
+
# Check directory size upfront
|
|
200
|
+
_, exceeds_limit, size_error = self.check_directory_size(project_path)
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
"result": {
|
|
204
|
+
"authenticated": is_authenticated,
|
|
205
|
+
"existing": existing,
|
|
206
|
+
"status": status,
|
|
207
|
+
"app_name": app_name,
|
|
208
|
+
"app_path": project_path,
|
|
209
|
+
"app_id": app_id,
|
|
210
|
+
"app_url": app_url,
|
|
211
|
+
"size_error": size_error if exceeds_limit else None,
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async def authenticate(self, data: Any) -> RPCResponse:
|
|
216
|
+
device_auth = await self.oauth_client.request_device_authorization()
|
|
217
|
+
return {"result": device_auth}
|
|
218
|
+
|
|
219
|
+
async def auth_poll(self, data: Any) -> RPCResponse:
|
|
220
|
+
device_code = data.get("device_code")
|
|
221
|
+
status_code, response = await self.oauth_client.check_authentication_status(device_code)
|
|
222
|
+
if status_code == 200:
|
|
223
|
+
await self.oauth_client._save_credentials(dict(response))
|
|
224
|
+
return {"result": {"success": True}}
|
|
225
|
+
else:
|
|
226
|
+
error = response.get("error", "unknown_error")
|
|
227
|
+
if error == "authorization_pending":
|
|
228
|
+
return {"result": {}}
|
|
229
|
+
elif error == "slow_down":
|
|
230
|
+
delay = 1 + data.get("delayed", 0)
|
|
231
|
+
return {"result": {"delay": delay}}
|
|
232
|
+
elif error == "expired_token":
|
|
233
|
+
return {"result": {"try_again": True}}
|
|
234
|
+
elif error == "access_denied":
|
|
235
|
+
return {"error": "Access denied by user"}
|
|
236
|
+
else:
|
|
237
|
+
return {"error": "Authentication Failed"}
|
|
238
|
+
|
|
239
|
+
async def status(self, data: Any) -> RPCResponse:
|
|
240
|
+
app_id = data.get("app_id")
|
|
241
|
+
async with DeploymentClient(self.oauth_client) as deploy_client:
|
|
242
|
+
status_data = await deploy_client.get_app_status(app_id)
|
|
243
|
+
app_url = format_app_url(status_data.get("app_url", ""))
|
|
244
|
+
return {"result": {"status": status_data.get("status", ""), "app_url": app_url}}
|
|
245
|
+
|
|
246
|
+
async def publish(self, data: Any) -> RPCResponse:
|
|
247
|
+
app_path = data.get("app_path")
|
|
248
|
+
app_id = data.get("app_id")
|
|
249
|
+
app_name = data.get("app_name")
|
|
250
|
+
|
|
251
|
+
config_path = get_config_path(app_path)
|
|
252
|
+
|
|
253
|
+
temp_file = tempfile.NamedTemporaryFile(suffix=".zip", delete=False)
|
|
254
|
+
zip_path = temp_file.name
|
|
255
|
+
temp_file.close()
|
|
256
|
+
|
|
257
|
+
await create_deployment_zip(app_path, zip_path)
|
|
258
|
+
|
|
259
|
+
# Resolve the entrypoint module
|
|
260
|
+
entrypoint_module = self.resolve_entrypoint_module()
|
|
261
|
+
|
|
262
|
+
async with DeploymentClient(self.oauth_client) as deploy_client:
|
|
263
|
+
if app_id:
|
|
264
|
+
# update app
|
|
265
|
+
app_data = await deploy_client.publish_app(app_id, zip_path, entrypoint_module)
|
|
266
|
+
config = load_deployment_config(config_path)
|
|
267
|
+
|
|
268
|
+
if config.get("app_url") != app_data.get("app_url"):
|
|
269
|
+
config["app_url"] = app_data.get("app_url", "")
|
|
270
|
+
save_deployment_config(config, config_path)
|
|
271
|
+
|
|
272
|
+
return {"result": {"app_id": app_id, "app_url": format_app_url(app_data.get("app_url"))}}
|
|
273
|
+
else:
|
|
274
|
+
# create new app
|
|
275
|
+
app_data = await deploy_client.create_app(app_name, zip_path, entrypoint_module)
|
|
276
|
+
|
|
277
|
+
config: AppDeploymentConfig = {
|
|
278
|
+
"name": app_name,
|
|
279
|
+
"app_id": app_data.get("id", ""),
|
|
280
|
+
"app_url": app_data.get("app_url", ""),
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
save_deployment_config(config, config_path)
|
|
284
|
+
|
|
285
|
+
return {"result": {"app_id": app_data.get("id"), "app_url": format_app_url(app_data.get("app_url"))}}
|
|
286
|
+
|
|
287
|
+
async def wait_auth(self, data: Any):
|
|
288
|
+
# this just wait to circumvent setTimeout with window.open restriction.
|
|
289
|
+
await asyncio.sleep(2)
|
|
290
|
+
return {"result": {}}
|
|
291
|
+
|
|
292
|
+
async def logout(self, data: Any):
|
|
293
|
+
await self.oauth_client.logout()
|
|
294
|
+
return {"result": {}}
|
plotly_cloud/_oauth.py
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
"""OAuth client implementation for Plotly Cloud CLI using WorkOS CLI Auth."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import time
|
|
7
|
+
import webbrowser
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Dict, Optional, Tuple, Union
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
from rich.live import Live
|
|
14
|
+
from rich.panel import Panel
|
|
15
|
+
from rich.spinner import Spinner
|
|
16
|
+
from typing_extensions import TypedDict
|
|
17
|
+
|
|
18
|
+
from .exceptions import (
|
|
19
|
+
CredentialError,
|
|
20
|
+
OAuthClientError,
|
|
21
|
+
TimeoutError,
|
|
22
|
+
TokenError,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
console = Console()
|
|
26
|
+
|
|
27
|
+
# WorkOS CLI Auth Configuration
|
|
28
|
+
WORKOS_API_BASE_URL = "https://api.workos.com"
|
|
29
|
+
WORKOS_ENDPOINTS = {
|
|
30
|
+
"DEVICE_AUTHORIZE": "/user_management/authorize/device",
|
|
31
|
+
"AUTHENTICATE": "/user_management/authenticate",
|
|
32
|
+
"REFRESH_TOKEN": "/user_management/authenticate",
|
|
33
|
+
"LOGOUT": "/user_management/sessions/logout",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
# OAuth Configuration
|
|
37
|
+
DEFAULT_AUTH_PROVIDER = "authkit"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class AuthTokenResponse(TypedDict):
|
|
41
|
+
"""Response from successful OAuth authentication."""
|
|
42
|
+
|
|
43
|
+
access_token: str
|
|
44
|
+
refresh_token: str
|
|
45
|
+
token_type: str
|
|
46
|
+
expires_in: int
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class AuthErrorResponse(TypedDict):
|
|
50
|
+
"""Response from failed OAuth authentication."""
|
|
51
|
+
|
|
52
|
+
error: str
|
|
53
|
+
error_description: Optional[str]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
AuthResponse = Union[AuthTokenResponse, AuthErrorResponse]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class OAuthClient:
|
|
60
|
+
"""OAuth client for WorkOS CLI Auth using device authorization flow."""
|
|
61
|
+
|
|
62
|
+
def __init__(self, client_id: str):
|
|
63
|
+
self.client_id = client_id
|
|
64
|
+
self.credentials_path = self._get_credentials_path()
|
|
65
|
+
|
|
66
|
+
def _get_credentials_path(self) -> Path:
|
|
67
|
+
"""Get cross-platform credentials file path."""
|
|
68
|
+
home = Path.home()
|
|
69
|
+
return home / ".plotly-cloud"
|
|
70
|
+
|
|
71
|
+
async def request_device_authorization(self, provider: str = DEFAULT_AUTH_PROVIDER) -> dict:
|
|
72
|
+
"""Request device authorization from WorkOS CLI Auth."""
|
|
73
|
+
if not self.client_id:
|
|
74
|
+
raise OAuthClientError("client_id is required")
|
|
75
|
+
|
|
76
|
+
device_auth_data = {
|
|
77
|
+
"client_id": self.client_id,
|
|
78
|
+
"provider": provider,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async with httpx.AsyncClient() as client:
|
|
82
|
+
response = await client.post(
|
|
83
|
+
f"{WORKOS_API_BASE_URL}{WORKOS_ENDPOINTS['DEVICE_AUTHORIZE']}",
|
|
84
|
+
data=device_auth_data,
|
|
85
|
+
headers={"Content-Type": "application/x-www-form-urlencoded", "user-agent": "PlotlyCloudCLI"},
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
if response.status_code != 200:
|
|
89
|
+
raise OAuthClientError("Device authorization failed", response.text)
|
|
90
|
+
|
|
91
|
+
return response.json()
|
|
92
|
+
|
|
93
|
+
async def check_authentication_status(
|
|
94
|
+
self,
|
|
95
|
+
device_code: str,
|
|
96
|
+
client: Optional[httpx.AsyncClient] = None,
|
|
97
|
+
) -> Tuple[int, AuthResponse]:
|
|
98
|
+
"""Check authentication status without terminal output or polling loop.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
device_code: The device code from device authorization
|
|
102
|
+
client: Optional httpx client to use, creates new one if not provided
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
Tuple of (status_code, response_data)
|
|
106
|
+
"""
|
|
107
|
+
token_data = {
|
|
108
|
+
"client_id": self.client_id,
|
|
109
|
+
"device_code": device_code,
|
|
110
|
+
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if client:
|
|
114
|
+
response = await client.post(
|
|
115
|
+
f"{WORKOS_API_BASE_URL}{WORKOS_ENDPOINTS['AUTHENTICATE']}",
|
|
116
|
+
data=token_data,
|
|
117
|
+
headers={"Content-Type": "application/x-www-form-urlencoded", "user-agent": "PlotlyCloudCLI"},
|
|
118
|
+
)
|
|
119
|
+
else:
|
|
120
|
+
async with httpx.AsyncClient() as new_client:
|
|
121
|
+
response = await new_client.post(
|
|
122
|
+
f"{WORKOS_API_BASE_URL}{WORKOS_ENDPOINTS['AUTHENTICATE']}",
|
|
123
|
+
data=token_data,
|
|
124
|
+
headers={"Content-Type": "application/x-www-form-urlencoded", "user-agent": "PlotlyCloudCLI"},
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
return response.status_code, response.json()
|
|
128
|
+
|
|
129
|
+
async def poll_for_authentication(
|
|
130
|
+
self, device_code: str, interval: int = 5, timeout: int = 300
|
|
131
|
+
) -> AuthTokenResponse:
|
|
132
|
+
"""Poll for authentication completion with exponential backoff."""
|
|
133
|
+
start_time = time.time()
|
|
134
|
+
current_interval = interval
|
|
135
|
+
max_interval = 30 # Maximum polling interval
|
|
136
|
+
|
|
137
|
+
spinner = Spinner("dots", text="ā³ Waiting for authorization...")
|
|
138
|
+
|
|
139
|
+
with Live(spinner, console=console, refresh_per_second=4):
|
|
140
|
+
async with httpx.AsyncClient() as client:
|
|
141
|
+
while time.time() - start_time < timeout:
|
|
142
|
+
status_code, response_data = await self.check_authentication_status(device_code, client)
|
|
143
|
+
|
|
144
|
+
if status_code == 200:
|
|
145
|
+
return response_data # type: ignore
|
|
146
|
+
|
|
147
|
+
error = response_data.get("error", "unknown_error")
|
|
148
|
+
|
|
149
|
+
if error == "authorization_pending":
|
|
150
|
+
# Continue polling
|
|
151
|
+
await asyncio.sleep(current_interval)
|
|
152
|
+
# Implement exponential backoff
|
|
153
|
+
current_interval = min(current_interval * 1.5, max_interval)
|
|
154
|
+
continue
|
|
155
|
+
elif error == "slow_down":
|
|
156
|
+
# Slow down polling
|
|
157
|
+
current_interval = min(current_interval * 2, max_interval)
|
|
158
|
+
spinner.text = f"š Slowing down polling to {current_interval}s..."
|
|
159
|
+
await asyncio.sleep(current_interval)
|
|
160
|
+
spinner.text = "ā³ Waiting for authorization..."
|
|
161
|
+
continue
|
|
162
|
+
elif error == "expired_token":
|
|
163
|
+
raise TokenError("Device code expired. Please try again.")
|
|
164
|
+
elif error == "access_denied":
|
|
165
|
+
raise OAuthClientError("Access denied by user.")
|
|
166
|
+
else:
|
|
167
|
+
raise OAuthClientError("Authentication failed", error)
|
|
168
|
+
|
|
169
|
+
raise TimeoutError("Authentication timed out. Please try again.")
|
|
170
|
+
|
|
171
|
+
async def login(self, open_browser: bool = True, provider: str = DEFAULT_AUTH_PROVIDER) -> AuthTokenResponse:
|
|
172
|
+
"""Perform CLI Auth device flow login."""
|
|
173
|
+
|
|
174
|
+
# Step 1: Request device authorization
|
|
175
|
+
device_auth = await self.request_device_authorization(provider)
|
|
176
|
+
|
|
177
|
+
device_code = device_auth["device_code"]
|
|
178
|
+
user_code = device_auth["user_code"]
|
|
179
|
+
verification_uri = device_auth["verification_uri"]
|
|
180
|
+
verification_uri_complete = device_auth["verification_uri_complete"]
|
|
181
|
+
expires_in = device_auth.get("expires_in", 300)
|
|
182
|
+
interval = device_auth.get("interval", 5)
|
|
183
|
+
|
|
184
|
+
# Step 2: Display user code and verification URI in a panel
|
|
185
|
+
panel_content = (
|
|
186
|
+
f"\nš Verification URL: "
|
|
187
|
+
f"[underline blue]{verification_uri}[/underline blue]\n"
|
|
188
|
+
f"\nš Device Code: [bold yellow]{user_code}[/bold yellow]\n"
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
if open_browser:
|
|
192
|
+
panel_title = "š Logging in to Plotly Cloud..."
|
|
193
|
+
webbrowser.open(verification_uri_complete)
|
|
194
|
+
else:
|
|
195
|
+
panel_title = "š Please open the URL in your browser to authenticate"
|
|
196
|
+
|
|
197
|
+
console.print()
|
|
198
|
+
console.print(
|
|
199
|
+
Panel(
|
|
200
|
+
panel_content,
|
|
201
|
+
title=panel_title,
|
|
202
|
+
border_style="dim yellow",
|
|
203
|
+
title_align="left",
|
|
204
|
+
)
|
|
205
|
+
)
|
|
206
|
+
console.print()
|
|
207
|
+
console.print()
|
|
208
|
+
|
|
209
|
+
# Step 3: Poll for authentication completion
|
|
210
|
+
try:
|
|
211
|
+
tokens = await self.poll_for_authentication(device_code, interval, expires_in)
|
|
212
|
+
except (OAuthClientError, TokenError, TimeoutError) as e:
|
|
213
|
+
console.print(f"ā Authentication failed: {e}")
|
|
214
|
+
raise
|
|
215
|
+
|
|
216
|
+
# Step 4: Save credentials
|
|
217
|
+
await self._save_credentials(dict(tokens))
|
|
218
|
+
|
|
219
|
+
return tokens
|
|
220
|
+
|
|
221
|
+
async def _save_credentials(self, credentials: Dict[str, Any]):
|
|
222
|
+
"""Save credentials to file."""
|
|
223
|
+
try:
|
|
224
|
+
# Ensure parent directory exists
|
|
225
|
+
self.credentials_path.parent.mkdir(exist_ok=True)
|
|
226
|
+
|
|
227
|
+
# Write credentials
|
|
228
|
+
with open(self.credentials_path, "w") as f:
|
|
229
|
+
json.dump(credentials, f, indent=2)
|
|
230
|
+
|
|
231
|
+
# Set secure permissions (readable only by owner)
|
|
232
|
+
os.chmod(self.credentials_path, 0o600)
|
|
233
|
+
|
|
234
|
+
except Exception as e:
|
|
235
|
+
raise CredentialError("Failed to save credentials", str(e)) from e
|
|
236
|
+
|
|
237
|
+
async def load_credentials(self) -> Optional[Dict[str, Any]]:
|
|
238
|
+
"""Load saved credentials."""
|
|
239
|
+
try:
|
|
240
|
+
if not self.credentials_path.exists():
|
|
241
|
+
return None
|
|
242
|
+
|
|
243
|
+
with open(self.credentials_path) as f:
|
|
244
|
+
return json.load(f)
|
|
245
|
+
|
|
246
|
+
except Exception as e:
|
|
247
|
+
console.print(f"ā Failed to load credentials: {e}")
|
|
248
|
+
return None
|
|
249
|
+
|
|
250
|
+
async def logout(self):
|
|
251
|
+
"""Logout and clear credentials."""
|
|
252
|
+
credentials = await self.load_credentials()
|
|
253
|
+
|
|
254
|
+
if credentials and "access_token" in credentials:
|
|
255
|
+
# Call WorkOS logout endpoint
|
|
256
|
+
try:
|
|
257
|
+
async with httpx.AsyncClient() as client:
|
|
258
|
+
await client.post(
|
|
259
|
+
f"{WORKOS_API_BASE_URL}{WORKOS_ENDPOINTS['LOGOUT']}",
|
|
260
|
+
headers={
|
|
261
|
+
"Authorization": f"Bearer {credentials['access_token']}",
|
|
262
|
+
"user-agent": "PlotlyCloudCLI",
|
|
263
|
+
},
|
|
264
|
+
)
|
|
265
|
+
except Exception as e:
|
|
266
|
+
console.print(f"ā Failed to logout from remote: {e}")
|
|
267
|
+
# Continue with local cleanup even if remote logout fails
|
|
268
|
+
|
|
269
|
+
# Clear local credentials
|
|
270
|
+
if self.clear_credentials():
|
|
271
|
+
console.print("Local credentials cleared")
|
|
272
|
+
|
|
273
|
+
def clear_credentials(self):
|
|
274
|
+
if self.credentials_path.exists():
|
|
275
|
+
self.credentials_path.unlink()
|
|
276
|
+
return True
|
|
277
|
+
return False
|
|
278
|
+
|
|
279
|
+
async def is_authenticated(self) -> bool:
|
|
280
|
+
"""Check if user is authenticated."""
|
|
281
|
+
credentials = await self.load_credentials()
|
|
282
|
+
return credentials is not None and "access_token" in credentials
|
|
283
|
+
|
|
284
|
+
async def get_access_token(self) -> Optional[str]:
|
|
285
|
+
"""Get current access token."""
|
|
286
|
+
credentials = await self.load_credentials()
|
|
287
|
+
if credentials:
|
|
288
|
+
return credentials.get("access_token")
|
|
289
|
+
return None
|
|
290
|
+
|
|
291
|
+
async def refresh_access_token(self) -> str:
|
|
292
|
+
"""Refresh the access token using the refresh token.
|
|
293
|
+
|
|
294
|
+
Raises:
|
|
295
|
+
TokenError: If no refresh token available or refresh fails
|
|
296
|
+
"""
|
|
297
|
+
credentials = await self.load_credentials()
|
|
298
|
+
if not credentials or "refresh_token" not in credentials:
|
|
299
|
+
raise TokenError("No refresh token available")
|
|
300
|
+
|
|
301
|
+
refresh_token = credentials["refresh_token"]
|
|
302
|
+
|
|
303
|
+
async with httpx.AsyncClient() as client:
|
|
304
|
+
response = await client.post(
|
|
305
|
+
f"{WORKOS_API_BASE_URL}{WORKOS_ENDPOINTS['REFRESH_TOKEN']}",
|
|
306
|
+
data={
|
|
307
|
+
"client_id": self.client_id,
|
|
308
|
+
"grant_type": "refresh_token",
|
|
309
|
+
"refresh_token": refresh_token,
|
|
310
|
+
},
|
|
311
|
+
headers={"Content-Type": "application/x-www-form-urlencoded", "user-agent": "PlotlyCloudCLI"},
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
if response.status_code == 200:
|
|
315
|
+
new_tokens = response.json()
|
|
316
|
+
# Update stored credentials with new tokens
|
|
317
|
+
credentials.update(new_tokens)
|
|
318
|
+
await self._save_credentials(credentials)
|
|
319
|
+
return new_tokens["access_token"]
|
|
320
|
+
else:
|
|
321
|
+
raise TokenError("Token refresh failed", response.text)
|
|
322
|
+
|
|
323
|
+
@property
|
|
324
|
+
def access_token(self) -> Optional[str]:
|
|
325
|
+
"""Synchronous property to get access token for backward compatibility."""
|
|
326
|
+
# This is a simplified sync version for compatibility
|
|
327
|
+
# In practice, you should use get_access_token() async method
|
|
328
|
+
try:
|
|
329
|
+
if self.credentials_path.exists():
|
|
330
|
+
with open(self.credentials_path) as f:
|
|
331
|
+
credentials = json.load(f)
|
|
332
|
+
return credentials.get("access_token")
|
|
333
|
+
except Exception:
|
|
334
|
+
pass
|
|
335
|
+
return None
|