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.
@@ -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