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/_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
@@ -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)