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.
- plotly_cloud/__init__.py +3 -0
- plotly_cloud/_api_types.py +37 -0
- plotly_cloud/_changes.py +77 -0
- plotly_cloud/_cloud_env.py +93 -0
- plotly_cloud/_commands.py +880 -0
- plotly_cloud/_definitions.py +109 -0
- plotly_cloud/_deploy.py +470 -0
- plotly_cloud/_devtool_hooks.py +61 -0
- plotly_cloud/_devtool_publish_rpc.py +294 -0
- plotly_cloud/_oauth.py +335 -0
- plotly_cloud/_parser.py +171 -0
- plotly_cloud/cli.py +300 -0
- plotly_cloud/cloud-env.toml +6 -0
- plotly_cloud/cloud_devtools.css +1 -0
- plotly_cloud/cloud_devtools.js +15 -0
- plotly_cloud/exceptions.py +198 -0
- plotly_cloud-0.1.0.dist-info/METADATA +294 -0
- plotly_cloud-0.1.0.dist-info/RECORD +21 -0
- plotly_cloud-0.1.0.dist-info/WHEEL +4 -0
- plotly_cloud-0.1.0.dist-info/entry_points.txt +5 -0
- plotly_cloud-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|
+
}
|
plotly_cloud/_deploy.py
ADDED
|
@@ -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()
|