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,109 @@
1
+ """Type definitions for Plotly Cloud CLI."""
2
+
3
+ from typing import Callable, Literal, Union
4
+
5
+ from typing_extensions import NotRequired, TypedDict
6
+
7
+
8
+ class HelpPanelStyle(TypedDict):
9
+ """Style configuration for help panels."""
10
+
11
+ border_style: str
12
+ argument_color: str
13
+ description_color: str
14
+ argument_width: int
15
+
16
+
17
+ class CommandArgument(TypedDict):
18
+ """Definition for a command argument."""
19
+
20
+ name: str
21
+ help: str
22
+ action: NotRequired[str]
23
+ type: NotRequired[Union[type, Callable[[str], object]]]
24
+ default: NotRequired[Union[str, int, float, bool]]
25
+ choices: NotRequired[list]
26
+ required: NotRequired[bool]
27
+ nargs: NotRequired[str]
28
+ metavar: NotRequired[str]
29
+
30
+
31
+ class AppDeploymentConfig(TypedDict):
32
+ """Deployed application configuration and metadata."""
33
+
34
+ name: NotRequired[str]
35
+ description: NotRequired[str]
36
+ app_id: NotRequired[str]
37
+ app_url: NotRequired[str]
38
+
39
+
40
+ # Revision status enum values
41
+ RevisionStatus = Literal[
42
+ "BUILDING",
43
+ "PENDING_ENTITLEMENTS",
44
+ "BUILD_FAILED",
45
+ "BUILD_COMPLETED",
46
+ "STARTING",
47
+ "RUNNING",
48
+ "STOPPING",
49
+ "STOPPED",
50
+ "FAILING",
51
+ ]
52
+
53
+
54
+ class RevisionStatusInfo(TypedDict):
55
+ """User-friendly information about revision status."""
56
+
57
+ label: str
58
+ emoji: str
59
+ color: str
60
+
61
+
62
+ # User-friendly status mappings based on HTML page
63
+ REVISION_STATUS_MAP: dict[str, RevisionStatusInfo] = {
64
+ "BUILDING": {
65
+ "label": "Building",
66
+ "emoji": "⚒",
67
+ "color": "yellow",
68
+ },
69
+ "PENDING_ENTITLEMENTS": {
70
+ "label": "Max Deployed Apps Reached",
71
+ "emoji": "⏸",
72
+ "color": "dim white",
73
+ },
74
+ "BUILD_FAILED": {
75
+ "label": "Build Failed",
76
+ "emoji": "✗",
77
+ "color": "red",
78
+ },
79
+ "BUILD_COMPLETED": {
80
+ "label": "Build Completed",
81
+ "emoji": "✓",
82
+ "color": "green",
83
+ },
84
+ "STARTING": {
85
+ "label": "Starting",
86
+ "emoji": "→",
87
+ "color": "dim white",
88
+ },
89
+ "RUNNING": {
90
+ "label": "Running",
91
+ "emoji": "▶",
92
+ "color": "green",
93
+ },
94
+ "STOPPING": {
95
+ "label": "Stopping",
96
+ "emoji": "⏹",
97
+ "color": "yellow",
98
+ },
99
+ "STOPPED": {
100
+ "label": "Stopped",
101
+ "emoji": "⏹",
102
+ "color": "dim white",
103
+ },
104
+ "FAILING": {
105
+ "label": "Failing",
106
+ "emoji": "⚠",
107
+ "color": "red",
108
+ },
109
+ }
@@ -0,0 +1,470 @@
1
+ """Deployment utilities for Plotly Cloud CLI."""
2
+
3
+ import json
4
+ import os
5
+ import pathlib
6
+ import re
7
+ import zipfile
8
+ from typing import Optional
9
+
10
+ import httpx
11
+ import tomli
12
+ import tomli_w
13
+
14
+ from plotly_cloud._api_types import App, AppRequest
15
+ from plotly_cloud._cloud_env import cloud_config
16
+ from plotly_cloud._definitions import AppDeploymentConfig
17
+ from plotly_cloud._oauth import OAuthClient
18
+
19
+ from .exceptions import (
20
+ APIError,
21
+ AppCreationError,
22
+ AppPublishError,
23
+ DeploymentClientError,
24
+ DeploymentError,
25
+ FileSizeError,
26
+ FileSystemError,
27
+ ForbiddenError,
28
+ NetworkError,
29
+ PackagingError,
30
+ PlotlyCloudError,
31
+ )
32
+
33
+ # Maximum allowed zip file size (200MB)
34
+ MAX_ZIP_SIZE = 200 * 1024 * 1024
35
+
36
+
37
+ def parse_gitignore(project_path: str) -> set[str]:
38
+ """Parse .gitignore file and return set of patterns to exclude."""
39
+ gitignore_path = os.path.join(project_path, ".gitignore")
40
+ exclude_patterns = set()
41
+
42
+ # Always exclude common virtual environment directories
43
+ exclude_patterns.update(
44
+ {
45
+ "venv/",
46
+ "venv",
47
+ ".venv/",
48
+ ".venv",
49
+ "env/",
50
+ "env",
51
+ ".env/",
52
+ ".env",
53
+ "__pycache__/",
54
+ "*.pyc",
55
+ "*.pyo",
56
+ "*.pyd",
57
+ ".Python",
58
+ "build/",
59
+ "develop-eggs/",
60
+ "dist/",
61
+ "downloads/",
62
+ "eggs/",
63
+ ".eggs/",
64
+ "lib/",
65
+ "lib64/",
66
+ "parts/",
67
+ "sdist/",
68
+ "var/",
69
+ "wheels/",
70
+ "*.egg-info/",
71
+ ".installed.cfg",
72
+ "*.egg",
73
+ "MANIFEST",
74
+ ".git/",
75
+ ".gitignore",
76
+ }
77
+ )
78
+
79
+ if os.path.exists(gitignore_path):
80
+ try:
81
+ with open(gitignore_path, encoding="utf-8") as f:
82
+ for line in f:
83
+ line = line.strip()
84
+ if line and not line.startswith("#"):
85
+ exclude_patterns.add(line)
86
+ except Exception as e:
87
+ raise FileSystemError("Warning: Could not read .gitignore file") from e
88
+
89
+ return exclude_patterns
90
+
91
+
92
+ def should_exclude_path(path: str, exclude_patterns: set[str]) -> bool:
93
+ """Check if a path should be excluded based on gitignore patterns."""
94
+ path_parts = pathlib.Path(path).parts
95
+
96
+ for pattern in exclude_patterns:
97
+ # Handle directory patterns (ending with /)
98
+ if pattern.endswith("/"):
99
+ pattern_name = pattern.rstrip("/")
100
+ if pattern_name in path_parts:
101
+ return True
102
+ # Handle wildcard patterns (basic support)
103
+ elif pattern.startswith("*"):
104
+ extension = pattern[1:] # Remove the *
105
+ if path.endswith(extension):
106
+ return True
107
+ # Handle exact file patterns (must be exact match at any level)
108
+ elif pattern == os.path.basename(path):
109
+ return True
110
+
111
+ return False
112
+
113
+
114
+ async def create_deployment_zip(project_path: str, output_path: str) -> int:
115
+ """
116
+ Create a zip file for deployment, excluding files based on .gitignore.
117
+
118
+ Args:
119
+ project_path: Path to the project directory
120
+ output_path: Path where the zip file should be created
121
+
122
+ Returns:
123
+ Size of the created zip file in bytes
124
+
125
+ Raises:
126
+ ValueError: If zip file exceeds size limit
127
+ """
128
+ exclude_patterns = parse_gitignore(project_path)
129
+ total_uncompressed_size = 0
130
+
131
+ with zipfile.ZipFile(output_path, "w", zipfile.ZIP_DEFLATED) as zipf:
132
+ for root, dirs, files in os.walk(project_path):
133
+ # Remove excluded directories from dirs list to avoid walking them
134
+ # Convert to relative paths for consistent pattern matching
135
+ dirs[:] = [
136
+ d
137
+ for d in dirs
138
+ if not should_exclude_path(
139
+ str(os.path.relpath(os.path.join(root, d), project_path)), exclude_patterns
140
+ )
141
+ ]
142
+
143
+ for file in files:
144
+ file_path = os.path.join(root, file)
145
+ relative_path = os.path.relpath(file_path, project_path)
146
+
147
+ # Ensure relative_path is a string
148
+ if isinstance(relative_path, bytes):
149
+ relative_path = relative_path.decode("utf-8")
150
+
151
+ # Skip if file should be excluded
152
+ if should_exclude_path(relative_path, exclude_patterns):
153
+ continue
154
+
155
+ # Skip if file is the output zip itself
156
+ if os.path.abspath(file_path) == os.path.abspath(output_path):
157
+ continue
158
+
159
+ try:
160
+ file_size = os.path.getsize(file_path)
161
+ total_uncompressed_size += file_size
162
+ zipf.write(file_path, relative_path)
163
+ except (OSError, PermissionError):
164
+ # Skip files that can't be read, continue with others
165
+ continue
166
+
167
+ # Check uncompressed size only
168
+ zip_size = os.path.getsize(output_path)
169
+
170
+ if total_uncompressed_size > MAX_ZIP_SIZE:
171
+ os.remove(output_path) # Clean up the oversized zip
172
+ raise FileSizeError(
173
+ f"This directory exceeds {MAX_ZIP_SIZE / (1024 * 1024):.0f}MB and couldn't be published",
174
+ f"Total size: {total_uncompressed_size / (1024 * 1024):.1f}MB. "
175
+ f"Maximum allowed: {MAX_ZIP_SIZE / (1024 * 1024):.0f}MB. "
176
+ "Consider excluding large files in your .gitignore.",
177
+ )
178
+
179
+ return zip_size
180
+
181
+
182
+ def get_config_path(project_path: str, config_file: str = "plotly-cloud.toml") -> str:
183
+ """Get the full path to the configuration file.
184
+
185
+ Args:
186
+ project_path: Path to the project directory
187
+ config_file: Name of the configuration file
188
+
189
+ Returns:
190
+ Full path to the configuration file
191
+ """
192
+ return os.path.join(project_path, config_file)
193
+
194
+
195
+ def load_deployment_config(config_path: str) -> AppDeploymentConfig:
196
+ """Load configuration from TOML file.
197
+
198
+ Args:
199
+ config_path: Path to the configuration file
200
+
201
+ Returns:
202
+ Dictionary containing configuration data
203
+ """
204
+ if not os.path.exists(config_path):
205
+ return {}
206
+
207
+ with open(config_path, "rb") as f:
208
+ config: AppDeploymentConfig = tomli.load(f) # type: ignore
209
+ return config
210
+
211
+
212
+ def save_deployment_config(config: AppDeploymentConfig, config_path: str) -> None:
213
+ """Save configuration to TOML file.
214
+
215
+ Args:
216
+ config: Configuration dictionary
217
+ config_path: Path to save the configuration file
218
+ """
219
+ with open(config_path, "wb") as f:
220
+ tomli_w.dump(config, f)
221
+
222
+
223
+ def format_app_url(app_url: Optional[str]) -> str:
224
+ """Format app URL to full HTTPS URL.
225
+
226
+ Args:
227
+ app_url: The app URL from the API (just the subdomain part)
228
+
229
+ Returns:
230
+ Full HTTPS URL or empty string if app_url is None/empty
231
+ """
232
+ if not app_url:
233
+ return ""
234
+ return f"https://{app_url}.plotly.app"
235
+
236
+
237
+ def _handle_error_response(response: Optional[httpx.Response], operation: str) -> PlotlyCloudError:
238
+ """Handle error responses from API calls.
239
+
240
+ Args:
241
+ response: The HTTP response object
242
+ operation: Description of the operation that failed
243
+
244
+ Raises:
245
+ Appropriate error based on the response
246
+ """
247
+ if not response:
248
+ return DeploymentError(f"Error performing {operation}")
249
+
250
+ # Handle 403 Forbidden specifically
251
+ if response.status_code == 403:
252
+ return ForbiddenError(f"Failed to {operation}: Access forbidden")
253
+
254
+ # Parse error based on content type
255
+ content_type = response.headers.get("content-type", "").lower()
256
+
257
+ if content_type.startswith("application/json"):
258
+ try:
259
+ error_data = response.json()
260
+ # Try different error message formats - authkit forwards various formats
261
+ error_msg = (
262
+ error_data.get("message") # Common authkit format
263
+ or error_data.get("error") # Standard OAuth format
264
+ or "Unknown error"
265
+ )
266
+ error_desc = error_data.get("error_description", "")
267
+ except (json.JSONDecodeError, KeyError):
268
+ error_msg = f"HTTP {response.status_code}"
269
+ error_desc = "Invalid JSON response"
270
+ elif content_type.startswith("text/html"):
271
+ # Extract text from HTML using regex
272
+ html_text = response.text.strip()
273
+ clean_text = re.sub(r"<[^>]+>", "", html_text) # Remove HTML tags
274
+ clean_text = " ".join(clean_text.split()) # Clean up whitespace
275
+ error_msg = f"HTTP {response.status_code}"
276
+ error_desc = clean_text[:200] + ("..." if len(clean_text) > 200 else "") # Truncate long HTML
277
+ else:
278
+ # Plain text or other content types
279
+ error_msg = f"HTTP {response.status_code}"
280
+ error_desc = response.text.strip()
281
+
282
+ if "create" in operation.lower():
283
+ return AppCreationError(f"Failed to {operation}: {error_msg}", error_desc)
284
+ elif "publish" in operation.lower():
285
+ return AppPublishError(f"Failed to {operation}: {error_msg}", error_desc)
286
+ else:
287
+ return APIError(f"Failed to {operation}: {error_msg}", status_code=response.status_code, details=error_desc)
288
+
289
+
290
+ class DeploymentClient:
291
+ """Client for handling Plotly Cloud deployments."""
292
+
293
+ def __init__(self, oauth_client: Optional[OAuthClient] = None):
294
+ self.api_base_url = cloud_config.get_api_base_url().rstrip("/")
295
+ self.oauth_client = oauth_client
296
+ self._client: Optional[httpx.AsyncClient] = None
297
+
298
+ async def __aenter__(self):
299
+ """Async context manager entry."""
300
+ headers = {"user-agent": "PlotlyCloudCLI"}
301
+ if self.oauth_client:
302
+ access_token = await self.oauth_client.get_access_token()
303
+ if access_token:
304
+ headers["Authorization"] = f"Bearer {access_token}"
305
+
306
+ self._client = httpx.AsyncClient(
307
+ timeout=300.0, # 5 minute timeout
308
+ headers=headers,
309
+ )
310
+ return self
311
+
312
+ async def __aexit__(self, exc_type, exc_val, exc_tb): # noqa: ARG002
313
+ """Async context manager exit."""
314
+ if self._client:
315
+ await self._client.aclose()
316
+
317
+ async def _refresh_token_if_needed(self, response: httpx.Response) -> bool:
318
+ """Refresh token if needed based on response. Returns True if token was refreshed."""
319
+ if response.status_code == 401 and self.oauth_client:
320
+ try:
321
+ new_token = await self.oauth_client.refresh_access_token()
322
+ if self._client:
323
+ self._client.headers["Authorization"] = f"Bearer {new_token}"
324
+ return True
325
+ except Exception:
326
+ # Token refresh failed, continue with original error
327
+ pass
328
+ return False
329
+
330
+ async def create_app(self, name: str, zip_path: str = "", entrypoint_module: Optional[str] = None) -> App:
331
+ """Create a new application and upload deployment in same request.
332
+
333
+ Args:
334
+ name: Application name
335
+ zip_path: Path to deployment zip file to upload
336
+ entrypoint_module: Entrypoint module for the application (e.g., "app:app")
337
+
338
+ Returns:
339
+ App response from the API
340
+
341
+ Raises:
342
+ Exception: If API call fails
343
+ """
344
+ if not self._client:
345
+ raise RuntimeError("Client not initialized. Use within async context manager.")
346
+
347
+ if not zip_path or not os.path.exists(zip_path):
348
+ raise PackagingError(f"Zip file not found: {zip_path}")
349
+
350
+ url = f"https://{self.api_base_url}/api/app"
351
+
352
+ try:
353
+ # Create typed request data
354
+ app_request: AppRequest = {"name": name}
355
+ if entrypoint_module:
356
+ app_request["entrypointModule"] = entrypoint_module
357
+
358
+ # Upload deployment in same request as app creation
359
+ retry_count = 0
360
+ response = None
361
+ while retry_count <= 1:
362
+ with open(zip_path, "rb") as f:
363
+ files = {"file": (os.path.basename(zip_path), f, "application/zip")}
364
+ data = {"json": json.dumps(app_request)}
365
+ response = await self._client.post(url, data=data, files=files)
366
+
367
+ if response.status_code in (200, 201):
368
+ api_response: App = response.json()
369
+ return api_response
370
+
371
+ token_refreshed = await self._refresh_token_if_needed(response)
372
+ if token_refreshed:
373
+ retry_count += 1
374
+ else:
375
+ break
376
+
377
+ raise _handle_error_response(response, "create app")
378
+ except httpx.RequestError as e:
379
+ raise NetworkError("Failed to create app", str(e)) from e
380
+
381
+ async def publish_app(self, app_id: str, zip_path: str = "", entrypoint_module: Optional[str] = None) -> App:
382
+ """Publish existing application and upload deployment in same request.
383
+
384
+ Args:
385
+ app_id: Application ID
386
+ zip_path: Path to deployment zip file to upload
387
+ entrypoint_module: Entrypoint module for the application (e.g., "app:app")
388
+
389
+ Returns:
390
+ App response from the API
391
+
392
+ Raises:
393
+ Exception: If API call fails
394
+ """
395
+ if not self._client:
396
+ raise RuntimeError("Client not initialized. Use within async context manager.")
397
+
398
+ if not zip_path or not os.path.exists(zip_path):
399
+ raise PackagingError(f"Zip file not found: {zip_path}")
400
+
401
+ url = f"https://{self.api_base_url}/api/app/{app_id}/publish"
402
+
403
+ try:
404
+ # Upload deployment in same request as app publish
405
+ retry_count = 0
406
+ response = None
407
+ while retry_count <= 1:
408
+ with open(zip_path, "rb") as f:
409
+ files = {"file": (os.path.basename(zip_path), f, "application/zip")}
410
+
411
+ # Add entrypoint module if provided
412
+ data = {}
413
+ if entrypoint_module:
414
+ app_request: AppRequest = {"entrypointModule": entrypoint_module}
415
+ data["json"] = json.dumps(app_request)
416
+
417
+ response = await self._client.post(url, files=files, data=data if data else None)
418
+
419
+ if response.status_code in (200, 201):
420
+ api_response: App = response.json()
421
+ return api_response
422
+
423
+ token_refreshed = await self._refresh_token_if_needed(response)
424
+ if token_refreshed:
425
+ retry_count += 1
426
+ else:
427
+ break
428
+
429
+ raise _handle_error_response(response, "publish app")
430
+ except httpx.RequestError as e:
431
+ raise NetworkError("Failed to publish app", str(e)) from e
432
+
433
+ async def get_app_status(self, app_id: str) -> App:
434
+ """Get application status and details.
435
+
436
+ Args:
437
+ app_id: Application ID
438
+
439
+ Returns:
440
+ App data from the API
441
+
442
+ Raises:
443
+ DeploymentClientError: If client is not initialized
444
+ APIError: If API call fails
445
+ NetworkError: If network request fails
446
+ """
447
+ if not self._client:
448
+ raise DeploymentClientError("Client not initialized. Use within async context manager.")
449
+
450
+ url = f"https://{self.api_base_url}/api/app/{app_id}"
451
+
452
+ try:
453
+ retry_count = 0
454
+ response = None
455
+ while retry_count <= 1:
456
+ response = await self._client.get(url)
457
+
458
+ if response.status_code == 200:
459
+ api_response: App = response.json()
460
+ return api_response
461
+
462
+ token_refreshed = await self._refresh_token_if_needed(response)
463
+ if token_refreshed:
464
+ retry_count += 1
465
+ else:
466
+ break
467
+
468
+ raise _handle_error_response(response, "get app status")
469
+ except httpx.RequestError as e:
470
+ raise NetworkError("Failed to get app status", str(e)) from e
@@ -0,0 +1,61 @@
1
+ import asyncio
2
+
3
+ import dash
4
+ import flask
5
+ from packaging.version import parse as _parse_version
6
+
7
+ from plotly_cloud._devtool_publish_rpc import PlotlyCloudPublishRPC
8
+
9
+ dash_version = _parse_version(dash.__version__)
10
+
11
+
12
+ def _run_sync(coro):
13
+ loop = asyncio.get_event_loop()
14
+ return loop.run_until_complete(coro)
15
+
16
+
17
+ def install_hook():
18
+ import nest_asyncio
19
+
20
+ # Make asyncio stuff runs smoothly.
21
+ # Prevent no loop and existing loop errors.
22
+ nest_asyncio.apply()
23
+
24
+ rpc = PlotlyCloudPublishRPC()
25
+
26
+ try:
27
+ # The style only works with the position left defined.
28
+ dash.hooks.devtool( # type: ignore
29
+ "plotly_cloud_publish_component",
30
+ "PlotlyCloudPublishComponent",
31
+ {"id": "_plotly-cloud-publish"},
32
+ position="left",
33
+ )
34
+ except Exception:
35
+ return
36
+
37
+ dash.hooks.script(
38
+ [
39
+ {"dev_package_path": "cloud_devtools.js", "namespace": "plotly_cloud", "dev_only": True},
40
+ ]
41
+ )
42
+
43
+ dash.hooks.stylesheet(
44
+ [
45
+ {"relative_package_path": "cloud_devtools.css", "namespace": "plotly_cloud"},
46
+ ]
47
+ )
48
+
49
+ @dash.hooks.route("_plotly_cloud_publish", methods=["POST"])
50
+ def plotly_cloud_publish_rpc():
51
+ data = flask.request.get_json()
52
+ data = _run_sync(rpc.handle_operation(data))
53
+ return flask.jsonify(data)
54
+
55
+ @dash.hooks.setup()
56
+ def plotly_cloud_setup(app):
57
+ rpc._app_setup = app
58
+
59
+
60
+ if hasattr(dash.hooks, "devtool"):
61
+ install_hook()