plotly-cloud 0.3.0__tar.gz → 0.4.0__tar.gz

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.
Files changed (22) hide show
  1. {plotly_cloud-0.3.0 → plotly_cloud-0.4.0}/PKG-INFO +1 -1
  2. {plotly_cloud-0.3.0 → plotly_cloud-0.4.0}/plotly_cloud/__init__.py +1 -1
  3. {plotly_cloud-0.3.0 → plotly_cloud-0.4.0}/plotly_cloud/_api_types.py +27 -0
  4. {plotly_cloud-0.3.0 → plotly_cloud-0.4.0}/plotly_cloud/_commands.py +178 -0
  5. {plotly_cloud-0.3.0 → plotly_cloud-0.4.0}/plotly_cloud/_deploy.py +167 -6
  6. {plotly_cloud-0.3.0 → plotly_cloud-0.4.0}/pyproject.toml +1 -1
  7. {plotly_cloud-0.3.0 → plotly_cloud-0.4.0}/.gitignore +0 -0
  8. {plotly_cloud-0.3.0 → plotly_cloud-0.4.0}/LICENSE +0 -0
  9. {plotly_cloud-0.3.0 → plotly_cloud-0.4.0}/README.md +0 -0
  10. {plotly_cloud-0.3.0 → plotly_cloud-0.4.0}/plotly_cloud/_changes.py +0 -0
  11. {plotly_cloud-0.3.0 → plotly_cloud-0.4.0}/plotly_cloud/_cloud_env.py +0 -0
  12. {plotly_cloud-0.3.0 → plotly_cloud-0.4.0}/plotly_cloud/_definitions.py +0 -0
  13. {plotly_cloud-0.3.0 → plotly_cloud-0.4.0}/plotly_cloud/_devtool_hooks.py +0 -0
  14. {plotly_cloud-0.3.0 → plotly_cloud-0.4.0}/plotly_cloud/_devtool_publish_rpc.py +0 -0
  15. {plotly_cloud-0.3.0 → plotly_cloud-0.4.0}/plotly_cloud/_oauth.py +0 -0
  16. {plotly_cloud-0.3.0 → plotly_cloud-0.4.0}/plotly_cloud/_parser.py +0 -0
  17. {plotly_cloud-0.3.0 → plotly_cloud-0.4.0}/plotly_cloud/_run_sync.py +0 -0
  18. {plotly_cloud-0.3.0 → plotly_cloud-0.4.0}/plotly_cloud/cli.py +0 -0
  19. {plotly_cloud-0.3.0 → plotly_cloud-0.4.0}/plotly_cloud/cloud-env.toml +0 -0
  20. {plotly_cloud-0.3.0 → plotly_cloud-0.4.0}/plotly_cloud/cloud_devtools.css +0 -0
  21. {plotly_cloud-0.3.0 → plotly_cloud-0.4.0}/plotly_cloud/cloud_devtools.js +0 -0
  22. {plotly_cloud-0.3.0 → plotly_cloud-0.4.0}/plotly_cloud/exceptions.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plotly-cloud
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: Python extension for Plotly Cloud with CLI and Dash dev tools integration
5
5
  Project-URL: Repository, https://github.com/plotly/plotly-cloud-extension
6
6
  License-File: LICENSE
@@ -1,3 +1,3 @@
1
1
  """Plotly Cloud Extension package."""
2
2
 
3
- __version__ = "0.3.0"
3
+ __version__ = "0.4.0"
@@ -56,3 +56,30 @@ class App(TypedDict):
56
56
  class UserTeamMembership(TypedDict):
57
57
  team: Team
58
58
  role: str
