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,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")