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/__init__.py +3 -0
- plotly_cloud/_api_types.py +36 -0
- plotly_cloud/_changes.py +77 -0
- plotly_cloud/_cloud_env.py +93 -0
- plotly_cloud/_commands.py +878 -0
- plotly_cloud/_definitions.py +109 -0
- plotly_cloud/_deploy.py +524 -0
- plotly_cloud/_oauth.py +283 -0
- plotly_cloud/_parser.py +171 -0
- plotly_cloud/cli.py +286 -0
- plotly_cloud/cloud-env.toml +6 -0
- plotly_cloud/exceptions.py +198 -0
- plotly_cloud-0.1.0rc1.dist-info/METADATA +320 -0
- plotly_cloud-0.1.0rc1.dist-info/RECORD +17 -0
- plotly_cloud-0.1.0rc1.dist-info/WHEEL +4 -0
- plotly_cloud-0.1.0rc1.dist-info/entry_points.txt +2 -0
- plotly_cloud-0.1.0rc1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,878 @@
|
|
|
1
|
+
"""Command implementations for Plotly Cloud CLI."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import importlib
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
import tempfile
|
|
9
|
+
import time
|
|
10
|
+
import webbrowser
|
|
11
|
+
from typing import Dict, List, TypedDict
|
|
12
|
+
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
from rich.live import Live
|
|
15
|
+
from rich.panel import Panel
|
|
16
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
17
|
+
from rich.prompt import Prompt
|
|
18
|
+
from rich.table import Table
|
|
19
|
+
from rich.text import Text
|
|
20
|
+
|
|
21
|
+
from plotly_cloud._changes import collect_module_files, until_change
|
|
22
|
+
from plotly_cloud._cloud_env import cloud_config
|
|
23
|
+
from plotly_cloud._definitions import REVISION_STATUS_MAP, CommandArgument
|
|
24
|
+
from plotly_cloud._deploy import (
|
|
25
|
+
DeploymentClient,
|
|
26
|
+
create_deployment_zip,
|
|
27
|
+
format_app_url,
|
|
28
|
+
get_config_path,
|
|
29
|
+
load_deployment_config,
|
|
30
|
+
save_deployment_config,
|
|
31
|
+
validate_dependencies,
|
|
32
|
+
)
|
|
33
|
+
from plotly_cloud._oauth import OAuthClient
|
|
34
|
+
from plotly_cloud._parser import ParsedArguments
|
|
35
|
+
|
|
36
|
+
from .exceptions import (
|
|
37
|
+
ApplicationError,
|
|
38
|
+
CredentialError,
|
|
39
|
+
DashAppError,
|
|
40
|
+
ModuleImportError,
|
|
41
|
+
TokenError,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
console = Console()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class CommandGroup(TypedDict):
|
|
48
|
+
description: str
|
|
49
|
+
commands: Dict[str, "BaseCommand"]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class CommandRegistry(type):
|
|
53
|
+
"""Metaclass to automatically register command classes."""
|
|
54
|
+
|
|
55
|
+
commands: Dict[str, CommandGroup] = {
|
|
56
|
+
"app": {"description": "", "commands": {}},
|
|
57
|
+
"user": {"description": "", "commands": {}},
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
def __new__(cls, name, bases, attrs):
|
|
61
|
+
new_cls = super().__new__(cls, name, bases, attrs)
|
|
62
|
+
if name != "BaseCommand" and "name" in attrs:
|
|
63
|
+
CommandRegistry.commands[attrs["group"]]["commands"][attrs["name"]] = new_cls # type: ignore
|
|
64
|
+
return new_cls
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class BaseCommand(metaclass=CommandRegistry):
|
|
68
|
+
"""Base class for CLI commands."""
|
|
69
|
+
|
|
70
|
+
name: str = ""
|
|
71
|
+
short_description: str = ""
|
|
72
|
+
description: str = ""
|
|
73
|
+
arguments: List[CommandArgument] = []
|
|
74
|
+
group: str = ""
|
|
75
|
+
|
|
76
|
+
@classmethod
|
|
77
|
+
async def execute(cls, args: ParsedArguments) -> None:
|
|
78
|
+
"""Execute the command."""
|
|
79
|
+
raise NotImplementedError
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class LoginCommand(BaseCommand):
|
|
83
|
+
"""Handle login to Plotly Cloud using OAuth."""
|
|
84
|
+
|
|
85
|
+
name = "login"
|
|
86
|
+
group = "user"
|
|
87
|
+
short_description = "🔐 Login to Plotly Cloud using OAuth"
|
|
88
|
+
description = "Authenticate with Plotly Cloud to publish and manage applications."
|
|
89
|
+
arguments: List[CommandArgument] = [
|
|
90
|
+
{
|
|
91
|
+
"name": "--browser",
|
|
92
|
+
"action": "store_true",
|
|
93
|
+
"help": "Open browser for authentication (default behavior)",
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
"name": "--no-browser",
|
|
97
|
+
"action": "store_true",
|
|
98
|
+
"help": "Don't open browser automatically - show URL instead",
|
|
99
|
+
},
|
|
100
|
+
]
|
|
101
|
+
|
|
102
|
+
@classmethod
|
|
103
|
+
async def execute(cls, args: ParsedArguments) -> None:
|
|
104
|
+
"""Execute login command."""
|
|
105
|
+
|
|
106
|
+
client_id = cloud_config.get_oauth_client_id()
|
|
107
|
+
|
|
108
|
+
oauth_client = OAuthClient(client_id)
|
|
109
|
+
|
|
110
|
+
# Check if already authenticated
|
|
111
|
+
if await oauth_client.is_authenticated():
|
|
112
|
+
console.print("✓ Already logged in to Plotly Cloud!")
|
|
113
|
+
return
|
|
114
|
+
|
|
115
|
+
# Perform OAuth login
|
|
116
|
+
open_browser = not args.no_browser
|
|
117
|
+
await oauth_client.login(open_browser=open_browser)
|
|
118
|
+
|
|
119
|
+
console.print("✓ Successfully logged in to Plotly Cloud!")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class LogoutCommand(BaseCommand):
|
|
123
|
+
"""Handle logout from Plotly Cloud."""
|
|
124
|
+
|
|
125
|
+
name = "logout"
|
|
126
|
+
group = "user"
|
|
127
|
+
short_description = "Logout from Plotly Cloud"
|
|
128
|
+
description = "Clear your authentication credentials and log out from Plotly Cloud."
|
|
129
|
+
|
|
130
|
+
@classmethod
|
|
131
|
+
async def execute(cls, args: ParsedArguments) -> None:
|
|
132
|
+
"""Execute logout command."""
|
|
133
|
+
console.print("Logging out from Plotly Cloud...")
|
|
134
|
+
|
|
135
|
+
client_id = cloud_config.get_oauth_client_id()
|
|
136
|
+
|
|
137
|
+
oauth_client = OAuthClient(client_id)
|
|
138
|
+
|
|
139
|
+
# Check if authenticated
|
|
140
|
+
if not await oauth_client.is_authenticated():
|
|
141
|
+
console.print("Not currently logged in.")
|
|
142
|
+
return
|
|
143
|
+
|
|
144
|
+
with Progress(
|
|
145
|
+
SpinnerColumn(),
|
|
146
|
+
TextColumn("[progress.description]{task.description}"),
|
|
147
|
+
console=console,
|
|
148
|
+
) as progress:
|
|
149
|
+
progress.add_task("Clearing credentials...", total=None)
|
|
150
|
+
await oauth_client.logout()
|
|
151
|
+
|
|
152
|
+
console.print("✓ Successfully logged out!")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class RunCommand(BaseCommand):
|
|
156
|
+
"""Run a Dash application."""
|
|
157
|
+
|
|
158
|
+
name = "run"
|
|
159
|
+
group = "app"
|
|
160
|
+
short_description = "🚀 Run a Dash application locally"
|
|
161
|
+
description = "Start a local development server for your Dash application with debugging tools."
|
|
162
|
+
arguments: List[CommandArgument] = [
|
|
163
|
+
{
|
|
164
|
+
"name": "app",
|
|
165
|
+
"help": "The Dash application to run in 'module:variable' format (e.g., 'app:app')",
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
"name": "--host",
|
|
169
|
+
"default": "127.0.0.1",
|
|
170
|
+
"help": "Host IP address to bind to",
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
"name": "--port",
|
|
174
|
+
"type": int,
|
|
175
|
+
"default": 8050,
|
|
176
|
+
"help": "Port number to listen on",
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
"name": "--proxy",
|
|
180
|
+
"help": "Proxy configuration for the application",
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
"name": "--debug",
|
|
184
|
+
"action": "store_true",
|
|
185
|
+
"help": "Enable debug mode with detailed error messages",
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
"name": "--dev-tools-ui",
|
|
189
|
+
"action": "store_true",
|
|
190
|
+
"help": "Enable development tools UI",
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
"name": "--dev-tools-props-check",
|
|
194
|
+
"action": "store_true",
|
|
195
|
+
"help": "Enable component prop validation",
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
"name": "--dev-tools-serve-dev-bundles",
|
|
199
|
+
"action": "store_true",
|
|
200
|
+
"help": "Enable serving development bundles",
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
"name": "--dev-tools-hot-reload",
|
|
204
|
+
"action": "store_true",
|
|
205
|
+
"help": "Enable hot reloading for development",
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
"name": "--dev-tools-hot-reload-interval",
|
|
209
|
+
"type": float,
|
|
210
|
+
"default": 3.0,
|
|
211
|
+
"help": "Polling interval for hot reload",
|
|
212
|
+
},
|
|
213
|
+
{
|
|
214
|
+
"name": "--dev-tools-hot-reload-watch-interval",
|
|
215
|
+
"type": float,
|
|
216
|
+
"default": 0.5,
|
|
217
|
+
"help": "File watch polling interval",
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
"name": "--dev-tools-hot-reload-max-retry",
|
|
221
|
+
"type": int,
|
|
222
|
+
"default": 8,
|
|
223
|
+
"help": "Max failed hot reload requests",
|
|
224
|
+
},
|
|
225
|
+
{
|
|
226
|
+
"name": "--dev-tools-silence-routes-logging",
|
|
227
|
+
"action": "store_true",
|
|
228
|
+
"help": "Silence Werkzeug route logging",
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
"name": "--dev-tools-disable-version-check",
|
|
232
|
+
"action": "store_true",
|
|
233
|
+
"help": "Disable Dash version upgrade check",
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
"name": "--dev-tools-prune-errors",
|
|
237
|
+
"action": "store_true",
|
|
238
|
+
"help": "Prune tracebacks to user code only",
|
|
239
|
+
},
|
|
240
|
+
{
|
|
241
|
+
"name": "--open",
|
|
242
|
+
"action": "store_true",
|
|
243
|
+
"help": "Automatically open browser with server URL",
|
|
244
|
+
},
|
|
245
|
+
]
|
|
246
|
+
|
|
247
|
+
@classmethod
|
|
248
|
+
async def execute(cls, args: ParsedArguments) -> None:
|
|
249
|
+
"""Execute run command."""
|
|
250
|
+
keep_running = True
|
|
251
|
+
|
|
252
|
+
# Add current directory to Python path for local module imports
|
|
253
|
+
if "." not in sys.path:
|
|
254
|
+
sys.path.insert(0, ".")
|
|
255
|
+
|
|
256
|
+
while keep_running:
|
|
257
|
+
if not args.debug:
|
|
258
|
+
keep_running = False
|
|
259
|
+
|
|
260
|
+
try:
|
|
261
|
+
# Parse module and variable
|
|
262
|
+
if ":" in args.app:
|
|
263
|
+
module_name, variable_name = args.app.split(":", 1)
|
|
264
|
+
else:
|
|
265
|
+
module_name = args.app
|
|
266
|
+
variable_name = "app"
|
|
267
|
+
|
|
268
|
+
# Handle directory separators - convert to dot notation for import
|
|
269
|
+
original_module_name = module_name
|
|
270
|
+
|
|
271
|
+
if module_name.endswith(".py"):
|
|
272
|
+
module_name = module_name[:-3]
|
|
273
|
+
if "/" in module_name or "\\" in module_name:
|
|
274
|
+
# Convert directory separators to dots for module import
|
|
275
|
+
module_name = module_name.replace("/", ".").replace("\\", ".")
|
|
276
|
+
|
|
277
|
+
# Import the module
|
|
278
|
+
try:
|
|
279
|
+
module = importlib.import_module(module_name)
|
|
280
|
+
except ImportError as e:
|
|
281
|
+
# If no path separators, just raise the original error
|
|
282
|
+
if not ("/" in original_module_name or "\\" in original_module_name):
|
|
283
|
+
raise ModuleImportError(f"Could not import module '{module_name}'", str(e)) from e
|
|
284
|
+
|
|
285
|
+
# Get the directory path and module name
|
|
286
|
+
if "/" in original_module_name:
|
|
287
|
+
parts = original_module_name.split("/")
|
|
288
|
+
else:
|
|
289
|
+
parts = original_module_name.split("\\")
|
|
290
|
+
|
|
291
|
+
# If only one part, no directory to change to
|
|
292
|
+
if len(parts) <= 1:
|
|
293
|
+
raise ModuleImportError(f"Could not import module '{original_module_name}'", str(e)) from e
|
|
294
|
+
|
|
295
|
+
dir_path = os.path.join(*parts[:-1])
|
|
296
|
+
just_module_name = parts[-1]
|
|
297
|
+
|
|
298
|
+
if just_module_name.endswith(".py"):
|
|
299
|
+
just_module_name = just_module_name[:-3]
|
|
300
|
+
|
|
301
|
+
# Check if the directory exists
|
|
302
|
+
if not os.path.exists(dir_path):
|
|
303
|
+
raise ModuleImportError(
|
|
304
|
+
f"Could not import module '{original_module_name}'"
|
|
305
|
+
" and directory '{dir_path}' does not exist",
|
|
306
|
+
str(e),
|
|
307
|
+
) from e
|
|
308
|
+
|
|
309
|
+
console.print(f"Trying to import from directory: {dir_path}")
|
|
310
|
+
|
|
311
|
+
# Save current directory
|
|
312
|
+
original_cwd = os.getcwd()
|
|
313
|
+
|
|
314
|
+
try:
|
|
315
|
+
# Change to the target directory
|
|
316
|
+
os.chdir(dir_path)
|
|
317
|
+
|
|
318
|
+
# Add the new directory to Python path
|
|
319
|
+
if "." not in sys.path:
|
|
320
|
+
sys.path.insert(0, ".")
|
|
321
|
+
|
|
322
|
+
# Try to import just the module name
|
|
323
|
+
module = importlib.import_module(just_module_name)
|
|
324
|
+
console.print(f"✓ Successfully imported {just_module_name} from {dir_path}")
|
|
325
|
+
|
|
326
|
+
except ImportError:
|
|
327
|
+
# Restore original directory and re-raise original error
|
|
328
|
+
os.chdir(original_cwd)
|
|
329
|
+
raise ModuleImportError(f"Could not import module '{original_module_name}'", str(e)) from e
|
|
330
|
+
|
|
331
|
+
# Get the app variable
|
|
332
|
+
if hasattr(module, variable_name):
|
|
333
|
+
app = getattr(module, variable_name)
|
|
334
|
+
else:
|
|
335
|
+
# Try to find the first Dash app in the module
|
|
336
|
+
import dash
|
|
337
|
+
|
|
338
|
+
for attr_name in dir(module):
|
|
339
|
+
attr = getattr(module, attr_name)
|
|
340
|
+
if isinstance(attr, dash.Dash):
|
|
341
|
+
app = attr
|
|
342
|
+
console.print(f"Using Dash app: {attr_name}")
|
|
343
|
+
break
|
|
344
|
+
else:
|
|
345
|
+
raise DashAppError(
|
|
346
|
+
f"Could not find variable '{variable_name}' or any Dash app in module '{module_name}'" # noqa: E501
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
# Prepare run arguments
|
|
350
|
+
run_kwargs = {
|
|
351
|
+
"host": args.host,
|
|
352
|
+
"port": args.port,
|
|
353
|
+
"debug": args.debug,
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
# Add optional arguments if provided
|
|
357
|
+
if args.proxy:
|
|
358
|
+
run_kwargs["proxy"] = args.proxy
|
|
359
|
+
if args.dev_tools_ui:
|
|
360
|
+
run_kwargs["dev_tools_ui"] = args.dev_tools_ui
|
|
361
|
+
if args.dev_tools_props_check:
|
|
362
|
+
run_kwargs["dev_tools_props_check"] = args.dev_tools_props_check
|
|
363
|
+
if args.dev_tools_serve_dev_bundles:
|
|
364
|
+
run_kwargs["dev_tools_serve_dev_bundles"] = args.dev_tools_serve_dev_bundles
|
|
365
|
+
if args.dev_tools_hot_reload:
|
|
366
|
+
run_kwargs["dev_tools_hot_reload"] = args.dev_tools_hot_reload
|
|
367
|
+
if args.dev_tools_hot_reload_interval != 3.0:
|
|
368
|
+
run_kwargs["dev_tools_hot_reload_interval"] = args.dev_tools_hot_reload_interval
|
|
369
|
+
if args.dev_tools_hot_reload_watch_interval != 0.5:
|
|
370
|
+
run_kwargs["dev_tools_hot_reload_watch_interval"] = args.dev_tools_hot_reload_watch_interval
|
|
371
|
+
if args.dev_tools_hot_reload_max_retry != 8:
|
|
372
|
+
run_kwargs["dev_tools_hot_reload_max_retry"] = args.dev_tools_hot_reload_max_retry
|
|
373
|
+
if args.dev_tools_silence_routes_logging:
|
|
374
|
+
run_kwargs["dev_tools_silence_routes_logging"] = args.dev_tools_silence_routes_logging
|
|
375
|
+
if args.dev_tools_disable_version_check:
|
|
376
|
+
run_kwargs["dev_tools_disable_version_check"] = args.dev_tools_disable_version_check
|
|
377
|
+
if args.dev_tools_prune_errors:
|
|
378
|
+
run_kwargs["dev_tools_prune_errors"] = args.dev_tools_prune_errors
|
|
379
|
+
|
|
380
|
+
# Open browser if requested
|
|
381
|
+
if args.open:
|
|
382
|
+
server_url = f"http://{args.host}:{args.port}"
|
|
383
|
+
console.print("Opening browser...")
|
|
384
|
+
webbrowser.open(server_url)
|
|
385
|
+
|
|
386
|
+
app.run(**run_kwargs)
|
|
387
|
+
|
|
388
|
+
# The server has been stopped normally, stop running.
|
|
389
|
+
keep_running = False
|
|
390
|
+
except Exception as e:
|
|
391
|
+
if not keep_running or isinstance(e, KeyboardInterrupt):
|
|
392
|
+
raise ApplicationError("Error running app", str(e)) from e
|
|
393
|
+
|
|
394
|
+
console.print_exception()
|
|
395
|
+
console.print("\n\n")
|
|
396
|
+
|
|
397
|
+
# Create a progress with spinner for waiting
|
|
398
|
+
progress = Progress(
|
|
399
|
+
SpinnerColumn(),
|
|
400
|
+
TextColumn("[cyan]Waiting for changes..."),
|
|
401
|
+
console=console,
|
|
402
|
+
)
|
|
403
|
+
with Live(progress, console=console, refresh_per_second=10):
|
|
404
|
+
progress.add_task("waiting", total=None)
|
|
405
|
+
|
|
406
|
+
# Resolve the actual file path for watching
|
|
407
|
+
module_spec = args.app.split(":")[0] if ":" in args.app else args.app
|
|
408
|
+
if not module_spec.endswith(".py"):
|
|
409
|
+
module_spec += ".py"
|
|
410
|
+
# Handle path separators
|
|
411
|
+
if "/" in module_spec or "\\" in module_spec:
|
|
412
|
+
actual_app_file = os.path.abspath(module_spec)
|
|
413
|
+
else:
|
|
414
|
+
# Current directory case
|
|
415
|
+
actual_app_file = os.path.abspath(module_spec)
|
|
416
|
+
|
|
417
|
+
await until_change(collect_module_files, actual_app_file)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
class PublishCommand(BaseCommand):
|
|
421
|
+
"""Deploy application to Plotly Cloud."""
|
|
422
|
+
|
|
423
|
+
name = "publish"
|
|
424
|
+
group = "app"
|
|
425
|
+
short_description = "📦 Publish application to Plotly Cloud"
|
|
426
|
+
description = "Package and publish your Dash application to Plotly Cloud."
|
|
427
|
+
arguments: List[CommandArgument] = [
|
|
428
|
+
{
|
|
429
|
+
"name": "--project-path",
|
|
430
|
+
"default": ".",
|
|
431
|
+
"help": "Path to project directory to deploy (default: current directory)",
|
|
432
|
+
},
|
|
433
|
+
{
|
|
434
|
+
"name": "--config",
|
|
435
|
+
"default": "plotly-cloud.toml",
|
|
436
|
+
"help": "Path to configuration file (default: plotly-cloud.toml)",
|
|
437
|
+
},
|
|
438
|
+
{
|
|
439
|
+
"name": "--name",
|
|
440
|
+
"help": "Application name (will prompt if not provided for first deployment)",
|
|
441
|
+
},
|
|
442
|
+
{
|
|
443
|
+
"name": "--output",
|
|
444
|
+
"help": "Output path for deployment zip file (default: temporary file)",
|
|
445
|
+
},
|
|
446
|
+
{
|
|
447
|
+
"name": "--keep-zip",
|
|
448
|
+
"action": "store_true",
|
|
449
|
+
"help": "Keep the deployment zip file after upload",
|
|
450
|
+
},
|
|
451
|
+
{
|
|
452
|
+
"name": "--poll-status",
|
|
453
|
+
"type": lambda x: x.lower() in ("true", "1", "yes", "on"), # type: ignore
|
|
454
|
+
"default": True,
|
|
455
|
+
"help": "Poll deployment status until completion (default: True)",
|
|
456
|
+
},
|
|
457
|
+
{
|
|
458
|
+
"name": "--poll-interval",
|
|
459
|
+
"type": float,
|
|
460
|
+
"default": 1.0,
|
|
461
|
+
"help": "Polling interval in seconds (default: 1.0)",
|
|
462
|
+
},
|
|
463
|
+
{
|
|
464
|
+
"name": "--poll-timeout",
|
|
465
|
+
"type": int,
|
|
466
|
+
"default": 180,
|
|
467
|
+
"help": "Polling timeout in seconds (default: 180 = 3 minutes)",
|
|
468
|
+
},
|
|
469
|
+
]
|
|
470
|
+
|
|
471
|
+
@classmethod
|
|
472
|
+
async def _poll_deployment_status(
|
|
473
|
+
cls, deploy_client: "DeploymentClient", app_id: str, poll_interval: float = 1.0, timeout_seconds: int = 180
|
|
474
|
+
) -> str:
|
|
475
|
+
"""Poll deployment status until completion.
|
|
476
|
+
|
|
477
|
+
Args:
|
|
478
|
+
deploy_client: The deployment client
|
|
479
|
+
app_id: Application ID to poll
|
|
480
|
+
poll_interval: Polling interval in seconds
|
|
481
|
+
timeout_seconds: Timeout in seconds (default: 180 = 3 minutes)
|
|
482
|
+
|
|
483
|
+
Returns:
|
|
484
|
+
Final status
|
|
485
|
+
"""
|
|
486
|
+
# Define terminal states
|
|
487
|
+
error_states = {"BUILD_FAILED", "PENDING_ENTITLEMENTS", "FAILING"}
|
|
488
|
+
success_states = {"RUNNING"}
|
|
489
|
+
terminal_states = error_states | success_states
|
|
490
|
+
|
|
491
|
+
# Start with STARTING status, wait 0.5 seconds before first poll
|
|
492
|
+
current_status = "STARTING"
|
|
493
|
+
start_time = time.time()
|
|
494
|
+
|
|
495
|
+
with Live(cls._create_status_display(current_status), refresh_per_second=4) as live:
|
|
496
|
+
await asyncio.sleep(0.5)
|
|
497
|
+
|
|
498
|
+
while current_status not in terminal_states:
|
|
499
|
+
# Check timeout
|
|
500
|
+
if time.time() - start_time > timeout_seconds:
|
|
501
|
+
current_status = "TIMEOUT"
|
|
502
|
+
live.update(cls._create_status_display(current_status))
|
|
503
|
+
break
|
|
504
|
+
|
|
505
|
+
status_data = await deploy_client.get_app_status(app_id)
|
|
506
|
+
new_status = status_data.get("status", "STARTING")
|
|
507
|
+
|
|
508
|
+
if new_status != current_status:
|
|
509
|
+
current_status = new_status
|
|
510
|
+
live.update(cls._create_status_display(current_status))
|
|
511
|
+
|
|
512
|
+
if current_status in terminal_states:
|
|
513
|
+
break
|
|
514
|
+
|
|
515
|
+
await asyncio.sleep(poll_interval)
|
|
516
|
+
|
|
517
|
+
return current_status
|
|
518
|
+
|
|
519
|
+
@classmethod
|
|
520
|
+
def _create_status_display(cls, status: str) -> Panel:
|
|
521
|
+
"""Create a rich display for the current status.
|
|
522
|
+
|
|
523
|
+
Args:
|
|
524
|
+
status: Current deployment status
|
|
525
|
+
|
|
526
|
+
Returns:
|
|
527
|
+
Rich Panel with status information
|
|
528
|
+
"""
|
|
529
|
+
if status == "TIMEOUT":
|
|
530
|
+
status_text = Text()
|
|
531
|
+
status_text.append("Timeout ", style="bold")
|
|
532
|
+
status_text.append("Timeout", style="bold yellow")
|
|
533
|
+
return Panel(status_text, title="🚀 Publish Status", border_style="yellow", padding=(0, 1))
|
|
534
|
+
|
|
535
|
+
status_info = REVISION_STATUS_MAP.get(status, {"label": status, "emoji": "⏳", "color": "white"})
|
|
536
|
+
|
|
537
|
+
status_text = Text()
|
|
538
|
+
status_text.append(f"{status_info['emoji']} ", style="bold")
|
|
539
|
+
status_text.append(status_info["label"], style=f"bold {status_info['color']}")
|
|
540
|
+
|
|
541
|
+
return Panel(status_text, title="🚀 Publish Status", border_style="blue", padding=(0, 1))
|
|
542
|
+
|
|
543
|
+
@classmethod
|
|
544
|
+
async def execute(cls, args: ParsedArguments) -> None:
|
|
545
|
+
"""Execute deploy command."""
|
|
546
|
+
# Check for user input needs before starting progress
|
|
547
|
+
project_path = os.path.abspath(args.project_path)
|
|
548
|
+
config_path = get_config_path(project_path, args.config)
|
|
549
|
+
config = load_deployment_config(config_path)
|
|
550
|
+
|
|
551
|
+
app_id = config.get("app_id")
|
|
552
|
+
is_new_app = app_id is None
|
|
553
|
+
deployment_warning = None # Track deployment warnings
|
|
554
|
+
|
|
555
|
+
if is_new_app:
|
|
556
|
+
app_name = args.name or config.get("name")
|
|
557
|
+
if not app_name:
|
|
558
|
+
# Use folder name as default suggestion
|
|
559
|
+
folder_name = os.path.basename(os.path.abspath(args.project_path))
|
|
560
|
+
console.print("App name is required the first time you publish an app.")
|
|
561
|
+
|
|
562
|
+
app_name = Prompt.ask("Enter app name: ", default=folder_name).strip()
|
|
563
|
+
if not app_name:
|
|
564
|
+
raise ApplicationError("App name cannot be empty.")
|
|
565
|
+
args.name = app_name
|
|
566
|
+
|
|
567
|
+
with Progress(
|
|
568
|
+
SpinnerColumn(),
|
|
569
|
+
TextColumn("[progress.description]{task.description}"),
|
|
570
|
+
console=console,
|
|
571
|
+
) as progress:
|
|
572
|
+
# Get OAuth client for authentication
|
|
573
|
+
auth_task = progress.add_task("🔐 Checking authentication...", total=None)
|
|
574
|
+
|
|
575
|
+
client_id = cloud_config.get_oauth_client_id()
|
|
576
|
+
oauth_client = OAuthClient(client_id)
|
|
577
|
+
|
|
578
|
+
# Check if authenticated, if not, perform login
|
|
579
|
+
if not await oauth_client.is_authenticated():
|
|
580
|
+
progress.update(auth_task, description="🔐 Authentication required - logging in...")
|
|
581
|
+
await oauth_client.login(open_browser=True)
|
|
582
|
+
progress.update(auth_task, description="✓ Successfully authenticated!")
|
|
583
|
+
else:
|
|
584
|
+
progress.update(auth_task, description="✓ Already authenticated!")
|
|
585
|
+
|
|
586
|
+
# Get the access token
|
|
587
|
+
auth_token = await oauth_client.get_access_token()
|
|
588
|
+
if not auth_token:
|
|
589
|
+
raise ApplicationError("Unable to retrieve access token. Please try logging in again.")
|
|
590
|
+
|
|
591
|
+
progress.remove_task(auth_task)
|
|
592
|
+
|
|
593
|
+
# Validate project path
|
|
594
|
+
validate_task = progress.add_task("Validating project...", total=None)
|
|
595
|
+
project_path = os.path.abspath(args.project_path)
|
|
596
|
+
if not os.path.exists(project_path):
|
|
597
|
+
raise ApplicationError(f"Project path does not exist: {project_path}")
|
|
598
|
+
|
|
599
|
+
if not os.path.isdir(project_path):
|
|
600
|
+
raise ApplicationError(f"Project path is not a directory: {project_path}")
|
|
601
|
+
|
|
602
|
+
# Get configuration file path
|
|
603
|
+
config_path = get_config_path(project_path, args.config)
|
|
604
|
+
|
|
605
|
+
# Initialize deployment client
|
|
606
|
+
async with DeploymentClient(oauth_client) as deploy_client:
|
|
607
|
+
if config:
|
|
608
|
+
progress.update(validate_task, description="Loaded existing configuration")
|
|
609
|
+
else:
|
|
610
|
+
progress.update(validate_task, description="No existing configuration found")
|
|
611
|
+
|
|
612
|
+
# Validate dependencies
|
|
613
|
+
progress.update(validate_task, description="Validating project dependencies...")
|
|
614
|
+
validate_dependencies(project_path)
|
|
615
|
+
progress.update(validate_task, description="✓ Dependencies validated!")
|
|
616
|
+
|
|
617
|
+
# Determine output path for zip file
|
|
618
|
+
if args.output:
|
|
619
|
+
zip_path = os.path.abspath(args.output)
|
|
620
|
+
else:
|
|
621
|
+
# Create temporary file
|
|
622
|
+
temp_file = tempfile.NamedTemporaryFile(suffix=".zip", delete=False)
|
|
623
|
+
zip_path = temp_file.name
|
|
624
|
+
temp_file.close()
|
|
625
|
+
|
|
626
|
+
progress.remove_task(validate_task)
|
|
627
|
+
|
|
628
|
+
try:
|
|
629
|
+
# Create deployment zip
|
|
630
|
+
package_task = progress.add_task("Creating deployment package...", total=None)
|
|
631
|
+
zip_size = await create_deployment_zip(project_path, zip_path)
|
|
632
|
+
progress.update(
|
|
633
|
+
package_task, description=f"✓ Created deployment package: {zip_size / (1024 * 1024):.1f}MB"
|
|
634
|
+
)
|
|
635
|
+
|
|
636
|
+
# Determine if this is a new app or existing app
|
|
637
|
+
app_id = config.get("app_id")
|
|
638
|
+
is_new_app = app_id is None
|
|
639
|
+
|
|
640
|
+
if is_new_app:
|
|
641
|
+
# New app - name is required
|
|
642
|
+
app_name = args.name or config.get("name")
|
|
643
|
+
assert app_name
|
|
644
|
+
|
|
645
|
+
# Create the app with deployment
|
|
646
|
+
progress.remove_task(package_task)
|
|
647
|
+
deploy_task = progress.add_task(f"Creating new app: {app_name}...", total=None)
|
|
648
|
+
|
|
649
|
+
app_data = await deploy_client.create_app(app_name, zip_path)
|
|
650
|
+
|
|
651
|
+
progress.update(
|
|
652
|
+
deploy_task,
|
|
653
|
+
description=f"✓ Created new app: {app_name} (ID: {app_data.get('app_id')})",
|
|
654
|
+
)
|
|
655
|
+
|
|
656
|
+
# Update config with app metadata
|
|
657
|
+
config.update(
|
|
658
|
+
{
|
|
659
|
+
"name": str(app_name),
|
|
660
|
+
"app_id": app_data.get("id", ""),
|
|
661
|
+
"app_url": app_data.get("app_url", ""),
|
|
662
|
+
}
|
|
663
|
+
)
|
|
664
|
+
|
|
665
|
+
# Save updated config
|
|
666
|
+
progress.update(deploy_task, description="Saving configuration...")
|
|
667
|
+
save_deployment_config(config, config_path)
|
|
668
|
+
progress.update(deploy_task, description="✓ Configuration saved!")
|
|
669
|
+
else:
|
|
670
|
+
# Existing app - publish update with deployment
|
|
671
|
+
assert app_id is not None # We know this is not None in else branch
|
|
672
|
+
progress.remove_task(package_task)
|
|
673
|
+
deploy_task = progress.add_task(f"Updating existing app (ID: {app_id})...", total=None)
|
|
674
|
+
app_data = await deploy_client.publish_app(app_id, zip_path)
|
|
675
|
+
progress.update(deploy_task, description=f"✓ Published app update (ID: {app_id})")
|
|
676
|
+
|
|
677
|
+
# Update config with any new data
|
|
678
|
+
if app_data.get("app_url"):
|
|
679
|
+
config["app_url"] = app_data.get("app_url", "")
|
|
680
|
+
progress.update(deploy_task, description="Updating configuration...")
|
|
681
|
+
save_deployment_config(config, config_path)
|
|
682
|
+
progress.update(deploy_task, description="✓ Configuration updated!")
|
|
683
|
+
|
|
684
|
+
# Poll for deployment status if enabled (after Progress context ends)
|
|
685
|
+
if args.poll_status:
|
|
686
|
+
progress.stop()
|
|
687
|
+
console.print()
|
|
688
|
+
final_app_id = config.get("app_id")
|
|
689
|
+
if final_app_id:
|
|
690
|
+
final_status = await cls._poll_deployment_status(
|
|
691
|
+
deploy_client, final_app_id, args.poll_interval, args.poll_timeout
|
|
692
|
+
)
|
|
693
|
+
|
|
694
|
+
# Set deployment warning based on final status
|
|
695
|
+
if final_status in {"BUILD_FAILED", "PENDING_ENTITLEMENTS", "FAILING", "TIMEOUT"}:
|
|
696
|
+
if final_status == "TIMEOUT":
|
|
697
|
+
timeout_minutes = args.poll_timeout / 60
|
|
698
|
+
deployment_warning = (
|
|
699
|
+
f"Deployment status polling timed out after {timeout_minutes:.1f} minutes. "
|
|
700
|
+
"Check the Plotly Cloud dashboard for current status."
|
|
701
|
+
)
|
|
702
|
+
else:
|
|
703
|
+
status_info = REVISION_STATUS_MAP.get(final_status, {"label": final_status})
|
|
704
|
+
deployment_warning = (
|
|
705
|
+
f"Publishing app failed with status: {status_info['label']}. "
|
|
706
|
+
"Check the Plotly Cloud dashboard for further details."
|
|
707
|
+
)
|
|
708
|
+
|
|
709
|
+
progress.start()
|
|
710
|
+
|
|
711
|
+
progress.remove_task(deploy_task)
|
|
712
|
+
|
|
713
|
+
finally:
|
|
714
|
+
# Clean up temporary zip file unless user wants to keep it
|
|
715
|
+
if not args.keep_zip and (not args.output):
|
|
716
|
+
try:
|
|
717
|
+
os.unlink(zip_path)
|
|
718
|
+
except OSError:
|
|
719
|
+
pass
|
|
720
|
+
elif args.keep_zip or args.output:
|
|
721
|
+
cleanup_task = progress.add_task("Keeping deployment package...", total=None)
|
|
722
|
+
progress.update(cleanup_task, description=f"Deployment package saved: {zip_path}")
|
|
723
|
+
progress.remove_task(cleanup_task)
|
|
724
|
+
|
|
725
|
+
# Show deployment warning if there was one
|
|
726
|
+
if deployment_warning:
|
|
727
|
+
console.print()
|
|
728
|
+
console.print(
|
|
729
|
+
Panel(
|
|
730
|
+
f"⚠ {deployment_warning}",
|
|
731
|
+
title="Warning",
|
|
732
|
+
border_style="magenta",
|
|
733
|
+
)
|
|
734
|
+
)
|
|
735
|
+
else:
|
|
736
|
+
console.print("🎉 Published app successfully!")
|
|
737
|
+
|
|
738
|
+
# Show app URL if available
|
|
739
|
+
app_url = config.get("app_url")
|
|
740
|
+
if app_url and not deployment_warning:
|
|
741
|
+
console.print(f"Your app is available at: {format_app_url(app_url)}")
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
class StatusCommand(BaseCommand):
|
|
745
|
+
"""Get the status of an app published to Plotly Cloud."""
|
|
746
|
+
|
|
747
|
+
name = "status"
|
|
748
|
+
group = "app"
|
|
749
|
+
short_description = "📊 Get a published app's current status."
|
|
750
|
+
description = "Retrieve the current status and details of your published app."
|
|
751
|
+
arguments: List[CommandArgument] = [
|
|
752
|
+
{
|
|
753
|
+
"name": "--project-path",
|
|
754
|
+
"default": ".",
|
|
755
|
+
"help": "Path to project directory",
|
|
756
|
+
},
|
|
757
|
+
{
|
|
758
|
+
"name": "--config",
|
|
759
|
+
"default": "plotly-cloud.toml",
|
|
760
|
+
"help": "Path to configuration file",
|
|
761
|
+
},
|
|
762
|
+
]
|
|
763
|
+
|
|
764
|
+
@classmethod
|
|
765
|
+
async def execute(cls, args: ParsedArguments) -> None:
|
|
766
|
+
"""Execute status command."""
|
|
767
|
+
# Load configuration
|
|
768
|
+
project_path = os.path.abspath(args.project_path)
|
|
769
|
+
config_path = get_config_path(project_path, args.config)
|
|
770
|
+
config = load_deployment_config(config_path)
|
|
771
|
+
|
|
772
|
+
columns_display = ["name", "app_url", "is_view_private", "status", "created_at"]
|
|
773
|
+
|
|
774
|
+
# Check if app_id exists
|
|
775
|
+
app_id = config.get("app_id")
|
|
776
|
+
if not app_id:
|
|
777
|
+
raise ApplicationError("No app_id found in configuration. Publish your app first using 'plotly publish'.")
|
|
778
|
+
|
|
779
|
+
# Get OAuth client for authentication
|
|
780
|
+
client_id = cloud_config.get_oauth_client_id()
|
|
781
|
+
oauth_client = OAuthClient(client_id)
|
|
782
|
+
|
|
783
|
+
# Check if authenticated
|
|
784
|
+
if not await oauth_client.is_authenticated():
|
|
785
|
+
raise ApplicationError("Not authenticated. Please run 'plotly login' first.")
|
|
786
|
+
|
|
787
|
+
# Get access token
|
|
788
|
+
auth_token = await oauth_client.get_access_token()
|
|
789
|
+
if not auth_token:
|
|
790
|
+
raise ApplicationError("Unable to retrieve access token. Please try logging in again.")
|
|
791
|
+
|
|
792
|
+
# Get app status
|
|
793
|
+
async with DeploymentClient(oauth_client) as deploy_client:
|
|
794
|
+
status_data = await deploy_client.get_app_status(app_id)
|
|
795
|
+
|
|
796
|
+
# Create a table for the status information
|
|
797
|
+
table = Table(show_header=True, header_style="bold blue")
|
|
798
|
+
table.add_column("Property", style="cyan", width=20)
|
|
799
|
+
table.add_column("Value", style="white")
|
|
800
|
+
|
|
801
|
+
# Add rows for each key-value pair
|
|
802
|
+
for key, value in status_data.items():
|
|
803
|
+
if not args.verbose and key not in columns_display:
|
|
804
|
+
continue
|
|
805
|
+
|
|
806
|
+
# Format the key to be more readable
|
|
807
|
+
display_key = key.replace("_", " ").title()
|
|
808
|
+
|
|
809
|
+
# Handle different value types
|
|
810
|
+
if isinstance(value, bool):
|
|
811
|
+
display_value = "✓ Yes" if value else "✗ No"
|
|
812
|
+
elif value is None:
|
|
813
|
+
display_value = "—"
|
|
814
|
+
elif isinstance(value, (list, dict)):
|
|
815
|
+
display_value = json.dumps(value, indent=2)
|
|
816
|
+
elif key == "app_url":
|
|
817
|
+
# Format the app URL properly if it's just a subdomain
|
|
818
|
+
formatted_url = format_app_url(str(value)) if value else None
|
|
819
|
+
display_value = f"[underline][blue]{formatted_url or config.get('app_url', '—')}[/blue][/underline]"
|
|
820
|
+
elif key == "status" and isinstance(value, str) and value in REVISION_STATUS_MAP:
|
|
821
|
+
# Use revision status mapping for user-friendly display
|
|
822
|
+
status_info = REVISION_STATUS_MAP[value]
|
|
823
|
+
display_value = (
|
|
824
|
+
f"{status_info['emoji']} [{status_info['color']}]{status_info['label']}[/{status_info['color']}]"
|
|
825
|
+
)
|
|
826
|
+
else:
|
|
827
|
+
display_value = str(value)
|
|
828
|
+
|
|
829
|
+
table.add_row(display_key, display_value)
|
|
830
|
+
|
|
831
|
+
console.print()
|
|
832
|
+
console.print(Panel.fit(table, title="📊 Application Status", border_style="bold blue"))
|
|
833
|
+
console.print()
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
class WhoamiCommand(BaseCommand):
|
|
837
|
+
"""Show current user information."""
|
|
838
|
+
|
|
839
|
+
name = "whoami"
|
|
840
|
+
group = "user"
|
|
841
|
+
short_description = "👤 Show current user information"
|
|
842
|
+
description = "Display the username if currently logged in with a valid token."
|
|
843
|
+
arguments: List[CommandArgument] = []
|
|
844
|
+
|
|
845
|
+
@classmethod
|
|
846
|
+
async def execute(cls, args: ParsedArguments) -> None:
|
|
847
|
+
"""Execute the whoami command."""
|
|
848
|
+
client_id = cloud_config.get_oauth_client_id()
|
|
849
|
+
oauth_client = OAuthClient(client_id)
|
|
850
|
+
|
|
851
|
+
# Check if authenticated
|
|
852
|
+
if not await oauth_client.is_authenticated():
|
|
853
|
+
console.print("✗ Not logged in")
|
|
854
|
+
return
|
|
855
|
+
|
|
856
|
+
# Load credentials to get user info
|
|
857
|
+
credentials = await oauth_client.load_credentials()
|
|
858
|
+
if not credentials:
|
|
859
|
+
console.print("✗ No credentials found")
|
|
860
|
+
return
|
|
861
|
+
|
|
862
|
+
# Try to refresh token to validate it
|
|
863
|
+
try:
|
|
864
|
+
await oauth_client.refresh_access_token()
|
|
865
|
+
|
|
866
|
+
# Extract user information from credentials
|
|
867
|
+
user_info = credentials.get("user", {})
|
|
868
|
+
email = user_info.get("email") or credentials.get("email")
|
|
869
|
+
|
|
870
|
+
if email:
|
|
871
|
+
console.print(f"✓ Logged in as: [bold green]{email}[/bold green]")
|
|
872
|
+
else:
|
|
873
|
+
console.print("✓ Logged in (no email information available)")
|
|
874
|
+
|
|
875
|
+
except (TokenError, CredentialError):
|
|
876
|
+
# Token is invalid and cannot be refreshed, clear credentials
|
|
877
|
+
await oauth_client.logout()
|
|
878
|
+
console.print("✗ Invalid token - credentials cleared")
|