plotly-cloud 0.1.0rc1__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 +36 -0
- plotly_cloud/_changes.py +77 -0
- plotly_cloud/_cloud_env.py +93 -0
- plotly_cloud/_commands.py +878 -0
- plotly_cloud/_definitions.py +109 -0
- plotly_cloud/_deploy.py +524 -0
- plotly_cloud/_oauth.py +283 -0
- plotly_cloud/_parser.py +171 -0
- plotly_cloud/cli.py +286 -0
- plotly_cloud/cloud-env.toml +6 -0
- plotly_cloud/exceptions.py +198 -0
- plotly_cloud-0.1.0rc1.dist-info/METADATA +320 -0
- plotly_cloud-0.1.0rc1.dist-info/RECORD +17 -0
- plotly_cloud-0.1.0rc1.dist-info/WHEEL +4 -0
- plotly_cloud-0.1.0rc1.dist-info/entry_points.txt +2 -0
- plotly_cloud-0.1.0rc1.dist-info/licenses/LICENSE +21 -0
plotly_cloud/_oauth.py
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
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 Optional
|
|
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
|
+
|
|
17
|
+
from .exceptions import (
|
|
18
|
+
CredentialError,
|
|
19
|
+
OAuthClientError,
|
|
20
|
+
TimeoutError,
|
|
21
|
+
TokenError,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
console = Console()
|
|
25
|
+
|
|
26
|
+
# WorkOS CLI Auth Configuration
|
|
27
|
+
WORKOS_API_BASE_URL = "https://api.workos.com"
|
|
28
|
+
WORKOS_ENDPOINTS = {
|
|
29
|
+
"DEVICE_AUTHORIZE": "/user_management/authorize/device",
|
|
30
|
+
"AUTHENTICATE": "/user_management/authenticate",
|
|
31
|
+
"REFRESH_TOKEN": "/user_management/authenticate",
|
|
32
|
+
"LOGOUT": "/user_management/sessions/logout",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
# OAuth Configuration
|
|
36
|
+
DEFAULT_AUTH_PROVIDER = "authkit"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class OAuthClient:
|
|
40
|
+
"""OAuth client for WorkOS CLI Auth using device authorization flow."""
|
|
41
|
+
|
|
42
|
+
def __init__(self, client_id: str):
|
|
43
|
+
self.client_id = client_id
|
|
44
|
+
self.credentials_path = self._get_credentials_path()
|
|
45
|
+
|
|
46
|
+
def _get_credentials_path(self) -> Path:
|
|
47
|
+
"""Get cross-platform credentials file path."""
|
|
48
|
+
home = Path.home()
|
|
49
|
+
return home / ".plotly-cloud"
|
|
50
|
+
|
|
51
|
+
async def request_device_authorization(self, provider: str = DEFAULT_AUTH_PROVIDER) -> dict:
|
|
52
|
+
"""Request device authorization from WorkOS CLI Auth."""
|
|
53
|
+
if not self.client_id:
|
|
54
|
+
raise OAuthClientError("client_id is required")
|
|
55
|
+
|
|
56
|
+
device_auth_data = {
|
|
57
|
+
"client_id": self.client_id,
|
|
58
|
+
"provider": provider,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async with httpx.AsyncClient() as client:
|
|
62
|
+
response = await client.post(
|
|
63
|
+
f"{WORKOS_API_BASE_URL}{WORKOS_ENDPOINTS['DEVICE_AUTHORIZE']}",
|
|
64
|
+
data=device_auth_data,
|
|
65
|
+
headers={"Content-Type": "application/x-www-form-urlencoded", "user-agent": "PlotlyCloudCLI"},
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
if response.status_code != 200:
|
|
69
|
+
raise OAuthClientError("Device authorization failed", response.text)
|
|
70
|
+
|
|
71
|
+
return response.json()
|
|
72
|
+
|
|
73
|
+
async def poll_for_authentication(self, device_code: str, interval: int = 5, timeout: int = 300) -> dict:
|
|
74
|
+
"""Poll for authentication completion with exponential backoff."""
|
|
75
|
+
start_time = time.time()
|
|
76
|
+
current_interval = interval
|
|
77
|
+
max_interval = 30 # Maximum polling interval
|
|
78
|
+
|
|
79
|
+
spinner = Spinner("dots", text="⏳ Waiting for authorization...")
|
|
80
|
+
|
|
81
|
+
with Live(spinner, console=console, refresh_per_second=4):
|
|
82
|
+
while time.time() - start_time < timeout:
|
|
83
|
+
token_data = {
|
|
84
|
+
"client_id": self.client_id,
|
|
85
|
+
"device_code": device_code,
|
|
86
|
+
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async with httpx.AsyncClient() as client:
|
|
90
|
+
response = await client.post(
|
|
91
|
+
f"{WORKOS_API_BASE_URL}{WORKOS_ENDPOINTS['AUTHENTICATE']}",
|
|
92
|
+
data=token_data,
|
|
93
|
+
headers={"Content-Type": "application/x-www-form-urlencoded", "user-agent": "PlotlyCloudCLI"},
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
if response.status_code == 200:
|
|
97
|
+
return response.json()
|
|
98
|
+
|
|
99
|
+
response_data = response.json()
|
|
100
|
+
error = response_data.get("error", "unknown_error")
|
|
101
|
+
|
|
102
|
+
if error == "authorization_pending":
|
|
103
|
+
# Continue polling
|
|
104
|
+
await asyncio.sleep(current_interval)
|
|
105
|
+
# Implement exponential backoff
|
|
106
|
+
current_interval = min(current_interval * 1.5, max_interval)
|
|
107
|
+
continue
|
|
108
|
+
elif error == "slow_down":
|
|
109
|
+
# Slow down polling
|
|
110
|
+
current_interval = min(current_interval * 2, max_interval)
|
|
111
|
+
spinner.text = f"🐌 Slowing down polling to {current_interval}s..."
|
|
112
|
+
await asyncio.sleep(current_interval)
|
|
113
|
+
spinner.text = "⏳ Waiting for authorization..."
|
|
114
|
+
continue
|
|
115
|
+
elif error == "expired_token":
|
|
116
|
+
raise TokenError("Device code expired. Please try again.")
|
|
117
|
+
elif error == "access_denied":
|
|
118
|
+
raise OAuthClientError("Access denied by user.")
|
|
119
|
+
else:
|
|
120
|
+
raise OAuthClientError("Authentication failed", error)
|
|
121
|
+
|
|
122
|
+
raise TimeoutError("Authentication timed out. Please try again.")
|
|
123
|
+
|
|
124
|
+
async def login(self, open_browser: bool = True, provider: str = DEFAULT_AUTH_PROVIDER) -> dict:
|
|
125
|
+
"""Perform CLI Auth device flow login."""
|
|
126
|
+
|
|
127
|
+
# Step 1: Request device authorization
|
|
128
|
+
device_auth = await self.request_device_authorization(provider)
|
|
129
|
+
|
|
130
|
+
device_code = device_auth["device_code"]
|
|
131
|
+
user_code = device_auth["user_code"]
|
|
132
|
+
verification_uri = device_auth["verification_uri"]
|
|
133
|
+
verification_uri_complete = device_auth["verification_uri_complete"]
|
|
134
|
+
expires_in = device_auth.get("expires_in", 300)
|
|
135
|
+
interval = device_auth.get("interval", 5)
|
|
136
|
+
|
|
137
|
+
# Step 2: Display user code and verification URI in a panel
|
|
138
|
+
panel_content = (
|
|
139
|
+
f"\n🌐 Verification URL: "
|
|
140
|
+
f"[underline blue]{verification_uri}[/underline blue]\n"
|
|
141
|
+
f"\n🔑 Device Code: [bold yellow]{user_code}[/bold yellow]\n"
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
if open_browser:
|
|
145
|
+
panel_title = "🔐 Logging in to Plotly Cloud..."
|
|
146
|
+
webbrowser.open(verification_uri_complete)
|
|
147
|
+
else:
|
|
148
|
+
panel_title = "📋 Please open the URL in your browser to authenticate"
|
|
149
|
+
|
|
150
|
+
console.print()
|
|
151
|
+
console.print(
|
|
152
|
+
Panel(
|
|
153
|
+
panel_content,
|
|
154
|
+
title=panel_title,
|
|
155
|
+
border_style="dim yellow",
|
|
156
|
+
title_align="left",
|
|
157
|
+
)
|
|
158
|
+
)
|
|
159
|
+
console.print()
|
|
160
|
+
console.print()
|
|
161
|
+
|
|
162
|
+
# Step 3: Poll for authentication completion
|
|
163
|
+
try:
|
|
164
|
+
tokens = await self.poll_for_authentication(device_code, interval, expires_in)
|
|
165
|
+
except (OAuthClientError, TokenError, TimeoutError) as e:
|
|
166
|
+
console.print(f"✗ Authentication failed: {e}")
|
|
167
|
+
raise
|
|
168
|
+
|
|
169
|
+
# Step 4: Save credentials
|
|
170
|
+
await self._save_credentials(tokens)
|
|
171
|
+
|
|
172
|
+
return tokens
|
|
173
|
+
|
|
174
|
+
async def _save_credentials(self, credentials: dict):
|
|
175
|
+
"""Save credentials to file."""
|
|
176
|
+
try:
|
|
177
|
+
# Ensure parent directory exists
|
|
178
|
+
self.credentials_path.parent.mkdir(exist_ok=True)
|
|
179
|
+
|
|
180
|
+
# Write credentials
|
|
181
|
+
with open(self.credentials_path, "w") as f:
|
|
182
|
+
json.dump(credentials, f, indent=2)
|
|
183
|
+
|
|
184
|
+
# Set secure permissions (readable only by owner)
|
|
185
|
+
os.chmod(self.credentials_path, 0o600)
|
|
186
|
+
|
|
187
|
+
except Exception as e:
|
|
188
|
+
raise CredentialError("Failed to save credentials", str(e)) from e
|
|
189
|
+
|
|
190
|
+
async def load_credentials(self) -> Optional[dict]:
|
|
191
|
+
"""Load saved credentials."""
|
|
192
|
+
try:
|
|
193
|
+
if not self.credentials_path.exists():
|
|
194
|
+
return None
|
|
195
|
+
|
|
196
|
+
with open(self.credentials_path) as f:
|
|
197
|
+
return json.load(f)
|
|
198
|
+
|
|
199
|
+
except Exception as e:
|
|
200
|
+
console.print(f"✗ Failed to load credentials: {e}")
|
|
201
|
+
return None
|
|
202
|
+
|
|
203
|
+
async def logout(self):
|
|
204
|
+
"""Logout and clear credentials."""
|
|
205
|
+
credentials = await self.load_credentials()
|
|
206
|
+
|
|
207
|
+
if credentials and "access_token" in credentials:
|
|
208
|
+
# Call WorkOS logout endpoint
|
|
209
|
+
try:
|
|
210
|
+
async with httpx.AsyncClient() as client:
|
|
211
|
+
await client.post(
|
|
212
|
+
f"{WORKOS_API_BASE_URL}{WORKOS_ENDPOINTS['LOGOUT']}",
|
|
213
|
+
headers={
|
|
214
|
+
"Authorization": f"Bearer {credentials['access_token']}",
|
|
215
|
+
"user-agent": "PlotlyCloudCLI",
|
|
216
|
+
},
|
|
217
|
+
)
|
|
218
|
+
except Exception as e:
|
|
219
|
+
console.print(f"⚠ Failed to logout from remote: {e}")
|
|
220
|
+
# Continue with local cleanup even if remote logout fails
|
|
221
|
+
|
|
222
|
+
# Clear local credentials
|
|
223
|
+
if self.credentials_path.exists():
|
|
224
|
+
self.credentials_path.unlink()
|
|
225
|
+
console.print("Local credentials cleared")
|
|
226
|
+
|
|
227
|
+
async def is_authenticated(self) -> bool:
|
|
228
|
+
"""Check if user is authenticated."""
|
|
229
|
+
credentials = await self.load_credentials()
|
|
230
|
+
return credentials is not None and "access_token" in credentials
|
|
231
|
+
|
|
232
|
+
async def get_access_token(self) -> Optional[str]:
|
|
233
|
+
"""Get current access token."""
|
|
234
|
+
credentials = await self.load_credentials()
|
|
235
|
+
if credentials:
|
|
236
|
+
return credentials.get("access_token")
|
|
237
|
+
return None
|
|
238
|
+
|
|
239
|
+
async def refresh_access_token(self) -> str:
|
|
240
|
+
"""Refresh the access token using the refresh token.
|
|
241
|
+
|
|
242
|
+
Raises:
|
|
243
|
+
TokenError: If no refresh token available or refresh fails
|
|
244
|
+
"""
|
|
245
|
+
credentials = await self.load_credentials()
|
|
246
|
+
if not credentials or "refresh_token" not in credentials:
|
|
247
|
+
raise TokenError("No refresh token available")
|
|
248
|
+
|
|
249
|
+
refresh_token = credentials["refresh_token"]
|
|
250
|
+
|
|
251
|
+
async with httpx.AsyncClient() as client:
|
|
252
|
+
response = await client.post(
|
|
253
|
+
f"{WORKOS_API_BASE_URL}{WORKOS_ENDPOINTS['REFRESH_TOKEN']}",
|
|
254
|
+
data={
|
|
255
|
+
"client_id": self.client_id,
|
|
256
|
+
"grant_type": "refresh_token",
|
|
257
|
+
"refresh_token": refresh_token,
|
|
258
|
+
},
|
|
259
|
+
headers={"Content-Type": "application/x-www-form-urlencoded", "user-agent": "PlotlyCloudCLI"},
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
if response.status_code == 200:
|
|
263
|
+
new_tokens = response.json()
|
|
264
|
+
# Update stored credentials with new tokens
|
|
265
|
+
credentials.update(new_tokens)
|
|
266
|
+
await self._save_credentials(credentials)
|
|
267
|
+
return new_tokens["access_token"]
|
|
268
|
+
else:
|
|
269
|
+
raise TokenError("Token refresh failed", response.text)
|
|
270
|
+
|
|
271
|
+
@property
|
|
272
|
+
def access_token(self) -> Optional[str]:
|
|
273
|
+
"""Synchronous property to get access token for backward compatibility."""
|
|
274
|
+
# This is a simplified sync version for compatibility
|
|
275
|
+
# In practice, you should use get_access_token() async method
|
|
276
|
+
try:
|
|
277
|
+
if self.credentials_path.exists():
|
|
278
|
+
with open(self.credentials_path) as f:
|
|
279
|
+
credentials = json.load(f)
|
|
280
|
+
return credentials.get("access_token")
|
|
281
|
+
except Exception:
|
|
282
|
+
pass
|
|
283
|
+
return None
|
plotly_cloud/_parser.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Simple argument parser to replace argparse."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from typing import Any, List
|
|
5
|
+
|
|
6
|
+
from plotly_cloud._definitions import CommandArgument
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ParsedArguments:
|
|
10
|
+
"""Simple namespace for parsed command arguments."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, **kwargs: Any) -> None:
|
|
13
|
+
object.__setattr__(self, "_data", kwargs)
|
|
14
|
+
|
|
15
|
+
def __getattribute__(self, name: str) -> Any:
|
|
16
|
+
"""Get attribute from internal data storage."""
|
|
17
|
+
if name == "_data":
|
|
18
|
+
return object.__getattribute__(self, name)
|
|
19
|
+
data = object.__getattribute__(self, "_data")
|
|
20
|
+
if name in data:
|
|
21
|
+
return data[name]
|
|
22
|
+
raise AttributeError(f"'{self.__class__.__name__}' object has no attribute '{name}'")
|
|
23
|
+
|
|
24
|
+
def __setattr__(self, name: str, value: Any) -> None:
|
|
25
|
+
"""Set attribute in internal data storage."""
|
|
26
|
+
if name == "_data":
|
|
27
|
+
object.__setattr__(self, name, value)
|
|
28
|
+
else:
|
|
29
|
+
if not hasattr(self, "_data"):
|
|
30
|
+
object.__setattr__(self, "_data", {})
|
|
31
|
+
self._data[name] = value
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def parse_group_and_command() -> tuple[str, str, int]:
|
|
35
|
+
"""Parse group and command from command line.
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
Tuple of (group, command, start_index) where start_index is where to start parsing args
|
|
39
|
+
"""
|
|
40
|
+
args = sys.argv[1:]
|
|
41
|
+
|
|
42
|
+
# Parse group and command first (if present)
|
|
43
|
+
if len(args) >= 2 and not args[0].startswith("-") and not args[1].startswith("-"):
|
|
44
|
+
return args[0], args[1], 3 # Skip group and command for further parsing
|
|
45
|
+
elif len(args) >= 1 and not args[0].startswith("-"):
|
|
46
|
+
return args[0], "help", 2 # Default to help if only group provided
|
|
47
|
+
else:
|
|
48
|
+
return "help", "help", 1 # Default to help if no group provided
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def parse_args(command_arguments: List[CommandArgument], args_index=3) -> ParsedArguments:
|
|
52
|
+
"""Parse command line arguments starting after group and command.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
command_arguments: List of CommandArgument definitions
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
ParsedArguments with all argument keys set
|
|
59
|
+
"""
|
|
60
|
+
# Start parsing after group and command (sys.argv[3:])
|
|
61
|
+
args = sys.argv[args_index:]
|
|
62
|
+
result = {}
|
|
63
|
+
|
|
64
|
+
# Separate positional and optional arguments
|
|
65
|
+
positional_args = [arg for arg in command_arguments if not arg["name"].startswith("-")]
|
|
66
|
+
optional_args = [arg for arg in command_arguments if arg["name"].startswith("-")]
|
|
67
|
+
|
|
68
|
+
# Initialize all arguments with their defaults
|
|
69
|
+
for arg_spec in command_arguments:
|
|
70
|
+
name = arg_spec["name"]
|
|
71
|
+
# Convert argument name to dictionary key (remove -- and convert - to _)
|
|
72
|
+
if name.startswith("--"):
|
|
73
|
+
key = name[2:].replace("-", "_")
|
|
74
|
+
elif name.startswith("-"):
|
|
75
|
+
key = name[1:].replace("-", "_")
|
|
76
|
+
else:
|
|
77
|
+
key = name.replace("-", "_")
|
|
78
|
+
|
|
79
|
+
result[key] = arg_spec.get("default")
|
|
80
|
+
|
|
81
|
+
# Add global verbose flag and help flag
|
|
82
|
+
result["verbose"] = False
|
|
83
|
+
result["help"] = False
|
|
84
|
+
|
|
85
|
+
# Check if the first argument is "help" and handle it specially
|
|
86
|
+
if len(args) > 0 and args[0] == "help":
|
|
87
|
+
result["help"] = True
|
|
88
|
+
args = args[1:] # Skip the "help" argument
|
|
89
|
+
|
|
90
|
+
# Track positional arguments
|
|
91
|
+
positional_index = 0
|
|
92
|
+
i = 0
|
|
93
|
+
while i < len(args):
|
|
94
|
+
arg = args[i]
|
|
95
|
+
|
|
96
|
+
if arg in ["--verbose", "-v"]:
|
|
97
|
+
result["verbose"] = True
|
|
98
|
+
i += 1
|
|
99
|
+
continue
|
|
100
|
+
|
|
101
|
+
if arg in ["--help", "-h"]:
|
|
102
|
+
result["help"] = True
|
|
103
|
+
i += 1
|
|
104
|
+
continue
|
|
105
|
+
|
|
106
|
+
# Check if this is an optional argument
|
|
107
|
+
if arg.startswith("-"):
|
|
108
|
+
# Find matching optional argument specification
|
|
109
|
+
arg_spec = None
|
|
110
|
+
for spec in optional_args:
|
|
111
|
+
if spec["name"] == arg:
|
|
112
|
+
arg_spec = spec
|
|
113
|
+
break
|
|
114
|
+
|
|
115
|
+
if not arg_spec:
|
|
116
|
+
# Skip unknown arguments for now
|
|
117
|
+
i += 1
|
|
118
|
+
continue
|
|
119
|
+
|
|
120
|
+
# Get the key name
|
|
121
|
+
name = arg_spec["name"]
|
|
122
|
+
if name.startswith("--"):
|
|
123
|
+
key = name[2:].replace("-", "_")
|
|
124
|
+
elif name.startswith("-"):
|
|
125
|
+
key = name[1:].replace("-", "_")
|
|
126
|
+
else:
|
|
127
|
+
key = name.replace("-", "_")
|
|
128
|
+
|
|
129
|
+
action = arg_spec.get("action", "store")
|
|
130
|
+
|
|
131
|
+
if action == "store_true":
|
|
132
|
+
result[key] = True
|
|
133
|
+
i += 1
|
|
134
|
+
elif action == "store":
|
|
135
|
+
# Get the next argument as the value
|
|
136
|
+
if i + 1 < len(args):
|
|
137
|
+
value = args[i + 1]
|
|
138
|
+
arg_type = arg_spec.get("type")
|
|
139
|
+
if arg_type and callable(arg_type):
|
|
140
|
+
try:
|
|
141
|
+
value = arg_type(value)
|
|
142
|
+
except (ValueError, TypeError):
|
|
143
|
+
pass # Keep as string if conversion fails
|
|
144
|
+
result[key] = value
|
|
145
|
+
i += 2
|
|
146
|
+
else:
|
|
147
|
+
i += 1
|
|
148
|
+
else:
|
|
149
|
+
i += 1
|
|
150
|
+
else:
|
|
151
|
+
# This is a positional argument
|
|
152
|
+
if positional_index < len(positional_args):
|
|
153
|
+
arg_spec = positional_args[positional_index]
|
|
154
|
+
name = arg_spec["name"]
|
|
155
|
+
key = name.replace("-", "_")
|
|
156
|
+
|
|
157
|
+
# Apply type conversion if specified
|
|
158
|
+
value = arg
|
|
159
|
+
arg_type = arg_spec.get("type")
|
|
160
|
+
if arg_type and callable(arg_type):
|
|
161
|
+
try:
|
|
162
|
+
value = arg_type(value)
|
|
163
|
+
except (ValueError, TypeError):
|
|
164
|
+
pass # Keep as string if conversion fails
|
|
165
|
+
|
|
166
|
+
result[key] = value
|
|
167
|
+
positional_index += 1
|
|
168
|
+
|
|
169
|
+
i += 1
|
|
170
|
+
|
|
171
|
+
return ParsedArguments(**result)
|