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.
@@ -0,0 +1,109 @@
1
+ """Type definitions for Plotly Cloud CLI."""
2
+
3
+ from typing import 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[type]
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,524 @@
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
+ DependencyValidationError,
24
+ DeploymentClientError,
25
+ DeploymentError,
26
+ FileSizeError,
27
+ FileSystemError,
28
+ ForbiddenError,
29
+ NetworkError,
30
+ PackagingError,
31
+ PlotlyCloudError,
32
+ )
33
+
34
+ # Maximum allowed zip file size (80MB)
35
+ MAX_ZIP_SIZE = 80 * 1024 * 1024
36
+
37
+ # Required dependencies for deployment
38
+ REQUIRED_DEPENDENCIES = {"dash"}
39
+
40
+
41
+ def validate_dependencies(project_path: str) -> None:
42
+ """
43
+ Validate that the project has required dependencies (dash).
44
+
45
+ Checks requirements.txt or pyproject.toml for the required dependencies.
46
+
47
+ Args:
48
+ project_path: Path to the project directory
49
+
50
+ Returns:
51
+ True if all required dependencies are found, False otherwise
52
+ """
53
+ requirements_path = os.path.join(project_path, "requirements.txt")
54
+ pyproject_path = os.path.join(project_path, "pyproject.toml")
55
+
56
+ found_deps = set()
57
+
58
+ # Check requirements.txt
59
+ if os.path.exists(requirements_path):
60
+ try:
61
+ with open(requirements_path, encoding="utf-8") as f:
62
+ for line in f:
63
+ line = line.strip()
64
+ if line and not line.startswith("#"):
65
+ # Extract package name (handle versions, extras, etc.)
66
+ # Split on version operators and extras
67
+ dep_name = re.split(r"[>=<!\[~]", line)[0].strip()
68
+ if dep_name: # Only add non-empty names
69
+ found_deps.add(dep_name.lower())
70
+ except Exception as err:
71
+ raise DependencyValidationError(f"Error reading requirement file: {requirements_path}") from err
72
+
73
+ # Check pyproject.toml
74
+ elif os.path.exists(pyproject_path):
75
+ try:
76
+ with open(pyproject_path, "rb") as f:
77
+ data = tomli.load(f)
78
+
79
+ # Check different possible locations for dependencies
80
+ dep_sources = [
81
+ data.get("project", {}).get("dependencies", []),
82
+ data.get("tool", {}).get("poetry", {}).get("dependencies", {}),
83
+ data.get("build-system", {}).get("requires", []),
84
+ ]
85
+
86
+ for deps in dep_sources:
87
+ if isinstance(deps, list):
88
+ # Handle list format (like project.dependencies)
89
+ for dep in deps:
90
+ if isinstance(dep, str):
91
+ dep_name = re.split(r"[>=<!\[~]", dep)[0].strip()
92
+ if dep_name: # Only add non-empty names
93
+ found_deps.add(dep_name.lower())
94
+ elif isinstance(deps, dict):
95
+ # Handle dict format (like poetry dependencies)
96
+ for dep_name in deps.keys():
97
+ found_deps.add(dep_name.lower())
98
+ except Exception as err:
99
+ raise DependencyValidationError(f"Error reading pyproject file: {pyproject_path}") from err
100
+ else:
101
+ raise FileNotFoundError("No requirements.txt or pyproject.toml found in project directory.")
102
+
103
+ # Check if all required dependencies are present
104
+ missing_deps = REQUIRED_DEPENDENCIES - found_deps
105
+
106
+ if missing_deps:
107
+ raise DependencyValidationError(
108
+ f"Missing required dependencies: {', '.join(sorted(missing_deps))}.\n\n"
109
+ ""
110
+ "please add them to your requirements.txt or pyproject.toml"
111
+ )
112
+
113
+
114
+ def parse_gitignore(project_path: str) -> set[str]:
115
+ """Parse .gitignore file and return set of patterns to exclude."""
116
+ gitignore_path = os.path.join(project_path, ".gitignore")
117
+ exclude_patterns = set()
118
+
119
+ # Always exclude common virtual environment directories
120
+ exclude_patterns.update(
121
+ {
122
+ "venv/",
123
+ "venv",
124
+ ".venv/",
125
+ ".venv",
126
+ "env/",
127
+ "env",
128
+ ".env/",
129
+ ".env",
130
+ "__pycache__/",
131
+ "*.pyc",
132
+ "*.pyo",
133
+ "*.pyd",
134
+ ".Python",
135
+ "build/",
136
+ "develop-eggs/",
137
+ "dist/",
138
+ "downloads/",
139
+ "eggs/",
140
+ ".eggs/",
141
+ "lib/",
142
+ "lib64/",
143
+ "parts/",
144
+ "sdist/",
145
+ "var/",
146
+ "wheels/",
147
+ "*.egg-info/",
148
+ ".installed.cfg",
149
+ "*.egg",
150
+ "MANIFEST",
151
+ ".git/",
152
+ ".gitignore",
153
+ }
154
+ )
155
+
156
+ if os.path.exists(gitignore_path):
157
+ try:
158
+ with open(gitignore_path, encoding="utf-8") as f:
159
+ for line in f:
160
+ line = line.strip()
161
+ if line and not line.startswith("#"):
162
+ exclude_patterns.add(line)
163
+ except Exception as e:
164
+ raise FileSystemError("Warning: Could not read .gitignore file") from e
165
+
166
+ return exclude_patterns
167
+
168
+
169
+ def should_exclude_path(path: str, exclude_patterns: set[str]) -> bool:
170
+ """Check if a path should be excluded based on gitignore patterns."""
171
+ path_parts = pathlib.Path(path).parts
172
+
173
+ for pattern in exclude_patterns:
174
+ # Handle directory patterns (ending with /)
175
+ if pattern.endswith("/"):
176
+ pattern_name = pattern.rstrip("/")
177
+ if pattern_name in path_parts:
178
+ return True
179
+ # Handle wildcard patterns (basic support)
180
+ elif pattern.startswith("*"):
181
+ extension = pattern[1:] # Remove the *
182
+ if path.endswith(extension):
183
+ return True
184
+ # Handle exact file patterns (must be exact match at any level)
185
+ elif pattern == os.path.basename(path):
186
+ return True
187
+
188
+ return False
189
+
190
+
191
+ async def create_deployment_zip(project_path: str, output_path: str) -> int:
192
+ """
193
+ Create a zip file for deployment, excluding files based on .gitignore.
194
+
195
+ Args:
196
+ project_path: Path to the project directory
197
+ output_path: Path where the zip file should be created
198
+
199
+ Returns:
200
+ Size of the created zip file in bytes
201
+
202
+ Raises:
203
+ ValueError: If zip file exceeds size limit
204
+ """
205
+ exclude_patterns = parse_gitignore(project_path)
206
+
207
+ with zipfile.ZipFile(output_path, "w", zipfile.ZIP_DEFLATED) as zipf:
208
+ for root, dirs, files in os.walk(project_path):
209
+ # Remove excluded directories from dirs list to avoid walking them
210
+ dirs[:] = [d for d in dirs if not should_exclude_path(os.path.join(root, d), exclude_patterns)]
211
+
212
+ for file in files:
213
+ file_path = os.path.join(root, file)
214
+ relative_path = os.path.relpath(file_path, project_path)
215
+
216
+ # Ensure relative_path is a string
217
+ if isinstance(relative_path, bytes):
218
+ relative_path = relative_path.decode("utf-8")
219
+
220
+ # Skip if file should be excluded
221
+ if should_exclude_path(relative_path, exclude_patterns):
222
+ continue
223
+
224
+ # Skip if file is the output zip itself
225
+ if os.path.abspath(file_path) == os.path.abspath(output_path):
226
+ continue
227
+
228
+ try:
229
+ zipf.write(file_path, relative_path)
230
+ except (OSError, PermissionError):
231
+ # Skip files that can't be read, continue with others
232
+ continue
233
+
234
+ # Check zip file size
235
+ zip_size = os.path.getsize(output_path)
236
+ if zip_size > MAX_ZIP_SIZE:
237
+ os.remove(output_path) # Clean up the oversized zip
238
+ raise FileSizeError(
239
+ f"Deployment package is too large ({zip_size / (1024 * 1024):.1f}MB)",
240
+ f"Maximum allowed size is {MAX_ZIP_SIZE / (1024 * 1024):.0f}MB. "
241
+ "Consider excluding more files in your .gitignore.",
242
+ )
243
+
244
+ return zip_size
245
+
246
+
247
+ def get_config_path(project_path: str, config_file: str = "plotly-cloud.toml") -> str:
248
+ """Get the full path to the configuration file.
249
+
250
+ Args:
251
+ project_path: Path to the project directory
252
+ config_file: Name of the configuration file
253
+
254
+ Returns:
255
+ Full path to the configuration file
256
+ """
257
+ return os.path.join(project_path, config_file)
258
+
259
+
260
+ def load_deployment_config(config_path: str) -> AppDeploymentConfig:
261
+ """Load configuration from TOML file.
262
+
263
+ Args:
264
+ config_path: Path to the configuration file
265
+
266
+ Returns:
267
+ Dictionary containing configuration data
268
+ """
269
+ if not os.path.exists(config_path):
270
+ return {}
271
+
272
+ with open(config_path, "rb") as f:
273
+ config: AppDeploymentConfig = tomli.load(f) # type: ignore
274
+ return config
275
+
276
+
277
+ def save_deployment_config(config: AppDeploymentConfig, config_path: str) -> None:
278
+ """Save configuration to TOML file.
279
+
280
+ Args:
281
+ config: Configuration dictionary
282
+ config_path: Path to save the configuration file
283
+ """
284
+ with open(config_path, "wb") as f:
285
+ tomli_w.dump(config, f)
286
+
287
+
288
+ def format_app_url(app_url: Optional[str]) -> str:
289
+ """Format app URL to full HTTPS URL.
290
+
291
+ Args:
292
+ app_url: The app URL from the API (just the subdomain part)
293
+
294
+ Returns:
295
+ Full HTTPS URL or empty string if app_url is None/empty
296
+ """
297
+ if not app_url:
298
+ return ""
299
+ return f"https://{app_url}.plotly.app"
300
+
301
+
302
+ def _handle_error_response(response: Optional[httpx.Response], operation: str) -> PlotlyCloudError:
303
+ """Handle error responses from API calls.
304
+
305
+ Args:
306
+ response: The HTTP response object
307
+ operation: Description of the operation that failed
308
+
309
+ Raises:
310
+ Appropriate error based on the response
311
+ """
312
+ if not response:
313
+ return DeploymentError(f"Error performing {operation}")
314
+
315
+ # Handle 403 Forbidden specifically
316
+ if response.status_code == 403:
317
+ return ForbiddenError(f"Failed to {operation}: Access forbidden")
318
+
319
+ # Parse error based on content type
320
+ content_type = response.headers.get("content-type", "").lower()
321
+
322
+ if content_type.startswith("application/json"):
323
+ try:
324
+ error_data = response.json()
325
+ # Try different error message formats - authkit forwards various formats
326
+ error_msg = (
327
+ error_data.get("message") # Common authkit format
328
+ or error_data.get("error") # Standard OAuth format
329
+ or "Unknown error"
330
+ )
331
+ error_desc = error_data.get("error_description", "")
332
+ except (json.JSONDecodeError, KeyError):
333
+ error_msg = f"HTTP {response.status_code}"
334
+ error_desc = "Invalid JSON response"
335
+ elif content_type.startswith("text/html"):
336
+ # Extract text from HTML using regex
337
+ html_text = response.text.strip()
338
+ clean_text = re.sub(r"<[^>]+>", "", html_text) # Remove HTML tags
339
+ clean_text = " ".join(clean_text.split()) # Clean up whitespace
340
+ error_msg = f"HTTP {response.status_code}"
341
+ error_desc = clean_text[:200] + ("..." if len(clean_text) > 200 else "") # Truncate long HTML
342
+ else:
343
+ # Plain text or other content types
344
+ error_msg = f"HTTP {response.status_code}"
345
+ error_desc = response.text.strip()
346
+
347
+ if "create" in operation.lower():
348
+ return AppCreationError(f"Failed to {operation}: {error_msg}", error_desc)
349
+ elif "publish" in operation.lower():
350
+ return AppPublishError(f"Failed to {operation}: {error_msg}", error_desc)
351
+ else:
352
+ return APIError(f"Failed to {operation}: {error_msg}", status_code=response.status_code, details=error_desc)
353
+
354
+
355
+ class DeploymentClient:
356
+ """Client for handling Plotly Cloud deployments."""
357
+
358
+ def __init__(self, oauth_client: Optional[OAuthClient] = None):
359
+ self.api_base_url = cloud_config.get_api_base_url().rstrip("/")
360
+ self.oauth_client = oauth_client
361
+ self._client: Optional[httpx.AsyncClient] = None
362
+
363
+ async def __aenter__(self):
364
+ """Async context manager entry."""
365
+ headers = {"user-agent": "PlotlyCloudCLI"}
366
+ if self.oauth_client:
367
+ access_token = await self.oauth_client.get_access_token()
368
+ if access_token:
369
+ headers["Authorization"] = f"Bearer {access_token}"
370
+
371
+ self._client = httpx.AsyncClient(
372
+ timeout=300.0, # 5 minute timeout
373
+ headers=headers,
374
+ )
375
+ return self
376
+
377
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
378
+ """Async context manager exit."""
379
+ if self._client:
380
+ await self._client.aclose()
381
+
382
+ async def _refresh_token_if_needed(self, response: httpx.Response) -> bool:
383
+ """Refresh token if needed based on response. Returns True if token was refreshed."""
384
+ if response.status_code == 401 and self.oauth_client:
385
+ try:
386
+ new_token = await self.oauth_client.refresh_access_token()
387
+ if self._client:
388
+ self._client.headers["Authorization"] = f"Bearer {new_token}"
389
+ return True
390
+ except Exception:
391
+ # Token refresh failed, continue with original error
392
+ pass
393
+ return False
394
+
395
+ async def create_app(self, name: str, zip_path: str = "") -> App:
396
+ """Create a new application and upload deployment in same request.
397
+
398
+ Args:
399
+ name: Application name
400
+ zip_path: Path to deployment zip file to upload
401
+
402
+ Returns:
403
+ App response from the API
404
+
405
+ Raises:
406
+ Exception: If API call fails
407
+ """
408
+ if not self._client:
409
+ raise RuntimeError("Client not initialized. Use within async context manager.")
410
+
411
+ if not zip_path or not os.path.exists(zip_path):
412
+ raise PackagingError(f"Zip file not found: {zip_path}")
413
+
414
+ url = f"https://{self.api_base_url}/api/app"
415
+
416
+ try:
417
+ # Create typed request data
418
+ app_request: AppRequest = {"name": name}
419
+
420
+ # Upload deployment in same request as app creation
421
+ retry_count = 0
422
+ response = None
423
+ while retry_count <= 1:
424
+ with open(zip_path, "rb") as f:
425
+ files = {"file": (os.path.basename(zip_path), f, "application/zip")}
426
+ data = {"json": json.dumps(app_request)}
427
+ response = await self._client.post(url, data=data, files=files)
428
+
429
+ if response.status_code in (200, 201):
430
+ api_response: App = response.json()
431
+ return api_response
432
+
433
+ token_refreshed = await self._refresh_token_if_needed(response)
434
+ if token_refreshed:
435
+ retry_count += 1
436
+ else:
437
+ break
438
+
439
+ raise _handle_error_response(response, "create app")
440
+ except httpx.RequestError as e:
441
+ raise NetworkError("Failed to create app", str(e)) from e
442
+
443
+ async def publish_app(self, app_id: str, zip_path: str = "") -> App:
444
+ """Publish existing application and upload deployment in same request.
445
+
446
+ Args:
447
+ app_id: Application ID
448
+ zip_path: Path to deployment zip file to upload
449
+
450
+ Returns:
451
+ App response from the API
452
+
453
+ Raises:
454
+ Exception: If API call fails
455
+ """
456
+ if not self._client:
457
+ raise RuntimeError("Client not initialized. Use within async context manager.")
458
+
459
+ if not zip_path or not os.path.exists(zip_path):
460
+ raise PackagingError(f"Zip file not found: {zip_path}")
461
+
462
+ url = f"https://{self.api_base_url}/api/app/{app_id}/publish"
463
+
464
+ try:
465
+ # Upload deployment in same request as app publish
466
+ retry_count = 0
467
+ response = None
468
+ while retry_count <= 1:
469
+ with open(zip_path, "rb") as f:
470
+ files = {"file": (os.path.basename(zip_path), f, "application/zip")}
471
+ response = await self._client.post(url, files=files)
472
+
473
+ if response.status_code in (200, 201):
474
+ api_response: App = response.json()
475
+ return api_response
476
+
477
+ token_refreshed = await self._refresh_token_if_needed(response)
478
+ if token_refreshed:
479
+ retry_count += 1
480
+ else:
481
+ break
482
+
483
+ raise _handle_error_response(response, "publish app")
484
+ except httpx.RequestError as e:
485
+ raise NetworkError("Failed to publish app", str(e)) from e
486
+
487
+ async def get_app_status(self, app_id: str) -> App:
488
+ """Get application status and details.
489
+
490
+ Args:
491
+ app_id: Application ID
492
+
493
+ Returns:
494
+ App data from the API
495
+
496
+ Raises:
497
+ DeploymentClientError: If client is not initialized
498
+ APIError: If API call fails
499
+ NetworkError: If network request fails
500
+ """
501
+ if not self._client:
502
+ raise DeploymentClientError("Client not initialized. Use within async context manager.")
503
+
504
+ url = f"https://{self.api_base_url}/api/app/{app_id}"
505
+
506
+ try:
507
+ retry_count = 0
508
+ response = None
509
+ while retry_count <= 1:
510
+ response = await self._client.get(url)
511
+
512
+ if response.status_code == 200:
513
+ api_response: App = response.json()
514
+ return api_response
515
+
516
+ token_refreshed = await self._refresh_token_if_needed(response)
517
+ if token_refreshed:
518
+ retry_count += 1
519
+ else:
520
+ break
521
+
522
+ raise _handle_error_response(response, "get app status")
523
+ except httpx.RequestError as e:
524
+ raise NetworkError("Failed to get app status", str(e)) from e