plotly-cloud 0.1.0__py3-none-any.whl

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