59
+
60
+
61
+ class Build(TypedDict):
62
+ id: NotRequired[str]
63
+ revisionId: NotRequired[str]
64
+ imageRef: NotRequired[str]
65
+ sourceKey: NotRequired[str]
66
+ pythonVersion: NotRequired[str]
67
+
68
+
69
+ class Revision(TypedDict):
70
+ id: str
71
+ workloadId: NotRequired[str]
72
+ revisionNumber: NotRequired[int]
73
+ status: NotRequired[str]
74
+ replicaSet: NotRequired[str]
75
+ createdBy: NotRequired[str]
76
+ createdByEmail: NotRequired[str]
77
+ createdByFirstName: NotRequired[str]
78
+ createdByLastName: NotRequired[str]
79
+ build: NotRequired[Build]
80
+
81
+
82
+ class DashlogsNotFound(TypedDict):
83
+ error: str
84
+ reason: str
85
+ retention_days: int
@@ -34,6 +34,7 @@ from plotly_cloud._parser import ParsedArguments
34
34
 
35
35
  from .exceptions import (
36
36
  ApplicationError,
37
+ AuthenticationError,
37
38
  CredentialError,
38
39
  DashAppError,
39
40
  ModuleImportError,
@@ -787,6 +788,95 @@ class PublishCommand(BaseCommand):
787
788
  console.print(f"Your app is available at: {format_app_url(app_url)}")
788
789
 
789
790
 
791
+ class ListCommand(BaseCommand):
792
+ """List all apps for the authenticated user."""
793
+
794
+ name = "list"
795
+ group = "app"
796
+ short_description = "📋 List all your published apps"
797
+ description = "Display all applications published to Plotly Cloud."
798
+ arguments: List[CommandArgument] = [
799
+ {
800
+ "name": "--team",
801
+ "help": "Filter apps by team name or slug",
802
+ },
803
+ {
804
+ "name": "--team-id",
805
+ "help": "Filter apps by team ID",
806
+ },
807
+ ]
808
+
809
+ @classmethod
810
+ async def execute(cls, args: ParsedArguments) -> None:
811
+ """Execute list command."""
812
+ client_id = cloud_config.get_oauth_client_id()
813
+ oauth_client = OAuthClient(client_id, token=getattr(args, "api_key", None))
814
+
815
+ if not await oauth_client.is_authenticated():
816
+ raise ApplicationError("Not authenticated. Please run 'plotly login' first.")
817
+
818
+ auth_token = await oauth_client.get_access_token()
819
+ if not auth_token:
820
+ raise ApplicationError("Unable to retrieve access token. Please try logging in again.")
821
+
822
+ async with DeploymentClient(oauth_client) as deploy_client:
823
+ # Resolve team name/slug to team_id if needed
824
+ team_id = getattr(args, "team_id", None)
825
+ team_name_or_slug = getattr(args, "team", None)
826
+
827
+ if team_name_or_slug and not team_id:
828
+ teams = await deploy_client.list_teams()
829
+ for team in teams:
830
+ if (
831
+ team.get("name", "").lower() == team_name_or_slug.lower()
832
+ or team.get("team_slug", "").lower() == team_name_or_slug.lower()
833
+ or team.get("id", "").lower() == team_name_or_slug.lower()
834
+ ):
835
+ team_id = team.get("id")
836
+ break
837
+ if not team_id:
838
+ raise ApplicationError(
839
+ f"Team '{team_name_or_slug}' not found. Use 'plotly user teams' to list available teams."
840
+ )
841
+
842
+ apps = await deploy_client.list_apps(team_id)
843
+
844
+ if not apps:
845
+ console.print(Panel("No apps found.", title="📋 Apps", border_style="yellow"))
846
+ return
847
+
848
+ table = Table(show_header=True, header_style="bold blue", expand=True)
849
+ table.add_column("Name", style="cyan", no_wrap=True, ratio=3)
850
+ table.add_column("Status", style="white", no_wrap=True, ratio=1)
851
+ table.add_column("App ID", style="dim", no_wrap=True, ratio=1)
852
+ table.add_column("Published", style="magenta", no_wrap=True, ratio=1)
853
+
854
+ for app in apps:
855
+ status = app.get("status", "—")
856
+ status_info = REVISION_STATUS_MAP.get(status, {"label": status, "emoji": "?", "color": "white"})
857
+ status_display = (
858
+ f"{status_info['emoji']} [{status_info['color']}]{status_info['label']}[/{status_info['color']}]"
859
+ )
860
+
861
+ last_published = app.get("last_published", "—")
862
+ if last_published and last_published != "—":
863
+ last_published = last_published[:10]
864
+
865
+ app_id = app.get("id", "—")
866
+ app_id_short = app_id[:8] if app_id != "—" else "—"
867
+
868
+ table.add_row(
869
+ app.get("name", "—"),
870
+ status_display,
871
+ app_id_short,
872
+ last_published,
873
+ )
874
+
875
+ console.print()
876
+ console.print(Panel.fit(table, title="📋 Your Apps", border_style="bold blue"))
877
+ console.print()
878
+
879
+
790
880
  class StatusCommand(BaseCommand):
791
881
  """Get the status of an app published to Plotly Cloud."""
792
882
 
@@ -932,6 +1022,94 @@ class TeamsCommand(BaseCommand):
932
1022
  console.print()
933
1023
 
934
1024
 
1025
+ class LogsCommand(BaseCommand):
1026
+ """Get build or runtime logs for a published app."""
1027
+
1028
+ name = "logs"
1029
+ group = "app"
1030
+ short_description = "📋 Get logs for a published app"
1031
+ description = "Retrieve build or runtime logs for your published application."
1032
+ arguments: List[CommandArgument] = [
1033
+ {
1034
+ "name": "--project-path",
1035
+ "default": ".",
1036
+ "help": "Path to project directory",
1037
+ },
1038
+ {
1039
+ "name": "--config",
1040
+ "default": "plotly-cloud.toml",
1041
+ "help": "Path to configuration file",
1042
+ },
1043
+ {
1044
+ "name": "--type",
1045
+ "default": "build",
1046
+ "choices": ["build", "runtime"],
1047
+ "help": "Type of logs to retrieve (default: build)",
1048
+ },
1049
+ {
1050
+ "name": "--revision",
1051
+ "help": "Revision ID (default: latest revision)",
1052
+ },
1053
+ ]
1054
+
1055
+ @classmethod
1056
+ async def execute(cls, args: ParsedArguments) -> None:
1057
+ """Execute logs command."""
1058
+ project_path = os.path.abspath(args.project_path)
1059
+ config_path = get_config_path(project_path, args.config)
1060
+ config = load_deployment_config(config_path)
1061
+
1062
+ app_id = config.get("app_id")
1063
+ if not app_id:
1064
+ raise ApplicationError("No app_id found in configuration. Publish your app first using 'plotly publish'.")
1065
+
1066
+ client_id = cloud_config.get_oauth_client_id()
1067
+ oauth_client = OAuthClient(client_id, token=getattr(args, "api_key", None))
1068
+
1069
+ if not await oauth_client.is_authenticated():
1070
+ raise AuthenticationError("Not authenticated. Please run 'plotly login' first.")
1071
+
1072
+ auth_token = await oauth_client.get_access_token()
1073
+ if not auth_token:
1074
+ raise AuthenticationError("Unable to retrieve access token. Please try logging in again.")
1075
+
1076
+ log_type = getattr(args, "type", "build")
1077
+ revision_id = getattr(args, "revision", None)
1078
+
1079
+ async with DeploymentClient(oauth_client) as deploy_client:
1080
+ with Progress(
1081
+ SpinnerColumn(),
1082
+ TextColumn("[progress.description]{task.description}"),
1083
+ console=console,
1084
+ ) as progress:
1085
+ # Resolve revision ID if not provided
1086
+ if not revision_id:
1087
+ task = progress.add_task("Fetching latest revision...", total=None)
1088
+ revisions = await deploy_client.get_app_revisions(app_id)
1089
+ if not revisions:
1090
+ raise ApplicationError("No revisions found for this app.")
1091
+ revision_id = revisions[0]["id"]
1092
+ progress.update(task, description=f"✓ Using revision {revision_id[:8]}...")
1093
+
1094
+ # Fetch logs
1095
+ task = progress.add_task(f"Fetching {log_type} logs...", total=None)
1096
+ log_text, _ = await deploy_client.get_logs(app_id, revision_id, log_type)
1097
+ progress.remove_task(task)
1098
+
1099
+ if not log_text.strip():
1100
+ console.print(f"No {log_type} logs available.")
1101
+ return
1102
+
1103
+ console.print(
1104
+ Panel(
1105
+ log_text,
1106
+ title=f"📋 {log_type.title()} Logs",
1107
+ border_style="blue",
1108
+ expand=True,
1109
+ )
1110
+ )
1111
+
1112
+
935
1113
  class WhoamiCommand(BaseCommand):
936
1114
  """Show current user information."""
937
1115
 
@@ -11,7 +11,7 @@ import httpx
11
11
  import tomli
12
12
  import tomli_w
13
13
 
14
- from plotly_cloud._api_types import App, AppRequest, Team
14
+ from plotly_cloud._api_types import App, AppRequest, DashlogsNotFound, Revision, Team, UserTeamMembership
15
15
  from plotly_cloud._cloud_env import cloud_config
16
16
  from plotly_cloud._definitions import AppDeploymentConfig
17
17
  from plotly_cloud._oauth import OAuthClient
@@ -112,9 +112,7 @@ def should_exclude_path(path: str, exclude_patterns: set[str]) -> bool:
112
112
  return False
113
113
 
114
114
 
115
- async def create_deployment_zip(
116
- project_path: str, output_path: str, skip_size_check: bool = False
117
- ) -> int:
115
+ async def create_deployment_zip(project_path: str, output_path: str, skip_size_check: bool = False) -> int:
118
116
  """
119
117
  Create a zip file for deployment, excluding files based on .gitignore.
120
118
 
@@ -512,8 +510,8 @@ class DeploymentClient:
512
510
  response = await self._client.get(url)
513
511
 
514
512
  if response.status_code == 200:
515
- teams: list[Team] = response.json()
516
- return teams
513
+ memberships: list[UserTeamMembership] = response.json()
514
+ return [m["team"] for m in memberships]
517
515
 
518
516
  token_refreshed = await self._refresh_token_if_needed(response)
519
517
  if token_refreshed:
@@ -524,3 +522,166 @@ class DeploymentClient:
524
522
  raise _handle_error_response(response, "list teams")
525
523
  except httpx.RequestError as e:
526
524
  raise NetworkError("Failed to list teams", str(e)) from e
525
+
526
+ async def list_apps(self, team_id: Optional[str] = None) -> list[App]:
527
+ """List all apps for the authenticated user.
528
+
529
+ Args:
530
+ team_id: Optional team ID to filter apps by
531
+
532
+ Returns:
533
+ List of App objects
534
+
535
+ Raises:
536
+ DeploymentClientError: If client is not initialized
537
+ APIError: If API call fails
538
+ NetworkError: If network request fails
539
+ """
540
+ if not self._client:
541
+ raise DeploymentClientError("Client not initialized. Use within async context manager.")
542
+
543
+ url = f"https://{self.api_base_url}/api/apps"
544
+ params = {}
545
+ if team_id:
546
+ params["teamId"] = team_id
547
+
548
+ try:
549
+ retry_count = 0
550
+ response = None
551
+ while retry_count <= 1:
552
+ response = await self._client.get(url, params=params)
553
+
554
+ if response.status_code == 200:
555
+ apps: list[App] = response.json()
556
+ return apps
557
+
558
+ token_refreshed = await self._refresh_token_if_needed(response)
559
+ if token_refreshed:
560
+ retry_count += 1
561
+ else:
562
+ break
563
+
564
+ raise _handle_error_response(response, "list apps")
565
+ except httpx.RequestError as e:
566
+ raise NetworkError("Failed to list apps", str(e)) from e
567
+
568
+ async def get_app_revisions(self, app_id: str) -> list[Revision]:
569
+ """Get revisions for an application.
570
+
571
+ Args:
572
+ app_id: Application ID
573
+
574
+ Returns:
575
+ List of Revision objects
576
+
577
+ Raises:
578
+ DeploymentClientError: If client is not initialized
579
+ APIError: If API call fails
580
+ NetworkError: If network request fails
581
+ """
582
+ if not self._client:
583
+ raise DeploymentClientError("Client not initialized. Use within async context manager.")
584
+
585
+ url = f"https://{self.api_base_url}/api/app/{app_id}/revisions"
586
+
587
+ try:
588
+ retry_count = 0
589
+ response = None
590
+ while retry_count <= 1:
591
+ response = await self._client.get(url)
592
+
593
+ if response.status_code == 200:
594
+ data = response.json()
595
+ revisions: list[Revision] = data.get("revisions", data) if isinstance(data, dict) else data
596
+ return revisions
597
+
598
+ token_refreshed = await self._refresh_token_if_needed(response)
599
+ if token_refreshed:
600
+ retry_count += 1
601
+ else:
602
+ break
603
+
604
+ raise _handle_error_response(response, "get app revisions")
605
+ except httpx.RequestError as e:
606
+ raise NetworkError("Failed to get app revisions", str(e)) from e
607
+
608
+ async def get_logs(
609
+ self,
610
+ app_id: str,
611
+ revision_id: str,
612
+ log_type: str = "build",
613
+ continue_token: Optional[str] = None,
614
+ ) -> tuple[str, Optional[str]]:
615
+ """Get build or runtime logs for an app revision.
616
+
617
+ Uses the dashlogs streaming endpoints which return log lines
618
+ followed by a JSON meta line with continuation token.
619
+
620
+ Args:
621
+ app_id: Application ID
622
+ revision_id: Revision ID
623
+ log_type: "build" or "runtime"
624
+ continue_token: Continuation token from a previous response
625
+
626
+ Returns:
627
+ Tuple of (log_text, next_continue_token).
628
+ next_continue_token is None if there are no more logs.
629
+
630
+ Raises:
631
+ DeploymentClientError: If client is not initialized
632
+ APIError: If API call fails (including 202 when logs not available)
633
+ NetworkError: If network request fails
634
+ """
635
+ if not self._client:
636
+ raise DeploymentClientError("Client not initialized. Use within async context manager.")
637
+
638
+ url = f"https://{self.api_base_url}/api/app/{app_id}/dashlogs/{revision_id}/{log_type}"
639
+ params = {}
640
+ if continue_token:
641
+ params["continue"] = continue_token
642
+
643
+ try:
644
+ retry_count = 0
645
+ response = None
646
+ while retry_count <= 1:
647
+ response = await self._client.get(url, params=params)
648
+
649
+ if response.status_code == 200:
650
+ body = response.text
651
+ # The last line is a JSON meta line with continuation info
652
+ lines = body.rstrip("\n").split("\n")
653
+ next_token = None
654
+ log_lines = lines
655
+ if lines:
656
+ try:
657
+ meta = json.loads(lines[-1])
658
+ next_token = meta.get("next_token")
659
+ log_lines = lines[:-1]
660
+ except (json.JSONDecodeError, ValueError):
661
+ pass
662
+ return "\n".join(log_lines), next_token
663
+
664
+ if response.status_code == 202:
665
+ not_found: DashlogsNotFound = response.json()
666
+ reason = not_found.get("reason", "unknown")
667
+ if reason == "expired":
668
+ raise APIError(
669
+ "Logs have expired",
670
+ status_code=202,
671
+ details=f"Logs are deleted after {not_found.get('retention_days', '?')} days",
672
+ )
673
+ raise APIError(
674
+ "Logs not available yet",
675
+ status_code=202,
676
+ details="Build may still be in progress",
677
+ )
678
+
679
+ token_refreshed = await self._refresh_token_if_needed(response)
680
+ if token_refreshed:
681
+ retry_count += 1
682
+ else:
683
+ break
684
+
685
+ raise _handle_error_response(response, f"get {log_type} logs")
686
+ except httpx.RequestError as e:
687
+ raise NetworkError(f"Failed to get {log_type} logs", str(e)) from e
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "plotly-cloud"
7
- version = "0.3.0"
7
+ version = "0.4.0"
8
8
  description = "Python extension for Plotly Cloud with CLI and Dash dev tools integration"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
File without changes
File without changes
File without changes